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.
Files changed (78) hide show
  1. package/AGENTS.md +21 -3
  2. package/NOTICE.md +2 -1
  3. package/README.md +248 -473
  4. package/dist/src/account.d.ts +37 -0
  5. package/dist/src/account.js +275 -0
  6. package/dist/src/api.d.ts +162 -0
  7. package/dist/src/api.js +134 -0
  8. package/dist/src/carve.d.ts +788 -0
  9. package/dist/src/carve.js +1721 -125
  10. package/dist/src/cli.d.ts +10 -0
  11. package/dist/src/cli.js +674 -47
  12. package/dist/src/command.d.ts +10 -0
  13. package/dist/src/core.d.ts +11 -0
  14. package/dist/src/cut.d.ts +78 -0
  15. package/dist/src/cut.js +25 -7
  16. package/dist/src/extract/compiler-config.d.ts +49 -0
  17. package/dist/src/extract/fixture.d.ts +103 -0
  18. package/dist/src/extract/hash.d.ts +6 -0
  19. package/dist/src/extract/package-registry.d.ts +47 -0
  20. package/dist/src/extract/package-types.d.ts +52 -0
  21. package/dist/src/extract/surface.d.ts +44 -0
  22. package/dist/src/extract/typescript.d.ts +180 -0
  23. package/dist/src/gate.d.ts +55 -0
  24. package/dist/src/gate.js +1 -0
  25. package/dist/src/graph.d.ts +142 -0
  26. package/dist/src/graph.js +4 -3
  27. package/dist/src/impact.d.ts +113 -0
  28. package/dist/src/imports.d.ts +67 -0
  29. package/dist/src/mergeback.d.ts +182 -0
  30. package/dist/src/mergeback.js +580 -35
  31. package/dist/src/platform.d.ts +69 -0
  32. package/dist/src/platform.js +184 -0
  33. package/dist/src/policy.d.ts +181 -0
  34. package/dist/src/policy.js +716 -230
  35. package/dist/src/project/build.d.ts +23 -0
  36. package/dist/src/project/catalog.d.ts +25 -0
  37. package/dist/src/project/project.d.ts +287 -0
  38. package/dist/src/project/project.js +479 -52
  39. package/dist/src/project/provenance.d.ts +73 -0
  40. package/dist/src/project/provenance.js +12 -0
  41. package/dist/src/project/runner.d.ts +19 -0
  42. package/dist/src/project/secrets.d.ts +64 -0
  43. package/dist/src/project/vitest-config.d.ts +70 -0
  44. package/dist/src/project/vitest-config.js +291 -0
  45. package/dist/src/pseudonyms/aliases.d.ts +107 -0
  46. package/dist/src/pseudonyms/aliases.js +394 -0
  47. package/dist/src/pseudonyms/draft.d.ts +64 -0
  48. package/dist/src/pseudonyms/draft.js +284 -0
  49. package/dist/src/pseudonyms/folder.d.ts +172 -0
  50. package/dist/src/pseudonyms/folder.js +527 -0
  51. package/dist/src/pseudonyms/index.d.ts +11 -0
  52. package/dist/src/pseudonyms/index.js +11 -0
  53. package/dist/src/pseudonyms/vocabulary.d.ts +113 -0
  54. package/dist/src/pseudonyms/vocabulary.js +522 -0
  55. package/dist/src/readiness.d.ts +71 -0
  56. package/dist/src/readiness.js +131 -0
  57. package/dist/src/receipt.d.ts +154 -0
  58. package/dist/src/receipt.js +296 -0
  59. package/dist/src/sandbox.d.ts +53 -0
  60. package/dist/src/sandbox.js +154 -0
  61. package/dist/src/slice/analysis.d.ts +75 -0
  62. package/dist/src/slice/externals.d.ts +51 -0
  63. package/dist/src/slice/graph.d.ts +69 -0
  64. package/dist/src/slice/manifest.d.ts +262 -0
  65. package/dist/src/slice/manifest.js +4 -3
  66. package/dist/src/slice/reach.d.ts +204 -0
  67. package/dist/src/slice/reach.js +11 -5
  68. package/dist/src/slice/report.d.ts +49 -0
  69. package/dist/src/slice/report.js +5 -2
  70. package/dist/src/specifiers.d.ts +48 -0
  71. package/dist/src/status.d.ts +46 -0
  72. package/dist/src/status.js +3 -2
  73. package/dist/src/test-integrity.d.ts +54 -0
  74. package/dist/src/test-integrity.js +812 -0
  75. package/package.json +24 -5
  76. package/skills/carveout/SKILL.md +518 -0
  77. package/skills/carveout-gate/SKILL.md +98 -0
  78. package/SKILL.md +0 -423
