candor-ts 0.40.1 → 0.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/package.json +6 -3
  3. package/scan.mjs +421 -46
package/README.md CHANGED
@@ -185,6 +185,20 @@ functions analyzed): its DI-style fs injection means many functions read `Unknow
185
185
  that's the contract working, not noise. The report says "can reach", never "does"; an absent
186
186
  literal is never a claim of absence.
187
187
 
188
+ **Framework dependency injection (spec §2's bounded-CHA clause).** rimraf's injection is a function-valued
189
+ parameter, which reads `Unknown`. A DI *container* is measured separately, on NestJS 10 with an executed fixture:
190
+
191
+ - a provider entry `{ provide: Store, useClass | useValue | useFactory | useExisting: … }` whose token is a project
192
+ CLASS is read as a conversion: the substitute joins `Store`'s candidates, so `this.store.save()` is charged the
193
+ union of `Store.save` and the substitute's `save` (SOUNDNESS R1061 — silent before, all three forms executed);
194
+ - a parameter typed by an INTERFACE (`@Inject("SINK") sink: Sink`) dispatches over the project's visible
195
+ implementors of `Sink`, so a `useClass` that `implements Sink` is charged; a `useValue` object literal that
196
+ implements nothing reads `Unknown` (`dispatch:`).
197
+
198
+ **Named miss:** a provider list the scan cannot see (built at runtime, or imported from an unscanned package), a
199
+ token that is a variable rather than the class itself, and a substitute supplied by a dependency are not
200
+ substitutions to this engine. Gate a DI-heavy service with `deny <E> Unknown` as well as `deny <E>`.
201
+
188
202
  ## Cross-engine consistency — machine-checked
189
203
 
190
204
  candor-ts is one of the **four code engines** (with the reference engine candor-java, the Rust
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.40.1",
3
+ "version": "0.40.2",
4
4
  "mcpName": "io.github.tombaldwin/candor",
5
5
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.40)",
6
6
  "type": "module",
7
7
  "dependencies": {
8
- "@types/node": "^25.9.2",
9
- "typescript": "^6.0.3"
8
+ "@types/node": "25.9.2",
9
+ "typescript": "6.0.3"
10
10
  },
11
11
  "bin": {
12
12
  "candor-ts": "./scan.mjs",
@@ -21,6 +21,8 @@
21
21
  },
22
22
  "scripts": {
23
23
  "lint": "eslint *.mjs",
24
+ "prepack": "node -e \"require('fs').copyFileSync('package-lock.json','npm-shrinkwrap.json')\"",
25
+ "postpack": "node -e \"require('fs').rmSync('npm-shrinkwrap.json',{force:true})\"",
24
26
  "test": "npm run lint && node --test test-unit.mjs && node test.mjs --parallel && node test-mcp.mjs && node test-lsp.mjs && node test-watch.mjs && npm run test:probe && npm run test:fuzz",
25
27
  "test:unit": "node --test test-unit.mjs",
26
28
  "test:probe": "node fabrication_probe.mjs",
@@ -45,6 +47,7 @@
45
47
  "node": ">=20"
46
48
  },
47
49
  "files": [
50
+ "npm-shrinkwrap.json",
48
51
  "Cases.ts",
49
52
  "scan.mjs",
50
53
  "query.mjs",
package/scan.mjs CHANGED
@@ -1653,11 +1653,60 @@ if (NO_SOURCES) {
1653
1653
  // types). Resolved via the module system, NOT a fixed relative path — npm HOISTS dependencies, so
1654
1654
  // in an npx/install tree @types/node sits BESIDE candor-ts, not inside it (the second probe's
1655
1655
  // catch). The TARGET's own @types win when present.
1656
+ // ⟨SOUNDNESS R1062⟩ THE ENGINE'S OWN TYPE PACKAGES ARE PART OF THE ENGINE. The compiler (and its `lib.*.d.ts`),
1657
+ // `@types/node` (the fallback root below) and `undici-types` (which `@types/node` re-exports `fetch`/`WebSocket`
1658
+ // from) decide resolutions, so a different version is, in principle, a different engine. A fresh install of the
1659
+ // published v0.40.1 resolved `@types/node` 25.9.9 against a tested 25.9.2 — MEASURED to move nothing on vitest
1660
+ // (the four-row difference first blamed on it was the type-ROOT leak below), and unmeasured everywhere else. The
1661
+ // package now pins them exactly and ships `npm-shrinkwrap.json`; this note covers the installs that ignore a
1662
+ // shrinkwrap (pnpm, yarn) or a hand-edited tree. Advisory: an untested version is not a wrong verdict, but it is
1663
+ // not the one this build's tests measured, and the operator should be told.
1664
+ {
1665
+ try {
1666
+ const lockPath = ["npm-shrinkwrap.json", "package-lock.json"].map((f) => path.join(ENGINE_DIR, f)).find((f) => fs.existsSync(f));
1667
+ if (lockPath) {
1668
+ const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")).packages ?? {};
1669
+ const req = createRequire(path.join(ENGINE_DIR, "scan.mjs"));
1670
+ const loaded = (name, from) => {
1671
+ try { return JSON.parse(fs.readFileSync(createRequire(from).resolve(`${name}/package.json`), "utf8")).version; }
1672
+ catch { return null; }
1673
+ };
1674
+ const nodeTypesPkg = (() => { try { return req.resolve("@types/node/package.json"); } catch { return null; } })();
1675
+ const seen = [["typescript", ts.version], ["@types/node", loaded("@types/node", path.join(ENGINE_DIR, "scan.mjs"))],
1676
+ ["undici-types", nodeTypesPkg ? loaded("undici-types", nodeTypesPkg) : null]];
1677
+ for (const [name, got] of seen) {
1678
+ const want = lock[`node_modules/${name}`]?.version;
1679
+ if (want && got && want !== got)
1680
+ console.error(`candor-ts: note — this install loaded ${name} ${got}; candor-ts ${PKG_VERSION} was tested `
1681
+ + `with ${want}, and type packages change verdicts (SOUNDNESS R1062). Reinstall with npm, which honours the `
1682
+ + `shipped npm-shrinkwrap.json.`);
1683
+ }
1684
+ }
1685
+ } catch { /* advisory only */ }
1686
+ }
1656
1687
  if (!compilerOptions.typeRoots) {
1657
1688
  const roots = [path.join(rootDir, "node_modules", "@types")];
1658
1689
  try {
1659
1690
  const req = createRequire(path.join(ENGINE_DIR, "scan.mjs"));
1660
- roots.push(path.dirname(path.dirname(req.resolve("@types/node/package.json"))));
1691
+ const nodeTypes = path.dirname(req.resolve("@types/node/package.json"));
1692
+ const engineTypes = path.dirname(nodeTypes);
1693
+ // ⟨SOUNDNESS R1062⟩ …AND ONLY @types/node. A type ROOT is not just where `types: ["node"]` is looked up: the
1694
+ // compiler also resolves a TARGET's bare import (`import type { Node } from "estree"`) through it when the
1695
+ // target has no package of that name installed. The engine's directory holds whatever else was installed
1696
+ // beside it — in a development tree, eslint's `@types/estree` — so vitest (scanned without node_modules) had
1697
+ // its `estree` imports resolved against the ENGINE's copy, and four rows that are `Unknown` from a clean
1698
+ // `npm install candor-ts` were absent from the same source run out of the repo (MEASURED; the `@types/node`
1699
+ // version, 25.9.2 vs 25.9.9, moved nothing). A verdict must not depend on the engine's neighbours, so when
1700
+ // that directory holds anything but `node`, the root is a private directory holding `node` alone.
1701
+ let others = [];
1702
+ try { others = fs.readdirSync(engineTypes).filter((n) => n !== "node" && !n.startsWith(".")); } catch { others = []; }
1703
+ if (!others.length) roots.push(engineTypes);
1704
+ else {
1705
+ const shim = fs.mkdtempSync(path.join(os.tmpdir(), "candor-ts-types-"));
1706
+ fs.symlinkSync(nodeTypes, path.join(shim, "node"), "junction");
1707
+ process.on("exit", () => { try { fs.rmSync(shim, { recursive: true, force: true }); } catch { /* best effort */ } });
1708
+ roots.push(shim);
1709
+ }
1661
1710
  } catch {}
1662
1711
  compilerOptions.typeRoots = roots;
1663
1712
  }
@@ -4023,6 +4072,7 @@ const fns = new Map(); // qualified name -> { direct, edges, hosts, ta
4023
4072
  const bodylessDecls = new Map(); // qual -> {node, mod, abstract, owner, member}
4024
4073
  const unlistedSeen = new Map(); // the κ-coverage ledger: unlisted npm package -> call-site count
4025
4074
  const nodeName = new WeakMap(); // declaration node -> qualified name
4075
+ const classExprCtorQual = new Map(); // ⟨R1016⟩ ClassExpression -> its constructor unit (see `mintClassExprCtor`)
4026
4076
  // ⟨scan-boundary, export-alias⟩ `module.exports = { thing: _internalImplName }` / `exports.thing =
