@openrewrite/rewrite 8.96.4 → 8.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/execution.d.ts +2 -0
  2. package/dist/execution.d.ts.map +1 -1
  3. package/dist/execution.js +4 -0
  4. package/dist/execution.js.map +1 -1
  5. package/dist/heavyweight.d.ts +8 -0
  6. package/dist/heavyweight.d.ts.map +1 -0
  7. package/dist/heavyweight.js +45 -0
  8. package/dist/heavyweight.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/java/tree.d.ts +2 -1
  14. package/dist/java/tree.d.ts.map +1 -1
  15. package/dist/java/tree.js.map +1 -1
  16. package/dist/java/visitor.d.ts +5 -0
  17. package/dist/java/visitor.d.ts.map +1 -1
  18. package/dist/java/visitor.js +264 -217
  19. package/dist/java/visitor.js.map +1 -1
  20. package/dist/javascript/add-import.d.ts +2 -1
  21. package/dist/javascript/add-import.d.ts.map +1 -1
  22. package/dist/javascript/add-import.js +14 -2
  23. package/dist/javascript/add-import.js.map +1 -1
  24. package/dist/javascript/amd.d.ts.map +1 -1
  25. package/dist/javascript/amd.js +2 -1
  26. package/dist/javascript/amd.js.map +1 -1
  27. package/dist/javascript/cleanup/add-parse-int-radix.js +1 -1
  28. package/dist/javascript/cleanup/add-parse-int-radix.js.map +1 -1
  29. package/dist/javascript/dependency-workspace.d.ts +8 -2
  30. package/dist/javascript/dependency-workspace.d.ts.map +1 -1
  31. package/dist/javascript/dependency-workspace.js +36 -4
  32. package/dist/javascript/dependency-workspace.js.map +1 -1
  33. package/dist/javascript/format/format.d.ts +10 -0
  34. package/dist/javascript/format/format.d.ts.map +1 -1
  35. package/dist/javascript/format/format.js +10 -2
  36. package/dist/javascript/format/format.js.map +1 -1
  37. package/dist/javascript/format/tabs-and-indents-visitor.d.ts.map +1 -1
  38. package/dist/javascript/format/tabs-and-indents-visitor.js +4 -0
  39. package/dist/javascript/format/tabs-and-indents-visitor.js.map +1 -1
  40. package/dist/javascript/format/whitespace-reconciler.d.ts.map +1 -1
  41. package/dist/javascript/format/whitespace-reconciler.js +4 -0
  42. package/dist/javascript/format/whitespace-reconciler.js.map +1 -1
  43. package/dist/javascript/node-resolution-result.d.ts.map +1 -1
  44. package/dist/javascript/node-resolution-result.js +2 -0
  45. package/dist/javascript/node-resolution-result.js.map +1 -1
  46. package/dist/javascript/parser-utils.d.ts.map +1 -1
  47. package/dist/javascript/parser-utils.js +9 -6
  48. package/dist/javascript/parser-utils.js.map +1 -1
  49. package/dist/javascript/templating/engine.d.ts +1 -0
  50. package/dist/javascript/templating/engine.d.ts.map +1 -1
  51. package/dist/javascript/templating/engine.js +39 -10
  52. package/dist/javascript/templating/engine.js.map +1 -1
  53. package/dist/javascript/templating/pattern.d.ts.map +1 -1
  54. package/dist/javascript/templating/pattern.js +8 -0
  55. package/dist/javascript/templating/pattern.js.map +1 -1
  56. package/dist/javascript/templating/placeholder-replacement.d.ts +2 -0
  57. package/dist/javascript/templating/placeholder-replacement.d.ts.map +1 -1
  58. package/dist/javascript/templating/placeholder-replacement.js +37 -1
  59. package/dist/javascript/templating/placeholder-replacement.js.map +1 -1
  60. package/dist/javascript/visitor.d.ts.map +1 -1
  61. package/dist/javascript/visitor.js +153 -118
  62. package/dist/javascript/visitor.js.map +1 -1
  63. package/dist/recipe.d.ts +5 -0
  64. package/dist/recipe.d.ts.map +1 -1
  65. package/dist/recipe.js +5 -0
  66. package/dist/recipe.js.map +1 -1
  67. package/dist/rewrite-javascript-version.txt +1 -1
  68. package/dist/run.d.ts +7 -1
  69. package/dist/run.d.ts.map +1 -1
  70. package/dist/run.js +40 -2
  71. package/dist/run.js.map +1 -1
  72. package/dist/test/accumulator-contents.d.ts +15 -0
  73. package/dist/test/accumulator-contents.d.ts.map +1 -0
  74. package/dist/test/accumulator-contents.js +185 -0
  75. package/dist/test/accumulator-contents.js.map +1 -0
  76. package/dist/test/empty-sources.d.ts +14 -0
  77. package/dist/test/empty-sources.d.ts.map +1 -0
  78. package/dist/test/empty-sources.js +98 -0
  79. package/dist/test/empty-sources.js.map +1 -0
  80. package/dist/test/formatting-scope.d.ts +18 -0
  81. package/dist/test/formatting-scope.d.ts.map +1 -0
  82. package/dist/test/formatting-scope.js +244 -0
  83. package/dist/test/formatting-scope.js.map +1 -0
  84. package/dist/test/probes.d.ts +25 -0
  85. package/dist/test/probes.d.ts.map +1 -0
  86. package/dist/test/probes.js +189 -0
  87. package/dist/test/probes.js.map +1 -0
  88. package/dist/test/rewrite-test.d.ts +27 -0
  89. package/dist/test/rewrite-test.d.ts.map +1 -1
  90. package/dist/test/rewrite-test.js +191 -21
  91. package/dist/test/rewrite-test.js.map +1 -1
  92. package/dist/test/tree-shapes.d.ts +15 -0
  93. package/dist/test/tree-shapes.d.ts.map +1 -0
  94. package/dist/test/tree-shapes.js +288 -0
  95. package/dist/test/tree-shapes.js.map +1 -0
  96. package/dist/test/unparsed-sources.d.ts +17 -0
  97. package/dist/test/unparsed-sources.d.ts.map +1 -0
  98. package/dist/test/unparsed-sources.js +113 -0
  99. package/dist/test/unparsed-sources.js.map +1 -0
  100. package/dist/tree.d.ts.map +1 -1
  101. package/dist/tree.js +2 -0
  102. package/dist/tree.js.map +1 -1
  103. package/dist/visitor.d.ts.map +1 -1
  104. package/dist/visitor.js +2 -0
  105. package/dist/visitor.js.map +1 -1
  106. package/package.json +1 -1
  107. package/src/execution.ts +6 -0
  108. package/src/heavyweight.ts +42 -0
  109. package/src/index.ts +1 -0
  110. package/src/java/tree.ts +2 -1
  111. package/src/java/visitor.ts +263 -216
  112. package/src/javascript/add-import.ts +11 -2
  113. package/src/javascript/amd.ts +4 -2
  114. package/src/javascript/cleanup/add-parse-int-radix.ts +2 -2
  115. package/src/javascript/dependency-workspace.ts +35 -3
  116. package/src/javascript/format/format.ts +18 -0
  117. package/src/javascript/format/tabs-and-indents-visitor.ts +4 -0
  118. package/src/javascript/format/whitespace-reconciler.ts +4 -0
  119. package/src/javascript/node-resolution-result.ts +3 -0
  120. package/src/javascript/parser-utils.ts +10 -6
  121. package/src/javascript/templating/engine.ts +41 -9
  122. package/src/javascript/templating/pattern.ts +10 -0
  123. package/src/javascript/templating/placeholder-replacement.ts +38 -1
  124. package/src/javascript/visitor.ts +166 -118
  125. package/src/recipe.ts +5 -0
  126. package/src/run.ts +30 -2
  127. package/src/test/accumulator-contents.ts +194 -0
  128. package/src/test/empty-sources.ts +99 -0
  129. package/src/test/formatting-scope.ts +231 -0
  130. package/src/test/probes.ts +156 -0
  131. package/src/test/rewrite-test.ts +177 -24
  132. package/src/test/tree-shapes.ts +332 -0
  133. package/src/test/unparsed-sources.ts +138 -0
  134. package/src/tree.ts +3 -0
  135. package/src/visitor.ts +3 -0
