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