4027
4077
  // _internalImplName` — a RENAMED re-export whose RHS NAMES an EXISTING declaration rather than being an
4028
4078
  // inline function. `localName` mints a CJS unit for `module.exports = function(){}` and
@@ -4563,6 +4613,7 @@ const STABLE_KEY_SHAPES = {
4563
4613
  callable: new Set([ts.SyntaxKind.ArrowFunction, ts.SyntaxKind.FunctionExpression]),
4564
4614
  decorator: new Set([ts.SyntaxKind.ArrowFunction, ts.SyntaxKind.FunctionExpression]),
4565
4615
  "decorator-arg": new Set([ts.SyntaxKind.CallExpression]),
4616
+ classexpr: new Set([ts.SyntaxKind.ClassExpression]), // ⟨R1016⟩ a class expression's constructor unit
4566
4617
  computed: null, // a descriptor member of any shape — counted over EVERY node under the anchor
4567
4618
  };
4568
4619
  const stableKeySanitise = (s) => s.replace(/[^A-Za-z0-9_$]/g, "_");
@@ -5134,6 +5185,7 @@ for (const sf of sources) {
5134
5185
  // (a wholly-unresolvable target leaves only the minted unit — nothing to join a forcing site to.)
5135
5186
  }
5136
5187
  }
5188
+ if (ts.isClassExpression(node)) mintClassExprCtor(node); // ⟨R1016⟩
5137
5189
  ts.forEachChild(node, collect);
5138
5190
  })(sf);
5139
5191
  }
@@ -5265,6 +5317,93 @@ for (const { mod, name, ident } of exportAliasCandidates) {
5265
5317
  }
5266
5318
  }
5267
5319
 
5320
+ // ⟨SOUNDNESS R1016⟩ A CLASS EXPRESSION'S CONSTRUCTOR IS A UNIT, as a class declaration's always was. Without one,
5321
+ // `const L = class { constructor() { fs.writeFileSync(…) } }; new L()` and `new Holder.Inner()` over a
5322
+ // `static Inner = class {…}` had nothing to edge to: the constructing function read `['Unknown']` (`deny Fs` exit 0
5323
+ // on a write that ran — EXECUTED), and the body's `Fs` fell through `enclosing` to whatever EVALUATES the class
5324
+ // (`<module>`, `Holder.<static-init>`), which never constructs it (EXECUTED: importing the module writes nothing).
5325
+ // The unit holds what runs at construction — the explicit constructor's body and parameters, and every INSTANCE
5326
+ // field initialiser that is not itself a function value (that one runs when called, and stays where it was) —
5327
+ // so `new` resolves to it through the checker like any constructor, and an implicit constructor is found through
5328
+ // the constructed value's TYPE (`classExprCtorOfNew`). Minted only where there is something to hold.
5329
+ //
5330
+ // WHETHER THE EVALUATING UNIT KEEPS THE CHARGE is ⟨R519⟩'s question, and its answer stands: minting is an
5331
+ // ADDITION unless every construction is provably one this scan edges. A class VALUE that escapes — exported,
5332
+ // returned, passed, stored, `extends`-ed, read as a static — can be constructed where no edge reaches it (another
5333
+ // package, which cannot name an anonymous class's constructor; an `any`-typed `new`), so pass 2a′ keeps the
5334
+ // evaluator's containment edge for it. Only a `const`-bound, unexported class expression whose every reference
5335
+ // is a `new` callee (or a type position, or an `instanceof` operand) drops it — there, every run of the body is
5336
+ // a `new` the scan resolved, and the evaluator's charge was the fabrication R1016 measured.
5337
+ function mintClassExprCtor(cls) {
5338
+ const ctor = (cls.members ?? []).find((m) => ts.isConstructorDeclaration(m) && m.body);
5339
+ const fieldInit = (cls.members ?? []).some((m) => ts.isPropertyDeclaration(m) && m.initializer
5340
+ && !(ts.getCombinedModifierFlags(m) & ts.ModifierFlags.Static)
5341
+ && !ts.isArrowFunction(m.initializer) && !ts.isFunctionExpression(m.initializer));
5342
+ if (!ctor && !fieldInit) return null;
5343
+ const sf = cls.getSourceFile();
5344
+ const local = `<class>@${stableUnitTag("classexpr", cls)}.constructor`;
5345
+ const qual = `${moduleOf(sf)}.${local}`;
5346
+ if (!fns.has(qual)) {
5347
+ const at = ctor ?? cls;
5348
+ const { line, character } = sf.getLineAndCharacterOfPosition(at.getStart());
5349
+ fns.set(qual, { local, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
5350
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(),
5351
+ why: new Set(), entry: false,
5352
+ loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
5353
+ endLine: sf.getLineAndCharacterOfPosition(at.getEnd()).line + 1 });
5354
+ }
5355
+ if (ctor) nodeName.set(ctor, qual);
5356
+ classExprCtorQual.set(cls, qual);
5357
+ if (process.env.CANDOR_R1016_REACH) console.error(`R1016-REACH mint ${qual}`);
5358
+ return qual;
5359
+ }
5360
+ // The class-expression constructor unit a `new X(…)` runs, read off the constructed value's TYPE — the route an
5361
+ // IMPLICIT constructor needs (the signature then has no declaration, or names the BASE's), and harmless beside an
5362
+ // explicit one, which the signature already names.
5363
+ function classExprCtorOfNew(node) {
5364
+ if (!classExprCtorQual.size || !ts.isNewExpression(node) || !node.expression) return null;
5365
+ let vd;
5366
+ try { vd = checker.getTypeAtLocation(node.expression)?.getSymbol?.()?.valueDeclaration; } catch { vd = undefined; }
5367
+ return vd && ts.isClassExpression(vd) ? (classExprCtorQual.get(vd) ?? null) : null;
5368
+ }
5369
+ // Does this class expression's VALUE reach anything but a `new` this scan resolves? (see `mintClassExprCtor`)
5370
+ function classExprEscapes(cls) {
5371
+ let top = cls;
5372
+ while (top.parent && (ts.isParenthesizedExpression(top.parent) || ts.isAsExpression(top.parent)
5373
+ || ts.isNonNullExpression(top.parent) || (ts.isSatisfiesExpression?.(top.parent) ?? false))) top = top.parent;
5374
+ const vd = top.parent;
5375
+ if (!vd || !ts.isVariableDeclaration(vd) || vd.initializer !== top || !ts.isIdentifier(vd.name)
5376
+ || !(ts.getCombinedNodeFlags(vd) & ts.NodeFlags.Const)
5377
+ || (ts.getCombinedModifierFlags(vd) & ts.ModifierFlags.Export)) return true;
5378
+ const syms = new Set();
5379
+ const outer = checker.getSymbolAtLocation(vd.name);
5380
+ if (!outer) return true;
5381
+ syms.add(outer);
5382
+ if (cls.name) { const inner = checker.getSymbolAtLocation(cls.name); if (inner) syms.add(inner); }
5383
+ const names = new Set([vd.name.text, cls.name?.text].filter(Boolean));
5384
+ let escapes = false;
5385
+ (function visit(n) {
5386
+ if (escapes) return;
5387
+ if (ts.isIdentifier(n) && names.has(n.text) && n !== vd.name && n !== cls.name) {
5388
+ let sym; try { sym = checker.getSymbolAtLocation(n); } catch { sym = undefined; }
5389
+ if (sym && (sym.flags & ts.SymbolFlags.Alias)) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
5390
+ const p0 = n.parent;
5391
+ if (p0 && ts.isShorthandPropertyAssignment(p0) && p0.name === n) { escapes = true; return; }
5392
+ if (sym && syms.has(sym)) {
5393
+ let cur = n;
5394
+ while (cur.parent && (ts.isParenthesizedExpression(cur.parent) || ts.isNonNullExpression(cur.parent)
5395
+ || ts.isAsExpression(cur.parent) || (ts.isSatisfiesExpression?.(cur.parent) ?? false))) cur = cur.parent;
5396
+ const p = cur.parent;
5397
+ const safe = !!p && ((ts.isNewExpression(p) && p.expression === cur)
5398
+ || ts.isTypeQueryNode(p)
5399
+ || (ts.isBinaryExpression(p) && p.right === cur && p.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword));
5400
+ if (!safe) { escapes = true; return; }
5401
+ }
5402
+ }
5403
+ ts.forEachChild(n, visit);
5404
+ })(vd.getSourceFile());
5405
+ return escapes;
5406
+ }
5268
5407
  // ⟨SOUNDNESS R956⟩ A class EXPRESSION's member has no unit (`localName` names class DECLARATION members only), so
