candor-ts 0.35.0 → 0.36.1
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/AGENTS.md +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/query.mjs +1 -1
- package/scan.mjs +1071 -74
package/scan.mjs
CHANGED
|
@@ -48,7 +48,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
48
48
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
49
49
|
// Reused, never re-littered.
|
|
50
50
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
51
|
-
const SPEC_VERSION = "0.
|
|
51
|
+
const SPEC_VERSION = "0.36";
|
|
52
52
|
|
|
53
53
|
// A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
|
|
54
54
|
//
|
|
@@ -207,6 +207,14 @@ const CONFIG_KEYS_IMPLEMENTED = new Set(["policy", "baseline", "deps", "unknown-
|
|
|
207
207
|
// It carries its own `prefix` because §3.3.1's direct-file locator accepts any `.json` name whatever its
|
|
208
208
|
// dot-segments: a consumer handed one file cannot recover the prefix from the filename.
|
|
209
209
|
let refusalPrefix = null, refusalTarget = null;
|
|
210
|
+
// The flags the parse loop accepts WITHOUT a value. A target may legitimately follow one of these
|
|
211
|
+
// (`candor-ts --json .`), so they are not stopping points; the value-taking flags are skipped by the
|
|
212
|
+
// arm above, and anything else dash-shaped is what that loop calls an unknown flag. `-h`/`-V`/
|
|
213
|
+
// `--help`/`--version` are print-and-exit modes consumed before this walk and are listed for symmetry.
|
|
214
|
+
const KNOWN_VALUELESS_FLAGS = new Set([
|
|
215
|
+
"--agents", "--json", "--allow-js", "--workspace", "--deps", "--dep-inits",
|
|
216
|
+
"--peek-excluded", "-h", "-V", "--help", "--version",
|
|
217
|
+
]);
|
|
210
218
|
const noteRefusalPrefix = (pfx) => { if (refusalPrefix === null) refusalPrefix = pfx; };
|
|
211
219
|
const noteRefusalTarget = (t) => { if (refusalTarget === null) refusalTarget = t; };
|
|
212
220
|
const writeRefusalMarker = (why) => {
|
|
@@ -239,6 +247,7 @@ const clearRefusalMarker = () => {
|
|
|
239
247
|
// deleting the question.
|
|
240
248
|
const preScan = (av) => {
|
|
241
249
|
let gate = null, policy = null, target = null, out = null, refused = false;
|
|
250
|
+
let stopped = false, markerTarget = null;
|
|
242
251
|
for (let i = 0; i < av.length; i++) {
|
|
243
252
|
const a = av[i], v = av[i + 1];
|
|
244
253
|
if (a === "--gate-json" || a === "--policy" || a === "--out") {
|
|
@@ -264,11 +273,23 @@ const preScan = (av) => {
|
|
|
264
273
|
i++;
|
|
265
274
|
continue;
|
|
266
275
|
}
|
|
276
|
+
// ⟨0.32⟩ A TOKEN THE PARSE LOOP WOULD REFUSE STOPS THE MARKER TARGET, BUT NOT THE GUARD TARGET.
|
|
277
|
+
// The two consumers of this walk want opposite things and used to share one variable. `target`
|
|
278
|
+
// feeds the input GUARDS, where the comment above is right that over-collecting can only protect a
|
|
279
|
+
// file more. `markerTarget` feeds the ⟨0.32⟩ refusal MARKER's prefix, where over-collecting is a
|
|
280
|
+
// WRITE: measured, `candor-ts --scope src` resolved `src` — the rejected flag's operand — and the
|
|
281
|
+
// marker was written to `src/.candor/`, creating that directory in the operator's tree. The parse
|
|
282
|
+
// loop never reaches that token; it refuses at `--scope`. candor-rust had the same defect on a
|
|
283
|
+
// different argv (SOUNDNESS R232); candor-swift writes nothing here and is the shape to match.
|
|
284
|
+
if (a.startsWith("-") && !KNOWN_VALUELESS_FLAGS.has(a)) stopped = true;
|
|
267
285
|
// The scan TARGET, needed to discover the `.candor/config` whose `policy` key may name an input
|
|
268
286
|
// this sink must not overwrite.
|
|
269
|
-
if (!a.startsWith("-") && target === null) {
|
|
287
|
+
if (!a.startsWith("-") && target === null) {
|
|
288
|
+
target = a;
|
|
289
|
+
if (!stopped && !refused) { markerTarget = a; noteRefusalTarget(a); }
|
|
290
|
+
}
|
|
270
291
|
}
|
|
271
|
-
return { gate, policy, target, out };
|
|
292
|
+
return { gate, policy, target, out, markerTarget };
|
|
272
293
|
};
|
|
273
294
|
|
|
274
295
|
// SPEC §3.3.1 ⟨0.28⟩ — every `--gate-json` this argv names. `preScan` keeps only the last, which is what
|
|
@@ -514,7 +535,7 @@ const preGateSink = preScan(argv).gate;
|
|
|
514
535
|
// The raw target is used rather than the resolved root: they coincide for a directory target, and being
|
|
515
536
|
// slightly wrong about WHERE costs a marker nobody reads, while being late costs the marker entirely.
|
|
516
537
|
{
|
|
517
|
-
const preT = preScan(argv).
|
|
538
|
+
const preT = preScan(argv).markerTarget;
|
|
518
539
|
if (preT) { noteRefusalTarget(preT); noteRefusalPrefix(path.join(preT, ".candor", "report")); }
|
|
519
540
|
}
|
|
520
541
|
// ⟨0.28⟩ SPEC §3.3.1 (4) — THE SAME RULE ONE HOP UPSTREAM, FOR THE REPORT STREAM. `--json` is the
|
|
@@ -2408,9 +2429,22 @@ const reassignedIn = (sf) => {
|
|
|
2408
2429
|
// identity is the binding the type literal belongs to: ConstructSignature -> TypeLiteral -> Variable-
|
|
2409
2430
|
// Declaration. Returns "" for a `class`-declared constructor (whose parent is a ClassDeclaration) and for
|
|
2410
2431
|
// anything else, so a caller can treat "" as "this mechanism cannot answer".
|
|
2432
|
+
//
|
|
2433
|
+
// R136 — …AND THE INTERFACE SPELLING, which is the second half of the same question. The lib files use
|
|
2434
|
+
// BOTH shapes for a constructible global: `declare var WebSocket: { new (…): WebSocket }` (an anonymous
|
|
2435
|
+
// type literal) and `declare var Date: DateConstructor; interface DateConstructor { new (): Date }` (a
|
|
2436
|
+
// NAMED interface). Only the first was walked, so every declaration-keyed branch saw `""` for the second
|
|
2437
|
+
// and a `Date` reached through an inherited constructor could not be named. Both parents are the binding
|
|
2438
|
+
// the construct signature belongs to; which one the lib author chose is not a fact about the program.
|
|
2439
|
+
// Still returns "" for a `class`-declared constructor (parent is a ClassDeclaration) and for anything
|
|
2440
|
+
// else. Callers use the answer ONLY to ADD a name to a denylist they already hold, never to remove one,
|
|
2441
|
+
// so widening the walk can over-charge and can never silence.
|
|
2411
2442
|
function declaredCtorClassName(decl) {
|
|
2412
|
-
const
|
|
2413
|
-
|
|
2443
|
+
const owner = decl && decl.parent;
|
|
2444
|
+
if (owner && ts.isInterfaceDeclaration(owner)) {
|
|
2445
|
+
return owner.name && ts.isIdentifier(owner.name) ? owner.name.text : "";
|
|
2446
|
+
}
|
|
2447
|
+
const vd = owner && ts.isTypeLiteralNode(owner) ? owner.parent : null;
|
|
2414
2448
|
return vd && ts.isVariableDeclaration(vd) && vd.name && ts.isIdentifier(vd.name) ? vd.name.text : "";
|
|
2415
2449
|
}
|
|
2416
2450
|
// R130 — `super(...)`. It is a CallExpression, not a NewExpression, so `ts.isNewExpression` is false and
|
|
@@ -3176,7 +3210,7 @@ const classOverrides = new Map();// base-method MemberDeclaration node -> overri
|
|
|
3176
3210
|
const classDescendants = new Map();// base ClassDeclaration -> transitive LOCAL subclass ClassDeclarations (coercion-CHA)
|
|
3177
3211
|
// `Object.defineProperty(target, key, { get/set })` runtime accessors (the silent-pure defineProperty
|
|
3178
3212
|
// hole): the TS checker types `target.key` as a plain DATA property (defineProperty is a runtime
|
|
3179
|
-
// construct), so `
|
|
3213
|
+
// construct), so `accessorsAt` finds no get-accessor and the forcing site `target.key` reads
|
|
3180
3214
|
// silent-pure. We index, keyed by the TARGET's symbol → key string → { get, set } descriptor function
|
|
3181
3215
|
// node, every such accessor seen in the project. The forcing-site arm consults this when the type-level
|
|
3182
3216
|
// accessor resolution comes up empty (precise edge when target+key resolve; else honest Unknown).
|
|
@@ -4275,73 +4309,322 @@ for (const sf of sources) {
|
|
|
4275
4309
|
// when the accessor's declaration lives in a project file (a UNIT we minted; edge to it). A resolved
|
|
4276
4310
|
// accessor we CAN'T see (external/typed-only declaration) returns local:false so the caller follows
|
|
4277
4311
|
// the existing Unknown/curated-κ posture — never silent-pure for a resolved-but-unseen accessor.
|
|
4278
|
-
// A property SYMBOL →
|
|
4279
|
-
// declaration lives in a project file (a unit we minted; edge to it). Shared by every
|
|
4280
|
-
// shape: dot access, element access, and object destructuring.
|
|
4281
|
-
|
|
4282
|
-
|
|
4312
|
+
// A property SYMBOL → EVERY accessor declaration of the wanted kind (a possibly-empty list). `local` is
|
|
4313
|
+
// true when that declaration lives in a project file (a unit we minted; edge to it). Shared by every
|
|
4314
|
+
// property-read shape: dot access, element access, and object destructuring.
|
|
4315
|
+
//
|
|
4316
|
+
// EVERY DECLARATION, NOT THE FIRST — this was a `.find`, and on a UNION-TYPED RECEIVER that is a silent
|
|
4317
|
+
// under-report chosen by declaration order. TypeScript synthesizes ONE property symbol for `x.token`
|
|
4318
|
+
// where `x: Aa | Bb`, carrying BOTH classes' accessor declarations, and their order is the checker's,
|
|
4319
|
+
// not the source's. `.find` therefore picked an arbitrary arm and, when it picked the pure one, the
|
|
4320
|
+
// effectful arm vanished. EXECUTED, node 22.12.0, counting real `fs.appendFileSync` calls by reading the
|
|
4321
|
+
// log file back:
|
|
4322
|
+
//
|
|
4323
|
+
// export class Aa { set token(v: string) { /* pure */ } }
|
|
4324
|
+
// export class Bb { set token(v: string) { fs.appendFileSync("/tmp/x", v); } }
|
|
4325
|
+
// export function u1(x: Aa | Bb) { x.token = "v"; }
|
|
4326
|
+
//
|
|
4327
|
+
// u1(new Bb()) -> 1 real write pre-fix: `c.u1` ABSENT from functions[], `deny Fs` exit 0
|
|
4328
|
+
// post-fix: `c.u1` ["Fs"], `deny Fs` exit 1
|
|
4329
|
+
//
|
|
4330
|
+
// and it was order-dependent in a way no reader could predict: the SAME program with the two classes
|
|
4331
|
+
// swapped in the file reported `Fs` (the checker happened to list the effectful arm first). A union
|
|
4332
|
+
// receiver is a disjunction — ANY arm may be the runtime value — so the sound answer is the UNION of
|
|
4333
|
+
// the arms' accessors, which is also what `classBodiedGetter`'s `every` already assumes one line up.
|
|
4334
|
+
// The direction this fails in is over-charge (an arm that cannot occur at this site), never silence.
|
|
4335
|
+
// SOUNDNESS R259 — A UNION RECEIVER IS A DISJUNCTION, AND `getProperties()` ANSWERS THE INTERSECTION.
|
|
4336
|
+
// R245 fixed the union question at `accessorsFromSym` — ONE synthesised symbol carrying several
|
|
4337
|
+
// declarations — and its own commit message states the rule this level does not implement: *"a union
|
|
4338
|
+
// receiver is a disjunction — ANY arm may be the runtime value — so the sound answer is the UNION of the
|
|
4339
|
+
// arms' accessors."* Every whole-object arm gets its property LIST from `type.getProperties()`, which on
|
|
4340
|
+
// a union returns only the properties present in EVERY constituent, so an accessor declared on ONE arm is
|
|
4341
|
+
// never handed to the (now-correct) helper at all. Proven against the TS API directly: for `x: Aa | Bb`
|
|
4342
|
+
// where only `Aa` declares `token`, `getProperties()` is `[other]` and `getProperty("token")` is null.
|
|
4343
|
+
//
|
|
4344
|
+
// EXECUTED, node 22.12.0, 1 real accessor invocation per cell, over 46 cells of a generated matrix —
|
|
4345
|
+
// six union shapes crossed with every arm that typechecks, caller ABSENT from `functions[]` in all 46:
|
|
4346
|
+
//
|
|
4347
|
+
// Aa|undefined assign-src, JSON.stringify, structuredClone, spread, rest 5 of 5 arms silent
|
|
4348
|
+
// Aa|null the same five 5 of 5
|
|
4349
|
+
// Aa|string + Object.assign target, Object.entries, Object.values 6 of 6
|
|
4350
|
+
// Aa|Bb + Reflect.get, Reflect.set 10 of 10
|
|
4351
|
+
// Aa|{lit} the same ten 10 of 10
|
|
4352
|
+
// (f ? a : b) the same ten — a union formed AT THE SITE 10 of 10
|
|
4353
|
+
//
|
|
4354
|
+
// and on the caller ALL EIGHT policy forms exit 0 — blanket `deny Fs`, `deny Unknown`, `deny Fs Unknown`,
|
|
4355
|
+
// `pure <caller>`, `deny Fs <caller>`, and the three reason-scoped ones — with the scoped rules BINDING.
|
|
4356
|
+
// Blanket `deny Fs` is red elsewhere in the module only when the object's PRODUCER is in scope; when the
|
|
4357
|
+
// object arrives as a parameter, nothing anywhere goes red. NARROWED forms are correct today and stay
|
|
4358
|
+
// correct — `x ?? fb` and `if (!x) return` both disclose, because TS narrows before the site, so the hole
|
|
4359
|
+
// needs the union to SURVIVE into the operation (12 arms each, 0 silent, carried as controls).
|
|
4360
|
+
//
|
|
4361
|
+
// The direction this fails in is OVER-CHARGE — an arm that cannot be the runtime value at this site —
|
|
4362
|
+
// never silence, which is the same trade `accessorsFromSym`'s `every` already makes one level down.
|
|
4363
|
+
/** Every property symbol ANY arm of `type` declares — the disjunction, not `getProperties()`'s intersection. */
|
|
4364
|
+
function propertiesAcrossArms(type) {
|
|
4365
|
+
if (!type?.getProperties) return [];
|
|
4366
|
+
if (!type.isUnion?.()) return type.getProperties();
|
|
4367
|
+
const seen = new Set();
|
|
4368
|
+
for (const arm of type.types) for (const p of (arm.getProperties?.() ?? [])) seen.add(p);
|
|
4369
|
+
return [...seen];
|
|
4370
|
+
}
|
|
4371
|
+
/** Every arm's symbol for `name` — a LIST, because two arms can declare the same name with different
|
|
4372
|
+
* accessor bodies and picking one is the order-dependence R245 was filed to kill. */
|
|
4373
|
+
function propertySymbolsAcrossArms(type, name) {
|
|
4374
|
+
if (!type?.getProperty) return [];
|
|
4375
|
+
const seen = new Set();
|
|
4376
|
+
const direct = type.getProperty(name);
|
|
4377
|
+
if (direct) seen.add(direct);
|
|
4378
|
+
if (type.isUnion?.()) for (const arm of type.types) { const p = arm.getProperty?.(name); if (p) seen.add(p); }
|
|
4379
|
+
return [...seen];
|
|
4380
|
+
}
|
|
4381
|
+
function accessorsFromSym(sym, kind /* "get" | "set" */) {
|
|
4382
|
+
if (!sym) return [];
|
|
4283
4383
|
const want = kind === "get" ? ts.isGetAccessorDeclaration : ts.isSetAccessorDeclaration;
|
|
4284
4384
|
// A symbol is an accessor only if its declarations include an accessor of the wanted kind.
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4385
|
+
return (sym.declarations ?? []).filter((d) => want(d))
|
|
4386
|
+
.map((decl) => ({ decl, local: projectFiles.has(path.resolve(decl.getSourceFile().fileName)) }));
|
|
4387
|
+
}
|
|
4388
|
+
// SOUNDNESS R240(a) — THE KEY'S TYPE PINS THE PROPERTY SET, AND ASKING IT IS NOT A GUESS.
|
|
4389
|
+
// `accessorsAt` used to resolve only a syntactic string literal, under a comment that said a dynamic
|
|
4390
|
+
// key "can't be pinned to one property … resolving it would guess". That is true of `k: string` and
|
|
4391
|
+
// FALSE of `k: "token" | "other"`, whose TYPE names exactly two properties — the same shape as R232/R233,
|
|
4392
|
+
// where a safety comment was true for the case in front of its author and false one spelling over.
|
|
4393
|
+
//
|
|
4394
|
+
// Returns the FINITE set of property names the key can hold, or `null` when there is no finite set.
|
|
4395
|
+
// `null` is a real answer ("we cannot say which property this is"), distinct from an empty set.
|
|
4396
|
+
//
|
|
4397
|
+
// THIS IS NOT AN OVER-APPROXIMATION: every name returned is a value the key is DECLARED to be able to
|
|
4398
|
+
// take, so charging all of them charges exactly what the program says can run. It fails toward `null`
|
|
4399
|
+
// (today's silence) on anything it cannot enumerate, which is the denylist direction — a name is
|
|
4400
|
+
// admitted only when a literal type PROVES it, never guessed from the receiver's property list.
|
|
4401
|
+
//
|
|
4402
|
+
// FIVE SPELLINGS, all measured on executed fixtures (see the test block and the CHANGELOG):
|
|
4403
|
+
// "token" | "other" a literal union -> {token, other}
|
|
4404
|
+
// const k = "token" a single literal type -> {token}
|
|
4405
|
+
// EK / EK.Token a string enum, whole or one member -> {token, other} / {token} (EnumLiteral
|
|
4406
|
+
// carries StringLiteral, so `.value` is
|
|
4407
|
+
// the runtime key)
|
|
4408
|
+
// keyof Session an Index type the checker has already normalised to a literal union
|
|
4409
|
+
// K extends keyof T a TYPE PARAMETER — pinned through its CONSTRAINT, which is that same union
|
|
4410
|
+
//
|
|
4411
|
+
// The CAP is a rail, not a semantic: a template-literal type can expand to tens of thousands of names,
|
|
4412
|
+
// and past the cap this returns `null` — the pre-fix answer, so the cap can only lose precision, never
|
|
4413
|
+
// soundness.
|
|
4414
|
+
const KEY_NAME_CAP = 512;
|
|
4415
|
+
function keyLiteralNames(t, depth = 0, seen = new Set()) {
|
|
4416
|
+
if (!t || depth > 4 || seen.has(t)) return null;
|
|
4417
|
+
seen.add(t);
|
|
4418
|
+
if (t.isUnion?.()) {
|
|
4419
|
+
const out = new Set();
|
|
4420
|
+
for (const c of t.types) {
|
|
4421
|
+
const s = keyLiteralNames(c, depth + 1, seen);
|
|
4422
|
+
if (!s) return null; // ONE unpinnable arm makes the whole key unpinnable
|
|
4423
|
+
for (const x of s) out.add(x);
|
|
4424
|
+
if (out.size > KEY_NAME_CAP) return null;
|
|
4425
|
+
}
|
|
4426
|
+
return out;
|
|
4427
|
+
}
|
|
4428
|
+
// A string/number literal type IS the property name. `EK.Token` is `EnumLiteral | StringLiteral`, so a
|
|
4429
|
+
// string enum member lands here and `.value` is the runtime key the enum member compiles to.
|
|
4430
|
+
if (t.isStringLiteral?.()) return new Set([t.value]);
|
|
4431
|
+
if (t.isNumberLiteral?.()) return new Set([String(t.value)]);
|
|
4432
|
+
// `K extends keyof Session` — the type parameter itself names nothing; its CONSTRAINT does.
|
|
4433
|
+
const c = checker.getBaseConstraintOfType?.(t);
|
|
4434
|
+
if (c && c !== t) return keyLiteralNames(c, depth + 1, seen);
|
|
4435
|
+
return null; // `string`, `symbol`, `keyof T` for generic T, …
|
|
4436
|
+
}
|
|
4437
|
+
function accessorsAt(propNode, kind /* "get" | "set" */) {
|
|
4291
4438
|
if (ts.isElementAccessExpression(propNode)) {
|
|
4292
|
-
// `c["prop"]` carries no `.name`; resolve the
|
|
4293
|
-
//
|
|
4294
|
-
//
|
|
4439
|
+
// `c["prop"]` carries no `.name`; resolve the key to the property NAMES it can hold and look each
|
|
4440
|
+
// one up on the receiver's type. Returns `null` — distinct from `[]` — when the key is NOT pinned
|
|
4441
|
+
// to a finite name set, so the caller can tell "pinned, and none of them is an accessor" (nothing
|
|
4442
|
+
// to charge) from "we do not know which property this is" (a disclosure question).
|
|
4295
4443
|
const arg = propNode.argumentExpression;
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4444
|
+
const texts = arg && ts.isStringLiteralLike(arg) ? new Set([arg.text])
|
|
4445
|
+
: arg ? keyLiteralNames(checker.getTypeAtLocation(arg)) : null;
|
|
4446
|
+
if (!texts) return null;
|
|
4447
|
+
const recvType = checker.getTypeAtLocation(propNode.expression);
|
|
4448
|
+
const out = [];
|
|
4449
|
+
// R259 — every ARM's symbol for the name, not just the union's own (which is null unless the
|
|
4450
|
+
// property is common to all of them).
|
|
4451
|
+
for (const t of texts) for (const sym of propertySymbolsAcrossArms(recvType, t))
|
|
4452
|
+
out.push(...accessorsFromSym(sym, kind));
|
|
4453
|
+
return out;
|
|
4454
|
+
}
|
|
4455
|
+
return accessorsFromSym(checker.getSymbolAtLocation(propNode.name ?? propNode), kind);
|
|
4303
4456
|
}
|
|
4304
4457
|
// A `Object.defineProperty` descriptor accessor for the forcing site `recv.key` (read → get, assign →
|
|
4305
|
-
// set), consulted ONLY when the type-level `
|
|
4458
|
+
// set), consulted ONLY when the type-level `accessorsAt` came up empty (the checker types target.key as
|
|
4306
4459
|
// a data prop, so defineProperty accessors are invisible to it). Resolve the receiver expression to its
|
|
4307
4460
|
// binding symbol and the key to a static string; look both up in `definePropAccessors`. Returns the
|
|
4308
4461
|
// descriptor function NODE (a minted unit) when found, or null. NO fabrication: a data (`value:`)
|
|
4309
4462
|
// descriptor was never indexed, an absent target/key returns null.
|
|
4310
4463
|
function definePropForceTarget(propNode, kind /* "get" | "set" */) {
|
|
4311
|
-
if (definePropAccessors.size === 0) return
|
|
4312
|
-
|
|
4464
|
+
if (definePropAccessors.size === 0) return [];
|
|
4465
|
+
// R240(a) — the key set, not one key: the SAME literal-type pinning the type-level arm now does. A
|
|
4466
|
+
// descriptor installed under `"hot"` and reached as `target[k]` with `k: "hot" | "cold"` was silent
|
|
4467
|
+
// here for exactly the reason it was silent there (executed: 1 real setter invocation, row ABSENT).
|
|
4468
|
+
let recvExpr, keyTexts;
|
|
4313
4469
|
if (ts.isElementAccessExpression(propNode)) {
|
|
4314
4470
|
recvExpr = propNode.expression;
|
|
4315
4471
|
const arg = propNode.argumentExpression;
|
|
4316
|
-
|
|
4472
|
+
keyTexts = arg && ts.isStringLiteralLike(arg) ? new Set([arg.text])
|
|
4473
|
+
: arg ? keyLiteralNames(checker.getTypeAtLocation(arg)) : null;
|
|
4317
4474
|
} else if (ts.isPropertyAccessExpression(propNode)) {
|
|
4318
4475
|
recvExpr = propNode.expression;
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4476
|
+
const n = propNode.name?.getText?.();
|
|
4477
|
+
keyTexts = n == null ? null : new Set([n]);
|
|
4478
|
+
} else return [];
|
|
4479
|
+
if (keyTexts == null) return [];
|
|
4322
4480
|
// Resolve the receiver to the SAME symbol the defineProperty target identifier resolved to. Follow an
|
|
4323
4481
|
// import alias so a cross-module `import { config }` access joins the defining module's index entry.
|
|
4324
4482
|
const rsym0 = checker.getSymbolAtLocation(recvExpr);
|
|
4325
|
-
if (!rsym0) return
|
|
4483
|
+
if (!rsym0) return [];
|
|
4326
4484
|
const rsym = rsym0.flags & ts.SymbolFlags.Alias ? (() => { try { return checker.getAliasedSymbol(rsym0); } catch { return rsym0; } })() : rsym0;
|
|
4327
4485
|
const byKey = definePropAccessors.get(rsym) ?? definePropAccessors.get(rsym0);
|
|
4328
|
-
|
|
4329
|
-
|
|
4486
|
+
if (!byKey) return [];
|
|
4487
|
+
const out = [];
|
|
4488
|
+
for (const k of keyTexts) { const n = byKey.get(k)?.[kind]; if (n) out.push(n); }
|
|
4489
|
+
return out;
|
|
4330
4490
|
}
|
|
4331
4491
|
// Record a resolved accessor HIT (read or write) as an edge from `owner`: into the accessor UNIT when
|
|
4332
4492
|
// it's a local declaration we minted; otherwise Unknown (a resolved-but-unseen accessor body — never
|
|
4333
4493
|
// silent-pure, SPEC §4). `label` tags the §-why disclosure.
|
|
4334
|
-
|
|
4494
|
+
// SOUNDNESS R282 — AND THE OVERRIDES, WHICH THIS NEVER ASKED FOR. The edge above lands on the
|
|
4495
|
+
// declaration RESOLUTION picked — for a base-typed receiver that is the BASE's accessor — and nothing
|
|
4496
|
+
// here consulted `classOverrides`, so a subclass override's effects never reached the caller. The
|
|
4497
|
+
// method path one arm over (`allOverrides` at the CallExpression site) does exactly this, off the SAME
|
|
4498
|
+
// index, which already keys accessor declarations: `classOverrides`'s own indexing loop matches
|
|
4499
|
+
// `isGetAccessorDeclaration`/`isSetAccessorDeclaration` explicitly. The index knew; the consumer never
|
|
4500
|
+
// asked. §F1 question 3 — separate implementations of one question, and this is the one that drifted.
|
|
4501
|
+
//
|
|
4502
|
+
// THE ROW THAT FILED THIS NAMED `abstract get` — A DECLARATION WITH NO BODY — AND THAT IS THE TRIGGER,
|
|
4503
|
+
// NOT THE CLASS. A CONCRETE base accessor WITH a body fails identically, and that is the shape real code
|
|
4504
|
+
// has. EXECUTED, node 22.12.0, counting real `fs.appendFileSync` calls, receiver typed as the base:
|
|
4505
|
+
//
|
|
4506
|
+
// abstract class T { abstract get val(): string; } 1 real write, `leak` ABSENT
|
|
4507
|
+
// class T { get val() { return "b"; } } + override get val() 1 real write, `leak` ABSENT
|
|
4508
|
+
// class T { m() { return "b"; } } + override m() 1 real write, `leak` ['Fs'] ← the control
|
|
4509
|
+
//
|
|
4510
|
+
// In a tree containing nothing else, `pure src.only.leak` and `deny Fs src.only.leak` both exit 0 with
|
|
4511
|
+
// the scope BINDING, `deny Unknown` exits 0, and blanket `deny Fs` exits 1 only INCIDENTALLY — via the
|
|
4512
|
+
// independently-reported `src.only.TImpl.get val` unit, never via the caller, which was not judged at all.
|
|
4513
|
+
//
|
|
4514
|
+
// Bounded exactly as the method path is, and the bounds are the point rather than trivia: the fan-out is
|
|
4515
|
+
// scoped to the RECEIVER's static-type subtree when the receiver pins a local class (a sibling
|
|
4516
|
+
// subclass's override is type-impossible on this path, and charging it would be fabrication-adjacent);
|
|
4517
|
+
// past `CHA_FANOUT_LIMIT`, or with any override not minted as a unit, it DISCLOSES rather than silently
|
|
4518
|
+
// dropping what it could not enumerate. A base accessor no subclass overrides has no index entry, so
|
|
4519
|
+
// today's answer is preserved byte-for-byte there.
|
|
4520
|
+
function accessorOverrideFanOut(rec, decl, recvExpr) {
|
|
4521
|
+
const allOverrides = classOverrides.get(decl);
|
|
4522
|
+
if (!allOverrides || allOverrides.length === 0) return;
|
|
4523
|
+
let overrides = allOverrides;
|
|
4524
|
+
if (recvExpr) {
|
|
4525
|
+
const rt = checker.getTypeAtLocation(recvExpr);
|
|
4526
|
+
const rootClass = (rt?.symbol?.declarations ?? []).find((d) =>
|
|
4527
|
+
ts.isClassDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
4528
|
+
// SOUNDNESS-PRESERVING FALLBACK, the method path's verbatim: a receiver we cannot pin to a LOCAL
|
|
4529
|
+
// class (a union, an interface, `any`, an external type) keeps the FULL override set.
|
|
4530
|
+
if (rootClass) overrides = allOverrides.filter((om) =>
|
|
4531
|
+
ts.isClassDeclaration(om.parent) && classInSubtree(om.parent, rootClass));
|
|
4532
|
+
}
|
|
4533
|
+
if (overrides.length === 0) return;
|
|
4534
|
+
const ownerQual = (d) => (d.parent?.name
|
|
4535
|
+
? `${moduleOf(d.parent.getSourceFile())}.${namespacePrefixOf(d.parent)}${d.parent.name.getText()}`
|
|
4536
|
+
: null);
|
|
4537
|
+
if (overrides.length > CHA_FANOUT_LIMIT) {
|
|
4538
|
+
rec.direct.add("Unknown"); // override family too wide to enumerate soundly
|
|
4539
|
+
rec.why.add(dispatchWhy(ownerQual(decl), decl.name?.getText?.()));
|
|
4540
|
+
return;
|
|
4541
|
+
}
|
|
4542
|
+
let allResolved = true;
|
|
4543
|
+
const targets = [];
|
|
4544
|
+
for (const om of overrides) { const ot = nodeName.get(om); if (ot) targets.push(ot); else allResolved = false; }
|
|
4545
|
+
for (const ot of targets) rec.edges.add(ot); // (EDGE) into each override — effects propagate
|
|
4546
|
+
if (!allResolved) {
|
|
4547
|
+
rec.direct.add("Unknown"); // an override we could not name is not a pure one
|
|
4548
|
+
rec.why.add(dispatchWhy(ownerQual(decl), decl.name?.getText?.()));
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
function recordAccessorHit(owner, hit, label, recvExpr = null) {
|
|
4335
4552
|
const rec = fns.get(owner);
|
|
4336
4553
|
const t = nodeName.get(hit.decl);
|
|
4337
4554
|
if (hit.local && t) {
|
|
4338
4555
|
rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
|
|
4556
|
+
accessorOverrideFanOut(rec, hit.decl, recvExpr); // R282 — …and into every override it may bind to
|
|
4339
4557
|
} else {
|
|
4340
4558
|
rec.direct.add("Unknown");
|
|
4341
4559
|
rec.why.add(`reflect:accessor:${label}`); // a defineProperty runtime accessor (descriptor get/set unseen) — metaprogramming, canonical `reflect:`
|
|
4342
4560
|
}
|
|
4343
4561
|
}
|
|
4344
4562
|
|
|
4563
|
+
// SOUNDNESS R247 — ONE AUTHORITY for "could a runtime key of this TYPE ever name this property?".
|
|
4564
|
+
// Extracted from R240(b)'s unpinnable-key branch, which is where the question was first answered and
|
|
4565
|
+
// where the corpus proved it has to be asked BOTH ways: a STRING key cannot name a symbol-keyed
|
|
4566
|
+
// accessor (axios 1.7.2's six `AxiosHeaders` rows, armed only by `get [Symbol.toStringTag]()`), and a
|
|
4567
|
+
// SYMBOL key cannot name a string-keyed one (mongoose 8's 18 rows, armed by bson's `get id()`).
|
|
4568
|
+
// R247's convergence needs the identical test on the `Reflect.set(t, k, v)` side, and a second private
|
|
4569
|
+
// copy is how the two spellings drift apart again — which is the entire subject of the row.
|
|
4570
|
+
//
|
|
4571
|
+
// A DENYLIST OF THE PROVEN-UNREACHABLE, not an allowlist: it excludes only when EVERY declaration of
|
|
4572
|
+
// the property has a computed name whose expression is symbol-TYPED, and it lifts entirely when the
|
|
4573
|
+
// key's own type could hold the other kind (`PropertyKey`, `symbol`, `any`, `unknown`) or when there is
|
|
4574
|
+
// no key expression to read at all (`keyT == null` → every property stays reachable).
|
|
4575
|
+
//
|
|
4576
|
+
// SOUNDNESS R283 — A TYPE PARAMETER IS NOT A SPELLING, AND THE COMMENT ABOVE IS TRUE OF FOUR SPELLINGS
|
|
4577
|
+
// AND FALSE OF EVERY GENERIC ONE. "It lifts entirely when the key's own type could hold the other kind
|
|
4578
|
+
// (`PropertyKey`, `symbol`, `any`, `unknown`)" was written by the commit that needed it, and it reads as
|
|
4579
|
+
// a considered ruling — §K exactly. A TYPE PARAMETER's own flags are `TypeParameter` and nothing else:
|
|
4580
|
+
// neither symbol-ish nor wild, so both tests read FALSE and the key was treated as provably not a symbol,
|
|
4581
|
+
// although `K extends PropertyKey` binds to one at the call site. The key's vocabulary is in its
|
|
4582
|
+
// CONSTRAINT, and `getBaseConstraintOfType` is where the checker keeps it — the same call `keyLiteralNames`
|
|
4583
|
+
// already makes twenty lines up for exactly this reason (§G: ask the authority, it already knows).
|
|
4584
|
+
//
|
|
4585
|
+
// MEASURED through the TS API on the fixture, at 9a0cfd9:
|
|
4586
|
+
// k: K where K extends PropertyKey flags 524288 (TypeParameter) keyMayBeSymbol FALSE
|
|
4587
|
+
// getBaseConstraintOfType -> PropertyKey, whose arms answer TRUE
|
|
4588
|
+
//
|
|
4589
|
+
// AND THE PART THAT DECIDES HOW MUCH THIS MATTERS: the blindness was NOT uniform, because one arm passed
|
|
4590
|
+
// BY ACCIDENT. `Reflect.set(t, k, v)`'s lib.d.ts `propertyKey: PropertyKey` is NON-generic, so the
|
|
4591
|
+
// checker hands this helper `PropertyKey` and the guard answered right for the wrong reason; `Reflect.get`
|
|
4592
|
+
// is generic (`P extends PropertyKey`), so `k` keeps type `K` and the guard answered wrong. A guard that
|
|
4593
|
+
// passes because of an overload's shape is one TypeScript release from flipping, and it is why the
|
|
4594
|
+
// axis here is the CONSTRAINT and not the four spellings. EXECUTED, 1 real getter invocation per cell,
|
|
4595
|
+
// over a class whose only accessor is `get [SYM]()`:
|
|
4596
|
+
//
|
|
4597
|
+
// K extends keyof Src Reflect.get SILENT Reflect.set SILENT s[k] SILENT s[k]=v SILENT
|
|
4598
|
+
// K extends symbol Reflect.get SILENT Reflect.set SILENT
|
|
4599
|
+
// K extends PropertyKey Reflect.get SILENT Reflect.set discloses ← the accidental pass
|
|
4600
|
+
// PropertyKey / symbol / keyof Src / any (non-generic) all four arms disclose
|
|
4601
|
+
// string / "tok" / "tok"|"other" correctly silent — 0 executed, no legal key reaches the accessor
|
|
4602
|
+
//
|
|
4603
|
+
// UNCONSTRAINED `<K>` fails OPEN (both kinds possible), which is the disclose direction: a bare type
|
|
4604
|
+
// parameter can be instantiated with anything, so nothing is proven and nothing may be excluded.
|
|
4605
|
+
function keyCouldNameAccessor(propSym, keyT) {
|
|
4606
|
+
const SYMBOLISH = ts.TypeFlags.ESSymbolLike, WILD = ts.TypeFlags.Any | ts.TypeFlags.Unknown;
|
|
4607
|
+
// A key type flattened to the arms whose FLAGS actually answer the question: a union spreads to its
|
|
4608
|
+
// constituents, a type parameter to its constraint's, and an UNCONSTRAINED one to `null` — "no
|
|
4609
|
+
// vocabulary is proven here", which both tests below read as possible.
|
|
4610
|
+
const arms = (t, depth = 0) => {
|
|
4611
|
+
if (!t) return [];
|
|
4612
|
+
if (t.isUnion?.()) return t.types.flatMap((x) => arms(x, depth));
|
|
4613
|
+
if ((t.flags & ts.TypeFlags.TypeParameter) && depth < 4) {
|
|
4614
|
+
const c = checker.getBaseConstraintOfType?.(t);
|
|
4615
|
+
return c && c !== t ? arms(c, depth + 1) : [null];
|
|
4616
|
+
}
|
|
4617
|
+
return [t];
|
|
4618
|
+
};
|
|
4619
|
+
const keyArms = keyT ? arms(keyT) : [];
|
|
4620
|
+
const keyMayBeSymbol = !keyT || keyArms.some((t) => t === null || (t.flags & (SYMBOLISH | WILD)));
|
|
4621
|
+
const keyMayBeString = !keyT || keyArms.some((t) => t === null || !(t.flags & SYMBOLISH));
|
|
4622
|
+
const ds = propSym?.declarations ?? [];
|
|
4623
|
+
const symbolNamed = ds.length > 0 && ds.every((d) => d.name && ts.isComputedPropertyName(d.name)
|
|
4624
|
+
&& !!(checker.getTypeAtLocation(d.name.expression)?.flags & SYMBOLISH));
|
|
4625
|
+
return symbolNamed ? keyMayBeSymbol : keyMayBeString;
|
|
4626
|
+
}
|
|
4627
|
+
|
|
4345
4628
|
// Object PROPERTY-ENUMERATION (`{...obj}`, `const {...rest} = obj`, `Object.assign(t, obj)`): copying an
|
|
4346
4629
|
// object's own enumerable props INVOKES each source getter — the whole-object analog of `obj.prop`,
|
|
4347
4630
|
// invisible to the property-access arm (no PropertyAccess node per key). Edge `owner` to every LOCAL
|
|
@@ -4430,10 +4713,9 @@ function classBodiedGetter(sym) {
|
|
|
4430
4713
|
function enumerateGetters(owner, type, srcExpr) {
|
|
4431
4714
|
if (!owner) return;
|
|
4432
4715
|
if (type && type.getProperties) {
|
|
4433
|
-
for (const p of type
|
|
4716
|
+
for (const p of propertiesAcrossArms(type)) { // R259 — the disjunction, not the intersection
|
|
4434
4717
|
if (classBodiedGetter(p)) continue; // prototype + non-enumerable → not copied by a spread
|
|
4435
|
-
const hit
|
|
4436
|
-
if (hit) recordAccessorHit(owner, hit, p.getName());
|
|
4718
|
+
for (const hit of accessorsFromSym(p, "get")) recordAccessorHit(owner, hit, p.getName(), srcExpr);
|
|
4437
4719
|
}
|
|
4438
4720
|
}
|
|
4439
4721
|
// The structural arm: `const o: SomeClass = { get k(){…} }` — the BINDING's initializer is an object
|
|
@@ -4466,6 +4748,149 @@ function enumerateGetters(owner, type, srcExpr) {
|
|
|
4466
4748
|
}
|
|
4467
4749
|
}
|
|
4468
4750
|
|
|
4751
|
+
// R116 — THE MIRROR OF `enumerateGetters`, AND THE ARM THAT DID NOT EXIST. `Object.assign(t, s)` is
|
|
4752
|
+
// specified as `t[k] = s[k]` for every own enumerable key of every source, so it invokes the TARGET's
|
|
4753
|
+
// SETTERS exactly as `t.k = v` does. `enumerateGetters` above handles the SOURCE side; nothing handled
|
|
4754
|
+
// this one, so a setter that writes a file was reported as nothing at all:
|
|
4755
|
+
//
|
|
4756
|
+
// export class Sink { set token(x: number) { fs.writeFileSync("/tmp/leak", String(x)); } }
|
|
4757
|
+
// export function viaAssign() { const s = new Sink(); Object.assign(s, { token: 1 }); }
|
|
4758
|
+
// -> `viaAssign` ABSENT from `functions[]`, `deny Fs` exit 0, in a tree containing NOTHING else
|
|
4759
|
+
// export function viaNamedWrite() { const s = new Sink(); s.token = 2; } -> ["Fs"], correctly
|
|
4760
|
+
//
|
|
4761
|
+
// One spelling of a question the engine already answers right, which is what makes it a defect rather
|
|
4762
|
+
// than a coverage gap. GROUND TRUTH EXECUTED on node 22.12.0, counting setter invocations: 1 for every
|
|
4763
|
+
// `Object.assign` spelling (literal source, two sources, a parameter source, a spread source, a
|
|
4764
|
+
// computed key) and 1 for `Reflect.set`; 0 for `{...s, token: 1}` (a spread builds a FRESH object, so
|
|
4765
|
+
// no setter runs) and 0 for `Object.defineProperties(s, { token: { value: 3 } })` (a `value:` descriptor
|
|
4766
|
+
// installs an own property and bypasses the setter). Those two zeroes are the controls.
|
|
4767
|
+
//
|
|
4768
|
+
// THE KEY SET IS A DENYLIST OF THE PROVEN, NOT AN ALLOWLIST OF THE GUESSED — the family rule, and the
|
|
4769
|
+
// direction this fails in is over-charge. Every setter the target declares is charged UNLESS the copied
|
|
4770
|
+
// key set can be PROVEN and excludes it. It is provable in exactly one shape: a fresh object literal at
|
|
4771
|
+
// the call site with no spread and no computed key, whose keys are its own text. It is NOT provable from
|
|
4772
|
+
// a source's declared TYPE, because TypeScript is structural and the runtime object may carry more keys
|
|
4773
|
+
// than the annotation admits:
|
|
4774
|
+
// function f(s: { a: number }) { Object.assign(target, s); } f({ a: 1, token: 2 }); // token IS copied
|
|
4775
|
+
// so a type-keyed answer would be an allowlist of guessed-safe keys, which is how a silent under-report
|
|
4776
|
+
// gets introduced while killing an over-charge.
|
|
4777
|
+
//
|
|
4778
|
+
// NO FABRICATION WHERE THERE IS NOTHING TO CHARGE: a target that declares no `set` accessor takes this
|
|
4779
|
+
// function through zero iterations, so the overwhelmingly common `Object.assign(cfg, opts)` over plain
|
|
4780
|
+
// data objects is untouched — measured on the corpus, not assumed.
|
|
4781
|
+
//
|
|
4782
|
+
// `keys === null` means "the copied key set is not provable"; a Set means it is, exactly.
|
|
4783
|
+
const provenCopiedKeys = (sources) => {
|
|
4784
|
+
const keys = new Set();
|
|
4785
|
+
for (let s of sources) {
|
|
4786
|
+
while (s && (ts.isParenthesizedExpression(s) || ts.isAsExpression(s)
|
|
4787
|
+
|| ts.isSatisfiesExpression(s) || ts.isNonNullExpression(s))) s = s.expression;
|
|
4788
|
+
if (!s || !ts.isObjectLiteralExpression(s)) return null; // parameter / call return / variable
|
|
4789
|
+
for (const pr of s.properties) {
|
|
4790
|
+
if (ts.isSpreadAssignment(pr)) return null; // `{...o}` copies an unknown key set
|
|
4791
|
+
const n = pr.name;
|
|
4792
|
+
if (!n) return null;
|
|
4793
|
+
if (ts.isIdentifier(n) || ts.isStringLiteralLike(n) || ts.isNumericLiteral(n)) keys.add(n.text);
|
|
4794
|
+
else return null; // computed key `{[k]: v}`
|
|
4795
|
+
}
|
|
4796
|
+
}
|
|
4797
|
+
return keys;
|
|
4798
|
+
};
|
|
4799
|
+
//
|
|
4800
|
+
// SOUNDNESS R247 — WHERE THE KEY SET IS UNPROVABLE, DISCLOSE `Unknown`; DO NOT CHARGE EVERY SETTER.
|
|
4801
|
+
// The paragraph above ("every setter the target declares is charged unless the copied key set can be
|
|
4802
|
+
// PROVEN and excludes it") described what this branch did until R247, and it made ONE ECMAScript
|
|
4803
|
+
// operation answer two ways: `Reflect.set(s, k, v)` with a runtime key charged `['Fs']` here, while
|
|
4804
|
+
// `s[k] = v` — the same property write, the same unpinnable key — disclosed `['Unknown']` under
|
|
4805
|
+
// R240(b) a few hundred lines down. A scoped `deny Fs` caught one and not the other.
|
|
4806
|
+
//
|
|
4807
|
+
// The two directions are not symmetric and that is what decides it: charging every setter is
|
|
4808
|
+
// FABRICATION (it claims an effect that a run may not perform), disclosing `Unknown` is honest about
|
|
4809
|
+
// exactly what is not known. The family's posture is to under-report rather than fabricate, and R240(b)
|
|
4810
|
+
// had already spent that posture on the other spelling.
|
|
4811
|
+
//
|
|
4812
|
+
// GROUND TRUTH EXECUTED, node 22.12.0, counting real `fs.writeFileSync` calls on a class declaring TWO
|
|
4813
|
+
// setters (`token`, `other`) and one data property (`plain`):
|
|
4814
|
+
// Reflect.set(s, k, v) k="token" 1 k="other" 1 k="plain" 0 ← at most ONE, never both
|
|
4815
|
+
// Object.assign(s, src) src={plain} 0 {plain,token} 1 {plain,token,other} 2
|
|
4816
|
+
// Reflect.set(s,"token",v) 1 Object.assign(s,{token:1}) 1 Object.assign(s,{plain:1}) 0
|
|
4817
|
+
// So the invoked set is an UNKNOWN SUBSET of the declared setters in both cases — including the empty
|
|
4818
|
+
// subset, which is the input the old charge fabricated on.
|
|
4819
|
+
//
|
|
4820
|
+
// THE PROVABLE BRANCH IS UNTOUCHED: a fresh object literal at the call site, or a string-literal
|
|
4821
|
+
// `Reflect.set` key, still resolves to the named setters and still propagates their effects through an
|
|
4822
|
+
// EDGE. Only `keys === null` changed. WITHDRAWAL PRICED FIRST, over 14,149 rows (1,623 TS-source +
|
|
4823
|
+
// 12,526 npm): 316 sites reach this branch and 0 rows lose an effect — every effect it charged is
|
|
4824
|
+
// either pure or already carried by another path.
|
|
4825
|
+
//
|
|
4826
|
+
// TWO REASON TAGS, deliberately not one. `Reflect.set` is the SAME mechanism as `s[k] = v` — one
|
|
4827
|
+
// runtime-chosen key, at most one setter — so it emits R240(b)'s own `reflect:accessor:dynamic-key`,
|
|
4828
|
+
// and that identity IS the convergence this row asked for. `Object.assign` is a different mechanism
|
|
4829
|
+
// with a different bound (an unprovable copied key SET; 0..n setters, measured 2 above), so it emits
|
|
4830
|
+
// `reflect:accessor:dynamic-keyset`. Both sit in the `reflect` class, so `deny Unknown[reflect]`
|
|
4831
|
+
// selects both and no policy has to know the difference.
|
|
4832
|
+
//
|
|
4833
|
+
// `keyType` is the type of the runtime key where there IS one (`Reflect.set`'s second argument), so the
|
|
4834
|
+
// two-way symbol/string denylist can rule out a provably-unreachable accessor exactly as R240(b) does.
|
|
4835
|
+
// `Object.assign` passes null — it copies own enumerable STRING **and SYMBOL** keys, so no accessor on
|
|
4836
|
+
// the target is provably out of reach and the guard must lift entirely.
|
|
4837
|
+
//
|
|
4838
|
+
// SOUNDNESS R251 — AND THE SAME FUNCTION ANSWERS THE **GET** SIDE, WHICH HAD NO ARM AT ALL.
|
|
4839
|
+
// `Reflect.get(t, k)` performs the ordinary [[Get]] — it runs whatever getter the property lookup finds
|
|
4840
|
+
// — and nothing in this file handled it, in ANY spelling. Not the unprovable-key case R240/R247 are
|
|
4841
|
+
// about: it was silent for the plain string-literal key too, so the whole builtin was missing.
|
|
4842
|
+
//
|
|
4843
|
+
// class Src { get token() { return fs.readFileSync("/etc/hosts", "utf8"); } }
|
|
4844
|
+
// export function go() { const s = new Src(); return Reflect.get(s, "token"); }
|
|
4845
|
+
// -> `go` ABSENT from `functions[]`; in a tree containing NOTHING else, `deny Fs src.only.go`
|
|
4846
|
+
// exit 0 AND `pure src.only.go` exit 0, both scopes binding (no unmatched-scope warning).
|
|
4847
|
+
//
|
|
4848
|
+
// This is `kind`-parameterised rather than copied, which is §G: R247 already merged the two spellings of
|
|
4849
|
+
// the SET question into one body after they drifted, and a private `enumerateTargetGetters` would be the
|
|
4850
|
+
// same mistake in the other direction. Only the accessor kind differs — the key-set logic, the
|
|
4851
|
+
// unprovable-key disclosure, the symbol/string denylist and the no-fabrication exit are identical.
|
|
4852
|
+
//
|
|
4853
|
+
// GROUND TRUTH EXECUTED, node 22.12.0, counting real getter invocations:
|
|
4854
|
+
// Reflect.get(o,"token") literal 1 class 1 ← BOTH, and the class arm is why there is no
|
|
4855
|
+
// Reflect.get(o,k) k="token" 1 1 `classBodiedGetter` exclusion below
|
|
4856
|
+
// Reflect.get(o,"other") 0 0 ← the provable-exclusion control
|
|
4857
|
+
// Reflect.has / ownKeys / deleteProperty / defineProperty / getOwnPropertyDescriptor / getPrototypeOf
|
|
4858
|
+
// 0 0 ← the whole rest of `Reflect.*`, swept, all zero
|
|
4859
|
+
// (`Reflect.apply`/`Reflect.construct` DO invoke user code — 1 each — and are already handled by the
|
|
4860
|
+
// reflective-invoke arm near `invokedRef`, not here.)
|
|
4861
|
+
function enumerateTargetAccessors(owner, targetExpr, keys, kind /* "get" | "set" */, unprovable = {}) {
|
|
4862
|
+
if (!owner || !targetExpr) return;
|
|
4863
|
+
const t = checker.getTypeAtLocation(targetExpr);
|
|
4864
|
+
if (!t || !t.getProperties) return;
|
|
4865
|
+
// `!keys`, not `keys === null`: a Set is always truthy, so this is exactly "the key set is not
|
|
4866
|
+
// provable" — and it keeps the tolerance the old `keys && !keys.has(…)` line had for a caller that
|
|
4867
|
+
// passes nothing. `keys.has` below is now unguarded, so a future third call site omitting the
|
|
4868
|
+
// argument would throw rather than take this branch.
|
|
4869
|
+
if (!keys) {
|
|
4870
|
+
for (const p of propertiesAcrossArms(t)) { // R259 — the disjunction, not the intersection
|
|
4871
|
+
if (!accessorsFromSym(p, kind).length) continue;
|
|
4872
|
+
if (!keyCouldNameAccessor(p, unprovable.keyType ?? null)) continue;
|
|
4873
|
+
const rec = fns.get(owner);
|
|
4874
|
+
rec.direct.add("Unknown");
|
|
4875
|
+
rec.why.add(`reflect:accessor:${unprovable.why ?? "dynamic-key"}`);
|
|
4876
|
+
return; // one disclosure per site — WHICH accessor runs is the thing not known
|
|
4877
|
+
}
|
|
4878
|
+
return; // a target declaring no reachable accessor discloses nothing (0 real invocations)
|
|
4879
|
+
}
|
|
4880
|
+
for (const p of propertiesAcrossArms(t)) { // R259 — the disjunction, not the intersection
|
|
4881
|
+
// NO `classBodiedGetter`-style exclusion here, and the asymmetry is the point rather than an
|
|
4882
|
+
// oversight: R115 excluded a class-bodied GETTER because a prototype accessor is non-enumerable and
|
|
4883
|
+
// therefore never COPIED. A prototype SETTER is the opposite — it is found by the assignment's
|
|
4884
|
+
// property lookup and IS invoked. Executed above: the `Sink` setter lives on `Sink.prototype` and
|
|
4885
|
+
// runs once. Excluding it here would be R115's reasoning applied to the direction it does not hold.
|
|
4886
|
+
// R251 — and the same holds for the GET side reached this way: `Reflect.get` is a property LOOKUP,
|
|
4887
|
+
// not a copy, so it finds a prototype getter too (executed: class 1). The exclusion belongs to
|
|
4888
|
+
// `enumerateGetters`, whose callers really are copies, and to nothing here.
|
|
4889
|
+
if (!keys.has(p.getName())) continue; // proven not touched by this operation
|
|
4890
|
+
for (const hit of accessorsFromSym(p, kind)) recordAccessorHit(owner, hit, p.getName(), targetExpr);
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4469
4894
|
// The synthesized `<module>` unit for a source file's TOP-LEVEL executable statements (spec §2
|
|
4470
4895
|
// unitKind "initializer" — java's `<clinit>` twin). Top-level `await fetch(…)`, a bare
|
|
4471
4896
|
// `readFileSync(…)`, an IIFE, `export const r = await fetch(…)` execute at MODULE-LOAD time and
|
|
@@ -4845,6 +5270,133 @@ const dispatchWhy = (qualifiedOwner, member) =>
|
|
|
4845
5270
|
qualifiedOwner && member ? `dispatch:${qualifiedOwner}.${member}`
|
|
4846
5271
|
: `callback:${qualifiedOwner ?? member ?? "unresolved call"}`;
|
|
4847
5272
|
|
|
5273
|
+
// SOUNDNESS R284 — THE OWNER A MEMBER HAS AND NOBODY WENT LOOKING FOR, plus the one site that formed an
|
|
5274
|
+
// owner it could not qualify. `dispatchWhy` above decides §4's class from whether an owner STRING could
|
|
5275
|
+
// be formed, so every producer that fails to form one silently demotes a member dispatch to `callback:`
|
|
5276
|
+
// and moves its §6.2 class from `dispatch` to `indirect` — narrowing every `deny E Unknown[dispatch]`
|
|
5277
|
+
// gate in the field, which SPEC §4 ⟨0.24⟩ names and rejects in terms.
|
|
5278
|
+
//
|
|
5279
|
+
// The producing arm read `sigDecl.parent?.name`, which an `InterfaceDeclaration` has and a `TypeLiteral`
|
|
5280
|
+
// NEVER does. MEASURED, one program, at 9a0cfd9 — a pure spelling difference in TypeScript:
|
|
5281
|
+
//
|
|
5282
|
+
// interface Shape { m(): void } x.m() -> dispatch:src.a.Shape.m [dispatch] RED
|
|
5283
|
+
// type Shape = { m(): void }; x.m() -> callback:m [dispatch] —, [indirect] RED
|
|
5284
|
+
//
|
|
5285
|
+
// AND THE BOUNDARY OF THE ROW THAT FILED IT WAS DRAWN AROUND ITS OWN TRIGGER — one arm, the one in hand.
|
|
5286
|
+
// Grepping the MECHANISM ("a site that forms a dispatch:/callback: owner") rather than the name found
|
|
5287
|
+
// two more, both measured on executing fixtures:
|
|
5288
|
+
//
|
|
5289
|
+
// · the >12-override family arm emits an UNQUALIFIED `dispatch:Shape.m` where every other site emits
|
|
5290
|
+
// `mod.Owner.member`. The consumer's `^dispatch:(.+)\.([^.]+)$` then yields owner `Shape`, which
|
|
5291
|
+
// matches no `declaringType` qual, so the dispatch frontier can never resolve it. The comment at the
|
|
5292
|
+
// producing arm asserts "the other emission sites produced none" of the 1,234 malformed strings — a
|
|
5293
|
+
// §K sentence: true of the corpus it was measured on, false of the code. A 14-subclass fixture
|
|
5294
|
+
// produces one on demand.
|
|
5295
|
+
// · `reflect:accessor:` took its owner from the same `parent?.name`, with `?? "?"` as the fallback, so
|
|
5296
|
+
// a type-alias-declared accessor discloses `reflect:accessor:?.val` where the interface spelling
|
|
5297
|
+
// discloses `reflect:accessor:Shape.val`. The class does not move (both are `reflect`), but `?` is
|
|
5298
|
+
// not an owner anything can scope to.
|
|
5299
|
+
//
|
|
5300
|
+
// WHAT THIS DOES **NOT** CLAIM: a fully anonymous inline literal (`function f(x: { m(): void })`) still
|
|
5301
|
+
// has no owner to name and stays `callback:`, because SPEC §4 reserves `dispatch:` for an owner type AND
|
|
5302
|
+
// member that are BOTH known. That residual is stated, not closed — it is the open list, and a nested
|
|
5303
|
+
// literal names the alias that declares the shape it sits in rather than inventing a path.
|
|
5304
|
+
const NAMED_TYPE_OWNER = (d) => ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)
|
|
5305
|
+
|| ts.isClassExpression(d) || ts.isTypeAliasDeclaration(d) || ts.isEnumDeclaration(d);
|
|
5306
|
+
// SOUNDNESS R355 — THE WALK MUST STOP AT A VALUE BOUNDARY, or it names a type that does not declare
|
|
5307
|
+
// the member. R284 added this ancestor walk so a member of an anonymous type LITERAL could still name
|
|
5308
|
+
// the alias that declares it; it climbs every parent until it finds a named declaration, and nothing
|
|
5309
|
+
// stopped it leaving the type position. Four shapes measured wrong on real code:
|
|
5310
|
+
//
|
|
5311
|
+
// literal in a method BODY eslint 9.x `SourceCode.traverse` -> dispatch:….SourceCode.enterNode
|
|
5312
|
+
// (`enterNode` is a method of a literal declared inside the method;
|
|
5313
|
+
// SourceCode declares no such member)
|
|
5314
|
+
// literal in a class FIELD zx `ProcessPromise.bus` -> dispatch:….ProcessPromise.unpipe, which
|
|
5315
|
+
// names a REAL but DIFFERENT method of that class
|
|
5316
|
+
// TYPE-PARAMETER constraint `class A<T extends {m(): void}>` -> dispatch:….A.m — the class named
|
|
5317
|
+
// as owner of its own constraint's member
|
|
5318
|
+
// inline PROPERTY type `cb!: { m(): void }` -> dispatch:….Holder.m
|
|
5319
|
+
//
|
|
5320
|
+
// SPEC §4 makes the dotted `dispatch:<owner-type>.<member>` detail NORMATIVE, so a phantom owner is a
|
|
5321
|
+
// wrong normative field, and `callers --include-unknown` builds frontier edges from it. It is
|
|
5322
|
+
// over-approximate rather than silent — never a cardinal sin — but 0.35.0 ships these rows as
|
|
5323
|
+
// `callback:`, so publishing the phantom and fixing it later would flip `deny Unknown[dispatch]` twice
|
|
5324
|
+
// on identical bytes.
|
|
5325
|
+
//
|
|
5326
|
+
// A DENYLIST OF VALUE-POSITION BOUNDARIES, not an allowlist of permitted ancestors: a node kind nobody
|
|
5327
|
+
// foresaw keeps climbing and over-fires visibly, rather than silently demoting a real owner to
|
|
5328
|
+
// `callback:`. Say which direction it fails in — this one fails loud.
|
|
5329
|
+
// SOUNDNESS R367 — RETIRED, AND KEPT ONLY AS THE RECORD OF WHY. This denylist was the syntactic proxy
|
|
5330
|
+
// for "has the walk left the type position", and it was wrong in BOTH directions: too narrow at R355
|
|
5331
|
+
// (it missed the interface/type-alias property spelling, which R359 then added), and once wide enough
|
|
5332
|
+
// to catch that, too broad — `ts.isTypeReferenceNode` demoted `type RO = Readonly<{ m(): void }>`,
|
|
5333
|
+
// which genuinely has `m`, turning a firing `deny Unknown[dispatch]` green on a real owner (R363).
|
|
5334
|
+
// Three commits chasing one question with the wrong instrument.
|
|
5335
|
+
//
|
|
5336
|
+
// `ownerDeclaresMember` now asks the checker directly and answers all six shapes correctly, including
|
|
5337
|
+
// the four this list was written for. The list is no longer consulted; it is left here, unused, for
|
|
5338
|
+
// one release so the next reader meets the reasoning rather than the deletion. THE GENERAL POINT is
|
|
5339
|
+
// in `candor-handlist-vein`: an engine WITH a type checker should not hand-maintain what the checker
|
|
5340
|
+
// derives — that instrument belongs to candor-rust and candor-swift, which have no checker.
|
|
5341
|
+
// (the list itself is deleted — `npm test`'s lint is right that a retired binding is dead code;
|
|
5342
|
+
// the shapes it covered are in SOUNDNESS R355/R359/R363/R367, which is where they belong.)
|
|
5343
|
+
/** The nearest ancestor declaration that NAMES the type this member belongs to, or null. */
|
|
5344
|
+
const namedTypeAncestor = (node) => {
|
|
5345
|
+
for (let n = node?.parent, guard = 0; n && guard++ < 32; n = n.parent) {
|
|
5346
|
+
if (ts.isSourceFile(n)) return null;
|
|
5347
|
+
if (NAMED_TYPE_OWNER(n) && n.name) return n;
|
|
5348
|
+
}
|
|
5349
|
+
return null;
|
|
5350
|
+
};
|
|
5351
|
+
/** `<module>.<namespace prefix><Name>` — the spelling `mod.Class.member` quals use, so the dispatch
|
|
5352
|
+
* frontier can resolve it against the hierarchy sidecar. A bare name cannot be resolved by anything. */
|
|
5353
|
+
const qualifiedTypeName = (d) => (d?.name
|
|
5354
|
+
? `${moduleOf(d.getSourceFile())}.${namespacePrefixOf(d)}${d.name.getText()}` : null);
|
|
5355
|
+
/** SOUNDNESS R367 — ASK THE CHECKER WHETHER THE CANDIDATE OWNER ACTUALLY HAS THE MEMBER.
|
|
5356
|
+
*
|
|
5357
|
+
* R284 walked up to the nearest named declaration and named it. R355 and R359 then tried to fix the
|
|
5358
|
+
* cases where that walk leaves the type position by enumerating NODE KINDS to stop at — a hand-
|
|
5359
|
+
* maintained syntactic list, which is the instrument the two engines WITHOUT a type checker are
|
|
5360
|
+
* forced to use. candor-ts has a checker. Enumerating positions here was answering a semantic
|
|
5361
|
+
* question with a syntactic proxy, and the proxy was wrong in both directions: it missed
|
|
5362
|
+
* `interface I { cb: { m(): void } }` (R359) and, once widened enough to catch that, it demoted
|
|
5363
|
+
* `type RO = Readonly<{ m(): void }>`, which genuinely HAS `m` — turning a firing
|
|
5364
|
+
* `deny Unknown[dispatch]` green on a real owner (R363).
|
|
5365
|
+
*
|
|
5366
|
+
* The question is not "what syntax is this literal sitting in". It is "does the type this
|
|
5367
|
+
* declaration declares have a property by this name". That is one checker call and it is exact:
|
|
5368
|
+
* type Named = { m(): void } -> has m -> owner kept
|
|
5369
|
+
* type RO = Readonly<{ m(): void }> -> has m -> owner kept (the mapped type maps it)
|
|
5370
|
+
* type Id<T>=T; type IdLit = Id<{m()}> -> has m -> owner kept
|
|
5371
|
+
* type ListOf = Array<{ m(): void }> -> no m -> no owner
|
|
5372
|
+
* interface I { cb: { m(): void } } -> no m -> no owner
|
|
5373
|
+
* class C { go(){ const a={m(){}}; a.m() }}-> no m -> no owner
|
|
5374
|
+
*
|
|
5375
|
+
* FAILS LOUD BY CONSTRUCTION. Any uncertainty — no symbol, no declared type, the checker throwing —
|
|
5376
|
+
* answers "yes, keep the owner", so an unanswerable case over-approximates visibly rather than
|
|
5377
|
+
* silently demoting a real owner to `callback:`. That is the direction R363 recorded as the one
|
|
5378
|
+
* nothing warns about. */
|
|
5379
|
+
const ownerDeclaresMember = (ownerDecl, memberName) => {
|
|
5380
|
+
if (!ownerDecl || !memberName) return true;
|
|
5381
|
+
try {
|
|
5382
|
+
const sym = ownerDecl.name && checker.getSymbolAtLocation(ownerDecl.name);
|
|
5383
|
+
if (!sym) return true;
|
|
5384
|
+
const t = checker.getDeclaredTypeOfSymbol(sym);
|
|
5385
|
+
if (!t) return true;
|
|
5386
|
+
return !!checker.getPropertyOfType(t, memberName);
|
|
5387
|
+
} catch { return true; }
|
|
5388
|
+
};
|
|
5389
|
+
|
|
5390
|
+
/** The owner qual for a member whose immediate parent may be an ANONYMOUS type literal. Falls back to
|
|
5391
|
+
* the nearest NAMED type declaration, VERIFIED to have the member; null when there genuinely is not
|
|
5392
|
+
* one, or when the one found does not declare it. */
|
|
5393
|
+
const memberOwnerQual = (member) => {
|
|
5394
|
+
if (member?.parent?.name) return qualifiedTypeName(member.parent);
|
|
5395
|
+
const anc = namedTypeAncestor(member);
|
|
5396
|
+
const name = member?.name?.getText?.();
|
|
5397
|
+
return ownerDeclaresMember(anc, name) ? qualifiedTypeName(anc) : null;
|
|
5398
|
+
};
|
|
5399
|
+
|
|
4848
5400
|
// ⟨THE FUNNEL⟩ Every site that reaches a resolved EXTERNAL declaration whose own κ lookup found nothing
|
|
4849
5401
|
// answers the SAME question — chained sibling report, §5.1 manifest, κ-coverage ledger, or the
|
|
4850
5402
|
// unanswerable-key disclosure — and it used to answer it up to four times over, independently, in the
|
|
@@ -5554,6 +6106,49 @@ const identIsEnvMayAlias = (id) => {
|
|
|
5554
6106
|
return !!sym && envMayAliasSymbols.has(sym);
|
|
5555
6107
|
};
|
|
5556
6108
|
|
|
6109
|
+
// R113 — WEB STORAGE, IDENTIFIED FROM THE RECEIVER'S TYPE rather than from the member's declaration.
|
|
6110
|
+
// R109 charged `localStorage.setItem(k, v)` by keying on the resolved MEMBER — `decl.parent.name ===
|
|
6111
|
+
// "Storage"` in the es-lib arm. That reading can only see a member the interface DECLARES. `Storage` also
|
|
6112
|
+
// carries an INDEX SIGNATURE (`[name: string]: any` — in lib.dom AND in @types/node's
|
|
6113
|
+
// `web-globals/storage.d.ts`, identically), so `localStorage.x = secret` resolves to no declaration at
|
|
6114
|
+
// all, no accessor exists for the property arm to find, and the write was reported as NOTHING in BOTH
|
|
6115
|
+
// lib configurations.
|
|
6116
|
+
//
|
|
6117
|
+
// GROUND TRUTH IS EXECUTED, node 22.12.0 with `--experimental-webstorage --localstorage-file=./ls.db`:
|
|
6118
|
+
// process 1 runs `localStorage.x = "SECRET"; localStorage["tok"] = "SECRET"`, process 2 reads both back.
|
|
6119
|
+
// The index-signature write PERSISTS ACROSS PROCESSES, byte-identically to the `setItem` call that IS
|
|
6120
|
+
// charged. `debug` publishes `localStorage.debug = 'worker:*'` as its documented browser API and
|
|
6121
|
+
// `util-deprecate/browser.js` does `global.localStorage[name]`, so this is the spelling real code uses.
|
|
6122
|
+
//
|
|
6123
|
+
// R109's OWN COMMENT DISMISSED THIS ROW — "the `localStorage.x = v` INDEX-SIGNATURE spelling is untouched
|
|
6124
|
+
// and still pure — pure under @types/node too, so it is not part of this split." Every literal word was
|
|
6125
|
+
// true. The conclusion was not: both arms agreeing is not a safety property, it is the R111 failure mode.
|
|
6126
|
+
// Attack K, in the comment that made the previous fix look complete.
|
|
6127
|
+
//
|
|
6128
|
+
// KEYED ON THE RECEIVER'S TYPE SYMBOL, which is the identity the call arm already uses — one step
|
|
6129
|
+
// earlier in the same chain, so it reaches the members the interface never named. It also gets the alias
|
|
6130
|
+
// and parameter spellings for free: `const ls = localStorage; ls.x = v`, `window.localStorage.x = v`,
|
|
6131
|
+
// `sessionStorage.x = v` and `function f(s: Storage) { s.x = v }` all have a `Storage`-typed receiver and
|
|
6132
|
+
// no new branch. FABRICATION GUARD: the type symbol must have a declaration in `typescript/lib/lib.*.d.ts`
|
|
6133
|
+
// or under `@types/node/`, so a project's own `interface Storage` / `class Storage` is never charged
|
|
6134
|
+
// (measured, not argued — the shadow control in test.mjs).
|
|
6135
|
+
//
|
|
6136
|
+
// `some`, not `every`: this INCLUDES rather than excludes, so the conservative direction is to charge on
|
|
6137
|
+
// any host declaration. Under @types/node the symbol legitimately carries two declarations (the
|
|
6138
|
+
// module-local `interface Storage` and the `declare global { interface Storage extends _Storage {} }`
|
|
6139
|
+
// merge), and `every` would have to be right about both.
|
|
6140
|
+
const webStorageDeclFile = (d) => {
|
|
6141
|
+
const f = path.resolve(d.getSourceFile().fileName).replace(/\\/g, "/");
|
|
6142
|
+
return /typescript\/lib\/lib\..*\.d\.ts$/.test(f) || declIsNodeTypes(d);
|
|
6143
|
+
};
|
|
6144
|
+
const isWebStorageExpr = (expr) => {
|
|
6145
|
+
if (!expr) return false;
|
|
6146
|
+
let t; try { t = checker.getTypeAtLocation(expr); } catch { return false; }
|
|
6147
|
+
const sym = t && (t.symbol ?? t.aliasSymbol);
|
|
6148
|
+
if (!sym || sym.name !== "Storage") return false;
|
|
6149
|
+
return (sym.declarations ?? []).some(webStorageDeclFile);
|
|
6150
|
+
};
|
|
6151
|
+
|
|
5557
6152
|
// ---- whole-object process.env access via builtins/spread ------------------------------------------------
|
|
5558
6153
|
// `process.env.KEY` is caught above, but the WHOLE env object handed to a builtin that enumerates or mutates it
|
|
5559
6154
|
// is the same Env effect and read silent-pure: `Object.assign(process.env, o)` / `Object.defineProperty(...)` /
|
|
@@ -5574,16 +6169,104 @@ const ENV_TOUCHING_BUILTIN = new Set([
|
|
|
5574
6169
|
const ENV_TOUCHING_GLOBAL = new Set(["structuredClone"]);
|
|
5575
6170
|
const identIsGlobal = (id) => // an identifier that is the ambient global (no project-local declaration shadows it)
|
|
5576
6171
|
!(checker.getSymbolAtLocation(id)?.declarations ?? []).some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
6172
|
+
// THE `globalThis.` QUALIFIER, WHICH DEFEATED FIVE TEXT-KEYED ARMS AT ONCE (SOUNDNESS row id pending —
|
|
6173
|
+
// filed by the coordinator, not invented here). Every arm below
|
|
6174
|
+
// recognises a whole-object builtin by the callee's TEXT (`Object.assign`, `Reflect.set`, `Object.keys`),
|
|
6175
|
+
// and `globalThis.Object.assign(...)` is the same function under a different spelling. MEASURED, one
|
|
6176
|
+
// file, `tsc --noEmit` clean, each pair differing ONLY in the qualifier:
|
|
6177
|
+
//
|
|
6178
|
+
// Object.assign(sink, {token:1}) ["Fs"] globalThis.Object.assign(…) ABSENT
|
|
6179
|
+
// Reflect.set(sink, "token", 1) ["Fs"] globalThis.Reflect.set(…) ABSENT
|
|
6180
|
+
// Object.assign({}, literalWithGetter) ["Unknown"] globalThis.Object.assign(…) ABSENT
|
|
6181
|
+
// Object.keys(process.env) ["Env"] globalThis.Object.keys(env) ABSENT
|
|
6182
|
+
// Object.keys(localStorage) ["Unknown"] globalThis.Object.keys(ls) ABSENT
|
|
6183
|
+
//
|
|
6184
|
+
// Ground truth EXECUTED on node 22.12.0: `globalThis.Object.assign`, `globalThis.Reflect.set` and the
|
|
6185
|
+
// bare spellings each invoke the accessor exactly once; `globalThis.Object.keys(process.env)` reads the
|
|
6186
|
+
// whole environment. Five arms, one spelling, and the `Env` one is a whole-environment read reported as
|
|
6187
|
+
// nothing. This is R95's `globalThis.fetch` class, one builtin family over.
|
|
6188
|
+
//
|
|
6189
|
+
// RETURNS THE CANONICAL `Owner.member` TEXT for a call whose callee is a member of an ambient global
|
|
6190
|
+
// builtin, reached BARE or through a proven-global `globalThis`/`global`/`window`/`self` root (parens,
|
|
6191
|
+
// `as` casts and `!` unwrapped, as `identIsGlobalProcess` already does for the process object). Returns
|
|
6192
|
+
// null for anything else, so a project's own `Object` — bare or hung off a project-shadowed root — is
|
|
6193
|
+
// never matched. It can only make an existing text test recognise MORE spellings of the same function;
|
|
6194
|
+
// it cannot make one stop matching.
|
|
6195
|
+
const GLOBAL_ROOTS = new Set(["globalThis", "global", "window", "self"]);
|
|
6196
|
+
const globalBuiltinCallee = (callee) => {
|
|
6197
|
+
if (!callee || !ts.isPropertyAccessExpression(callee)) return null;
|
|
6198
|
+
const member = callee.name?.text;
|
|
6199
|
+
if (!member) return null;
|
|
6200
|
+
let owner = callee.expression;
|
|
6201
|
+
while (owner && (ts.isParenthesizedExpression(owner) || ts.isAsExpression(owner)
|
|
6202
|
+
|| ts.isNonNullExpression(owner))) owner = owner.expression;
|
|
6203
|
+
if (ts.isIdentifier(owner)) return identIsGlobal(owner) ? `${owner.text}.${member}` : null;
|
|
6204
|
+
if (!ts.isPropertyAccessExpression(owner) || !owner.name?.text) return null;
|
|
6205
|
+
let root = owner.expression;
|
|
6206
|
+
while (root && (ts.isParenthesizedExpression(root) || ts.isAsExpression(root)
|
|
6207
|
+
|| ts.isNonNullExpression(root))) root = root.expression;
|
|
6208
|
+
if (!ts.isIdentifier(root) || !GLOBAL_ROOTS.has(root.text) || !identIsGlobal(root)) return null;
|
|
6209
|
+
return `${owner.name.text}.${member}`;
|
|
6210
|
+
};
|
|
6211
|
+
// SOUNDNESS R281 — THE SAME QUESTION FOR A **BARE** GLOBAL, AND THE TABLE THE QUALIFIER FIX DID NOT REACH.
|
|
6212
|
+
// `globalBuiltinCallee` above canonicalises a MEMBER of a global object (`globalThis.Object.assign` ->
|
|
6213
|
+
// `Object.assign`). `structuredClone` is not a member of anything — it is a bare global — so it lives in
|
|
6214
|
+
// its own table (`ENV_TOUCHING_GLOBAL`) whose two consumers both tested `ts.isIdentifier(callee)`, and a
|
|
6215
|
+
// qualified spelling is a PropertyAccess. `f58dc0f` fixed FIVE member-keyed arms by routing them through
|
|
6216
|
+
// the helper above and left this one; `9a0cfd9` (R252) then added a NEW consumer of the same table with
|
|
6217
|
+
// the same identifier test, so it was born with the hole its sibling commit had just closed.
|
|
6218
|
+
//
|
|
6219
|
+
// MEASURED at 9a0cfd9, three isolated trees each containing exactly one function, `tsc --noEmit` clean:
|
|
6220
|
+
//
|
|
6221
|
+
// structuredClone(process.env) functions[] = [["src.only.leak",["Env"]]] deny Env -> 1
|
|
6222
|
+
// globalThis.structuredClone(process.env) functions[] = [] deny Env -> 0
|
|
6223
|
+
// window.structuredClone(process.env) functions[] = [] deny Env -> 0
|
|
6224
|
+
// self.structuredClone(process.env) functions[] = [] deny Env -> 0
|
|
6225
|
+
//
|
|
6226
|
+
// and `deny Unknown`, `deny Env Unknown`, `pure src.only.leak` and `deny Env src.only.leak` ALL exit 0 on
|
|
6227
|
+
// the three qualified spellings, with the scoped rule BINDING (a bogus name prints `matched NO function`;
|
|
6228
|
+
// these do not). No incidental catch anywhere — a whole-environment read reported as nothing at all.
|
|
6229
|
+
// EXECUTED on node 22.12.0 with `window`/`self` bound to `globalThis` as a browser/worker binds them:
|
|
6230
|
+
// each spelling really clones all 66 variables, a planted `CANDOR_SECRET` among them.
|
|
6231
|
+
//
|
|
6232
|
+
// `window.`/`self.` were asserted "identical BY CONSTRUCTION" in the row that filed this; they are not
|
|
6233
|
+
// asserted here, they are the two rows above, run.
|
|
6234
|
+
//
|
|
6235
|
+
// Returns the BARE global name a callee resolves to, or null. Shadow-guarded by the same `identIsGlobal`
|
|
6236
|
+
// the member helper uses, on the identifier that actually decides: the bare callee itself, or the
|
|
6237
|
+
// `globalThis`/`global`/`window`/`self` root. It can only make an existing text test recognise MORE
|
|
6238
|
+
// spellings of the same function; a project's own `structuredClone`, bare or hung off a shadowed root,
|
|
6239
|
+
// still matches nothing.
|
|
6240
|
+
const globalBareCallee = (callee) => {
|
|
6241
|
+
if (!callee) return null;
|
|
6242
|
+
if (ts.isIdentifier(callee)) return identIsGlobal(callee) ? callee.text : null;
|
|
6243
|
+
if (!ts.isPropertyAccessExpression(callee) || !callee.name?.text) return null;
|
|
6244
|
+
let root = callee.expression;
|
|
6245
|
+
while (root && (ts.isParenthesizedExpression(root) || ts.isAsExpression(root)
|
|
6246
|
+
|| ts.isNonNullExpression(root))) root = root.expression;
|
|
6247
|
+
// ONLY a global ROOT, never an arbitrary owner: `Object.assign` must not read as the bare global
|
|
6248
|
+
// `assign`, and `globalThis.Object.assign` must not read as the bare global `Object` — both are the
|
|
6249
|
+
// member helper's business, and answering them here would be the second copy §G exists to prevent.
|
|
6250
|
+
return ts.isIdentifier(root) && GLOBAL_ROOTS.has(root.text) && identIsGlobal(root) ? callee.name.text : null;
|
|
6251
|
+
};
|
|
5577
6252
|
// True when `node` is a call to a global builtin that reads/writes every key of an object argument (so any
|
|
5578
6253
|
// env-object argument makes the enclosing fn Env): `Object.*`/`Reflect.*`/`JSON.stringify` (member) or
|
|
5579
6254
|
// `structuredClone` (bare). Guarded against a project-local shadow of the callee.
|
|
5580
6255
|
const envTouchingBuiltinCall = (node) => {
|
|
5581
6256
|
if (!ts.isCallExpression(node)) return false;
|
|
5582
6257
|
const c = node.expression;
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
6258
|
+
// THE `globalThis.` QUALIFIER (SOUNDNESS row id pending — filed by the coordinator) — through
|
|
6259
|
+
// `globalBuiltinCallee`, so `globalThis.Object.keys(process.env)` is the same call
|
|
6260
|
+
// as `Object.keys(process.env)`. The old test required `c.expression` to be an IDENTIFIER, so the
|
|
6261
|
+
// qualified spelling produced `"undefined.keys"` and read false: a whole-environment read reported as
|
|
6262
|
+
// nothing. The shadow guard moves INTO the helper (it still checks `identIsGlobal` on the root), so
|
|
6263
|
+
// this is not a widening of what counts as global, only of how it may be spelled.
|
|
6264
|
+
if (ts.isPropertyAccessExpression(c) && ENV_TOUCHING_BUILTIN.has(globalBuiltinCallee(c) ?? "")) return true;
|
|
6265
|
+
// R281 — the bare-global table, through `globalBareCallee`, so `globalThis.structuredClone(process.env)`
|
|
6266
|
+
// is the same call as `structuredClone(process.env)`. The old test required an IDENTIFIER callee, so
|
|
6267
|
+
// every qualified spelling read false: a whole-environment read reported as nothing. NOT a widening of
|
|
6268
|
+
// what counts as global — the helper still asks `identIsGlobal` — only of how it may be spelled.
|
|
6269
|
+
return ENV_TOUCHING_GLOBAL.has(globalBareCallee(c) ?? "");
|
|
5587
6270
|
};
|
|
5588
6271
|
|
|
5589
6272
|
/** ⟨R95⟩ Does this CALL reach the host's `fetch`, whatever the callee is spelled as?
|
|
@@ -6083,7 +6766,9 @@ function visitCalls(node) {
|
|
|
6083
6766
|
}
|
|
6084
6767
|
} else {
|
|
6085
6768
|
rec.direct.add("Unknown"); // override family too wide to enumerate soundly
|
|
6086
|
-
|
|
6769
|
+
// R284 — QUALIFIED, like its <=12 sibling four lines up and like every other emission
|
|
6770
|
+
// site. This one alone emitted a bare `dispatch:Shape.m`, which no frontier resolves.
|
|
6771
|
+
rec.why.add(dispatchWhy(memberOwnerQual(decl), decl.name?.getText?.())); // class-override dispatch (overridable member, unresolved/too-wide family) — canonical `dispatch:OWNER.member`, frontier-relevant
|
|
6087
6772
|
}
|
|
6088
6773
|
}
|
|
6089
6774
|
}
|
|
@@ -6181,13 +6866,23 @@ function visitCalls(node) {
|
|
|
6181
6866
|
// QUALIFIED owner (module.Type), matching the `mod.Class.member` fn quals so the
|
|
6182
6867
|
// dispatch-frontier (callers --include-unknown) can resolve overrides against the
|
|
6183
6868
|
// hierarchy sidecar. Bare `decl.parent.name` would not match a reacher's declaringType.
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6869
|
+
// R284 — `memberOwnerQual`, not `parent?.name`: a MethodSignature in a `type X = {...}`
|
|
6870
|
+
// has an owner (`X`) that only the ancestor walk can see, and demoting it to `callback:`
|
|
6871
|
+
// moved its §6.2 class out of `dispatch`. Same helper as the class-override arm, so the
|
|
6872
|
+
// two cannot answer this differently again.
|
|
6873
|
+
const tn = memberOwnerQual(sigDecl);
|
|
6187
6874
|
// A CALL SIGNATURE has no member to name (`interface UnaryFunction { (x: T): R }`,
|
|
6188
|
-
// `type PatchFn = (a, b) => void`)
|
|
6189
|
-
//
|
|
6190
|
-
//
|
|
6875
|
+
// `type PatchFn = (a, b) => void`) — a function-VALUE invocation, not member dispatch.
|
|
6876
|
+
//
|
|
6877
|
+
// THE SECOND HALF OF THIS SENTENCE WAS FALSE FROM R284 UNTIL R359, and R355's commit
|
|
6878
|
+
// message claimed to have corrected it while changing nothing here. It read "…and a
|
|
6879
|
+
// member of an ANONYMOUS type literal has no owner to name". R284 gave such a member an
|
|
6880
|
+
// owner deliberately — the named declaration that DECLARES the literal — which is right
|
|
6881
|
+
// for `type Named = { m(): void }` and wrong the moment the walk leaves the type
|
|
6882
|
+
// position. The rule now is: a literal in TYPE position takes the name of the
|
|
6883
|
+
// declaration that declares it; a literal in VALUE position, or nested behind a
|
|
6884
|
+
// property signature or a type argument, has no owner and stays `callback:`. See
|
|
6885
|
+
// `VALUE_POSITION_BOUNDARY` and `dispatchWhy`. This is where all 1,234 malformed strings measured on a 15-repo corpus
|
|
6191
6886
|
// came from; the other emission sites produced none.
|
|
6192
6887
|
rec.why.add(dispatchWhy(tn, sigDecl.name?.getText?.())); // resolution landed on a type, not a body — canonical `dispatch:OWNER.member` (frontier-relevant)
|
|
6193
6888
|
}
|
|
@@ -6233,8 +6928,19 @@ function visitCalls(node) {
|
|
|
6233
6928
|
|| (parent === "Console" && CLOCK_READING_CONSOLE_MEMBERS.test(name)))
|
|
6234
6929
|
rec.direct.add("Clock");
|
|
6235
6930
|
if (parent === "Math" && name === "random") rec.direct.add("Rand");
|
|
6931
|
+
// R136 — …AND THROUGH A SUBCLASS. `checker.getTypeAtLocation(node.expression)` names the
|
|
6932
|
+
// CALL-SITE expression's type, so `class MyDate extends Date {}` + `new MyDate()` reads
|
|
6933
|
+
// "MyDate" and the clock read vanished — silently, with no `Unknown` and no `invisible`.
|
|
6934
|
+
// The authority is the same one the connecting-ctor arm below now uses: the DECLARATION the
|
|
6935
|
+
// constructor resolved to, which for an inherited implicit constructor is the BASE's construct
|
|
6936
|
+
// signature, here `interface DateConstructor`. ADDITIVE (an `||`): a construction that named
|
|
6937
|
+
// itself before still fires, so this can only add a Clock, never remove one. Ground truth
|
|
6938
|
+
// EXECUTED on node 22.12.0 — `new MyDate()` and a two-level `class Deep extends MyDate {}`
|
|
6939
|
+
// both return the current time; `new MyDate(0)` returns the epoch and is correctly NOT charged,
|
|
6940
|
+
// because the zero-argument test is unchanged.
|
|
6236
6941
|
if (ts.isNewExpression(node) && (node.arguments ?? []).length === 0
|
|
6237
|
-
&& checker.getTypeAtLocation(node.expression)?.symbol?.name === "DateConstructor"
|
|
6942
|
+
&& (checker.getTypeAtLocation(node.expression)?.symbol?.name === "DateConstructor"
|
|
6943
|
+
|| declaredCtorClassName(decl) === "DateConstructor"))
|
|
6238
6944
|
rec.direct.add("Clock");
|
|
6239
6945
|
// Browser/runtime NETWORK globals declared in lib.dom — no importable module for the κ table to
|
|
6240
6946
|
// key on, so they read SILENT-PURE. `XMLHttpRequest.send`/`.open` issue the HTTP request; the
|
|
@@ -6379,8 +7085,26 @@ function visitCalls(node) {
|
|
|
6379
7085
|
// …through an alias too: `const W = WebSocket; new W(url)` reads a ctor named "W".
|
|
6380
7086
|
// Same defect as the call path, one node type over — hence the SHARED unwrap.
|
|
6381
7087
|
const unCtor = unaliasGlobal(node.expression);
|
|
6382
|
-
const
|
|
7088
|
+
const ctorSiteName = unCtor.node.getText();
|
|
6383
7089
|
if (unCtor.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
|
|
7090
|
+
// R136 — …AND THROUGH A SUBCLASS, which neither the call-site name nor the alias unwrap can
|
|
7091
|
+
// see. `class Y3 extends WebSocket {}` declares no constructor, so `new Y3(u)` resolves to
|
|
7092
|
+
// the BASE's construct signature and the identifier at the call site is "Y3" — no rule
|
|
7093
|
+
// matches and a construction that opens a real socket reported NOTHING: absent from
|
|
7094
|
+
// `functions[]`, no `invisible`, no `Unknown`, and `deny Net` went exit 1 -> exit 0. This is
|
|
7095
|
+
// R130's own `declaredCtorClassName` walk, which landed on the κ arm's `ctorRuleName` and on
|
|
7096
|
+
// the `super(…)` branch six lines up but NOT here, so the fix was invisible to any test that
|
|
7097
|
+
// exercised the lib.dom path: under `types: ["node"]` the identical source read `['Net']`.
|
|
7098
|
+
// Same discipline as `ctorRuleName`: consulted ONLY when the call-site name is not already a
|
|
7099
|
+
// connecting ctor, and its answer used ONLY when it IS one — so it can add a name to the
|
|
7100
|
+
// connecting set and can never take one away. `class Y1 extends Headers {}` (declared name
|
|
7101
|
+
// "Headers", no rule) stays absent, measured. Ground truth EXECUTED on node 22.12.0: the
|
|
7102
|
+
// implicit-constructor subclass fires one real upgrade handshake against an
|
|
7103
|
+
// `http.createServer` listener, and so does a two-level `class ZZ extends Y3 {}`.
|
|
7104
|
+
const ctorName = CONNECTING_WEB_CTORS.test(ctorSiteName)
|
|
7105
|
+
? ctorSiteName
|
|
7106
|
+
: (CONNECTING_WEB_CTORS.test(declaredCtorClassName(decl))
|
|
7107
|
+
? declaredCtorClassName(decl) : ctorSiteName);
|
|
6384
7108
|
// R130 — the SHARED constant, not a literal pair repeated here. The identical two names are
|
|
6385
7109
|
// now κ rules for `undici-types` (the package `@types/node` re-exports these globals from),
|
|
6386
7110
|
// and this arm and that table answering the same question from two hand-kept lists is the
|
|
@@ -6857,6 +7581,54 @@ function visitCalls(node) {
|
|
|
6857
7581
|
markEnv();
|
|
6858
7582
|
}
|
|
6859
7583
|
}
|
|
7584
|
+
// R113 — WEB STORAGE, THE WHOLE INTERFACE, through the same six shapes the `process.env` block above
|
|
7585
|
+
// enumerates. That block is the AUTHORITY for "a host object touched as a whole", not a template to
|
|
7586
|
+
// paraphrase (§G): the question — which spellings reach a foreign key/value store — has one answer, and
|
|
7587
|
+
// writing a second, shorter list is how the next spelling gets missed. The shapes are dot/bracket access
|
|
7588
|
+
// (literal OR runtime key), destructuring, the `in` test, spread, a key-enumerating builtin, and for-in.
|
|
7589
|
+
//
|
|
7590
|
+
// `Unknown`, not `Fs`: the backing store is not modelled (a browser's is not a filesystem; node's
|
|
7591
|
+
// `--localstorage-file` one is). That is R109's answer verbatim, and the point is that the two lib
|
|
7592
|
+
// configurations converge on it. The reason names the interface actually resolved, so it gates as
|
|
7593
|
+
// `Unknown[native]` exactly like the `setItem` call.
|
|
7594
|
+
//
|
|
7595
|
+
// THE WHOLE INTERFACE, NOT A VERB LIST — the denylist direction, R109's argument transferring verbatim.
|
|
7596
|
+
// Reads persist across sessions and origins just as writes do, `length`/`key(i)` expose the stored key
|
|
7597
|
+
// set, and an unlisted member would be silently pure. Over-charging a `Storage` receiver is a precision
|
|
7598
|
+
// cost bounded to code that already touches web storage; under-charging one is the cardinal sin.
|
|
7599
|
+
{
|
|
7600
|
+
const markStore = (label) => {
|
|
7601
|
+
const owner = enclosing(node);
|
|
7602
|
+
if (!owner) return;
|
|
7603
|
+
const r = fns.get(owner);
|
|
7604
|
+
r.direct.add("Unknown");
|
|
7605
|
+
r.why.add(`native:Storage.${label}`);
|
|
7606
|
+
};
|
|
7607
|
+
const memberLabel = (n) => (ts.isPropertyAccessExpression(n) ? (n.name?.getText?.() ?? "?")
|
|
7608
|
+
: (n.argumentExpression && ts.isStringLiteralLike(n.argumentExpression)
|
|
7609
|
+
? n.argumentExpression.text : "[computed]"));
|
|
7610
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node))
|
|
7611
|
+
&& isWebStorageExpr(node.expression)) {
|
|
7612
|
+
markStore(memberLabel(node));
|
|
7613
|
+
}
|
|
7614
|
+
else if (ts.isVariableDeclaration(node) && node.name && ts.isObjectBindingPattern(node.name)
|
|
7615
|
+
&& node.initializer && isWebStorageExpr(node.initializer)) {
|
|
7616
|
+
markStore("<destructure>");
|
|
7617
|
+
}
|
|
7618
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword
|
|
7619
|
+
&& isWebStorageExpr(node.right)) {
|
|
7620
|
+
markStore("<in>");
|
|
7621
|
+
}
|
|
7622
|
+
else if ((ts.isSpreadAssignment(node) || ts.isSpreadElement(node)) && isWebStorageExpr(node.expression)) {
|
|
7623
|
+
markStore("<spread>");
|
|
7624
|
+
}
|
|
7625
|
+
else if (envTouchingBuiltinCall(node) && node.arguments.some((a) => isWebStorageExpr(a))) {
|
|
7626
|
+
markStore("<enumerate>");
|
|
7627
|
+
}
|
|
7628
|
+
else if (ts.isForInStatement(node) && isWebStorageExpr(node.expression)) {
|
|
7629
|
+
markStore("<for-in>");
|
|
7630
|
+
}
|
|
7631
|
+
}
|
|
6860
7632
|
// Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
|
|
6861
7633
|
// `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
|
|
6862
7634
|
// channel (Ipc); the global `fetch(...)` is the standard modern HTTP client (Net). Matched on the
|
|
@@ -7118,11 +7890,114 @@ function visitCalls(node) {
|
|
|
7118
7890
|
}
|
|
7119
7891
|
// Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
|
|
7120
7892
|
// getters (the object-spread twin). Enumerate the sources' local getters.
|
|
7121
|
-
|
|
7893
|
+
//
|
|
7894
|
+
// R116 — …AND THE TARGET'S SETTERS, which the copy invokes for every key it writes. See
|
|
7895
|
+
// `enumerateTargetSetters`: the source arm and the target arm are the two halves of one operation
|
|
7896
|
+
// and only one of them existed.
|
|
7897
|
+
// THE `globalThis.` QUALIFIER (SOUNDNESS row id pending — filed by the coordinator) — the SAME
|
|
7898
|
+
// text, recognised through the qualifier too. `globalBuiltinCallee`
|
|
7899
|
+
// is the one authority for that question (`envTouchingBuiltinCall` reads it as well); a second
|
|
7900
|
+
// private copy is how the next spelling gets missed.
|
|
7901
|
+
const ct116 = globalBuiltinCallee(callee) ?? callee.getText().replace(/\s+/g, "");
|
|
7902
|
+
if (ct116 === "Object.assign") {
|
|
7122
7903
|
const owner = enclosing(node);
|
|
7123
|
-
|
|
7904
|
+
const sources = (node.arguments ?? []).slice(1);
|
|
7905
|
+
for (const src of sources) {
|
|
7124
7906
|
enumerateGetters(owner, checker.getTypeAtLocation(src), src);
|
|
7125
7907
|
}
|
|
7908
|
+
// SHADOW-GUARDED, like the `Reflect.set` arm below. The getter line above is NOT — it matches
|
|
7909
|
+
// `Object.assign` by TEXT, so a project's own `const Object = { assign(){} }` reaches it. That is
|
|
7910
|
+
// pre-existing and is REPORTED rather than silently widened here (it is a fabrication-direction
|
|
7911
|
+
// question of its own), but the charge this fix ADDS does not inherit the hole.
|
|
7912
|
+
if (globalBuiltinCallee(callee) === "Object.assign")
|
|
7913
|
+
enumerateTargetAccessors(owner, (node.arguments ?? [])[0], provenCopiedKeys(sources), "set",
|
|
7914
|
+
{ why: "dynamic-keyset", keyType: null }); // R247 — copies string AND symbol keys
|
|
7915
|
+
}
|
|
7916
|
+
// SOUNDNESS R252 — THE OTHER BUILTINS THAT READ EVERY OWN ENUMERABLE **VALUE**, AND THE COMMENT
|
|
7917
|
+
// THAT RULED THEM OUT ON THE WRONG HALF OF THE QUESTION.
|
|
7918
|
+
//
|
|
7919
|
+
// `Object.entries`/`Object.values`/`JSON.stringify`/`structuredClone` each read every own enumerable
|
|
7920
|
+
// property's VALUE, so each invokes an own enumerable getter exactly as a spread or an
|
|
7921
|
+
// `Object.assign` SOURCE does. They are the same operation as the line above; nothing routed them
|
|
7922
|
+
// through it. Executed: caller ABSENT from `functions[]` in all four, `deny Fs` and `pure` scoped to
|
|
7923
|
+
// the caller both exit 0 over a tree containing nothing but the literal and the call.
|
|
7924
|
+
//
|
|
7925
|
+
// WHY IT SURVIVED, and it is the constraint on the fix rather than trivia. R115's comment states
|
|
7926
|
+
// "`JSON.stringify(c)` and `Object.entries(c)` STAYING PURE IS CORRECT … (executed: 0 invocations on
|
|
7927
|
+
// a class instance)". That is TRUE — a class accessor is installed on the prototype and is
|
|
7928
|
+
// non-enumerable, so these never visit it — and it is FALSE for an object LITERAL, whose getter is an
|
|
7929
|
+
// OWN enumerable property. The sentence measured the case in front of it and then read as a general
|
|
7930
|
+
// ruling. §K, in a comment written by the commit that needed it.
|
|
7931
|
+
//
|
|
7932
|
+
// SO THE CLASS-INSTANCE ANSWER MUST NOT MOVE — it is correct, and it is this fix's over-charge
|
|
7933
|
+
// control. That is why this routes through `enumerateGetters` rather than growing a private loop:
|
|
7934
|
+
// `classBodiedGetter` is exactly R115's exclusion, so the correct half is preserved BY CONSTRUCTION
|
|
7935
|
+
// instead of by a second implementation that has to remember to agree (§G).
|
|
7936
|
+
//
|
|
7937
|
+
// GROUND TRUTH EXECUTED, node 22.12.0, counting real getter invocations — object LITERAL vs CLASS
|
|
7938
|
+
// instance. The zeroes are as load-bearing as the ones: they are the rest of the sweep, and they are
|
|
7939
|
+
// why this list is four names and not "every whole-object builtin".
|
|
7940
|
+
// Object.entries 1/0 Object.values 1/0 JSON.stringify 1/0 structuredClone 1/0
|
|
7941
|
+
// Object.keys 0/0 Object.getOwnPropertyNames 0/0 Object.getOwnPropertyDescriptors 0/0
|
|
7942
|
+
// Object.freeze 0/0 Object.seal 0/0 for..in 0/0 console.log 0/0 String(o) 0/0
|
|
7943
|
+
// `${o}` 0/0 util.inspect(o) 0/0 assert.deepStrictEqual 0/0
|
|
7944
|
+
// `Object.keys` and `getOwnPropertyNames` read NAMES, never values; a descriptor read returns the
|
|
7945
|
+
// accessor function itself without calling it; `console.log`/`util.inspect` print `[Getter]`.
|
|
7946
|
+
// (`Object.assign` and spread are the line above; `{...o}`/rest are the object-literal arm.)
|
|
7947
|
+
//
|
|
7948
|
+
// RESIDUALS, stated as the open list they are: `console.log("%j", o)` and
|
|
7949
|
+
// `util.inspect(o, {getters: true})` DO invoke (executed 1) and are option/format-string dependent;
|
|
7950
|
+
// and every one of these reads NESTED objects too — `JSON.stringify({ a: lit })` and
|
|
7951
|
+
// `structuredClone([lit])` invoke the getter one level down (executed 1), which no arm here reaches
|
|
7952
|
+
// because `enumerateGetters` asks the ARGUMENT's own property list.
|
|
7953
|
+
// R281 — `structuredClone` asks `globalBareCallee`, the SAME authority the Env arm asks, so the two
|
|
7954
|
+
// consumers of `ENV_TOUCHING_GLOBAL` cannot drift the way they just did. The identifier-only test
|
|
7955
|
+
// this replaces was copied here by R252 from the Env arm six commits after `f58dc0f` had fixed the
|
|
7956
|
+
// identical hole one table over.
|
|
7957
|
+
if (["Object.entries", "Object.values", "JSON.stringify"].includes(globalBuiltinCallee(callee))
|
|
7958
|
+
|| globalBareCallee(callee) === "structuredClone") {
|
|
7959
|
+
const arg = (node.arguments ?? [])[0];
|
|
7960
|
+
if (arg) enumerateGetters(enclosing(node), checker.getTypeAtLocation(arg), arg);
|
|
7961
|
+
}
|
|
7962
|
+
// R116 §9 — WIDENED PAST THE ROW'S OWN TRIGGER, by grepping the MECHANISM ("a builtin that writes a
|
|
7963
|
+
// property into a caller-supplied target") rather than the one call the row named. `Reflect.set(t, k,
|
|
7964
|
+
// v)` is specified to run the setter the property lookup finds, and it was silent for the same reason
|
|
7965
|
+
// — EXECUTED, 1 setter invocation for both the literal-key and the runtime-key spelling, and both
|
|
7966
|
+
// ABSENT from `functions[]` before this. A literal key names exactly one property; a runtime key can
|
|
7967
|
+
// name any, so it falls to the unprovable branch and charges every setter the target declares.
|
|
7968
|
+
// (`Object.defineProperty`/`defineProperties` are deliberately NOT here: a descriptor INSTALLS a
|
|
7969
|
+
// property and bypasses the setter — executed, 0 invocations — and the engine already indexes those
|
|
7970
|
+
// through `definePropAccessors`. `Object.create`/`structuredClone`/spread all build fresh objects.)
|
|
7971
|
+
//
|
|
7972
|
+
// SHADOW-GUARDED with `identIsGlobal`, which the `Object.assign` line above still is not — a
|
|
7973
|
+
// project's own `const Object = …` would match its text test. Left as it is rather than widened
|
|
7974
|
+
// silently: it is a separate (fabrication-direction) question, it is REPORTED rather than folded in
|
|
7975
|
+
// here, and this new arm does not inherit the hole.
|
|
7976
|
+
//
|
|
7977
|
+
// SOUNDNESS R251 — `Reflect.get(t, k)` IS THE SAME ARM, AND IT DID NOT EXIST. Found the way R116's
|
|
7978
|
+
// own `Reflect.set` sibling was: by grepping the MECHANISM rather than the name. The two are one
|
|
7979
|
+
// table entry apart in `ENV_TOUCHING_BUILTIN` above, and only one of them had an accessor arm.
|
|
7980
|
+
// EXECUTED: 1 getter invocation for the literal-key spelling AND for the runtime-key one, on an
|
|
7981
|
+
// object literal AND on a class instance; the caller was ABSENT from `functions[]` in all four.
|
|
7982
|
+
//
|
|
7983
|
+
// ONE key-set expression for both directions, deliberately, because two spellings of one question
|
|
7984
|
+
// is what R247 was filed to close and a second copy here would reopen it (§G / F1-3). Today it is a
|
|
7985
|
+
// string-literal test: a literal key names exactly one property, anything else falls to the
|
|
7986
|
+
// unprovable branch and DISCLOSES. It is knowingly weaker than `accessorsAt`, which pins a key
|
|
7987
|
+
// whose TYPE names a finite set (R240(a): `const k = "token"`, a literal union, a string enum) — so
|
|
7988
|
+
// `s[k] = v` resolves a pinned key while `Reflect.set(s, k, v)` discloses `Unknown` for it. That
|
|
7989
|
+
// residual fails in the DISCLOSE direction, not the silent one, so it is a precision gap and is
|
|
7990
|
+
// REPORTED as its own row rather than folded into this fix's pricing.
|
|
7991
|
+
const reflectKeySet = (k) => (k && ts.isStringLiteralLike(k) ? new Set([k.text]) : null);
|
|
7992
|
+
for (const [name, kind] of [["Reflect.set", "set"], ["Reflect.get", "get"]]) {
|
|
7993
|
+
if (globalBuiltinCallee(callee) !== name) continue;
|
|
7994
|
+
const k = (node.arguments ?? [])[1];
|
|
7995
|
+
// R247 — an unprovable key here answers with R240(b)'s OWN tag, because `Reflect.set(t, k, v)` and
|
|
7996
|
+
// `t[k] = v` are one operation: one runtime key, at most one setter (executed above). R251 —
|
|
7997
|
+
// `Reflect.get(t, k)` and `t[k]` are that same one operation on the read side, so it takes the
|
|
7998
|
+
// same tag; a `deny Unknown[reflect]` written for one already selects the other.
|
|
7999
|
+
enumerateTargetAccessors(enclosing(node), (node.arguments ?? [])[0], reflectKeySet(k), kind,
|
|
8000
|
+
{ why: "dynamic-key", keyType: k ? checker.getTypeAtLocation(k) : null });
|
|
7126
8001
|
}
|
|
7127
8002
|
}
|
|
7128
8003
|
// GET/SET ACCESSOR access (the silent-pure-accessor fix): a property read that resolves to a
|
|
@@ -7142,25 +8017,33 @@ function visitCalls(node) {
|
|
|
7142
8017
|
const compoundAssign = isBinAssign && !simpleAssign
|
|
7143
8018
|
&& p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
|
|
7144
8019
|
const recordKind = (kind) => {
|
|
7145
|
-
const
|
|
7146
|
-
if (
|
|
8020
|
+
const hits = accessorsAt(node, kind);
|
|
8021
|
+
if (hits && hits.length) {
|
|
7147
8022
|
const owner = enclosing(node);
|
|
7148
8023
|
if (!owner) return;
|
|
7149
|
-
const an = hit.decl.parent?.name?.getText?.() ?? "?";
|
|
7150
8024
|
const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
|
|
7151
|
-
|
|
8025
|
+
// R284 — `?` is not an owner. A type-alias-declared accessor's parent is a TypeLiteral with no
|
|
8026
|
+
// name; the alias that declares it does have one. BARE here, matching this label's existing
|
|
8027
|
+
// spelling (`reflect:` detail is best-effort per §4, and requalifying it would move strings
|
|
8028
|
+
// nothing asked to move) — the fallback only fires where the old code printed `?`.
|
|
8029
|
+
for (const hit of hits) {
|
|
8030
|
+
const aOwner = hit.decl.parent?.name?.getText?.() ?? namedTypeAncestor(hit.decl)?.name?.getText?.() ?? "?";
|
|
8031
|
+
recordAccessorHit(owner, hit, `${aOwner}.${pn}`, node.expression);
|
|
8032
|
+
}
|
|
7152
8033
|
return;
|
|
7153
8034
|
}
|
|
7154
8035
|
// No type-level accessor — try the `Object.defineProperty` runtime-accessor index. The checker
|
|
7155
8036
|
// types target.key as a data prop, so an effectful defineProperty getter/setter is invisible to
|
|
7156
|
-
//
|
|
8037
|
+
// accessorsAt; consult definePropForceTarget so the forcing site edges to the descriptor unit
|
|
7157
8038
|
// (precise) instead of reading silent-pure (the cardinal sin). A descriptor we minted is always
|
|
7158
8039
|
// local, so this is an EDGE; never Unknown for a resolved-and-seen descriptor.
|
|
7159
|
-
const
|
|
7160
|
-
if (
|
|
8040
|
+
const dpNodes = definePropForceTarget(node, kind);
|
|
8041
|
+
if (dpNodes.length) {
|
|
7161
8042
|
const owner = enclosing(node);
|
|
7162
|
-
|
|
7163
|
-
|
|
8043
|
+
if (owner) for (const dpNode of dpNodes) {
|
|
8044
|
+
const t = nodeName.get(dpNode);
|
|
8045
|
+
if (t) fns.get(owner).edges.add(t);
|
|
8046
|
+
}
|
|
7164
8047
|
return;
|
|
7165
8048
|
}
|
|
7166
8049
|
// A computed-key descriptor accessor on this receiver's target means `recv.<anything>` MIGHT
|
|
@@ -7176,6 +8059,61 @@ function visitCalls(node) {
|
|
|
7176
8059
|
if (kinds && kinds.has(kind)) {
|
|
7177
8060
|
const owner = enclosing(node);
|
|
7178
8061
|
if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`reflect:defineProperty:dynamic-key`); } // dynamic-key descriptor install — metaprogramming, canonical `reflect:`
|
|
8062
|
+
return;
|
|
8063
|
+
}
|
|
8064
|
+
}
|
|
8065
|
+
// SOUNDNESS R240(b) — AN UNPINNABLE KEY ON A RECEIVER THAT DECLARES ACCESSORS.
|
|
8066
|
+
// `hits === null` means the key names no finite property set (`k: string`), so R240(a)'s resolver
|
|
8067
|
+
// had nothing to enumerate. Charging every declared accessor would be a guess about WHICH one runs;
|
|
8068
|
+
// saying nothing certifies the caller pure, which is the cardinal sin. Disclose `Unknown` — the
|
|
8069
|
+
// posture this same function already takes eleven lines up for a computed-key `defineProperty`
|
|
8070
|
+
// descriptor, and the one §4 prescribes for an unresolvable target.
|
|
8071
|
+
//
|
|
8072
|
+
// GATED ON THE RECEIVER'S OWN DECLARATIONS, so it is not a blanket hedge on every `obj[k] = v`: a
|
|
8073
|
+
// receiver type that declares NO accessor of this kind cannot invoke one, and stays ABSENT
|
|
8074
|
+
// (executed: 0 real writes). Measured over 17 corpus entries — 5,535 unpinnable element accesses,
|
|
8075
|
+
// of which 14 sit on a receiver declaring an accessor of the wanted kind.
|
|
8076
|
+
if (hits === null) {
|
|
8077
|
+
const rt = checker.getTypeAtLocation(node.expression);
|
|
8078
|
+
// A SYMBOL-NAMED accessor (`get [Symbol.toStringTag]() {…}`) cannot be reached by a STRING key,
|
|
8079
|
+
// and arming the disclosure on one is a fabrication, not a hedge. FOUND BY AUDITING THE CORPUS
|
|
8080
|
+
// ROWS RATHER THAN THE COUNT: the first cut of this branch fired on all six flagged
|
|
8081
|
+
// `AxiosHeaders` methods in axios 1.7.2 — every one because `AxiosHeaders` declares
|
|
8082
|
+
// `get [Symbol.toStringTag]()`, which `self[key]` with `key: string` can never name. The
|
|
8083
|
+
// exclusion is a DENYLIST of the PROVEN-unreachable: it fires only when EVERY declaration of
|
|
8084
|
+
// the property has a computed name whose expression is symbol-TYPED, and it lifts entirely if
|
|
8085
|
+
// the key's own type could hold a symbol (`PropertyKey`, `symbol`, `any`).
|
|
8086
|
+
// BOTH DIRECTIONS, because the first cut had only one and the corpus caught it twice. A string
|
|
8087
|
+
// key cannot name a symbol property, AND a symbol key cannot name a string one — the mirror was
|
|
8088
|
+
// missing and produced the whole of this branch's measured price: mongoose 8's 18 rows traced to
|
|
8089
|
+
// ONE direct source, `types/objectid.js:39` `ObjectId.prototype[objectIdSymbol] = true` with
|
|
8090
|
+
// `objectIdSymbol: unique symbol`, armed by bson's STRING-named `get id()`. Same audit-boundary
|
|
8091
|
+
// error one line over from the fix for it.
|
|
8092
|
+
// R247 — the two-way symbol/string test now lives in `keyCouldNameAccessor`, ONE authority shared
|
|
8093
|
+
// with `enumerateTargetSetters`'s `Reflect.set` arm. The behaviour here is unchanged (pinned by
|
|
8094
|
+
// the four symbol rows below); what changed is that the other spelling now asks the same code.
|
|
8095
|
+
const keyT = ts.isElementAccessExpression(node) && node.argumentExpression
|
|
8096
|
+
? checker.getTypeAtLocation(node.argumentExpression) : null;
|
|
8097
|
+
for (const prop of (rt?.getProperties?.() ?? [])) {
|
|
8098
|
+
if (!accessorsFromSym(prop, kind).length) continue;
|
|
8099
|
+
if (!keyCouldNameAccessor(prop, keyT)) continue;
|
|
8100
|
+
const owner = enclosing(node);
|
|
8101
|
+
if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`reflect:accessor:dynamic-key`); } // runtime-chosen property name — metaprogramming, canonical `reflect:`
|
|
8102
|
+
return;
|
|
8103
|
+
}
|
|
8104
|
+
// …and the same question for a `defineProperty` DESCRIPTOR: the descriptor was installed under a
|
|
8105
|
+
// LITERAL key (so `definePropDynamicKey` above does not fire) but is being reached through an
|
|
8106
|
+
// unpinnable one, which is the mirror of that case and was silent for the mirror reason.
|
|
8107
|
+
if (definePropAccessors.size > 0) {
|
|
8108
|
+
const r0 = checker.getSymbolAtLocation(node.expression);
|
|
8109
|
+
const r1 = r0 && (r0.flags & ts.SymbolFlags.Alias)
|
|
8110
|
+
? (() => { try { return checker.getAliasedSymbol(r0); } catch { return r0; } })() : r0;
|
|
8111
|
+
const byKey = (r1 && definePropAccessors.get(r1)) || (r0 && definePropAccessors.get(r0));
|
|
8112
|
+
if (byKey) for (const e of byKey.values()) if (e?.[kind]) {
|
|
8113
|
+
const owner = enclosing(node);
|
|
8114
|
+
if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`reflect:accessor:dynamic-key`); }
|
|
8115
|
+
break;
|
|
8116
|
+
}
|
|
7179
8117
|
}
|
|
7180
8118
|
}
|
|
7181
8119
|
};
|
|
@@ -7200,8 +8138,9 @@ function visitCalls(node) {
|
|
|
7200
8138
|
const keyName = ts.isIdentifier(key) ? key.text
|
|
7201
8139
|
: ts.isStringLiteralLike(key) ? key.text : null;
|
|
7202
8140
|
if (keyName === null) continue; // computed key (`{[k]: v}`) — unresolvable to one property
|
|
7203
|
-
const
|
|
7204
|
-
|
|
8141
|
+
for (const sym of propertySymbolsAcrossArms(recvType, keyName)) // R259
|
|
8142
|
+
for (const hit of accessorsFromSym(sym, "get"))
|
|
8143
|
+
recordAccessorHit(owner, hit, keyName);
|
|
7205
8144
|
}
|
|
7206
8145
|
}
|
|
7207
8146
|
}
|
|
@@ -8731,6 +9670,37 @@ let peekAttempted = false;
|
|
|
8731
9670
|
// per CLASS, so the answer is too: a class is peeked only when no file of that class went unread.
|
|
8732
9671
|
const peekUnread = new Set();
|
|
8733
9672
|
let peekUnattributed = false;
|
|
9673
|
+
// ⟨0.19⟩/⟨0.24⟩ SPEC §6.2 — THE POLICY'S REASON-CLASS VOCABULARY (`.candor/config` `unknown-alias`),
|
|
9674
|
+
// parsed ONCE here and read by BOTH the peek below and the §6.2 gate far below. It used to be parsed only
|
|
9675
|
+
// at the gate, and the peek passed a bare `{}` in its place.
|
|
9676
|
+
//
|
|
9677
|
+
// R154: `{}` is not an EMPTY alias map, it is an object with no `.has`. `parsePolicy`'s alias arm reads
|
|
9678
|
+
// `else if (aliases && aliases.has(cn))` — truthy, then TypeError — so the FIRST `Unknown[<token>]` whose
|
|
9679
|
+
// token is not `*`, `dynamic` or a built-in REASON_CLASS threw straight into the peek's catch, leaving
|
|
9680
|
+
// `peekPolicy` null. The peek was then never ATTEMPTED, which is strictly worse than a peek that ran and
|
|
9681
|
+
// found nothing: `outOfScope` and `scannedUnder` go ABSENT rather than `[]`, `excluded[].peeked` reads
|
|
9682
|
+
// false, and ⟨0.30⟩'s fail-closed INCOMPLETE verdict never arms. MEASURED on published 0.35.0 over one
|
|
9683
|
+
// excluded file performing `Fs`: `deny Fs` alone exits 2 naming the function; adding `deny Unknown[corp]`
|
|
9684
|
+
// beside it exits 0 `policy ✓` with no disclosure of any kind. An unrelated rule turned a red verdict
|
|
9685
|
+
// green — while the gate honoured that same rule correctly, so two paths disagreed about one policy.
|
|
9686
|
+
//
|
|
9687
|
+
// THE TRIGGER IS A TOKEN THE MAP IS NEEDED FOR, not an alias being defined. `Unknown[nosuchalias]` disarms
|
|
9688
|
+
// the peek identically; there the gate's own §6.2 refusal happens to hold the exit at 2, which is why only
|
|
9689
|
+
// the defined-alias spelling surfaces as a silent under-report. `Unknown[reflect]` (a built-in class)
|
|
9690
|
+
// never reaches the alias arm and was always fine — that pair is what isolates the arm.
|
|
9691
|
+
//
|
|
9692
|
+
// ANCHORED AT THE POLICY FILE, the same anchor the gate uses (SPEC §3.1 `99eb4e9`) — vocabulary travels
|
|
9693
|
+
// with the policy that uses it, and two anchors would expand one rule two ways. Memoized rather than
|
|
9694
|
+
// eager, so `parseUnknownAliases`'s own stderr warnings are still printed exactly once.
|
|
9695
|
+
let policyAliasMap = null, policyAliasErrs = null;
|
|
9696
|
+
const policyAliases = () => {
|
|
9697
|
+
if (policyAliasMap === null) {
|
|
9698
|
+
policyAliasErrs = [];
|
|
9699
|
+
policyAliasMap = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(policyPath, target)),
|
|
9700
|
+
policyAliasErrs);
|
|
9701
|
+
}
|
|
9702
|
+
return policyAliasMap;
|
|
9703
|
+
};
|
|
8734
9704
|
if (policyPath) {
|
|
8735
9705
|
// ⟨0.33⟩ NO LONGER GATED ON `excludedFiles.length`. A tree with a policy and NOTHING excluded used to
|
|
8736
9706
|
// skip this whole block, so `outOfScopeFindings`/`scannedUnderRules` stayed `null` and both
|
|
@@ -8747,17 +9717,40 @@ if (policyPath) {
|
|
|
8747
9717
|
// run must not fail the gate" catch — findings silently empty, gate green. The catch is right; a bug
|
|
8748
9718
|
// hiding behind it is not, which is why the trigger below is now a POSITIVE test on the rules.
|
|
8749
9719
|
let peekPolicy = null;
|
|
8750
|
-
|
|
8751
|
-
|
|
9720
|
+
// R154 — THE CATCH COVERS THE READ ONLY. It used to wrap the parse too, and its own comment named only
|
|
9721
|
+
// the read ("an unreadable policy"), which is exactly how the TypeError above sat behind it for a whole
|
|
9722
|
+
// rung: the ⟨0.30⟩ note above already records a ReferenceError hiding in this same catch, and answered it
|
|
9723
|
+
// by tightening the TRIGGER — which does nothing for a throw one line earlier. The ARGUMENT for
|
|
9724
|
+
// narrowing, stated as the assumption it is: the gate below calls the SAME `parsePolicy` on the SAME
|
|
9725
|
+
// bytes with the SAME map and no `try` at all, so on every input that reaches both, a throw here is one
|
|
9726
|
+
// that would kill the run at the gate anyway — catching it only moved where it died and made one of the
|
|
9727
|
+
// two places a silent green. That holds while both reach the parse, which is every run bar a policy file
|
|
9728
|
+
// that becomes unreadable between these two lines.
|
|
9729
|
+
//
|
|
9730
|
+
// ONE MEASURED BEHAVIOUR CHANGE, and it moves the right way. `policyAliases()` runs here now, and
|
|
9731
|
+
// `discoverConfigText` refuses an existing-but-unreadable `.candor/config` with `process.exit(2)`. Where
|
|
9732
|
+
// the policy is filed in a tree of its OWN whose config is unreadable — the only case the target-anchored
|
|
9733
|
+
// reads at :1151/:1314 do not already catch — the refusal now lands BEFORE the envelope is written
|
|
9734
|
+
// instead of after. MEASURED both arms: exit 2 either way, report WRITTEN at 0.35.0 and NONE here, which
|
|
9735
|
+
// is the §3.1 posture (a refusal produces no report), not a regression from it.
|
|
9736
|
+
let peekText = null;
|
|
9737
|
+
try { peekText = fs.readFileSync(policyPath, "utf8"); }
|
|
9738
|
+
catch { /* an unreadable policy is the gate's business to refuse (exit 2), not the peek's */ }
|
|
9739
|
+
if (peekText !== null) {
|
|
9740
|
+
const pol = parsePolicy(peekText, policyAliases());
|
|
8752
9741
|
// ⟨0.29⟩ A REFUSED POLICY LEAVES THE KEY ABSENT (SPEC §2). The peek is a producer reading the policy,
|
|
8753
9742
|
// so §3.1 binds it exactly as it binds the gate: over a policy no route will honour, `outOfScope: []`
|
|
8754
9743
|
// claims a look taken against rules that never stood, and the `denied` set it would look for is the
|
|
8755
9744
|
// parser's SALVAGE of an unhonourable file — the rewriting `fatalPolicyErrors` exists to refuse.
|
|
8756
9745
|
// candor-java already withheld here; this engine, candor-rust and candor-swift did not.
|
|
8757
|
-
|
|
9746
|
+
// R154: the ALIAS-DEFINITION errors count under that same rule, and the gate already folds them into
|
|
9747
|
+
// its own refusal (`parseErrs` below). `unknown-alias corp = reflect,nativ` keeps `corp` in the map, so
|
|
9748
|
+
// `pol.errors` alone is EMPTY and the peek would have published `outOfScope`/`scannedUnder` against a
|
|
9749
|
+
// policy the gate is about to refuse at exit 2 — a look taken under rules that never stood.
|
|
9750
|
+
if (!fatalPolicyErrors([...policyAliasErrs, ...pol.errors]).length) {
|
|
8758
9751
|
peekPolicy = pol;
|
|
8759
9752
|
}
|
|
8760
|
-
}
|
|
9753
|
+
}
|
|
8761
9754
|
// ⟨0.30⟩ THE TRIGGER IS "ARE THERE DENY RULES", not "is the flattened effect-name set non-empty". The
|
|
8762
9755
|
// old test read the name set, and `pure` is a deny rule with an EMPTY effect list meaning "every effect
|
|
8763
9756
|
// except Unknown" — so under the STRICTEST policy the set was empty, the peek never ran, and the tree
|
|
@@ -9545,8 +10538,12 @@ if (policyPath !== null) {
|
|
|
9545
10538
|
// with the policy filed outside the scan target the two expanded the SAME rule differently and §3.1's
|
|
9546
10539
|
// byte-equality MUST was breakable by a file that is neither the report nor the policy. `net-partner`
|
|
9547
10540
|
// (above, at the target) is deliberately NOT moved: it describes the thing being scanned.
|
|
9548
|
-
|
|
9549
|
-
|
|
10541
|
+
// R154 — THE SAME MAP THE PEEK USED, not a second parse of the same file. The peek passed `{}` and this
|
|
10542
|
+
// route passed the real vocabulary, so one `Unknown[<alias>]` line meant two different things to two
|
|
10543
|
+
// paths reading one policy: the gate honoured it, the peek threw on it. `policyAliases()` is memoized
|
|
10544
|
+
// above, so the alias parser's own stderr warnings are still printed exactly once per run.
|
|
10545
|
+
const unknownAliases = policyAliases();
|
|
10546
|
+
const parseErrs = [...policyAliasErrs];
|
|
9550
10547
|
const gatePolicy = parsePolicy(text, unknownAliases);
|
|
9551
10548
|
parseErrs.push(...gatePolicy.errors);
|
|
9552
10549
|
// ⟨0.28⟩ SPEC §6.2 — the LINES THE PARSE DROPPED, for the verdict document below. Non-fatal by
|