@openrewrite/rewrite 8.96.3 → 8.96.5
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/execution.d.ts +2 -0
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +4 -0
- package/dist/execution.js.map +1 -1
- package/dist/heavyweight.d.ts +8 -0
- package/dist/heavyweight.d.ts.map +1 -0
- package/dist/heavyweight.js +45 -0
- package/dist/heavyweight.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/java/tree.d.ts +11 -11
- package/dist/java/tree.d.ts.map +1 -1
- package/dist/java/visitor.d.ts.map +1 -1
- package/dist/java/visitor.js +46 -2
- package/dist/java/visitor.js.map +1 -1
- package/dist/javascript/add-import.d.ts +2 -1
- package/dist/javascript/add-import.d.ts.map +1 -1
- package/dist/javascript/add-import.js +14 -2
- package/dist/javascript/add-import.js.map +1 -1
- package/dist/javascript/amd.d.ts.map +1 -1
- package/dist/javascript/amd.js +2 -1
- package/dist/javascript/amd.js.map +1 -1
- package/dist/javascript/cleanup/add-parse-int-radix.js +1 -1
- package/dist/javascript/cleanup/add-parse-int-radix.js.map +1 -1
- package/dist/javascript/format/format.d.ts +12 -0
- package/dist/javascript/format/format.d.ts.map +1 -1
- package/dist/javascript/format/format.js +17 -3
- package/dist/javascript/format/format.js.map +1 -1
- package/dist/javascript/format/tabs-and-indents-visitor.d.ts.map +1 -1
- package/dist/javascript/format/tabs-and-indents-visitor.js +4 -0
- package/dist/javascript/format/tabs-and-indents-visitor.js.map +1 -1
- package/dist/javascript/format/whitespace-reconciler.d.ts.map +1 -1
- package/dist/javascript/format/whitespace-reconciler.js +4 -0
- package/dist/javascript/format/whitespace-reconciler.js.map +1 -1
- package/dist/javascript/node-resolution-result.d.ts.map +1 -1
- package/dist/javascript/node-resolution-result.js +2 -0
- package/dist/javascript/node-resolution-result.js.map +1 -1
- package/dist/javascript/parser-utils.d.ts.map +1 -1
- package/dist/javascript/parser-utils.js +16 -5
- package/dist/javascript/parser-utils.js.map +1 -1
- package/dist/javascript/templating/engine.d.ts.map +1 -1
- package/dist/javascript/templating/engine.js +11 -6
- package/dist/javascript/templating/engine.js.map +1 -1
- package/dist/javascript/templating/pattern.d.ts.map +1 -1
- package/dist/javascript/templating/pattern.js +8 -0
- package/dist/javascript/templating/pattern.js.map +1 -1
- package/dist/javascript/templating/placeholder-replacement.d.ts.map +1 -1
- package/dist/javascript/templating/placeholder-replacement.js +15 -0
- package/dist/javascript/templating/placeholder-replacement.js.map +1 -1
- package/dist/javascript/templating/precedence.js +1 -1
- package/dist/javascript/templating/precedence.js.map +1 -1
- package/dist/javascript/tree.d.ts +12 -12
- package/dist/javascript/tree.d.ts.map +1 -1
- package/dist/javascript/visitor.d.ts.map +1 -1
- package/dist/javascript/visitor.js +96 -0
- package/dist/javascript/visitor.js.map +1 -1
- package/dist/recipe.d.ts +5 -0
- package/dist/recipe.d.ts.map +1 -1
- package/dist/recipe.js +5 -0
- package/dist/recipe.js.map +1 -1
- package/dist/rewrite-javascript-version.txt +1 -1
- package/dist/run.d.ts +7 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +40 -2
- package/dist/run.js.map +1 -1
- package/dist/test/accumulator-contents.d.ts +15 -0
- package/dist/test/accumulator-contents.d.ts.map +1 -0
- package/dist/test/accumulator-contents.js +185 -0
- package/dist/test/accumulator-contents.js.map +1 -0
- package/dist/test/empty-sources.d.ts +14 -0
- package/dist/test/empty-sources.d.ts.map +1 -0
- package/dist/test/empty-sources.js +98 -0
- package/dist/test/empty-sources.js.map +1 -0
- package/dist/test/formatting-scope.d.ts +18 -0
- package/dist/test/formatting-scope.d.ts.map +1 -0
- package/dist/test/formatting-scope.js +244 -0
- package/dist/test/formatting-scope.js.map +1 -0
- package/dist/test/probes.d.ts +25 -0
- package/dist/test/probes.d.ts.map +1 -0
- package/dist/test/probes.js +189 -0
- package/dist/test/probes.js.map +1 -0
- package/dist/test/rewrite-test.d.ts +27 -0
- package/dist/test/rewrite-test.d.ts.map +1 -1
- package/dist/test/rewrite-test.js +191 -21
- package/dist/test/rewrite-test.js.map +1 -1
- package/dist/test/tree-shapes.d.ts +15 -0
- package/dist/test/tree-shapes.d.ts.map +1 -0
- package/dist/test/tree-shapes.js +288 -0
- package/dist/test/tree-shapes.js.map +1 -0
- package/dist/test/unparsed-sources.d.ts +17 -0
- package/dist/test/unparsed-sources.d.ts.map +1 -0
- package/dist/test/unparsed-sources.js +113 -0
- package/dist/test/unparsed-sources.js.map +1 -0
- package/dist/tree.d.ts.map +1 -1
- package/dist/tree.js +2 -0
- package/dist/tree.js.map +1 -1
- package/dist/visitor.d.ts.map +1 -1
- package/dist/visitor.js +2 -0
- package/dist/visitor.js.map +1 -1
- package/node_modules/picomatch/LICENSE +21 -0
- package/node_modules/picomatch/README.md +743 -0
- package/node_modules/picomatch/index.js +17 -0
- package/node_modules/picomatch/lib/constants.js +184 -0
- package/node_modules/picomatch/lib/parse.js +1506 -0
- package/node_modules/picomatch/lib/picomatch.js +361 -0
- package/node_modules/picomatch/lib/scan.js +403 -0
- package/node_modules/picomatch/lib/utils.js +72 -0
- package/node_modules/picomatch/package.json +83 -0
- package/node_modules/picomatch/posix.js +3 -0
- package/package.json +88 -90
- package/src/execution.ts +6 -0
- package/src/heavyweight.ts +42 -0
- package/src/index.ts +1 -0
- package/src/java/tree.ts +11 -11
- package/src/java/visitor.ts +52 -2
- package/src/javascript/add-import.ts +11 -2
- package/src/javascript/amd.ts +4 -2
- package/src/javascript/cleanup/add-parse-int-radix.ts +2 -2
- package/src/javascript/format/format.ts +25 -1
- package/src/javascript/format/tabs-and-indents-visitor.ts +4 -0
- package/src/javascript/format/whitespace-reconciler.ts +4 -0
- package/src/javascript/node-resolution-result.ts +3 -0
- package/src/javascript/parser-utils.ts +19 -7
- package/src/javascript/templating/engine.ts +11 -6
- package/src/javascript/templating/pattern.ts +10 -0
- package/src/javascript/templating/placeholder-replacement.ts +16 -0
- package/src/javascript/templating/precedence.ts +1 -1
- package/src/javascript/tree.ts +12 -12
- package/src/javascript/visitor.ts +120 -0
- package/src/recipe.ts +5 -0
- package/src/run.ts +30 -2
- package/src/test/accumulator-contents.ts +194 -0
- package/src/test/empty-sources.ts +99 -0
- package/src/test/formatting-scope.ts +231 -0
- package/src/test/probes.ts +156 -0
- package/src/test/rewrite-test.ts +177 -24
- package/src/test/tree-shapes.ts +332 -0
- package/src/test/unparsed-sources.ts +138 -0
- package/src/tree.ts +3 -0
- package/src/visitor.ts +3 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 the original author or authors.
|
|
3
|
+
* <p>
|
|
4
|
+
* Licensed under the Moderne Source Available License (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
* <p>
|
|
8
|
+
* https://docs.moderne.io/licensing/moderne-source-available-license
|
|
9
|
+
* <p>
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import {types} from "util";
|
|
17
|
+
import {AccumulatorListener} from "../run";
|
|
18
|
+
import {Recipe} from "../recipe";
|
|
19
|
+
import {isSourceFile} from "../tree";
|
|
20
|
+
import {heavyweightType} from "../heavyweight";
|
|
21
|
+
import {Type} from "../java";
|
|
22
|
+
import {typeName} from "./probes";
|
|
23
|
+
|
|
24
|
+
/** Beyond this many objects in one accumulator the walk stops descending. */
|
|
25
|
+
const MAX_OBJECTS = 100_000;
|
|
26
|
+
|
|
27
|
+
const MAX_PATHS = 5;
|
|
28
|
+
|
|
29
|
+
interface Finding {
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly what: string
|
|
32
|
+
readonly phase: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Node {
|
|
36
|
+
readonly value: object
|
|
37
|
+
readonly parent?: Node
|
|
38
|
+
readonly step: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Finds the heavyweight objects that ScanningRecipe accumulators keep, which are trees and the types
|
|
43
|
+
* registerHeavyweight registers. An accumulator lives from the first source file scanned until the cycle ends. The
|
|
44
|
+
* walk reads data properties and built-in collections, and runs no code of the recipe's own.
|
|
45
|
+
*/
|
|
46
|
+
export class AccumulatorContents {
|
|
47
|
+
/** What each recipe's accumulator keeps, by recipe name and then by the shape of the path to it. */
|
|
48
|
+
private readonly findings = new Map<string, Map<string, Finding>>();
|
|
49
|
+
|
|
50
|
+
readonly listener: AccumulatorListener = (accumulators, phase) => {
|
|
51
|
+
const walked = new Set<unknown>();
|
|
52
|
+
for (const [recipe, acc] of accumulators) {
|
|
53
|
+
if (acc !== null && typeof acc === "object" && !walked.has(acc)) {
|
|
54
|
+
walked.add(acc);
|
|
55
|
+
try {
|
|
56
|
+
this.walk(recipe.name, acc, phase);
|
|
57
|
+
} catch {
|
|
58
|
+
// an accumulator the walk can't read is left alone
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
assertNoneFound(): void {
|
|
65
|
+
if (this.findings.size === 0) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const lines = ["A ScanningRecipe's accumulator should keep the values the recipe needs, such as package " +
|
|
69
|
+
"names, versions, paths and ids, and no heavyweight objects, such as trees, cursors, visitors, execution " +
|
|
70
|
+
"contexts and dependency models:"];
|
|
71
|
+
for (const [recipe, found] of this.findings) {
|
|
72
|
+
lines.push(` ${recipe} keeps`);
|
|
73
|
+
const shown = [...found.values()].slice(0, MAX_PATHS);
|
|
74
|
+
lines.push(...shown.map(f => ` ${f.path} -> ${f.what}, ${f.phase}`));
|
|
75
|
+
if (found.size > MAX_PATHS) {
|
|
76
|
+
lines.push(` and ${found.size - MAX_PATHS} more`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
lines.push("An accumulator stays in memory from the first source file scanned until the cycle ends, so " +
|
|
80
|
+
"what it keeps for each source file adds up over a large repository. A NodeResolutionResult can reach " +
|
|
81
|
+
"the model of a whole dependency tree, while small markers such as NodeRuntime are fine to keep. A " +
|
|
82
|
+
"recipe that must keep a heavyweight object opts out with spec.checkLightweightAccumulators = false.");
|
|
83
|
+
throw new Error(lines.join("\n"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Breadth first, so that the path to what the accumulator keeps is a short one. */
|
|
87
|
+
private walk(recipe: string, acc: object, phase: string): void {
|
|
88
|
+
const visited = new Set<object>();
|
|
89
|
+
const queue: Node[] = [];
|
|
90
|
+
let budget = MAX_OBJECTS;
|
|
91
|
+
const visit = (value: unknown, parent: Node | undefined, step: string) => {
|
|
92
|
+
if (!walks(value)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const what = heavyweight(value);
|
|
96
|
+
if (what) {
|
|
97
|
+
const path = pathOf({value, parent, step});
|
|
98
|
+
let found = this.findings.get(recipe);
|
|
99
|
+
if (!found) {
|
|
100
|
+
this.findings.set(recipe, found = new Map());
|
|
101
|
+
}
|
|
102
|
+
const shape = `${path.replace(/\[[^\]]*]/g, "[]")} -> ${what}`;
|
|
103
|
+
if (!found.has(shape)) {
|
|
104
|
+
found.set(shape, {path, what, phase});
|
|
105
|
+
}
|
|
106
|
+
} else if (budget > 0 && !visited.has(value)) {
|
|
107
|
+
budget--;
|
|
108
|
+
visited.add(value);
|
|
109
|
+
queue.push({value, parent, step});
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
visit(acc, undefined, "acc");
|
|
114
|
+
for (let i = 0; i < queue.length; i++) {
|
|
115
|
+
const node = queue[i];
|
|
116
|
+
const value = node.value;
|
|
117
|
+
if (types.isMap(value)) {
|
|
118
|
+
Map.prototype.forEach.call(value, (v: unknown, k: unknown) => {
|
|
119
|
+
if (walks(k) || walks(v)) {
|
|
120
|
+
visit(k, node, `[${key(k)}]`);
|
|
121
|
+
visit(v, node, `[${key(k)}]`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
} else if (types.isSet(value)) {
|
|
125
|
+
let index = 0;
|
|
126
|
+
Set.prototype.forEach.call(value, (v: unknown) => visit(v, node, `[${index++}]`));
|
|
127
|
+
} else {
|
|
128
|
+
// data properties only, since a getter is code of the recipe's own
|
|
129
|
+
const array = Array.isArray(value);
|
|
130
|
+
for (const name of Reflect.ownKeys(value)) {
|
|
131
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, name);
|
|
132
|
+
if (descriptor && "value" in descriptor && !(array && name === "length")) {
|
|
133
|
+
visit(descriptor.value, node, step(name, array));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether the walk looks at a value at all, which it doesn't for primitives and for what refers to no tree. */
|
|
142
|
+
function walks(value: unknown): value is object {
|
|
143
|
+
return value !== null && typeof value === "object" && !types.isProxy(value) && !types.isPromise(value) &&
|
|
144
|
+
!types.isWeakMap(value) && !types.isWeakSet(value) && !types.isAnyArrayBuffer(value) &&
|
|
145
|
+
!types.isArrayBufferView(value) && !types.isDate(value) && !types.isRegExp(value) &&
|
|
146
|
+
!types.isBoxedPrimitive(value) &&
|
|
147
|
+
// a recipe lives for the whole run anyway, and type attribution refers to no tree
|
|
148
|
+
!(value instanceof Recipe) && !Type.isType(value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** How to name what the accumulator keeps, when it is a heavyweight object. */
|
|
152
|
+
function heavyweight(value: object): string | undefined {
|
|
153
|
+
const type = heavyweightType(value);
|
|
154
|
+
if (type === undefined) {
|
|
155
|
+
return undefined;
|
|
156
|
+
} else if (typeof type === "function") {
|
|
157
|
+
return type.name || "an object of an anonymous class";
|
|
158
|
+
}
|
|
159
|
+
return isSourceFile(value) ? `${typeName(type)} ${value.sourcePath}` : typeName(type);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function pathOf(node: Node): string {
|
|
163
|
+
const steps: string[] = [];
|
|
164
|
+
for (let n: Node | undefined = node; n; n = n.parent) {
|
|
165
|
+
steps.push(n.step);
|
|
166
|
+
}
|
|
167
|
+
return steps.reverse().join("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function step(name: string | symbol, array: boolean): string {
|
|
171
|
+
if (typeof name === "symbol") {
|
|
172
|
+
return `[${name.toString()}]`;
|
|
173
|
+
}
|
|
174
|
+
return array && /^\d+$/.test(name) ? `[${name}]` :
|
|
175
|
+
/^[A-Za-z_$][\w$]*$/.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A map key as the path shows it, without calling code of the recipe's own. */
|
|
179
|
+
function key(k: unknown): string {
|
|
180
|
+
let shown: string;
|
|
181
|
+
if (k === null || typeof k !== "object") {
|
|
182
|
+
shown = typeof k === "symbol" ? k.toString() : String(k);
|
|
183
|
+
} else if (isSourceFile(k)) {
|
|
184
|
+
shown = k.sourcePath;
|
|
185
|
+
} else if (k instanceof Recipe) {
|
|
186
|
+
shown = k.name;
|
|
187
|
+
} else if (typeof (k as { kind?: unknown }).kind === "string") {
|
|
188
|
+
shown = typeName((k as { kind: string }).kind);
|
|
189
|
+
} else {
|
|
190
|
+
shown = Object.getPrototypeOf(k)?.constructor?.name || "Object";
|
|
191
|
+
}
|
|
192
|
+
return shown.length > 60 ? `${shown.substring(0, 60)}...` : shown;
|
|
193
|
+
}
|
|
194
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 the original author or authors.
|
|
3
|
+
* <p>
|
|
4
|
+
* Licensed under the Moderne Source Available License (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
* <p>
|
|
8
|
+
* https://docs.moderne.io/licensing/moderne-source-available-license
|
|
9
|
+
* <p>
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import type {SourceSpec} from "./rewrite-test";
|
|
17
|
+
import {Recipe} from "../recipe";
|
|
18
|
+
import {ExecutionContext} from "../execution";
|
|
19
|
+
import {SourceFile} from "../tree";
|
|
20
|
+
import {Result} from "../run";
|
|
21
|
+
import {TreePrinters} from "../print";
|
|
22
|
+
import {describeThrown, probeOnce, thrownBy, typeName, withMarkersOf} from "./probes";
|
|
23
|
+
|
|
24
|
+
/** Runs the recipe on an empty file of each kind it changed, and fails the test if the recipe throws there. */
|
|
25
|
+
export class EmptySources {
|
|
26
|
+
private readonly changed: Set<SourceFile>;
|
|
27
|
+
|
|
28
|
+
constructor(private readonly recipe: Recipe, results: Result[]) {
|
|
29
|
+
this.changed = new Set(results.filter(r => r.before && r.after !== r.before).map(r => r.before!));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async assertNoneThrow(parsed: [SourceSpec<any>, SourceFile][], parseCtx: ExecutionContext,
|
|
33
|
+
recipeCtx: ExecutionContext, cycles: number): Promise<void> {
|
|
34
|
+
const kinds = new Set<string>();
|
|
35
|
+
for (const [spec, before] of parsed) {
|
|
36
|
+
const probe = `empty ${before.kind} ${spec.ext}`;
|
|
37
|
+
if (!this.changed.has(before) || kinds.has(probe)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
kinds.add(probe);
|
|
41
|
+
await probeOnce(this.recipe, probe, async () => {
|
|
42
|
+
const empty = await parseEmpty(before, spec, parseCtx);
|
|
43
|
+
const thrown = empty ? await thrownBy(this.recipe, [empty], recipeCtx, cycles) : undefined;
|
|
44
|
+
if (empty && thrown) {
|
|
45
|
+
throw new Error(this.message(empty, thrown.error));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private message(empty: SourceFile, thrown: unknown): string {
|
|
52
|
+
return [
|
|
53
|
+
"Check for the structure the recipe edits before using it, so that it handles an empty file or leaves " +
|
|
54
|
+
"it unchanged instead of throwing.",
|
|
55
|
+
`${this.recipe.name} threw when run on an empty ${typeName(empty.kind)} at ${empty.sourcePath}` +
|
|
56
|
+
`${withMarkersOf(empty.markers.markers)}:`,
|
|
57
|
+
describeThrown(thrown, " "),
|
|
58
|
+
"The test gives the recipe only files that contain what it edits, but repositories also hold empty " +
|
|
59
|
+
"files and files without that block, such as an empty index.ts, a placeholder tsconfig.json or an " +
|
|
60
|
+
"empty .eslintrc.yml.",
|
|
61
|
+
" - return the tree unchanged when what the visitor assumes is absent: check the kind before a cast " +
|
|
62
|
+
"(`document.value.kind === Json.Kind.Object`), the length before an index (`cu.statements.length > 0`) " +
|
|
63
|
+
"and undefined before a dereference (`documents.documents[0]?.block`)",
|
|
64
|
+
" - if the recipe adds what is missing, build that content correctly for an empty file too",
|
|
65
|
+
" - don't catch the exception to make this pass",
|
|
66
|
+
"Then add a test with an empty source, such as typescript(\"\"), json(\"\") or yaml(\"\"), that states the " +
|
|
67
|
+
"intended result."
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An empty file at the source's path, with the markers that describe the source's project, or undefined for a kind of
|
|
74
|
+
* file that can't be empty.
|
|
75
|
+
*/
|
|
76
|
+
async function parseEmpty(before: SourceFile, spec: SourceSpec<any>,
|
|
77
|
+
parseCtx: ExecutionContext): Promise<SourceFile | undefined> {
|
|
78
|
+
// a file its parser resolves a project from, such as a package.json, can't be empty
|
|
79
|
+
if (before.markers.markers.some(m => m.kind.endsWith("ResolutionResult"))) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
let empty: SourceFile;
|
|
83
|
+
try {
|
|
84
|
+
const parsed: SourceFile[] = [];
|
|
85
|
+
for await (const sourceFile of spec.parser(parseCtx).parse({text: "", sourcePath: before.sourcePath})) {
|
|
86
|
+
parsed.push(sourceFile);
|
|
87
|
+
}
|
|
88
|
+
// some kinds of file can't be empty
|
|
89
|
+
if (parsed.length !== 1 || parsed[0].kind !== before.kind || await TreePrinters.print(parsed[0]) !== "") {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
empty = parsed[0];
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const present = new Set(empty.markers.markers.map(m => m.kind));
|
|
97
|
+
const added = before.markers.markers.filter(m => !present.has(m.kind));
|
|
98
|
+
return {...empty, markers: {...empty.markers, markers: [...empty.markers.markers, ...added]}};
|
|
99
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 the original author or authors.
|
|
3
|
+
* <p>
|
|
4
|
+
* Licensed under the Moderne Source Available License (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
* <p>
|
|
8
|
+
* https://docs.moderne.io/licensing/moderne-source-available-license
|
|
9
|
+
* <p>
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import {Recipe} from "../recipe";
|
|
18
|
+
import {Cursor, isSourceFile, SourceFile, Tree} from "../tree";
|
|
19
|
+
import {Result} from "../run";
|
|
20
|
+
import {TreePrinters} from "../print";
|
|
21
|
+
import {UUID} from "../uuid";
|
|
22
|
+
import {J} from "../java";
|
|
23
|
+
import {JavaScriptVisitor} from "../javascript";
|
|
24
|
+
import type {FormattingListener} from "../javascript/format";
|
|
25
|
+
|
|
26
|
+
interface Request {
|
|
27
|
+
readonly tree: Tree
|
|
28
|
+
readonly sourceFile: UUID
|
|
29
|
+
readonly callSite: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Finds recipes that auto-format a whole source file or class that existed before the run. Test inputs are usually
|
|
34
|
+
* formatted already, so the reformatting this causes on real code rarely shows in an expected output.
|
|
35
|
+
*/
|
|
36
|
+
export class FormattingScope implements FormattingListener {
|
|
37
|
+
private readonly requests: Request[] = [];
|
|
38
|
+
private readonly requested = new Set<Tree>();
|
|
39
|
+
|
|
40
|
+
constructor(private readonly recipe: Recipe) {
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onFormat(tree: Tree, stopAfter: Tree | undefined, cursor: Cursor | undefined): void {
|
|
44
|
+
if (stopAfter || !isWhole(tree) || this.requested.has(tree)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.requested.add(tree);
|
|
48
|
+
const sourceFile = isSourceFile(tree) ? tree : cursor?.firstEnclosing(isSourceFile);
|
|
49
|
+
const site = sourceFile && callSite(new Error().stack ?? "");
|
|
50
|
+
if (sourceFile && site) {
|
|
51
|
+
this.requests.push({tree, sourceFile: sourceFile.id, callSite: site});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async assertFormattedOnlyWhatChanged(results: Result[]): Promise<void> {
|
|
56
|
+
if (this.requests.length === 0 || await isFormatter(this.recipe)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const treesByFile = new Map<UUID, Map<UUID, Tree>>();
|
|
60
|
+
const found = new Map<string, Set<string>>();
|
|
61
|
+
for (const request of this.requests) {
|
|
62
|
+
const result = results.find(r => r.before?.id === request.sourceFile && r.after && r.after !== r.before);
|
|
63
|
+
if (!result || await onlyWhitespaceChanged(result)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const before = result.before!;
|
|
67
|
+
let trees = treesByFile.get(before.id);
|
|
68
|
+
if (!trees) {
|
|
69
|
+
treesByFile.set(before.id, trees = await treesById(before));
|
|
70
|
+
}
|
|
71
|
+
const original = trees.get(request.tree.id);
|
|
72
|
+
if (original && !await holdsNoneOfItsChildren(original, request.tree)) {
|
|
73
|
+
let targets = found.get(request.callSite);
|
|
74
|
+
if (!targets) {
|
|
75
|
+
found.set(request.callSite, targets = new Set());
|
|
76
|
+
}
|
|
77
|
+
targets.add(`${describe(request.tree)} of ${before.sourcePath}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (found.size > 0) {
|
|
81
|
+
throw new Error(this.message(found));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private message(found: Map<string, Set<string>>): string {
|
|
86
|
+
return [
|
|
87
|
+
"Format only what the recipe changed, not the whole class or file around it. These calls of " +
|
|
88
|
+
`${this.recipe.name} auto-formatted a whole file or class that existed before the run, with no stopAfter:`,
|
|
89
|
+
...[...found].flatMap(([site, targets]) => [` ${site}`, ...[...targets].map(target => ` ${target}`)]),
|
|
90
|
+
"That rewrites the whitespace of code the recipe did not change. Test inputs are usually formatted " +
|
|
91
|
+
"already, so expected outputs rarely show it, but on real code the diff will carry unrelated " +
|
|
92
|
+
"reformatting. Instead:",
|
|
93
|
+
" - format the element you created or replaced: maybeAutoFormat(original, changed, ctx) on that " +
|
|
94
|
+
"element, or autoFormat(newElement, ctx, undefined, cursor) with a cursor on its parent",
|
|
95
|
+
" - when a new element only formats right inside its container, format a copy of the container that " +
|
|
96
|
+
"holds just the new element, and add the formatted element to the original",
|
|
97
|
+
" - after changing a declaration's decorators or modifiers, stop after its header: " +
|
|
98
|
+
"maybeAutoFormat(c, updated, ctx, updated.name, this.cursor.parent)",
|
|
99
|
+
" - when you remove an element, give its prefix to the element that takes its place",
|
|
100
|
+
" - re-indent statements you moved with autoIndent(..) rather than autoFormat",
|
|
101
|
+
" - a template already formats what it inserts, so drop the extra call",
|
|
102
|
+
"Recipes named with a `format` segment, such as org.openrewrite.javascript.format.auto-format, are exempt. " +
|
|
103
|
+
"Any other recipe whose purpose is formatting opts out with spec.checkMinimalFormatting = false."
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A source file or a class. */
|
|
109
|
+
function isWhole(tree: Tree): boolean {
|
|
110
|
+
return isSourceFile(tree) || tree.kind === J.Kind.ClassDeclaration;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface Frame {
|
|
114
|
+
readonly fn: string
|
|
115
|
+
readonly location: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The code that asked for the format, from the stack of the request, or undefined when that is a formatter, a
|
|
120
|
+
* template, or the scheduler running a recipe whose own visitor is a formatter.
|
|
121
|
+
*/
|
|
122
|
+
function callSite(stack: string): string | undefined {
|
|
123
|
+
const frames: Frame[] = stack.split("\n").map(line => line.trim()).filter(line => line.startsWith("at "))
|
|
124
|
+
.map(line => {
|
|
125
|
+
const m = /^at (?:async )?(?:(.*?) \((.*)\)|(.*))$/.exec(line)!;
|
|
126
|
+
return {fn: m[1] ?? "", location: (m[2] ?? m[3]).split(process.cwd() + path.sep).join("")};
|
|
127
|
+
});
|
|
128
|
+
// the listener, the formatter and the helpers that run it
|
|
129
|
+
let i = frames.findIndex(f => /\.visit$/.test(f.fn) && inFormat(f));
|
|
130
|
+
if (i < 0) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
let helper: string | undefined;
|
|
134
|
+
for (i++; i < frames.length && inFormat(frames[i]) && /^(maybeAutoFormat|autoFormat)$/.test(frames[i].fn); i++) {
|
|
135
|
+
helper = frames[i].fn;
|
|
136
|
+
}
|
|
137
|
+
// a precondition and a scanning recipe's editor hand the tree on to the visitor they wrap, after an await
|
|
138
|
+
while (i < frames.length && (isWrapper(frames[i]) || frames[i].location.startsWith("node:"))) {
|
|
139
|
+
i++;
|
|
140
|
+
}
|
|
141
|
+
const caller = frames[i];
|
|
142
|
+
if (!caller) {
|
|
143
|
+
return undefined;
|
|
144
|
+
} else if (/(^|[\\/])(src|dist)[\\/]visitor\.[jt]s:/.test(caller.location)) {
|
|
145
|
+
// the visitor that asked is gone from the stack by the time its after-visitors run
|
|
146
|
+
return "A visitor's afterVisit.push(new AutoformatVisitor()) ran on";
|
|
147
|
+
} else if (inFormat(caller) || /[\\/]templating[\\/]/.test(caller.location) ||
|
|
148
|
+
/(^|[\\/])(src|dist)[\\/]run\.[jt]s:/.test(caller.location)) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
return `${caller.fn || "<anonymous>"} (${caller.location}) ${helper ? `called ${helper} on` : "ran an AutoformatVisitor on"}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function inFormat(frame: Frame): boolean {
|
|
155
|
+
return /[\\/]format[\\/]/.test(frame.location);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The visit of a precondition or of a scanning recipe's editor, which runs the visitor it wraps. */
|
|
159
|
+
function isWrapper(frame: Frame): boolean {
|
|
160
|
+
return /\.visit$/.test(frame.fn) && /(^|[\\/])(src|dist)[\\/](preconditions|recipe)\.[jt]s:/.test(frame.location);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A formatting recipe may format whole files, and so may the recipes run alongside it. */
|
|
164
|
+
async function isFormatter(recipe: Recipe): Promise<boolean> {
|
|
165
|
+
if (/(^|\.)format\./.test(recipe.name)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
for (const child of await recipe.recipeList()) {
|
|
169
|
+
if (await isFormatter(child)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function onlyWhitespaceChanged(result: Result): Promise<boolean> {
|
|
177
|
+
const [before, after] = [await TreePrinters.print(result.before!), await TreePrinters.print(result.after!)];
|
|
178
|
+
return before.replace(/\s+/g, "") === after.replace(/\s+/g, "");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function treesById(sourceFile: SourceFile): Promise<Map<UUID, Tree>> {
|
|
182
|
+
const trees = new Map<UUID, Tree>();
|
|
183
|
+
await new class extends JavaScriptVisitor<undefined> {
|
|
184
|
+
protected override async preVisit(tree: J): Promise<J | undefined> {
|
|
185
|
+
trees.set(tree.id, tree);
|
|
186
|
+
return tree;
|
|
187
|
+
}
|
|
188
|
+
}().visit(sourceFile, undefined);
|
|
189
|
+
return trees;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A tree formatted with none of the children it had reformats no existing code below it: a class with its body
|
|
194
|
+
* emptied to be put back afterwards, or a copy of a container holding only a new element. A tree that had no children
|
|
195
|
+
* to begin with would format them on real code.
|
|
196
|
+
*/
|
|
197
|
+
async function holdsNoneOfItsChildren(original: Tree, formatted: Tree): Promise<boolean> {
|
|
198
|
+
const children = await childrenOf(original);
|
|
199
|
+
if (children.size === 0) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
for (const child of await childrenOf(formatted)) {
|
|
203
|
+
if (children.has(child)) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The members of a class's body, or the trees directly below any other tree. */
|
|
211
|
+
async function childrenOf(tree: Tree): Promise<Set<UUID>> {
|
|
212
|
+
if (tree.kind === J.Kind.ClassDeclaration) {
|
|
213
|
+
return new Set((tree as J.ClassDeclaration).body.statements.map(s => s.element.id));
|
|
214
|
+
}
|
|
215
|
+
const children = new Set<UUID>();
|
|
216
|
+
await new class extends JavaScriptVisitor<undefined> {
|
|
217
|
+
protected override async preVisit(t: J): Promise<J | undefined> {
|
|
218
|
+
if (t !== tree) {
|
|
219
|
+
children.add(t.id);
|
|
220
|
+
this.stopAfterPreVisit();
|
|
221
|
+
}
|
|
222
|
+
return t;
|
|
223
|
+
}
|
|
224
|
+
}().visit(tree, undefined);
|
|
225
|
+
return children;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Reads as "all" or "class `A`". */
|
|
229
|
+
function describe(tree: Tree): string {
|
|
230
|
+
return isSourceFile(tree) ? "all" : `class \`${(tree as J.ClassDeclaration).name.simpleName}\``;
|
|
231
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 the original author or authors.
|
|
3
|
+
* <p>
|
|
4
|
+
* Licensed under the Moderne Source Available License (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
* <p>
|
|
8
|
+
* https://docs.moderne.io/licensing/moderne-source-available-license
|
|
9
|
+
* <p>
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import {Recipe} from "../recipe";
|
|
18
|
+
import {ExecutionContext} from "../execution";
|
|
19
|
+
import {SourceFile} from "../tree";
|
|
20
|
+
import {ACCUMULATOR_LISTENER, scheduleRun} from "../run";
|
|
21
|
+
import {RecipeRunError} from "../visitor";
|
|
22
|
+
import {DATA_TABLE_STORE} from "../data-table";
|
|
23
|
+
import {Marker} from "../markers";
|
|
24
|
+
|
|
25
|
+
/** Messages that belong to the test's own run. */
|
|
26
|
+
const NOT_COPIED = new Set<string | symbol>([DATA_TABLE_STORE, ACCUMULATOR_LISTENER]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Probes of recipes that resolve dependencies take seconds each, so a recipe stops being probed in this process once
|
|
30
|
+
* its probes have taken this long.
|
|
31
|
+
*/
|
|
32
|
+
const TIME_CAP_MS = 20_000;
|
|
33
|
+
|
|
34
|
+
/** How long each recipe's probes have taken in this process, by {@link timeCapKey}. */
|
|
35
|
+
const PROBE_MS = new Map<string, number>();
|
|
36
|
+
|
|
37
|
+
/** The probes each recipe class ran, which a process runs once whether they pass or fail. */
|
|
38
|
+
const PROBED = new WeakMap<Function, Set<string>>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Runs `run`, a probe of the recipe on what `probe` names, unless this process ran that probe before or the recipe's
|
|
42
|
+
* probes have taken the time cap, and charges the recipe the time it takes.
|
|
43
|
+
*/
|
|
44
|
+
export async function probeOnce(recipe: Recipe, probe: string, run: () => Promise<void>): Promise<void> {
|
|
45
|
+
if ((PROBE_MS.get(await timeCapKey(recipe)) ?? 0) > TIME_CAP_MS) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let probed = PROBED.get(recipe.constructor);
|
|
49
|
+
if (!probed) {
|
|
50
|
+
PROBED.set(recipe.constructor, probed = new Set());
|
|
51
|
+
}
|
|
52
|
+
const key = await probeKey(recipe, probe);
|
|
53
|
+
if (probed.has(key)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
probed.add(key);
|
|
57
|
+
const start = performance.now();
|
|
58
|
+
try {
|
|
59
|
+
await run();
|
|
60
|
+
} finally {
|
|
61
|
+
await charge(recipe, performance.now() - start);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Adds `ms` to the time the recipe's probes have taken in this process. */
|
|
66
|
+
export async function charge(recipe: Recipe, ms: number): Promise<void> {
|
|
67
|
+
const key = await timeCapKey(recipe);
|
|
68
|
+
PROBE_MS.set(key, (PROBE_MS.get(key) ?? 0) + ms);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a recipe's probes share a time cap by: its name, and for a recipe with a recipe list its children's keys too,
|
|
73
|
+
* since a composite a test declares has one name whatever it runs.
|
|
74
|
+
*/
|
|
75
|
+
async function timeCapKey(recipe: Recipe): Promise<string> {
|
|
76
|
+
let children: Recipe[];
|
|
77
|
+
try {
|
|
78
|
+
children = await recipe.recipeList();
|
|
79
|
+
} catch {
|
|
80
|
+
return recipe.name;
|
|
81
|
+
}
|
|
82
|
+
const keys: string[] = [];
|
|
83
|
+
for (const child of children) {
|
|
84
|
+
keys.push(await timeCapKey(child));
|
|
85
|
+
}
|
|
86
|
+
return keys.length === 0 ? recipe.name : `${recipe.name}[${keys.join(",")}]`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function probeKey(recipe: Recipe, probe: string): Promise<string> {
|
|
90
|
+
try {
|
|
91
|
+
return `${JSON.stringify(await recipe.descriptor())}\0${probe}`;
|
|
92
|
+
} catch {
|
|
93
|
+
return `${recipe.name}\0${probe}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The error the recipe threw when run on `sources` for up to `cycles` cycles, if it threw. */
|
|
98
|
+
export async function thrownBy(recipe: Recipe, sources: SourceFile[], recipeCtx: ExecutionContext,
|
|
99
|
+
cycles: number): Promise<{ error: unknown } | undefined> {
|
|
100
|
+
const messages: { [key: string | symbol]: any } = {};
|
|
101
|
+
for (const key of Reflect.ownKeys(recipeCtx.messages)) {
|
|
102
|
+
if (!NOT_COPIED.has(key)) {
|
|
103
|
+
messages[key] = recipeCtx.messages[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const ctx = new ExecutionContext(messages);
|
|
107
|
+
let current = sources;
|
|
108
|
+
try {
|
|
109
|
+
for (let cycle = 1; cycle <= cycles; cycle++) {
|
|
110
|
+
const changeset = (await scheduleRun(recipe, current, ctx)).changeset;
|
|
111
|
+
if (changeset.every(r => r.before === r.after)) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
current = changeset.filter(r => r.after).map(r => r.after!);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return {error};
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The error the recipe threw, with the first line of its message and the first frame of its stack, and when that
|
|
124
|
+
* frame is in a dependency, the first frame that isn't.
|
|
125
|
+
*/
|
|
126
|
+
export function describeThrown(thrown: unknown, indent: string): string {
|
|
127
|
+
const cause = thrown instanceof RecipeRunError ? thrown.cause : thrown;
|
|
128
|
+
if (!(cause instanceof Error)) {
|
|
129
|
+
return `${indent}${String(cause)}`;
|
|
130
|
+
}
|
|
131
|
+
const firstLine = cause.message.trim().split("\n")[0];
|
|
132
|
+
let description = `${indent}${cause.name}${firstLine ? `: ${firstLine.length > 200 ?
|
|
133
|
+
`${firstLine.substring(0, 200)}...` : firstLine}` : ""}`;
|
|
134
|
+
const frames = (cause.stack ?? "").split("\n").map(line => line.trim())
|
|
135
|
+
.filter(line => line.startsWith("at ") && !/\(node:|^at node:|<anonymous>/.test(line))
|
|
136
|
+
.map(line => line.split(process.cwd() + path.sep).join(""));
|
|
137
|
+
if (frames.length > 0) {
|
|
138
|
+
description += `\n${indent} ${frames[0]}`;
|
|
139
|
+
const own = frames.find(frame => !frame.includes("node_modules"));
|
|
140
|
+
if (own && own !== frames[0]) {
|
|
141
|
+
description += `\n${indent} ${own}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return description;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reads as " with the markers of the test's source (Autodetect, PrettierStyle)", or as nothing. */
|
|
148
|
+
export function withMarkersOf(markers: Marker[]): string {
|
|
149
|
+
const kinds = [...new Set(markers.map(m => typeName(m.kind)))];
|
|
150
|
+
return kinds.length === 0 ? "" : ` with the markers of the test's source (${kinds.join(", ")})`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Reads as "PlainText" or "JS.CompilationUnit". */
|
|
154
|
+
export function typeName(kind: string): string {
|
|
155
|
+
return kind.replace(/^.*\./, "").replace(/\$/g, ".");
|
|
156
|
+
}
|