@paramour-js/next 0.11.2 → 1.0.0-rc.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.
@@ -30,12 +30,13 @@ export declare function findNextConfig(projectRoot: string): FoundNextConfig | u
30
30
  export declare function manualSnippet(): string;
31
31
  /**
32
32
  * Reads `trailingSlash` from a next.config source for `doctor`, statically:
33
- * the default export (or CJS `module.exports`) is followed through wrapper
34
- * calls (first argument), TS `as`/`satisfies`, and top-level `const`
35
- * bindings to an object literal. A literal without the key is Next's default,
36
- * `false`. Anything else (a config function, a non-literal value, a spread
37
- * that could carry the key) is `undefined`: doctor stays quiet rather than
38
- * guess, since evaluating the config would run user code.
33
+ * the default export (or CJS `module.exports`) is followed through
34
+ * single-argument wrapper calls, TS `as`/`satisfies`, and top-level bindings
35
+ * to an object literal. A literal without the key is Next's default, `false`.
36
+ * Anything else (a config function, a non-literal value, a spread that could
37
+ * carry the key, a multi-argument call, a binding written after its
38
+ * declaration) is `undefined`: doctor stays quiet rather than guess, since
39
+ * evaluating the config would run user code.
39
40
  */
40
41
  export declare function readTrailingSlash(source: string): Promise<boolean | undefined>;
41
42
  /**
@@ -1,6 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  const IMPORT_LINE = `import { withTypedRoutes } from "@paramour-js/next";`;
4
+ /** {@link writtenBindings}' name for `module.exports` (no identifier has a dot). */
5
+ const MODULE_EXPORTS = "module.exports";
6
+ /** The `Object` statics that write their first argument's properties. */
7
+ const OBJECT_WRITERS = new Set([
8
+ "assign",
9
+ "defineProperties",
10
+ "defineProperty",
11
+ ]);
4
12
  /**
5
13
  * Wrap-state probe for `doctor`: same import/callee detection the codemod
6
14
  * uses for idempotence, without mutating anything.
@@ -43,12 +51,13 @@ export function manualSnippet() {
43
51
  }
44
52
  /**
45
53
  * Reads `trailingSlash` from a next.config source for `doctor`, statically:
46
- * the default export (or CJS `module.exports`) is followed through wrapper
47
- * calls (first argument), TS `as`/`satisfies`, and top-level `const`
48
- * bindings to an object literal. A literal without the key is Next's default,
49
- * `false`. Anything else (a config function, a non-literal value, a spread
50
- * that could carry the key) is `undefined`: doctor stays quiet rather than
51
- * guess, since evaluating the config would run user code.
54
+ * the default export (or CJS `module.exports`) is followed through
55
+ * single-argument wrapper calls, TS `as`/`satisfies`, and top-level bindings
56
+ * to an object literal. A literal without the key is Next's default, `false`.
57
+ * Anything else (a config function, a non-literal value, a spread that could
58
+ * carry the key, a multi-argument call, a binding written after its
59
+ * declaration) is `undefined`: doctor stays quiet rather than guess, since
60
+ * evaluating the config would run user code.
52
61
  */
