@openrewrite/rewrite 8.95.0 → 8.96.1

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.
@@ -513,8 +513,11 @@ export function withoutDependencyAt(
513
513
  block: AmdBlock,
514
514
  index: number
515
515
  ): J.MethodInvocation {
516
- const dependencies = withElements(block.dependencies, removeEntry(elementsOf(block), index, dependencySlot));
517
516
  const parameters = parametersOf(block);
517
+ if (index < 0 || index >= Math.max(elementsOf(block).length, parameters.length)) {
518
+ return call;
519
+ }
520
+ const dependencies = withElements(block.dependencies, removeEntry(elementsOf(block), index, dependencySlot));
518
521
  const factory = index >= parameters.length ?
519
522
  block.factory :
520
523
  withParameters(block.factory, removeEntry(parameters, index, parameterSlot));
@@ -644,6 +647,11 @@ function queuedFor(visitor: JavaScriptVisitor<any>, blockId: UUID): AddAmdDepend
644
647
  return visitor.afterVisit.filter((v): v is AddAmdDependency<any> => v instanceof AddAmdDependency && v.blockId === blockId);
645
648
  }
646
649
 
650
+ /** Whether `scope` is where the factory's parameters are declared, which for an arrow is the arrow wrapping its lambda. */
651
+ function declaresParameters(scope: J | undefined, factory: J.MethodDeclaration | J.Lambda): boolean {
652
+ return scope === factory || (scope?.kind === JS.Kind.ArrowFunction && (scope as JS.ArrowFunction).lambda === factory);
653
+ }
654
+
647
655
  /**
648
656
  * A local binding for `module` on the AMD lane, creating one where the block does not already
649
657
  * have it, or `undefined` where no safe binding exists.
@@ -662,18 +670,19 @@ export function bindAmd(
662
670
  const modules = dependencyNames(amd.block);
663
671
  const bindings = parameterNames(amd.block);
664
672
 
665
- const declared = module === "" ? -1 : modules.indexOf(module);
666
- if (declared >= 0) {
667
- const bound = bindings[declared];
668
- if (bound === undefined) {
669
- // A parameter that is not a plain name gives the module no binding to hand back.
670
- return undefined;
671
- }
672
- // Only code the factory encloses can shadow its parameter, so a cursor elsewhere in the
673
- // block — on the `define` call, or on its dependency array — has nothing to measure.
674
- const cursor = cursorOf(visitor);
675
- const withinFactory = cursor?.firstEnclosing((v): v is J => v === amd.block.factory);
676
- if (withinFactory === undefined || scopeOf(cursor!).declaringScope(bound) === amd.block.factory) {
673
+ const declared = module === "" ? [] : modules.flatMap((m, i) => m === module ? [i] : []);
674
+ if (declared.length > 0 && bindings[declared[0]] === undefined) {
675
+ // A parameter that is not a plain name gives the module no binding to hand back.
676
+ return undefined;
677
+ }
678
+ // Only code the factory encloses can shadow its parameter, so a cursor elsewhere in the
679
+ // block — on the `define` call, or on its dependency array — has nothing to measure.
680
+ const cursor = cursorOf(visitor);
681
+ const withinFactory = cursor?.firstEnclosing((v): v is J => v === amd.block.factory);
682
+ for (const index of declared) {
683
+ const bound = bindings[index];
684
+ if (bound !== undefined && (withinFactory === undefined ||
685
+ declaresParameters(scopeOf(cursor!).declaringScope(bound), amd.block.factory))) {
677
686
  return bound;
678
687
  }
679
688
  }
@@ -255,6 +255,9 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
255
255
 
256
256
  public async visitContainer<T extends J>(container: J.Container<T>, p: P): Promise<J.Container<T>> {
257
257
  const ret = await super.visitContainer(container, p) as J.Container<T>;
258
+ if (this.cursor.value?.kind === J.Kind.TypeParameter) {
259
+ return ret;
260
+ }
258
261
  return produce(ret, draft => {
259
262
  if (draft.elements.length > 0) {
260
263
  // Apply beforeComma rule to all elements except the last
@@ -321,7 +324,7 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
321
324
  const ret = await super.visitForLoop(forLoop, p) as J.ForLoop;
322
325
  return produceAsync(ret, async draft => {
323
326
  draft.control.prefix.whitespace = this.style.beforeParentheses.forParentheses ? " " : "";
324
- draft.control.init = await Promise.all(draft.control.init.map(async (oneInit, index) => {
327
+ draft.control.init.forEach((oneInit, index) => {
325
328
  if (oneInit.element.kind === J.Kind.VariableDeclarations) {
326
329
  const vd = oneInit.element as Draft<J.VariableDeclarations>;
327
330
  if (vd.modifiers && vd.modifiers.length > 0) {
@@ -330,8 +333,7 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
330
333
  }
331
334
  oneInit.after.whitespace = "";
332
335
  this.spaceBeforeRightPaddedElementDraft(oneInit, index === 0 ? this.style.within.forParentheses : true);
333
- return oneInit;
334
- }));
336
+ });
335
337
  if (draft.control.condition) {
336
338
  draft.control.condition.element.prefix.whitespace = " ";
337
339
  draft.control.condition.after.whitespace = this.style.other.beforeForSemicolon ? " " : "";
@@ -641,9 +643,14 @@ export class SpacesVisitor<P> extends JavaScriptVisitor<P> {
641
643
  const hasConstraint = constraintType.element.kind !== J.Kind.Empty;
642
644
  const hasDefault = defaultType.element.kind !== J.Kind.Empty;
643
645
 
646
+ if (hasDefault && !defaultType.element.prefix.whitespace.includes("\n")) {
647
+ defaultType.element.prefix.whitespace = this.style.aroundOperators.assignment ? " " : "";
648
+ }
644
649
  if (hasConstraint) {
645
- // Space before '=' for default type (in the `after` of constraint type)
646
- if (hasDefault && !constraintType.after.whitespace.includes("\n")) {
650
+ if (!hasDefault) {
651
+ this.spaceAfterRightPaddedDraft(constraintType, this.style.other.beforeComma);
652
+ } else if (!constraintType.after.whitespace.includes("\n")) {
653
+ // Space before '=' for default type (in the `after` of constraint type)
647
654
  constraintType.after.whitespace = this.style.aroundOperators.assignment ? " " : "";
648
655
  }
649
656
  } else if (hasDefault) {
@@ -172,43 +172,39 @@ export class OrderImports extends Recipe {
172
172
  return; // Nothing to sort
173
173
  }
174
174
 
175
- // Handle trailing comma
176
- const trailingComma = elements.length > 0 &&
177
- elements[elements.length - 1].markers?.markers.find(m => m.kind === J.Markers.TrailingComma);
178
- if (trailingComma) {
179
- elements[elements.length - 1].markers.markers =
180
- elements[elements.length - 1].markers.markers.filter(m => m.kind !== J.Markers.TrailingComma);
181
- }
175
+ const bySortKey = (a: { element: unknown }, b: { element: unknown }) =>
176
+ this.getSpecifierSortKey(a.element as JS.ImportSpecifier)
177
+ .localeCompare(this.getSpecifierSortKey(b.element as JS.ImportSpecifier));
178
+ if (elements.some((e, i) => i > 0 && bySortKey(elements[i - 1], e) > 0)) {
179
+ const trailingComma = elements[elements.length - 1].markers?.markers.find(m => m.kind === J.Markers.TrailingComma);
180
+ if (trailingComma) {
181
+ elements[elements.length - 1].markers.markers =
182
+ elements[elements.length - 1].markers.markers.filter(m => m.kind !== J.Markers.TrailingComma);
183
+ }
184
+
185
+ elements.sort(bySortKey);
182
186
 
183
- // Sort by the imported name (not alias)
184
- elements.sort((a, b) => {
185
- const nameA = this.getSpecifierSortKey(a.element as JS.ImportSpecifier);
186
- const nameB = this.getSpecifierSortKey(b.element as JS.ImportSpecifier);
187
- return nameA.localeCompare(nameB);
188
- });
187
+ if (trailingComma) {
188
+ elements[elements.length - 1].markers.markers.push(trailingComma);
189
+ }
190
+ }
189
191
 
190
- // Normalize spacing based on es6ImportExportBraces style
191
192
  const braceSpace = useBraceSpaces ? " " : "";
193
+ const differs = (space: J.Space, whitespace: string) =>
194
+ space.whitespace !== whitespace || space.comments.length > 0;
195
+ const spaceOf = (whitespace: string): J.Space => ({kind: J.Kind.Space, whitespace, comments: []});
192
196
  for (let i = 0; i < elements.length; i++) {
193
- if (i === 0) {
194
- // First element: space after opening brace based on style
195
- elements[i].element.prefix = {kind: J.Kind.Space, whitespace: braceSpace, comments: []};
196
- } else {
197
- // Other elements: space after comma
198
- elements[i].element.prefix = {kind: J.Kind.Space, whitespace: ' ', comments: []};
197
+ const whitespace = i === 0 ? braceSpace : ' ';
198
+ if (differs(elements[i].element.prefix, whitespace)) {
199
+ elements[i].element.prefix = spaceOf(whitespace);
199
200
  }
200
201
  }
201
- // Last element: space before closing brace based on style
202
- elements[elements.length - 1].after = {kind: J.Kind.Space, whitespace: braceSpace, comments: []};
203
-
204
- // Restore trailing comma to last element
205
- if (trailingComma && elements.length > 0 &&
206
- !elements[elements.length - 1].markers.markers.find(m => m.kind === J.Markers.TrailingComma)) {
207
- elements[elements.length - 1].markers.markers.push(trailingComma);
202
+ if (differs(elements[elements.length - 1].after, braceSpace)) {
203
+ elements[elements.length - 1].after = spaceOf(braceSpace);
208
204
  }
209
205
  });
210
206
 
211
- ret.push(produce(importPadded, draft => {
207
+ ret.push(importSorted === import_ ? importPadded : produce(importPadded, draft => {
212
208
  draft.element = importSorted;
213
209
  }));
214
210
  } else {
@@ -163,6 +163,9 @@ export class FindDependency extends Recipe {
163
163
  private async markDependency(member: Json.Member, ctx: ExecutionContext): Promise<Json.Member> {
164
164
  const visitedMember = await super.visitMember(member, ctx) as Json.Member;
165
165
  const markedKey = foundSearchResult(visitedMember.key.element);
166
+ if (markedKey === visitedMember.key.element) {
167
+ return visitedMember;
168
+ }
166
169
  return {
167
170
  ...visitedMember,
168
171
  key: {
package/src/markers.ts CHANGED
@@ -275,6 +275,10 @@ export function markupDebug<T extends { markers: Markers }>(t: T, message: strin
275
275
  * Helper to add a markup marker to a tree node.
276
276
  */
