@remix-run/cli 0.0.0 → 0.1.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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -3
  3. package/bootstrap/AGENTS.md +35 -0
  4. package/bootstrap/README.md +27 -0
  5. package/bootstrap/app/controllers/auth.tsx +21 -0
  6. package/bootstrap/app/controllers/home.tsx +26 -0
  7. package/bootstrap/app/router.ts +10 -0
  8. package/bootstrap/app/routes.ts +6 -0
  9. package/bootstrap/app/ui/document.tsx +21 -0
  10. package/bootstrap/app/ui/layout.tsx +22 -0
  11. package/bootstrap/app/utils/render.tsx +7 -0
  12. package/bootstrap/package.json +22 -0
  13. package/bootstrap/server.ts +37 -0
  14. package/bootstrap/tsconfig.json +19 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/lib/bootstrap-project.d.ts +17 -0
  19. package/dist/lib/bootstrap-project.d.ts.map +1 -0
  20. package/dist/lib/bootstrap-project.js +137 -0
  21. package/dist/lib/cli-context.d.ts +11 -0
  22. package/dist/lib/cli-context.d.ts.map +1 -0
  23. package/dist/lib/cli-context.js +19 -0
  24. package/dist/lib/cli.d.ts +6 -0
  25. package/dist/lib/cli.d.ts.map +1 -0
  26. package/dist/lib/cli.js +87 -0
  27. package/dist/lib/commands/completion.d.ts +3 -0
  28. package/dist/lib/commands/completion.d.ts.map +1 -0
  29. package/dist/lib/commands/completion.js +51 -0
  30. package/dist/lib/commands/doctor.d.ts +4 -0
  31. package/dist/lib/commands/doctor.d.ts.map +1 -0
  32. package/dist/lib/commands/doctor.js +293 -0
  33. package/dist/lib/commands/help.d.ts +4 -0
  34. package/dist/lib/commands/help.d.ts.map +1 -0
  35. package/dist/lib/commands/help.js +124 -0
  36. package/dist/lib/commands/new.d.ts +4 -0
  37. package/dist/lib/commands/new.d.ts.map +1 -0
  38. package/dist/lib/commands/new.js +70 -0
  39. package/dist/lib/commands/routes.d.ts +4 -0
  40. package/dist/lib/commands/routes.d.ts.map +1 -0
  41. package/dist/lib/commands/routes.js +161 -0
  42. package/dist/lib/commands/skills.d.ts +6 -0
  43. package/dist/lib/commands/skills.d.ts.map +1 -0
  44. package/dist/lib/commands/skills.js +222 -0
  45. package/dist/lib/commands/test.d.ts +4 -0
  46. package/dist/lib/commands/test.d.ts.map +1 -0
  47. package/dist/lib/commands/test.js +19 -0
  48. package/dist/lib/commands/version.d.ts +4 -0
  49. package/dist/lib/commands/version.d.ts.map +1 -0
  50. package/dist/lib/commands/version.js +29 -0
  51. package/dist/lib/completion.d.ts +12 -0
  52. package/dist/lib/completion.d.ts.map +1 -0
  53. package/dist/lib/completion.js +408 -0
  54. package/dist/lib/contained-path.d.ts +2 -0
  55. package/dist/lib/contained-path.d.ts.map +1 -0
  56. package/dist/lib/contained-path.js +10 -0
  57. package/dist/lib/controller-files.d.ts +14 -0
  58. package/dist/lib/controller-files.d.ts.map +1 -0
  59. package/dist/lib/controller-files.js +53 -0
  60. package/dist/lib/controller-ownership.d.ts +41 -0
  61. package/dist/lib/controller-ownership.d.ts.map +1 -0
  62. package/dist/lib/controller-ownership.js +224 -0
  63. package/dist/lib/display-path.d.ts +2 -0
  64. package/dist/lib/display-path.d.ts.map +1 -0
  65. package/dist/lib/display-path.js +12 -0
  66. package/dist/lib/doctor/controller-findings.d.ts +4 -0
  67. package/dist/lib/doctor/controller-findings.d.ts.map +1 -0
  68. package/dist/lib/doctor/controller-findings.js +132 -0
  69. package/dist/lib/doctor/controller-fix-plans.d.ts +4 -0
  70. package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -0
  71. package/dist/lib/doctor/controller-fix-plans.js +130 -0
  72. package/dist/lib/doctor/controller-placeholders.d.ts +4 -0
  73. package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -0
  74. package/dist/lib/doctor/controller-placeholders.js +183 -0
  75. package/dist/lib/doctor/controllers.d.ts +8 -0
  76. package/dist/lib/doctor/controllers.d.ts.map +1 -0
  77. package/dist/lib/doctor/controllers.js +13 -0
  78. package/dist/lib/doctor/environment.d.ts +17 -0
  79. package/dist/lib/doctor/environment.d.ts.map +1 -0
  80. package/dist/lib/doctor/environment.js +226 -0
  81. package/dist/lib/doctor/fixes.d.ts +3 -0
  82. package/dist/lib/doctor/fixes.d.ts.map +1 -0
  83. package/dist/lib/doctor/fixes.js +65 -0
  84. package/dist/lib/doctor/project.d.ts +10 -0
  85. package/dist/lib/doctor/project.d.ts.map +1 -0
  86. package/dist/lib/doctor/project.js +371 -0
  87. package/dist/lib/doctor/types.d.ts +48 -0
  88. package/dist/lib/doctor/types.d.ts.map +1 -0
  89. package/dist/lib/doctor/types.js +15 -0
  90. package/dist/lib/errors.d.ts +173 -0
  91. package/dist/lib/errors.d.ts.map +1 -0
  92. package/dist/lib/errors.js +318 -0
  93. package/dist/lib/help-text.d.ts +13 -0
  94. package/dist/lib/help-text.d.ts.map +1 -0
  95. package/dist/lib/help-text.js +43 -0
  96. package/dist/lib/load-route-map-worker.d.ts +2 -0
  97. package/dist/lib/load-route-map-worker.d.ts.map +1 -0
  98. package/dist/lib/load-route-map-worker.js +92 -0
  99. package/dist/lib/parse-args.d.ts +22 -0
  100. package/dist/lib/parse-args.d.ts.map +1 -0
  101. package/dist/lib/parse-args.js +35 -0
  102. package/dist/lib/remix-version.d.ts +2 -0
  103. package/dist/lib/remix-version.d.ts.map +1 -0
  104. package/dist/lib/remix-version.js +58 -0
  105. package/dist/lib/reporter.d.ts +56 -0
  106. package/dist/lib/reporter.d.ts.map +1 -0
  107. package/dist/lib/reporter.js +357 -0
  108. package/dist/lib/route-map.d.ts +38 -0
  109. package/dist/lib/route-map.d.ts.map +1 -0
  110. package/dist/lib/route-map.js +195 -0
  111. package/dist/lib/skills-cache.d.ts +19 -0
  112. package/dist/lib/skills-cache.d.ts.map +1 -0
  113. package/dist/lib/skills-cache.js +89 -0
  114. package/dist/lib/skills.d.ts +30 -0
  115. package/dist/lib/skills.d.ts.map +1 -0
  116. package/dist/lib/skills.js +441 -0
  117. package/dist/lib/terminal.d.ts +17 -0
  118. package/dist/lib/terminal.d.ts.map +1 -0
  119. package/dist/lib/terminal.js +66 -0
  120. package/package.json +48 -5
  121. package/src/index.ts +1 -0
  122. package/src/lib/bootstrap-project.ts +222 -0
  123. package/src/lib/cli-context.ts +38 -0
  124. package/src/lib/cli.ts +120 -0
  125. package/src/lib/commands/completion.ts +74 -0
  126. package/src/lib/commands/doctor.ts +393 -0
  127. package/src/lib/commands/help.ts +157 -0
  128. package/src/lib/commands/new.ts +104 -0
  129. package/src/lib/commands/routes.ts +251 -0
  130. package/src/lib/commands/skills.ts +306 -0
  131. package/src/lib/commands/test.ts +25 -0
  132. package/src/lib/commands/version.ts +39 -0
  133. package/src/lib/completion.ts +561 -0
  134. package/src/lib/contained-path.ts +13 -0
  135. package/src/lib/controller-files.ts +79 -0
  136. package/src/lib/controller-ownership.ts +397 -0
  137. package/src/lib/display-path.ts +16 -0
  138. package/src/lib/doctor/controller-findings.ts +157 -0
  139. package/src/lib/doctor/controller-fix-plans.ts +184 -0
  140. package/src/lib/doctor/controller-placeholders.ts +242 -0
  141. package/src/lib/doctor/controllers.ts +23 -0
  142. package/src/lib/doctor/environment.ts +289 -0
  143. package/src/lib/doctor/fixes.ts +84 -0
  144. package/src/lib/doctor/project.ts +435 -0
  145. package/src/lib/doctor/types.ts +95 -0
  146. package/src/lib/errors.ts +379 -0
  147. package/src/lib/help-text.ts +88 -0
  148. package/src/lib/load-route-map-worker.ts +110 -0
  149. package/src/lib/parse-args.ts +82 -0
  150. package/src/lib/remix-version.ts +80 -0
  151. package/src/lib/reporter.ts +535 -0
  152. package/src/lib/route-map.ts +303 -0
  153. package/src/lib/skills-cache.ts +140 -0
  154. package/src/lib/skills.ts +706 -0
  155. package/src/lib/terminal.ts +86 -0