@@ -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
+ }
@@ -19,15 +19,24 @@ import {noopVisitor, TreeVisitor} from "../visitor";
19
19
  import {Parser} from "../parser";
20
20
  import {MarkerPrinter, PrintOutputCapture, TreePrinters} from "../print";
21
21
  import {Cursor, isTree, rootCursor, SourceFile} from "../tree";
22
- import {Result, scheduleRun} from "../run";
22
+ import {ACCUMULATOR_LISTENER, Result, scheduleRun} from "../run";
23
23
  import {mapAsync, trimIndent} from "../util";
24
24
  import {isParseError, ParseErrorKind} from "../parse-error";
25
25
  import {Marker, Markers, MarkersKind, ParseExceptionResult} from "../markers";
26
26
  import {isNamedStyles} from "../style";
27
27
  import {JavaScriptVisitor} from "../javascript";
28
+ import {listenToFormatting} from "../javascript/format";
28
29
  import {attributionHint, dumpTypesIfRequested} from "../javascript/type-report";
29
30
  import {J, Type} from "../java";
30
31
  import {DATA_TABLE_STORE, InMemoryDataTableStore} from "../data-table";
32
+ import * as path from "path";
33
+ import {AccumulatorContents} from "./accumulator-contents";
34
+ import {EmptySources} from "./empty-sources";
35
+ import {UnparsedSources} from "./unparsed-sources";
36
+ import {TreeShapes} from "./tree-shapes";
37
+ import {FormattingScope} from "./formatting-scope";
38
+ import {RpcRecipe} from "../rpc/recipe";
39
+ import {DelegatingRecipe} from "../rpc/java-recipe";
31
40
 
