carveout 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -3
- package/NOTICE.md +2 -1
- package/README.md +248 -473
- package/dist/src/account.d.ts +37 -0
- package/dist/src/account.js +275 -0
- package/dist/src/api.d.ts +162 -0
- package/dist/src/api.js +134 -0
- package/dist/src/carve.d.ts +788 -0
- package/dist/src/carve.js +1721 -125
- package/dist/src/cli.d.ts +10 -0
- package/dist/src/cli.js +674 -47
- package/dist/src/command.d.ts +10 -0
- package/dist/src/core.d.ts +11 -0
- package/dist/src/cut.d.ts +78 -0
- package/dist/src/cut.js +25 -7
- package/dist/src/extract/compiler-config.d.ts +49 -0
- package/dist/src/extract/fixture.d.ts +103 -0
- package/dist/src/extract/hash.d.ts +6 -0
- package/dist/src/extract/package-registry.d.ts +47 -0
- package/dist/src/extract/package-types.d.ts +52 -0
- package/dist/src/extract/surface.d.ts +44 -0
- package/dist/src/extract/typescript.d.ts +180 -0
- package/dist/src/gate.d.ts +55 -0
- package/dist/src/gate.js +1 -0
- package/dist/src/graph.d.ts +142 -0
- package/dist/src/graph.js +4 -3
- package/dist/src/impact.d.ts +113 -0
- package/dist/src/imports.d.ts +67 -0
- package/dist/src/mergeback.d.ts +182 -0
- package/dist/src/mergeback.js +580 -35
- package/dist/src/platform.d.ts +69 -0
- package/dist/src/platform.js +184 -0
- package/dist/src/policy.d.ts +181 -0
- package/dist/src/policy.js +716 -230
- package/dist/src/project/build.d.ts +23 -0
- package/dist/src/project/catalog.d.ts +25 -0
- package/dist/src/project/project.d.ts +287 -0
- package/dist/src/project/project.js +479 -52
- package/dist/src/project/provenance.d.ts +73 -0
- package/dist/src/project/provenance.js +12 -0
- package/dist/src/project/runner.d.ts +19 -0
- package/dist/src/project/secrets.d.ts +64 -0
- package/dist/src/project/vitest-config.d.ts +70 -0
- package/dist/src/project/vitest-config.js +291 -0
- package/dist/src/pseudonyms/aliases.d.ts +107 -0
- package/dist/src/pseudonyms/aliases.js +394 -0
- package/dist/src/pseudonyms/draft.d.ts +64 -0
- package/dist/src/pseudonyms/draft.js +284 -0
- package/dist/src/pseudonyms/folder.d.ts +172 -0
- package/dist/src/pseudonyms/folder.js +527 -0
- package/dist/src/pseudonyms/index.d.ts +11 -0
- package/dist/src/pseudonyms/index.js +11 -0
- package/dist/src/pseudonyms/vocabulary.d.ts +113 -0
- package/dist/src/pseudonyms/vocabulary.js +522 -0
- package/dist/src/readiness.d.ts +71 -0
- package/dist/src/readiness.js +131 -0
- package/dist/src/receipt.d.ts +154 -0
- package/dist/src/receipt.js +296 -0
- package/dist/src/sandbox.d.ts +53 -0
- package/dist/src/sandbox.js +154 -0
- package/dist/src/slice/analysis.d.ts +75 -0
- package/dist/src/slice/externals.d.ts +51 -0
- package/dist/src/slice/graph.d.ts +69 -0
- package/dist/src/slice/manifest.d.ts +262 -0
- package/dist/src/slice/manifest.js +4 -3
- package/dist/src/slice/reach.d.ts +204 -0
- package/dist/src/slice/reach.js +11 -5
- package/dist/src/slice/report.d.ts +49 -0
- package/dist/src/slice/report.js +5 -2
- package/dist/src/specifiers.d.ts +48 -0
- package/dist/src/status.d.ts +46 -0
- package/dist/src/status.js +3 -2
- package/dist/src/test-integrity.d.ts +54 -0
- package/dist/src/test-integrity.js +812 -0
- package/package.json +24 -5
- package/skills/carveout/SKILL.md +518 -0
- package/skills/carveout-gate/SKILL.md +98 -0
- package/SKILL.md +0 -423
package/dist/src/policy.js
CHANGED
|
@@ -16,7 +16,8 @@ import { execFileSync } from "node:child_process";
|
|
|
16
16
|
import { existsSync, statSync } from "node:fs";
|
|
17
17
|
import { posix, resolve } from "node:path";
|
|
18
18
|
import ts from "typescript";
|
|
19
|
-
import { isTestPath, pathMatcher } from "./core.js";
|
|
19
|
+
import { isTestPath, isTestSupportPath, pathMatcher } from "./core.js";
|
|
20
|
+
import { ownSpecifier, scriptKindOf, weakenedTest } from "./test-integrity.js";
|
|
20
21
|
import { isNodeBuiltin } from "./extract/typescript.js";
|
|
21
22
|
import { PURE_NODE_MODULES, serviceOf } from "./slice/externals.js";
|
|
22
23
|
/**
|
|
@@ -63,6 +64,26 @@ export const PROTECTED_PATHS = [
|
|
|
63
64
|
"**/docker-compose*.yaml",
|
|
64
65
|
"**/Makefile",
|
|
65
66
|
"**/*.sh",
|
|
67
|
+
// Task runners and hooks that run a command the file names.
|
|
68
|
+
"**/project.json",
|
|
69
|
+
"**/nx.json",
|
|
70
|
+
"**/turbo.json",
|
|
71
|
+
"**/.lintstagedrc*",
|
|
72
|
+
"**/lint-staged.config.*",
|
|
73
|
+
"**/lefthook.y*ml",
|
|
74
|
+
"**/.pre-commit-config.yaml",
|
|
75
|
+
"**/bunfig.toml",
|
|
76
|
+
"**/deno.json",
|
|
77
|
+
"**/deno.jsonc",
|
|
78
|
+
"**/vercel.json",
|
|
79
|
+
"**/netlify.toml",
|
|
80
|
+
"**/Taskfile.y*ml",
|
|
81
|
+
"**/justfile",
|
|
82
|
+
"**/Justfile",
|
|
83
|
+
"**/Procfile",
|
|
84
|
+
"**/*.tf",
|
|
85
|
+
"**/cdk.json",
|
|
86
|
+
"**/serverless.y*ml",
|
|
66
87
|
];
|
|
67
88
|
/**
|
|
68
89
|
* Throws unless `path` is a plain relative path that stays where it is
|
|
@@ -151,18 +172,50 @@ export const CHECK_CONFIG_PATHS = [
|
|
|
151
172
|
"**/biome.jsonc",
|
|
152
173
|
"**/vitest.config.*",
|
|
153
174
|
"**/vitest.workspace.*",
|
|
175
|
+
"**/vitest.setup.*",
|
|
154
176
|
"**/vite.config.*",
|
|
155
177
|
"**/jest.config.*",
|
|
178
|
+
"**/jest.setup.*",
|
|
179
|
+
"**/setupTests.*",
|
|
180
|
+
"**/test-setup.*",
|
|
181
|
+
"**/.mocharc*",
|
|
182
|
+
"**/ava.config.*",
|
|
183
|
+
"**/playwright.config.*",
|
|
184
|
+
"**/babel.config.*",
|
|
185
|
+
"**/.babelrc*",
|
|
186
|
+
"**/.swcrc",
|
|
156
187
|
"**/.c8rc",
|
|
157
188
|
"**/.c8rc.json",
|
|
158
189
|
"**/.nycrc",
|
|
159
190
|
"**/.nycrc.json",
|
|
160
191
|
];
|
|
192
|
+
/**
|
|
193
|
+
* Of those, the files a runner or a build picks up by being there: added,
|
|
194
|
+
* they change what a check holds code to as an edit does (a
|
|
195
|
+
* `vitest.config.ts` that includes nothing, a setup file that skips every
|
|
196
|
+
* test, a transform that rewrites the code). A new package's tsconfig or
|
|
197
|
+
* linter configuration is not one.
|
|
198
|
+
*/
|
|
199
|
+
const PICKED_UP_CONFIG_PATHS = CHECK_CONFIG_PATHS.filter((pattern) => !/tsconfig|jsconfig/.test(pattern));
|
|
200
|
+
/** A test's helper by its name, wherever it is kept: `test-helpers.ts`, `cart.fixture.ts`. */
|
|
201
|
+
const HELPER_NAME = /(^|\/)[^/]*(test|spec)[-_.]?(helpers?|utils?|support|setup|fakes?|doubles?|builders?|factories)\.[cm]?[jt]sx?$|\.(fixtures?|mocks?|fakes?|stubs?)\.[cm]?[jt]sx?$/i;
|
|
202
|
+
/**
|
|
203
|
+
* Data a test compares against: snapshots and screenshots, wherever they
|
|
204
|
+
* are kept. Edited or deleted, the test is.
|
|
205
|
+
*/
|
|
206
|
+
const FIXTURE_PATH = /(^|\/)(__snapshots__|__screenshots__|__fixtures__|__image_snapshots__|[^/]*-snapshots)\/|\.snap(shot)?$/;
|
|
207
|
+
/** Code the policy cannot read: added or edited, it is held for a reader. */
|
|
208
|
+
const UNREAD_CODE = /\.(py|rb|go|rs|java|kt|kts|scala|php|cs|swift|lua|pl|vue|svelte|astro|mdx|node|wasm|so|dylib|dll|exe|bat|cmd|ps1)$/i;
|
|
161
209
|
/** Comments that switch a check off for the code after them. */
|
|
162
210
|
const SUPPRESSION = /@ts-ignore|@ts-nocheck|@ts-expect-error|eslint-disable|biome-ignore|istanbul ignore|c8 ignore|v8 ignore/g;
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
211
|
+
/**
|
|
212
|
+
* How many check suppressions a script's text holds. A test's
|
|
213
|
+
* `@ts-expect-error` asserts that a call does not type-check, which is a
|
|
214
|
+
* test of the types, not a suppression.
|
|
215
|
+
*/
|
|
216
|
+
export function suppressionsOf(text, path = "") {
|
|
217
|
+
const found = text.match(SUPPRESSION) ?? [];
|
|
218
|
+
return isTestPath(path) ? found.filter((match) => match !== "@ts-expect-error").length : found.length;
|
|
166
219
|
}
|
|
167
220
|
/**
|
|
168
221
|
* Judges each path a patch would carry. `packages` are the repository's
|
|
@@ -173,6 +226,7 @@ export function handoffPolicy(options) {
|
|
|
173
226
|
const allowed = pathMatcher(options.allow ?? []);
|
|
174
227
|
const protectedPath = pathMatcher(PROTECTED_PATHS);
|
|
175
228
|
const checkConfig = pathMatcher(options.checks === true ? CHECK_CONFIG_PATHS : []);
|
|
229
|
+
const pickedUp = pathMatcher(options.checks === true ? PICKED_UP_CONFIG_PATHS : []);
|
|
176
230
|
const noun = options.testNoun ?? "a test the slice was handed";
|
|
177
231
|
const { packages, packageOf } = options;
|
|
178
232
|
return (path, change, texts) => {
|
|
@@ -180,27 +234,58 @@ export function handoffPolicy(options) {
|
|
|
180
234
|
return null;
|
|
181
235
|
if (protectedPath(path))
|
|
182
236
|
return "a protected path (CI, hooks, manifests, lockfiles, environment): review it and pass --allow to carry it";
|
|
183
|
-
if (checkConfig(path) && change !== "added")
|
|
184
|
-
return "changes what a check holds code to (compiler, linter
|
|
237
|
+
if ((checkConfig(path) || options.testSetup?.(path) === true) && (change !== "added" || pickedUp(path)))
|
|
238
|
+
return `${change === "added" ? "adds" : change === "deleted" ? "deletes" : "changes"} what a check holds code to (compiler, linter, test-runner configuration or setup): review it and pass --allow to carry it`;
|
|
185
239
|
if (change === "added" &&
|
|
186
240
|
packages !== undefined &&
|
|
187
241
|
packageOf !== undefined &&
|
|
188
242
|
!packages.has(packageOf(path)))
|
|
189
243
|
return "a new file outside the slice's packages";
|
|
244
|
+
const script = /\.[cm]?[jt]sx?$/.test(path) && !/\.d\.[cm]?ts$/.test(path);
|
|
245
|
+
// A fake, a builder or a fixture decides what a test sees as much as
|
|
246
|
+
// the test does: an edit to one is a test edit.
|
|
247
|
+
const support = !isTestPath(path) &&
|
|
248
|
+
(isTestSupportPath(path) || FIXTURE_PATH.test(path) || HELPER_NAME.test(path) || (options.testSupport?.(path) ?? false));
|
|
249
|
+
// A module mock a runner picks up by being there (`__mocks__/pkg.ts`
|
|
250
|
+
// replaces `pkg` in every Jest test).
|
|
251
|
+
if (change === "added" && /(^|\/)__mocks__\//.test(path))
|
|
252
|
+
return "adds a module mock (__mocks__) that the test runner picks up for every test: review it and pass --allow to carry it";
|
|
253
|
+
if (change !== "deleted" && UNREAD_CODE.test(path))
|
|
254
|
+
return "code in a language the policy does not read: review it and pass --allow to carry it";
|
|
255
|
+
// A file with no extension, which Node loads as code by its bare name
|
|
256
|
+
// (`./checkout`) and the policy cannot read as a script.
|
|
257
|
+
if (change === "added" && !/\.[^/.]+$/.test(path.split("/").pop() ?? path))
|
|
258
|
+
return "a file with no extension, which Node can load as code by its bare name: review it and pass --allow to carry it";
|
|
190
259
|
if (change === "deleted" && isTestPath(path))
|
|
191
260
|
return `deletes ${noun}`;
|
|
192
|
-
if (change === "
|
|
193
|
-
|
|
261
|
+
if (change === "deleted" && support)
|
|
262
|
+
return "deletes a test's helper or fixture: review it and pass --allow to carry it";
|
|
263
|
+
if (change === "changed" &&
|
|
264
|
+
(isTestPath(path) || (support && script)) &&
|
|
265
|
+
texts !== undefined &&
|
|
266
|
+
texts.before !== null) {
|
|
267
|
+
const weakened = weakenedTest(path, texts.before, texts.after, support ? "a test's helper" : noun);
|
|
194
268
|
if (weakened !== null)
|
|
195
269
|
return `${weakened}: review it and pass --allow to carry it`;
|
|
196
270
|
}
|
|
271
|
+
if (change === "changed" && support && !script && (texts === undefined || texts.before !== texts.after))
|
|
272
|
+
return "changes a test fixture (data a test reads): review it and pass --allow to carry it";
|
|
273
|
+
if (options.integrity === true && change !== "deleted" && texts !== undefined) {
|
|
274
|
+
const gained = gainedIntegrity(path, texts.before, texts.after);
|
|
275
|
+
if (gained.length > 0)
|
|
276
|
+
return `could pass a test without the code passing it (${gained.join(", ")}): review it and pass --allow to carry it`;
|
|
277
|
+
}
|
|
197
278
|
if (options.checks === true && change !== "deleted" && texts !== undefined && /\.[cm]?[jt]sx?$/.test(path)) {
|
|
198
|
-
const more = suppressionsOf(texts.after) - suppressionsOf(texts.before ?? "");
|
|
279
|
+
const more = suppressionsOf(texts.after, path) - suppressionsOf(texts.before ?? "", path);
|
|
199
280
|
if (more > 0)
|
|
200
281
|
return `adds ${more} check suppression${more === 1 ? "" : "s"} (@ts-ignore, eslint-disable, coverage ignores): review it and pass --allow to carry it`;
|
|
201
282
|
}
|
|
202
283
|
if (options.reach === true && change !== "deleted" && texts !== undefined) {
|
|
203
|
-
const gained = gainedReach(path, texts.before, texts.after
|
|
284
|
+
const gained = gainedReach(path, texts.before, texts.after, {
|
|
285
|
+
...(options.knownPackages === undefined ? {} : { knownPackages: options.knownPackages }),
|
|
286
|
+
...(packages === undefined || packageOf === undefined ? {} : { packages, packageOf }),
|
|
287
|
+
...(options.holds === undefined ? {} : { holds: options.holds }),
|
|
288
|
+
});
|
|
204
289
|
if (gained.length > 0)
|
|
205
290
|
return `reaches what it did not before (${gained.join(", ")}): it runs beside the whole repository, the closed part included; read it and pass --allow to carry it`;
|
|
206
291
|
}
|
|
@@ -232,40 +317,72 @@ const REACHING_GLOBALS = new Set([
|
|
|
232
317
|
"EventSource",
|
|
233
318
|
"eval",
|
|
234
319
|
"Function",
|
|
320
|
+
"WebAssembly",
|
|
235
321
|
"Worker",
|
|
236
322
|
"Deno",
|
|
237
323
|
"Bun",
|
|
238
324
|
]);
|
|
239
|
-
/**
|
|
325
|
+
/** A module specifier that loads code from somewhere other than a package or a file. */
|
|
326
|
+
const URL_SPECIFIER = /^(data|https?|file|blob):/i;
|
|
327
|
+
/** A specifier a bundler or Node reads as raw bytes or native code: `x?raw`, `addon.node`. */
|
|
328
|
+
const RAW_SPECIFIER = /\?|\.node$/;
|
|
329
|
+
/** What a `process` member reaches: its environment, a report of it, or native code. */
|
|
240
330
|
const REACHING_PROCESS = new Set([
|
|
241
331
|
"env",
|
|
242
332
|
"binding",
|
|
243
333
|
"dlopen",
|
|
244
334
|
"mainModule",
|
|
245
335
|
"getBuiltinModule",
|
|
336
|
+
"report",
|
|
337
|
+
"loadEnvFile",
|
|
246
338
|
]);
|
|
247
339
|
/** Names the global object goes by. */
|
|
248
340
|
const GLOBAL_OBJECTS = new Set(["globalThis", "global", "window", "self"]);
|
|
249
341
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* (`node:fs`), service SDKs, network and code-running globals (`fetch`,
|
|
253
|
-
* `eval`), the environment (`process.env`, `import.meta.env`), and modules
|
|
254
|
-
* or globals named by a computed value, which could be any of these. Each
|
|
255
|
-
* as a short label, sorted. Text-level: a hold for a reader, not a sandbox.
|
|
342
|
+
* Packages a slice installs that carry the disk or the environment inside:
|
|
343
|
+
* typescript's `sys` (always installed), vite's `loadEnv`.
|
|
256
344
|
*/
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
345
|
+
const PACKAGE_REACH = [
|
|
346
|
+
{ module: "typescript", member: "sys", label: "typescript's sys (the disk and the environment)" },
|
|
347
|
+
{ module: "vite", member: "loadEnv", label: "vite's loadEnv (the environment)" },
|
|
348
|
+
];
|
|
349
|
+
/** The members of CommonJS's `module` that load code. */
|
|
350
|
+
const MODULE_LOADER = new Set(["require", "constructor", "_load", "_resolveFilename", "_compile", "createRequire"]);
|
|
351
|
+
/** A label for each time a script reaches past its computation; the multiset `reachOf` reads. */
|
|
352
|
+
function reachCounts(path, text, context = {}) {
|
|
353
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKindOf(path));
|
|
354
|
+
const found = new Map();
|
|
355
|
+
const add = (label) => found.set(label, (found.get(label) ?? 0) + 1);
|
|
261
356
|
const specifier = (name) => {
|
|
262
|
-
if (
|
|
357
|
+
if (URL_SPECIFIER.test(name))
|
|
358
|
+
add("a module loaded from a URL");
|
|
359
|
+
else if (RAW_SPECIFIER.test(name))
|
|
360
|
+
add("a file loaded raw or as native code");
|
|
361
|
+
else if (isNodeBuiltin(name)) {
|
|
263
362
|
const root = (name.startsWith("node:") ? name.slice(5) : name).split("/")[0] ?? name;
|
|
264
363
|
if (!QUIET_NODE_MODULES.has(root))
|
|
265
|
-
|
|
364
|
+
add(`node:${root}`);
|
|
365
|
+
}
|
|
366
|
+
else if (name.startsWith(".")) {
|
|
367
|
+
// An installed package's files by path: no patch shows what is there,
|
|
368
|
+
// and the folder's own copy is the contributor's to plant.
|
|
369
|
+
if (/(^|\/)node_modules(\/|$)/.test(name))
|
|
370
|
+
add("a package's files by path (node_modules)");
|
|
371
|
+
// A relative import that leaves the slice's packages reads closed code.
|
|
372
|
+
const { packages, packageOf, holds } = context;
|
|
373
|
+
if (packages !== undefined && packageOf !== undefined) {
|
|
374
|
+
const target = posix.normalize(posix.join(posix.dirname(path), name));
|
|
375
|
+
if (!target.startsWith("..") && !packages.has(packageOf(target)) && !(holds?.(target) ?? false))
|
|
376
|
+
add("a module outside the slice's packages");
|
|
377
|
+
}
|
|
266
378
|
}
|
|
267
379
|
else if (serviceOf(name) !== null)
|
|
268
|
-
|
|
380
|
+
add(name);
|
|
381
|
+
else if (!ownSpecifier(name) && context.knownPackages !== undefined) {
|
|
382
|
+
const pkg = name.startsWith("@") ? name.split("/").slice(0, 2).join("/") : (name.split("/")[0] ?? name);
|
|
383
|
+
if (!context.knownPackages.has(pkg))
|
|
384
|
+
add(`a package the slice does not have (${pkg})`);
|
|
385
|
+
}
|
|
269
386
|
};
|
|
270
387
|
const computed = "a module named by a computed value";
|
|
271
388
|
const literal = (node) => node !== undefined && ts.isStringLiteralLike(node) ? node.text : null;
|
|
@@ -283,6 +400,8 @@ export function reachOf(path, text) {
|
|
|
283
400
|
// `{ fetch }` reads it; `{ fetch: 1 }`, `const fetch`, `f(fetch)` name or read.
|
|
284
401
|
if (ts.isShorthandPropertyAssignment(parent))
|
|
285
402
|
return false;
|
|
403
|
+
if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent))
|
|
404
|
+
return true;
|
|
286
405
|
return parent.name === node;
|
|
287
406
|
};
|
|
288
407
|
/** `process`, or `globalThis.process`. */
|
|
@@ -291,267 +410,634 @@ export function reachOf(path, text) {
|
|
|
291
410
|
node.name.text === "process" &&
|
|
292
411
|
ts.isIdentifier(node.expression) &&
|
|
293
412
|
GLOBAL_OBJECTS.has(node.expression.text));
|
|
413
|
+
// The names the file declares anywhere: a bare name it does not declare is a global.
|
|
414
|
+
const declared = new Set();
|
|
415
|
+
const declare = (node) => {
|
|
416
|
+
if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isImportSpecifier(node) || ts.isImportClause(node) || ts.isNamespaceImport(node)) &&
|
|
417
|
+
node.name !== undefined &&
|
|
418
|
+
ts.isIdentifier(node.name))
|
|
419
|
+
declared.add(node.name.text);
|
|
420
|
+
ts.forEachChild(node, declare);
|
|
421
|
+
};
|
|
422
|
+
declare(source);
|
|
423
|
+
// The names this file binds to a package that carries reach inside.
|
|
424
|
+
const packageReach = new Map();
|
|
425
|
+
for (const statement of source.statements) {
|
|
426
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
427
|
+
continue;
|
|
428
|
+
for (const rule of PACKAGE_REACH) {
|
|
429
|
+
if (statement.moduleSpecifier.text !== rule.module)
|
|
430
|
+
continue;
|
|
431
|
+
const clause = statement.importClause;
|
|
432
|
+
if (clause?.name !== undefined)
|
|
433
|
+
packageReach.set(clause.name.text, { ...rule, whole: true });
|
|
434
|
+
const bindings = clause?.namedBindings;
|
|
435
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings))
|
|
436
|
+
packageReach.set(bindings.name.text, { ...rule, whole: true });
|
|
437
|
+
else if (bindings !== undefined)
|
|
438
|
+
for (const element of bindings.elements)
|
|
439
|
+
if ((element.propertyName ?? element.name).text === rule.member)
|
|
440
|
+
add(rule.label);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
294
443
|
const visit = (node) => {
|
|
444
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
445
|
+
const bound = packageReach.get(node.expression.text);
|
|
446
|
+
if (bound !== undefined && node.name.text === bound.member)
|
|
447
|
+
add(bound.label);
|
|
448
|
+
}
|
|
449
|
+
if (ts.isImportDeclaration(node) &&
|
|
450
|
+
ts.isStringLiteral(node.moduleSpecifier) &&
|
|
451
|
+
/^(node:)?test$/.test(node.moduleSpecifier.text) &&
|
|
452
|
+
node.importClause?.namedBindings !== undefined &&
|
|
453
|
+
ts.isNamedImports(node.importClause.namedBindings) &&
|
|
454
|
+
node.importClause.namedBindings.elements.some((element) => (element.propertyName ?? element.name).text === "run"))
|
|
455
|
+
add("a test run of its own (node:test run)");
|
|
295
456
|
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier !== undefined) {
|
|
296
457
|
const name = literal(node.moduleSpecifier);
|
|
297
|
-
|
|
458
|
+
const typeOnly = (ts.isImportDeclaration(node) && node.importClause?.isTypeOnly === true) ||
|
|
459
|
+
(ts.isExportDeclaration(node) && node.isTypeOnly);
|
|
460
|
+
if (name !== null && !typeOnly)
|
|
298
461
|
specifier(name);
|
|
299
462
|
}
|
|
300
463
|
else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
|
301
464
|
const name = literal(node.moduleReference.expression);
|
|
302
465
|
if (name === null)
|
|
303
|
-
|
|
466
|
+
add(computed);
|
|
304
467
|
else
|
|
305
468
|
specifier(name);
|
|
306
469
|
}
|
|
307
|
-
else if (ts.isCallExpression(node)) {
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
else
|
|
314
|
-
specifier(name);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
else if (ts.isPropertyAccessExpression(node)) {
|
|
318
|
-
if (isProcess(node.expression) && REACHING_PROCESS.has(node.name.text))
|
|
319
|
-
found.add(`process.${node.name.text}`);
|
|
320
|
-
if (node.name.text === "env" &&
|
|
321
|
-
ts.isMetaProperty(node.expression) &&
|
|
322
|
-
node.expression.keywordToken === ts.SyntaxKind.ImportKeyword)
|
|
323
|
-
found.add("import.meta.env");
|
|
470
|
+
else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === "require"))) {
|
|
471
|
+
const name = literal(node.arguments[0]);
|
|
472
|
+
if (name === null)
|
|
473
|
+
add(computed);
|
|
474
|
+
else
|
|
475
|
+
specifier(name);
|
|
324
476
|
}
|
|
325
|
-
else if (ts.isElementAccessExpression(node)) {
|
|
477
|
+
else if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
326
478
|
const target = node.expression;
|
|
327
|
-
const key = literal(node.argumentExpression);
|
|
479
|
+
const key = ts.isPropertyAccessExpression(node) ? node.name.text : literal(node.argumentExpression);
|
|
328
480
|
if (isProcess(target)) {
|
|
329
481
|
if (key === null)
|
|
330
|
-
|
|
482
|
+
add("a process member named by a computed value");
|
|
331
483
|
else if (REACHING_PROCESS.has(key))
|
|
332
|
-
|
|
484
|
+
add(`process.${key}`);
|
|
333
485
|
}
|
|
334
486
|
else if (ts.isIdentifier(target) && GLOBAL_OBJECTS.has(target.text)) {
|
|
335
487
|
if (key === null)
|
|
336
|
-
|
|
488
|
+
add("a global named by a computed value");
|
|
337
489
|
else if (REACHING_GLOBALS.has(key))
|
|
338
|
-
|
|
490
|
+
add(key);
|
|
339
491
|
else if (key === "process")
|
|
340
|
-
|
|
492
|
+
add("process");
|
|
493
|
+
}
|
|
494
|
+
else if (ts.isIdentifier(target) && target.text === "module" && key !== null && MODULE_LOADER.has(key)) {
|
|
495
|
+
add("the module loader (module.require)");
|
|
496
|
+
}
|
|
497
|
+
// `x.constructor`, and `x.constructor.constructor`, are `Function` by
|
|
498
|
+
// another name: only its `.name`, a comparison and `this.constructor`
|
|
499
|
+
// in a class are not.
|
|
500
|
+
if (key === "constructor" && target.kind !== ts.SyntaxKind.ThisKeyword) {
|
|
501
|
+
const parent = node.parent;
|
|
502
|
+
const harmless = (ts.isPropertyAccessExpression(parent) && parent.expression === node && parent.name.text === "name") ||
|
|
503
|
+
(ts.isBinaryExpression(parent) &&
|
|
504
|
+
[
|
|
505
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
506
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
507
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
508
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
509
|
+
ts.SyntaxKind.InstanceOfKeyword,
|
|
510
|
+
].includes(parent.operatorToken.kind));
|
|
511
|
+
if (!harmless)
|
|
512
|
+
add("a constructor reached through .constructor");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
else if (ts.isMetaProperty(node) && node.keywordToken === ts.SyntaxKind.ImportKeyword && node.name.text === "meta") {
|
|
516
|
+
// `import.meta.env`, also through a cast (`(import.meta as any).env`),
|
|
517
|
+
// a computed key, a destructuring or an alias (`const m = import.meta`):
|
|
518
|
+
// only a named member other than `env` (`url`, `dirname`, `glob`) is
|
|
519
|
+
// no reach.
|
|
520
|
+
let outer = node;
|
|
521
|
+
while (ts.isParenthesizedExpression(outer.parent) ||
|
|
522
|
+
ts.isAsExpression(outer.parent) ||
|
|
523
|
+
ts.isNonNullExpression(outer.parent) ||
|
|
524
|
+
ts.isSatisfiesExpression(outer.parent) ||
|
|
525
|
+
ts.isTypeAssertionExpression(outer.parent))
|
|
526
|
+
outer = outer.parent;
|
|
527
|
+
const parent = outer.parent;
|
|
528
|
+
if ((ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && parent.expression === outer) {
|
|
529
|
+
const key = ts.isPropertyAccessExpression(parent) ? parent.name.text : literal(parent.argumentExpression);
|
|
530
|
+
if (key === "env")
|
|
531
|
+
add("import.meta.env");
|
|
532
|
+
else if (key === null)
|
|
533
|
+
add("import.meta named by a computed value");
|
|
341
534
|
}
|
|
535
|
+
else if (ts.isVariableDeclaration(parent) && parent.initializer === outer && ts.isObjectBindingPattern(parent.name)) {
|
|
536
|
+
for (const element of parent.name.elements) {
|
|
537
|
+
const key = element.dotDotDotToken !== undefined
|
|
538
|
+
? null
|
|
539
|
+
: element.propertyName === undefined
|
|
540
|
+
? ts.isIdentifier(element.name)
|
|
541
|
+
? element.name.text
|
|
542
|
+
: null
|
|
543
|
+
: ts.isIdentifier(element.propertyName) || ts.isStringLiteralLike(element.propertyName)
|
|
544
|
+
? element.propertyName.text
|
|
545
|
+
: null;
|
|
546
|
+
if (key === "env")
|
|
547
|
+
add("import.meta.env");
|
|
548
|
+
else if (key === null)
|
|
549
|
+
add("import.meta named by a computed value");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else if (!ts.isTypeOfExpression(parent))
|
|
553
|
+
add("import.meta taken as a value");
|
|
342
554
|
}
|
|
343
555
|
else if (ts.isIdentifier(node) && REACHING_GLOBALS.has(node.text) && !named(node)) {
|
|
344
|
-
|
|
556
|
+
add(node.text);
|
|
557
|
+
}
|
|
558
|
+
else if (ts.isIdentifier(node) && node.text === "require" && !named(node)) {
|
|
559
|
+
// `require("x")` names its module and `require.resolve` loads nothing;
|
|
560
|
+
// `const r = require` loads whatever `r` is later given.
|
|
561
|
+
const parent = node.parent;
|
|
562
|
+
const called = ts.isCallExpression(parent) && parent.expression === node;
|
|
563
|
+
const resolves = ts.isPropertyAccessExpression(parent) && parent.name.text === "resolve";
|
|
564
|
+
if (!called && !resolves)
|
|
565
|
+
add("require taken as a value");
|
|
566
|
+
}
|
|
567
|
+
else if (ts.isIdentifier(node) &&
|
|
568
|
+
(node.text === "globalThis" || node.text === "global" || node.text === "process" || ((node.text === "window" || node.text === "self") && !declared.has(node.text))) &&
|
|
569
|
+
!named(node)) {
|
|
570
|
+
// `process.env` and `globalThis["fetch"]` are judged by name above;
|
|
571
|
+
// `const p = process`, `const { env } = process` or `Reflect.get(process, …)`
|
|
572
|
+
// hide every later access behind another name.
|
|
573
|
+
const parent = node.parent;
|
|
574
|
+
const accessed = ((ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && parent.expression === node) ||
|
|
575
|
+
ts.isTypeOfExpression(parent) ||
|
|
576
|
+
// `vi.spyOn(process, "exit")` replaces a member for a test.
|
|
577
|
+
(ts.isCallExpression(parent) &&
|
|
578
|
+
parent.arguments[0] === node &&
|
|
579
|
+
/^(vi\.spyOn|jest\.spyOn|mock\.method|t\.mock\.method|sinon\.stub)$/.test(parent.expression.getText(source)));
|
|
580
|
+
if (!accessed)
|
|
581
|
+
add(node.text === "process" ? "process taken as a value" : "the global object taken as a value");
|
|
345
582
|
}
|
|
346
583
|
ts.forEachChild(node, visit);
|
|
347
584
|
};
|
|
348
585
|
visit(source);
|
|
349
|
-
return
|
|
350
|
-
}
|
|
351
|
-
/**
|
|
352
|
-
* The reach a script's new text has that its old text did not (`reachOf`),
|
|
353
|
-
* or nothing when it is no script. A file that already read the disk may
|
|
354
|
-
* keep doing so: only what the change adds is a question.
|
|
355
|
-
*/
|
|
356
|
-
export function gainedReach(path, before, after) {
|
|
357
|
-
if (!/\.[cm]?[jt]sx?$/.test(path) || /\.d\.[cm]?ts$/.test(path))
|
|
358
|
-
return [];
|
|
359
|
-
const had = new Set(before === null ? [] : reachOf(path, before));
|
|
360
|
-
return reachOf(path, after).filter((label) => !had.has(label));
|
|
586
|
+
return found;
|
|
361
587
|
}
|
|
362
588
|
/**
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
589
|
+
* What a script reaches beyond its own computation, read from its syntax
|
|
590
|
+
* and never run: Node modules with the disk, the network or the process
|
|
591
|
+
* (`node:fs`), service SDKs, a package the slice does not have, a module
|
|
592
|
+
* outside the slice's packages, network and code-running globals (`fetch`,
|
|
593
|
+
* `eval`, `Function` by any route), the environment (`process.env`,
|
|
594
|
+
* `import.meta.env`, `process.report`), the module loader, a file loaded
|
|
595
|
+
* raw or as native code, and modules or globals named by a computed value
|
|
596
|
+
* or hidden behind one (`const p = process`), which could be any of these.
|
|
597
|
+
* Each as a short label, sorted. Text-level: a hold for a reader, not a
|
|
598
|
+
* sandbox.
|
|
366
599
|
*/
|
|
367
|
-
function
|
|
368
|
-
|
|
369
|
-
return constantOf(node.expression);
|
|
370
|
-
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
|
|
371
|
-
const inner = constantOf(node.operand);
|
|
372
|
-
return inner === null ? null : !inner;
|
|
373
|
-
}
|
|
374
|
-
if (node.kind === ts.SyntaxKind.TrueKeyword)
|
|
375
|
-
return true;
|
|
376
|
-
if (node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword)
|
|
377
|
-
return false;
|
|
378
|
-
if (ts.isIdentifier(node) && node.text === "undefined")
|
|
379
|
-
return false;
|
|
380
|
-
if (ts.isNumericLiteral(node))
|
|
381
|
-
return Number(node.text) !== 0;
|
|
382
|
-
if (ts.isStringLiteralLike(node))
|
|
383
|
-
return node.text !== "";
|
|
384
|
-
return null;
|
|
600
|
+
export function reachOf(path, text, context = {}) {
|
|
601
|
+
return [...reachCounts(path, text, context).keys()].sort();
|
|
385
602
|
}
|
|
386
|
-
/**
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
603
|
+
/** Test runners' globals: read from code that is not a test, they tell a test run from a real one. */
|
|
604
|
+
const TEST_RUNNER_GLOBALS = new Set([
|
|
605
|
+
"describe",
|
|
606
|
+
"it",
|
|
607
|
+
"test",
|
|
608
|
+
"expect",
|
|
609
|
+
"vi",
|
|
610
|
+
"jest",
|
|
611
|
+
"suite",
|
|
612
|
+
"beforeEach",
|
|
613
|
+
"afterEach",
|
|
614
|
+
"beforeAll",
|
|
615
|
+
"afterAll",
|
|
616
|
+
"__vitest_worker__",
|
|
617
|
+
"__vitest_environment__",
|
|
618
|
+
]);
|
|
619
|
+
/** Environment variables a test runner sets in the processes it starts. */
|
|
620
|
+
const TEST_RUNNER_ENV = new Set([
|
|
621
|
+
"VITEST",
|
|
622
|
+
"VITEST_WORKER_ID",
|
|
623
|
+
"VITEST_POOL_ID",
|
|
624
|
+
"VITEST_MODE",
|
|
625
|
+
"JEST_WORKER_ID",
|
|
626
|
+
"NODE_TEST_CONTEXT",
|
|
627
|
+
"MOCHA_WORKER_ID",
|
|
628
|
+
"npm_lifecycle_event",
|
|
629
|
+
]);
|
|
630
|
+
/** What, matched against `process.argv`, names a test run. */
|
|
631
|
+
const RUNNER_ARGUMENT = /\b(vitest|jest|mocha|ava)\b|--test(?![\w-])|\.(test|spec)\.|(?:^|[\\/._-])(?:tests?|spec)\.[cm]?[jt]sx?$|test[-_]runner|node:test/i;
|
|
632
|
+
/** What, searched for anywhere in `process.argv` (not one argument's place), names a test run too. */
|
|
633
|
+
const RUNNER_WORD = /(?<![\w-])(tests?|spec)(?![\w-])/i;
|
|
634
|
+
/** Methods that search `process.argv` as a whole, rather than read one argument. */
|
|
635
|
+
const SEARCHES = new Set(["includes", "some", "find", "findIndex", "join", "filter", "indexOf", "toString", "every"]);
|
|
636
|
+
/** `process` members that end the run, or set the code it ends with. */
|
|
637
|
+
const ENDING_PROCESS = new Set(["exit", "exitCode", "abort", "reallyExit", "kill"]);
|
|
638
|
+
/** Process events a listener can use to swallow a test's failure, or rewrite its exit. */
|
|
639
|
+
const FAILURE_EVENTS = new Set([
|
|
640
|
+
"uncaughtException",
|
|
641
|
+
"uncaughtExceptionMonitor",
|
|
642
|
+
"unhandledRejection",
|
|
643
|
+
"exit",
|
|
644
|
+
"beforeExit",
|
|
645
|
+
]);
|
|
646
|
+
/** Built-ins whose members code under test calls: rewritten, they change every test's answer. */
|
|
647
|
+
const BUILTIN_OBJECTS = new Set([
|
|
648
|
+
"Object",
|
|
649
|
+
"Array",
|
|
650
|
+
"String",
|
|
651
|
+
"Number",
|
|
652
|
+
"Boolean",
|
|
653
|
+
"BigInt",
|
|
654
|
+
"Symbol",
|
|
655
|
+
"Promise",
|
|
656
|
+
"JSON",
|
|
657
|
+
"Math",
|
|
658
|
+
"Date",
|
|
659
|
+
"Function",
|
|
660
|
+
"RegExp",
|
|
661
|
+
"Error",
|
|
662
|
+
"Map",
|
|
663
|
+
"Set",
|
|
664
|
+
"WeakMap",
|
|
665
|
+
"WeakSet",
|
|
666
|
+
"Reflect",
|
|
667
|
+
"Intl",
|
|
668
|
+
]);
|
|
669
|
+
/** Built-in members a test may fake for itself: the clock and the dice. */
|
|
670
|
+
const TEST_FAKES = new Set(["Date.now", "Math.random"]);
|
|
671
|
+
/** Built-in members any code may set: how much of a stack an error keeps. */
|
|
672
|
+
const BUILTIN_SETTINGS = new Set(["Error.stackTraceLimit"]);
|
|
673
|
+
/** Modules a test takes its runner or its assertions from. */
|
|
674
|
+
const RUNNER_MODULES = /^(node:)?(assert(\/strict)?|test)$|^(vitest|chai|expect|@jest\/globals|mocha|bun:test)$/;
|
|
675
|
+
/** Matchers a runner has, which `expect.extend` must not replace. */
|
|
676
|
+
const BUILTIN_MATCHERS = /^(to[A-Z]\w*|resolves|rejects|not)$/;
|
|
677
|
+
/** Functions that run their callback right after the module loads. */
|
|
678
|
+
const SOON = /^(queueMicrotask|setTimeout|setImmediate|setInterval|process\.nextTick)$/;
|
|
679
|
+
/** A label for each way a script could pass a test without the code; the multiset `integrityOf` reads. */
|
|
680
|
+
function integrityCounts(path, text) {
|
|
681
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKindOf(path));
|
|
682
|
+
const testCode = isTestPath(path) || isTestSupportPath(path);
|
|
683
|
+
const found = new Map();
|
|
684
|
+
const add = (label) => found.set(label, (found.get(label) ?? 0) + 1);
|
|
685
|
+
const literal = (node) => node !== undefined && ts.isStringLiteralLike(node) ? node.text : null;
|
|
686
|
+
/** The identifier a member chain starts at, through casts: `assert` for `(assert as any).equal`. */
|
|
414
687
|
const rootOf = (node) => {
|
|
415
688
|
let current = node;
|
|
416
689
|
for (;;) {
|
|
417
690
|
if (ts.isIdentifier(current))
|
|
418
|
-
return current
|
|
691
|
+
return current;
|
|
419
692
|
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current))
|
|
420
693
|
current = current.expression;
|
|
421
|
-
else if (ts.
|
|
422
|
-
current
|
|
423
|
-
|
|
694
|
+
else if (ts.isParenthesizedExpression(current) ||
|
|
695
|
+
ts.isNonNullExpression(current) ||
|
|
696
|
+
ts.isAsExpression(current) ||
|
|
697
|
+
ts.isSatisfiesExpression(current) ||
|
|
698
|
+
ts.isTypeAssertionExpression(current))
|
|
424
699
|
current = current.expression;
|
|
425
700
|
else
|
|
426
701
|
return null;
|
|
427
702
|
}
|
|
428
703
|
};
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
704
|
+
// The names this file binds to its runner and assertion modules, the
|
|
705
|
+
// runner's globals, `process`, the built-ins, and what aliases them.
|
|
706
|
+
const runnerBindings = new Set(["assert", "expect", "vi", "jest", "chai"]);
|
|
707
|
+
const processNames = new Set(["process"]);
|
|
708
|
+
const builtins = new Set(BUILTIN_OBJECTS);
|
|
709
|
+
const bindName = (into, name) => {
|
|
710
|
+
if (ts.isIdentifier(name))
|
|
711
|
+
into.add(name.text);
|
|
712
|
+
};
|
|
713
|
+
const scan = (node) => {
|
|
714
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && RUNNER_MODULES.test(node.moduleSpecifier.text)) {
|
|
715
|
+
const clause = node.importClause;
|
|
716
|
+
if (clause?.name !== undefined)
|
|
717
|
+
runnerBindings.add(clause.name.text);
|
|
718
|
+
const bindings = clause?.namedBindings;
|
|
719
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings))
|
|
720
|
+
runnerBindings.add(bindings.name.text);
|
|
721
|
+
else if (bindings !== undefined)
|
|
722
|
+
for (const element of bindings.elements)
|
|
723
|
+
runnerBindings.add(element.name.text);
|
|
724
|
+
}
|
|
725
|
+
if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
|
|
726
|
+
const init = node.initializer;
|
|
727
|
+
// `const assert = require("node:assert")`
|
|
728
|
+
if (ts.isCallExpression(init) && ts.isIdentifier(init.expression) && init.expression.text === "require" && RUNNER_MODULES.test(literal(init.arguments[0]) ?? ""))
|
|
729
|
+
bindName(runnerBindings, node.name);
|
|
730
|
+
// `const a = assert`, `const O = Object`, `const p = process`
|
|
731
|
+
const root = rootOf(init);
|
|
732
|
+
if (root !== null && root === init) {
|
|
733
|
+
if (runnerBindings.has(root.text))
|
|
734
|
+
bindName(runnerBindings, node.name);
|
|
735
|
+
if (builtins.has(root.text))
|
|
736
|
+
bindName(builtins, node.name);
|
|
737
|
+
if (processNames.has(root.text))
|
|
738
|
+
bindName(processNames, node.name);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
ts.forEachChild(node, scan);
|
|
742
|
+
};
|
|
743
|
+
scan(source);
|
|
744
|
+
const declaredNames = new Set();
|
|
745
|
+
const declare = (node) => {
|
|
746
|
+
if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isImportSpecifier(node) || ts.isImportClause(node) || ts.isNamespaceImport(node)) &&
|
|
747
|
+
node.name !== undefined &&
|
|
748
|
+
ts.isIdentifier(node.name))
|
|
749
|
+
declaredNames.add(node.name.text);
|
|
750
|
+
ts.forEachChild(node, declare);
|
|
751
|
+
};
|
|
752
|
+
declare(source);
|
|
753
|
+
const isProcess = (node) => {
|
|
754
|
+
const root = rootOf(node);
|
|
755
|
+
return ((root !== null && root === node && processNames.has(root.text)) ||
|
|
756
|
+
(ts.isPropertyAccessExpression(node) &&
|
|
757
|
+
node.name.text === "process" &&
|
|
758
|
+
ts.isIdentifier(node.expression) &&
|
|
759
|
+
GLOBAL_OBJECTS.has(node.expression.text)));
|
|
760
|
+
};
|
|
761
|
+
/** `x.name` or `x["name"]`: the member's name when it is written out. */
|
|
762
|
+
const memberName = (node) => ts.isPropertyAccessExpression(node)
|
|
763
|
+
? node.name.text
|
|
764
|
+
: ts.isElementAccessExpression(node)
|
|
765
|
+
? literal(node.argumentExpression)
|
|
766
|
+
: null;
|
|
767
|
+
/** Whether a member chain, or a name, is the runner's or the assertion library's. */
|
|
768
|
+
const onRunner = (node) => {
|
|
769
|
+
const root = rootOf(node);
|
|
770
|
+
return root !== null && runnerBindings.has(root.text);
|
|
771
|
+
};
|
|
772
|
+
/** `Array.prototype.x`, `Math.x`, or `Object` itself: the built-in and the members named, casts left out. */
|
|
773
|
+
const builtinTarget = (node) => {
|
|
774
|
+
const root = rootOf(node);
|
|
775
|
+
if (root === null || !builtins.has(root.text))
|
|
776
|
+
return null;
|
|
777
|
+
const members = [];
|
|
778
|
+
let current = node;
|
|
779
|
+
while (current !== root) {
|
|
780
|
+
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) {
|
|
781
|
+
members.unshift(memberName(current) ?? "[…]");
|
|
782
|
+
current = current.expression;
|
|
783
|
+
}
|
|
784
|
+
else if (ts.isParenthesizedExpression(current) ||
|
|
785
|
+
ts.isNonNullExpression(current) ||
|
|
786
|
+
ts.isAsExpression(current) ||
|
|
787
|
+
ts.isSatisfiesExpression(current) ||
|
|
788
|
+
ts.isTypeAssertionExpression(current))
|
|
789
|
+
current = current.expression;
|
|
790
|
+
else
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
return [BUILTIN_OBJECTS.has(root.text) ? root.text : "a built-in", ...members].join(".");
|
|
794
|
+
};
|
|
795
|
+
/** Whether a node sits in a listener for a signal (`process.on("SIGTERM", …)`), which runs only when one comes. */
|
|
796
|
+
const onSignal = (node) => {
|
|
797
|
+
for (let current = node.parent; current !== undefined; current = current.parent)
|
|
798
|
+
if (ts.isFunctionLike(current) &&
|
|
799
|
+
current.parent !== undefined &&
|
|
800
|
+
ts.isCallExpression(current.parent) &&
|
|
801
|
+
/^SIG[A-Z]+$/.test(literal(current.parent.arguments[0]) ?? ""))
|
|
802
|
+
return true;
|
|
803
|
+
return false;
|
|
804
|
+
};
|
|
805
|
+
/**
|
|
806
|
+
* Whether a node runs when the module loads: at its top level, in an
|
|
807
|
+
* immediately called function, or in a callback a timer or a microtask
|
|
808
|
+
* runs right after; not inside a function something else must call.
|
|
809
|
+
*/
|
|
810
|
+
const runsOnLoad = (node) => {
|
|
811
|
+
for (let current = node.parent; current !== undefined; current = current.parent) {
|
|
812
|
+
if (!ts.isFunctionLike(current))
|
|
813
|
+
continue;
|
|
814
|
+
let call = current.parent;
|
|
815
|
+
while (call !== undefined && ts.isParenthesizedExpression(call))
|
|
816
|
+
call = call.parent;
|
|
817
|
+
const immediately = call !== undefined &&
|
|
818
|
+
ts.isCallExpression(call) &&
|
|
819
|
+
(call.expression === current ||
|
|
820
|
+
(ts.isParenthesizedExpression(call.expression) && call.expression.expression === current) ||
|
|
821
|
+
(call.arguments.some((argument) => argument === current) && SOON.test(call.expression.getText(source))));
|
|
822
|
+
if (!immediately)
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
return true;
|
|
826
|
+
};
|
|
827
|
+
const isTestEnv = (node) => {
|
|
828
|
+
if (!(ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)))
|
|
829
|
+
return null;
|
|
830
|
+
const name = memberName(node);
|
|
831
|
+
const target = node.expression;
|
|
832
|
+
if (name === null || !((ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) && memberName(target) === "env" && isProcess(target.expression)))
|
|
833
|
+
return null;
|
|
834
|
+
return TEST_RUNNER_ENV.has(name) ? `process.env.${name}` : name === "NODE_ENV" ? "NODE_ENV" : null;
|
|
835
|
+
};
|
|
432
836
|
const visit = (node) => {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const name =
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
837
|
+
// Ending the run: `process.exit(0)`, `process.exitCode = 0`, `process.kill(process.pid)`.
|
|
838
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && isProcess(node.expression)) {
|
|
839
|
+
const name = memberName(node);
|
|
840
|
+
const parent = node.parent;
|
|
841
|
+
const called = ts.isCallExpression(parent) && parent.expression === node ? parent : null;
|
|
842
|
+
const assigned = ts.isBinaryExpression(parent) && parent.left === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken ? parent : null;
|
|
843
|
+
// A literal non-zero code fails the run: it fakes no pass.
|
|
844
|
+
const code = called?.arguments[0] ?? assigned?.right;
|
|
845
|
+
const failing = code !== undefined && ts.isNumericLiteral(code) && Number(code.text) !== 0;
|
|
846
|
+
const killsItself = name !== "kill" ||
|
|
847
|
+
(called !== null && /^(globalThis\.)?process\.p?pid$/.test(called.arguments[0]?.getText(source).replace(/\s+/g, "") ?? ""));
|
|
848
|
+
// Anywhere, not only when the module loads: a function of the code
|
|
849
|
+
// under test that exits 0 when a test calls it ends the test file
|
|
850
|
+
// there, and node:test reports the file as passing, the failing
|
|
851
|
+
// assertion after it and the tests below never run.
|
|
852
|
+
if (name !== null && ENDING_PROCESS.has(name) && killsItself && !failing && !onSignal(node))
|
|
853
|
+
add(`process.${name}`);
|
|
854
|
+
if (!testCode && (name === "argv" || name === "execArgv")) {
|
|
855
|
+
// Read from the code under test and matched against a runner's name.
|
|
856
|
+
let statement = node;
|
|
857
|
+
while (statement.parent !== undefined && !ts.isStatement(statement))
|
|
858
|
+
statement = statement.parent;
|
|
859
|
+
const said = [];
|
|
860
|
+
const strings = (child) => {
|
|
861
|
+
if (ts.isStringLiteralLike(child))
|
|
862
|
+
said.push(child.text);
|
|
863
|
+
if (ts.isRegularExpressionLiteral(child))
|
|
864
|
+
said.push(child.text);
|
|
865
|
+
ts.forEachChild(child, strings);
|
|
866
|
+
};
|
|
867
|
+
strings(statement);
|
|
868
|
+
const parent = node.parent;
|
|
869
|
+
const searched = (ts.isPropertyAccessExpression(parent) && parent.expression === node && SEARCHES.has(parent.name.text)) ||
|
|
870
|
+
ts.isTemplateSpan(parent) ||
|
|
871
|
+
(ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === node));
|
|
872
|
+
if (said.some((value) => RUNNER_ARGUMENT.test(value) || (searched && RUNNER_WORD.test(value))))
|
|
873
|
+
add(`process.${name} matched against a test runner`);
|
|
461
874
|
}
|
|
462
875
|
}
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
876
|
+
// Swallowing a failure: `process.on("uncaughtException", () => {})`.
|
|
877
|
+
if (ts.isCallExpression(node)) {
|
|
878
|
+
const callee = node.expression;
|
|
879
|
+
const method = memberName(callee);
|
|
880
|
+
const event = literal(node.arguments[0]);
|
|
881
|
+
if ((ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) &&
|
|
882
|
+
isProcess(callee.expression) &&
|
|
883
|
+
method !== null &&
|
|
884
|
+
["on", "once", "addListener", "prependListener", "prependOnceListener"].includes(method) &&
|
|
885
|
+
event !== null &&
|
|
886
|
+
FAILURE_EVENTS.has(event) &&
|
|
887
|
+
(testCode || runsOnLoad(node)))
|
|
888
|
+
add(`process.${method}("${event}")`);
|
|
889
|
+
// `expect.extend({ toBe })` replaces a matcher; `addEqualityTesters`
|
|
890
|
+
// decides what equal means.
|
|
891
|
+
if (ts.isPropertyAccessExpression(callee) && onRunner(callee.expression)) {
|
|
892
|
+
if (callee.name.text === "addEqualityTesters")
|
|
893
|
+
add("rewrites the test runner or the assertion library");
|
|
894
|
+
if (callee.name.text === "extend") {
|
|
895
|
+
const matchers = node.arguments[0];
|
|
896
|
+
const replaces = matchers === undefined ||
|
|
897
|
+
!ts.isObjectLiteralExpression(matchers) ||
|
|
898
|
+
matchers.properties.some((property) => {
|
|
899
|
+
const key = property.name !== undefined && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
|
|
900
|
+
return key === null || BUILTIN_MATCHERS.test(key);
|
|
901
|
+
});
|
|
902
|
+
if (replaces)
|
|
903
|
+
add("rewrites the test runner or the assertion library");
|
|
904
|
+
}
|
|
905
|
+
if (/^(overwrite|add)(Method|Property|ChainableMethod)$/.test(callee.name.text))
|
|
906
|
+
add("rewrites the test runner or the assertion library");
|
|
475
907
|
}
|
|
476
|
-
return;
|
|
477
908
|
}
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
if (ts.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
visit(node.finallyBlock);
|
|
494
|
-
return;
|
|
909
|
+
// Rewriting the runner, the assertions, or a built-in.
|
|
910
|
+
const written = [];
|
|
911
|
+
if (ts.isBinaryExpression(node) &&
|
|
912
|
+
node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
913
|
+
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment)
|
|
914
|
+
written.push(node.left);
|
|
915
|
+
if (ts.isCallExpression(node)) {
|
|
916
|
+
const callee = node.expression;
|
|
917
|
+
const target = ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) ? callee.expression.text : null;
|
|
918
|
+
const method = ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
919
|
+
const first = node.arguments[0];
|
|
920
|
+
if (first !== undefined &&
|
|
921
|
+
((target === "Object" && ["assign", "defineProperty", "defineProperties", "setPrototypeOf"].includes(method ?? "")) ||
|
|
922
|
+
(target === "Reflect" && ["set", "defineProperty", "setPrototypeOf", "deleteProperty"].includes(method ?? ""))))
|
|
923
|
+
written.push(first);
|
|
495
924
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
925
|
+
for (const target of written) {
|
|
926
|
+
if (onRunner(target))
|
|
927
|
+
add("rewrites the test runner or the assertion library");
|
|
928
|
+
else {
|
|
929
|
+
const member = builtinTarget(target);
|
|
930
|
+
if (member !== null && !BUILTIN_SETTINGS.has(member) && !(testCode && TEST_FAKES.has(member)))
|
|
931
|
+
add(`rewrites ${member}`);
|
|
932
|
+
}
|
|
504
933
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
934
|
+
if (!testCode) {
|
|
935
|
+
// Telling a test run from a real one.
|
|
936
|
+
if (ts.isTypeOfExpression(node) && ts.isIdentifier(node.expression) && TEST_RUNNER_GLOBALS.has(node.expression.text))
|
|
937
|
+
add(`typeof ${node.expression.text}`);
|
|
938
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
|
|
939
|
+
ts.isIdentifier(node.expression) &&
|
|
940
|
+
GLOBAL_OBJECTS.has(node.expression.text)) {
|
|
941
|
+
const name = memberName(node);
|
|
942
|
+
if (name !== null && TEST_RUNNER_GLOBALS.has(name))
|
|
943
|
+
add(`${node.expression.text}.${name}`);
|
|
944
|
+
}
|
|
945
|
+
if (ts.isPropertyAccessExpression(node) &&
|
|
946
|
+
node.name.text === "vitest" &&
|
|
947
|
+
ts.isMetaProperty(node.expression) &&
|
|
948
|
+
node.expression.keywordToken === ts.SyntaxKind.ImportKeyword)
|
|
949
|
+
add("import.meta.vitest");
|
|
950
|
+
if (ts.isIdentifier(node) && (node.text === "__vitest_worker__" || node.text === "__vitest_environment__"))
|
|
951
|
+
add(node.text);
|
|
952
|
+
// `if (it)`, `typeof expect`: a runner's global, read where nothing declares it.
|
|
953
|
+
if (ts.isIdentifier(node) &&
|
|
954
|
+
TEST_RUNNER_GLOBALS.has(node.text) &&
|
|
955
|
+
!node.text.startsWith("__") &&
|
|
956
|
+
!declaredNames.has(node.text) &&
|
|
957
|
+
!ts.isTypeOfExpression(node.parent) &&
|
|
958
|
+
!(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) &&
|
|
959
|
+
!(ts.isPropertyAssignment(node.parent) && node.parent.name === node) &&
|
|
960
|
+
!ts.isTypeReferenceNode(node.parent) &&
|
|
961
|
+
!ts.isQualifiedName(node.parent) &&
|
|
962
|
+
!(node.parent.name === node))
|
|
963
|
+
add(`${node.text} read as a global`);
|
|
964
|
+
const env = isTestEnv(node);
|
|
965
|
+
if (env !== null && env !== "NODE_ENV")
|
|
966
|
+
add(env);
|
|
967
|
+
// `process.env.NODE_ENV === "test"`, either way round, or as a `case`.
|
|
968
|
+
if (ts.isBinaryExpression(node) &&
|
|
969
|
+
[
|
|
970
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
971
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
972
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
973
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
974
|
+
].includes(node.operatorToken.kind)) {
|
|
975
|
+
const sides = [node.left, node.right];
|
|
976
|
+
if (sides.some((side) => isTestEnv(side) === "NODE_ENV") && sides.some((side) => /^test(ing)?$/i.test(literal(side) ?? "")))
|
|
977
|
+
add('NODE_ENV === "test"');
|
|
978
|
+
}
|
|
979
|
+
if (ts.isSwitchStatement(node) && isTestEnv(node.expression) === "NODE_ENV" &&
|
|
980
|
+
node.caseBlock.clauses.some((clause) => ts.isCaseClause(clause) && /^test(ing)?$/i.test(literal(clause.expression) ?? "")))
|
|
981
|
+
add('NODE_ENV === "test"');
|
|
982
|
+
// Reading the stack for the runner's frames.
|
|
983
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === "stack") {
|
|
984
|
+
let statement = node;
|
|
985
|
+
while (statement.parent !== undefined && !ts.isStatement(statement))
|
|
986
|
+
statement = statement.parent;
|
|
987
|
+
if (/test_runner|vitest|jest|node:test/.test(statement.getText(source)))
|
|
988
|
+
add("the stack read for a test runner");
|
|
989
|
+
}
|
|
521
990
|
}
|
|
522
991
|
ts.forEachChild(node, visit);
|
|
523
992
|
};
|
|
524
993
|
visit(source);
|
|
525
|
-
return
|
|
994
|
+
return found;
|
|
526
995
|
}
|
|
527
996
|
/**
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
997
|
+
* How a script could make a test pass without the code under test passing
|
|
998
|
+
* it, read from its syntax and never run. In any script: ending the
|
|
999
|
+
* process early with no failing code (`process.exit(0)` before the tests
|
|
1000
|
+
* run reports success; in the code under test, when it loads), listening
|
|
1001
|
+
* for the failures it would otherwise die of (`process.on("uncaughtException",
|
|
1002
|
+
* …)`), rewriting the assertion library or the runner (`assert.equal = …`,
|
|
1003
|
+
* an alias of it, `expect.extend` over a built-in matcher,
|
|
1004
|
+
* `addEqualityTesters`), and rewriting a built-in (`Object.is = …`), which
|
|
1005
|
+
* a test may do only for the clock and the dice (`Date.now`,
|
|
1006
|
+
* `Math.random`). In code that is not a test nor a test's helper: telling
|
|
1007
|
+
* a test run from a real one (`typeof describe`, `import.meta.vitest`,
|
|
1008
|
+
* `process.env.VITEST`, `NODE_ENV === "test"`, `process.argv` matched
|
|
1009
|
+
* against a runner, the stack read for one). `process` and the built-ins
|
|
1010
|
+
* are followed through aliases and casts. Each as a short label, sorted.
|
|
1011
|
+
* Text-level, like `reachOf`: a hold for a reader, not a sandbox.
|
|
534
1012
|
*/
|
|
535
|
-
export function
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
1013
|
+
export function integrityOf(path, text) {
|
|
1014
|
+
return [...integrityCounts(path, text).keys()].sort();
|
|
1015
|
+
}
|
|
1016
|
+
/** The labels whose count grew from one text to the other. */
|
|
1017
|
+
function grown(before, after) {
|
|
1018
|
+
return [...after.entries()]
|
|
1019
|
+
.filter(([label, count]) => count > (before?.get(label) ?? 0))
|
|
1020
|
+
.map(([label]) => label)
|
|
1021
|
+
.sort();
|
|
1022
|
+
}
|
|
1023
|
+
const isScriptPath = (path) => /\.[cm]?[jt]sx?$/.test(path) && !/\.d\.[cm]?ts$/.test(path);
|
|
1024
|
+
/**
|
|
1025
|
+
* The ways to pass a test without the code that a script's new text has
|
|
1026
|
+
* more of than its old text (`integrityOf`), or nothing when it is no script.
|
|
1027
|
+
*/
|
|
1028
|
+
export function gainedIntegrity(path, before, after) {
|
|
1029
|
+
if (!isScriptPath(path))
|
|
1030
|
+
return [];
|
|
1031
|
+
return grown(before === null ? null : integrityCounts(path, before), integrityCounts(path, after));
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* The reach a script's new text has more of than its old text did
|
|
1035
|
+
* (`reachOf`), or nothing when it is no script. A file that already read
|
|
1036
|
+
* the disk may keep doing so: only what the change adds is a question.
|
|
1037
|
+
*/
|
|
1038
|
+
export function gainedReach(path, before, after, context = {}) {
|
|
1039
|
+
if (!isScriptPath(path))
|
|
1040
|
+
return [];
|
|
1041
|
+
return grown(before === null ? null : reachCounts(path, before, context), reachCounts(path, after, context));
|
|
557
1042
|
}
|
|
1043
|
+
export { weakenedTest };
|