277
277
  function addMarkup<T extends { markers: Markers }>(t: T, markup: Markup): T {
278
+ if (t.markers.markers.some(m => m.kind === markup.kind &&
279
+ (m as Markup).message === markup.message && (m as Markup).detail === markup.detail)) {
280
+ return t;
281
+ }
278
282
  return {
279
283
  ...t,
280
284
  markers: {
@@ -27,6 +27,7 @@ import {isNamedStyles} from "../style";
27
27
  import {JavaScriptVisitor} from "../javascript";
28
28
  import {attributionHint, dumpTypesIfRequested} from "../javascript/type-report";
29
29
  import {J, Type} from "../java";
30
+ import {DATA_TABLE_STORE} from "../data-table";
30
31
 
31
32
  export interface SourceSpec<T extends SourceFile> {
32
33
  kind: string,
@@ -44,6 +45,8 @@ interface RecipeSpecConfiguration {
44
45
  checkParsePrintIdempotence: boolean;
45
46
  allowEmptyDiff: boolean;
46
47
  checkReportingMarkersOnly: boolean;
48
+ cycles?: number;
49
+ expectedCyclesThatMakeChanges?: number;
47
50
  recipe: Recipe;
48
51
  recipeExecutionContext: ExecutionContext;
49
52
  dataTableAssertions: { [key: string]: (rows: any[]) => void };
@@ -59,6 +62,19 @@ export class RecipeSpec {
59
62
  */
60
63
  checkReportingMarkersOnly: boolean = true
61
64
 
65
+ /**
66
+ * The cycles to run, by default one more than {@link expectedCyclesThatMakeChanges} and at least two. The
67
+ * cycles after the expected ones, and a run on a fresh parse of the output, fail the test if they change
68
+ * anything, so setting this to 1 turns the stability checks off.
69
+ */
70
+ cycles?: number
71
+
72
+ /**
73
+ * How many cycles a recipe needs to settle, by default 1. A recipe that only reaches its result over
74
+ * several cycles declares how many here, and the test then compares the output of the last of them.
75
+ */
76
+ expectedCyclesThatMakeChanges?: number
77
+
62
78
  recipe: Recipe = new NoopRecipe()
63
79
 
64
80
  /**
@@ -94,6 +110,8 @@ export class RecipeSpec {
94
110
  checkParsePrintIdempotence: this.checkParsePrintIdempotence,
95
111
  allowEmptyDiff: this.allowEmptyDiff,
96
112
  checkReportingMarkersOnly: this.checkReportingMarkersOnly,
113
+ cycles: this.cycles,
114
+ expectedCyclesThatMakeChanges: this.expectedCyclesThatMakeChanges,
97
115
  recipe: this.recipe,
98
116
  recipeExecutionContext: this.recipeExecutionContext,
99
117
  dataTableAssertions: {...this.dataTableAssertions}
@@ -194,19 +212,142 @@ export class RecipeSpec {
194
212
  }
195
213
  }
196
214
 
197
- const changeset = (await scheduleRun(this.recipe,
198
- allParsed.map(([_, sourceFile]) => sourceFile),
199
- this.recipeExecutionContext)).changeset;
215
+ const expectedCycles = this.expectedCyclesThatMakeChanges ?? 1;
216
+ const cycles = this.cycles ?? Math.max(2, expectedCycles + 1);
217
+ const {changeset, unsettled} = await this.runCycles(allParsed.map(([_, sourceFile]) => sourceFile),
218
+ cycles, expectedCycles);
200
219
 
201
220
  this.checkReportingMarkersOnly && await this.expectNoAddedMarkers(flattenedSpecs, allParsed, changeset);
202
221
  await this.expectResultsToMatchAfter(flattenedSpecs, changeset, allParsed);
203
222
  await this.expectGeneratedFiles(flattenedSpecs, changeset);
223
+ if (unsettled) {
224
+ await this.expectNoFurtherChanges(unsettled.changes,
225
+ `Expected recipe to complete in ${pluralCycles(expectedCycles)}, but cycle ${unsettled.cycle} changed`);
226
+ }
227
+ cycles > expectedCycles && await this.expectStableOnFreshParse(flattenedSpecs, allParsed, changeset);
204
228
 
205
229
  // for (const [name, assertion] of Object.entries(this.dataTableAssertions)) {
206
230
  // assertion(getRows(name, this.recipeExecutionContext));
207
231
  // }
208
232
  }
209
233
 
234
+ private async runCycles(sources: SourceFile[], cycles: number, expectedCycles: number):
235
+ Promise<{ changeset: Result[], unsettled?: { cycle: number, changes: Result[] } }> {
236
+ const lineages: { origin?: SourceFile, latest?: SourceFile }[] = sources.map(s => ({origin: s, latest: s}));
237
+ const changesetOf = () => lineages.map(({origin, latest}) => new Result(origin, latest));
238
+ let changeset: Result[] | undefined;
239
+ let cyclesThatMadeChanges = 0;
240
+ for (let cycle = 1; cycle <= cycles; cycle++) {
241
+ const live = lineages.filter(l => l.latest).map(l => l.latest!);
242
+ const ctx = cycle === 1 ? this.recipeExecutionContext : this.laterRunContext();
243
+ const changes = (await scheduleRun(this.recipe, live, ctx)).changeset.filter(r => r.before !== r.after);
244
+ if (changes.length === 0) {
245
+ break;
246
+ }
247
+ if (changeset) {
248
+ return {changeset, unsettled: {cycle, changes}};
249
+ }
250
+ cyclesThatMadeChanges++;
251
+ for (const {before, after} of changes) {
252
+ const lineage = before && lineages.find(l => l.latest === before);
253
+ if (lineage) {
254
+ lineage.latest = after;
255
+ } else {
256
+ lineages.push({latest: after});
257
+ }
258
+ }
259
+ if (cycle >= expectedCycles) {
260
+ changeset = changesetOf();
261
+ }
262
+ }
263
+ if (cyclesThatMadeChanges < expectedCycles && expectedCycles > 1) {
264
+ throw new Error(`Expected recipe to complete in ${pluralCycles(expectedCycles)}, ` +
265
+ `but it settled after ${pluralCycles(cyclesThatMadeChanges)}. Lower expectedCyclesThatMakeChanges.`);
266
+ }
267
+ return {changeset: changeset ?? changesetOf()};
268
+ }
269
+
270
+ /** Data tables are written by the first cycle only, as in Java. */
271
+ private laterRunContext(): ExecutionContext {
272
+ const messages = {...this.recipeExecutionContext.messages};
273
+ delete messages[DATA_TABLE_STORE];
274
+ return new ExecutionContext(messages);
275
+ }
276
+
277
+ private async expectNoFurtherChanges(changes: Result[], headline: string, sanitized: boolean = false) {
278
+ const print = (sourceFile: SourceFile) => sanitized ? printSanitized(sourceFile) : TreePrinters.print(sourceFile);
279
+ const found: string[] = [];
280
+ for (const {before, after} of changes) {
281
+ if (!before) {
282
+ found.push(` ${after!.sourcePath}, which it generated`);
283
+ } else if (!after) {
284
+ found.push(` ${before.sourcePath}, which it deleted`);
285
+ } else {
286
+ const [expected, actual] = [await print(before), await print(after)];
287
+ if (expected !== actual) {
288
+ found.push(` ${after.sourcePath}\nExpected:\n${expected}\n\nActual:\n${actual}`);
289
+ } else if (!sanitized && !this.allowEmptyDiff) {
290
+ found.push(` ${after.sourcePath}, whose LST it changed without changing the printed source`);
291
+ }
292
+ }
293
+ }
294
+ if (found.length > 0) {
295
+ throw new Error([
296
+ `${headline} the sources again:`,
297
+ ...found,
298
+ "A recipe should recognize code it has already changed and leave it alone, so that running it " +
299
+ "again changes nothing. A recipe that is meant to need more cycles declares how many with " +
300
+ "expectedCyclesThatMakeChanges."
301
+ ].join("\n"));
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Code that the next run reads is parsed from the printed output, not the LST this run produced, and a
307
+ * recipe that recognizes its own output only by the shape of that LST changes it again.
308
+ */
309
+ private async expectStableOnFreshParse(specs: SourceSpec<any>[], parsed: [SourceSpec<any>, SourceFile][],
310
+ changeset: Result[]) {
311
+ if (!changeset.some(r => r.before !== r.after)) {
312
+ return;
313
+ }
314
+ const finals = changeset.filter(r => r.after).map(({before, after}): [SourceSpec<any> | undefined, SourceFile] => [
315
+ before ? parsed.find(([_, sourceFile]) => sourceFile === before)?.[0] :
316
+ specs.find(s => s.path === after!.sourcePath),
317
+ after!
318
+ ]);
319
+
320
+ const reparsedByFinal = new Map<SourceFile, SourceFile>();
321
+ const kinds = new Set(finals.filter(([spec]) => spec).map(([spec]) => spec!.kind));
322
+ for (const kind of kinds) {
323
+ const group = finals.filter(([spec]) => spec?.kind === kind);
324
+ const inputs = [];
325
+ for (const [_, sourceFile] of group) {
326
+ inputs.push({text: await printSanitized(sourceFile), sourcePath: sourceFile.sourcePath});
327
+ }
328
+ let i = 0;
329
+ try {
330
+ for await (const reparsed of group[0][0]!.parser(this.executionContext).parse(...inputs)) {
331
+ if (!isParseError(reparsed) && await printSanitized(reparsed) === inputs[i].text) {
332
+ // What the source file carries, like its styles, comes from outside its text
333
+ reparsedByFinal.set(group[i][1], {...reparsed, markers: group[i][1].markers});
334
+ }
335
+ i++;
336
+ }
337
+ } catch {
338
+ // files that do not parse again keep the LST the recipe produced
339
+ }
340
+ }
341
+ if (reparsedByFinal.size === 0) {
342
+ return;
343
+ }
344
+
345
+ const sources = finals.map(([_, sourceFile]) => reparsedByFinal.get(sourceFile) ?? sourceFile);
346
+ const changes = (await scheduleRun(this.recipe, sources, this.laterRunContext())).changeset
347
+ .filter(r => r.before !== r.after);
348
+ await this.expectNoFurtherChanges(changes, "Running the recipe on a fresh parse of its output changed", true);
349
+ }
350
+
210
351
  private async expectNoParseFailures(parsed: [SourceSpec<any>, SourceFile][]) {
211
352
  for (const [_, sourceFile] of parsed) {
212
353
  if (sourceFile.kind === ParseErrorKind) {
@@ -291,9 +432,7 @@ export class RecipeSpec {
291
432
  */
292
433
  private async freshParsePlacements(spec: SourceSpec<any>, after: SourceFile): Promise<Set<string>> {
293
434
  try {
294
- // Without the comments that search results and markup print as
295
- const out = new PrintOutputCapture(MarkerPrinter.SANITIZED);
296
- const text = await TreePrinters.printer(after).print(after, out);
435
+ const text = await printSanitized(after);
297
436
  const reparsed = await spec.parser(this.executionContext).parseOne({text, sourcePath: after.sourcePath});
298
437
  return isParseError(reparsed) ? new Set() : new Set(markerPlacements(reparsed).keys());
299
438
  } catch {
@@ -420,6 +559,15 @@ class ValidateWhitespaceVisitor extends JavaScriptVisitor<ExecutionContext> {
420
559
  }
421
560
  }
422
561
 
562
+ /** Without the comments that search results and markup print as. */
563
+ function printSanitized(sourceFile: SourceFile): Promise<string> {
564
+ return TreePrinters.printer(sourceFile).print(sourceFile, new PrintOutputCapture(MarkerPrinter.SANITIZED));
565
+ }
566
+
567
+ function pluralCycles(count: number): string {
568
+ return `${count} cycle${count === 1 ? "" : "s"}`;
569
+ }
570
+
423
571
  /** Markers that report on an LST rather than describe it, which a recipe is free to leave. */
424
572
  const reportingMarkerKinds = new Set<string>([
425
573
  MarkersKind.SearchResult,