miniread 1.53.0 → 1.55.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 (83) hide show
  1. package/README.md +7 -0
  2. package/dist/cli/execute-transforms.d.ts +6 -1
  3. package/dist/cli/execute-transforms.js +9 -3
  4. package/dist/cli/find-input-files.d.ts +13 -0
  5. package/dist/cli/find-input-files.js +68 -0
  6. package/dist/cli/run-transforms.d.ts +1 -0
  7. package/dist/cli/run-transforms.js +7 -59
  8. package/dist/cli/transform-stdin.d.ts +1 -0
  9. package/dist/cli/transform-stdin.js +4 -3
  10. package/dist/cli.js +24 -1
  11. package/dist/core/collect-exported-names.js +28 -8
  12. package/dist/core/create-has-binding-options-up-to-scope.d.ts +8 -0
  13. package/dist/core/create-has-binding-options-up-to-scope.js +7 -0
  14. package/dist/core/default-transform-options.d.ts +10 -0
  15. package/dist/core/default-transform-options.js +5 -0
  16. package/dist/core/has-shadowing-risk.d.ts +2 -0
  17. package/dist/core/has-shadowing-risk.js +24 -0
  18. package/dist/core/stable-naming.d.ts +3 -0
  19. package/dist/core/stable-naming.js +28 -34
  20. package/dist/core/types.d.ts +1 -1
  21. package/dist/scripts/snapshot/run-testcase-transform.js +3 -1
  22. package/dist/transforms/_generated/manifest.js +70 -8
  23. package/dist/transforms/_generated/registry.js +6 -0
  24. package/dist/transforms/preset-stats.json +2 -2
  25. package/dist/transforms/recommended-transform-order.d.ts +7 -0
  26. package/dist/transforms/recommended-transform-order.js +59 -0
  27. package/dist/transforms/rename-deferred-resolve-parameters/manifest.json +2 -2
  28. package/dist/transforms/rename-deferred-resolve-parameters/rename-binding-if-safe.js +1 -10
  29. package/dist/transforms/rename-event-parameters/can-rename-binding-safely.js +1 -13
  30. package/dist/transforms/rename-event-parameters/manifest.json +3 -3
  31. package/dist/transforms/rename-interval-ids/manifest.json +12 -0
  32. package/dist/transforms/rename-interval-ids/rename-interval-ids-transform.d.ts +2 -0
  33. package/dist/transforms/rename-interval-ids/rename-interval-ids-transform.js +85 -0
  34. package/dist/transforms/rename-promise-executor-parameters/rename-binding-if-safe.js +1 -14
  35. package/dist/transforms/rename-promise-executor-parameters-v2/manifest.json +3 -3
  36. package/dist/transforms/rename-promise-executor-parameters-v2/rename-binding-if-safe.js +1 -23
  37. package/dist/transforms/replace-dynamic-require-eval/manifest.json +13 -0
  38. package/dist/transforms/replace-dynamic-require-eval/replace-dynamic-require-eval-transform.d.ts +2 -0
  39. package/dist/transforms/replace-dynamic-require-eval/replace-dynamic-require-eval-transform.js +134 -0
  40. package/dist/transforms/stabilize-top-level-bindings/analyze-global-object-member-access.d.ts +23 -0
  41. package/dist/transforms/stabilize-top-level-bindings/analyze-global-object-member-access.js +22 -0
  42. package/dist/transforms/stabilize-top-level-bindings/can-rename-binding-safely.d.ts +2 -0
  43. package/dist/transforms/stabilize-top-level-bindings/can-rename-binding-safely.js +24 -0
  44. package/dist/transforms/stabilize-top-level-bindings/collect-first-program-body-assignments.d.ts +12 -0
  45. package/dist/transforms/stabilize-top-level-bindings/collect-first-program-body-assignments.js +29 -0
  46. package/dist/transforms/stabilize-top-level-bindings/collect-nested-program-scope-variable-initializers.d.ts +16 -0
  47. package/dist/transforms/stabilize-top-level-bindings/collect-nested-program-scope-variable-initializers.js +63 -0
  48. package/dist/transforms/stabilize-top-level-bindings/collect-rename-candidates.d.ts +15 -0
  49. package/dist/transforms/stabilize-top-level-bindings/collect-rename-candidates.js +185 -0
  50. package/dist/transforms/stabilize-top-level-bindings/create-global-object-member-expression-visitors.d.ts +13 -0
  51. package/dist/transforms/stabilize-top-level-bindings/create-global-object-member-expression-visitors.js +41 -0
  52. package/dist/transforms/stabilize-top-level-bindings/detect-dynamic-name-lookup.d.ts +26 -0
  53. package/dist/transforms/stabilize-top-level-bindings/detect-dynamic-name-lookup.js +151 -0
  54. package/dist/transforms/stabilize-top-level-bindings/detect-global-object-property-access.d.ts +21 -0
  55. package/dist/transforms/stabilize-top-level-bindings/detect-global-object-property-access.js +29 -0
  56. package/dist/transforms/stabilize-top-level-bindings/eval-like-call-detection.d.ts +4 -0
  57. package/dist/transforms/stabilize-top-level-bindings/eval-like-call-detection.js +73 -0
  58. package/dist/transforms/stabilize-top-level-bindings/fingerprint-leaf-node.d.ts +2 -0
  59. package/dist/transforms/stabilize-top-level-bindings/fingerprint-leaf-node.js +23 -0
  60. package/dist/transforms/stabilize-top-level-bindings/fingerprint-node.d.ts +1 -0
  61. package/dist/transforms/stabilize-top-level-bindings/fingerprint-node.js +9 -0
  62. package/dist/transforms/stabilize-top-level-bindings/fingerprint-scalar-fields.d.ts +2 -0
  63. package/dist/transforms/stabilize-top-level-bindings/fingerprint-scalar-fields.js +153 -0
  64. package/dist/transforms/stabilize-top-level-bindings/function-constructor-call-detection.d.ts +3 -0
  65. package/dist/transforms/stabilize-top-level-bindings/function-constructor-call-detection.js +63 -0
  66. package/dist/transforms/stabilize-top-level-bindings/global-object-access.d.ts +10 -0
  67. package/dist/transforms/stabilize-top-level-bindings/global-object-access.js +118 -0
  68. package/dist/transforms/stabilize-top-level-bindings/is-global-eval-destructuring.d.ts +7 -0
  69. package/dist/transforms/stabilize-top-level-bindings/is-global-eval-destructuring.js +29 -0
  70. package/dist/transforms/stabilize-top-level-bindings/is-valid-binding-identifier.d.ts +1 -0
  71. package/dist/transforms/stabilize-top-level-bindings/is-valid-binding-identifier.js +10 -0
  72. package/dist/transforms/stabilize-top-level-bindings/manifest.json +13 -0
  73. package/dist/transforms/stabilize-top-level-bindings/rename-binding-in-place.d.ts +13 -0
  74. package/dist/transforms/stabilize-top-level-bindings/rename-binding-in-place.js +140 -0
  75. package/dist/transforms/stabilize-top-level-bindings/rename-candidate.d.ts +19 -0
  76. package/dist/transforms/stabilize-top-level-bindings/rename-candidate.js +14 -0
  77. package/dist/transforms/stabilize-top-level-bindings/serialize-fingerprint-node.d.ts +3 -0
  78. package/dist/transforms/stabilize-top-level-bindings/serialize-fingerprint-node.js +112 -0
  79. package/dist/transforms/stabilize-top-level-bindings/stabilize-top-level-bindings-transform.d.ts +2 -0
  80. package/dist/transforms/stabilize-top-level-bindings/stabilize-top-level-bindings-transform.js +101 -0
  81. package/dist/transforms/stabilize-top-level-bindings/string-timer-call-detection.d.ts +4 -0
  82. package/dist/transforms/stabilize-top-level-bindings/string-timer-call-detection.js +83 -0
  83. package/package.json +1 -1
