@remix-run/cli 0.2.0 → 0.3.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 (84) hide show
  1. package/dist/lib/bootstrap-project.d.ts.map +1 -1
  2. package/dist/lib/bootstrap-project.js +32 -9
  3. package/dist/lib/cli.d.ts +29 -0
  4. package/dist/lib/cli.d.ts.map +1 -1
  5. package/dist/lib/cli.js +26 -4
  6. package/dist/lib/commands/completion.d.ts.map +1 -1
  7. package/dist/lib/commands/completion.js +5 -1
  8. package/dist/lib/commands/doctor.js +18 -18
  9. package/dist/lib/commands/routes.js +3 -3
  10. package/dist/lib/completion.js +1 -5
  11. package/dist/lib/controller-files.d.ts +0 -1
  12. package/dist/lib/controller-files.d.ts.map +1 -1
  13. package/dist/lib/controller-files.js +3 -5
  14. package/dist/lib/controller-ownership.d.ts +9 -9
  15. package/dist/lib/controller-ownership.d.ts.map +1 -1
  16. package/dist/lib/controller-ownership.js +56 -91
  17. package/dist/lib/doctor/controller-findings.d.ts +1 -1
  18. package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
  19. package/dist/lib/doctor/controller-findings.js +15 -87
  20. package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -1
  21. package/dist/lib/doctor/controller-fix-plans.js +13 -24
  22. package/dist/lib/doctor/controller-placeholders.d.ts +2 -3
  23. package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -1
  24. package/dist/lib/doctor/controller-placeholders.js +18 -149
  25. package/dist/lib/doctor/controllers.js +1 -1
  26. package/dist/lib/doctor/project.js +60 -52
  27. package/dist/lib/doctor/types.d.ts +2 -2
  28. package/dist/lib/doctor/types.d.ts.map +1 -1
  29. package/dist/lib/load-route-map-worker.js +17 -9
  30. package/dist/lib/route-map.d.ts +1 -1
  31. package/dist/lib/route-map.d.ts.map +1 -1
  32. package/dist/lib/route-map.js +29 -17
  33. package/package.json +3 -3
  34. package/src/lib/bootstrap-project.ts +39 -13
  35. package/src/lib/cli.ts +39 -4
  36. package/src/lib/commands/completion.ts +6 -1
  37. package/src/lib/commands/doctor.ts +18 -21
  38. package/src/lib/commands/routes.ts +3 -3
  39. package/src/lib/completion.ts +1 -5
  40. package/src/lib/controller-files.ts +4 -8
  41. package/src/lib/controller-ownership.ts +78 -141
  42. package/src/lib/doctor/controller-findings.ts +20 -97
  43. package/src/lib/doctor/controller-fix-plans.ts +13 -29
  44. package/src/lib/doctor/controller-placeholders.ts +17 -189
  45. package/src/lib/doctor/controllers.ts +1 -1
  46. package/src/lib/doctor/project.ts +60 -52
  47. package/src/lib/doctor/types.ts +1 -5
  48. package/src/lib/load-route-map-worker.ts +19 -10
  49. package/src/lib/route-map.ts +61 -16
  50. package/{bootstrap → template}/.agents/skills/remix/SKILL.md +160 -73
  51. package/{bootstrap → template}/.agents/skills/remix/references/assets-and-browser-modules.md +22 -14
  52. package/{bootstrap → template}/.agents/skills/remix/references/auth-and-sessions.md +41 -18
  53. package/{bootstrap → template}/.agents/skills/remix/references/data-and-validation.md +21 -5
  54. package/{bootstrap → template}/.agents/skills/remix/references/middleware-and-server.md +43 -53
  55. package/{bootstrap → template}/.agents/skills/remix/references/routing-and-controllers.md +111 -44
  56. package/{bootstrap → template}/.agents/skills/remix/references/testing-patterns.md +17 -1
  57. package/{bootstrap → template}/AGENTS.md +10 -9
  58. package/template/README.md +29 -0
  59. package/template/app/actions/controller.tsx +18 -0
  60. package/template/app/assets/entry.ts +8 -0
  61. package/{bootstrap/app/ui → template/app/assets}/prompt-button.tsx +2 -1
  62. package/{bootstrap → template}/app/assets.ts +5 -3
  63. package/template/app/middleware/render.tsx +43 -0
  64. package/template/app/router.ts +20 -0
  65. package/{bootstrap → template}/app/routes.ts +1 -2
  66. package/{bootstrap → template}/app/ui/document.tsx +11 -4
  67. package/{bootstrap → template}/app/ui/scaffold-home-page.tsx +39 -38
  68. package/template/gitignore +4 -0
  69. package/{bootstrap → template}/package.json +5 -6
  70. package/template/public/favicon.svg +11 -0
  71. package/{bootstrap → template}/server.ts +10 -11
  72. package/{bootstrap → template}/tsconfig.json +3 -2
  73. package/bootstrap/README.md +0 -27
  74. package/bootstrap/app/assets/entry.ts +0 -19
  75. package/bootstrap/app/controllers/auth.tsx +0 -21
  76. package/bootstrap/app/controllers/home.tsx +0 -11
  77. package/bootstrap/app/router.ts +0 -16
  78. package/bootstrap/app/ui/layout.tsx +0 -22
  79. package/bootstrap/app/utils/render.tsx +0 -26
  80. /package/{bootstrap → template}/.agents/skills/remix/references/animate-elements.md +0 -0
  81. /package/{bootstrap → template}/.agents/skills/remix/references/component-model.md +0 -0
  82. /package/{bootstrap → template}/.agents/skills/remix/references/create-mixins.md +0 -0
  83. /package/{bootstrap → template}/.agents/skills/remix/references/hydration-frames-navigation.md +0 -0
  84. /package/{bootstrap → template}/.agents/skills/remix/references/mixins-styling-events.md +0 -0
