@cplieger/deadset-ts 8.0.0-dev.1 → 8.0.0-dev.3
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/dist/src/analysis.js +30 -6
- package/dist/src/augmentations.js +105 -12
- package/dist/src/build-scripts.js +437 -0
- package/dist/src/conformance-record.js +1 -1
- package/dist/src/convention-rows.js +62 -2
- package/dist/src/conventions.js +15 -0
- package/dist/src/dependencies.js +89 -0
- package/dist/src/entry-points.js +9 -18
- package/dist/src/findings/completion.js +122 -49
- package/dist/src/findings/intra-function.js +4 -2
- package/dist/src/findings/reads-and-writes.js +7 -6
- package/dist/src/findings/unused-declarations.js +13 -5
- package/dist/src/findings/visibility-narrowing.js +8 -3
- package/dist/src/graph.js +5 -3
- package/dist/src/intra-function-parts.js +45 -17
- package/dist/src/kinds.js +8 -8
- package/dist/src/manifest.js +18 -0
- package/dist/src/matrix.js +76 -4
- package/dist/src/references.js +34 -3
- package/dist/src/report.js +23 -4
- package/dist/src/reporters/sarif.js +12 -8
- package/dist/src/roots.js +138 -11
- package/dist/src/serialization-contract.js +258 -8
- package/dist/src/suppress.js +1 -0
- package/dist/src/sweep.js +7 -9
- package/dist/src/test-globals.js +234 -0
- package/dist/src/test-support.js +8 -11
- package/dist/src/type-errors.js +11 -3
- package/dist/src/unseen-loads.js +107 -0
- package/dist/src/verbs/analyze.js +24 -2
- package/dist/src/yaml-document.js +355 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +48 -8
- package/src/augmentations.ts +134 -16
- package/src/build-scripts.ts +564 -0
- package/src/conformance-record.ts +1 -1
- package/src/convention-rows.ts +72 -2
- package/src/conventions.ts +16 -0
- package/src/dependencies.ts +96 -0
- package/src/entry-points.ts +10 -25
- package/src/file-facts.ts +3 -0
- package/src/finding.ts +5 -0
- package/src/findings/completion.ts +153 -56
- package/src/findings/intra-function.ts +4 -2
- package/src/findings/reads-and-writes.ts +7 -6
- package/src/findings/unused-declarations.ts +17 -5
- package/src/findings/visibility-narrowing.ts +9 -3
- package/src/graph.ts +5 -3
- package/src/intra-function-parts.ts +55 -16
- package/src/kinds.ts +8 -8
- package/src/manifest.ts +21 -3
- package/src/matrix.ts +122 -4
- package/src/references.ts +38 -2
- package/src/report.ts +56 -16
- package/src/reporters/sarif.ts +16 -8
- package/src/roots.ts +162 -12
- package/src/serialization-contract.ts +309 -7
- package/src/suppress.ts +1 -0
- package/src/sweep.ts +21 -9
- package/src/test-globals.ts +275 -0
- package/src/test-support.ts +17 -13
- package/src/type-errors.ts +14 -3
- package/src/unseen-loads.ts +145 -0
- package/src/verbs/analyze.ts +28 -2
- package/src/yaml-document.ts +377 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the project handing such a parameter to a destination is one, to a fixpoint, and a
|
|
7
7
|
* function invoked through `call`, `apply` or `bind` is the callee.
|
|
8
8
|
*/
|
|
9
|
-
import { isArrayLiteralExpression, isArrowFunction, isAsExpression, isCallExpression, isClassDeclaration, isClassExpression, isClassStaticBlockDeclaration, isFunctionDeclaration, isFunctionExpression, isGetAccessorDeclaration, isIdentifier, isInterfaceDeclaration, isMethodDeclaration, isNoSubstitutionTemplateLiteral, isNumericLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPropertyAccessExpression, isPropertyDeclaration, isSatisfiesExpression, isSetAccessorDeclaration, isSpreadElement, isStringLiteral, isTypeLiteralNode, isVariableDeclaration, ModifierFlags, NodeFlags, SyntaxKind, } from "@typescript/native/ast";
|
|
9
|
+
import { isArrayLiteralExpression, isArrowFunction, isAsExpression, isCallExpression, isClassDeclaration, isClassExpression, isClassStaticBlockDeclaration, isFunctionDeclaration, isFunctionExpression, isFunctionLikeDeclaration, isGetAccessorDeclaration, isIdentifier, isInterfaceDeclaration, isMethodDeclaration, isNoSubstitutionTemplateLiteral, isNumericLiteral, isObjectLiteralExpression, isParenthesizedExpression, isForInStatement, isPropertyAccessExpression, isPropertyDeclaration, isSatisfiesExpression, isSetAccessorDeclaration, isSpreadElement, isStringLiteral, isTypeLiteralNode, isVariableDeclaration, ModifierFlags, NodeFlags, SyntaxKind, } from "@typescript/native/ast";
|
|
10
10
|
import { SignatureKind, SymbolFlags, TypeFlags, } from "@typescript/native/sync";
|
|
11
11
|
import { aliasChains } from "./alias-chain.js";
|
|
12
12
|
import { calleeName, resolvedTargets, valuesPassed } from "./calls.js";
|
|
@@ -173,9 +173,31 @@ function walkProject(project, held) {
|
|
|
173
173
|
}
|
|
174
174
|
return { sites, parameters };
|
|
175
175
|
}
|
|
176
|
+
/** The calls of `Object` that read every own property of their argument by name. */
|
|
177
|
+
const ENUMERATIONS = new Set([
|
|
178
|
+
"keys",
|
|
179
|
+
"entries",
|
|
180
|
+
"values",
|
|
181
|
+
"getOwnPropertyNames",
|
|
182
|
+
"assign",
|
|
183
|
+
]);
|
|
176
184
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
185
|
+
* Whether a parameter type keeps no member name of the value it is given: `unknown`, `any`,
|
|
186
|
+
* or an object type of a string index signature and no property.
|
|
187
|
+
*/
|
|
188
|
+
function keepsNoName(project, type) {
|
|
189
|
+
if ((type.flags & ERASED) !== 0) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
if ((type.flags & TypeFlags.Object) === 0 ||
|
|
193
|
+
must(project.queries.propertiesOf(type)).length > 0) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return must(project.queries.indexInfosOf(type)).some((info) => (info.keyType.flags & TypeFlags.String) !== 0);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The parameters that keep no member name of the value they are given, as `keepsNoName`
|
|
200
|
+
* reads it, and a rest parameter whose elements keep none, each by its symbol.
|
|
179
201
|
*/
|
|
180
202
|
function erasedParameters(project, parameters) {
|
|
181
203
|
const types = typesAt(project, parameters.map((parameter) => parameter.name));
|
|
@@ -185,10 +207,10 @@ function erasedParameters(project, parameters) {
|
|
|
185
207
|
return false;
|
|
186
208
|
}
|
|
187
209
|
if (!parameter.rest) {
|
|
188
|
-
return (type
|
|
210
|
+
return keepsNoName(project, type);
|
|
189
211
|
}
|
|
190
212
|
const [element] = must(project.queries.typeArguments(type));
|
|
191
|
-
return element !== undefined && (element
|
|
213
|
+
return element !== undefined && keepsNoName(project, element);
|
|
192
214
|
});
|
|
193
215
|
const bySymbol = new Map();
|
|
194
216
|
project
|
|
@@ -202,6 +224,103 @@ function erasedParameters(project, parameters) {
|
|
|
202
224
|
});
|
|
203
225
|
return bySymbol;
|
|
204
226
|
}
|
|
227
|
+
/** Whether one node is a function, whose body runs only where something calls it. */
|
|
228
|
+
function isFunctionNode(node) {
|
|
229
|
+
return (isFunctionDeclaration(node) ||
|
|
230
|
+
isFunctionExpression(node) ||
|
|
231
|
+
isArrowFunction(node) ||
|
|
232
|
+
isMethodDeclaration(node) ||
|
|
233
|
+
isGetAccessorDeclaration(node) ||
|
|
234
|
+
isSetAccessorDeclaration(node));
|
|
235
|
+
}
|
|
236
|
+
/** The name a function nested in another is called by: its own, or the variable it initializes. */
|
|
237
|
+
function nestedName(fn) {
|
|
238
|
+
if (isFunctionDeclaration(fn)) {
|
|
239
|
+
return fn.name?.text;
|
|
240
|
+
}
|
|
241
|
+
let at = fn.parent;
|
|
242
|
+
while (isParenthesizedExpression(at) || isAsExpression(at) || isSatisfiesExpression(at)) {
|
|
243
|
+
at = at.parent;
|
|
244
|
+
}
|
|
245
|
+
return isVariableDeclaration(at) && isIdentifier(at.name) ? at.name.text : undefined;
|
|
246
|
+
}
|
|
247
|
+
/** The names one function's body writes as identifiers, other than the names its declarations bind. */
|
|
248
|
+
function namesWritten(fn) {
|
|
249
|
+
const found = new Set();
|
|
250
|
+
const visit = (node) => {
|
|
251
|
+
const declares = (isFunctionDeclaration(node.parent) || isVariableDeclaration(node.parent)) &&
|
|
252
|
+
node.parent.name === node;
|
|
253
|
+
if (isIdentifier(node) && !declares) {
|
|
254
|
+
found.add(node.text);
|
|
255
|
+
}
|
|
256
|
+
node.forEachChild(visit);
|
|
257
|
+
};
|
|
258
|
+
fn.forEachChild(visit);
|
|
259
|
+
return found;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The erased parameters their own function enumerates: one an `Object.keys`, `entries`,
|
|
263
|
+
* `values`, `getOwnPropertyNames` or `assign` call takes, `Object` the standard library's,
|
|
264
|
+
* or a `for…in` statement walks, which reads every property of the value by its name. A
|
|
265
|
+
* nested function is read as the function's own only where its name is written in it, or
|
|
266
|
+
* it has none, a callback or a function called where it is written.
|
|
267
|
+
*/
|
|
268
|
+
function enumeratedParameters(project, erased) {
|
|
269
|
+
const asked = [];
|
|
270
|
+
for (const parameter of erased.values()) {
|
|
271
|
+
const fn = parameter.name.parent.parent;
|
|
272
|
+
const spelled = parameter.name.getText();
|
|
273
|
+
const written = namesWritten(fn);
|
|
274
|
+
const visit = (node) => {
|
|
275
|
+
if (isFunctionNode(node)) {
|
|
276
|
+
const name = nestedName(node);
|
|
277
|
+
if (name !== undefined && !written.has(name)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const read = [];
|
|
282
|
+
let object;
|
|
283
|
+
if (isCallExpression(node)) {
|
|
284
|
+
const callee = node.expression;
|
|
285
|
+
if (isPropertyAccessExpression(callee) &&
|
|
286
|
+
isIdentifier(callee.expression) &&
|
|
287
|
+
callee.expression.text === "Object" &&
|
|
288
|
+
ENUMERATIONS.has(callee.name.text)) {
|
|
289
|
+
object = callee.expression;
|
|
290
|
+
// `Object.assign` reads its sources, every argument after the target.
|
|
291
|
+
read.push(...node.arguments.slice(...(callee.name.text === "assign" ? [1] : [0, 1])));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else if (isForInStatement(node)) {
|
|
295
|
+
read.push(node.expression);
|
|
296
|
+
}
|
|
297
|
+
for (const value of read.map(evaluated)) {
|
|
298
|
+
if (isIdentifier(value) && value.text === spelled) {
|
|
299
|
+
asked.push({ node: value, parameter, object });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
node.forEachChild(visit);
|
|
303
|
+
};
|
|
304
|
+
fn.forEachChild(visit);
|
|
305
|
+
}
|
|
306
|
+
const symbols = project.symbolsAt(asked.map((one) => project.handle(one.node))).map(must);
|
|
307
|
+
const objects = asked.flatMap((one) => (one.object === undefined ? [] : [one.object]));
|
|
308
|
+
const own = project.ownPaths();
|
|
309
|
+
const standard = new Set(project
|
|
310
|
+
.symbolsAt(objects.map((node) => project.handle(node)))
|
|
311
|
+
.map(must)
|
|
312
|
+
.flatMap((symbol, at) => symbol?.declarations.every((handle) => !own.has(handle.path)) === true ? [objects[at]] : []));
|
|
313
|
+
return [
|
|
314
|
+
...new Set(asked.flatMap((one, at) => {
|
|
315
|
+
const symbol = symbols[at];
|
|
316
|
+
return symbol !== undefined &&
|
|
317
|
+
erased.get(symbol.id) === one.parameter &&
|
|
318
|
+
(one.object === undefined || standard.has(one.object))
|
|
319
|
+
? [one.parameter]
|
|
320
|
+
: [];
|
|
321
|
+
})),
|
|
322
|
+
];
|
|
323
|
+
}
|
|
205
324
|
/** Each place a call's argument passes one of the erased parameters it can see. */
|
|
206
325
|
function handedOn(project, sites, erased, invocationOf) {
|
|
207
326
|
const spellings = new Set([...erased.values()].map((parameter) => parameter.name.getText()));
|
|
@@ -285,8 +404,8 @@ function implementedOwners(project, inAnalysis) {
|
|
|
285
404
|
* callee is a serializer an entry names; at the arguments its resolved signature types
|
|
286
405
|
* `unknown` or `any` where its callee is declared outside the analysis or only ambiently
|
|
287
406
|
* with no implementation in it; and, where its callee is a function of the project, at each
|
|
288
|
-
* parameter the function hands on to a destination, holding back the widest
|
|
289
|
-
*
|
|
407
|
+
* parameter the function enumerates or hands on to a destination, holding back the widest
|
|
408
|
+
* of what those hold back, settled by widening until no parameter moves.
|
|
290
409
|
*/
|
|
291
410
|
function destinations(input, chains, walked) {
|
|
292
411
|
const { project, held } = input;
|
|
@@ -478,6 +597,9 @@ function destinations(input, chains, walked) {
|
|
|
478
597
|
}
|
|
479
598
|
return undefined;
|
|
480
599
|
};
|
|
600
|
+
for (const parameter of enumeratedParameters(project, erased)) {
|
|
601
|
+
forwards.set(keyOf(parameter), "properties");
|
|
602
|
+
}
|
|
481
603
|
const forwarded = handedOn(project, walked.sites, erased, invocation);
|
|
482
604
|
for (let moved = true; moved;) {
|
|
483
605
|
moved = false;
|
|
@@ -551,6 +673,14 @@ function flowsOf(project, held, sites, to, stores) {
|
|
|
551
673
|
},
|
|
552
674
|
];
|
|
553
675
|
});
|
|
676
|
+
const generic = typeParameterFlows(project, reach, sites, arguments_.flatMap((one) => {
|
|
677
|
+
const type = typeOf.get(one.value);
|
|
678
|
+
const parameters = type === undefined ? [] : typeParametersOf(project, type, 0);
|
|
679
|
+
const destination = parameters.length === 0 ? undefined : to.at(one.site.call, one.argument);
|
|
680
|
+
return destination === undefined
|
|
681
|
+
? []
|
|
682
|
+
: parameters.map((parameter) => ({ parameter, to: destination }));
|
|
683
|
+
}));
|
|
554
684
|
const left = stores.outside.flatMap((store) => {
|
|
555
685
|
const reached = reach.reachedFrom(store.type);
|
|
556
686
|
return reached.classes.size === 0 && reached.members.length === 0
|
|
@@ -565,7 +695,127 @@ function flowsOf(project, held, sites, to, stores) {
|
|
|
565
695
|
},
|
|
566
696
|
];
|
|
567
697
|
});
|
|
568
|
-
return [...passed, ...left];
|
|
698
|
+
return [...passed, ...generic, ...left];
|
|
699
|
+
}
|
|
700
|
+
/** The type parameters one type names, through constituents and type arguments. */
|
|
701
|
+
function typeParametersOf(project, type, depth) {
|
|
702
|
+
if (type.isTypeParameter()) {
|
|
703
|
+
return [type];
|
|
704
|
+
}
|
|
705
|
+
if (depth >= MENTION_DEPTH) {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
const { queries } = project;
|
|
709
|
+
return [
|
|
710
|
+
...(type.isUnionType() || type.isIntersectionType() ? must(queries.constituents(type)) : []),
|
|
711
|
+
...must(queries.typeArguments(type)),
|
|
712
|
+
].flatMap((one) => typeParametersOf(project, one, depth + 1));
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* The types an instantiated type puts where a declared type names `parameter`: matched
|
|
716
|
+
* through type arguments, and in a union every constituent the declared union does not
|
|
717
|
+
* itself name.
|
|
718
|
+
*/
|
|
719
|
+
function bound(project, declared, instantiated, parameter, depth) {
|
|
720
|
+
if (declared.id === parameter.id) {
|
|
721
|
+
return [instantiated];
|
|
722
|
+
}
|
|
723
|
+
if (depth >= MENTION_DEPTH) {
|
|
724
|
+
return [];
|
|
725
|
+
}
|
|
726
|
+
const { queries } = project;
|
|
727
|
+
if (declared.isUnionType()) {
|
|
728
|
+
const parts = must(queries.constituents(declared));
|
|
729
|
+
if (parts.some((part) => part.id === parameter.id)) {
|
|
730
|
+
const kept = new Set(parts.map((part) => part.id));
|
|
731
|
+
const given = instantiated.isUnionType()
|
|
732
|
+
? must(queries.constituents(instantiated))
|
|
733
|
+
: [instantiated];
|
|
734
|
+
return given.filter((part) => !kept.has(part.id));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const declaredArguments = must(queries.typeArguments(declared));
|
|
738
|
+
const givenArguments = must(queries.typeArguments(instantiated));
|
|
739
|
+
return declaredArguments.length !== givenArguments.length
|
|
740
|
+
? []
|
|
741
|
+
: declaredArguments.flatMap((one, at) => {
|
|
742
|
+
const given = givenArguments[at];
|
|
743
|
+
return given === undefined ? [] : bound(project, one, given, parameter, depth + 1);
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* The flows a type parameter of a function of the project carries where a value its type
|
|
748
|
+
* names reaches a destination: each call of the function passes its type argument there,
|
|
749
|
+
* written or inferred from the arguments, and a type argument that is a type parameter
|
|
750
|
+
* of the calling function is followed on to that function's calls, to a fixpoint.
|
|
751
|
+
*/
|
|
752
|
+
function typeParameterFlows(project, reach, sites, pending) {
|
|
753
|
+
const { queries } = project;
|
|
754
|
+
const byName = new Map();
|
|
755
|
+
for (const site of sites) {
|
|
756
|
+
const name = calleeName(site.call.expression)?.getText();
|
|
757
|
+
if (name !== undefined) {
|
|
758
|
+
byName.set(name, [...(byName.get(name) ?? []), site]);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const found = [];
|
|
762
|
+
const seen = new Set();
|
|
763
|
+
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
|
764
|
+
const { parameter, to } = next;
|
|
765
|
+
if (seen.has(parameter.id)) {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
seen.add(parameter.id);
|
|
769
|
+
const symbol = must(queries.symbolOfType(parameter));
|
|
770
|
+
const [handle] = symbol?.declarations ?? [];
|
|
771
|
+
const declaration = handle === undefined ? undefined : project.declarationAt(handle)?.node;
|
|
772
|
+
const fn = declaration?.parent;
|
|
773
|
+
const index = fn === undefined || !isFunctionLikeDeclaration(fn)
|
|
774
|
+
? -1
|
|
775
|
+
: (fn.typeParameters ?? []).findIndex((one) => one === declaration);
|
|
776
|
+
const name = fn === undefined
|
|
777
|
+
? undefined
|
|
778
|
+
: isMethodDeclaration(fn) && isIdentifier(fn.name)
|
|
779
|
+
? fn.name.text
|
|
780
|
+
: nestedName(fn);
|
|
781
|
+
if (symbol === undefined || fn === undefined || index < 0 || name === undefined) {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
for (const site of byName.get(name) ?? []) {
|
|
785
|
+
const resolved = must(queries.resolvedSignature(site.call));
|
|
786
|
+
const declared = resolved === undefined ? undefined : must(queries.genericOf(resolved));
|
|
787
|
+
const called = declared?.declaration === undefined
|
|
788
|
+
? undefined
|
|
789
|
+
: project.declarationAt(declared.declaration)?.node;
|
|
790
|
+
if (resolved === undefined || declared === undefined || called !== fn) {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const written = site.call.typeArguments?.[index];
|
|
794
|
+
const given = written === undefined
|
|
795
|
+
? site.call.arguments.flatMap((_argument, at) => {
|
|
796
|
+
const from = must(queries.parameterType(declared, at));
|
|
797
|
+
const into = must(queries.parameterType(resolved, at));
|
|
798
|
+
return from === undefined || into === undefined
|
|
799
|
+
? []
|
|
800
|
+
: bound(project, from, into, parameter, 0);
|
|
801
|
+
})
|
|
802
|
+
: typesAt(project, [written]).filter((type) => type !== undefined);
|
|
803
|
+
for (const type of given) {
|
|
804
|
+
const reached = reach.reachedFrom(type);
|
|
805
|
+
if (reached.classes.size > 0 || reached.members.length > 0) {
|
|
806
|
+
found.push({
|
|
807
|
+
site: site.call,
|
|
808
|
+
classes: reached.classes,
|
|
809
|
+
members: reached.members,
|
|
810
|
+
to,
|
|
811
|
+
detail: `passed to ${to.callee} through the type parameter ${symbol.name}`,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
pending.push(...typeParametersOf(project, type, 0).map((one) => ({ parameter: one, to })));
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return found;
|
|
569
819
|
}
|
|
570
820
|
/**
|
|
571
821
|
* The serialization-contract detector: each data member and `toJSON` of each class,
|
package/dist/src/suppress.js
CHANGED
|
@@ -57,6 +57,7 @@ export const GO_REF_EXPRESSIONS = [
|
|
|
57
57
|
`^go://${GO_PATH}#${GO_IDENT}$`,
|
|
58
58
|
`^go://${GO_PATH}#${GO_IDENT}(?:\\.${GO_IDENT})+$`,
|
|
59
59
|
`^go://${GO_PATH}#${GO_IDENT}(?:\\.${GO_IDENT})?\\[${GO_IDENT}\\]$`,
|
|
60
|
+
`^go://${GO_PATH}#(?:${GO_IDENT}(?:\\.${GO_IDENT})?)?\\(${GO_IDENT}\\)$`,
|
|
60
61
|
`^go://${GO_PATH}#${GO_FILE}:file$`,
|
|
61
62
|
`^go://${GO_PATH}#${GO_PATH}(?:@${GO_VERSION})?:require$`,
|
|
62
63
|
`^go://${GO_PATH}#${GO_PATH}(?:@${GO_VERSION})?:replace$`,
|
package/dist/src/sweep.js
CHANGED
|
@@ -84,10 +84,10 @@ export function runnerCalls(symbol) {
|
|
|
84
84
|
* That run's roots are the graph's own and, of each test file, its top level and every
|
|
85
85
|
* function it exports, which a test runner may call, and every export of a manual mock,
|
|
86
86
|
* which the runner reads; any other export of a test file, and test-support code, which
|
|
87
|
-
* runs where a test imports it, are judged as any file's are.
|
|
88
|
-
* `
|
|
87
|
+
* runs where a test imports it, are judged as any file's are. `held` seeds it as a mark
|
|
88
|
+
* seeds a sweep; with `namespaces` false a file read reaches none of the file's exports.
|
|
89
89
|
*/
|
|
90
|
-
export function reachedWithTheTests(graph, held) {
|
|
90
|
+
export function reachedWithTheTests(graph, held, namespaces = true) {
|
|
91
91
|
const tests = graph.symbols.flatMap((symbol, at) => {
|
|
92
92
|
const exported = symbol.exported && graph.symbols[graph.parent[at] ?? OUTSIDE]?.kind === "file";
|
|
93
93
|
const called = exported && runnerCalls(symbol);
|
|
@@ -97,9 +97,7 @@ export function reachedWithTheTests(graph, held) {
|
|
|
97
97
|
? [{ at, kind: "test-runner" }]
|
|
98
98
|
: [];
|
|
99
99
|
});
|
|
100
|
-
return reachable({ ...graph, rooted: [...graph.rooted, ...tests] }, flagged(graph, held), {
|
|
101
|
-
production: false,
|
|
102
|
-
});
|
|
100
|
+
return reachable({ ...graph, rooted: [...graph.rooted, ...tests] }, flagged(graph, held), { production: false }, namespaces);
|
|
103
101
|
}
|
|
104
102
|
/**
|
|
105
103
|
* The references a loaded consumer makes that the mode counts. A production mode counts
|
|
@@ -126,7 +124,7 @@ function flagged(graph, ids) {
|
|
|
126
124
|
* included, while a root reaches through the references the mode counts. The held seed
|
|
127
125
|
* runs first, so what it reaches is expanded under the wider rule.
|
|
128
126
|
*/
|
|
129
|
-
function reachable(graph, held, mode) {
|
|
127
|
+
function reachable(graph, held, mode, namespaces = true) {
|
|
130
128
|
const reached = graph.symbols.map(() => false);
|
|
131
129
|
const expanded = graph.symbols.map(() => false);
|
|
132
130
|
const queue = [];
|
|
@@ -154,7 +152,7 @@ function reachable(graph, held, mode) {
|
|
|
154
152
|
if (!holding && mode.production && edge.test) {
|
|
155
153
|
continue;
|
|
156
154
|
}
|
|
157
|
-
enter(edge.to, !edge.evaluation);
|
|
155
|
+
enter(edge.to, namespaces && !edge.evaluation);
|
|
158
156
|
}
|
|
159
157
|
}
|
|
160
158
|
};
|
|
@@ -169,7 +167,7 @@ function reachable(graph, held, mode) {
|
|
|
169
167
|
}
|
|
170
168
|
// A consumer's reference reaches what it names as one of the target's own does.
|
|
171
169
|
for (const edge of callsOf(graph, mode)) {
|
|
172
|
-
enter(edge.to, !edge.evaluation);
|
|
170
|
+
enter(edge.to, namespaces && !edge.evaluation);
|
|
173
171
|
}
|
|
174
172
|
walk(false);
|
|
175
173
|
return reached;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The globals a test runner injects into every file it runs, which no import names: a
|
|
3
|
+
* Vitest configuration whose `test.globals` is `true` gives each test and each file it
|
|
4
|
+
* loads `describe`, `it`, `expect` and `vi` (https://vitest.dev/config/globals). A program
|
|
5
|
+
* analyzed without the runner's declarations of them reads every use as an error, so a
|
|
6
|
+
* configuration under such a runner is read with them; and a file that calls one is
|
|
7
|
+
* written for the runner, as one that imports it is.
|
|
8
|
+
*/
|
|
9
|
+
import { isAsExpression, isBinaryExpression, isCallExpression, isExportAssignment, isExpressionStatement, isIdentifier, isObjectLiteralExpression, isParenthesizedExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, SyntaxKind, } from "@typescript/native/ast";
|
|
10
|
+
import { dirnamePath, joinPath } from "./paths.js";
|
|
11
|
+
import { UNANSWERED } from "./query.js";
|
|
12
|
+
import { writtenPath } from "./workspace-programs.js";
|
|
13
|
+
/** The test runner whose configuration injects the globals, and the `types` entry declaring them. */
|
|
14
|
+
export const GLOBALS_RUNNER = "vitest";
|
|
15
|
+
const GLOBALS_TYPES = "vitest/globals";
|
|
16
|
+
/** The configuration files the runner reads its settings from, by name. */
|
|
17
|
+
const RUNNER_CONFIG = /^(?:vitest|vite)\.config\.(?:js|mjs|cjs|ts|mts|cts)$/u;
|
|
18
|
+
/** The globals `vitest/globals` declares that a test or a helper calls or reads a member of. */
|
|
19
|
+
const GLOBAL_NAMES = new Set([
|
|
20
|
+
"afterAll",
|
|
21
|
+
"afterEach",
|
|
22
|
+
"assert",
|
|
23
|
+
"assertType",
|
|
24
|
+
"beforeAll",
|
|
25
|
+
"beforeEach",
|
|
26
|
+
"bench",
|
|
27
|
+
"describe",
|
|
28
|
+
"expect",
|
|
29
|
+
"expectTypeOf",
|
|
30
|
+
"it",
|
|
31
|
+
"onTestFailed",
|
|
32
|
+
"onTestFinished",
|
|
33
|
+
"suite",
|
|
34
|
+
"test",
|
|
35
|
+
"vi",
|
|
36
|
+
"vitest",
|
|
37
|
+
]);
|
|
38
|
+
/** The expression one wrapper that changes no value holds, and a call's first argument. */
|
|
39
|
+
function configObject(node) {
|
|
40
|
+
let at = node;
|
|
41
|
+
while (at !== undefined) {
|
|
42
|
+
if (isParenthesizedExpression(at) || isAsExpression(at) || isSatisfiesExpression(at)) {
|
|
43
|
+
at = at.expression;
|
|
44
|
+
}
|
|
45
|
+
else if (isCallExpression(at)) {
|
|
46
|
+
at = at.arguments[0];
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
return at;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
/** The value one object literal writes for one member as `name: value`. */
|
|
55
|
+
function member(object, name) {
|
|
56
|
+
if (object === undefined || !isObjectLiteralExpression(object)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
for (const property of object.properties) {
|
|
60
|
+
if (isPropertyAssignment(property) &&
|
|
61
|
+
isIdentifier(property.name) &&
|
|
62
|
+
property.name.text === name) {
|
|
63
|
+
return configObject(property.initializer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Whether one runner configuration's default export sets `test.globals` to `true`. */
|
|
69
|
+
function setsGlobals(file) {
|
|
70
|
+
for (const statement of file.statements) {
|
|
71
|
+
let exported;
|
|
72
|
+
if (isExportAssignment(statement)) {
|
|
73
|
+
exported = statement.expression;
|
|
74
|
+
}
|
|
75
|
+
else if (isExpressionStatement(statement) && isBinaryExpression(statement.expression)) {
|
|
76
|
+
const { left, operatorToken, right } = statement.expression;
|
|
77
|
+
if (operatorToken.kind === SyntaxKind.EqualsToken &&
|
|
78
|
+
isPropertyAccessExpression(left) &&
|
|
79
|
+
isIdentifier(left.expression) &&
|
|
80
|
+
left.expression.text === "module" &&
|
|
81
|
+
isIdentifier(left.name) &&
|
|
82
|
+
left.name.text === "exports") {
|
|
83
|
+
exported = right;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const object = exported === undefined ? undefined : configObject(exported);
|
|
87
|
+
const globals = member(member(object, "test"), "globals");
|
|
88
|
+
if (globals?.kind === SyntaxKind.TrueKeyword) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
/** Whether the runner's configuration in one manifest's directory injects the globals. */
|
|
95
|
+
function injectsGlobals(host, parse, dir) {
|
|
96
|
+
let names;
|
|
97
|
+
try {
|
|
98
|
+
names = host
|
|
99
|
+
.readDirectory(dir)
|
|
100
|
+
.filter((entry) => !entry.directory && RUNNER_CONFIG.test(entry.name))
|
|
101
|
+
.map((entry) => entry.name);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return names.some((name) => {
|
|
107
|
+
const path = joinPath(dir, name);
|
|
108
|
+
try {
|
|
109
|
+
return setsGlobals(parse(path, host.readFile(path)));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Whether a file declaring the globals is installed where the configuration resolves packages from. */
|
|
117
|
+
function globalsInstalled(host, configFile) {
|
|
118
|
+
for (let dir = dirnamePath(configFile);; dir = dirnamePath(dir)) {
|
|
119
|
+
if (host.kindOf(joinPath(dir, "node_modules", GLOBALS_RUNNER, "globals.d.ts")) === "file") {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (dirnamePath(dir) === dir) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** The directory of the manifest nearest one configuration file, at or above its own. */
|
|
128
|
+
function manifestDir(host, configFile) {
|
|
129
|
+
for (let dir = dirnamePath(configFile);; dir = dirnamePath(dir)) {
|
|
130
|
+
if (host.kindOf(joinPath(dir, "package.json")) === "file") {
|
|
131
|
+
return dir;
|
|
132
|
+
}
|
|
133
|
+
if (dirnamePath(dir) === dir) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function typesOf(value) {
|
|
139
|
+
return Array.isArray(value) && value.every((one) => typeof one === "string") ? value : undefined;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* How the snapshot reads each configuration of `configFiles` whose nearest manifest's
|
|
143
|
+
* runner configuration injects the globals and that can resolve their declarations: as
|
|
144
|
+
* `programs` reads it, else as itself, with the globals' declarations added to its `types`.
|
|
145
|
+
* A configuration the compiler cannot read is left for its project to refuse.
|
|
146
|
+
*/
|
|
147
|
+
export function withTestGlobals(engine, host, parse, configFiles, programs) {
|
|
148
|
+
const found = new Map(programs);
|
|
149
|
+
const injects = new Map();
|
|
150
|
+
for (const configFile of configFiles) {
|
|
151
|
+
const dir = manifestDir(host, configFile);
|
|
152
|
+
if (dir === undefined || !globalsInstalled(host, configFile)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!injects.has(dir)) {
|
|
156
|
+
injects.set(dir, injectsGlobals(host, parse, dir));
|
|
157
|
+
}
|
|
158
|
+
if (injects.get(dir) !== true) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let base;
|
|
162
|
+
try {
|
|
163
|
+
base = typesOf(engine.parseConfigFile(configFile).options.types) ?? [];
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const held = found.get(configFile);
|
|
169
|
+
if (held?.kind === "program") {
|
|
170
|
+
const listed = typesOf(held.params.compilerOptions.types) ?? base;
|
|
171
|
+
if (!listed.includes(GLOBALS_TYPES)) {
|
|
172
|
+
found.set(configFile, {
|
|
173
|
+
kind: "program",
|
|
174
|
+
params: {
|
|
175
|
+
...held.params,
|
|
176
|
+
compilerOptions: { ...held.params.compilerOptions, types: [...listed, GLOBALS_TYPES] },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
let written = { extends: configFile };
|
|
183
|
+
if (held !== undefined) {
|
|
184
|
+
try {
|
|
185
|
+
written = JSON.parse(held.text);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const options = (written["compilerOptions"] ?? {});
|
|
192
|
+
const listed = typesOf(options["types"]) ?? base;
|
|
193
|
+
if (listed.includes(GLOBALS_TYPES)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
found.set(configFile, {
|
|
197
|
+
kind: "configuration",
|
|
198
|
+
file: held?.file ?? writtenPath(host, configFile),
|
|
199
|
+
text: `${JSON.stringify({ ...written, compilerOptions: { ...options, types: [...listed, GLOBALS_TYPES] } }, null, 2)}\n`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Whether one file names a global the runner injects, which is how a file written for the
|
|
206
|
+
* runner's globals reads: a call of one of the names or a read of a member of it that
|
|
207
|
+
* resolves to a declaration outside the project's own files, the runner's declaration
|
|
208
|
+
* file. A name the file binds, imported, declared or a parameter, resolves to its own.
|
|
209
|
+
*/
|
|
210
|
+
export function namesTestGlobals(project, file) {
|
|
211
|
+
const named = [];
|
|
212
|
+
const visit = (node) => {
|
|
213
|
+
const callee = isCallExpression(node)
|
|
214
|
+
? node.expression
|
|
215
|
+
: isPropertyAccessExpression(node)
|
|
216
|
+
? node.expression
|
|
217
|
+
: undefined;
|
|
218
|
+
if (callee !== undefined && isIdentifier(callee) && GLOBAL_NAMES.has(callee.text)) {
|
|
219
|
+
named.push(callee);
|
|
220
|
+
}
|
|
221
|
+
node.forEachChild(visit);
|
|
222
|
+
};
|
|
223
|
+
file.forEachChild(visit);
|
|
224
|
+
if (named.length === 0) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const own = project.ownPaths();
|
|
228
|
+
return project
|
|
229
|
+
.symbolsAt(named.map((node) => project.handle(node)))
|
|
230
|
+
.some((symbol) => symbol === UNANSWERED ||
|
|
231
|
+
(symbol !== undefined &&
|
|
232
|
+
symbol.declarations.length > 0 &&
|
|
233
|
+
symbol.declarations.every((handle) => !own.has(handle.path))));
|
|
234
|
+
}
|
package/dist/src/test-support.js
CHANGED
|
@@ -5,21 +5,18 @@
|
|
|
5
5
|
* another such file, so it is judged with the tests: what it references is referenced by
|
|
6
6
|
* test code.
|
|
7
7
|
*/
|
|
8
|
-
import { CONVENTION_ROWS } from "./convention-rows.js";
|
|
8
|
+
import { CONVENTION_ROWS, TEST_LIBRARIES, TEST_LIBRARY_SCOPES } from "./convention-rows.js";
|
|
9
9
|
import { packageOfSpecifier } from "./dependencies.js";
|
|
10
10
|
/** The root kind that never takes a file out of test-support code: the skip only withholds. */
|
|
11
11
|
const SKIP_ROOT = "type-error";
|
|
12
12
|
/**
|
|
13
13
|
* The test runners and test libraries an import of which is test evidence: each runner a
|
|
14
|
-
* convention row reads test files for, the
|
|
14
|
+
* convention row reads test files for, and the convention table's test libraries.
|
|
15
15
|
*/
|
|
16
16
|
const TEST_PACKAGES = new Set([
|
|
17
17
|
...CONVENTION_ROWS.filter((row) => row.testFiles !== undefined).map((row) => row.package),
|
|
18
|
-
|
|
19
|
-
"@playwright/test",
|
|
18
|
+
...TEST_LIBRARIES,
|
|
20
19
|
]);
|
|
21
|
-
/** The scopes every package of which is a test library. */
|
|
22
|
-
const TEST_SCOPES = ["@testing-library/"];
|
|
23
20
|
/**
|
|
24
21
|
* The Node.js built-in modules a bare specifier names, which are no package. The set is
|
|
25
22
|
* closed: Node.js adds a new built-in under the `node:` scheme alone
|
|
@@ -73,7 +70,7 @@ const NODE_BUILTINS = new Set([
|
|
|
73
70
|
function namesTestPackage(specifier) {
|
|
74
71
|
const name = specifier.startsWith("node:") ? specifier : packageOfSpecifier(specifier);
|
|
75
72
|
return (name !== undefined &&
|
|
76
|
-
(TEST_PACKAGES.has(name) ||
|
|
73
|
+
(TEST_PACKAGES.has(name) || TEST_LIBRARY_SCOPES.some((scope) => name.startsWith(scope))));
|
|
77
74
|
}
|
|
78
75
|
/**
|
|
79
76
|
* Whether a file carries test evidence: an import of a test runner or a test library, or
|
|
@@ -106,9 +103,9 @@ function evidenceReader(tests, evidence) {
|
|
|
106
103
|
* The files of one project that are test-support code, below the target root: the
|
|
107
104
|
* largest set of files a test file reaches, none of them a test file or holding a root,
|
|
108
105
|
* each carrying test evidence of its own, whose every user outside the set is a test
|
|
109
|
-
* file
|
|
110
|
-
*
|
|
111
|
-
* the test-file rules classified.
|
|
106
|
+
* file; and the files only tests reach that are not among them. A root has a caller
|
|
107
|
+
* outside the reference graph: a runtime, a tool, a configuration, or a consumer of a
|
|
108
|
+
* library's published API. `testFiles` are the files the test-file rules classified.
|
|
112
109
|
*/
|
|
113
110
|
export function testSupportFiles(symbols, references, roots, testFiles, evidence) {
|
|
114
111
|
const fileOf = new Map(symbols.map((symbol) => [symbol.id, symbol.position.path]));
|
|
@@ -162,7 +159,7 @@ export function testSupportFiles(symbols, references, roots, testFiles, evidence
|
|
|
162
159
|
const onlyTests = withoutOutsideUsers(new Set([...reached].filter((path) => !rooted.has(path))));
|
|
163
160
|
const carries = evidenceReader(tests, evidence);
|
|
164
161
|
const support = withoutOutsideUsers(new Set([...onlyTests].filter((path) => carries(onlyTests, path))));
|
|
165
|
-
return support;
|
|
162
|
+
return { support, testUsed: new Set([...onlyTests].filter((path) => !support.has(path))) };
|
|
166
163
|
}
|
|
167
164
|
/** The references with every one a test-support file makes counted as a test reference. */
|
|
168
165
|
export function supportReferences(references, support) {
|