package/README.md CHANGED
@@ -17,8 +17,15 @@ miniread --list-transforms
17
17
  miniread --input ./minified --output ./readable --transforms recommended
18
18
  miniread --input ./minified --output ./readable --dry-run
19
19
  miniread --input ./minified --output ./readable --workers 8
20
+ miniread --input ./minified --output ./readable --safe-stabilize-top-level-bindings
20
21
  ```
21
22
 
23
+ ## Output stability
24
+
25
+ The recommended preset includes `stabilize-top-level-bindings`, which renames program-scope bindings to stable hash-based names (`$h_<hash>`). This produces deterministic output across minifier versions, making diffs more useful.
26
+
27
+ By default, this transform runs even when it detects code patterns (like `eval`) that could make the output non-runnable. Use `--safe-stabilize-top-level-bindings` or set `MINIREAD_UNSAFE_STABILIZE_TOP_LEVEL_BINDINGS` to a falsy value (`0`, `false`, `no`, or empty string) to skip renaming in such cases.
28
+
22
29
  ## Examples
23
30
 
24
31
  ### Find the most common identifiers in a minified bundle
@@ -5,5 +5,10 @@ type TransformExecutionResult = {
5
5
  error?: string;
6
6
  };
7
7
  type VerboseLogger = (message: string) => void;
8
- export declare const executeTransforms: (transforms: Transform[], projectGraph: ProjectGraph, queue: PQueue, log: VerboseLogger) => Promise<TransformExecutionResult>;
8
+ /**
9
+ * @param transformOptions - Shared across all transform invocations; shallow-frozen
10
+ * at runtime to prevent accidental top-level property mutation. Nested objects are
11
+ * not frozen (current options are all primitives).
12
+ */
13
+ export declare const executeTransforms: (transforms: Transform[], projectGraph: ProjectGraph, queue: PQueue, transformOptions: Readonly<Record<string, unknown>>, log: VerboseLogger) => Promise<TransformExecutionResult>;
9
14
  export {};
@@ -1,4 +1,10 @@
1
- export const executeTransforms = async (transforms, projectGraph, queue, log) => {
1
+ /**
2
+ * @param transformOptions - Shared across all transform invocations; shallow-frozen
3
+ * at runtime to prevent accidental top-level property mutation. Nested objects are
4
+ * not frozen (current options are all primitives).
5
+ */
6
+ export const executeTransforms = async (transforms, projectGraph, queue, transformOptions, log) => {
7
+ const frozenOptions = Object.freeze({ ...transformOptions });
2
8
  let totalTransformations = 0;
3
9
  for (const transform of transforms) {
4
10
  log(`Running transform: ${transform.id}`);
@@ -9,7 +15,7 @@ export const executeTransforms = async (transforms, projectGraph, queue, log) =>
9
15
  const statsPromises = fileInfos.map((fileInfo) => queue.add(() => transform.transform({
10
16
  projectGraph,
11
17
  currentFile: fileInfo,
12
- options: {},
18
+ options: frozenOptions,
13
19
  })));
14
20
  const allStats = await Promise.all(statsPromises);
15
21
  const transformApplied = allStats.reduce((sum, stats) => sum + stats.transformationsApplied, 0);
@@ -21,7 +27,7 @@ export const executeTransforms = async (transforms, projectGraph, queue, log) =>
21
27
  const stats = await transform.transform({
22
28
  projectGraph,
23
29
  currentFile: undefined,
24
- options: {},
30
+ options: frozenOptions,
25
31
  });
26
32
  totalTransformations += stats.transformationsApplied;
27
33
  log(`Applied ${stats.transformationsApplied} transformation(s)`);
@@ -0,0 +1,13 @@
1
+ type FindInputFilesOk = {
2
+ ok: true;
3
+ inputBase: string;
4
+ sourceFilePaths: string[];
5
+ otherFilePaths: string[];
6
+ };
7
+ type FindInputFilesError = {
8
+ ok: false;
9
+ errors: string[];
10
+ };
11
+ type FindInputFilesResult = FindInputFilesOk | FindInputFilesError;
12
+ export declare const findInputFiles: (input: string) => Promise<FindInputFilesResult>;
13
+ export {};
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const isSourceFile = (fileName) => {
4
+ return (!fileName.endsWith(".d.ts") &&
5
+ (fileName.endsWith(".ts") ||
6
+ fileName.endsWith(".tsx") ||
7
+ fileName.endsWith(".js") ||
8
+ fileName.endsWith(".jsx")));
9
+ };
10
+ const findAllFiles = async (directory) => {
11
+ const sourceFiles = [];
12
+ const otherFiles = [];
13
+ const entries = await fs.readdir(directory, { withFileTypes: true });
14
+ for (const entry of entries) {
15
+ const fullPath = path.join(directory, entry.name);
16
+ if (entry.isDirectory()) {
17
+ const subResult = await findAllFiles(fullPath);
18
+ sourceFiles.push(...subResult.sourceFiles);
19
+ otherFiles.push(...subResult.otherFiles);
20
+ continue;
21
+ }
22
+ if (!entry.isFile())
23
+ continue;
24
+ if (isSourceFile(entry.name)) {
25
+ sourceFiles.push(fullPath);
26
+ continue;
27
+ }
28
+ otherFiles.push(fullPath);
29
+ }
30
+ return { sourceFiles, otherFiles };
31
+ };
32
+ export const findInputFiles = async (input) => {
33
+ let inputStat;
34
+ try {
35
+ inputStat = await fs.stat(input);
36
+ }
37
+ catch (error) {
38
+ const message = error instanceof Error ? error.message : "Unknown error accessing path";
39
+ return { ok: false, errors: [`Cannot access input path: ${message}`] };
40
+ }
41
+ let sourceFilePaths;
42
+ let otherFilePaths;
43
+ try {
44
+ if (inputStat.isDirectory()) {
45
+ const allFiles = await findAllFiles(input);
46
+ sourceFilePaths = allFiles.sourceFiles;
47
+ otherFilePaths = allFiles.otherFiles;
48
+ }
49
+ else {
50
+ sourceFilePaths = isSourceFile(input) ? [input] : [];
51
+ otherFilePaths = isSourceFile(input) ? [] : [input];
52
+ }
53
+ }
54
+ catch (error) {
55
+ const message = error instanceof Error ? error.message : "Unknown error reading files";
56
+ return { ok: false, errors: [`Error reading input: ${message}`] };
57
+ }
58
+ if (sourceFilePaths.length === 0) {
59
+ return { ok: false, errors: ["No source files found in input path"] };
60
+ }
61
+ const inputBase = inputStat.isDirectory() ? input : path.dirname(input);
62
+ return {
63
+ ok: true,
64
+ inputBase,
65
+ sourceFilePaths,
66
+ otherFilePaths,
67
+ };
68
+ };
@@ -3,6 +3,7 @@ export type RunnerOptions = {
3
3
  input: string;
4
4
  output: string;
5
5
  transforms: Transform[];
6
+ transformOptions?: Record<string, unknown>;
6
7
  dryRun: boolean;
7
8
  workers: number;
8
9
  overwrite: boolean;
@@ -1,77 +1,26 @@
1
1
  import * as fs from "node:fs/promises";
2
- import path from "node:path";
3
2
  import PQueue from "p-queue";
4
3
  import { buildProjectGraph } from "../core/project-graph.js";
5
4
  import { printDryRunSummary, writeOutputFiles } from "./output.js";
6
5
  import { generateCode } from "./generate-code.js";
7
6
  import { executeTransforms } from "./execute-transforms.js";
7
+ import { findInputFiles } from "./find-input-files.js";
8
8
  const logVerbose = (verbose, message) => {
9
9
  if (!verbose)
10
10
  return;
11
11
  console.error(message);
12
12
  };
13
- const isSourceFile = (fileName) => {
14
- return (!fileName.endsWith(".d.ts") &&
15
- (fileName.endsWith(".ts") ||
16
- fileName.endsWith(".tsx") ||
17
- fileName.endsWith(".js") ||
18
- fileName.endsWith(".jsx")));
19
- };
20
- const findAllFiles = async (directory) => {
21
- const sourceFiles = [];
22
- const otherFiles = [];
23
- const entries = await fs.readdir(directory, { withFileTypes: true });
24
- for (const entry of entries) {
25
- const fullPath = path.join(directory, entry.name);
26
- if (entry.isDirectory()) {
27
- const subResult = await findAllFiles(fullPath);
28
- sourceFiles.push(...subResult.sourceFiles);
29
- otherFiles.push(...subResult.otherFiles);
30
- }
31
- else if (entry.isFile()) {
32
- if (isSourceFile(entry.name)) {
33
- sourceFiles.push(fullPath);
34
- }
35
- else {
36
- otherFiles.push(fullPath);
37
- }
38
- }
39
- }
40
- return { sourceFiles, otherFiles };
41
- };
42
13
  export const runTransforms = async (options) => {
43
- const { input, output, transforms, dryRun, workers, overwrite, verbose } = options;
44
- // Check if input exists
45
- try {
46
- await fs.access(input);
47
- }
48
- catch {
49
- return {
50
- filesProcessed: 0,
51
- totalTransformations: 0,
52
- errors: [`Input path does not exist: ${input}`],
53
- };
54
- }
55
- // Find all files
56
- const inputStat = await fs.stat(input);
57
- let sourceFilePaths;
58
- let otherFilePaths;
59
- if (inputStat.isDirectory()) {
60
- const allFiles = await findAllFiles(input);
61
- sourceFilePaths = allFiles.sourceFiles;
62
- otherFilePaths = allFiles.otherFiles;
63
- }
64
- else {
65
- sourceFilePaths = isSourceFile(input) ? [input] : [];
66
- otherFilePaths = isSourceFile(input) ? [] : [input];
67
- }
68
- if (sourceFilePaths.length === 0) {
14
+ const { input, output, transforms, transformOptions = {}, dryRun, workers, overwrite, verbose, } = options;
15
+ const inputFilesResult = await findInputFiles(input);
16
+ if (!inputFilesResult.ok) {
69
17
  return {
70
18
  filesProcessed: 0,
71
19
  totalTransformations: 0,
72
- errors: ["No source files found in input path"],
20
+ errors: inputFilesResult.errors,
73
21
  };
74
22
  }
23
+ const { inputBase, sourceFilePaths, otherFilePaths } = inputFilesResult;
75
24
  logVerbose(verbose, `Found ${sourceFilePaths.length} source file(s)`);
76
25
  if (otherFilePaths.length > 0) {
77
26
  logVerbose(verbose, `Found ${otherFilePaths.length} other file(s) to copy`);
@@ -92,7 +41,7 @@ export const runTransforms = async (options) => {
92
41
  const log = (message) => {
93
42
  logVerbose(verbose, message);
94
43
  };
95
- const result = await executeTransforms(transforms, projectGraph, queue, log);
44
+ const result = await executeTransforms(transforms, projectGraph, queue, transformOptions, log);
96
45
  if (result.error) {
97
46
  return {
98
47
  filesProcessed: 0,
@@ -108,7 +57,6 @@ export const runTransforms = async (options) => {
108
57
  finalFiles.set(filePath, generated);
109
58
  }
110
59
  // Output results
111
- const inputBase = inputStat.isDirectory() ? input : path.dirname(input);
112
60
  if (dryRun) {
113
61
  printDryRunSummary(finalFiles, otherFilePaths, inputBase);
114
62
  }
@@ -1,6 +1,7 @@
1
1
  import type { Transform } from "../core/types.js";
2
2
  type StdinTransformOptions = {
3
3
  transforms: Transform[];
4
+ transformOptions: Record<string, unknown>;
4
5
  verbose: boolean;
5
6
  };
6
7
  type StdinTransformResult = {
@@ -19,7 +19,7 @@ const readStdin = async () => {
19
19
  });
20
20
  };
21
21
  export const transformStdin = async (options) => {
22
- const { transforms, verbose } = options;
22
+ const { transforms, transformOptions, verbose } = options;
23
23
  const inputText = await readStdin();
24
24
  const stdinPath = "stdin.tsx";
25
25
  const parsedFiles = [{ path: stdinPath, content: inputText }];
@@ -28,13 +28,14 @@ export const transformStdin = async (options) => {
28
28
  if (!stdinFile) {
29
29
  return { ok: false, error: "Failed to parse input" };
30
30
  }
31
+ const frozenOptions = Object.freeze({ ...transformOptions });
31
32
  let totalTransformations = 0;
32
33
  for (const transform of transforms) {
33
34
  try {
34
35
  const stats = await transform.transform({
35
36
  projectGraph,
36
- currentFile: undefined,
37
- options: {},
37
+ currentFile: stdinFile,
38
+ options: frozenOptions,
38
39
  });
39
40
  totalTransformations += stats.transformationsApplied;
40
41
  logVerbose(verbose, `Applied ${stats.transformationsApplied} transformation(s) from ${transform.id}`);
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { Command, InvalidArgumentError } from "@commander-js/extra-typings";
3
3
  import packageJson from "../package.json" with { type: "json" };
4
4
  import { getTransformsToRun, getTransformSummaries } from "./cli/config.js";
5
5
  import { runTransforms, transformStdin } from "./cli/runner.js";
6
+ import { getDefaultTransformOptions } from "./core/default-transform-options.js";
6
7
  const parseListFormat = (value) => {
7
8
  if (value === "text" || value === "tsv" || value === "json")
8
9
  return value;
@@ -15,6 +16,19 @@ const parsePositiveInt = (value) => {
15
16
  }
16
17
  return parsed;
17
18
  };
19
+ const isEnvironmentFalsy = (value) => {
20
+ if (value === undefined)
21
+ return false;
22
+ const trimmed = value.trim().toLowerCase();
23
+ return trimmed === "" || ["0", "false", "no"].includes(trimmed);
24
+ };
25
+ const shouldUnsafeStabilize = (safeFlag) => {
26
+ if (safeFlag === true)
27
+ return false;
28
+ if (isEnvironmentFalsy(process.env.MINIREAD_UNSAFE_STABILIZE_TOP_LEVEL_BINDINGS))
29
+ return false;
30
+ return true;
31
+ };
18
32
  const program = new Command()
19
33
  .name(packageJson.name)
20
34
  .description(packageJson.description)
@@ -29,7 +43,8 @@ const program = new Command()
29
43
  .option("--format <format>", "Output format for --list-transforms: text, tsv, json", parseListFormat, "text")
30
44
  .option("--dry-run", "Show what would be changed without writing files")
31
45
  .option("-w, --workers <n>", "Number of parallel workers", parsePositiveInt, 4)
32
- .option("-f, --overwrite", "Overwrite existing files in output directory");
46
+ .option("-f, --overwrite", "Overwrite existing files in output directory")
47
+ .option("--safe-stabilize-top-level-bindings", "Make stabilize-top-level-bindings bail out on dynamic-name hazards (safer; output more likely runnable)");
33
48
  program.addHelpText("after", `