@@ -2,16 +2,18 @@ import * as fs from 'node:fs/promises'
2
2
  import * as path from 'node:path'
3
3
 
4
4
  import {
5
- getActionOwnerCandidates,
6
5
  getControllerOwnerCandidates,
7
6
  getOwnerModuleBaseName,
8
7
  getPreferredOwnerDisplayPath,
9
8
  getRouteSubtreePath,
10
- isActionFileName,
11
9
  isControllerEntryFileName,
12
10
  toDiskSegment,
13
11
  } from './controller-files.ts'
14
- import type { RouteOwnerKind, RouteTreeNodeKind } from './route-map.ts'
12
+ import type { RouteTreeNodeKind } from './route-map.ts'
13
+
14
+ const ACTIONS_PATH_PREFIX = 'app/actions/'
15
+
16
+ export const ROOT_ROUTE_NAME = '<root>'
15
17
 
16
18
  export interface OwnershipRouteNode {
17
19
  children: OwnershipRouteNode[]
@@ -22,25 +24,24 @@ export interface OwnershipRouteNode {
22
24
  }
23
25
 
24
26
  export interface ControllerDirectoryScan {
25
- actionEntryPaths: Set<string>
26
27
  controllerEntryPaths: Set<string>
27
- rootDirectoryPaths: Set<string>
28
+ routeDirectoryPaths: Set<string>
28
29
  routeLocalFilePaths: Set<string>
29
30
  }
30
31
 
32
+ export interface RouteDirectoryPlan {
33
+ directoryPath: string
34
+ routeName: string
35
+ }
36
+
31
37
  export interface OwnedSubtreePlan {
32
- alternateCandidates: string[]
33
- alternateDisplayPath: string
34
38
  entryCandidates: string[]
35
39
  entryDisplayPath: string
36
- kind: RouteOwnerKind
37
40
  routeName: string
38
41
  subtreePath: string
39
42
  }
40
43
 
41
44
  export interface OwnedSubtree extends OwnedSubtreePlan {
42
- actualAlternatePath: string | null
43
- actualAlternatePaths: string[]
44
45
  actualEntryPath: string | null
45
46
  actualEntryPaths: string[]
46
47
  claimedFilePaths: string[]
@@ -48,9 +49,9 @@ export interface OwnedSubtree extends OwnedSubtreePlan {
48
49
  }
49
50
 
50
51
  export interface ControllerOwnership {
51
- orphanActionPaths: string[]
52
52
  orphanControllerPaths: string[]
53
53
  orphanRouteDirectoryPaths: string[]
54
+ routeDirectories: RouteDirectoryPlan[]
54
55
  scan: ControllerDirectoryScan
55
56
  subtrees: OwnedSubtree[]
56
57
  }
@@ -60,13 +61,14 @@ export async function inspectControllerOwnership(
60
61
  tree: OwnershipRouteNode[],
61
62
  ): Promise<ControllerOwnership> {
62
63
  let subtreePlans = buildOwnedSubtrees(tree)
64
+ let routeDirectories = buildRouteDirectories(tree)
63
65
  let scan = await scanControllersDirectory(appRoot)
64
66
  let subtrees = applyScanToSubtrees(subtreePlans, scan)
65
67
 
66
68
  return {
67
- orphanActionPaths: getOrphanActionPaths(subtreePlans, scan),
68
69
  orphanControllerPaths: getOrphanControllerPaths(subtreePlans, scan),
69
- orphanRouteDirectoryPaths: getOrphanRouteDirectoryPaths(subtreePlans, scan),
70
+ orphanRouteDirectoryPaths: getOrphanRouteDirectoryPaths(routeDirectories, scan),
71
+ routeDirectories,
70
72
  scan,
71
73
  subtrees,
72
74
  }
@@ -77,52 +79,69 @@ export function buildOwnedSubtrees(
77
79
  parentSegments: string[] = [],
78
80
  subtrees: OwnedSubtreePlan[] = [],
79
81
  ): OwnedSubtreePlan[] {
80
- for (let node of tree) {
81
- let segments = [...parentSegments, toDiskSegment(node.key)]
82
+ if (
83
+ parentSegments.length === 0 &&
84
+ subtrees.length === 0 &&
85
+ tree.some((node) => node.kind === 'route')
86
+ ) {
87
+ addSubtreePlan(ROOT_ROUTE_NAME, [], subtrees)
88
+ }
82
89
 
83
- if (node.kind === 'group') {
84
- let alternateCandidates = getActionOwnerCandidates(segments)
85
- let entryCandidates = getControllerOwnerCandidates(segments)
86
-
87
- subtrees.push({
88
- alternateCandidates,
89
- alternateDisplayPath: getPreferredOwnerDisplayPath(alternateCandidates),
90
- entryCandidates,
91
- entryDisplayPath: getPreferredOwnerDisplayPath(entryCandidates),
92
- kind: 'controller',
93
- routeName: node.name,
94
- subtreePath: getRouteSubtreePath(segments),
95
- })
96
- buildOwnedSubtrees(node.children, segments, subtrees)
90
+ for (let node of tree) {
91
+ if (node.kind !== 'group') {
97
92
  continue
98
93
  }
99
94
 
100
- if (parentSegments.length > 0) {
101
- continue
95
+ let segments = [...parentSegments, toDiskSegment(node.key)]
96
+ if (hasDirectRouteLeaf(node.children)) {
97
+ addSubtreePlan(node.name, segments, subtrees)
102
98
  }
99
+ buildOwnedSubtrees(node.children, segments, subtrees)
100
+ }
101
+
102
+ return subtrees
103
+ }
103
104
 
104
- let alternateCandidates = getControllerOwnerCandidates(segments)
105
- let entryCandidates = getActionOwnerCandidates(segments)
105
+ export function buildRouteDirectories(
106
+ tree: OwnershipRouteNode[],
107
+ parentSegments: string[] = [],
108
+ directories: RouteDirectoryPlan[] = [],
109
+ ): RouteDirectoryPlan[] {
110
+ for (let node of tree) {
111
+ if (node.kind !== 'group') {
112
+ continue
113
+ }
106
114
 
107
- subtrees.push({
108
- alternateCandidates,
109
- alternateDisplayPath: getPreferredOwnerDisplayPath(alternateCandidates),
110
- entryCandidates,
111
- entryDisplayPath: getPreferredOwnerDisplayPath(entryCandidates),
112
- kind: 'action',
115
+ let segments = [...parentSegments, toDiskSegment(node.key)]
116
+ directories.push({
117
+ directoryPath: getRouteSubtreePath(segments),
113
118
  routeName: node.name,
114
- subtreePath: getRouteSubtreePath(segments),
115
119
  })
120
+ buildRouteDirectories(node.children, segments, directories)
116
121
  }
117
122
 
118
- return subtrees
123
+ return directories
124
+ }
125
+
126
+ function hasDirectRouteLeaf(tree: OwnershipRouteNode[]): boolean {
127
+ return tree.some((node) => node.kind === 'route')
128
+ }
129
+
130
+ function addSubtreePlan(routeName: string, segments: string[], subtrees: OwnedSubtreePlan[]): void {
131
+ let entryCandidates = getControllerOwnerCandidates(segments)
132
+
133
+ subtrees.push({
134
+ entryCandidates,
135
+ entryDisplayPath: getPreferredOwnerDisplayPath(entryCandidates),
136
+ routeName,
137
+ subtreePath: getRouteSubtreePath(segments),
138
+ })
119
139
  }
120
140
 
121
141
  async function scanControllersDirectory(appRoot: string): Promise<ControllerDirectoryScan> {
122
- let controllersDir = path.join(appRoot, 'app', 'controllers')
142
+ let actionsDir = path.join(appRoot, 'app', 'actions')
123
143
  let controllerEntryPaths = new Set<string>()
124
- let actionEntryPaths = new Set<string>()
125
- let rootDirectoryPaths = new Set<string>()
144
+ let routeDirectoryPaths = new Set<string>()
126
145
  let routeLocalFilePaths = new Set<string>()
127
146
 
128
147
  async function walk(currentDir: string, isRoot: boolean): Promise<void> {
@@ -143,10 +162,7 @@ async function scanControllersDirectory(appRoot: string): Promise<ControllerDire
143
162
  let relativePath = normalizeRelativePath(path.relative(appRoot, entryPath))
144
163
 
145
164
  if (entry.isDirectory()) {
146
- if (isRoot) {
147
- rootDirectoryPaths.add(relativePath)
148
- }
149
-
165
+ routeDirectoryPaths.add(relativePath)
150
166
  await walk(entryPath, false)
151
167
  continue
152
168
  }
@@ -160,23 +176,17 @@ async function scanControllersDirectory(appRoot: string): Promise<ControllerDire
160
176
  continue
161
177
  }
162
178
 
163
- if (isRoot && isActionCandidate(entry.name)) {
164
- actionEntryPaths.add(relativePath)
165
- continue
166
- }
167
-
168
- if (!isRoot && isRouteLocalFileName(entry.name)) {
179
+ if (isRouteLocalFileName(entry.name)) {
169
180
  routeLocalFilePaths.add(relativePath)
170
181
  }
171
182
  }
172
183
  }
173
184
 
174
- await walk(controllersDir, true)
185
+ await walk(actionsDir, true)
175
186
 
176
187
  return {
177
- actionEntryPaths,
178
188
  controllerEntryPaths,
179
- rootDirectoryPaths,
189
+ routeDirectoryPaths,
180
190
  routeLocalFilePaths,
181
191
  }
182
192
  }
@@ -192,17 +202,10 @@ function applyScanToSubtrees(
192
202
  let claimedContentPaths = claimFilesToDeepestSubtree(getNestedContentPaths(scan), subtreePlans)
193
203
 
194
204
  return subtreePlans.map((subtree) => {
195
- let actualAlternatePaths = findOwnerPaths(
196
- scan,
197
- invertOwnerKind(subtree.kind),
198
- subtree.alternateCandidates,
199
- )
200
- let actualEntryPaths = findOwnerPaths(scan, subtree.kind, subtree.entryCandidates)
205
+ let actualEntryPaths = findOwnerPaths(scan, subtree.entryCandidates)
201
206
 
202
207
  return {
203
208
  ...subtree,
204
- actualAlternatePath: actualAlternatePaths[0] ?? null,
205
- actualAlternatePaths,
206
209
  actualEntryPath: actualEntryPaths[0] ?? null,
207
210
  actualEntryPaths,
208
211
  claimedFilePaths: claimedContentPaths.get(subtree.routeName) ?? [],
@@ -253,61 +256,30 @@ function claimFilesToDeepestSubtree(
253
256
  return claims
254
257
  }
255
258
 
256
- function getOrphanActionPaths(
257
- subtreePlans: OwnedSubtreePlan[],
258
- scan: ControllerDirectoryScan,
259
- ): string[] {
260
- let expectedActionPaths = new Set(
261
- subtreePlans
262
- .filter((subtree) => subtree.kind === 'action')
263
- .flatMap((subtree) => subtree.entryCandidates),
264
- )
265
- let alternateActionPaths = new Set(
266
- subtreePlans
267
- .filter((subtree) => subtree.kind === 'controller')
268
- .flatMap((subtree) => subtree.alternateCandidates),
269
- )
270
-
271
- return [...scan.actionEntryPaths]
272
- .filter((filePath) => !expectedActionPaths.has(filePath))
273
- .filter((filePath) => !alternateActionPaths.has(filePath))
274
- .sort()
275
- }
276
-
277
259
  function getOrphanControllerPaths(
278
260
  subtreePlans: OwnedSubtreePlan[],
279
261
  scan: ControllerDirectoryScan,
280
262
  ): string[] {
281
- let expectedControllerPaths = new Set(
282
- subtreePlans
283
- .filter((subtree) => subtree.kind === 'controller')
284
- .flatMap((subtree) => subtree.entryCandidates),
285
- )
286
- let alternateControllerPaths = new Set(
287
- subtreePlans
288
- .filter((subtree) => subtree.kind === 'action')
289
- .flatMap((subtree) => subtree.alternateCandidates),
290
- )
263
+ let expectedControllerPaths = new Set(subtreePlans.flatMap((subtree) => subtree.entryCandidates))
291
264
 
292
265
  return [...scan.controllerEntryPaths]
293
266
  .filter((filePath) => !expectedControllerPaths.has(filePath))
294
- .filter((filePath) => !alternateControllerPaths.has(filePath))
295
267
  .sort()
296
268
  }
297
269
 
298
270
  function getOrphanRouteDirectoryPaths(
299
- subtreePlans: OwnedSubtreePlan[],
271
+ routeDirectories: RouteDirectoryPlan[],
300
272
  scan: ControllerDirectoryScan,
301
273
  ): string[] {
302
- let expectedRootDirectories = new Set(
303
- subtreePlans.map((subtree) => getTopLevelSubtreePath(subtree.subtreePath)),
274
+ let expectedRouteDirectories = new Set(
275
+ routeDirectories.map((routeDirectory) => routeDirectory.directoryPath),
304
276
  )
305
277
  let actualControllerDirectories = [...scan.controllerEntryPaths].map((controllerPath) =>
306
278
  normalizeRelativePath(path.dirname(controllerPath)),
307
279
  )
308
280
 
309
- return [...scan.rootDirectoryPaths]
310
- .filter((directoryPath) => !expectedRootDirectories.has(directoryPath))
281
+ return [...scan.routeDirectoryPaths]
282
+ .filter((directoryPath) => !expectedRouteDirectories.has(directoryPath))
311
283
  .filter(
312
284
  (directoryPath) =>
313
285
  !actualControllerDirectories.some((controllerPath) =>
@@ -317,24 +289,6 @@ function getOrphanRouteDirectoryPaths(
317
289
  .sort()
318
290
  }
319
291
 
320
- function hasOwnerPath(
321
- scan: ControllerDirectoryScan,
322
- kind: RouteOwnerKind,
323
- filePath: string,
324
- ): boolean {
325
- return kind === 'action'
326
- ? scan.actionEntryPaths.has(filePath)
327
- : scan.controllerEntryPaths.has(filePath)
328
- }
329
-
330
- function invertOwnerKind(kind: RouteOwnerKind): RouteOwnerKind {
331
- return kind === 'action' ? 'controller' : 'action'
332
- }
333
-
334
- function isActionCandidate(fileName: string): boolean {
335
- return isActionFileName(fileName)
336
- }
337
-
338
292
  function isRouteLocalFileName(fileName: string): boolean {
339
293
  let baseName = getOwnerModuleBaseName(fileName)
340
294
 
@@ -346,23 +300,11 @@ function isRouteLocalFileName(fileName: string): boolean {
346
300
  )
347
301
  }
348
302
 
349
- function findOwnerPath(
350
- scan: ControllerDirectoryScan,
351
- kind: RouteOwnerKind,
352
- candidatePaths: string[],
353
- ): string | null {
354
- return findOwnerPaths(scan, kind, candidatePaths)[0] ?? null
355
- }
356
-
357
- function findOwnerPaths(
358
- scan: ControllerDirectoryScan,
359
- kind: RouteOwnerKind,
360
- candidatePaths: string[],
361
- ): string[] {
303
+ function findOwnerPaths(scan: ControllerDirectoryScan, candidatePaths: string[]): string[] {
362
304
  let existingPaths: string[] = []
363
305
 
364
306
  for (let candidatePath of candidatePaths) {
365
- if (hasOwnerPath(scan, kind, candidatePath)) {
307
+ if (scan.controllerEntryPaths.has(candidatePath)) {
366
308
  existingPaths.push(candidatePath)
367
309
  }
368
310
  }
@@ -372,8 +314,8 @@ function findOwnerPaths(
372
314
 
373
315
  function isNestedControllerPath(filePath: string): boolean {
374
316
  return (
375
- filePath.startsWith('app/controllers/') &&
376
- filePath.slice('app/controllers/'.length).includes('/')
317
+ filePath.startsWith(ACTIONS_PATH_PREFIX) &&
318
+ filePath.slice(ACTIONS_PATH_PREFIX.length).includes('/')
377
319
  )
378
320
  }
379
321
 
@@ -387,11 +329,6 @@ function isDirectoryWithinDirectory(directoryPath: string, parentDirectoryPath:
387
329
  )
388
330
  }
389
331
 
390
- function getTopLevelSubtreePath(subtreePath: string): string {
391
- let subtreeSegments = subtreePath.slice('app/controllers/'.length).split('/')
392
- return getRouteSubtreePath([subtreeSegments[0]])
393
- }
394
-
395
332
  function normalizeRelativePath(filePath: string): string {
396
333
  return filePath.split(path.sep).join('/')
397
334
  }
@@ -1,4 +1,8 @@
1
- import type { ControllerOwnership, OwnedSubtree } from '../controller-ownership.ts'
1
+ import {
2
+ ROOT_ROUTE_NAME,
3
+ type ControllerOwnership,
4
+ type OwnedSubtree,
5
+ } from '../controller-ownership.ts'
2
6
  import type { DoctorFinding, DoctorFixPlan } from './types.ts'
3
7
 
4
8
  export function getControllerFindings(
@@ -7,26 +11,19 @@ export function getControllerFindings(
7
11
  ): DoctorFinding[] {
8
12
  return [
9
13
  ...getSubtreeFindings(ownership.subtrees, fixPlans),
10
- ...ownership.orphanActionPaths.map((actualPath) => ({
11
- actualPath,
12
- code: 'orphan-action' as const,
13
- message: `Standalone action ${actualPath} does not match any top-level route.`,
14
- severity: 'warn' as const,
15
- suite: 'controllers' as const,
16
- })),
17
14
  ...ownership.orphanControllerPaths.map((actualPath) => ({
18
15
  actualPath,
19
16
  code: 'orphan-controller' as const,
20
- message: `Controller ${actualPath} does not match any route group.`,
17
+ message: `Action controller ${actualPath} does not match any route map.`,
21
18
  severity: 'warn' as const,
22
- suite: 'controllers' as const,
19
+ suite: 'actions' as const,
23
20
  })),
24
21
  ...ownership.orphanRouteDirectoryPaths.map((actualPath) => ({
25
22
  actualPath,
26
23
  code: 'orphan-route-directory' as const,
27
- message: `Directory ${actualPath} does not match any route subtree.`,
24
+ message: `Directory ${actualPath} does not match any route-map key path.`,
28
25
  severity: 'warn' as const,
29
- suite: 'controllers' as const,
26
+ suite: 'actions' as const,
30
27
  })),
31
28
  ]
32
29
  }
@@ -38,71 +35,22 @@ function getSubtreeFindings(subtrees: OwnedSubtree[], fixPlans: DoctorFixPlan[])
38
35
  )
39
36
 
40
37
  for (let subtree of subtrees) {
41
- let actualEntryPath = subtree.actualEntryPath ?? undefined
42
- let actualAlternatePath = subtree.actualAlternatePath ?? undefined
38
+ let routeMapName = formatRouteMapName(subtree.routeName)
43
39
 
44
40
  if (subtree.actualEntryPaths.length > 1) {
45
41
  findings.push({
46
42
  actualPath: subtree.actualEntryPaths[0],
47
43
  code: 'duplicate-owner-file',
48
44
  expectedPath: subtree.entryDisplayPath,
49
- message: `Route "${subtree.routeName}" has multiple ${subtree.kind} files: ${subtree.actualEntryPaths.join(', ')}. Keep only one ${subtree.kind} owner file.`,
50
- routeName: subtree.routeName,
51
- severity: 'warn',
52
- suite: 'controllers',
53
- })
54
- continue
55
- }
56
-
57
- if (subtree.actualAlternatePaths.length > 1) {
58
- let alternateKind = subtree.kind === 'action' ? 'controller' : 'action'
59
-
60
- findings.push({
61
- actualPath: subtree.actualAlternatePaths[0],
62
- code: 'duplicate-owner-file',
63
- expectedPath: subtree.entryDisplayPath,
64
- message: `Route "${subtree.routeName}" has multiple ${alternateKind} files: ${subtree.actualAlternatePaths.join(', ')}. Keep only one ${alternateKind} owner file.`,
65
- routeName: subtree.routeName,
66
- severity: 'warn',
67
- suite: 'controllers',
68
- })
69
- continue
70
- }
71
-
72
- if (actualEntryPath != null && actualAlternatePath != null) {
73
- findings.push({
74
- actualPath: actualAlternatePath,
75
- code: 'ambiguous-owner',
76
- expectedPath: actualEntryPath,
77
- message:
78
- subtree.kind === 'action'
79
- ? `Route "${subtree.routeName}" has both action ${actualEntryPath} and controller ${actualAlternatePath}.`
80
- : `Route "${subtree.routeName}" has both controller ${actualEntryPath} and standalone action ${actualAlternatePath}.`,
81
- routeName: subtree.routeName,
82
- severity: 'warn',
83
- suite: 'controllers',
84
- })
85
- continue
86
- }
87
-
88
- if (actualAlternatePath != null) {
89
- findings.push({
90
- actualPath: actualAlternatePath,
91
- code: 'wrong-owner-kind',
92
- expectedPath: subtree.entryDisplayPath,
93
- message:
94
- subtree.kind === 'action'
95
- ? `Route "${subtree.routeName}" expects action ${subtree.entryDisplayPath}, but found controller ${actualAlternatePath}.`
96
- : `Route "${subtree.routeName}" expects controller ${subtree.entryDisplayPath}, but found standalone action ${actualAlternatePath}.`,
97
- fixable: fixableFindingKeys.has(`wrong-owner-kind:${subtree.routeName}`),
45
+ message: `${routeMapName} has multiple action controller files: ${subtree.actualEntryPaths.join(', ')}. Keep only one controller owner file.`,
98
46
  routeName: subtree.routeName,
99
47
  severity: 'warn',
100
- suite: 'controllers',
48
+ suite: 'actions',
101
49
  })
102
50
  continue
103
51
  }
104
52
 
105
- if (subtree.kind === 'controller' && subtree.actualEntryPath == null) {
53
+ if (subtree.actualEntryPath == null) {
106
54
  let code =
107
55
  subtree.claimedFilePaths.length > 0
108
56
  ? ('incomplete-controller' as const)
@@ -115,43 +63,18 @@ function getSubtreeFindings(subtrees: OwnedSubtree[], fixPlans: DoctorFixPlan[])
115
63
  fixable: fixableFindingKeys.has(`${code}:${subtree.routeName}`),
116
64
  message:
117
65
  subtree.claimedFilePaths.length > 0
118
- ? `Route "${subtree.routeName}" has files under ${subtree.subtreePath}, but is missing controller ${subtree.entryDisplayPath}.`
119
- : `Route "${subtree.routeName}" is missing controller ${subtree.entryDisplayPath}.`,
66
+ ? `${routeMapName} has files under ${subtree.subtreePath}, but is missing action controller ${subtree.entryDisplayPath}.`
67
+ : `${routeMapName} is missing action controller ${subtree.entryDisplayPath}.`,
120
68
  routeName: subtree.routeName,
121
69
  severity: 'warn',
122
- suite: 'controllers',
123
- })
124
- continue
125
- }
126
-
127
- if (subtree.kind === 'action' && subtree.claimedFilePaths.length > 0) {
128
- findings.push({
129
- actualPath: subtree.subtreePath,
130
- code: 'promotion-drift',
131
- expectedPath: actualEntryPath ?? subtree.entryDisplayPath,
132
- message:
133
- actualEntryPath == null
134
- ? `Route "${subtree.routeName}" has files under ${subtree.subtreePath}, but is still expected to use action ${subtree.entryDisplayPath}. Promote it to controller ${subtree.alternateDisplayPath} or move the files back into ${subtree.entryDisplayPath}.`
135
- : `Route "${subtree.routeName}" uses action ${actualEntryPath}, but also has files under ${subtree.subtreePath}. Promote it to controller ${subtree.alternateDisplayPath} or keep the route in ${actualEntryPath}.`,
136
- routeName: subtree.routeName,
137
- severity: 'warn',
138
- suite: 'controllers',
139
- })
140
- continue
141
- }
142
-
143
- if (subtree.actualEntryPath == null) {
144
- findings.push({
145
- code: 'missing-owner',
146
- expectedPath: subtree.entryDisplayPath,
147
- fixable: fixableFindingKeys.has(`missing-owner:${subtree.routeName}`),
148
- message: `Route "${subtree.routeName}" is missing action ${subtree.entryDisplayPath}.`,
149
- routeName: subtree.routeName,
150
- severity: 'warn',
151
- suite: 'controllers',
70
+ suite: 'actions',
152
71
  })
153
72
  }
154
73
  }
155
74
 
156
75
  return findings
157
76
  }
77
+
78
+ function formatRouteMapName(routeName: string): string {
79
+ return routeName === ROOT_ROUTE_NAME ? 'Root route map' : `Route map "${routeName}"`
80
+ }
@@ -11,7 +11,8 @@ import type {
11
11
  OwnedSubtree,
12
12
  OwnershipRouteNode,
13
13
  } from '../controller-ownership.ts'
14
- import { renderActionPlaceholder, renderControllerPlaceholder } from './controller-placeholders.ts'
14
+ import { ROOT_ROUTE_NAME } from '../controller-ownership.ts'
15
+ import { renderControllerPlaceholder } from './controller-placeholders.ts'
15
16
  import type { DoctorFixPlan } from './types.ts'
16
17
 
17
18
  const OWNER_EXTENSION_PRIORITY: OwnerFileExtension[] = ['.tsx', '.ts', '.jsx', '.js']
@@ -57,10 +58,7 @@ export async function getControllerFixPlans(
57
58
  continue
58
59
  }
59
60
 
60
- let contents =
61
- subtree.kind === 'action'
62
- ? renderActionPlaceholder(routeNode, entryPath)
63
- : renderControllerPlaceholder(routeNode, entryPath, resolvedEntryPathByRouteName)
61
+ let contents = renderControllerPlaceholder(routeNode, entryPath)
64
62
 
65
63
  fixPlans.push({
66
64
  code,
@@ -68,7 +66,7 @@ export async function getControllerFixPlans(
68
66
  kind: 'create-file',
69
67
  path: entryPath,
70
68
  routeName: subtree.routeName,
71
- suite: 'controllers',
69
+ suite: 'actions',
72
70
  })
73
71
  }
74
72
 
@@ -76,7 +74,7 @@ export async function getControllerFixPlans(
76
74
  }
77
75
 
78
76
  function getFixCodeForSubtree(subtree: OwnedSubtree): DoctorFixPlan['code'] | null {
79
- if (subtree.actualEntryPaths.length > 1 || subtree.actualAlternatePaths.length > 1) {
77
+ if (subtree.actualEntryPaths.length > 1) {
80
78
  return null
81
79
  }
82
80
 
@@ -84,19 +82,7 @@ function getFixCodeForSubtree(subtree: OwnedSubtree): DoctorFixPlan['code'] | nu
84
82
  return null
85
83
  }
86
84
 
87
- if (subtree.actualAlternatePath != null) {
88
- return 'wrong-owner-kind'
89
- }
90
-
91
- if (subtree.kind === 'controller') {
92
- return subtree.claimedFilePaths.length > 0 ? 'incomplete-controller' : 'missing-owner'
93
- }
94
-
95
- if (subtree.claimedFilePaths.length > 0) {
96
- return null
97
- }
98
-
99
- return 'missing-owner'
85
+ return subtree.claimedFilePaths.length > 0 ? 'incomplete-controller' : 'missing-owner'
100
86
  }
101
87
 
102
88
  function inferOwnerExtension(
@@ -109,15 +95,7 @@ function inferOwnerExtension(
109
95
  return subtreeExtension
110
96
  }
111
97
 
112
- let alternateExtension = getMostCommonOwnerExtension(subtree.actualAlternatePaths)
113
- if (alternateExtension != null) {
114
- return alternateExtension
115
- }
116
-
117
- let projectExtension = getMostCommonOwnerExtension([
118
- ...ownership.scan.actionEntryPaths,
119
- ...ownership.scan.controllerEntryPaths,
120
- ])
98
+ let projectExtension = getMostCommonOwnerExtension([...ownership.scan.controllerEntryPaths])
121
99
  if (projectExtension != null) {
122
100
  return projectExtension
123
101
  }
@@ -157,6 +135,12 @@ function getMostCommonOwnerExtension(filePaths: string[]): OwnerFileExtension |
157
135
 
158
136
  function getRouteNodesByName(tree: OwnershipRouteNode[]): Map<string, OwnershipRouteNode> {
159
137
  let routeNodesByName = new Map<string, OwnershipRouteNode>()
138
+ routeNodesByName.set(ROOT_ROUTE_NAME, {
139
+ children: tree,
140
+ key: ROOT_ROUTE_NAME,
141
+ kind: 'group',
142
+ name: ROOT_ROUTE_NAME,
143
+ })
160
144
 
161
145
  function visit(nodes: OwnershipRouteNode[]): void {
162
146
  for (let node of nodes) {