32
41
  export interface SourceSpec<T extends SourceFile> {
33
42
  kind: string,
@@ -45,6 +54,11 @@ interface RecipeSpecConfiguration {
45
54
  checkParsePrintIdempotence: boolean;
46
55
  allowEmptyDiff: boolean;
47
56
  checkReportingMarkersOnly: boolean;
57
+ checkExecutionContextUnchanged: boolean;
58
+ checkNoEditsInScanningPhase: boolean;
59
+ checkLightweightAccumulators: boolean;
60
+ checkParserShapedTrees: boolean;
61
+ checkMinimalFormatting: boolean;
48
62
  cycles?: number;
49
63
  expectedCyclesThatMakeChanges?: number;
50
64
  recipe: Recipe;
@@ -62,6 +76,33 @@ export class RecipeSpec {
62
76
  */
63
77
  checkReportingMarkersOnly: boolean = true
64
78
 
79
+ /**
80
+ * Fails a run whose recipe puts a message on the execution context, which every recipe and source file in a run
81
+ * shares.
82
+ */
83
+ checkExecutionContextUnchanged: boolean = true
84
+
85
+ /** Fails a run whose ScanningRecipe's scanner edits a tree, which the scanning phase discards. */
86
+ checkNoEditsInScanningPhase: boolean = true
87
+
88
+ /**
89
+ * Fails a run whose ScanningRecipe's accumulator keeps a tree, cursor, visitor, execution context, dependency model
90
+ * or other type given to registerHeavyweight, which would stay in memory all cycle.
91
+ */
92
+ checkLightweightAccumulators: boolean = true
93
+
94
+ /**
95
+ * Fails a run whose output holds a node that the source's parser builds differently from the text it prints, such
96
+ * as a J.Identifier where the parser makes a J.Literal of `null`.
97
+ */
98
+ checkParserShapedTrees: boolean = true
99
+
100
+ /**
101
+ * Fails a run whose recipe auto-formats a whole source file or class that existed before the run, instead of only
102
+ * what it changed.
103
+ */
104
+ checkMinimalFormatting: boolean = true
105
+
65
106
  /**
66
107
  * The cycles to run, by default one more than {@link expectedCyclesThatMakeChanges} and at least two. The
67
108
  * cycles after the expected ones, and a run on a fresh parse of the output, fail the test if they change
@@ -114,6 +155,11 @@ export class RecipeSpec {
114
155
  checkParsePrintIdempotence: this.checkParsePrintIdempotence,
115
156
  allowEmptyDiff: this.allowEmptyDiff,
116
157
  checkReportingMarkersOnly: this.checkReportingMarkersOnly,
158
+ checkExecutionContextUnchanged: this.checkExecutionContextUnchanged,
159
+ checkNoEditsInScanningPhase: this.checkNoEditsInScanningPhase,
160
+ checkLightweightAccumulators: this.checkLightweightAccumulators,
161
+ checkParserShapedTrees: this.checkParserShapedTrees,
162
+ checkMinimalFormatting: this.checkMinimalFormatting,
117
163
  cycles: this.cycles,
118
164
  expectedCyclesThatMakeChanges: this.expectedCyclesThatMakeChanges,
119
165
  recipe: this.recipe,
@@ -217,21 +263,43 @@ export class RecipeSpec {
217
263
  }
218
264
 
219
265
  this.recipeExecutionContext.messages[DATA_TABLE_STORE] = new InMemoryDataTableStore();
266
+ this.recipeExecutionContext.messages[ExecutionContext.SCANNING_MUTATION_VALIDATION] =
267
+ this.checkNoEditsInScanningPhase;
268
+ const accumulatorContents = this.checkLightweightAccumulators ? new AccumulatorContents() : undefined;
269
+ this.recipeExecutionContext.messages[ACCUMULATOR_LISTENER] = accumulatorContents?.listener;
270
+ const contextWrites = this.checkExecutionContextUnchanged ? new ExecutionContextWrites() : undefined;
271
+ const runContext = (ctx: ExecutionContext) => contextWrites?.watch(ctx) ?? ctx;
220
272
  const expectedCycles = this.expectedCyclesThatMakeChanges ?? 1;
221
273
  const cycles = this.cycles ?? Math.max(2, expectedCycles + 1);
222
- const {changeset, unsettled} = await this.runCycles(allParsed.map(([_, sourceFile]) => sourceFile),
223
- cycles, expectedCycles);
274
+ const formattingScope = this.checkMinimalFormatting ? new FormattingScope(this.recipe) : undefined;
275
+ const run = () => this.runCycles(allParsed.map(([_, sourceFile]) => sourceFile), cycles, expectedCycles,
276
+ runContext);
277
+ const {changeset, unsettled} = formattingScope ? await listenToFormatting(formattingScope, run) : await run();
278
+ contextWrites?.assertNone(this.recipe);
224
279
 
225
280
  this.expectNoUnexpectedGeneratedFiles(flattenedSpecs, changeset);
226
281
  this.checkReportingMarkersOnly && await this.expectNoAddedMarkers(flattenedSpecs, allParsed, changeset);
282
+ accumulatorContents?.assertNoneFound();
227
283
  await this.expectResultsToMatchAfter(flattenedSpecs, changeset, allParsed);
228
284
  await this.expectGeneratedFiles(flattenedSpecs, changeset);
229
285
  if (unsettled) {
230
286
  await this.expectNoFurtherChanges(unsettled.changes,
231
287
  `Expected recipe to complete in ${pluralCycles(expectedCycles)}, but cycle ${unsettled.cycle} changed`);
232
288
  }
233
- cycles > expectedCycles && await this.expectStableOnFreshParse(flattenedSpecs, allParsed, changeset);
289
+ await formattingScope?.assertFormattedOnlyWhatChanged(changeset);
290
+ const changed = changeset.some(r => r.before !== r.after);
291
+ const freshParse = changed && (cycles > expectedCycles || this.checkParserShapedTrees) ?
292
+ await this.freshParse(flattenedSpecs, allParsed, changeset) : new Map<SourceFile, SourceFile>();
293
+ this.checkParserShapedTrees && await new TreeShapes(this.recipe).assertParserShaped(changeset, freshParse);
294
+ cycles > expectedCycles && await this.expectStableOnFreshParse(changeset, freshParse, runContext);
295
+ contextWrites?.assertNone(this.recipe);
234
296
  await this.expectDataTables();
297
+ if (changeset.some(r => r.before && r.after !== r.before) && await isProbed(this.recipe)) {
298
+ await new EmptySources(this.recipe, changeset)
299
+ .assertNoneThrow(allParsed, this.executionContext, this.recipeExecutionContext, cycles);
300
+ await new UnparsedSources(this.recipe, changeset)
301
+ .assertNoneThrow(allParsed.map(([_, sourceFile]) => sourceFile), this.recipeExecutionContext, cycles);
302
+ }
235
303
  }
236
304
 
237
305
  private async expectDataTables() {
@@ -247,7 +315,8 @@ export class RecipeSpec {
247
315
  }
248
316
  }
249
317
 
250
- private async runCycles(sources: SourceFile[], cycles: number, expectedCycles: number):
318
+ private async runCycles(sources: SourceFile[], cycles: number, expectedCycles: number,
319
+ runContext: (ctx: ExecutionContext) => ExecutionContext):
251
320
  Promise<{ changeset: Result[], unsettled?: { cycle: number, changes: Result[] } }> {
252
321
  const lineages: { origin?: SourceFile, latest?: SourceFile }[] = sources.map(s => ({origin: s, latest: s}));
253
322
  const changesetOf = () => lineages.map(({origin, latest}) => new Result(origin, latest));
@@ -255,7 +324,7 @@ export class RecipeSpec {
255
324
  let cyclesThatMadeChanges = 0;
256
325
  for (let cycle = 1; cycle <= cycles; cycle++) {
257
326
  const live = lineages.filter(l => l.latest).map(l => l.latest!);
258
- const ctx = cycle === 1 ? this.recipeExecutionContext : this.laterRunContext();
327
+ const ctx = runContext(cycle === 1 ? this.recipeExecutionContext : this.laterRunContext());
259
328
  const changes = (await scheduleRun(this.recipe, live, ctx)).changeset.filter(r => r.before !== r.after);
260
329
  if (changes.length === 0) {
261
330
  break;
@@ -319,14 +388,11 @@ export class RecipeSpec {
319
388
  }
320
389
 
321
390
  /**
322
- * Code that the next run reads is parsed from the printed output, not the LST this run produced, and a
323
- * recipe that recognizes its own output only by the shape of that LST changes it again.
391
+ * A fresh parse, by the parser of its spec, of the text each source the recipe left prints, parsing the sources
392
+ * of one kind together so that they can refer to one another. A parser that throws leaves its sources out.
324
393
  */
325
- private async expectStableOnFreshParse(specs: SourceSpec<any>[], parsed: [SourceSpec<any>, SourceFile][],
326
- changeset: Result[]) {
327
- if (!changeset.some(r => r.before !== r.after)) {
328
- return;
329
- }
394
+ private async freshParse(specs: SourceSpec<any>[], parsed: [SourceSpec<any>, SourceFile][],
395
+ changeset: Result[]): Promise<Map<SourceFile, SourceFile>> {
330
396
  const finals = changeset.filter(r => r.after).map(({before, after}): [SourceSpec<any> | undefined, SourceFile] => [
331
397
  before ? parsed.find(([_, sourceFile]) => sourceFile === before)?.[0] :
332
398
  specs.find(s => s.path === after!.sourcePath),
@@ -344,22 +410,39 @@ export class RecipeSpec {
344
410
  let i = 0;
345
411
  try {
346
412
  for await (const reparsed of group[0][0]!.parser(this.executionContext).parse(...inputs)) {
347
- if (!isParseError(reparsed) && await printSanitized(reparsed) === inputs[i].text) {
348
- // What the source file carries, like its styles, comes from outside its text
349
- reparsedByFinal.set(group[i][1], {...reparsed, markers: group[i][1].markers});
350
- }
351
- i++;
413
+ reparsedByFinal.set(group[i++][1], reparsed);
352
414
  }
353
415
  } catch {
416
+ // the sources it didn't get to are left out
417
+ }
418
+ }
419
+ return reparsedByFinal;
420
+ }
421
+
422
+ /**
423
+ * Code that the next run reads is parsed from the printed output, not the LST this run produced, and a
424
+ * recipe that recognizes its own output only by the shape of that LST changes it again.
425
+ */
426
+ private async expectStableOnFreshParse(changeset: Result[], freshParse: Map<SourceFile, SourceFile>,
427
+ runContext: (ctx: ExecutionContext) => ExecutionContext) {
428
+ let reparsedAny = false;
429
+ const sources: SourceFile[] = [];
430
+ for (const after of changeset.filter(r => r.after).map(r => r.after!)) {
431
+ const reparsed = freshParse.get(after);
432
+ if (reparsed && !isParseError(reparsed) && await printSanitized(reparsed) === await printSanitized(after)) {
433
+ // What the source file carries, like its styles, comes from outside its text
434
+ sources.push({...reparsed, markers: after.markers});
435
+ reparsedAny = true;
436
+ } else {
354
437
  // files that do not parse again keep the LST the recipe produced
438
+ sources.push(after);
355
439
  }
356
440
  }
357
- if (reparsedByFinal.size === 0) {
441
+ if (!reparsedAny) {
358
442
  return;
359
443
  }
360
444
 
361
- const sources = finals.map(([_, sourceFile]) => reparsedByFinal.get(sourceFile) ?? sourceFile);
362
- const changes = (await scheduleRun(this.recipe, sources, this.laterRunContext())).changeset
445
+ const changes = (await scheduleRun(this.recipe, sources, runContext(this.laterRunContext()))).changeset
363
446
  .filter(r => r.before !== r.after);
364
447
  await this.expectNoFurtherChanges(changes, "Running the recipe on a fresh parse of its output changed", true);
365
448
  }
@@ -501,7 +584,7 @@ export class RecipeSpec {
501
584
 
502
585
  private async expectGeneratedFiles(specs: SourceSpec<any>[], changeset: Result[]) {
503
586
  for (const spec of specs) {
504
- if (spec.before) {
587
+ if (spec.before !== null) {
505
588
  continue;
506
589
  }
507
590
  if (!spec.after) {
@@ -513,7 +596,7 @@ export class RecipeSpec {
513
596
  }
514
597
 
515
598
  private expectNoUnexpectedGeneratedFiles(specs: SourceSpec<any>[], changeset: Result[]) {
516
- const expectedPaths = new Set(specs.filter(spec => !spec.before).map(spec => spec.path));
599
+ const expectedPaths = new Set(specs.filter(spec => spec.before === null).map(spec => spec.path));
517
600
  const unexpected = changeset.filter(c => !c.before && c.after && !expectedPaths.has(c.after.sourcePath));
518
601
  if (unexpected.length > 0) {
519
602
  throw new Error([
@@ -554,7 +637,7 @@ export class RecipeSpec {
554
637
  let unnamed = 0;
555
638
  const before: [SourceSpec<any>, { text: string, sourcePath: string }][] = [];
556
639
  for (const spec of specs) {
557
- if (spec.before) {
640
+ if (spec.before !== null) {
558
641
  const sourcePath = spec.path || `source-${unnamed++}.${spec.ext}`;
559
642
  before.push([spec, {text: dedent(spec.before), sourcePath: sourcePath}]);
560
643
  }
@@ -587,6 +670,76 @@ class ValidateWhitespaceVisitor extends JavaScriptVisitor<ExecutionContext> {
587
670
  }
588
671
  }
589
672
 
673
+ /** Whether the framework puts `key` on the execution context of a run itself. */
674
+ function isFrameworkMessage(key: string | symbol): boolean {
675
+ return key === DATA_TABLE_STORE || key === ExecutionContext.REQUIRE_PRINT_EQUALS_INPUT;
676
+ }
677
+
678
+ /** Records the messages that a recipe puts on the execution context of its run, with the code that put each. */
679
+ class ExecutionContextWrites {
680
+ private readonly writes = new Map<string | symbol, string | undefined>();
681
+
682
+ /** A view of `ctx` whose messages record what is put on them, and that otherwise acts as `ctx`. */
683
+ watch(ctx: ExecutionContext): ExecutionContext {
684
+ const record = (key: string | symbol) => {
685
+ if (!isFrameworkMessage(key) && !this.writes.has(key)) {
686
+ this.writes.set(key, callerFrame());
687
+ }
688
+ };
689
+ const messages = new Proxy(ctx.messages, {
690
+ set(target, key, value) {
691
+ record(key);
692
+ return Reflect.set(target, key, value);
693
+ },
694
+ defineProperty(target, key, attributes) {
695
+ record(key);
696
+ return Reflect.defineProperty(target, key, attributes);
697
+ }
698
+ });
699
+ return Object.create(ctx, {messages: {value: messages, enumerable: true, writable: true}});
700
+ }
701
+
702
+ assertNone(recipe: Recipe): void {
703
+ if (this.writes.size === 0) {
704
+ return;
705
+ }
706
+ throw new Error([
707
+ "Keep state that spans source files in a ScanningRecipe's accumulator, and state within one source file " +
708
+ "in cursor messages, with this.cursor.messages.set(key, value). " +
709
+ `${recipe.name} put messages on the ExecutionContext, which every recipe and source file in a run ` +
710
+ "shares, so what it keeps there leaks into the others:",
711
+ ...[...this.writes].map(([key, frame]) =>
712
+ ` ${typeof key === "symbol" ? key.toString() : JSON.stringify(key)}${frame ? ` at ${frame}` : ""}`),
713
+ "A recipe that must change the ExecutionContext opts out with spec.checkExecutionContextUnchanged = false."
714
+ ].join("\n"));
715
+ }
716
+ }
717
+
718
+ /** The first frame of the stack outside the harness and the runtime, relative to the working directory. */
719
+ function callerFrame(): string | undefined {
720
+ const frame = (new Error().stack ?? "").split("\n").slice(1)
721
+ .map(line => line.trim().replace(/^at (async )?/, ""))
722
+ .find(line => !/[\\/]test[\\/]rewrite-test\.[jt]s:|<anonymous>|^node:/.test(line));
723
+ return frame?.split(process.cwd() + path.sep).join("");
724
+ }
725
+
726
+ /**
727
+ * Whether the recipe is run on sources the test doesn't give it, which a recipe isn't when it or one in its recipe list
728
+ * is the harness's stand-in for a visitor, or runs in Java, whose harness probes it.
729
+ */
730
+ async function isProbed(recipe: Recipe): Promise<boolean> {
731
+ if (recipe instanceof NoopRecipe || recipe instanceof AdHocRecipe || recipe instanceof RpcRecipe ||
732
+ recipe instanceof DelegatingRecipe) {
733
+ return false;
734
+ }
735
+ for (const child of await recipe.recipeList()) {
736
+ if (!await isProbed(child)) {
737
+ return false;
738
+ }
739
+ }
740
+ return true;
741
+ }
742
+
590
743
  /** Without the comments that search results and markup print as. */
591
744
  function printSanitized(sourceFile: SourceFile): Promise<string> {
592
745
  return TreePrinters.printer(sourceFile).print(sourceFile, new PrintOutputCapture(MarkerPrinter.SANITIZED));