@@ -0,0 +1,397 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as path from 'node:path'
3
+
4
+ import {
5
+ getActionOwnerCandidates,
6
+ getControllerOwnerCandidates,
7
+ getOwnerModuleBaseName,
8
+ getPreferredOwnerDisplayPath,
9
+ getRouteSubtreePath,
10
+ isActionFileName,
11
+ isControllerEntryFileName,
12
+ toDiskSegment,
13
+ } from './controller-files.ts'
14
+ import type { RouteOwnerKind, RouteTreeNodeKind } from './route-map.ts'
15
+
16
+ export interface OwnershipRouteNode {
17
+ children: OwnershipRouteNode[]
18
+ key: string
19
+ kind: RouteTreeNodeKind
20
+ method?: string
21
+ name: string
22
+ }
23
+
24
+ export interface ControllerDirectoryScan {
25
+ actionEntryPaths: Set<string>
26
+ controllerEntryPaths: Set<string>
27
+ rootDirectoryPaths: Set<string>
28
+ routeLocalFilePaths: Set<string>
29
+ }
30
+
31
+ export interface OwnedSubtreePlan {
32
+ alternateCandidates: string[]
33
+ alternateDisplayPath: string
34
+ entryCandidates: string[]
35
+ entryDisplayPath: string
36
+ kind: RouteOwnerKind
37
+ routeName: string
38
+ subtreePath: string
39
+ }
40
+
41
+ export interface OwnedSubtree extends OwnedSubtreePlan {
42
+ actualAlternatePath: string | null
43
+ actualAlternatePaths: string[]
44
+ actualEntryPath: string | null
45
+ actualEntryPaths: string[]
46
+ claimedFilePaths: string[]
47
+ claimedRouteLocalFilePaths: string[]
48
+ }
49
+
50
+ export interface ControllerOwnership {
51
+ orphanActionPaths: string[]
52
+ orphanControllerPaths: string[]
53
+ orphanRouteDirectoryPaths: string[]
54
+ scan: ControllerDirectoryScan
55
+ subtrees: OwnedSubtree[]
56
+ }
57
+
58
+ export async function inspectControllerOwnership(
59
+ appRoot: string,
60
+ tree: OwnershipRouteNode[],
61
+ ): Promise<ControllerOwnership> {
62
+ let subtreePlans = buildOwnedSubtrees(tree)
63
+ let scan = await scanControllersDirectory(appRoot)
64
+ let subtrees = applyScanToSubtrees(subtreePlans, scan)
65
+
66
+ return {
67
+ orphanActionPaths: getOrphanActionPaths(subtreePlans, scan),
68
+ orphanControllerPaths: getOrphanControllerPaths(subtreePlans, scan),
69
+ orphanRouteDirectoryPaths: getOrphanRouteDirectoryPaths(subtreePlans, scan),
70
+ scan,
71
+ subtrees,
72
+ }
73
+ }
74
+
75
+ export function buildOwnedSubtrees(
76
+ tree: OwnershipRouteNode[],
77
+ parentSegments: string[] = [],
78
+ subtrees: OwnedSubtreePlan[] = [],
79
+ ): OwnedSubtreePlan[] {
80
+ for (let node of tree) {
81
+ let segments = [...parentSegments, toDiskSegment(node.key)]
82
+
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)
97
+ continue
98
+ }
99
+
100
+ if (parentSegments.length > 0) {
101
+ continue
102
+ }
103
+
104
+ let alternateCandidates = getControllerOwnerCandidates(segments)
105
+ let entryCandidates = getActionOwnerCandidates(segments)
106
+
107
+ subtrees.push({
108
+ alternateCandidates,
109
+ alternateDisplayPath: getPreferredOwnerDisplayPath(alternateCandidates),
110
+ entryCandidates,
111
+ entryDisplayPath: getPreferredOwnerDisplayPath(entryCandidates),
112
+ kind: 'action',
113
+ routeName: node.name,
114
+ subtreePath: getRouteSubtreePath(segments),
115
+ })
116
+ }
117
+
118
+ return subtrees
119
+ }
120
+
121
+ async function scanControllersDirectory(appRoot: string): Promise<ControllerDirectoryScan> {
122
+ let controllersDir = path.join(appRoot, 'app', 'controllers')
123
+ let controllerEntryPaths = new Set<string>()
124
+ let actionEntryPaths = new Set<string>()
125
+ let rootDirectoryPaths = new Set<string>()
126
+ let routeLocalFilePaths = new Set<string>()
127
+
128
+ async function walk(currentDir: string, isRoot: boolean): Promise<void> {
129
+ let entries
130
+ try {
131
+ entries = await fs.readdir(currentDir, { withFileTypes: true })
132
+ } catch (error) {
133
+ let nodeError = error as NodeJS.ErrnoException
134
+ if (nodeError.code === 'ENOENT' && isRoot) {
135
+ return
136
+ }
137
+
138
+ throw error
139
+ }
140
+
141
+ for (let entry of entries) {
142
+ let entryPath = path.join(currentDir, entry.name)
143
+ let relativePath = normalizeRelativePath(path.relative(appRoot, entryPath))
144
+
145
+ if (entry.isDirectory()) {
146
+ if (isRoot) {
147
+ rootDirectoryPaths.add(relativePath)
148
+ }
149
+
150
+ await walk(entryPath, false)
151
+ continue
152
+ }
153
+
154
+ if (!entry.isFile()) {
155
+ continue
156
+ }
157
+
158
+ if (isControllerEntryFileName(entry.name)) {
159
+ controllerEntryPaths.add(relativePath)
160
+ continue
161
+ }
162
+
163
+ if (isRoot && isActionCandidate(entry.name)) {
164
+ actionEntryPaths.add(relativePath)
165
+ continue
166
+ }
167
+
168
+ if (!isRoot && isRouteLocalFileName(entry.name)) {
169
+ routeLocalFilePaths.add(relativePath)
170
+ }
171
+ }
172
+ }
173
+
174
+ await walk(controllersDir, true)
175
+
176
+ return {
177
+ actionEntryPaths,
178
+ controllerEntryPaths,
179
+ rootDirectoryPaths,
180
+ routeLocalFilePaths,
181
+ }
182
+ }
183
+
184
+ function applyScanToSubtrees(
185
+ subtreePlans: OwnedSubtreePlan[],
186
+ scan: ControllerDirectoryScan,
187
+ ): OwnedSubtree[] {
188
+ let claimedRouteLocalPaths = claimFilesToDeepestSubtree(
189
+ [...scan.routeLocalFilePaths],
190
+ subtreePlans,
191
+ )
192
+ let claimedContentPaths = claimFilesToDeepestSubtree(getNestedContentPaths(scan), subtreePlans)
193
+
194
+ 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)
201
+
202
+ return {
203
+ ...subtree,
204
+ actualAlternatePath: actualAlternatePaths[0] ?? null,
205
+ actualAlternatePaths,
206
+ actualEntryPath: actualEntryPaths[0] ?? null,
207
+ actualEntryPaths,
208
+ claimedFilePaths: claimedContentPaths.get(subtree.routeName) ?? [],
209
+ claimedRouteLocalFilePaths: claimedRouteLocalPaths.get(subtree.routeName) ?? [],
210
+ }
211
+ })
212
+ }
213
+
214
+ function getNestedContentPaths(scan: ControllerDirectoryScan): string[] {
215
+ let nestedControllerPaths = [...scan.controllerEntryPaths].filter((filePath) =>
216
+ isNestedControllerPath(filePath),
217
+ )
218
+
219
+ return [...new Set([...nestedControllerPaths, ...scan.routeLocalFilePaths])].sort()
220
+ }
221
+
222
+ function claimFilesToDeepestSubtree(
223
+ filePaths: string[],
224
+ subtreePlans: OwnedSubtreePlan[],
225
+ ): Map<string, string[]> {
226
+ let subtreesByDepth = [...subtreePlans].sort((left, right) => {
227
+ if (right.subtreePath.length !== left.subtreePath.length) {
228
+ return right.subtreePath.length - left.subtreePath.length
229
+ }
230
+
231
+ return left.routeName.localeCompare(right.routeName)
232
+ })
233
+ let claims = new Map<string, string[]>()
234
+
235
+ for (let filePath of filePaths.sort()) {
236
+ let matchingSubtree = subtreesByDepth.find((subtree) =>
237
+ isWithinDirectory(filePath, subtree.subtreePath),
238
+ )
239
+
240
+ if (matchingSubtree == null) {
241
+ continue
242
+ }
243
+
244
+ let claimedPaths = claims.get(matchingSubtree.routeName)
245
+ if (claimedPaths == null) {
246
+ claims.set(matchingSubtree.routeName, [filePath])
247
+ continue
248
+ }
249
+
250
+ claimedPaths.push(filePath)
251
+ }
252
+
253
+ return claims
254
+ }
255
+
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
+ function getOrphanControllerPaths(
278
+ subtreePlans: OwnedSubtreePlan[],
279
+ scan: ControllerDirectoryScan,
280
+ ): 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
+ )
291
+
292
+ return [...scan.controllerEntryPaths]
293
+ .filter((filePath) => !expectedControllerPaths.has(filePath))
294
+ .filter((filePath) => !alternateControllerPaths.has(filePath))
295
+ .sort()
296
+ }
297
+
298
+ function getOrphanRouteDirectoryPaths(
299
+ subtreePlans: OwnedSubtreePlan[],
300
+ scan: ControllerDirectoryScan,
301
+ ): string[] {
302
+ let expectedRootDirectories = new Set(
303
+ subtreePlans.map((subtree) => getTopLevelSubtreePath(subtree.subtreePath)),
304
+ )
305
+ let actualControllerDirectories = [...scan.controllerEntryPaths].map((controllerPath) =>
306
+ normalizeRelativePath(path.dirname(controllerPath)),
307
+ )
308
+
309
+ return [...scan.rootDirectoryPaths]
310
+ .filter((directoryPath) => !expectedRootDirectories.has(directoryPath))
311
+ .filter(
312
+ (directoryPath) =>
313
+ !actualControllerDirectories.some((controllerPath) =>
314
+ isDirectoryWithinDirectory(controllerPath, directoryPath),
315
+ ),
316
+ )
317
+ .sort()
318
+ }
319
+
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
+ function isRouteLocalFileName(fileName: string): boolean {
339
+ let baseName = getOwnerModuleBaseName(fileName)
340
+
341
+ return (
342
+ baseName != null &&
343
+ baseName !== 'controller' &&
344
+ !baseName.endsWith('.test') &&
345
+ !baseName.endsWith('.spec')
346
+ )
347
+ }
348
+
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[] {
362
+ let existingPaths: string[] = []
363
+
364
+ for (let candidatePath of candidatePaths) {
365
+ if (hasOwnerPath(scan, kind, candidatePath)) {
366
+ existingPaths.push(candidatePath)
367
+ }
368
+ }
369
+
370
+ return existingPaths
371
+ }
372
+
373
+ function isNestedControllerPath(filePath: string): boolean {
374
+ return (
375
+ filePath.startsWith('app/controllers/') &&
376
+ filePath.slice('app/controllers/'.length).includes('/')
377
+ )
378
+ }
379
+
380
+ function isWithinDirectory(filePath: string, directoryPath: string): boolean {
381
+ return filePath.startsWith(`${directoryPath}/`)
382
+ }
383
+
384
+ function isDirectoryWithinDirectory(directoryPath: string, parentDirectoryPath: string): boolean {
385
+ return (
386
+ directoryPath === parentDirectoryPath || isWithinDirectory(directoryPath, parentDirectoryPath)
387
+ )
388
+ }
389
+
390
+ function getTopLevelSubtreePath(subtreePath: string): string {
391
+ let subtreeSegments = subtreePath.slice('app/controllers/'.length).split('/')
392
+ return getRouteSubtreePath([subtreeSegments[0]])
393
+ }
394
+
395
+ function normalizeRelativePath(filePath: string): string {
396
+ return filePath.split(path.sep).join('/')
397
+ }
@@ -0,0 +1,16 @@
1
+ import * as path from 'node:path'
2
+ import * as process from 'node:process'
3
+
4
+ export function getDisplayPath(filePath: string, cwd: string = process.cwd()): string {
5
+ let relativePath = path.relative(path.resolve(cwd), path.resolve(filePath))
6
+
7
+ if (relativePath.length === 0) {
8
+ return '.'
9
+ }
10
+
11
+ if (path.isAbsolute(relativePath)) {
12
+ return filePath
13
+ }
14
+
15
+ return relativePath
16
+ }
@@ -0,0 +1,157 @@
1
+ import type { ControllerOwnership, OwnedSubtree } from '../controller-ownership.ts'
2
+ import type { DoctorFinding, DoctorFixPlan } from './types.ts'
3
+
4
+ export function getControllerFindings(
5
+ ownership: ControllerOwnership,
6
+ fixPlans: DoctorFixPlan[],
7
+ ): DoctorFinding[] {
8
+ return [
9
+ ...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
+ ...ownership.orphanControllerPaths.map((actualPath) => ({
18
+ actualPath,
19
+ code: 'orphan-controller' as const,
20
+ message: `Controller ${actualPath} does not match any route group.`,
21
+ severity: 'warn' as const,
22
+ suite: 'controllers' as const,
23
+ })),
24
+ ...ownership.orphanRouteDirectoryPaths.map((actualPath) => ({
25
+ actualPath,
26
+ code: 'orphan-route-directory' as const,
27
+ message: `Directory ${actualPath} does not match any route subtree.`,
28
+ severity: 'warn' as const,
29
+ suite: 'controllers' as const,
30
+ })),
31
+ ]
32
+ }
33
+
34
+ function getSubtreeFindings(subtrees: OwnedSubtree[], fixPlans: DoctorFixPlan[]): DoctorFinding[] {
35
+ let findings: DoctorFinding[] = []
36
+ let fixableFindingKeys = new Set(
37
+ fixPlans.map((fixPlan) => `${fixPlan.code}:${fixPlan.routeName ?? ''}`),
38
+ )
39
+
40
+ for (let subtree of subtrees) {
41
+ let actualEntryPath = subtree.actualEntryPath ?? undefined
42
+ let actualAlternatePath = subtree.actualAlternatePath ?? undefined
43
+
44
+ if (subtree.actualEntryPaths.length > 1) {
45
+ findings.push({
46
+ actualPath: subtree.actualEntryPaths[0],
47
+ code: 'duplicate-owner-file',
48
+ 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}`),
98
+ routeName: subtree.routeName,
99
+ severity: 'warn',
100
+ suite: 'controllers',
101
+ })
102
+ continue
103
+ }
104
+
105
+ if (subtree.kind === 'controller' && subtree.actualEntryPath == null) {
106
+ let code =
107
+ subtree.claimedFilePaths.length > 0
108
+ ? ('incomplete-controller' as const)
109
+ : ('missing-owner' as const)
110
+
111
+ findings.push({
112
+ actualPath: subtree.claimedFilePaths.length > 0 ? subtree.subtreePath : undefined,
113
+ code,
114
+ expectedPath: subtree.entryDisplayPath,
115
+ fixable: fixableFindingKeys.has(`${code}:${subtree.routeName}`),
116
+ message:
117
+ 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}.`,
120
+ routeName: subtree.routeName,
121
+ 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',
152
+ })
153
+ }
154
+ }
155
+
156
+ return findings
157
+ }