@payload-auth-link/migrate 0.3.0 → 0.4.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 (69) hide show
  1. package/README.md +6 -3
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +149 -101
  4. package/dist/cli.js.map +1 -1
  5. package/dist/codemods.d.ts +19 -8
  6. package/dist/codemods.d.ts.map +1 -1
  7. package/dist/codemods.js +440 -21
  8. package/dist/codemods.js.map +1 -1
  9. package/dist/doctor/database.d.ts +5 -1
  10. package/dist/doctor/database.d.ts.map +1 -1
  11. package/dist/doctor/database.js +98 -34
  12. package/dist/doctor/database.js.map +1 -1
  13. package/dist/doctor/plugins.d.ts +15 -0
  14. package/dist/doctor/plugins.d.ts.map +1 -1
  15. package/dist/doctor/plugins.js +43 -0
  16. package/dist/doctor/plugins.js.map +1 -1
  17. package/dist/doctor/run-doctor.d.ts +11 -0
  18. package/dist/doctor/run-doctor.d.ts.map +1 -1
  19. package/dist/doctor/run-doctor.js +66 -20
  20. package/dist/doctor/run-doctor.js.map +1 -1
  21. package/dist/doctor/source-scan.d.ts +5 -0
  22. package/dist/doctor/source-scan.d.ts.map +1 -1
  23. package/dist/doctor/source-scan.js +9 -0
  24. package/dist/doctor/source-scan.js.map +1 -1
  25. package/dist/doctor/types.d.ts +12 -0
  26. package/dist/doctor/types.d.ts.map +1 -1
  27. package/dist/exporters.d.ts +7 -0
  28. package/dist/exporters.d.ts.map +1 -1
  29. package/dist/exporters.js +36 -1
  30. package/dist/exporters.js.map +1 -1
  31. package/dist/importer/apply.d.ts +5 -0
  32. package/dist/importer/apply.d.ts.map +1 -1
  33. package/dist/importer/apply.js +61 -33
  34. package/dist/importer/apply.js.map +1 -1
  35. package/dist/importer/lifecycle.d.ts +15 -6
  36. package/dist/importer/lifecycle.d.ts.map +1 -1
  37. package/dist/importer/lifecycle.js +131 -39
  38. package/dist/importer/lifecycle.js.map +1 -1
  39. package/dist/importer/run-import.d.ts +8 -2
  40. package/dist/importer/run-import.d.ts.map +1 -1
  41. package/dist/importer/run-import.js +73 -21
  42. package/dist/importer/run-import.js.map +1 -1
  43. package/dist/importer/source.d.ts +47 -4
  44. package/dist/importer/source.d.ts.map +1 -1
  45. package/dist/importer/source.js +159 -27
  46. package/dist/importer/source.js.map +1 -1
  47. package/dist/importer/types.d.ts +33 -0
  48. package/dist/importer/types.d.ts.map +1 -1
  49. package/dist/importer/types.js +48 -0
  50. package/dist/importer/types.js.map +1 -1
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/package.json +8 -11
  56. package/src/cli.ts +153 -100
  57. package/src/codemods.ts +540 -36
  58. package/src/doctor/database.ts +129 -46
  59. package/src/doctor/plugins.ts +51 -0
  60. package/src/doctor/run-doctor.ts +90 -25
  61. package/src/doctor/source-scan.ts +10 -0
  62. package/src/doctor/types.ts +9 -0
  63. package/src/exporters.ts +46 -1
  64. package/src/importer/apply.ts +78 -32
  65. package/src/importer/lifecycle.ts +163 -43
  66. package/src/importer/run-import.ts +107 -22
  67. package/src/importer/source.ts +214 -30
  68. package/src/importer/types.ts +86 -0
  69. package/src/index.ts +25 -2