34
49
  Examples:
35
50
  # Read from stdin, write to stdout (filter mode)
@@ -86,6 +101,10 @@ else if (options.input) {
86
101
  input: options.input,
87
102
  output: options.output,
88
103
  transforms: transformsResult.transforms,
104
+ transformOptions: {
105
+ ...getDefaultTransformOptions(),
106
+ unsafeStabilizeTopLevelBindings: shouldUnsafeStabilize(options.safeStabilizeTopLevelBindings),
107
+ },
89
108
  dryRun: options.dryRun ?? false,
90
109
  workers: options.workers,
91
110
  overwrite: options.overwrite ?? false,
@@ -111,6 +130,10 @@ else {
111
130
  }
112
131
  const result = await transformStdin({
113
132
  transforms: transformsResult.transforms,
133
+ transformOptions: {
134
+ ...getDefaultTransformOptions(),
135
+ unsafeStabilizeTopLevelBindings: shouldUnsafeStabilize(options.safeStabilizeTopLevelBindings),
136
+ },
114
137
  verbose: options.verbose ?? false,
115
138
  });
116
139
  if (!result.ok) {
@@ -1,4 +1,4 @@
1
- import { isArrayPattern, isAssignmentPattern, isClassDeclaration, isExportNamedDeclaration, isExportSpecifier, isFunctionDeclaration, isIdentifier, isObjectPattern, isObjectProperty, isRestElement, isVariableDeclaration, } from "@babel/types";
1
+ import { isArrayPattern, isAssignmentPattern, isClassDeclaration, isExportDefaultDeclaration, isExportNamedDeclaration, isExportSpecifier, isFunctionDeclaration, isIdentifier, isObjectPattern, isObjectProperty, isRestElement, isVariableDeclaration, } from "@babel/types";
2
2
  const addBindingNamesFromNode = (node, out) => {
3
3
  const babelNode = node;
4
4
  if (!babelNode)
@@ -38,6 +38,22 @@ const addBindingNamesFromNode = (node, out) => {
38
38
  export const collectExportedNames = (program) => {
39
39
  const exportedNames = new Set();
40
40
  for (const statement of program.body) {
41
+ if (isExportDefaultDeclaration(statement)) {
42
+ const declaration = statement.declaration;
43
+ if (isIdentifier(declaration)) {
44
+ // `export default foo` doesn't declare a new binding, but `foo` is still
45
+ // part of the module's public surface, so be conservative and skip it.
46
+ exportedNames.add(declaration.name);
47
+ continue;
48
+ }
49
+ if (isFunctionDeclaration(declaration) ||
50
+ isClassDeclaration(declaration)) {
51
+ const id = declaration.id;
52
+ if (id)
53
+ exportedNames.add(id.name);
54
+ }
55
+ continue;
56
+ }
41
57
  if (!isExportNamedDeclaration(statement))
42
58
  continue;
43
59
  const declaration = statement.declaration;
@@ -55,13 +71,17 @@ export const collectExportedNames = (program) => {
55
71
  exportedNames.add(id.name);
56
72
  }
57
73
  }
58
- for (const specifier of statement.specifiers) {
59
- if (!isExportSpecifier(specifier))
60
- continue;
61
- const local = specifier.local;
62
- if (!isIdentifier(local))
63
- continue;
64
- exportedNames.add(local.name);
74
+ // Re-exports like `export { foo } from "mod"` do not reference local bindings,
75
+ // so they should not affect rename-skipping decisions.
76
+ if (!statement.source) {
77
+ for (const specifier of statement.specifiers) {
78
+ if (!isExportSpecifier(specifier))
79
+ continue;
80
+ const local = specifier.local;
81
+ if (!isIdentifier(local))
82
+ continue;
83
+ exportedNames.add(local.name);
84
+ }
65
85
  }
66
86
  }
67
87
  return exportedNames;
@@ -0,0 +1,8 @@
1
+ import type { Scope } from "@babel/traverse";
2
+ type HasBindingOptions = {
3
+ noGlobals?: boolean;
4
+ noUids?: boolean;
5
+ upToScope?: Scope;
6
+ };
7
+ export declare const createHasBindingOptionsUpToScope: (upToScope: Scope) => HasBindingOptions;
8
+ export {};
@@ -0,0 +1,7 @@
1
+ export const createHasBindingOptionsUpToScope = (upToScope) => {
2
+ return {
3
+ noGlobals: true,
4
+ noUids: true,
5
+ upToScope,
6
+ };
7
+ };
@@ -0,0 +1,10 @@
1
+ type DefaultTransformOptions = {
2
+ /**
3
+ * When true, `stabilize-top-level-bindings` runs even if the file contains
4
+ * dynamic-name hazards (e.g. eval/with). This can improve diff stability and
5
+ * readability, but the transformed output may not be runnable.
6
+ */
7
+ unsafeStabilizeTopLevelBindings: boolean;
8
+ };
9
+ export declare const getDefaultTransformOptions: () => DefaultTransformOptions;
10
+ export {};
@@ -0,0 +1,5 @@
1
+ export const getDefaultTransformOptions = () => {
2
+ return {
3
+ unsafeStabilizeTopLevelBindings: true,
4
+ };
5
+ };
@@ -0,0 +1,2 @@
1
+ import type { Binding, Scope } from "@babel/traverse";
2
+ export declare const hasShadowingRisk: (binding: Binding, bindingScope: Scope, targetName: string) => boolean;
@@ -0,0 +1,24 @@
1
+ import * as t from "@babel/types";
2
+ import { createHasBindingOptionsUpToScope } from "./create-has-binding-options-up-to-scope.js";
3
+ export const hasShadowingRisk = (binding, bindingScope, targetName) => {
4
+ // Include reassignment sites (`x = ...`) in addition to references so we also catch
5
+ // potential capture/shadowing risks at write locations (see has-shadowing-risk.test.ts).
6
+ const identifierPaths = [
7
+ ...binding.referencePaths,
8
+ ...binding.constantViolations,
9
+ ];
10
+ return identifierPaths.some((referencePath) => {
11
+ if (referencePath.scope === bindingScope)
12
+ return false;
13
+ // Inside a `with` statement, identifier resolution is runtime-dependent.
14
+ // Be conservative and refuse renames that touch such references.
15
+ if (referencePath.findParent((path) => {
16
+ return t.isWithStatement(path.node);
17
+ })) {
18
+ return true;
19
+ }
20
+ // Only consider bindings between this reference’s scope and the binding scope.
21
+ // This avoids incorrectly blocking safe renames due to unrelated outer bindings.
22
+ return referencePath.scope.hasBinding(targetName, createHasBindingOptionsUpToScope(bindingScope));
23
+ });
24
+ };
@@ -4,6 +4,9 @@
4
4
  * The `$` prefix signals that a variable has been renamed stably by a transform
5
5
  * and should be skipped by other transforms.
6
6
  *
7
+ * Reserved prefixes:
8
+ * - `$h_`: used by `stabilize-top-level-bindings` for content-hash-based names.
9
+ *
7
10
  * - Stable names (`$timeoutId`): Readable AND deterministic across versions
8
11
  * - Readable names (`timeoutId`): Semantic but order-dependent
9
12
  */
@@ -4,9 +4,13 @@
4
4
  * The `$` prefix signals that a variable has been renamed stably by a transform
5
5
  * and should be skipped by other transforms.
6
6
  *
7
+ * Reserved prefixes:
8
+ * - `$h_`: used by `stabilize-top-level-bindings` for content-hash-based names.
9
+ *
7
10
  * - Stable names (`$timeoutId`): Readable AND deterministic across versions
8
11
  * - Readable names (`timeoutId`): Semantic but order-dependent
9
12
  */
13
+ import { hasShadowingRisk } from "./has-shadowing-risk.js";
10
14
  import { isTypeOnlyGlobalReference } from "./is-type-only-global-reference.js";
11
15
  const STABLE_PREFIX = "$";
12
16
  /**
@@ -23,19 +27,6 @@ export const isStableRenamed = (name) => {
23
27
  const makeStableName = (baseName) => {
24
28
  return `${STABLE_PREFIX}${baseName}`;
25
29
  };
26
- const hasShadowingRisk = (binding, bindingScope, targetName) => binding.referencePaths.some((referencePath) => {
27
- if (referencePath.scope === bindingScope)
28
- return false;
29
- // Only consider bindings between this reference’s scope and the binding scope.
30
- // This avoids incorrectly blocking safe renames due to unrelated outer bindings.
31
- const hasBindingOptions = {
32
- noGlobals: true,
33
- noUids: true,
34
- // Present in Babel scope implementation, but missing from our TypeScript types.
35
- upToScope: bindingScope,
36
- };
37
- return referencePath.scope.hasBinding(targetName, hasBindingOptions);
38
- });
39
30
  // Cache is keyed by the program `Scope` instance produced by Babel traversal,
40
31
  // so it won't leak across separate traversals / transform passes.
41
32
  const programValueGlobalsCache = new WeakMap();
@@ -105,33 +96,36 @@ const findAvailableName = (scope, binding, baseName, startIndex, canBeStable, cu
105
96
  ? false
106
97
  : scope.hasBinding(candidateStable) ||
107
98
  programGlobals.has(candidateStable);
108
- if (canBeStable) {
109
- if (stableTaken) {
110
- index++;
111
- continue;
112
- }
113
- // Stable names (`$foo`) don't collide with readable names (`foo`), so when
114
- // we know we'll emit a stable name, we only need to check the stable slot.
115
- const name = candidateStable;
116
- if (binding && hasShadowingRisk(binding, scope, name)) {
117
- index++;
118
- continue;
119
- }
120
- return { name, nextIndex: index + 1 };
121
- }
122
99
  const readableTaken = candidateReadable === currentName
123
100
  ? false
124
101
  : scope.hasBinding(candidateReadable) ||
125
102
  programGlobals.has(candidateReadable);
126
- if (!stableTaken && !readableTaken) {
127
- const name = candidateReadable;
128
- if (binding && hasShadowingRisk(binding, scope, name)) {
129
- index++;
130
- continue;
103
+ if (canBeStable) {
104
+ if (!stableTaken &&
105
+ (!binding || !hasShadowingRisk(binding, scope, candidateStable))) {
106
+ return { name: candidateStable, nextIndex: index + 1 };
107
+ }
108
+ // Prefer stable names, but fall back to the readable form (same suffix)
109
+ // before incrementing when the stable variant is blocked by shadowing.
110
+ if (!stableTaken &&
111
+ !readableTaken &&
112
+ (!binding || !hasShadowingRisk(binding, scope, candidateReadable))) {
113
+ return { name: candidateReadable, nextIndex: index + 1 };
131
114
  }
132
- return { name, nextIndex: index + 1 };
115
+ index++;
116
+ continue;
117
+ }
118
+ // Reserve the stable name slot even for readable renames so a pre-existing
119
+ // `$foo` blocks introducing `foo` (keeps stable/readable "slots" aligned).
120
+ if (stableTaken || readableTaken) {
121
+ index++;
122
+ continue;
123
+ }
124
+ if (binding && hasShadowingRisk(binding, scope, candidateReadable)) {
125
+ index++;
126
+ continue;
133
127
  }
134
- index++;
128
+ return { name: candidateReadable, nextIndex: index + 1 };
135
129
  }
136
130
  };
137
131
  /**
@@ -14,7 +14,7 @@ export type ProjectGraph = {
14
14
  export type TransformContext = {
15
15
  projectGraph: ProjectGraph;
16
16
  currentFile: SourceFileInfo | undefined;
17
- options: Record<string, unknown>;
17
+ options: Readonly<Record<string, unknown>>;
18
18
  };
19
19
  export type TransformStats = {
20
20
  nodesVisited: number;
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { buildProjectGraph } from "../../core/project-graph.js";
4
+ import { getDefaultTransformOptions } from "../../core/default-transform-options.js";
4
5
  import { transformPresets } from "../../transforms/transform-presets.js";
5
6
  import { transformRegistry } from "../../transforms/transform-registry.js";
6
7
  import { formatCode } from "./format-code.js";
@@ -20,11 +21,12 @@ const runTransform = async (inputPath, transformId) => {
20
21
  return transform;
21
22
  });
22
23
  const projectGraph = buildProjectGraph([{ path: inputPath, content }]);
24
+ const options = getDefaultTransformOptions();
23
25
  for (const transform of transforms) {
24
26
  await transform.transform({
25
27
  projectGraph,
26
28
  currentFile: undefined,
27
- options: {},
29
+ options,
28
30
  });
29
31
  }
30
32
  const fileInfo = projectGraph.files.get(inputPath);