5269
5408
  // its body folded into whatever evaluates the expression and no dispatch could land on it. An override needs an
5270
5409
  // addressable body: mint it the way a structural implementor's member is minted — keyed by `stableUnitTag`, with
@@ -6119,12 +6258,13 @@ function recordConformer(c, t) {
6119
6258
  }
6120
6259
  if (ts.isClassDeclaration(t)) {
6121
6260
  if (c.kind === "class" && classInSubtree(c.node, t)) return false; // nominal: the subtree already answers
6122
- // R958 — an ASSERTED downcast (`base as Sub`, the target inside the conformer's own subtree) names no new
6123
- // conformer: the receiver's class arm answers `Sub`'s subtree. Scoped to an ASSERTION on purpose. Without one,
6124
- // `f(new Base())` into `f(s: Sub)` passed the checker's own (structural) check and the value really IS a
6125
- // `Base` — refusing that would drop the body that runs. MEASURED: the guard unscoped is load-bearing (a
6126
- // structurally identical `Base` IS assignable to `Sub`), so its scope decides which of the two it decides.
6127
- if (c.kind === "class" && conversionViaAssertion && classInSubtree(t, c.node)) return false;
6261
+ // ⟨SOUNDNESS R958, the DOWNCAST half⟩ An asserted downcast (`base as Sub`) IS a new conformer when the value is a
6262
+ // `Base`, and nothing here can tell that apart from a truthful one. A refusal stood here, scoped to assertions,
6263
+ // on the claim that "the receiver's class arm answers `Sub`'s subtree" — but the value's class is `Base`, which
6264
+ // is NOT in `Sub`'s subtree, so `Base.m` was the body that ran and the one no arm named. EXECUTED: `dO(bO as
6265
+ // SubO)` wrote a file through `BaseO.m` with `dO`/`rO` ABSENT and `pure a.dO` exit 0, while `dP(bP)` — the same
6266
+ // value without the `as` — was `Fs`, exit 1. An assertion must not delete a charge the unasserted conversion
6267
+ // makes. On a TRUTHFUL downcast the registration over-charges a `Sub`-typed receiver with `Base`'s override.
6128
6268
  let added = false;
6129
6269
  for (const anc of localAncestorsAndSelf(t)) added = pushUnique(classConformers, anc, c.node) || added;
6130
6270
  if (c.kind === "lit") mintStructuralMembers(c.node, true);
@@ -6197,7 +6337,6 @@ function isConversionPosition(n) {
6197
6337
  return false;
6198
6338
  }
6199
6339
  // R958 — the value under an assertion chain: parentheses, `as`, `<T>`, `!` and `satisfies` change no runtime value.
6200
- let conversionViaAssertion = false; // set while `convert` records a source read THROUGH an assertion
6201
6340
  const peelAssertions = (e) => unwrapArgExpr(e); // R780: one wrapper set, not a third copy of it
6202
6341
  // R82 / PART 87: is the conversion's SOURCE type a fact about the VALUE, or only about a generic signature? A
6203
6342
  // call whose declared return type mentions one of its own type parameters (`wrap<T>(x: T): T`) hands back
@@ -6395,21 +6534,19 @@ for (const sf of sources) {
6395
6534
  // called as `launder(new LocalW())` ran LocalW's `m` (EXECUTED) with `deny Fs` exit 0 on the caller. The
6396
6535
  // parameter's visible arguments say what it holds — the SAME provenance index R246's accessor arm asks
6397
6536
  // (`receiverValueTypes`) — so each argument type is recorded as a conversion source. Upcasts only, as
6398
- // ever (`recordConversion` keeps a target the type is assignable to). A dependency-sourced value and a
6399
- // lying downcast are untouched: they are R958's by-design residue.
6400
- if (anyishType(st)) for (const vt of receiverValueTypes(src)) {
6537
+ // ever (`recordConversion` keeps a target the type is assignable to). ⟨R958 widened-local half⟩ and the
6538
+ // same for a source typed `object`/`{}`, and a `const` declared wider than its initializer
6539
+ // (`uninformativeType`). A dependency-sourced value is R958's disclosed residue (`invisible`).
6540
+ if (uninformativeType(st)) for (const vt of receiverValueTypes(src)) {
6401
6541
  try { recordConversion(vt, tt); } catch { /* best effort, as the main record below */ }
6402
6542
  CONFORMER_REACH?.("any-param", `${path.relative(rootDir, sf.fileName)}:${at.getStart()}`);
6403
6543
  }
6404
- conversionViaAssertion = kind !== "conversion";
6405
- try {
6406
- if (!CONFORMER_REACH) { recordConversion(st, tt); return; }
6407
- const count = () => [...interfaceImpls.values(), ...foreignInterfaceImpls.values(), ...classConformers.values(),
6408
- ...depConformers.values()].reduce((a, x) => a + x.length, 0);
6409
- const before = count();
6410
- recordConversion(st, tt);
6411
- if (count() > before) CONFORMER_REACH(kind, `${path.relative(rootDir, sf.fileName)}:${at.getStart()}`);
6412
- } finally { conversionViaAssertion = false; }
6544
+ if (!CONFORMER_REACH) { recordConversion(st, tt); return; }
6545
+ const count = () => [...interfaceImpls.values(), ...foreignInterfaceImpls.values(), ...classConformers.values(),
6546
+ ...depConformers.values()].reduce((a, x) => a + x.length, 0);
6547
+ const before = count();
6548
+ recordConversion(st, tt);
6549
+ if (count() > before) CONFORMER_REACH(kind, `${path.relative(rootDir, sf.fileName)}:${at.getStart()}`);
6413
6550
  };
6414
6551
  (function walkConversions(node) {
6415
6552
  if (ts.isExpression(node) && !ts.isSpreadElement(node) && isConversionPosition(node)) {
@@ -6429,9 +6566,53 @@ for (const sf of sources) {
6429
6566
  let tt; try { tt = checker.getTypeFromTypeNode(node.type); } catch { tt = undefined; }
6430
6567
  convert(peelAssertions(node.expression), tt, node, "assertion");
6431
6568
  }
6569
+ if (ts.isObjectLiteralExpression(node)) providerSubstitution(node); // ⟨R1061⟩
6432
6570
  ts.forEachChild(node, walkConversions);
6433
6571
  })(sf);
6434
6572
  }
6573
+ // ⟨SOUNDNESS R1061⟩ A DEPENDENCY-INJECTION PROVIDER IS A CONVERSION THE CHECKER NEVER SEES. NestJS (and Angular,
6574
+ // which shares the shape) wires `{ provide: Store, useClass: FileStore }` — or `useValue: <obj>`, `useFactory: () =>
6575
+ // <obj>`, `useExisting: Other` — and the container then hands the substitute to every `store: Store` parameter. No
6576
+ // expression converts the substitute to `Store`, so a `this.store.save()` resolved to `Store.save` alone: EXECUTED on
6577
+ // NestJS 10, `FileStore.save` wrote a file while the calling method was ABSENT and `deny Fs`/`deny Fs Unknown` exited 0
6578
+ // for all three provider forms. The provider entry IS the conversion, so the substitute is registered as a conformer
6579
+ // of the token class — the union of the declared class and the visible substitute, through the R874/R954 conformer
6580
+ // arm every class dispatch already asks. Registered directly, without the checker's assignability test, because the
6581
+ // container does not apply one (a token class with a private member is not structurally assignable from its
6582
+ // substitute, and is still what gets injected). Only a token that names a LOCAL class registers; a string or symbol
6583
+ // token types its parameter by an interface or `any`, which the interface arm already answers or discloses.
6584
+ function providerSubstitution(lit) {
6585
+ const prop = (name) => lit.properties.find((p) => ts.isPropertyAssignment(p) && p.name && !ts.isComputedPropertyName(p.name)
6586
+ && (p.name.text ?? p.name.getText()) === name);
6587
+ const provide = prop("provide");
6588
+ if (!provide) return;
6589
+ let tsym; try { tsym = checker.getSymbolAtLocation(peelAssertions(provide.initializer)); } catch { tsym = undefined; }
6590
+ if (tsym && (tsym.flags & ts.SymbolFlags.Alias)) { try { tsym = checker.getAliasedSymbol(tsym); } catch { return; } }
6591
+ const target = (tsym?.declarations ?? []).find((d) => ts.isClassDeclaration(d) && isProjectNode(d));
6592
+ if (!target) return;
6593
+ const instanceOf = (e) => {
6594
+ let s; try { s = checker.getSymbolAtLocation(peelAssertions(e)); } catch { s = undefined; }
6595
+ if (s && (s.flags & ts.SymbolFlags.Alias)) { try { s = checker.getAliasedSymbol(s); } catch { s = undefined; } }
6596
+ if (!s || !(s.flags & ts.SymbolFlags.Class)) return null;
6597
+ try { return checker.getDeclaredTypeOfSymbol(s); } catch { return null; }
6598
+ };
6599
+ const sources = [];
6600
+ for (const key of ["useClass", "useExisting"]) { const p = prop(key); const t = p && instanceOf(p.initializer); if (t) sources.push(t); }
6601
+ const uv = prop("useValue");
6602
+ if (uv) { try { sources.push(checker.getTypeAtLocation(peelAssertions(uv.initializer))); } catch { /* none */ } }
6603
+ const uf = prop("useFactory");
6604
+ if (uf) {
6605
+ let ft; try { ft = checker.getTypeAtLocation(uf.initializer); } catch { ft = undefined; }
6606
+ for (const sig of ft?.getCallSignatures?.() ?? []) {
6607
+ let rt; try { rt = sig.getReturnType(); rt = checker.getAwaitedType?.(rt) ?? rt; } catch { rt = undefined; }
6608
+ if (rt) sources.push(rt);
6609
+ }
6610
+ }
6611
+ for (const st of sources) for (const s of st?.isUnion?.() ? st.types : [st]) {
6612
+ const c = conformerOfSourceType(s);
6613
+ if (c && recordConformer(c, target)) CONFORMER_REACH?.("di-provider", `${path.relative(rootDir, lit.getSourceFile().fileName)}:${lit.getStart()}`);
6614
+ }
6615
+ }
6435
6616
  // The interface-flow fixpoint: a `J`-typed value converted to `X` carries every conformer of `J` with it.
6436
6617
  for (let changed = true, g = 0; changed && g++ < 32; ) {
6437
6618
  changed = false;
@@ -6590,6 +6771,19 @@ function keyLiteralNames(t, depth = 0, seen = new Set()) {
6590
6771
  // the accessor edges the value's type declares — and never hedges; a receiver neither rule answers is
6591
6772
  // exactly as it was (the residual is stated in SOUNDNESS R246, not hidden here).
6592
6773
  function anyishType(t) { return !!t && !!(t.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)); } // hoisted: the conversion walk asks it first
6774
+ // ⟨SOUNDNESS R958, the WIDENED-LOCAL half⟩ A type that names no member is as silent about the value as `any`: a local
6775
+ // declared `object` or `{}` holding `new LocalW()` and asserted into an interface (`qDisp(o as Sink)`) ran
6776
+ // `LocalW.m` (EXECUTED) with every unit `[]` and no disclosure — `anyishType` alone let it through. Asked by the
6777
+ // conversion walk and the R246 receiver question alike; it only ever lets MORE value types be recorded.
6778
+ function uninformativeType(t) {
6779
+ if (!t) return false;
6780
+ if (anyishType(t) || (t.flags & ts.TypeFlags.NonPrimitive)) return true;
6781
+ if (!(t.flags & ts.TypeFlags.Object) || t.isUnion?.() || t.isIntersection?.()) return false;
6782
+ try {
6783
+ return checker.getPropertiesOfType(t).length === 0 && t.getCallSignatures().length === 0
6784
+ && t.getConstructSignatures().length === 0 && !checker.getIndexInfosOfType?.(t)?.length;
6785
+ } catch { return false; }
6786
+ }
6593
6787
  function receiverValueTypes(expr, depth = 0, seen = new Set()) {
6594
6788
  if (!expr || depth > 6) return [];
6595
6789
  let t; try { t = checker.getTypeAtLocation(expr); } catch { t = null; }
@@ -6599,10 +6793,18 @@ function receiverValueTypes(expr, depth = 0, seen = new Set()) {
6599
6793
  let ti; try { ti = checker.getTypeAtLocation(inner); } catch { ti = null; }
6600
6794
  return [...(ti && !anyishType(ti) ? [ti] : []), ...below];
6601
6795
  }
6602
- if (!anyishType(t) || !ts.isIdentifier(expr)) return [];
6796
+ if (!uninformativeType(t) || !ts.isIdentifier(expr)) return [];
6603
6797
  const sym = checker.getSymbolAtLocation(expr);
6604
6798
  const d = sym?.valueDeclaration;
6605
- if (!sym || !d || !ts.isParameter(d) || seen.has(sym)) return [];
6799
+ if (!sym || !d || seen.has(sym)) return [];
6800
+ // ⟨R958, widened-local half⟩ a `const` declared wider than its initializer holds the initializer's value.
6801
+ if (ts.isVariableDeclaration(d) && ts.isIdentifier(d.name) && d.initializer
6802
+ && (ts.getCombinedNodeFlags(d) & ts.NodeFlags.Const)) {
6803
+ seen.add(sym);
6804
+ let ti; try { ti = checker.getTypeAtLocation(d.initializer); } catch { ti = null; }
6805
+ return [...(ti && !anyishType(ti) ? [ti] : []), ...receiverValueTypes(d.initializer, depth + 1, seen)];
6806
+ }
6807
+ if (!ts.isParameter(d)) return [];
6606
6808
  seen.add(sym);
6607
6809
  const args = paramArgs.get(sym);
6608
6810
  if (!args || paramFnEscaped(paramOwnerOf.get(sym))) return [];
@@ -6610,7 +6812,7 @@ function receiverValueTypes(expr, depth = 0, seen = new Set()) {
6610
6812
  for (const a of args) {
6611
6813
  if (!a) continue;
6612
6814
  let ta; try { ta = checker.getTypeAtLocation(a); } catch { ta = null; }
6613
- if (ta && !anyishType(ta)) out.push(ta);
6815
+ if (ta && !anyishType(ta)) out.push(ta); // a VALUE's own type, even `{}`, is a fact about it
6614
6816
  out.push(...receiverValueTypes(a, depth + 1, seen));
6615
6817
  }
6616
6818
  return out;
@@ -7327,6 +7529,12 @@ function enclosing(node) {
7327
7529
  && (ts.getCombinedModifierFlags(p) & ts.ModifierFlags.Static)
7328
7530
  && p.parent && (ts.isClassDeclaration(p.parent) || ts.isClassExpression(p.parent)))
7329
7531
  { R815_REACH?.("static"); return staticBlockUnit(p); }
7532
+ // ⟨R1016⟩ an INSTANCE field initialiser of a class expression runs at construction — its constructor unit.
7533
+ if (prev && ts.isPropertyDeclaration(p) && p.initializer === prev
7534
+ && !(ts.getCombinedModifierFlags(p) & ts.ModifierFlags.Static)
7535
+ && !ts.isArrowFunction(prev) && !ts.isFunctionExpression(prev)
7536
+ && p.parent && ts.isClassExpression(p.parent) && classExprCtorQual.has(p.parent))
7537
+ return classExprCtorQual.get(p.parent);
7330
7538
  if (ts.isHeritageClause(p) && p.token === ts.SyntaxKind.ExtendsKeyword && p.parent
7331
7539
  && (ts.isClassDeclaration(p.parent) || ts.isClassExpression(p.parent))) {
7332
7540
  const ctor = nodeName.get(p.parent);
@@ -9040,6 +9248,74 @@ const envPropMustSymbols = new Set();
9040
9248
  const envPropMaySymbols = new Set();
9041
9249
  // ⟨R935⟩ a property location's identity: its first declaration node (see `memberSymOf` for why not the symbol).
9042
9250
  const envLocKey = (sym) => sym?.declarations?.[0] ?? null;
9251
+ // ⟨SOUNDNESS R935, the RECEIVER half⟩ THE LOCATION A MEMBER READ NAMES IS THE OBJECT THE RECEIVER HOLDS, not the
9252
+ // member its DECLARED TYPE declares. The first cut keyed a read on the checker's property symbol, which is the
9253
+ // literal's own property only when the binding's type was INFERRED from the literal. Give the binding a type —
9254
+ // `const icfg: Cfg = { env: process.env }` — and `icfg.env` resolves to `Cfg`'s `PropertySignature`, an open set
9255
+ // that is no location at all, so `viaIface() { return icfg.env.SECRET }` was ABSENT with `deny Env viaIface` exit
9256
+ // 0 over a read that printed the planted secret (EXECUTED). An ARRAY literal had no location to key at all:
9257
+ // `const arr = [process.env]; arr[0].SECRET`, same measurement. Where the receiver is an identifier bound by a
9258
+ // `const` to an object or array literal, the object IS that literal, so the read is keyed on the literal's own
9259
+ // member — the last property of that name (a later spread makes it unresolvable), or the element at a literal
9260
+ // index with no spread or hole before it. Everything that can REBIND such a member off the books is a binding
9261
+ // the fixpoint must see, or the location is charged over a replaced value:
9262
+ // · an OBJECT literal's member is written through its declared member too (`icfg.env = {}` names `Cfg.env`,
9263
+ // as does any other `Cfg`-typed object), so a read resolved this way is MAY wherever that declared member
9264
+ // is ever written or reflectively written (`envDeclWritten`, filled by the binding walk below);
9265
+ // · an ARRAY's elements move under any method call or escape, so an element is MUST only while every
9266
+ // reference to the array is a literal-index READ; any other reference makes every element MAY.
9267
+ // MAY discloses `Unknown[env-maybe-read]`, as every other location does. A `let` binding, a receiver that is a
9268
+ // parameter or a property, and a read through iteration (`for…of`, `.map`) are not resolved here.
9269
+ const envDeclWritten = new Set();
9270
+ const envLitElems = new Map(); // array-literal element node -> true while every reference is an index read
9271
+ const envConstLiteral = (id) => {
9272
+ if (!id || !ts.isIdentifier(id)) return null;
9273
+ let sym = checker.getSymbolAtLocation(id);
9274
+ if (sym && (sym.flags & ts.SymbolFlags.Alias)) { try { sym = checker.getAliasedSymbol(sym); } catch { return null; } }
9275
+ const d = sym?.valueDeclaration;
9276
+ if (!d || !ts.isVariableDeclaration(d) || !ts.isIdentifier(d.name) || !d.initializer
9277
+ || !(ts.getCombinedNodeFlags(d) & ts.NodeFlags.Const)
9278
+ || !projectFiles.has(path.resolve(d.getSourceFile().fileName))) return null;
9279
+ const lit = unwrapArgExpr(d.initializer);
9280
+ return lit && (ts.isObjectLiteralExpression(lit) || ts.isArrayLiteralExpression(lit)) ? lit : null;
9281
+ };
9282
+ const envMemberName = (p) => (p.name && (ts.isIdentifier(p.name) || ts.isStringLiteralLike(p.name)
9283
+ || ts.isNumericLiteral(p.name) || ts.isPrivateIdentifier(p.name))) ? p.name.text : null;
9284
+ // -> { key, declKey } for a member read whose receiver is a const literal, else null.
9285
+ const envLiteralMember = (n) => {
9286
+ let recv, name = null, idx = null;
9287
+ if (ts.isPropertyAccessExpression(n)) { recv = n.expression; name = n.name.text; }
9288
+ else if (ts.isElementAccessExpression(n) && n.argumentExpression) {
9289
+ recv = n.expression;
9290
+ if (ts.isStringLiteralLike(n.argumentExpression)) name = n.argumentExpression.text;
9291
+ else if (ts.isNumericLiteral(n.argumentExpression)) idx = Number(n.argumentExpression.text);
9292
+ else return null;
9293
+ } else return null;
9294
+ const lit = envConstLiteral(unwrapArgExpr(recv));
9295
+ if (!lit) return null;
9296
+ if (ts.isArrayLiteralExpression(lit)) {
9297
+ if (idx === null || !Number.isInteger(idx) || idx >= lit.elements.length) return null;
9298
+ for (let i = 0; i <= idx; i++) {
9299
+ const el = lit.elements[i];
9300
+ if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) return null;
9301
+ }
9302
+ return { key: lit.elements[idx], declKey: null };
9303
+ }
9304
+ const want = name ?? (idx !== null ? String(idx) : null);
9305
+ if (want === null) return null;
9306
+ let hit = null;
9307
+ for (const p of lit.properties) {
9308
+ if (ts.isSpreadAssignment(p)) hit = p;
9309
+ else if (envMemberName(p) === want) hit = p;
9310
+ }
9311
+ if (!hit || !(ts.isPropertyAssignment(hit) || ts.isShorthandPropertyAssignment(hit))) return null;
9312
+ const key = envLocKey(checker.getSymbolAtLocation(hit.name));
9313
+ if (!key) return null;
9314
+ let raw = null;
9315
+ try { raw = ts.isPropertyAccessExpression(n) ? checker.getSymbolAtLocation(n.name)
9316
+ : (n.argumentExpression ? checker.getSymbolAtLocation(n.argumentExpression) : null); } catch { raw = null; }
9317
+ return { key, declKey: envLocKey(raw) };
9318
+ };
9043
9319
  {
9044
9320
  const ENV_DESTRUCTURED = Symbol("env-destructured");
9045
9321
  const bindings = new Map(); // symbol -> [value expr | null | ENV_DESTRUCTURED | { same: symbol }]
@@ -9056,7 +9332,7 @@ const envLocKey = (sym) => sym?.declarations?.[0] ?? null;
9056
9332
  const memberRawSym = (n) => (ts.isPropertyAccessExpression(n) ? checker.getSymbolAtLocation(n.name)
9057
9333
  : (ts.isElementAccessExpression(n) && n.argumentExpression && ts.isStringLiteralLike(n.argumentExpression))
9058
9334
  ? checker.getSymbolAtLocation(n.argumentExpression) : null) ?? null;
9059
- const memberSymOf = (n) => envLocKey(memberRawSym(n));
9335
+ const memberSymOf = (n) => envLiteralMember(n)?.key ?? envLocKey(memberRawSym(n));
9060
9336
  // Is this property symbol a location whose bindings we can enumerate? Every declaration must be
9061
9337
  // value-bearing, in a project file, and not a member of an extended class.
9062
9338
  const propEligible = (sym) => {
@@ -9073,7 +9349,12 @@ const envLocKey = (sym) => sym?.declarations?.[0] ?? null;
9073
9349
  if (!ok) propIneligible.add(sym);
9074
9350
  return ok;
9075
9351
  };
9076
- const noteProp = (sym, v) => { if (propEligible(sym)) { const k = envLocKey(sym); propSyms.add(k); note(k, v); } };
9352
+ const noteProp = (sym, v) => {
9353
+ if (propEligible(sym)) { const k = envLocKey(sym); propSyms.add(k); note(k, v); }
9354
+ else if (sym && v !== undefined) envDeclWritten.add(envLocKey(sym)); // ⟨R935⟩ see `envLiteralMember`
9355
+ };
9356
+ // ⟨R935⟩ a WRITE through a member that is not itself a location still rebinds whatever object it lands on.
9357
+ const noteWrite = (sym, v) => { if (sym) { envDeclWritten.add(envLocKey(sym)); noteProp(sym, v); } };
9077
9358
  const collect = (node) => {
9078
9359
  if (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
9079
9360
  note(checker.getSymbolAtLocation(node.name), node.initializer ?? null);
@@ -9091,7 +9372,10 @@ const envLocKey = (sym) => sym?.declarations?.[0] ?? null;
9091
9372
  && (ts.isPropertyAccessExpression(node.left) || ts.isElementAccessExpression(node.left))) {
9092
9373
  // ⟨R935⟩ an assignment to a property location; a compound operator (`+=`) binds a non-environment.
9093
9374
  const s = memberRawSym(node.left);
9094
- if (s) noteProp(s, LOGICAL_ASSIGN.has(node.operatorToken.kind) ? node.right : null);
9375
+ if (s) noteWrite(s, LOGICAL_ASSIGN.has(node.operatorToken.kind) ? node.right : null);
9376
+ // ⟨R935⟩ …and the const literal's own member, when the receiver resolves to one.
9377
+ const lm = envLiteralMember(node.left);
9378
+ if (lm && !ts.isArrayLiteralExpression(lm.key.parent)) { propSyms.add(lm.key); note(lm.key, LOGICAL_ASSIGN.has(node.operatorToken.kind) ? node.right : null); }
9095
9379
  } else if (ts.isPropertyAssignment(node) && ts.isObjectLiteralExpression(node.parent)) {
9096
9380
  const s = checker.getSymbolAtLocation(node.name);
9097
9381
  if (s && (s.flags & ts.SymbolFlags.Property)) noteProp(s, node.initializer);
@@ -9126,27 +9410,80 @@ const envLocKey = (sym) => sym?.declarations?.[0] ?? null;
9126
9410
  if (reflectTarget) {
9127
9411
  let props = [];
9128
9412
  try { props = checker.getTypeAtLocation(reflectTarget)?.getProperties?.() ?? []; } catch { props = []; }
9129
- for (const ps of props) if (propEligible(ps)) noteProp(ps, null);
9413
+ for (const ps of props) { envDeclWritten.add(envLocKey(ps)); if (propEligible(ps)) noteProp(ps, null); }
9414
+ // ⟨R935⟩ a const object literal written reflectively: every member of the literal itself is rebound.
9415
+ const tl = envConstLiteral(unwrapArgExpr(reflectTarget));
9416
+ if (tl && ts.isObjectLiteralExpression(tl)) for (const p of tl.properties) {
9417
+ const k = (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) ? envLocKey(checker.getSymbolAtLocation(p.name)) : null;
9418
+ if (k) { propSyms.add(k); note(k, null); }
9419
+ }
9420
+ }
9421
+ // ⟨R935⟩ an ARRAY literal bound by a `const`: each element is a location holding its own value.
9422
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer
9423
+ && (ts.getCombinedNodeFlags(node) & ts.NodeFlags.Const)) {
9424
+ const al = unwrapArgExpr(node.initializer);
9425
+ if (al && ts.isArrayLiteralExpression(al)) {
9426
+ for (const el of al.elements) if (!ts.isSpreadElement(el) && !ts.isOmittedExpression(el)) {
9427
+ propSyms.add(el); note(el, el); envLitElems.set(el, al);
9428
+ }
9429
+ }
9130
9430
  }
9131
9431
  ts.forEachChild(node, collect);
9132
9432
  };
9133
9433
  for (const sf of sources) collect(sf);
9134
- // ⟨R934⟩ the parameter bindings come from the shared PARAMETER PROVENANCE index above.
9135
- for (const [ps, vals] of paramArgs) { paramSyms.add(ps); for (const v of vals) note(ps, v); }
9434
+ // ⟨R935⟩ an array literal's elements are MUST only while every reference to the array is a literal-index READ.
9435
+ if (envLitElems.size) {
9436
+ const arrs = new Set(envLitElems.values());
9437
+ const names = new Set([...arrs].map((a) => a.parent?.name?.text).filter(Boolean));
9438
+ const opened = new Set();
9439
+ const visitRefs = (node) => {
9440
+ if (ts.isIdentifier(node) && names.has(node.text)) {
9441
+ const lit = envConstLiteral(node);
9442
+ if (lit && arrs.has(lit) && !opened.has(lit) && !(lit.parent?.name === node)) {
9443
+ let cur = node;
9444
+ while (cur.parent && (ts.isParenthesizedExpression(cur.parent) || ts.isNonNullExpression(cur.parent)
9445
+ || ts.isAsExpression(cur.parent) || ts.isSatisfiesExpression?.(cur.parent))) cur = cur.parent;
9446
+ const p = cur.parent;
9447
+ const indexRead = p && ts.isElementAccessExpression(p) && p.expression === cur && p.argumentExpression
9448
+ && ts.isNumericLiteral(p.argumentExpression)
9449
+ && !(p.parent && ts.isBinaryExpression(p.parent) && p.parent.left === p && p.parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment)
9450
+ && !(p.parent && (ts.isDeleteExpression(p.parent) || ts.isPrefixUnaryExpression(p.parent) || ts.isPostfixUnaryExpression(p.parent)))
9451
+ && !isDestructuringAssignTarget(p);
9452
+ const typeOnly = p && (ts.isTypeQueryNode(p) || ts.isExportSpecifier(p));
9453
+ if (!indexRead && !typeOnly) opened.add(lit);
9454
+ }
9455
+ }
9456
+ ts.forEachChild(node, visitRefs);
9457
+ };
9458
+ for (const sf of sources) visitRefs(sf);
9459
+ for (const [el, al] of envLitElems) if (opened.has(al)) note(el, null);
9460
+ }
9461
+ // ⟨R934⟩ the parameter bindings come from the shared PARAMETER PROVENANCE index above. What `collect` noted for
9462
+ // a parameter BEFORE this line is its in-body REASSIGNMENTS (`e = {}`); kept apart, because the two kinds of
9463
+ // binding answer different questions below.
9464
+ const paramAssigns = new Map();
9465
+ for (const [ps, vals] of paramArgs) {
9466
+ paramSyms.add(ps); paramAssigns.set(ps, [...(bindings.get(ps) ?? [])]);
9467
+ for (const v of vals) note(ps, v);
9468
+ }
9136
9469
  const leafIn = (leaf, set, propSet) => isProcessEnvExpr(leaf)
9137
9470
  || (ts.isIdentifier(leaf) && set.has(checker.getSymbolAtLocation(leaf)))
9138
- || (!!propSet && propSet.has(memberSymOf(leaf)));
9471
+ || (!!propSet && propSet.has(memberSymOf(leaf)) && !(propSet === envPropMustSymbols && envLitDowngraded(leaf)));
9472
+ // ⟨R935⟩ a literal member read through a receiver whose DECLARED member is written elsewhere is MAY at best.
9473
+ function envLitDowngraded(n) {
9474
+ const lm = (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) ? envLiteralMember(n) : null;
9475
+ return !!lm && !!lm.declKey && lm.declKey !== lm.key && envDeclWritten.has(lm.declKey);
9476
+ }
9139
9477
  const hasEnvLeaf = (v, set, propSet) => v === ENV_DESTRUCTURED
9140
9478
  || (!!v && typeof v === "object" && "same" in v ? set.has(v.same) || propSet?.has(v.same) === true
9141
9479
  : (!!v && typeof v === "object" && envValueLeaves(v).some((l) => leafIn(l, set, propSet))));
9142
9480
 
9143
9481
 
9144
- // MAY: least fixpoint of "some binding has a leaf that is process.env or already MAY" — LOCALS and
9145
- // PROPERTIES (a property's MAY reads a local's MAY and vice versa). Parameters never join MAY (above).
9482
+ // MAY: least fixpoint of "some binding has a leaf that is process.env or already MAY" — LOCALS, PROPERTIES
9483
+ // and (⟨R934⟩, see MUST below) PARAMETERS: a property's MAY reads a local's MAY and vice versa.
9146
9484
  for (let changed = true, n = 0; changed && n < 64; n++) {
9147
9485
  changed = false;
9148
9486
  for (const [sym, vals] of bindings) {
9149
- if (paramSyms.has(sym)) continue;
9150
9487
  const isProp = propSyms.has(sym);
9151
9488
  const set = isProp ? envPropMaySymbols : envMayAliasSymbols;
9152
9489
  if (!set.has(sym) && vals.some((v) => hasEnvLeaf(v, envMayAliasSymbols, envPropMaySymbols))) { set.add(sym); changed = true; }
@@ -9154,24 +9491,37 @@ const envLocKey = (sym) => sym?.declarations?.[0] ?? null;
9154
9491
  }
9155
9492
  // MUST: least fixpoint (from empty) of "every binding has a leaf that is process.env or already MUST" —
9156
9493
  // least, so a cycle that never touches process.env directly stays MAY (the disclosing side). Locals and
9157
- // properties start from their MAY sets; parameters are tried directly (MUST-only). A parameter whose
9158
- // function escapes is dropped before it can join.
9159
- const paramCands = [...paramSyms].filter((s) => bindings.get(s)?.some((v) => v !== null));
9160
- let batched = false;
9494
+ // properties start from their MAY sets.
9495
+ //
9496
+ // ⟨SOUNDNESS R934, the DIVERGENT half⟩ A PARAMETER IS THE ENVIRONMENT IN ITS READER'S FRAME WHEN *SOME* VISIBLE
9497
+ // CALL SITE HANDS IT THE ENVIRONMENT — not every one. The first cut of R934 required every site, citing pass
9498
+ // 2b's divergent-HOF ruling, and left `readDiv(e) { return e.SECRET }` called as `readDiv(process.env)` and
9499
+ // `readDiv({…})` ABSENT with `deny Env readDiv` exit 0 over a read that printed the planted secret (EXECUTED).
9500
+ // 2b's ruling does not transfer: it is about a CALLBACK, whose effect is another unit's body, so the HOF
9501
+ // performs nothing itself and pooling the callbacks onto it would invent an effect no execution of the HOF's
9502
+ // own code performs. Here the read is in the callee's OWN body, and an execution in which it reads the
9503
+ // environment exists — that is the all-paths merge `envValueLeaves` already charges for `(o ?? process.env).X`.
9504
+ // Pass 2c, in this same file, already answers the WRITE side this way (`envFed` is set by ANY site, and the
9505
+ // writer is charged `Env`), so the read side requiring every site was the same question answered twice.
9506
+ // The price is the one every summary pays: a caller handing the callee `{}` inherits `Env` through the
9507
+ // ordinary edge, exactly as a caller of 2c's `populate` and of an `??`-merging reader already does.
9508
+ // Because SOME suffices, whether the function escapes no longer matters — hidden sites can only add bindings.
9509
+ // An in-body REASSIGNMENT (`e = {}`) is the flow-insensitive rebind the local-alias rule exists for: the
9510
+ // parameter is then MUST only if every reassignment is also the environment, and otherwise MAY, which
9511
+ // discloses `Unknown[env-maybe-read]` on the reader rather than charging over a value that was replaced.
9161
9512
  const mustTry = () => {
9162
9513
  let changed = false;
9163
9514
  for (const sym of envMayAliasSymbols) {
9515
+ if (paramSyms.has(sym)) continue;
9164
9516
  if (!envAliasSymbols.has(sym) && bindings.get(sym).every((v) => hasEnvLeaf(v, envAliasSymbols, envPropMustSymbols))) { envAliasSymbols.add(sym); changed = true; }
9165
9517
  }
9166
9518
  for (const sym of envPropMaySymbols) {
9167
9519
  if (!envPropMustSymbols.has(sym) && bindings.get(sym).every((v) => hasEnvLeaf(v, envAliasSymbols, envPropMustSymbols))) { envPropMustSymbols.add(sym); changed = true; }
9168
9520
  }
9169
- for (const sym of paramCands) {
9521
+ for (const sym of paramSyms) {
9170
9522
  if (envAliasSymbols.has(sym)) continue;
9171
- if (!bindings.get(sym).every((v) => hasEnvLeaf(v, envAliasSymbols, envPropMustSymbols))) continue;
9172
- const fsym = paramOwnerOf.get(sym);
9173
- if (!batched) { batched = true; paramFnEscaped.batch(paramCands.map((p) => paramOwnerOf.get(p)).filter(Boolean)); }
9174
- if (paramFnEscaped(fsym)) continue;
9523
+ const must = (v) => hasEnvLeaf(v, envAliasSymbols, envPropMustSymbols);
9524
+ if (!(paramArgs.get(sym) ?? []).some(must) || !(paramAssigns.get(sym) ?? []).every(must)) continue;
9175
9525
  envAliasSymbols.add(sym); changed = true;
9176
9526
  }
9177
9527
  return changed;
@@ -9195,6 +9545,8 @@ const identIsEnvMayAlias = (id) => {
9195
9545
  // ⟨R935⟩ a member reference `o.p` / `o["p"]` whose property is an environment location.
9196
9546
  const envMemberSym = (n) => {
9197
9547
  if (!(envPropMaySymbols.size > 0)) return null;
9548
+ const lm = envLiteralMember(n); // ⟨R935⟩ the object the receiver holds, before the member its type declares
9549
+ if (lm && envPropMaySymbols.has(lm.key)) return lm.key;
9198
9550
  if (ts.isPropertyAccessExpression(n)) return envLocKey(checker.getSymbolAtLocation(n.name));
9199
9551
  if (ts.isElementAccessExpression(n) && n.argumentExpression && ts.isStringLiteralLike(n.argumentExpression))
9200
9552
  return envLocKey(checker.getSymbolAtLocation(n.argumentExpression));
@@ -9203,7 +9555,11 @@ const envMemberSym = (n) => {
9203
9555
  const envPropKind = (n) => {
9204
9556
  const s = envMemberSym(n);
9205
9557
  if (!s || !envPropMaySymbols.has(s)) return null;
9206
- return envPropMustSymbols.has(s) ? "env" : "may";
9558
+ if (!envPropMustSymbols.has(s)) return "may";
9559
+ // ⟨R935⟩ resolved through the receiver: MAY wherever the declared member is written off the literal.
9560
+ const lm = envLiteralMember(n);
9561
+ if (lm && lm.key === s && lm.declKey && lm.declKey !== s && envDeclWritten.has(lm.declKey)) return "may";
9562
+ return "env";
9207
9563
  };
9208
9564
  const envValueKind = (expr) => {
9209
9565
  let kind = null;
@@ -9641,6 +9997,12 @@ function visitCalls(node) {
9641
9997
  }
9642
9998
  const sig = checker.getResolvedSignature(node);
9643
9999
  let decl = sig && sig.declaration;
10000
+ // ⟨R1016⟩ a construction of a class expression runs its constructor unit, whatever the signature names.
10001
+ const classExprCtor = rec ? classExprCtorOfNew(node) : null;
10002
+ if (classExprCtor && classExprCtor !== owner) {
10003
+ rec.edges.add(classExprCtor);
10004
+ if (process.env.CANDOR_R1016_REACH) console.error(`R1016-REACH new ${owner} -> ${classExprCtor}`);
10005
+ }
9644
10006
  // ⟨R103⟩ A WRITABLE SLOT IS AN INCOMPLETE CANDIDATE SET — see `openCallSlot`, and see the class-
9645
10007
  // override fan-out below, which is the authority this converges on rather than a second rule: it
9646
10008
  // edges to every candidate it CAN name and adds `Unknown` when the set it enumerated is not
@@ -9660,8 +10022,8 @@ function visitCalls(node) {
9660
10022
  if (!decl) {
9661
10023
  // `new C()` on a class with an IMPLICIT constructor resolves to no declaration — edge to
9662
10024
  // the class's (synthesized) ctor unit via the class identifier before concluding Unknown.
9663
- let edged = false, externalClass = false;
9664
- if (ts.isNewExpression(node) && node.expression && ts.isIdentifier(node.expression)) {
10025
+ let edged = !!classExprCtor, externalClass = false; // ⟨R1016⟩
10026
+ if (!edged && ts.isNewExpression(node) && node.expression && ts.isIdentifier(node.expression)) {
9665
10027
  const cd = realDecl(checker.getSymbolAtLocation(node.expression));
9666
10028
  const t = cd && nodeName.get(cd);
9667
10029
  if (t) { rec.edges.add(t); edged = true; }
@@ -12118,8 +12480,21 @@ for (const sf of sources) visitCalls(sf);
12118
12480
  const herR = herQ && fns.get(herQ);
12119
12481
  if (herR && ["direct", "edges", "why", "blind", "hosts", "paths", "cmds", "incomplete", "dispatch", "tables"]
12120
12482
  .some((k) => herR[k]?.size)) { add(herQ, "heritage"); fns.get(ctorQ)?.edges.add(herQ); }
12483
+ // ⟨R1016⟩ the containment edge to a class expression's constructor, unless its value cannot escape a `new`.
12484
+ // Asked from the class itself rather than its parent, so a `static X = class {…}` lands on `<static-init>`.
12485
+ const ceq = ts.isClassExpression(cls) ? classExprCtorQual.get(cls) : null;
12486
+ if (ceq && classExprEscapes(cls)) {
12487
+ const cfrom = enclosing(cls);
12488
+ const crec = cfrom && cfrom !== ceq ? fns.get(cfrom) : null;
12489
+ if (crec && !crec.edges.has(ceq)) { crec.edges.add(ceq); hit("classexpr"); }
12490
+ } else if (ceq && process.env.CANDOR_R1016_REACH) console.error(`R1016-REACH contained-dropped ${ceq}`);
12121
12491
  if (!targets.length) continue;
12122
- const from = enclosing(cls.parent);
12492
+ // ⟨SOUNDNESS R1060⟩ a class EXPRESSION is asked from itself, as the R1016 edge above is: climbing from its PARENT
12493
+ // starts at a `static X = class {…}` property with no child in hand, so R815's static-initialiser rule never
12494
+ // matched and the inner class's definition-time work was wired from the OUTER class's constructor — `new HB()`
12495
+ // charged for a `static {}` block that runs once, at import (EXECUTED), and the module that runs it silent.
12496
+ // A declaration is still asked from its parent: from itself, `nodeName` would answer its own constructor.
12497
+ const from = ts.isClassExpression(cls) ? enclosing(cls) : enclosing(cls.parent);
12123
12498
  const rec = from && fns.get(from);
12124
12499
  if (!rec) continue;
12125
12500
  for (const [q, k] of targets) if (q !== from && !rec.edges.has(q)) { rec.edges.add(q); hit(k); }