package/src/codemods.ts CHANGED
@@ -1,10 +1,17 @@
1
+ import * as ts from 'typescript'
2
+
1
3
  /**
2
- * Codemods (issue #53): rule-based rewrites for the call sites a
3
- * payload-auth app must migrate. Deliberately textual and scoped —
4
- * each rule matches an exact, idiomatic payload-auth pattern and
5
- * leaves anything it cannot fully rewrite untouched (reported in the
6
- * result), because a half-rewritten call site is worse than an
7
- * explicit one.
4
+ * Codemods (issues #53, #70): rule-based rewrites for the call sites a
5
+ * payload-auth app must migrate, matched on the TypeScript AST and
6
+ * applied as text splices over the original ranges (no printer —
7
+ * formatting survives).
8
+ *
9
+ * Each rule rewrites only what it can FULLY rewrite and reports the
10
+ * rest as manual sites ({@link findManualSites}); a half-rewritten
11
+ * call site is worse than an explicit one. In particular, a file with
12
+ * ANY unrewritable `getPayloadAuth` reference keeps its whole
13
+ * `get-payload-auth` state untouched, so the output never references a
14
+ * symbol no import provides.
8
15
  */
9
16
 
10
17
  export type CodemodRule = {
@@ -21,59 +28,556 @@ export type CodemodFileResult = {
21
28
  readonly applied: readonly string[]
22
29
  /** Matches the rules could not fully rewrite (reported, never touched). */
23
30
  readonly manual: readonly string[]
31
+ /** The rewritten content — equal to the input when nothing changed. */
32
+ readonly after: string
33
+ }
34
+
35
+ // --- shared parsing helpers -----------------------------------------------
36
+
37
+ function parse(content: string): ts.SourceFile {
38
+ return ts.createSourceFile('codemod-input.ts', content, ts.ScriptTarget.Latest, true)
39
+ }
40
+
41
+ function lineOf(source: ts.SourceFile, node: ts.Node): number {
42
+ return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
43
+ }
44
+
45
+ type Splice = { readonly start: number; readonly end: number; readonly text: string }
46
+
47
+ function applySplices(content: string, splices: readonly Splice[]): string {
48
+ let out = content
49
+ for (const splice of [...splices].sort((a, b) => b.start - a.start)) {
50
+ out = out.slice(0, splice.start) + splice.text + out.slice(splice.end)
51
+ }
52
+ return out
53
+ }
54
+
55
+ function escapeRegExp(text: string): string {
56
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
+ }
58
+
59
+ /**
60
+ * Whether the node sits where `await` is legal: inside an async
61
+ * function-like, or at module top level (top-level await). The cheap
62
+ * heuristic walks to the nearest enclosing function-like and looks for
63
+ * the `async` keyword (#70).
64
+ */
65
+ function awaitContext(node: ts.Node): 'awaitable' | 'sync-function' {
66
+ let current: ts.Node | undefined = node.parent
67
+ while (current !== undefined) {
68
+ if (
69
+ ts.isFunctionDeclaration(current) ||
70
+ ts.isMethodDeclaration(current) ||
71
+ ts.isArrowFunction(current) ||
72
+ ts.isFunctionExpression(current) ||
73
+ ts.isGetAccessorDeclaration(current) ||
74
+ ts.isSetAccessorDeclaration(current)
75
+ ) {
76
+ const async =
77
+ current.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true
78
+ return async ? 'awaitable' : 'sync-function'
79
+ }
80
+ current = current.parent
81
+ }
82
+ return 'awaitable'
83
+ }
84
+
85
+ /** The module specifier of a static import declaration. */
86
+ function moduleSpecifierOf(statement: ts.ImportDeclaration): string {
87
+ const specifier = statement.moduleSpecifier
88
+ return ts.isStringLiteral(specifier) ? specifier.text : ''
89
+ }
90
+
91
+ function soleNamedElement(statement: ts.ImportDeclaration): boolean {
92
+ const clause = statement.importClause
93
+ return (
94
+ clause !== undefined &&
95
+ clause.name === undefined &&
96
+ clause.namedBindings !== undefined &&
97
+ ts.isNamedImports(clause.namedBindings) &&
98
+ clause.namedBindings.elements.length === 1
99
+ )
100
+ }
101
+
102
+ /**
103
+ * Splice that removes one named import element plus an adjacent comma,
104
+ * keeping the rest of the clause valid.
105
+ */
106
+ function removeElementSplice(
107
+ statement: ts.ImportDeclaration,
108
+ element: ts.ImportSpecifier,
109
+ source: ts.SourceFile
110
+ ): Splice {
111
+ const elements = statement.importClause?.namedBindings
112
+ if (elements === undefined || !ts.isNamedImports(elements)) {
113
+ return { start: element.getStart(source), end: element.getEnd(), text: '' }
114
+ }
115
+ const index = elements.elements.indexOf(element)
116
+ const next = index >= 0 ? elements.elements[index + 1] : undefined
117
+ if (next !== undefined) {
118
+ return { start: element.getStart(source), end: next.getStart(source), text: '' }
119
+ }
120
+ const previous = index > 0 ? elements.elements[index - 1] : undefined
121
+ if (previous !== undefined) {
122
+ return { start: previous.getEnd(), end: element.getEnd(), text: '' }
123
+ }
124
+ return { start: element.getStart(source), end: element.getEnd(), text: '' }
125
+ }
126
+
127
+ // --- getPayloadAuth (#70.1) -------------------------------------------------
128
+
129
+ const payloadAuthModule = /^payload-auth(?:\/[A-Za-z0-9_./-]+)?$/
130
+
131
+ type PayloadAuthImport = {
132
+ readonly statement: ts.ImportDeclaration
133
+ readonly element: ts.ImportSpecifier
134
+ /** The name the file binds (`getPayloadAuth`, or its alias). */
135
+ readonly local: string
136
+ readonly typeOnly: boolean
137
+ }
138
+
139
+ type PayloadAuthRef = {
140
+ readonly node: ts.Identifier
141
+ readonly kind: 'call' | 'type' | 'value'
142
+ /** The enclosing call expression when `kind === 'call'`. */
143
+ readonly call?: ts.CallExpression
144
+ /** The full-rewrite replacement when `kind === 'call'` and rewritable. */
145
+ readonly replacement?: string
146
+ /** The manual-site message when this reference cannot be rewritten. */
147
+ readonly reason?: string
148
+ }
149
+
150
+ function hasPayloadConfigImport(source: ts.SourceFile): boolean {
151
+ let found = false
152
+ const visit = (node: ts.Node): void => {
153
+ if (ts.isImportDeclaration(node)) {
154
+ if (moduleSpecifierOf(node) === '@payload-config') {
155
+ found = true
156
+ }
157
+ } else if (
158
+ ts.isCallExpression(node) &&
159
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
160
+ node.arguments.length === 1
161
+ ) {
162
+ const arg = node.arguments[0]
163
+ if (arg !== undefined && ts.isStringLiteral(arg) && arg.text === '@payload-config') {
164
+ found = true
165
+ }
166
+ }
167
+ if (!found) {
168
+ ts.forEachChild(node, visit)
169
+ }
170
+ }
171
+ visit(source)
172
+ return found
173
+ }
174
+
175
+ /**
176
+ * Every `getPayloadAuth` (or aliased) import and reference in the file,
177
+ * classified: a call that can be fully rewritten carries its
178
+ * replacement; anything else carries its manual-site reason.
179
+ */
180
+ function analyzePayloadAuth(content: string): {
181
+ readonly source: ts.SourceFile
182
+ readonly imports: readonly PayloadAuthImport[]
183
+ readonly refs: readonly PayloadAuthRef[]
184
+ } {
185
+ const source = parse(content)
186
+ const imports: PayloadAuthImport[] = []
187
+ for (const statement of source.statements) {
188
+ if (
189
+ !ts.isImportDeclaration(statement) ||
190
+ !payloadAuthModule.test(moduleSpecifierOf(statement))
191
+ ) {
192
+ continue
193
+ }
194
+ const clause = statement.importClause
195
+ if (
196
+ clause === undefined ||
197
+ clause.namedBindings === undefined ||
198
+ !ts.isNamedImports(clause.namedBindings)
199
+ ) {
200
+ continue
201
+ }
202
+ for (const element of clause.namedBindings.elements) {
203
+ const exported = element.propertyName ?? element.name
204
+ if (!ts.isIdentifier(exported) || exported.text !== 'getPayloadAuth') {
205
+ continue
206
+ }
207
+ imports.push({
208
+ statement,
209
+ element,
210
+ local: element.name.text,
211
+ typeOnly: clause.isTypeOnly || element.isTypeOnly,
212
+ })
213
+ }
214
+ }
215
+ const locals = new Set(imports.map((imp) => imp.local))
216
+ const bindings = new Set<ts.Identifier>()
217
+ for (const imp of imports) {
218
+ bindings.add(imp.element.name)
219
+ const exported = imp.element.propertyName
220
+ if (exported !== undefined && ts.isIdentifier(exported)) {
221
+ bindings.add(exported)
222
+ }
223
+ }
224
+ const payloadConfig = hasPayloadConfigImport(source)
225
+ const refs: PayloadAuthRef[] = []
226
+ const visit = (node: ts.Node): void => {
227
+ if (ts.isIdentifier(node) && locals.has(node.text) && !bindings.has(node)) {
228
+ const parent = node.parent
229
+ if (ts.isCallExpression(parent) && parent.expression === node) {
230
+ const context = awaitContext(parent)
231
+ const argCount = parent.arguments.length
232
+ const firstArg = parent.arguments[0]
233
+ if (context === 'sync-function') {
234
+ refs.push({
235
+ node,
236
+ kind: 'call',
237
+ call: parent,
238
+ reason: `getPayloadAuth (line ${lineOf(source, parent)}) — the enclosing function is not async: make it async and use await getPayload({ config })`,
239
+ })
240
+ } else if (argCount === 0) {
241
+ if (payloadConfig) {
242
+ refs.push({
243
+ node,
244
+ kind: 'call',
245
+ call: parent,
246
+ replacement: '(await getPayload({ config }))',
247
+ })
248
+ } else {
249
+ refs.push({
250
+ node,
251
+ kind: 'call',
252
+ call: parent,
253
+ // The message spells the specifier without `from '…'`:
254
+ // shipped string literals must not read as imports
255
+ // (release:check scans for undeclared ones).
256
+ reason: `getPayloadAuth() (line ${lineOf(source, parent)}) — the no-arg form needs this file to import the payload config alias (@payload-config); add that import and use await getPayload({ config })`,
257
+ })
258
+ }
259
+ } else if (argCount === 1 && firstArg !== undefined) {
260
+ refs.push({
261
+ node,
262
+ kind: 'call',
263
+ call: parent,
264
+ replacement: `(await getPayload({ config: ${firstArg.getText(source)} }))`,
265
+ })
266
+ } else {
267
+ refs.push({
268
+ node,
269
+ kind: 'call',
270
+ call: parent,
271
+ reason: `getPayloadAuth (line ${lineOf(source, parent)}) — unexpected call shape (${argCount} arguments); call await getPayload({ config }) by hand`,
272
+ })
273
+ }
274
+ } else if (ts.isTypeQueryNode(parent) && parent.exprName === node) {
275
+ refs.push({
276
+ node,
277
+ kind: 'type',
278
+ reason: `getPayloadAuth type reference (line ${lineOf(source, node)}) — use payload's own types instead: import type { Payload } from 'payload'`,
279
+ })
280
+ } else {
281
+ refs.push({
282
+ node,
283
+ kind: 'value',
284
+ reason: `getPayloadAuth value reference (line ${lineOf(source, node)}) — rewrite by hand to await getPayload({ config })`,
285
+ })
286
+ }
287
+ }
288
+ ts.forEachChild(node, visit)
289
+ }
290
+ visit(source)
291
+ return { source, imports, refs }
24
292
  }
25
293
 
294
+ /**
295
+ * The get-payload-auth rewrite (issue #70): ALL references rewritable,
296
+ * or nothing. A single manual site keeps the whole file's
297
+ * getPayloadAuth state untouched — the output must never reference a
298
+ * symbol no import provides.
299
+ */
300
+ function rewritePayloadAuth(content: string): string {
301
+ const { source, imports, refs } = analyzePayloadAuth(content)
302
+ if (imports.length === 0 && refs.length === 0) {
303
+ return content
304
+ }
305
+ if (refs.some((ref) => ref.reason !== undefined)) {
306
+ return content
307
+ }
308
+ if (imports.some((imp) => imp.typeOnly)) {
309
+ return content
310
+ }
311
+ const splices: Splice[] = []
312
+ for (const ref of refs) {
313
+ if (ref.kind === 'call' && ref.call !== undefined && ref.replacement !== undefined) {
314
+ splices.push({
315
+ start: ref.call.getStart(source),
316
+ end: ref.call.getEnd(),
317
+ text: ref.replacement,
318
+ })
319
+ }
320
+ }
321
+ for (const imp of imports) {
322
+ if (soleNamedElement(imp.statement)) {
323
+ splices.push({
324
+ start: imp.statement.getStart(source),
325
+ end: imp.statement.getEnd(),
326
+ text: "import { getPayload } from 'payload'",
327
+ })
328
+ } else {
329
+ splices.push(removeElementSplice(imp.statement, imp.element, source))
330
+ splices.push({
331
+ start: imp.statement.getStart(source),
332
+ end: imp.statement.getStart(source),
333
+ text: "import { getPayload } from 'payload'\n",
334
+ })
335
+ }
336
+ }
337
+ return applySplices(content, splices)
338
+ }
339
+
340
+ // --- <expr>.betterAuth.api.getSession (#70.2) -------------------------------
341
+
342
+ /** The receiver text when the callee is exactly `<expr>.betterAuth.api.getSession`. */
343
+ function betterAuthSessionReceiver(call: ts.CallExpression, source: ts.SourceFile): string | null {
344
+ const callee = call.expression
345
+ if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== 'getSession') {
346
+ return null
347
+ }
348
+ const api = callee.expression
349
+ if (!ts.isPropertyAccessExpression(api) || api.name.text !== 'api') {
350
+ return null
351
+ }
352
+ const betterAuth = api.expression
353
+ if (!ts.isPropertyAccessExpression(betterAuth) || betterAuth.name.text !== 'betterAuth') {
354
+ return null
355
+ }
356
+ // Aliased bindings (`const auth = payload.betterAuth`) are never
357
+ // rewritten — the rule matches only direct `.betterAuth.api` chains;
358
+ // findManualSites reports the surviving accessor.
359
+ return betterAuth.expression.getText(source)
360
+ }
361
+
362
+ /** The getSession argument text per #70: unwrap a lone `headers` object. */
363
+ function sessionArgsText(call: ts.CallExpression, source: ts.SourceFile): string {
364
+ const arg = call.arguments[0]
365
+ if (
366
+ call.arguments.length >= 1 &&
367
+ arg !== undefined &&
368
+ ts.isObjectLiteralExpression(arg) &&
369
+ arg.properties.length === 1
370
+ ) {
371
+ const prop = arg.properties[0]
372
+ if (prop !== undefined) {
373
+ if (ts.isPropertyAssignment(prop) && prop.name.getText(source) === 'headers') {
374
+ return prop.initializer.getText(source)
375
+ }
376
+ if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === 'headers') {
377
+ return prop.name.getText(source)
378
+ }
379
+ }
380
+ }
381
+ return call.arguments.map((entry) => entry.getText(source)).join(', ')
382
+ }
383
+
384
+ function rewriteBetterAuthGetSession(content: string): string {
385
+ const source = parse(content)
386
+ const splices: Splice[] = []
387
+ const visit = (node: ts.Node): void => {
388
+ if (ts.isCallExpression(node)) {
389
+ const receiver = betterAuthSessionReceiver(node, source)
390
+ if (receiver !== null && awaitContext(node) !== 'sync-function') {
391
+ splices.push({
392
+ start: node.getStart(source),
393
+ end: node.getEnd(),
394
+ text: `(await authLinkServer(${receiver}).getSession(${sessionArgsText(node, source)}))`,
395
+ })
396
+ }
397
+ }
398
+ ts.forEachChild(node, visit)
399
+ }
400
+ visit(source)
401
+ return applySplices(content, splices)
402
+ }
403
+
404
+ // --- toNextJsHandler (#70.3) ------------------------------------------------
405
+
406
+ const nextJsHandlerOutput =
407
+ 'export const GET = createRouteHandlers.GET\nexport const POST = createRouteHandlers.POST'
408
+
409
+ type NextJsHandlerImport = {
410
+ readonly statement: ts.ImportDeclaration
411
+ readonly element: ts.ImportSpecifier
412
+ readonly local: string
413
+ }
414
+
415
+ function collectNextJsHandlerImports(source: ts.SourceFile): readonly NextJsHandlerImport[] {
416
+ const imports: NextJsHandlerImport[] = []
417
+ for (const statement of source.statements) {
418
+ if (
419
+ !ts.isImportDeclaration(statement) ||
420
+ moduleSpecifierOf(statement) !== 'better-auth/next-js'
421
+ ) {
422
+ continue
423
+ }
424
+ const clause = statement.importClause
425
+ if (
426
+ clause === undefined ||
427
+ clause.namedBindings === undefined ||
428
+ !ts.isNamedImports(clause.namedBindings)
429
+ ) {
430
+ continue
431
+ }
432
+ for (const element of clause.namedBindings.elements) {
433
+ const imported = (element.propertyName ?? element.name).text
434
+ if (imported !== 'toNextJsHandler') {
435
+ continue
436
+ }
437
+ imports.push({ statement, element, local: element.name.text })
438
+ }
439
+ }
440
+ return imports
441
+ }
442
+
443
+ function rewriteNextJsHandler(content: string): string {
444
+ const source = parse(content)
445
+ const imports = collectNextJsHandlerImports(source)
446
+ if (imports.length === 0) {
447
+ return content
448
+ }
449
+ // The export-assignment shape stays textual (same output as #53),
450
+ // now matching the aliased local name too.
451
+ let current = content
452
+ for (const imp of imports) {
453
+ const pattern = new RegExp(
454
+ `export\\s*\\{\\s*(\\w+)\\s+as\\s+GET\\s*,\\s*(\\w+)\\s+as\\s+POST\\s*\\}\\s*=\\s*${escapeRegExp(imp.local)}\\s*\\(\\s*payload\\.betterAuth\\s*\\)`,
455
+ 'g'
456
+ )
457
+ current = current.replace(pattern, nextJsHandlerOutput)
458
+ }
459
+ // The import rewrite runs on the rewritten content.
460
+ const after = parse(current)
461
+ const splices: Splice[] = []
462
+ for (const imp of collectNextJsHandlerImports(after)) {
463
+ if (soleNamedElement(imp.statement)) {
464
+ splices.push({
465
+ start: imp.statement.getStart(after),
466
+ end: imp.statement.getEnd(),
467
+ text: "import { createRouteHandlers } from '@payload-auth-link/next'",
468
+ })
469
+ } else {
470
+ splices.push(removeElementSplice(imp.statement, imp.element, after))
471
+ splices.push({
472
+ start: imp.statement.getStart(after),
473
+ end: imp.statement.getStart(after),
474
+ text: "import { createRouteHandlers } from '@payload-auth-link/next'\n",
475
+ })
476
+ }
477
+ }
478
+ return applySplices(current, splices)
479
+ }
480
+
481
+ // --- rules -------------------------------------------------------------------
482
+
26
483
  export const codemodRules: readonly CodemodRule[] = [
27
484
  {
28
485
  id: 'get-payload-auth',
29
- description: 'getPayloadAuth() -> getPayload({ config })',
30
- apply: (content) =>
31
- content
32
- .replace(
33
- /import\s*\{\s*getPayloadAuth\s*(?:,[^}]*)?\}\s*from\s*['"]payload-auth['"];?/g,
34
- "import { getPayload } from 'payload'"
35
- )
36
- .replace(/\bgetPayloadAuth\(\s*\)/g, 'await getPayload({ config })'),
486
+ description: 'getPayloadAuth() -> getPayload({ config }), import -> payload',
487
+ apply: rewritePayloadAuth,
37
488
  },
38
489
  {
39
490
  id: 'better-auth-get-session',
40
491
  description:
41
492
  'payload.betterAuth.api.getSession({ headers }) -> authLinkServer(payload).getSession(headers)',
42
- apply: (content) =>
43
- content.replace(
44
- /payload\.betterAuth\.api\.getSession\(\s*\{\s*headers\s*\}\s*\)/g,
45
- '(await authLinkServer(payload)).getSession(headers)'
46
- ),
493
+ apply: rewriteBetterAuthGetSession,
47
494
  },
48
495
  {
49
496
  id: 'to-next-js-handler',
50
497
  description:
51
498
  'toNextJsHandler(payload.betterAuth) -> createRouteHandlers from @payload-auth-link/next',
52
- apply: (content) =>
53
- content
54
- .replace(
55
- /import\s*\{\s*toNextJsHandler\s*\}\s*from\s*['"]better-auth\/next-js['"];?/g,
56
- "import { createRouteHandlers } from '@payload-auth-link/next'"
57
- )
58
- .replace(
59
- /export\s*\{\s*(\w+)\s+as\s+GET\s*,\s*(\w+)\s+as\s+POST\s*\}\s*=\s*toNextJsHandler\(\s*payload\.betterAuth\s*\)/g,
60
- 'export const GET = createRouteHandlers.GET\nexport const POST = createRouteHandlers.POST'
61
- ),
499
+ apply: rewriteNextJsHandler,
62
500
  },
63
501
  ]
64
502
 
65
503
  /**
66
- * Report call sites the rules cannot fully rewrite — each one needs a
67
- * human decision (which session API call, which route handler shape).
504
+ * Report call sites the rules cannot fully rewrite (issue #70.4):
505
+ * EVERY remaining `getPayloadAuth` reference (calls, type references,
506
+ * the import itself), every surviving `payload.betterAuth` accessor
507
+ * (including `.betterAuth.api.<method>` and `$context`), and every
508
+ * `internalAdapter`. Nothing is silently ignored — the doctor counts
509
+ * these references, so the codemod must account for them.
68
510
  */
69
511
  export function findManualSites(content: string): readonly string[] {
512
+ const source = parse(content)
70
513
  const manual: string[] = []
71
- if (/\bpayload\.betterAuth\.api\.(?!getSession\b)\w+/.test(content)) {
72
- manual.push('payload.betterAuth.api.<method> — see the authLinkServer session API (#54)')
514
+ const reportedLines = new Set<number>()
515
+ const { imports, refs } = analyzePayloadAuth(content)
516
+ for (const ref of refs) {
517
+ if (ref.reason !== undefined) {
518
+ manual.push(ref.reason)
519
+ reportedLines.add(lineOf(source, ref.node))
520
+ }
521
+ }
522
+ for (const imp of imports) {
523
+ manual.push(
524
+ `getPayloadAuth import (line ${lineOf(source, imp.statement)}) — replace with import { getPayload } from 'payload'`
525
+ )
526
+ reportedLines.add(lineOf(source, imp.statement))
73
527
  }
74
- if (/\$context\.(?:internalAdapter|adapter)\b/.test(content)) {
75
- manual.push('$context internals — see the supported server session API (#54)')
528
+ const seenInternalsLines = new Set<number>()
529
+ const visit = (node: ts.Node): void => {
530
+ // A getPayloadAuth reference the import analysis did not cover (a
531
+ // local binding, no payload-auth import) is still reported.
532
+ if (
533
+ ts.isIdentifier(node) &&
534
+ node.text === 'getPayloadAuth' &&
535
+ !reportedLines.has(lineOf(source, node))
536
+ ) {
537
+ manual.push(
538
+ `getPayloadAuth reference (line ${lineOf(source, node)}) — rewrite by hand to await getPayload({ config })`
539
+ )
540
+ reportedLines.add(lineOf(source, node))
541
+ }
542
+ if (ts.isPropertyAccessExpression(node)) {
543
+ const line = lineOf(source, node)
544
+ if (node.name.text === 'betterAuth') {
545
+ const parent = node.parent
546
+ if (ts.isPropertyAccessExpression(parent) && parent.name.text === 'api') {
547
+ const method = parent.parent
548
+ if (
549
+ ts.isPropertyAccessExpression(method) &&
550
+ method.name.text === 'getSession' &&
551
+ ts.isCallExpression(method.parent) &&
552
+ awaitContext(method.parent) === 'sync-function'
553
+ ) {
554
+ manual.push(
555
+ `getSession (line ${line}) — the enclosing function is not async: make it async and use await authLinkServer(...).getSession(...)`
556
+ )
557
+ } else {
558
+ manual.push(
559
+ `payload.betterAuth.api.<method> (line ${line}) — see the authLinkServer session API (#54)`
560
+ )
561
+ }
562
+ } else {
563
+ manual.push(
564
+ `payload.betterAuth accessor (line ${line}) — see the authLinkServer session API (#54)`
565
+ )
566
+ }
567
+ } else if (node.name.text === '$context') {
568
+ seenInternalsLines.add(line)
569
+ manual.push(
570
+ `$context internals (line ${line}) — see the supported server session API (#54)`
571
+ )
572
+ } else if (node.name.text === 'internalAdapter' && !seenInternalsLines.has(line)) {
573
+ manual.push(
574
+ `internalAdapter (line ${line}) — Better Auth internals; see the supported server session API (#54)`
575
+ )
576
+ }
577
+ }
578
+ ts.forEachChild(node, visit)
76
579
  }
580
+ visit(source)
77
581
  return manual
78
582
  }
79
583
 
@@ -89,5 +593,5 @@ export function applyCodemods(file: string, content: string): CodemodFileResult
89
593
  }
90
594
  }
91
595
  const manual = findManualSites(current)
92
- return { file, changed: current !== content, applied, manual }
596
+ return { file, changed: current !== content, applied, manual, after: current }
93
597
  }