53
62
  export async function readTrailingSlash(source) {
54
63
  const { parseModule } = await import("magicast");
@@ -59,6 +68,7 @@ export async function readTrailingSlash(source) {
59
68
  catch {
60
69
  return undefined;
61
70
  }
71
+ const written = writtenBindings(program);
62
72
  const body = asNodes(program.body);
63
73
  const bindings = new Map();
64
74
  let exported;
@@ -66,7 +76,9 @@ export async function readTrailingSlash(source) {
66
76
  if (statement.type === "VariableDeclaration") {
67
77
  for (const declarator of asNodes(statement.declarations)) {
68
78
  const id = asNode(declarator.id);
69
- if (id?.type === "Identifier" && typeof id.name === "string") {
79
+ if (id?.type === "Identifier" &&
80
+ typeof id.name === "string" &&
81
+ !written.has(id.name)) {
70
82
  bindings.set(id.name, declarator.init);
71
83
  }
72
84
  }
@@ -78,7 +90,7 @@ export async function readTrailingSlash(source) {
78
90
  const expression = asNode(statement.expression);
79
91
  if (expression?.type === "AssignmentExpression" &&
80
92
  isModuleExports(asNode(expression.left))) {
81
- exported = expression.right;
93
+ exported = written.has(MODULE_EXPORTS) ? undefined : expression.right;
82
94
  }
83
95
  }
84
96
  }
@@ -181,8 +193,13 @@ function trailingSlashOf(value, bindings, depth) {
181
193
  return undefined;
182
194
  switch (node.type) {
183
195
  case "CallExpression": {
184
- const [first] = asNodes(node.arguments);
185
- return trailingSlashOf(first, bindings, depth + 1);
196
+ // Only a single-argument wrapper (`withTypedRoutes(config)`, curried
197
+ // `withX(options)(config)`) is followed. With more arguments nothing
198
+ // says which one is the config, and plugin options often come first.
199
+ const [only, ...rest] = asNodes(node.arguments);
200
+ return rest.length === 0
201
+ ? trailingSlashOf(only, bindings, depth + 1)
202
+ : undefined;
186
203
  }
187
204
  case "Identifier": {
188
205
  return typeof node.name === "string" && bindings.has(node.name)
@@ -221,3 +238,86 @@ function trailingSlashOf(value, bindings, depth) {
221
238
  function withTypedRoutesLocal(items) {
222
239
  return items.find((item) => item.from === "@paramour-js/next" && item.imported === "withTypedRoutes")?.local;
223
240
  }
241
+ /**
242
+ * The binding a write lands on: an identifier, or `module.exports`. TS
243
+ * wrappers are seen through, so `(config as NextConfig).x = …` counts.
244
+ */
245
+ function writeTarget(value) {
246
+ let node = asNode(value);
247
+ while (node?.type === "ParenthesizedExpression" ||
248
+ node?.type === "TSAsExpression" ||
249
+ node?.type === "TSNonNullExpression" ||
250
+ node?.type === "TSSatisfiesExpression") {
251
+ node = asNode(node.expression);
252
+ }
253
+ if (node?.type === "Identifier" && typeof node.name === "string") {
254
+ return node.name;
255
+ }
256
+ return isModuleExports(node) ? MODULE_EXPORTS : undefined;
257
+ }
258
+ /**
259
+ * Every binding the program writes after its declaration, which makes its
260
+ * declared value an unreliable read: a direct property write or delete
261
+ * (`c.x = …`, `c[k] = …`, `delete c.x`), an `Object.assign` /
262
+ * `Object.defineProperty` / `Object.defineProperties` target, or a
263
+ * reassignment (`c = …`, or `module.exports` assigned more than once). A
264
+ * write to a nested object (`c.experimental.x = …`) is not counted, since it
265
+ * cannot change `c.trailingSlash`. The whole program is searched, not just
266
+ * the top level, because a static-export toggle usually sits under an `if`.
267
+ * Scope is ignored, so a shadowing parameter of the same name also counts;
268
+ * that errs toward unknown, which only costs a finding.
269
+ */
270
+ function writtenBindings(program) {
271
+ const written = new Set();
272
+ let moduleExportsAssignments = 0;
273
+ const add = (target) => {
274
+ if (target !== undefined)
275
+ written.add(target);
276
+ };
277
+ const visit = (node) => {
278
+ if (node.type === "AssignmentExpression") {
279
+ const left = asNode(node.left);
280
+ // `module.exports` is itself a member expression: test it first.
281
+ if (isModuleExports(left)) {
282
+ moduleExportsAssignments += 1;
283
+ }
284
+ else if (left?.type === "MemberExpression") {
285
+ add(writeTarget(left.object));
286
+ }
287
+ else {
288
+ add(writeTarget(left));
289
+ }
290
+ }
291
+ else if (node.type === "UnaryExpression" && node.operator === "delete") {
292
+ const argument = asNode(node.argument);
293
+ if (argument?.type === "MemberExpression") {
294
+ add(writeTarget(argument.object));
295
+ }
296
+ }
297
+ else if (node.type === "CallExpression") {
298
+ const callee = asNode(node.callee);
299
+ const object = asNode(callee?.object);
300
+ const property = asNode(callee?.property);
301
+ if (callee?.type === "MemberExpression" &&
302
+ callee.computed !== true &&
303
+ object?.type === "Identifier" &&
304
+ object.name === "Object" &&
305
+ property?.type === "Identifier" &&
306
+ typeof property.name === "string" &&
307
+ OBJECT_WRITERS.has(property.name)) {
308
+ add(writeTarget(asNodes(node.arguments)[0]));
309
+ }
310
+ }
311
+ for (const child of Object.values(node)) {
312
+ for (const item of Array.isArray(child) ? asNodes(child) : [child]) {
313
+ const next = asNode(item);
314
+ if (next !== undefined)
315
+ visit(next);
316
+ }
317
+ }
318
+ };
319
+ visit(program);
320
+ if (moduleExportsAssignments > 1)
321
+ written.add(MODULE_EXPORTS);
322
+ return written;
323
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paramour-js/next",
3
- "version": "0.11.2",
3
+ "version": "1.0.0-rc.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -51,7 +51,7 @@
51
51
  "react-dom": "^19.2.0",
52
52
  "typescript": "^6.0.3",
53
53
  "zod": "^4.4.3",
54
- "paramour": "0.11.2"
54
+ "paramour": "1.0.0-rc.0"
55
55
  },
56
56
  "description": "Next.js integration for paramour: withTypedRoutes, App and Pages Router hooks, PageProps glue, and the codegen CLI.",
57
57
  "author": "Jason Paff <jasonpaff@gmail.com>",