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
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an edit to a test weakens it: read from the syntax of the test
|
|
3
|
+
* as it was and as it is, never run.
|
|
4
|
+
*
|
|
5
|
+
* A test may grow. Its assertions, the statements they rest on (the data
|
|
6
|
+
* passed in, the value compared with, the setup, a fake's method), and
|
|
7
|
+
* the tests it skips or focuses are what it held the code to, and an edit
|
|
8
|
+
* that removes, rewrites, guards or outruns one of them is held for the
|
|
9
|
+
* maintainer: an expected value changed may be the change that was asked
|
|
10
|
+
* for, or a test bent to pass. Statements are compared as TypeScript prints
|
|
11
|
+
* them, so formatting is no change and a string's own characters are.
|
|
12
|
+
*/
|
|
13
|
+
import ts from "typescript";
|
|
14
|
+
/** Calls whose function arguments hold a test or its hooks. */
|
|
15
|
+
export const TEST_STRUCTURE = new Set([
|
|
16
|
+
"describe",
|
|
17
|
+
"suite",
|
|
18
|
+
"it",
|
|
19
|
+
"test",
|
|
20
|
+
"specify",
|
|
21
|
+
"bench",
|
|
22
|
+
"beforeEach",
|
|
23
|
+
"afterEach",
|
|
24
|
+
"beforeAll",
|
|
25
|
+
"afterAll",
|
|
26
|
+
"before",
|
|
27
|
+
"after",
|
|
28
|
+
]);
|
|
29
|
+
/** Of those, the hooks: code that runs before or after every test of its scope. */
|
|
30
|
+
const HOOKS = new Set(["beforeEach", "afterEach", "beforeAll", "afterAll", "before", "after"]);
|
|
31
|
+
/** Of those, the groups: a plain one only names its tests, and wrapping tests in one changes none. */
|
|
32
|
+
const GROUPS = new Set(["describe", "suite"]);
|
|
33
|
+
/** Whether a structural call is a plain group (`describe("x", …)`), adding nothing to what its tests run. */
|
|
34
|
+
const plainGroup = (call) => ts.isIdentifier(call.expression) && GROUPS.has(call.expression.text);
|
|
35
|
+
/** Test calls that run their body only sometimes, or expect it to fail. */
|
|
36
|
+
const CONDITIONAL_TESTS = new Set(["skipIf", "runIf", "fails", "failing", "todoIf"]);
|
|
37
|
+
/** Modules whose every export asserts: `node:assert`'s `equal`, the `expect` package. */
|
|
38
|
+
const ASSERT_EVERY = /^(node:)?assert(\/strict)?$|^expect$/;
|
|
39
|
+
/** Modules a test takes its runner from, with only these names asserting. */
|
|
40
|
+
const ASSERT_NAMED = /^(vitest|@jest\/globals|chai|bun:test|node:test|test)$/;
|
|
41
|
+
const ASSERTING_NAMES = new Set(["expect", "expectTypeOf", "assertType", "assert", "should"]);
|
|
42
|
+
/** The script kind TypeScript should parse a path as. */
|
|
43
|
+
export const scriptKindOf = (path) => /\.[cm]?tsx$|\.jsx$/.test(path) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
44
|
+
const parse = (path, text) => ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKindOf(path));
|
|
45
|
+
const printer = ts.createPrinter({ removeComments: true, newLine: ts.NewLineKind.LineFeed });
|
|
46
|
+
/**
|
|
47
|
+
* A node as TypeScript prints it, its strings in one quoting: the same
|
|
48
|
+
* code, however it is laid out, quoted or semicoloned, prints the same,
|
|
49
|
+
* and a string's own characters are kept. A trailing comma is layout.
|
|
50
|
+
*/
|
|
51
|
+
export function canonical(node, source) {
|
|
52
|
+
const result = ts.transform(node, [
|
|
53
|
+
(context) => (root) => {
|
|
54
|
+
const visit = (child) => ts.isStringLiteral(child) || ts.isNoSubstitutionTemplateLiteral(child)
|
|
55
|
+
? ts.factory.createStringLiteral(child.text)
|
|
56
|
+
: ts.visitEachChild(child, visit, context);
|
|
57
|
+
return ts.visitNode(root, visit);
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
try {
|
|
61
|
+
const printed = printer.printNode(ts.EmitHint.Unspecified, result.transformed[0] ?? node, source);
|
|
62
|
+
return printed.replace(/,(\s*[)\]}])/g, "$1");
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
result.dispose();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The names a test file's assertions go by: `assert` and `expect`, every
|
|
70
|
+
* name imported from an assertion module (`strictEqual` from `node:assert`),
|
|
71
|
+
* and from a runner's module only its asserting names (`expect` from
|
|
72
|
+
* `vitest`, never `describe`), whether imported or required.
|
|
73
|
+
*/
|
|
74
|
+
export function assertionRoots(source) {
|
|
75
|
+
const roots = new Set(["assert", "expect"]);
|
|
76
|
+
const take = (module, names) => {
|
|
77
|
+
if (ASSERT_EVERY.test(module))
|
|
78
|
+
for (const name of names)
|
|
79
|
+
roots.add(name);
|
|
80
|
+
else if (ASSERT_NAMED.test(module))
|
|
81
|
+
for (const name of names)
|
|
82
|
+
if (ASSERTING_NAMES.has(name))
|
|
83
|
+
roots.add(name);
|
|
84
|
+
};
|
|
85
|
+
for (const statement of source.statements) {
|
|
86
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
87
|
+
const clause = statement.importClause;
|
|
88
|
+
const names = [];
|
|
89
|
+
if (clause?.name !== undefined)
|
|
90
|
+
names.push(clause.name.text);
|
|
91
|
+
const bindings = clause?.namedBindings;
|
|
92
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings))
|
|
93
|
+
names.push(bindings.name.text);
|
|
94
|
+
else if (bindings !== undefined)
|
|
95
|
+
for (const element of bindings.elements)
|
|
96
|
+
// `import { expect as check }`: the local name asserts if the exported one does.
|
|
97
|
+
names.push(ASSERT_EVERY.test(statement.moduleSpecifier.text) || ASSERTING_NAMES.has((element.propertyName ?? element.name).text) ? element.name.text : "");
|
|
98
|
+
take(statement.moduleSpecifier.text, names.filter(Boolean));
|
|
99
|
+
}
|
|
100
|
+
// `const assert = require("node:assert")`, `const { expect } = require("chai")`
|
|
101
|
+
if (ts.isVariableStatement(statement))
|
|
102
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
103
|
+
const init = declaration.initializer;
|
|
104
|
+
if (init === undefined ||
|
|
105
|
+
!ts.isCallExpression(init) ||
|
|
106
|
+
!ts.isIdentifier(init.expression) ||
|
|
107
|
+
init.expression.text !== "require" ||
|
|
108
|
+
init.arguments[0] === undefined ||
|
|
109
|
+
!ts.isStringLiteral(init.arguments[0]))
|
|
110
|
+
continue;
|
|
111
|
+
const module = init.arguments[0].text;
|
|
112
|
+
if (ts.isIdentifier(declaration.name))
|
|
113
|
+
take(module, [ASSERT_EVERY.test(module) ? declaration.name.text : ""]);
|
|
114
|
+
else if (ts.isObjectBindingPattern(declaration.name))
|
|
115
|
+
take(module, declaration.name.elements.map((element) => (ts.isIdentifier(element.name) ? element.name.text : "")));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
roots.delete("");
|
|
119
|
+
return roots;
|
|
120
|
+
}
|
|
121
|
+
/** The identifier a call or member chain starts at, or null. */
|
|
122
|
+
export function chainRoot(node) {
|
|
123
|
+
let current = node;
|
|
124
|
+
for (;;) {
|
|
125
|
+
if (ts.isIdentifier(current))
|
|
126
|
+
return current.text;
|
|
127
|
+
if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current))
|
|
128
|
+
current = current.expression;
|
|
129
|
+
else if (ts.isCallExpression(current))
|
|
130
|
+
current = current.expression;
|
|
131
|
+
else if (ts.isNonNullExpression(current) ||
|
|
132
|
+
ts.isParenthesizedExpression(current) ||
|
|
133
|
+
ts.isAsExpression(current) ||
|
|
134
|
+
ts.isSatisfiesExpression(current) ||
|
|
135
|
+
ts.isTypeAssertionExpression(current))
|
|
136
|
+
current = current.expression;
|
|
137
|
+
else
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Whether an expression is a constant the code can read without running
|
|
143
|
+
* it: `true`, `false`, `0`, `1`, `""`, `null`, `undefined`, `!x` of one, or
|
|
144
|
+
* one in parentheses. Null for anything else.
|
|
145
|
+
*/
|
|
146
|
+
export function constantOf(node) {
|
|
147
|
+
if (ts.isParenthesizedExpression(node))
|
|
148
|
+
return constantOf(node.expression);
|
|
149
|
+
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
|
|
150
|
+
const inner = constantOf(node.operand);
|
|
151
|
+
return inner === null ? null : !inner;
|
|
152
|
+
}
|
|
153
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword)
|
|
154
|
+
return true;
|
|
155
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword)
|
|
156
|
+
return false;
|
|
157
|
+
if (ts.isIdentifier(node) && node.text === "undefined")
|
|
158
|
+
return false;
|
|
159
|
+
if (ts.isNumericLiteral(node))
|
|
160
|
+
return Number(node.text) !== 0;
|
|
161
|
+
if (ts.isStringLiteralLike(node))
|
|
162
|
+
return node.text !== "";
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Whether a node holds a `return`, `break` or `continue` outside any
|
|
167
|
+
* function inside it: a way to leave before what follows runs. A `throw`
|
|
168
|
+
* fails the test instead, which weakens nothing.
|
|
169
|
+
*/
|
|
170
|
+
function exits(node) {
|
|
171
|
+
let found = false;
|
|
172
|
+
const visit = (child) => {
|
|
173
|
+
if (found)
|
|
174
|
+
return;
|
|
175
|
+
if (ts.isReturnStatement(child) || ts.isBreakStatement(child) || ts.isContinueStatement(child)) {
|
|
176
|
+
found = true;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (ts.isFunctionLike(child) || ts.isClassLike(child))
|
|
180
|
+
return;
|
|
181
|
+
ts.forEachChild(child, visit);
|
|
182
|
+
};
|
|
183
|
+
visit(node);
|
|
184
|
+
return found;
|
|
185
|
+
}
|
|
186
|
+
/** Whether a call is a test or a hook: its callee's chain starts at one of their names. */
|
|
187
|
+
function structural(call) {
|
|
188
|
+
return TEST_STRUCTURE.has(chainRoot(call.expression) ?? "");
|
|
189
|
+
}
|
|
190
|
+
/** A test call's header: its callee and its arguments other than functions, printed. */
|
|
191
|
+
function headerOf(call, source) {
|
|
192
|
+
return `${canonical(call.expression, source)}(${call.arguments
|
|
193
|
+
.filter((argument) => !ts.isArrowFunction(argument) && !ts.isFunctionExpression(argument))
|
|
194
|
+
.map((argument) => canonical(argument, source))
|
|
195
|
+
.join(",")})`;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The assertions of a test file, each as what it runs under and its
|
|
199
|
+
* printed text, and the tests it skips or focuses. An assertion is a call
|
|
200
|
+
* rooted at an assertion name (`assertionRoots`); what it runs under is
|
|
201
|
+
* every condition, loop, early exit, callback and test between it and the
|
|
202
|
+
* file (`guardsOf`), so an assertion moved under `if (result)`, after an
|
|
203
|
+
* `if (x) return;`, into a callback nothing calls or into another test
|
|
204
|
+
* counts as rewritten. Code that never runs holds no assertion. A skip is
|
|
205
|
+
* `test.skip`, `it.todo`, `xit`, a `{ skip }` or `{ todo }` option (any
|
|
206
|
+
* key spelling, or options not written out), `.skip()` or `.todo()` on a
|
|
207
|
+
* test's context, a conditional test (`it.skipIf`, `it.fails`) or an
|
|
208
|
+
* empty table (`it.each([])`); a focus is `.only`, `fit` or `fdescribe`.
|
|
209
|
+
*/
|
|
210
|
+
function assertionsOf(path, text) {
|
|
211
|
+
const source = parse(path, text);
|
|
212
|
+
const roots = assertionRoots(source);
|
|
213
|
+
/** The test contexts in scope: the first parameter of each test's function, by name. */
|
|
214
|
+
const guardsOf = (node) => {
|
|
215
|
+
const guards = [];
|
|
216
|
+
let child = node;
|
|
217
|
+
for (let current = node.parent; current !== undefined; current = current.parent) {
|
|
218
|
+
// An earlier statement of the same block that may leave it.
|
|
219
|
+
if (ts.isBlock(current) || ts.isSourceFile(current) || ts.isCaseClause(current) || ts.isDefaultClause(current) || ts.isModuleBlock(current)) {
|
|
220
|
+
const statements = current.statements;
|
|
221
|
+
const at = statements.indexOf(child);
|
|
222
|
+
for (const earlier of statements.slice(0, Math.max(0, at)))
|
|
223
|
+
if (exits(earlier))
|
|
224
|
+
guards.push(`after(${canonical(earlier, source)})`);
|
|
225
|
+
}
|
|
226
|
+
if (ts.isSourceFile(current))
|
|
227
|
+
break;
|
|
228
|
+
if (ts.isIfStatement(current)) {
|
|
229
|
+
const known = constantOf(current.expression);
|
|
230
|
+
if (child === current.thenStatement && known !== true)
|
|
231
|
+
guards.push(`if(${canonical(current.expression, source)})`);
|
|
232
|
+
if (child === current.elseStatement && known !== false)
|
|
233
|
+
guards.push(`else(${canonical(current.expression, source)})`);
|
|
234
|
+
}
|
|
235
|
+
else if (ts.isConditionalExpression(current)) {
|
|
236
|
+
if (child === current.whenTrue)
|
|
237
|
+
guards.push(`?(${canonical(current.condition, source)})`);
|
|
238
|
+
if (child === current.whenFalse)
|
|
239
|
+
guards.push(`:(${canonical(current.condition, source)})`);
|
|
240
|
+
}
|
|
241
|
+
else if (ts.isBinaryExpression(current) && child === current.right) {
|
|
242
|
+
const operator = current.operatorToken.kind;
|
|
243
|
+
const known = constantOf(current.left);
|
|
244
|
+
if (operator === ts.SyntaxKind.AmpersandAmpersandToken && known !== true)
|
|
245
|
+
guards.push(`&&(${canonical(current.left, source)})`);
|
|
246
|
+
if (operator === ts.SyntaxKind.BarBarToken && known !== false)
|
|
247
|
+
guards.push(`||(${canonical(current.left, source)})`);
|
|
248
|
+
if (operator === ts.SyntaxKind.QuestionQuestionToken)
|
|
249
|
+
guards.push(`??(${canonical(current.left, source)})`);
|
|
250
|
+
}
|
|
251
|
+
else if (ts.isForOfStatement(current) || ts.isForInStatement(current)) {
|
|
252
|
+
if (child === current.statement)
|
|
253
|
+
guards.push(`for(${canonical(current.expression, source)})`);
|
|
254
|
+
}
|
|
255
|
+
else if (ts.isForStatement(current) || ts.isWhileStatement(current)) {
|
|
256
|
+
if (child === current.statement)
|
|
257
|
+
guards.push(`loop(${ts.isForStatement(current) ? (current.condition === undefined ? "" : canonical(current.condition, source)) : canonical(current.expression, source)})`);
|
|
258
|
+
}
|
|
259
|
+
else if (ts.isCaseClause(current)) {
|
|
260
|
+
guards.push(`case(${canonical(current.expression, source)})`);
|
|
261
|
+
}
|
|
262
|
+
else if (ts.isDefaultClause(current)) {
|
|
263
|
+
guards.push("default");
|
|
264
|
+
}
|
|
265
|
+
else if (ts.isLabeledStatement(current)) {
|
|
266
|
+
guards.push(`label(${current.label.text})`);
|
|
267
|
+
}
|
|
268
|
+
else if (ts.isCatchClause(current)) {
|
|
269
|
+
// Runs only when the block before it failed. (A `try` whose catch
|
|
270
|
+
// swallows the failure is `assertionsOf`'s; one that throws again,
|
|
271
|
+
// and a `finally`, run as the code around them.)
|
|
272
|
+
guards.push("catch");
|
|
273
|
+
}
|
|
274
|
+
else if (ts.isFunctionLike(current)) {
|
|
275
|
+
// A test's or a hook's own function adds its header; any other
|
|
276
|
+
// function runs only when something calls it.
|
|
277
|
+
const call = current.parent;
|
|
278
|
+
if (call !== undefined && ts.isCallExpression(call) && call.arguments.some((argument) => argument === current) && structural(call)) {
|
|
279
|
+
if (!plainGroup(call))
|
|
280
|
+
guards.push(headerOf(call, source));
|
|
281
|
+
}
|
|
282
|
+
else
|
|
283
|
+
guards.push(`fn(${call !== undefined && ts.isCallExpression(call) ? canonical(call.expression, source) : ts.isFunctionDeclaration(current) ? (current.name?.text ?? "") : String(current.kind)})`);
|
|
284
|
+
}
|
|
285
|
+
child = current;
|
|
286
|
+
}
|
|
287
|
+
return guards.reverse().join(">");
|
|
288
|
+
};
|
|
289
|
+
/** The names a test's context goes by in the functions around a node. */
|
|
290
|
+
const contextNames = (node) => {
|
|
291
|
+
const names = new Set();
|
|
292
|
+
for (let current = node.parent; current !== undefined; current = current.parent)
|
|
293
|
+
if (ts.isFunctionLike(current) && current.parent !== undefined && ts.isCallExpression(current.parent) && structural(current.parent)) {
|
|
294
|
+
const first = current.parameters[0];
|
|
295
|
+
if (first !== undefined && ts.isIdentifier(first.name))
|
|
296
|
+
names.add(first.name.text);
|
|
297
|
+
if (first !== undefined && ts.isObjectBindingPattern(first.name))
|
|
298
|
+
for (const element of first.name.elements)
|
|
299
|
+
if (ts.isIdentifier(element.name))
|
|
300
|
+
names.add(`{${(element.propertyName ?? element.name).getText(source)}}${element.name.text}`);
|
|
301
|
+
}
|
|
302
|
+
return names;
|
|
303
|
+
};
|
|
304
|
+
const assertions = [];
|
|
305
|
+
let skips = 0;
|
|
306
|
+
let focused = 0;
|
|
307
|
+
const visit = (node) => {
|
|
308
|
+
if (ts.isCallExpression(node)) {
|
|
309
|
+
const callee = node.expression;
|
|
310
|
+
const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
311
|
+
const base = chainRoot(callee);
|
|
312
|
+
const testCall = base !== null && ["test", "it", "describe", "suite", "specify"].includes(base);
|
|
313
|
+
if ((name === "skip" || name === "todo") && testCall)
|
|
314
|
+
skips += 1;
|
|
315
|
+
if (name !== null && CONDITIONAL_TESTS.has(name) && testCall)
|
|
316
|
+
skips += 1;
|
|
317
|
+
// `it.each([])`: a table with no rows runs nothing.
|
|
318
|
+
if (name === "each" &&
|
|
319
|
+
testCall &&
|
|
320
|
+
node.arguments.length === 1 &&
|
|
321
|
+
ts.isArrayLiteralExpression(node.arguments[0]) &&
|
|
322
|
+
node.arguments[0].elements.length === 0)
|
|
323
|
+
skips += 1;
|
|
324
|
+
if (name === "only" && testCall)
|
|
325
|
+
focused += 1;
|
|
326
|
+
if (ts.isIdentifier(callee) && (callee.text === "fit" || callee.text === "fdescribe"))
|
|
327
|
+
focused += 1;
|
|
328
|
+
if (ts.isIdentifier(callee) && ["xit", "xdescribe", "xtest", "xspecify"].includes(callee.text))
|
|
329
|
+
skips += 1;
|
|
330
|
+
// `t.skip()`, `ctx.todo()`, or `skip()` taken from a test's context.
|
|
331
|
+
if ((name === "skip" || name === "todo") &&
|
|
332
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
333
|
+
ts.isIdentifier(callee.expression) &&
|
|
334
|
+
contextNames(node).has(callee.expression.text))
|
|
335
|
+
skips += 1;
|
|
336
|
+
if (ts.isIdentifier(callee) &&
|
|
337
|
+
[...contextNames(node)].some((entry) => entry === `{skip}${callee.text}` || entry === `{todo}${callee.text}`))
|
|
338
|
+
skips += 1;
|
|
339
|
+
if (testCall)
|
|
340
|
+
for (const argument of node.arguments) {
|
|
341
|
+
if (ts.isObjectLiteralExpression(argument))
|
|
342
|
+
for (const property of argument.properties) {
|
|
343
|
+
const key = ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)
|
|
344
|
+
? ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)
|
|
345
|
+
? property.name.text
|
|
346
|
+
: null
|
|
347
|
+
: null;
|
|
348
|
+
// A spread or a computed key could be either.
|
|
349
|
+
if (key === null)
|
|
350
|
+
skips += 1;
|
|
351
|
+
else if ((key === "skip" || key === "todo") &&
|
|
352
|
+
!(ts.isPropertyAssignment(property) && property.initializer.kind === ts.SyntaxKind.FalseKeyword))
|
|
353
|
+
skips += 1;
|
|
354
|
+
}
|
|
355
|
+
// Options not written out (`test("x", opts, fn)`): they could skip it.
|
|
356
|
+
else if (!ts.isStringLiteralLike(argument) &&
|
|
357
|
+
!ts.isTemplateExpression(argument) &&
|
|
358
|
+
!ts.isArrowFunction(argument) &&
|
|
359
|
+
!ts.isFunctionExpression(argument) &&
|
|
360
|
+
!ts.isNumericLiteral(argument) &&
|
|
361
|
+
!(ts.isIdentifier(argument) && argument === node.arguments[node.arguments.length - 1]) &&
|
|
362
|
+
argument !== node.arguments[0])
|
|
363
|
+
skips += 1;
|
|
364
|
+
}
|
|
365
|
+
if (base !== null && roots.has(base)) {
|
|
366
|
+
// The outermost call of a chain is the assertion: `expect(x).toBe(y)`.
|
|
367
|
+
const guards = guardsOf(node);
|
|
368
|
+
const printed = canonical(node, source);
|
|
369
|
+
assertions.push(guards === "" ? printed : `${guards}>${printed}`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Code that never runs holds no assertion: what follows a `return`,
|
|
374
|
+
// `throw`, `break` or `continue` in its block, and the branch of an
|
|
375
|
+
// `if` (or the body of a loop) on a constant that never takes it.
|
|
376
|
+
if (ts.isBlock(node) || ts.isSourceFile(node) || ts.isCaseClause(node) || ts.isDefaultClause(node) || ts.isModuleBlock(node)) {
|
|
377
|
+
for (const statement of node.statements) {
|
|
378
|
+
visit(statement);
|
|
379
|
+
if (ts.isReturnStatement(statement) ||
|
|
380
|
+
ts.isThrowStatement(statement) ||
|
|
381
|
+
ts.isBreakStatement(statement) ||
|
|
382
|
+
ts.isContinueStatement(statement))
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// An assertion in a `try` fails into its `catch`, which passes unless it
|
|
388
|
+
// ends by throwing again; a `finally` that leaves swallows it too.
|
|
389
|
+
if (ts.isTryStatement(node)) {
|
|
390
|
+
const catchClause = node.catchClause;
|
|
391
|
+
const last = catchClause?.block.statements.at(-1);
|
|
392
|
+
const rethrows = catchClause === undefined || (last !== undefined && ts.isThrowStatement(last));
|
|
393
|
+
const finallyLeaves = node.finallyBlock !== undefined && exits(node.finallyBlock);
|
|
394
|
+
if (rethrows && !finallyLeaves)
|
|
395
|
+
visit(node.tryBlock);
|
|
396
|
+
if (catchClause !== undefined)
|
|
397
|
+
visit(catchClause.block);
|
|
398
|
+
if (node.finallyBlock !== undefined)
|
|
399
|
+
visit(node.finallyBlock);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (ts.isIfStatement(node)) {
|
|
403
|
+
const known = constantOf(node.expression);
|
|
404
|
+
visit(node.expression);
|
|
405
|
+
if (known !== false)
|
|
406
|
+
visit(node.thenStatement);
|
|
407
|
+
if (known !== true && node.elseStatement !== undefined)
|
|
408
|
+
visit(node.elseStatement);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const loopCondition = ts.isWhileStatement(node) ? node.expression : ts.isForStatement(node) ? node.condition : undefined;
|
|
412
|
+
if (loopCondition !== undefined && constantOf(loopCondition) === false)
|
|
413
|
+
return;
|
|
414
|
+
if (ts.isBinaryExpression(node) && constantOf(node.left) !== null) {
|
|
415
|
+
const known = constantOf(node.left);
|
|
416
|
+
visit(node.left);
|
|
417
|
+
const operator = node.operatorToken.kind;
|
|
418
|
+
if ((operator === ts.SyntaxKind.AmpersandAmpersandToken && known === true) ||
|
|
419
|
+
(operator === ts.SyntaxKind.BarBarToken && known === false) ||
|
|
420
|
+
(operator !== ts.SyntaxKind.AmpersandAmpersandToken && operator !== ts.SyntaxKind.BarBarToken))
|
|
421
|
+
visit(node.right);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
ts.forEachChild(node, visit);
|
|
425
|
+
};
|
|
426
|
+
visit(source);
|
|
427
|
+
return { assertions, skips, focused };
|
|
428
|
+
}
|
|
429
|
+
/** The names a declaration statement binds. */
|
|
430
|
+
function declaredNames(node) {
|
|
431
|
+
const names = [];
|
|
432
|
+
const bind = (name) => {
|
|
433
|
+
if (ts.isIdentifier(name))
|
|
434
|
+
names.push(name.text);
|
|
435
|
+
else
|
|
436
|
+
for (const element of name.elements)
|
|
437
|
+
if (!ts.isOmittedExpression(element))
|
|
438
|
+
bind(element.name);
|
|
439
|
+
};
|
|
440
|
+
if (ts.isVariableStatement(node))
|
|
441
|
+
for (const declaration of node.declarationList.declarations)
|
|
442
|
+
bind(declaration.name);
|
|
443
|
+
if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) && node.name !== undefined)
|
|
444
|
+
names.push(node.name.text);
|
|
445
|
+
return names;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The statements of a test file that a change may add to but not alter,
|
|
449
|
+
* each printed with the tests and hooks around it: the code a test's
|
|
450
|
+
* verdict rests on besides its assertions (its data, its setup, its mocks,
|
|
451
|
+
* its helpers). The walk descends into the bodies of tests and hooks, so a
|
|
452
|
+
* test added inside a `describe` leaves the others as they were, and into
|
|
453
|
+
* class bodies, so a method added to a fake leaves the others. Imports
|
|
454
|
+
* count by the names they bind. Assertions are `assertionsOf`'s.
|
|
455
|
+
*/
|
|
456
|
+
function statementsOf(path, text) {
|
|
457
|
+
const source = parse(path, text);
|
|
458
|
+
const roots = assertionRoots(source);
|
|
459
|
+
const out = [];
|
|
460
|
+
const statement = (node, prefix, scope) => {
|
|
461
|
+
const parent = prefix.endsWith(">") ? prefix.slice(0, -1) : prefix;
|
|
462
|
+
if (ts.isImportDeclaration(node)) {
|
|
463
|
+
const from = ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : canonical(node.moduleSpecifier, source);
|
|
464
|
+
const quoted = JSON.stringify(from);
|
|
465
|
+
const clause = node.importClause;
|
|
466
|
+
const add = (key, name) => out.push({ key: `${prefix}${key}`, scope, declares: [name], kind: "import", parent, from, node });
|
|
467
|
+
if (clause === undefined)
|
|
468
|
+
out.push({ key: `${prefix}import ${quoted}`, scope, declares: [], kind: "import", parent, from, node });
|
|
469
|
+
else {
|
|
470
|
+
if (clause.name !== undefined)
|
|
471
|
+
add(`import ${clause.name.text} from ${quoted}`, clause.name.text);
|
|
472
|
+
const bindings = clause.namedBindings;
|
|
473
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings))
|
|
474
|
+
add(`import * as ${bindings.name.text} from ${quoted}`, bindings.name.text);
|
|
475
|
+
else if (bindings !== undefined)
|
|
476
|
+
for (const element of bindings.elements)
|
|
477
|
+
add(`import { ${canonical(element, source)} } from ${quoted}`, element.name.text);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) {
|
|
482
|
+
const call = node.expression;
|
|
483
|
+
const root = chainRoot(call.expression);
|
|
484
|
+
if (root !== null && roots.has(root))
|
|
485
|
+
return;
|
|
486
|
+
if (root !== null && TEST_STRUCTURE.has(root)) {
|
|
487
|
+
const header = headerOf(call, source);
|
|
488
|
+
out.push({ key: `${prefix}${header}`, scope, declares: [], kind: HOOKS.has(root) ? "hook" : "test", parent, node });
|
|
489
|
+
// A plain group's body runs as the file's top does; a test's or a
|
|
490
|
+
// hook's statements are its own.
|
|
491
|
+
const inner = plainGroup(call) ? prefix : `${prefix}${header}>`;
|
|
492
|
+
const innerScope = plainGroup(call) ? scope : "body";
|
|
493
|
+
for (const argument of call.arguments)
|
|
494
|
+
if (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) {
|
|
495
|
+
if (ts.isBlock(argument.body))
|
|
496
|
+
for (const each of argument.body.statements)
|
|
497
|
+
statement(each, inner, innerScope);
|
|
498
|
+
else
|
|
499
|
+
out.push({
|
|
500
|
+
key: `${inner}${canonical(argument.body, source)}`,
|
|
501
|
+
scope: innerScope,
|
|
502
|
+
declares: [],
|
|
503
|
+
kind: "other",
|
|
504
|
+
parent: inner.endsWith(">") ? inner.slice(0, -1) : inner,
|
|
505
|
+
node: argument.body,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (ts.isClassDeclaration(node)) {
|
|
512
|
+
const head = source.text.slice(node.getStart(source), node.members.pos).replace(/\s+/g, " ").trim();
|
|
513
|
+
out.push({ key: `${prefix}${head}`, scope, declares: declaredNames(node), kind: "other", parent, node });
|
|
514
|
+
for (const member of node.members)
|
|
515
|
+
out.push({ key: `${prefix}${head}>${canonical(member, source)}`, scope, declares: [], kind: "member", parent, node: member });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
out.push({ key: `${prefix}${canonical(node, source)}`, scope, declares: declaredNames(node), kind: "other", parent, node });
|
|
519
|
+
};
|
|
520
|
+
for (const node of source.statements)
|
|
521
|
+
statement(node, "", "top");
|
|
522
|
+
// The assertions, by where they start, printed.
|
|
523
|
+
const assertionsAt = [];
|
|
524
|
+
const findAssertions = (node) => {
|
|
525
|
+
if (ts.isCallExpression(node)) {
|
|
526
|
+
const root = chainRoot(node.expression);
|
|
527
|
+
if (root !== null && roots.has(root)) {
|
|
528
|
+
assertionsAt.push({ at: node.getStart(source), text: canonical(node, source) });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
ts.forEachChild(node, findAssertions);
|
|
533
|
+
};
|
|
534
|
+
findAssertions(source);
|
|
535
|
+
/** Whether a node holds an assertion, and no other statement that does something. */
|
|
536
|
+
const onlyAssertions = (node) => {
|
|
537
|
+
let assertions = 0;
|
|
538
|
+
let other = false;
|
|
539
|
+
const walk = (child) => {
|
|
540
|
+
if (other)
|
|
541
|
+
return;
|
|
542
|
+
if (ts.isCallExpression(child)) {
|
|
543
|
+
const root = chainRoot(child.expression);
|
|
544
|
+
if (root !== null && roots.has(root)) {
|
|
545
|
+
assertions += 1;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (ts.isExpressionStatement(child) && !(ts.isCallExpression(child.expression) && roots.has(chainRoot(child.expression.expression) ?? ""))) {
|
|
550
|
+
other = true;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (ts.isVariableStatement(child) || ts.isFunctionLike(child) || ts.isClassLike(child)) {
|
|
554
|
+
other = true;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// A catch runs once the test has failed; whether it swallows that is
|
|
558
|
+
// the assertions' question.
|
|
559
|
+
if (ts.isCatchClause(child))
|
|
560
|
+
return;
|
|
561
|
+
ts.forEachChild(child, walk);
|
|
562
|
+
};
|
|
563
|
+
walk(node);
|
|
564
|
+
return assertions > 0 && !other;
|
|
565
|
+
};
|
|
566
|
+
/** `const x = [..]` or `const x = {..}`: its head and its literal's parts, printed. */
|
|
567
|
+
const literalOf = (node) => {
|
|
568
|
+
if (!ts.isVariableStatement(node) || node.declarationList.declarations.length !== 1)
|
|
569
|
+
return undefined;
|
|
570
|
+
const declaration = node.declarationList.declarations[0];
|
|
571
|
+
let init = declaration?.initializer;
|
|
572
|
+
while (init !== undefined && (ts.isAsExpression(init) || ts.isSatisfiesExpression(init) || ts.isParenthesizedExpression(init)))
|
|
573
|
+
init = init.expression;
|
|
574
|
+
if (declaration === undefined || init === undefined)
|
|
575
|
+
return undefined;
|
|
576
|
+
const head = `${ts.tokenToString(node.declarationList.flags & ts.NodeFlags.Const ? ts.SyntaxKind.ConstKeyword : ts.SyntaxKind.LetKeyword)} ${canonical(declaration.name, source)}${declaration.type === undefined ? "" : `: ${canonical(declaration.type, source)}`}`;
|
|
577
|
+
if (ts.isArrayLiteralExpression(init))
|
|
578
|
+
return { head: `${head} = [`, parts: init.elements.map((element) => canonical(element, source)) };
|
|
579
|
+
if (ts.isObjectLiteralExpression(init))
|
|
580
|
+
return { head: `${head} = {`, parts: init.properties.map((property) => canonical(property, source)) };
|
|
581
|
+
return undefined;
|
|
582
|
+
};
|
|
583
|
+
return out.map(({ node, ...entry }) => {
|
|
584
|
+
const block = node.parent;
|
|
585
|
+
const end = node.getEnd();
|
|
586
|
+
const start = node.getStart(source);
|
|
587
|
+
const literal = entry.kind === "other" ? literalOf(node) : undefined;
|
|
588
|
+
return {
|
|
589
|
+
...entry,
|
|
590
|
+
...(literal === undefined ? {} : { literal }),
|
|
591
|
+
assertionsInside: assertionsAt.filter(({ at }) => at >= start && at < end).map(({ text }) => text),
|
|
592
|
+
onlyAssertions: entry.kind === "other" && onlyAssertions(node),
|
|
593
|
+
assertionsAfter: entry.scope === "body" && block !== undefined
|
|
594
|
+
? assertionsAt.filter(({ at }) => at > end && at < block.getEnd()).map(({ text }) => text)
|
|
595
|
+
: [],
|
|
596
|
+
};
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
/** How much of a statement's key is its own text, past the tests around it. */
|
|
600
|
+
function canonicalLength(statements, key) {
|
|
601
|
+
const entry = statements.find((each) => each.key === key);
|
|
602
|
+
return entry === undefined ? key.length : key.length - (entry.parent === "" ? 0 : entry.parent.length + 1);
|
|
603
|
+
}
|
|
604
|
+
/** Every assertion of a file, printed, wherever it is. */
|
|
605
|
+
function assertionTexts(path, text) {
|
|
606
|
+
const source = parse(path, text);
|
|
607
|
+
const roots = assertionRoots(source);
|
|
608
|
+
const found = [];
|
|
609
|
+
const visit = (node) => {
|
|
610
|
+
if (ts.isCallExpression(node)) {
|
|
611
|
+
const root = chainRoot(node.expression);
|
|
612
|
+
if (root !== null && roots.has(root)) {
|
|
613
|
+
found.push(canonical(node, source));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
ts.forEachChild(node, visit);
|
|
618
|
+
};
|
|
619
|
+
visit(source);
|
|
620
|
+
return found;
|
|
621
|
+
}
|
|
622
|
+
/** What `before` holds that `after` does not, counted as multisets. */
|
|
623
|
+
export function removedFrom(before, after) {
|
|
624
|
+
const left = new Map();
|
|
625
|
+
for (const item of after)
|
|
626
|
+
left.set(item, (left.get(item) ?? 0) + 1);
|
|
627
|
+
const removed = [];
|
|
628
|
+
for (const item of before) {
|
|
629
|
+
const count = left.get(item) ?? 0;
|
|
630
|
+
if (count > 0)
|
|
631
|
+
left.set(item, count - 1);
|
|
632
|
+
else
|
|
633
|
+
removed.push(item);
|
|
634
|
+
}
|
|
635
|
+
return removed;
|
|
636
|
+
}
|
|
637
|
+
/** Whether a module specifier names the code's own module: a path, or a path alias. */
|
|
638
|
+
export function ownSpecifier(specifier) {
|
|
639
|
+
return /^(\.|\/|~\/|#|@\/)/.test(specifier);
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* How a test file stands in for the code's own modules: a module mocked by
|
|
643
|
+
* specifier (`vi.mock("./cart")`, `jest.mock`, `mock.module`) that is the
|
|
644
|
+
* code's own or named by a computed value, a function spied on or a member
|
|
645
|
+
* written on a binding imported from one (`vi.spyOn(pricing, "round")`,
|
|
646
|
+
* `mock.method(cart, "total")`, `pricing.round = …`). A package's mock
|
|
647
|
+
* (`vi.mock("stripe")`) stands in for a service, not for the code.
|
|
648
|
+
*/
|
|
649
|
+
function ownModuleMocks(path, text) {
|
|
650
|
+
const source = parse(path, text);
|
|
651
|
+
const own = new Set();
|
|
652
|
+
for (const statement of source.statements) {
|
|
653
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
654
|
+
continue;
|
|
655
|
+
if (!ownSpecifier(statement.moduleSpecifier.text))
|
|
656
|
+
continue;
|
|
657
|
+
const clause = statement.importClause;
|
|
658
|
+
if (clause?.name !== undefined)
|
|
659
|
+
own.add(clause.name.text);
|
|
660
|
+
const bindings = clause?.namedBindings;
|
|
661
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings))
|
|
662
|
+
own.add(bindings.name.text);
|
|
663
|
+
else if (bindings !== undefined)
|
|
664
|
+
for (const element of bindings.elements)
|
|
665
|
+
own.add(element.name.text);
|
|
666
|
+
}
|
|
667
|
+
const mocked = [];
|
|
668
|
+
const visit = (node) => {
|
|
669
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
670
|
+
const callee = node.expression;
|
|
671
|
+
const method = callee.name.text;
|
|
672
|
+
const target = callee.expression;
|
|
673
|
+
const byModule = (["mock", "doMock", "unstable_mockModule"].includes(method) &&
|
|
674
|
+
ts.isIdentifier(target) &&
|
|
675
|
+
(target.text === "vi" || target.text === "jest")) ||
|
|
676
|
+
(method === "module" &&
|
|
677
|
+
((ts.isIdentifier(target) && target.text === "mock") ||
|
|
678
|
+
(ts.isPropertyAccessExpression(target) && target.name.text === "mock")));
|
|
679
|
+
if (byModule) {
|
|
680
|
+
const first = node.arguments[0];
|
|
681
|
+
const name = first !== undefined && ts.isStringLiteralLike(first) ? first.text : null;
|
|
682
|
+
if (name === null)
|
|
683
|
+
mocked.push("a module named by a computed value");
|
|
684
|
+
else if (ownSpecifier(name))
|
|
685
|
+
mocked.push(name);
|
|
686
|
+
}
|
|
687
|
+
const spies = (method === "spyOn" && ts.isIdentifier(target) && ["vi", "jest"].includes(target.text)) ||
|
|
688
|
+
(method === "method" && ((ts.isIdentifier(target) && target.text === "mock") || (ts.isPropertyAccessExpression(target) && target.name.text === "mock"))) ||
|
|
689
|
+
(["stub", "replace", "spy"].includes(method) && ts.isIdentifier(target) && target.text === "sinon");
|
|
690
|
+
const first = node.arguments[0];
|
|
691
|
+
if (spies && first !== undefined) {
|
|
692
|
+
const root = chainRoot(first);
|
|
693
|
+
if (root !== null && own.has(root))
|
|
694
|
+
mocked.push(`${root} (spied on)`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (ts.isBinaryExpression(node) &&
|
|
698
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
699
|
+
(ts.isPropertyAccessExpression(node.left) || ts.isElementAccessExpression(node.left))) {
|
|
700
|
+
const root = chainRoot(node.left);
|
|
701
|
+
if (root !== null && own.has(root))
|
|
702
|
+
mocked.push(`${root} (a member written)`);
|
|
703
|
+
}
|
|
704
|
+
ts.forEachChild(node, visit);
|
|
705
|
+
};
|
|
706
|
+
visit(source);
|
|
707
|
+
return mocked;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Why a change to a test weakens it, or null: an assertion removed,
|
|
711
|
+
* rewritten or put where it may not run; a test skipped or focused; a
|
|
712
|
+
* statement the assertions rest on rewritten or removed; a mock, spy or
|
|
713
|
+
* member write of the code's own module added, which can stand in for the
|
|
714
|
+
* code under test; or a statement added that changes what a kept assertion
|
|
715
|
+
* reads (one that declares a name the test already reads, a hook in a
|
|
716
|
+
* scope that holds tests, top-level code, or a statement before a kept
|
|
717
|
+
* assertion in its body). A new test, a new import, a new helper with a
|
|
718
|
+
* name of its own and statements after the last assertion pass.
|
|
719
|
+
*/
|
|
720
|
+
export function weakenedTest(path, before, after, noun = "a test the slice was handed") {
|
|
721
|
+
const old = assertionsOf(path, before);
|
|
722
|
+
const now = assertionsOf(path, after);
|
|
723
|
+
const oldStatements = statementsOf(path, before);
|
|
724
|
+
const newStatements = statementsOf(path, after);
|
|
725
|
+
// A data table or a fake object that only grew (a row, a field) keeps
|
|
726
|
+
// every part it had: kept, and the growth is no new statement.
|
|
727
|
+
const grownFrom = new Map();
|
|
728
|
+
const removedKeys = removedFrom(oldStatements.map((entry) => entry.key), newStatements.map((entry) => entry.key));
|
|
729
|
+
const addedKeys = removedFrom(newStatements.map((entry) => entry.key), oldStatements.map((entry) => entry.key));
|
|
730
|
+
for (const oldKey of removedKeys) {
|
|
731
|
+
const was = oldStatements.find((entry) => entry.key === oldKey)?.literal;
|
|
732
|
+
if (was === undefined)
|
|
733
|
+
continue;
|
|
734
|
+
const prefix = oldKey.slice(0, oldKey.length - canonicalLength(oldStatements, oldKey));
|
|
735
|
+
const grown = addedKeys.find((newKey) => {
|
|
736
|
+
if (grownFrom.has(newKey) || !newKey.startsWith(prefix))
|
|
737
|
+
return false;
|
|
738
|
+
const now = newStatements.find((entry) => entry.key === newKey)?.literal;
|
|
739
|
+
return now !== undefined && now.head === was.head && removedFrom(was.parts, now.parts).length === 0;
|
|
740
|
+
});
|
|
741
|
+
if (grown !== undefined)
|
|
742
|
+
grownFrom.set(grown, oldKey);
|
|
743
|
+
}
|
|
744
|
+
const grownOld = new Set(grownFrom.values());
|
|
745
|
+
const rewritten = removedKeys.filter((key) => !grownOld.has(key)).length;
|
|
746
|
+
const mocks = [...new Set(removedFrom(ownModuleMocks(path, after), ownModuleMocks(path, before)))].sort();
|
|
747
|
+
// What the change adds, and whether each can change a kept assertion's verdict.
|
|
748
|
+
const added = addedKeys.filter((key) => !grownFrom.has(key));
|
|
749
|
+
const readBefore = new Set();
|
|
750
|
+
const names = (node) => {
|
|
751
|
+
if (ts.isIdentifier(node))
|
|
752
|
+
readBefore.add(node.text);
|
|
753
|
+
ts.forEachChild(node, names);
|
|
754
|
+
};
|
|
755
|
+
names(parse(path, before));
|
|
756
|
+
const oldKeys = new Set(oldStatements.map((entry) => entry.key));
|
|
757
|
+
const keptAssertions = new Set(assertionTexts(path, before));
|
|
758
|
+
const risky = new Set();
|
|
759
|
+
const pool = new Map();
|
|
760
|
+
for (const entry of newStatements)
|
|
761
|
+
pool.set(entry.key, [...(pool.get(entry.key) ?? []), entry]);
|
|
762
|
+
for (const key of added) {
|
|
763
|
+
const entry = pool.get(key)?.shift();
|
|
764
|
+
if (entry === undefined)
|
|
765
|
+
continue;
|
|
766
|
+
// A new test's own statements run for it alone.
|
|
767
|
+
if (entry.parent !== "" && !oldKeys.has(entry.parent))
|
|
768
|
+
continue;
|
|
769
|
+
if (entry.kind === "test" || entry.kind === "member" || entry.onlyAssertions)
|
|
770
|
+
continue;
|
|
771
|
+
// A new block, loop or branch around a kept assertion, with code of its
|
|
772
|
+
// own: what the assertion reads may be that code's.
|
|
773
|
+
if (entry.assertionsInside.some((text) => keptAssertions.has(text))) {
|
|
774
|
+
risky.add(key);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (entry.kind === "import") {
|
|
778
|
+
// A name the test read, bound anew: unless from its runner, what it reads changed.
|
|
779
|
+
const runner = entry.from !== undefined && (ASSERT_EVERY.test(entry.from) || ASSERT_NAMED.test(entry.from));
|
|
780
|
+
if (!runner && entry.declares.some((name) => readBefore.has(name)))
|
|
781
|
+
risky.add(key);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (entry.kind === "hook") {
|
|
785
|
+
risky.add(key);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (entry.declares.length > 0) {
|
|
789
|
+
// A helper with a name of its own passes; one that shadows a name the test reads does not.
|
|
790
|
+
if (entry.declares.some((name) => readBefore.has(name)))
|
|
791
|
+
risky.add(key);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (entry.scope === "top" || entry.assertionsAfter.some((text) => keptAssertions.has(text)))
|
|
795
|
+
risky.add(key);
|
|
796
|
+
}
|
|
797
|
+
const reasons = [];
|
|
798
|
+
const removed = removedFrom(old.assertions, now.assertions).length;
|
|
799
|
+
if (removed > 0)
|
|
800
|
+
reasons.push(`removes or rewrites ${removed} assertion${removed === 1 ? "" : "s"} of ${noun}`);
|
|
801
|
+
if (now.skips > old.skips)
|
|
802
|
+
reasons.push(`skips ${now.skips - old.skips} more test${now.skips - old.skips === 1 ? "" : "s"}`);
|
|
803
|
+
if (now.focused > old.focused)
|
|
804
|
+
reasons.push(`focuses ${now.focused - old.focused} more test${now.focused - old.focused === 1 ? "" : "s"} with .only, which skips the rest of the file`);
|
|
805
|
+
if (rewritten > 0)
|
|
806
|
+
reasons.push(`rewrites or removes ${rewritten} other statement${rewritten === 1 ? "" : "s"} of ${noun} (the data, setup, mocks or helpers its assertions rest on)`);
|
|
807
|
+
if (mocks.length > 0)
|
|
808
|
+
reasons.push(`mocks ${mocks.length} more of the code's own modules (${mocks.join(", ")}), which can stand in for the code under test`);
|
|
809
|
+
if (risky.size > 0)
|
|
810
|
+
reasons.push(`adds ${risky.size} statement${risky.size === 1 ? "" : "s"} that run before ${noun}'s assertions or shadow a name they read`);
|
|
811
|
+
return reasons.length === 0 ? null : reasons.join(", and ");
|
|
812
|
+
}
|