@@ -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
- /** How many check suppressions a script's text holds. */
164
- export function suppressionsOf(text) {
165
- return text.match(SUPPRESSION)?.length ?? 0;
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 or test-runner configuration): review it and pass --allow to carry it";
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 === "changed" && isTestPath(path) && texts !== undefined && texts.before !== null) {
193
- const weakened = weakenedTest(path, texts.before, texts.after, noun);
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
- /** What a `process` member reaches: its environment, or native code. */
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
- * What a script reaches beyond its own computation, read from its syntax
251
- * and never run: Node modules with the disk, the network or the process
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
- export function reachOf(path, text) {
258
- const kind = /\.[cm]?tsx$|\.jsx$/.test(path) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
259
- const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind);
260
- const found = new Set();
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 (isNodeBuiltin(name)) {
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
- found.add(`node:${root}`);
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
- found.add(name);
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
- if (name !== null && !(ts.isImportDeclaration(node) && node.importClause?.isTypeOnly === true))
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
- found.add(computed);
466
+ add(computed);
304
467
  else
305
468
  specifier(name);
306
469
  }
307
- else if (ts.isCallExpression(node)) {
308
- const callee = node.expression;
309
- if (callee.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(callee) && callee.text === "require")) {
310
- const name = literal(node.arguments[0]);
311
- if (name === null)
312
- found.add(computed);
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
- found.add("a process member named by a computed value");
482
+ add("a process member named by a computed value");
331
483
  else if (REACHING_PROCESS.has(key))
332
- found.add(`process.${key}`);
484
+ add(`process.${key}`);
333
485
  }
334
486
  else if (ts.isIdentifier(target) && GLOBAL_OBJECTS.has(target.text)) {
335
487
  if (key === null)
336
- found.add("a global named by a computed value");
488
+ add("a global named by a computed value");
337
489
  else if (REACHING_GLOBALS.has(key))
338
- found.add(key);
490
+ add(key);
339
491
  else if (key === "process")
340
- found.add("process");
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
- found.add(node.text);
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 [...found].sort();
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
- * Whether an expression is a constant the code can read without running
364
- * it: `true`, `false`, `0`, `1`, `""`, `null`, `undefined`, `!x` of one, or
365
- * one in parentheses. Null for anything else.
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 constantOf(node) {
368
- if (ts.isParenthesizedExpression(node))
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
- /** Modules whose calls are assertions, by import specifier. */
387
- const ASSERTION_MODULES = /^(node:)?assert(\/strict)?$|^(vitest|chai|expect|@jest\/globals)$/;
388
- /**
389
- * The assertions of a test file, each as its whitespace-free text, and the
390
- * tests it skips. An assertion is a call rooted at `assert` or `expect`,
391
- * or at a name imported from an assertion module (`strictEqual` from
392
- * `node:assert`); a skip is `test.skip`, `it.todo`, `describe.skip`,
393
- * `xit`, `xdescribe`, or a `{ skip }` or `{ todo }` option; a focus is
394
- * `test.only`, `describe.only`, `fit` or `fdescribe`, which skips the rest.
395
- */
396
- function assertionsOf(path, text) {
397
- const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true);
398
- const roots = new Set(["assert", "expect"]);
399
- for (const statement of source.statements) {
400
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
401
- continue;
402
- if (!ASSERTION_MODULES.test(statement.moduleSpecifier.text))
403
- continue;
404
- const clause = statement.importClause;
405
- if (clause?.name !== undefined)
406
- roots.add(clause.name.text);
407
- const bindings = clause?.namedBindings;
408
- if (bindings !== undefined && ts.isNamespaceImport(bindings))
409
- roots.add(bindings.name.text);
410
- else if (bindings !== undefined)
411
- for (const element of bindings.elements)
412
- roots.add(element.name.text);
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.text;
691
+ return current;
419
692
  if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current))
420
693
  current = current.expression;
421
- else if (ts.isCallExpression(current))
422
- current = current.expression;
423
- else if (ts.isNonNullExpression(current) || ts.isParenthesizedExpression(current))
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
- const assertions = [];
430
- let skips = 0;
431
- let focused = 0;
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
- if (ts.isCallExpression(node)) {
434
- const callee = node.expression;
435
- const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
436
- const base = rootOf(callee);
437
- if ((name === "skip" || name === "todo") &&
438
- base !== null &&
439
- ["test", "it", "describe", "suite"].includes(base))
440
- skips += 1;
441
- if (name === "only" && base !== null && ["test", "it", "describe", "suite"].includes(base))
442
- focused += 1;
443
- if (ts.isIdentifier(callee) && (callee.text === "fit" || callee.text === "fdescribe"))
444
- focused += 1;
445
- if (ts.isIdentifier(callee) && (callee.text === "xit" || callee.text === "xdescribe" || callee.text === "xtest"))
446
- skips += 1;
447
- if (base !== null && ["test", "it", "describe", "suite"].includes(base))
448
- for (const argument of node.arguments)
449
- if (ts.isObjectLiteralExpression(argument))
450
- for (const property of argument.properties)
451
- if (ts.isPropertyAssignment(property) &&
452
- ts.isIdentifier(property.name) &&
453
- (property.name.text === "skip" || property.name.text === "todo") &&
454
- property.initializer.kind !== ts.SyntaxKind.FalseKeyword)
455
- skips += 1;
456
- if (base !== null && roots.has(base)) {
457
- // The outermost call of a chain is the assertion: `expect(x).toBe(y)`.
458
- // Formatting is not a change: whitespace and trailing commas go.
459
- assertions.push(node.getText(source).replace(/\s+/g, "").replace(/,(?=[)\]}])/g, ""));
460
- return;
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
- // Code that never runs holds no assertion: what follows a `return`,
464
- // `throw`, `break` or `continue` in its block, and the branch of an
465
- // `if` (or the body of a loop) on a constant that never takes it. A
466
- // test emptied by `return;` at its top keeps every assertion's text.
467
- if (ts.isBlock(node) || ts.isSourceFile(node) || ts.isCaseClause(node) || ts.isDefaultClause(node) || ts.isModuleBlock(node)) {
468
- for (const statement of node.statements) {
469
- visit(statement);
470
- if (ts.isReturnStatement(statement) ||
471
- ts.isThrowStatement(statement) ||
472
- ts.isBreakStatement(statement) ||
473
- ts.isContinueStatement(statement))
474
- return;
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
- // An assertion in a `try` whose `catch` does not throw again fails
479
- // into the catch and passes: `try { assert(...) } catch {}`.
480
- if (ts.isTryStatement(node) && node.catchClause !== undefined) {
481
- let rethrows = false;
482
- const find = (child) => {
483
- if (ts.isThrowStatement(child))
484
- rethrows = true;
485
- else if (!ts.isFunctionLike(child))
486
- ts.forEachChild(child, find);
487
- };
488
- find(node.catchClause.block);
489
- if (rethrows)
490
- visit(node.tryBlock);
491
- visit(node.catchClause.block);
492
- if (node.finallyBlock !== undefined)
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
- if (ts.isIfStatement(node)) {
497
- const known = constantOf(node.expression);
498
- visit(node.expression);
499
- if (known !== false)
500
- visit(node.thenStatement);
501
- if (known !== true && node.elseStatement !== undefined)
502
- visit(node.elseStatement);
503
- return;
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
- const loopCondition = ts.isWhileStatement(node)
506
- ? node.expression
507
- : ts.isForStatement(node)
508
- ? node.condition
509
- : undefined;
510
- if (loopCondition !== undefined && constantOf(loopCondition) === false)
511
- return;
512
- if (ts.isBinaryExpression(node) && constantOf(node.left) !== null) {
513
- const known = constantOf(node.left);
514
- visit(node.left);
515
- const operator = node.operatorToken.kind;
516
- if ((operator === ts.SyntaxKind.AmpersandAmpersandToken && known === true) ||
517
- (operator === ts.SyntaxKind.BarBarToken && known === false) ||
518
- (operator !== ts.SyntaxKind.AmpersandAmpersandToken && operator !== ts.SyntaxKind.BarBarToken))
519
- visit(node.right);
520
- return;
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 { assertions, skips, focused };
994
+ return found;
526
995
  }
527
996
  /**
528
- * Why a change to a test the slice was handed weakens it, or null: an
529
- * assertion removed or rewritten (an expected value changed), a test
530
- * skipped, or one focused with `.only`. Assertions only added pass. A
531
- * rewritten assertion may be the change itself (a behaviour the
532
- * contributor was asked to change), so it is refused for a maintainer to
533
- * read, not judged wrong.
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 weakenedTest(path, before, after, noun = "a test the slice was handed") {
536
- const old = assertionsOf(path, before);
537
- const now = assertionsOf(path, after);
538
- const left = new Map();
539
- for (const assertion of now.assertions)
540
- left.set(assertion, (left.get(assertion) ?? 0) + 1);
541
- let removed = 0;
542
- for (const assertion of old.assertions) {
543
- const count = left.get(assertion) ?? 0;
544
- if (count > 0)
545
- left.set(assertion, count - 1);
546
- else
547
- removed += 1;
548
- }
549
- const reasons = [];
550
- if (removed > 0)
551
- reasons.push(`removes or rewrites ${removed} assertion${removed === 1 ? "" : "s"} of ${noun}`);
552
- if (now.skips > old.skips)
553
- reasons.push(`skips ${now.skips - old.skips} more test${now.skips - old.skips === 1 ? "" : "s"}`);
554
- if (now.focused > old.focused)
555
- reasons.push(`focuses ${now.focused - old.focused} more test${now.focused - old.focused === 1 ? "" : "s"} with .only, which skips the rest of the file`);
556
- return reasons.length === 0 ? null : reasons.join(", and ");
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 };