@nifrajs/cli 5.0.0 → 5.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 (202) hide show
  1. package/README.md +7 -2
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +4 -2
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts +12 -2
  6. package/dist/assure.d.ts.map +1 -1
  7. package/dist/assure.js +59 -8
  8. package/dist/assure.js.map +1 -1
  9. package/dist/capabilities-tool.d.ts.map +1 -1
  10. package/dist/capabilities-tool.js +24 -7
  11. package/dist/capabilities-tool.js.map +1 -1
  12. package/dist/check-diagnostics.d.ts +2 -0
  13. package/dist/check-diagnostics.d.ts.map +1 -1
  14. package/dist/check-diagnostics.js.map +1 -1
  15. package/dist/check-scan.d.ts +8 -2
  16. package/dist/check-scan.d.ts.map +1 -1
  17. package/dist/check-scan.js +43 -10
  18. package/dist/check-scan.js.map +1 -1
  19. package/dist/check.d.ts.map +1 -1
  20. package/dist/check.js +39 -33
  21. package/dist/check.js.map +1 -1
  22. package/dist/cli.d.ts +2 -0
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +106 -48
  25. package/dist/cli.js.map +1 -1
  26. package/dist/client-module.d.ts +5 -3
  27. package/dist/client-module.d.ts.map +1 -1
  28. package/dist/client-module.js +34 -10
  29. package/dist/client-module.js.map +1 -1
  30. package/dist/command-catalog.d.ts +9 -1
  31. package/dist/command-catalog.d.ts.map +1 -1
  32. package/dist/command-catalog.js +67 -22
  33. package/dist/command-catalog.js.map +1 -1
  34. package/dist/dev-feed-tool.d.ts +18 -1
  35. package/dist/dev-feed-tool.d.ts.map +1 -1
  36. package/dist/dev-feed-tool.js +93 -0
  37. package/dist/dev-feed-tool.js.map +1 -1
  38. package/dist/fix-recipes.d.ts +2 -0
  39. package/dist/fix-recipes.d.ts.map +1 -1
  40. package/dist/fix-recipes.js +93 -12
  41. package/dist/fix-recipes.js.map +1 -1
  42. package/dist/init-agents.d.ts +16 -0
  43. package/dist/init-agents.d.ts.map +1 -1
  44. package/dist/init-agents.js +3 -2
  45. package/dist/init-agents.js.map +1 -1
  46. package/dist/internal/config-paths.d.ts +16 -0
  47. package/dist/internal/config-paths.d.ts.map +1 -0
  48. package/dist/internal/config-paths.js +124 -0
  49. package/dist/internal/config-paths.js.map +1 -0
  50. package/dist/internal/installed-package.d.ts +4 -0
  51. package/dist/internal/installed-package.d.ts.map +1 -0
  52. package/dist/internal/installed-package.js +17 -0
  53. package/dist/internal/installed-package.js.map +1 -0
  54. package/dist/internal/project-specifiers.d.ts +14 -1
  55. package/dist/internal/project-specifiers.d.ts.map +1 -1
  56. package/dist/internal/project-specifiers.js +54 -15
  57. package/dist/internal/project-specifiers.js.map +1 -1
  58. package/dist/internal/source-facts.d.ts +6 -1
  59. package/dist/internal/source-facts.d.ts.map +1 -1
  60. package/dist/internal/source-facts.js +11 -5
  61. package/dist/internal/source-facts.js.map +1 -1
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +5 -3
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +9 -0
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +32 -1
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/levels-tool.d.ts.map +1 -1
  70. package/dist/levels-tool.js +5 -8
  71. package/dist/levels-tool.js.map +1 -1
  72. package/dist/load.d.ts +2 -2
  73. package/dist/load.d.ts.map +1 -1
  74. package/dist/load.js +23 -3
  75. package/dist/load.js.map +1 -1
  76. package/dist/mcp-context.d.ts.map +1 -1
  77. package/dist/mcp-context.js +6 -21
  78. package/dist/mcp-context.js.map +1 -1
  79. package/dist/mcp-delegate.d.ts +2 -2
  80. package/dist/mcp-delegate.d.ts.map +1 -1
  81. package/dist/mcp-delegate.js +19 -7
  82. package/dist/mcp-delegate.js.map +1 -1
  83. package/dist/mcp-exec.d.ts +1 -0
  84. package/dist/mcp-exec.d.ts.map +1 -1
  85. package/dist/mcp-exec.js +8 -3
  86. package/dist/mcp-exec.js.map +1 -1
  87. package/dist/mcp-http.js +1 -1
  88. package/dist/mcp-io.d.ts +13 -0
  89. package/dist/mcp-io.d.ts.map +1 -1
  90. package/dist/mcp-io.js +23 -0
  91. package/dist/mcp-io.js.map +1 -1
  92. package/dist/mcp-path.d.ts +2 -1
  93. package/dist/mcp-path.d.ts.map +1 -1
  94. package/dist/mcp-path.js +9 -24
  95. package/dist/mcp-path.js.map +1 -1
  96. package/dist/mcp.d.ts.map +1 -1
  97. package/dist/mcp.js +40 -17
  98. package/dist/mcp.js.map +1 -1
  99. package/dist/migrate-layout.d.ts +40 -1
  100. package/dist/migrate-layout.d.ts.map +1 -1
  101. package/dist/migrate-layout.js +1316 -228
  102. package/dist/migrate-layout.js.map +1 -1
  103. package/dist/output-schemas.d.ts +34 -0
  104. package/dist/output-schemas.d.ts.map +1 -1
  105. package/dist/output-schemas.js +132 -21
  106. package/dist/output-schemas.js.map +1 -1
  107. package/dist/project-path.d.ts +6 -0
  108. package/dist/project-path.d.ts.map +1 -1
  109. package/dist/project-path.js +48 -14
  110. package/dist/project-path.js.map +1 -1
  111. package/dist/release-verification.d.ts +5 -0
  112. package/dist/release-verification.d.ts.map +1 -1
  113. package/dist/release-verification.js +14 -0
  114. package/dist/release-verification.js.map +1 -1
  115. package/dist/review.d.ts.map +1 -1
  116. package/dist/review.js +5 -0
  117. package/dist/review.js.map +1 -1
  118. package/dist/rules/codes.d.ts +3 -1
  119. package/dist/rules/codes.d.ts.map +1 -1
  120. package/dist/rules/codes.js +3 -1
  121. package/dist/rules/codes.js.map +1 -1
  122. package/dist/rules/data-guard.d.ts +6 -0
  123. package/dist/rules/data-guard.d.ts.map +1 -1
  124. package/dist/rules/data-guard.js +37 -0
  125. package/dist/rules/data-guard.js.map +1 -1
  126. package/dist/rules/legacy.d.ts.map +1 -1
  127. package/dist/rules/legacy.js +20 -1
  128. package/dist/rules/legacy.js.map +1 -1
  129. package/dist/rules/pages.d.ts +29 -3
  130. package/dist/rules/pages.d.ts.map +1 -1
  131. package/dist/rules/pages.js +236 -22
  132. package/dist/rules/pages.js.map +1 -1
  133. package/dist/rules/security.d.ts +3 -2
  134. package/dist/rules/security.d.ts.map +1 -1
  135. package/dist/rules/security.js +42 -11
  136. package/dist/rules/security.js.map +1 -1
  137. package/dist/rules/zones.d.ts.map +1 -1
  138. package/dist/rules/zones.js +31 -9
  139. package/dist/rules/zones.js.map +1 -1
  140. package/dist/stylex-migrate.d.ts.map +1 -1
  141. package/dist/stylex-migrate.js +127 -17
  142. package/dist/stylex-migrate.js.map +1 -1
  143. package/dist/test-tool.d.ts.map +1 -1
  144. package/dist/test-tool.js +3 -9
  145. package/dist/test-tool.js.map +1 -1
  146. package/dist/upgrade.d.ts +20 -5
  147. package/dist/upgrade.d.ts.map +1 -1
  148. package/dist/upgrade.js +86 -9
  149. package/dist/upgrade.js.map +1 -1
  150. package/dist/verification-plan.d.ts.map +1 -1
  151. package/dist/verification-plan.js +5 -2
  152. package/dist/verification-plan.js.map +1 -1
  153. package/dist/version-drift.d.ts.map +1 -1
  154. package/dist/version-drift.js +55 -3
  155. package/dist/version-drift.js.map +1 -1
  156. package/docs/examples.json +2 -2
  157. package/docs/llms-full.txt +481 -61
  158. package/docs/types.json +147 -70
  159. package/package.json +11 -11
  160. package/src/assure-hydration.ts +4 -1
  161. package/src/assure.ts +88 -10
  162. package/src/capabilities-tool.ts +33 -7
  163. package/src/check-diagnostics.ts +2 -0
  164. package/src/check-scan.ts +56 -9
  165. package/src/check.ts +55 -39
  166. package/src/cli.ts +104 -35
  167. package/src/client-module.ts +45 -9
  168. package/src/command-catalog.ts +77 -21
  169. package/src/dev-feed-tool.ts +112 -1
  170. package/src/fix-recipes.ts +101 -10
  171. package/src/init-agents.ts +4 -3
  172. package/src/internal/config-paths.ts +154 -0
  173. package/src/internal/installed-package.ts +15 -0
  174. package/src/internal/project-specifiers.ts +69 -16
  175. package/src/internal/source-facts.ts +23 -5
  176. package/src/internal/typescript-import.ts +5 -3
  177. package/src/introspect.ts +39 -1
  178. package/src/levels-tool.ts +7 -9
  179. package/src/load.ts +29 -3
  180. package/src/mcp-context.ts +5 -18
  181. package/src/mcp-delegate.ts +22 -10
  182. package/src/mcp-exec.ts +9 -2
  183. package/src/mcp-http.ts +1 -1
  184. package/src/mcp-io.ts +33 -0
  185. package/src/mcp-path.ts +8 -22
  186. package/src/mcp.ts +48 -19
  187. package/src/migrate-layout.ts +1496 -237
  188. package/src/output-schemas.ts +179 -23
  189. package/src/project-path.ts +44 -13
  190. package/src/release-verification.ts +20 -0
  191. package/src/review.ts +5 -0
  192. package/src/rules/codes.ts +3 -1
  193. package/src/rules/data-guard.ts +40 -0
  194. package/src/rules/legacy.ts +22 -1
  195. package/src/rules/pages.ts +266 -24
  196. package/src/rules/security.ts +49 -11
  197. package/src/rules/zones.ts +30 -7
  198. package/src/stylex-migrate.ts +117 -17
  199. package/src/test-tool.ts +3 -9
  200. package/src/upgrade.ts +96 -8
  201. package/src/verification-plan.ts +9 -2
  202. package/src/version-drift.ts +53 -2
@@ -793,6 +793,117 @@ export const logsSpec: CommandSpec<LogsInput, LogsOutput> = {
793
793
  json: (out) => out,
794
794
  }
795
795
 
796
+ export interface ExplainInput {
797
+ readonly error?: string | undefined
798
+ readonly stack?: string | undefined
799
+ readonly name?: string | undefined
800
+ readonly port?: number | undefined
801
+ readonly dir?: string | undefined
802
+ readonly json?: boolean | undefined
803
+ }
804
+
805
+ /** A diagnostic, or `NIFRA_NONE` with why there was nothing to explain. */
806
+ export type ExplainOutput =
807
+ | (Diagnostic & { readonly entry?: object; readonly note?: string })
808
+ | { code: "NIFRA_NONE"; message: string }
809
+
810
+ const explained = (out: ExplainOutput): out is Diagnostic => out.code !== "NIFRA_NONE"
811
+
812
+ export const explainSpec: CommandSpec<ExplainInput, ExplainOutput> = {
813
+ name: "explain",
814
+ summary:
815
+ "Turn an error into a structured diagnostic: its code, the frame in your source with a codeframe, and the cause and fix when nifra recognises it. With no message, the running dev server's latest error.",
816
+ input: {
817
+ jsonSchema: {
818
+ type: "object",
819
+ properties: {
820
+ error: { type: "string", description: "The error message to explain." },
821
+ stack: {
822
+ type: "string",
823
+ description: "Its stack trace, for the codeframe and source frame.",
824
+ },
825
+ name: { type: "string", description: "Its class name, such as TypeError." },
826
+ port: SHARED_PROPERTIES.port,
827
+ dir: { type: "string", description: "The app directory." },
828
+ json: { type: "boolean" },
829
+ },
830
+ additionalProperties: false,
831
+ },
832
+ parse: (value) => {
833
+ const raw = fields(value)
834
+ return {
835
+ error: text(raw, "error"),
836
+ stack: text(raw, "stack"),
837
+ name: text(raw, "name"),
838
+ port: count(raw, "port"),
839
+ dir: text(raw, "dir"),
840
+ json: flag(raw, "json"),
841
+ }
842
+ },
843
+ },
844
+ output: {
845
+ version: 1,
846
+ jsonSchema: {
847
+ type: "object",
848
+ properties: { code: { type: "string" }, message: { type: "string" } },
849
+ required: ["code", "message"],
850
+ },
851
+ parse: (value) => {
852
+ if (isDiagnostic(value)) return value
853
+ if (
854
+ typeof value === "object" &&
855
+ value !== null &&
856
+ "code" in value &&
857
+ value.code === "NIFRA_NONE" &&
858
+ "message" in value &&
859
+ typeof value.message === "string"
860
+ )
861
+ return { code: "NIFRA_NONE", message: value.message }
862
+ throw new TypeError("explain output must be a diagnostic or NIFRA_NONE")
863
+ },
864
+ },
865
+ transports: ["cli"],
866
+ stability: "experimental",
867
+ argv: {
868
+ positionals: ["error"],
869
+ flags: [
870
+ { name: "stack", field: "stack", type: "string" },
871
+ { name: "name", field: "name", type: "string" },
872
+ { name: "port", field: "port", type: "number" },
873
+ { name: "dir", field: "dir", type: "string" },
874
+ { name: "json", field: "json", type: "boolean" },
875
+ ],
876
+ },
877
+ async run(input, ctx) {
878
+ const root = resolve(ctx.cwd, input.dir ?? ".")
879
+ if (input.error === undefined && input.stack === undefined) {
880
+ return explainLatest(root, input.port, ctx.signal)
881
+ }
882
+ const { buildDiagnostic } = await import("@nifrajs/web/diagnostic")
883
+ const { explainSourceGate } = await import("./mcp-exec.ts")
884
+ const error = new Error(input.error ?? "")
885
+ if (input.name !== undefined) error.name = input.name
886
+ // Without --stack the frames would be this command's own, not the app's.
887
+ error.stack = input.stack ?? `${error.name}: ${error.message}`
888
+ return buildDiagnostic(error, { root, showSource: explainSourceGate(root) })
889
+ },
890
+ render: (out) => {
891
+ if (!explained(out)) return [out.message]
892
+ const at = topFrame(out)
893
+ return [
894
+ `${out.code} ${headline(out)}`,
895
+ ...(at === undefined ? [] : [` at ${at}`]),
896
+ ...(out.codeframe?.lines ?? []).map(
897
+ (line) => ` ${line.caret ? ">" : " "} ${String(line.number).padStart(4)} | ${line.text}`,
898
+ ),
899
+ ...(out.cause === undefined ? [] : [` cause: ${out.cause}`]),
900
+ ...(out.fix === undefined ? [] : [` fix: ${out.fix}`]),
901
+ ]
902
+ },
903
+ exitCode: (out) => (explained(out) ? 0 : 2),
904
+ json: (out) => out,
905
+ }
906
+
796
907
  // ---------------------------------------------------------------------------------------------------
797
908
  // nifra_explain / nifra_inspect reads
798
909
  // ---------------------------------------------------------------------------------------------------
@@ -802,7 +913,7 @@ export async function explainLatest(
802
913
  cwd: string,
803
914
  port: number | undefined,
804
915
  signal?: AbortSignal,
805
- ): Promise<unknown> {
916
+ ): Promise<ExplainOutput> {
806
917
  const { findDevServer, describeLookup, readDevFeed } = await import("./dev-server-client.ts")
807
918
  const lookup = await findDevServer(cwd, { port }, signal)
808
919
  if (lookup.status === "live") {
@@ -7,6 +7,8 @@ import { resolveWorkspaceLinkedPackage } from "./workspace-link.ts"
7
7
  export interface FixRecipe {
8
8
  readonly id: string
9
9
  readonly description: string
10
+ /** Run only by `nifra fix --code <code>`: the change it makes is a decision a person reviews first. */
11
+ readonly onlyWhenNamed?: true
10
12
  apply(root: string, diagnostic: Diagnostic): Promise<readonly string[]>
11
13
  readonly verify: string
12
14
  }
@@ -84,22 +86,39 @@ registerFixRecipe({
84
86
  description:
85
87
  "Serve the backend over HTTP at /api by exporting apiPrefix from the framework config.",
86
88
  verify: "nifra check",
87
- async apply(root, diagnostic) {
88
- if (diagnostic.file === undefined) return []
89
- const path = await resolveInsideProject(root, diagnostic.file)
90
- if (path === undefined) return []
91
- const { withDefaultApiPrefix } = await import("./rules/pages.ts")
92
- const file = Bun.file(path)
93
- const next = withDefaultApiPrefix((await file.exists()) ? await file.text() : undefined)
94
- if (next === undefined) return []
95
- await writeFile(path, next, "utf8")
96
- return [diagnostic.file]
89
+ async apply(root) {
90
+ const { CONFIG_FILE, FRAMEWORK_FILE } = await import("./app-files.ts")
91
+ const { apiPrefixFix } = await import("./rules/pages.ts")
92
+ const frameworkPath = await resolveInsideProject(root, FRAMEWORK_FILE)
93
+ const configPath = await resolveInsideProject(root, CONFIG_FILE)
94
+ if (frameworkPath === undefined || configPath === undefined) return []
95
+ const source = async (path: string): Promise<string | undefined> => {
96
+ const file = Bun.file(path)
97
+ return (await file.exists()) ? await file.text() : undefined
98
+ }
99
+ const next = apiPrefixFix({
100
+ framework: await source(frameworkPath),
101
+ config: await source(configPath),
102
+ })
103
+ const edits: readonly (readonly [string, string, string | undefined])[] = [
104
+ [FRAMEWORK_FILE, frameworkPath, next.framework],
105
+ [CONFIG_FILE, configPath, next.config],
106
+ ]
107
+ const changed: string[] = []
108
+ for (const [file, path, text] of edits) {
109
+ if (text === undefined) continue
110
+ await writeFile(path, text, "utf8")
111
+ changed.push(file)
112
+ }
113
+ return changed
97
114
  },
98
115
  })
99
116
 
100
117
  registerFixRecipe({
101
118
  id: "contracts.snapshot",
102
119
  description: "Refresh the opt-in contract lock after reviewing an intentional route change.",
120
+ // Rewriting the lock accepts whatever the routes changed to, which is the review the lock exists for.
121
+ onlyWhenNamed: true,
103
122
  verify: "nifra contracts check",
104
123
  async apply(root) {
105
124
  const { snapshotContracts } = await import("./contracts.ts")
@@ -158,6 +177,78 @@ registerFixRecipe({
158
177
  },
159
178
  })
160
179
 
180
+ registerFixRecipe({
181
+ id: "route.split",
182
+ description:
183
+ "Move a route frontend file's server-only exports, and the code only they use, into its x.backend.ts.",
184
+ verify: "nifra check",
185
+ async apply(root, diagnostic) {
186
+ const file = diagnostic.file
187
+ if (file === undefined || (await resolveInsideProject(root, file)) === undefined) return []
188
+ const { splitRouteFile } = await import("./migrate-layout.ts")
189
+ return splitRouteFile(root, file)
190
+ },
191
+ })
192
+
193
+ registerFixRecipe({
194
+ id: "layout.migrate",
195
+ description:
196
+ "Move the app onto the frontend/backend layout, as `nifra migrate layout --write` does.",
197
+ onlyWhenNamed: true,
198
+ verify: "nifra check --lints-only",
199
+ async apply(root) {
200
+ const { migrateLayout } = await import("./migrate-layout.ts")
201
+ const result = await migrateLayout(root, { write: true })
202
+ return [
203
+ ...new Set([
204
+ ...result.moves.map((move) => move.to),
205
+ ...result.splits.flatMap((split) => [split.file, split.backend]),
206
+ ...result.rewritten,
207
+ ...result.tooling.map((edit) => edit.file),
208
+ ]),
209
+ ].sort()
210
+ },
211
+ })
212
+
213
+ registerFixRecipe({
214
+ id: "schemas.draft",
215
+ description:
216
+ "Draft the missing loader or action output schema from the function's return type, for the author to review.",
217
+ verify: "nifra check --lints-only",
218
+ async apply(root, diagnostic) {
219
+ const file = diagnostic.file
220
+ if (file === undefined) return []
221
+ const path = await resolveInsideProject(root, file)
222
+ if (path === undefined) return []
223
+ const schema = diagnostic.evidence
224
+ ?.find((line) => line.startsWith("missing: "))
225
+ ?.slice("missing: ".length)
226
+ if (schema !== "loaderOutput" && schema !== "actionOutput") return []
227
+ const { planOutputSchemas } = await import("./output-schemas.ts")
228
+ const source = await readFile(path, "utf8")
229
+ // Only this declaration: other schemas in the file, generated or hand-written, stay as they are.
230
+ const plan = await planOutputSchemas(
231
+ root,
232
+ [file],
233
+ (read) => (read === file ? source : undefined),
234
+ {
235
+ draft: (at, name) => at === file && name === schema,
236
+ },
237
+ )
238
+ const blocked =
239
+ plan.warnings.length > 0
240
+ ? plan.warnings.join("; ")
241
+ : plan.entries.find((entry) => entry.schema === schema && entry.reason !== undefined)
242
+ ?.reason
243
+ if (blocked !== undefined)
244
+ throw new Error(`[nifra] no ${schema} was drafted in ${file}: ${blocked}`)
245
+ const drafted = plan.edits.get(file)
246
+ if (drafted === undefined) return []
247
+ await writeFile(path, drafted, "utf8")
248
+ return [file]
249
+ },
250
+ })
251
+
161
252
  /**
162
253
  * How many typecheck passes the reserved-segment codemod will run. A nested collision
163
254
  * (`/api/delete/then`) only reveals its second site once the first is rewritten, so one pass is not
@@ -258,7 +258,7 @@ function nameMember(
258
258
  return { text: rewritten, named }
259
259
  }
260
260
 
261
- interface PinScan {
261
+ export interface PinScan {
262
262
  /** The input with every stale pin rewritten - every other character untouched. */
263
263
  readonly text: string
264
264
  /** Pins found, current or stale. */
@@ -302,8 +302,9 @@ function repinRegistry(text: string, target: string, member?: string): PinScan {
302
302
  return named ? { ...scan, text: rewritten, named: member } : scan
303
303
  }
304
304
 
305
- /** Every file that carries the pin, and the part of it --sync-mcp may rewrite. */
306
- const PIN_SITES: readonly {
305
+ /** Every file that carries the pin, and the part of it --sync-mcp and `nifra upgrade --write` may
306
+ * rewrite. */
307
+ export const PIN_SITES: readonly {
307
308
  readonly path: string
308
309
  readonly repin: (text: string, target: string, member?: string) => PinScan
309
310
  }[] = [
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The module path a nifra config names, read from its source without running it. The loader reads a
3
+ * string from the app root (`"./src/client.tsx"`, or an alias or package name the bundler resolves);
4
+ * `resolve`/`join` from `node:path` of the config's own folder and literal segments, or
5
+ * `fileURLToPath(new URL("x", import.meta.url))`, names a file relative to the config itself.
6
+ */
7
+ import { posix } from "node:path"
8
+ import type * as TS from "typescript"
9
+
10
+ export interface StaticConfigPath {
11
+ /** `"app"`: read from the app root as the loader reads a string. `"config"`: from the config's folder. */
12
+ readonly base: "app" | "config"
13
+ readonly path: string
14
+ /** The string literal's text, quotes excluded, for an `"app"` path: [start, end). */
15
+ readonly span?: readonly [number, number]
16
+ }
17
+
18
+ const OWN_DIR = new Set(["import.meta.dir", "import.meta.dirname", "__dirname"])
19
+ const PATH_MODULES = new Set(["node:path", "path", "node:path/posix", "path/posix"])
20
+ const URL_MODULES = new Set(["node:url", "url"])
21
+
22
+ interface PathBindings {
23
+ /** Local names of `resolve` and `join`. */
24
+ readonly calls: ReadonlySet<string>
25
+ /** Default and namespace imports of `node:path`. */
26
+ readonly namespaces: ReadonlySet<string>
27
+ readonly fileURLToPath: ReadonlySet<string>
28
+ }
29
+
30
+ function pathBindings(ts: typeof TS, sf: TS.SourceFile): PathBindings {
31
+ const calls = new Set<string>()
32
+ const namespaces = new Set<string>()
33
+ const fileURLToPath = new Set<string>()
34
+ for (const statement of sf.statements) {
35
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
36
+ continue
37
+ const from = statement.moduleSpecifier.text
38
+ const clause = statement.importClause
39
+ if (clause === undefined || clause.isTypeOnly) continue
40
+ const isPath = PATH_MODULES.has(from)
41
+ if (!isPath && !URL_MODULES.has(from)) continue
42
+ if (isPath && clause.name !== undefined) namespaces.add(clause.name.text)
43
+ const bindings = clause.namedBindings
44
+ if (bindings === undefined) continue
45
+ if (ts.isNamespaceImport(bindings)) {
46
+ if (isPath) namespaces.add(bindings.name.text)
47
+ continue
48
+ }
49
+ for (const element of bindings.elements) {
50
+ if (element.isTypeOnly) continue
51
+ const imported = (element.propertyName ?? element.name).text
52
+ if (isPath && (imported === "resolve" || imported === "join")) calls.add(element.name.text)
53
+ else if (!isPath && imported === "fileURLToPath") fileURLToPath.add(element.name.text)
54
+ }
55
+ }
56
+ return { calls, namespaces, fileURLToPath }
57
+ }
58
+
59
+ const unwrap = (ts: typeof TS, node: TS.Expression): TS.Expression => {
60
+ let current = node
61
+ while (
62
+ ts.isAsExpression(current) ||
63
+ ts.isSatisfiesExpression(current) ||
64
+ ts.isParenthesizedExpression(current) ||
65
+ ts.isNonNullExpression(current)
66
+ )
67
+ current = current.expression
68
+ return current
69
+ }
70
+
71
+ const isLiteral = (
72
+ ts: typeof TS,
73
+ node: TS.Node | undefined,
74
+ ): node is TS.StringLiteral | TS.NoSubstitutionTemplateLiteral =>
75
+ node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
76
+
77
+ /** A readable static path, normalized; `undefined` for an absolute one or a URL. */
78
+ function relativeSegments(segments: readonly string[]): string | undefined {
79
+ if (segments.some((segment) => segment.startsWith("/") || /^[a-z][a-z\d+.-]*:/i.test(segment)))
80
+ return undefined
81
+ const path = posix.normalize(posix.join(...segments))
82
+ return path === "." ? undefined : path
83
+ }
84
+
85
+ /**
86
+ * Create a reader for one config file. It answers for an expression the config exports, so a path
87
+ * built any other way (a variable, `process.cwd()`, a template with substitutions) is `undefined`.
88
+ */
89
+ export function configPathReader(
90
+ ts: typeof TS,
91
+ sf: TS.SourceFile,
92
+ ): (node: TS.Expression | undefined) => StaticConfigPath | undefined {
93
+ const bindings = pathBindings(ts, sf)
94
+ const isPathCall = (callee: TS.Expression): boolean =>
95
+ ts.isIdentifier(callee)
96
+ ? bindings.calls.has(callee.text)
97
+ : ts.isPropertyAccessExpression(callee) &&
98
+ ts.isIdentifier(callee.expression) &&
99
+ bindings.namespaces.has(callee.expression.text) &&
100
+ (callee.name.text === "resolve" || callee.name.text === "join")
101
+ return (node) => {
102
+ if (node === undefined) return undefined
103
+ const value = unwrap(ts, node)
104
+ if (isLiteral(ts, value))
105
+ return { base: "app", path: value.text, span: [value.getStart(sf) + 1, value.getEnd() - 1] }
106
+ if (!ts.isCallExpression(value)) return undefined
107
+ const [first, ...rest] = value.arguments
108
+ if (isPathCall(value.expression)) {
109
+ if (first === undefined || !OWN_DIR.has(first.getText(sf)) || rest.length === 0)
110
+ return undefined
111
+ const segments: string[] = []
112
+ for (const segment of rest) {
113
+ if (!isLiteral(ts, segment)) return undefined
114
+ segments.push(segment.text)
115
+ }
116
+ const path = relativeSegments(segments)
117
+ return path === undefined ? undefined : { base: "config", path }
118
+ }
119
+ if (
120
+ ts.isIdentifier(value.expression) &&
121
+ bindings.fileURLToPath.has(value.expression.text) &&
122
+ first !== undefined &&
123
+ ts.isNewExpression(first) &&
124
+ first.expression.getText(sf) === "URL" &&
125
+ first.arguments?.length === 2 &&
126
+ first.arguments[1]?.getText(sf) === "import.meta.url" &&
127
+ isLiteral(ts, first.arguments[0])
128
+ ) {
129
+ const path = relativeSegments([first.arguments[0].text])
130
+ return path === undefined ? undefined : { base: "config", path }
131
+ }
132
+ return undefined
133
+ }
134
+ }
135
+
136
+ /** The `export const <name> = ...` initializer in a config, or `undefined`. */
137
+ export function exportedInitializer(
138
+ ts: typeof TS,
139
+ sf: TS.SourceFile,
140
+ name: string,
141
+ ): TS.Expression | undefined {
142
+ for (const statement of sf.statements) {
143
+ if (
144
+ !ts.isVariableStatement(statement) ||
145
+ !statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
146
+ )
147
+ continue
148
+ for (const declaration of statement.declarationList.declarations) {
149
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name)
150
+ return declaration.initializer
151
+ }
152
+ }
153
+ return undefined
154
+ }
@@ -0,0 +1,15 @@
1
+ import { existsSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
3
+
4
+ /** The manifest of the installed package `name`, as Node finds it: the nearest `node_modules/<name>`
5
+ * at or above the importing file. A workspace link resolves through its symlink. */
6
+ export function installedManifest(fromFile: string, name: string): string | undefined {
7
+ let dir = dirname(fromFile)
8
+ for (;;) {
9
+ const manifest = join(dir, "node_modules", name, "package.json")
10
+ if (existsSync(manifest)) return manifest
11
+ const parent = dirname(dir)
12
+ if (parent === dir) return undefined
13
+ dir = parent
14
+ }
15
+ }
@@ -22,6 +22,12 @@ export interface ProjectResolver extends SpecifierResolver {
22
22
  readonly respell: (fromFile: string, specifier: string, file: string) => string | undefined
23
23
  /** The `paths` patterns of the nearest tsconfig at or above `dir`, after its `extends` chain. */
24
24
  readonly pathPatterns: (dir: string) => readonly string[]
25
+ /**
26
+ * Whether the compiler settings for `file` keep an import whose named specifiers are all
27
+ * type-only, as `import {} from "x"` (`verbatimModuleSyntax`). `true` when its tsconfig chain
28
+ * cannot be read, so a scan that trusts the answer keeps the edge.
29
+ */
30
+ readonly keepsTypeOnlyImports: (file: string) => boolean
25
31
  }
26
32
 
27
33
  /** How an alias specifier reached its file: what a rewrite through the same alias has to fit. */
@@ -59,8 +65,19 @@ interface PathsConfig {
59
65
  readonly pathsBase: string
60
66
  readonly paths: ReadonlyArray<readonly [string, readonly string[]]>
61
67
  readonly baseUrl: string | undefined
68
+ /** The {@link IMPORT_EMIT_OPTIONS} the chain sets, later configs winning; `undefined` when part of
69
+ * the chain could not be read. */
70
+ readonly importEmit: Readonly<Record<string, unknown>> | undefined
62
71
  }
63
72
 
73
+ // The options that decide whether an import left with only type specifiers is emitted. The last two
74
+ // are removed in TypeScript 6 but still read by older compilers and bundlers.
75
+ const IMPORT_EMIT_OPTIONS = [
76
+ "verbatimModuleSyntax",
77
+ "importsNotUsedAsValues",
78
+ "preserveValueImports",
79
+ ]
80
+
64
81
  /** JSON with comments and trailing commas, as tsconfig files are written. Throws on anything else. */
65
82
  export function parseJsonc(text: string): unknown {
66
83
  let code = ""
@@ -101,8 +118,14 @@ const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =
101
118
  ? Object.fromEntries(Object.entries(value))
102
119
  : undefined
103
120
 
104
- const isFile = (path: string): boolean =>
105
- statSync(path, { throwIfNoEntry: false })?.isFile() ?? false
121
+ /** Whether `path` is a file. A path through a file (`a.ts/index.ts`) is none, not an error. */
122
+ export const isFile = (path: string): boolean => {
123
+ try {
124
+ return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false
125
+ } catch {
126
+ return false
127
+ }
128
+ }
106
129
 
107
130
  const real = (path: string): string => {
108
131
  try {
@@ -113,18 +136,18 @@ const real = (path: string): string => {
113
136
  }
114
137
 
115
138
  /** The file a path names: as written, its source for a `.js` name, with an extension, or an index. */
116
- function probe(base: string): string | undefined {
117
- if (isFile(base)) return base
139
+ function probe(base: string, exists: (path: string) => boolean): string | undefined {
140
+ if (exists(base)) return base
118
141
  const ext = extname(base)
119
142
  for (const source of SOURCE_FOR[ext] ?? []) {
120
143
  const candidate = `${base.slice(0, -ext.length)}${source}`
121
- if (isFile(candidate)) return candidate
144
+ if (exists(candidate)) return candidate
122
145
  }
123
146
  for (const extension of EXTENSIONS)
124
- if (isFile(`${base}${extension}`)) return `${base}${extension}`
147
+ if (exists(`${base}${extension}`)) return `${base}${extension}`
125
148
  for (const extension of EXTENSIONS) {
126
149
  const index = join(base, `index${extension}`)
127
- if (isFile(index)) return index
150
+ if (exists(index)) return index
128
151
  }
129
152
  return undefined
130
153
  }
@@ -164,7 +187,14 @@ function matchPattern(
164
187
  return best
165
188
  }
166
189
 
167
- export function createSpecifierResolver(): ProjectResolver {
190
+ export interface SpecifierResolverOptions {
191
+ /** Whether a module file exists, for resolving against a planned layout rather than the disk.
192
+ * Config files (tsconfig, package.json) are still read from the disk. */
193
+ readonly moduleExists?: (path: string) => boolean
194
+ }
195
+
196
+ export function createSpecifierResolver(options: SpecifierResolverOptions = {}): ProjectResolver {
197
+ const exists = options.moduleExists ?? isFile
168
198
  const configs = new Map<string, PathsConfig | undefined>()
169
199
  const tsconfigByDir = new Map<string, string | undefined>()
170
200
  const manifests = new Map<string, Readonly<Record<string, unknown>> | undefined>()
@@ -209,7 +239,7 @@ export function createSpecifierResolver(): ProjectResolver {
209
239
  }
210
240
  }
211
241
 
212
- /** The `paths` and `baseUrl` a config ends up with after its `extends` chain. */
242
+ /** The `paths`, `baseUrl` and import-emit options a config ends up with after its `extends` chain. */
213
243
  const pathsConfig = (
214
244
  file: string,
215
245
  seen: ReadonlySet<string> = new Set(),
@@ -218,6 +248,7 @@ export function createSpecifierResolver(): ProjectResolver {
218
248
  if (seen.has(file) || seen.size >= MAX_EXTENDS) return undefined
219
249
  const config = readJsonc(file)
220
250
  let inherited: PathsConfig | undefined
251
+ let importEmit: Record<string, unknown> | undefined = {}
221
252
  const extended = config?.extends
222
253
  for (const name of typeof extended === "string"
223
254
  ? [extended]
@@ -227,15 +258,24 @@ export function createSpecifierResolver(): ProjectResolver {
227
258
  if (typeof name !== "string") continue
228
259
  const base = extendedConfig(file, name)
229
260
  const parent = base === undefined ? undefined : pathsConfig(base, new Set([...seen, file]))
261
+ importEmit =
262
+ importEmit === undefined || parent?.importEmit === undefined
263
+ ? undefined
264
+ : { ...importEmit, ...parent.importEmit }
230
265
  if (parent === undefined) continue
231
266
  inherited = {
232
267
  paths: parent.paths.length > 0 ? parent.paths : (inherited?.paths ?? []),
233
268
  baseUrl: parent.baseUrl ?? inherited?.baseUrl,
234
269
  pathsBase:
235
270
  parent.paths.length > 0 ? parent.pathsBase : (inherited?.pathsBase ?? parent.pathsBase),
271
+ importEmit,
236
272
  }
237
273
  }
238
274
  const options = record(config?.compilerOptions)
275
+ if (importEmit !== undefined) {
276
+ for (const key of IMPORT_EMIT_OPTIONS)
277
+ if (options?.[key] !== undefined) importEmit = { ...importEmit, [key]: options[key] }
278
+ }
239
279
  const baseUrl =
240
280
  typeof options?.baseUrl === "string"
241
281
  ? join(dirname(file), options.baseUrl)
@@ -255,7 +295,7 @@ export function createSpecifierResolver(): ProjectResolver {
255
295
  )
256
296
  const pathsBase =
257
297
  baseUrl ?? (ownPaths === undefined ? (inherited?.pathsBase ?? dirname(file)) : dirname(file))
258
- const resolved = config === undefined ? undefined : { paths, baseUrl, pathsBase }
298
+ const resolved = config === undefined ? undefined : { paths, baseUrl, pathsBase, importEmit }
259
299
  configs.set(file, resolved)
260
300
  return resolved
261
301
  }
@@ -315,7 +355,7 @@ export function createSpecifierResolver(): ProjectResolver {
315
355
  if (template === undefined) return undefined
316
356
  const base = dirname(manifestPath)
317
357
  const written = join(base, template.replaceAll(STAR, match.star ?? ""))
318
- const found = probe(written)
358
+ const found = probe(written, exists)
319
359
  return found === undefined
320
360
  ? undefined
321
361
  : { key: match.pattern, keys, base, templates: [template], written, found }
@@ -335,7 +375,7 @@ export function createSpecifierResolver(): ProjectResolver {
335
375
  if (match === undefined) {
336
376
  if (config.baseUrl === undefined) return undefined
337
377
  const written = join(config.baseUrl, specifier)
338
- const found = probe(written)
378
+ const found = probe(written, exists)
339
379
  return found === undefined
340
380
  ? undefined
341
381
  : { key: undefined, keys, base: config.baseUrl, templates: [STAR], written, found }
@@ -346,7 +386,7 @@ export function createSpecifierResolver(): ProjectResolver {
346
386
  )
347
387
  for (const target of targets) {
348
388
  const written = join(config.pathsBase, target.replaceAll("*", match.star ?? ""))
349
- const found = probe(written)
389
+ const found = probe(written, exists)
350
390
  if (found === undefined) continue
351
391
  const templates = targets.map((t) => t.replaceAll("*", STAR))
352
392
  return { key: match.pattern, keys, base: config.pathsBase, templates, written, found }
@@ -401,7 +441,20 @@ export function createSpecifierResolver(): ProjectResolver {
401
441
  return (config?.paths ?? []).map(([pattern]) => pattern)
402
442
  }
403
443
 
404
- return Object.assign(resolve, { respell, pathPatterns })
444
+ const keepsTypeOnlyImports = (file: string): boolean => {
445
+ const tsconfig = tsconfigFor(dirname(file))
446
+ if (tsconfig === undefined) return false
447
+ const emit = pathsConfig(tsconfig)?.importEmit
448
+ if (emit === undefined) return true
449
+ return (
450
+ emit.verbatimModuleSyntax === true ||
451
+ emit.preserveValueImports === true ||
452
+ emit.importsNotUsedAsValues === "preserve" ||
453
+ emit.importsNotUsedAsValues === "error"
454
+ )
455
+ }
456
+
457
+ return Object.assign(resolve, { respell, pathPatterns, keepsTypeOnlyImports })
405
458
 
406
459
  function resolveFrom(fromFile: string, specifier: string): string | undefined {
407
460
  let found: string | undefined
@@ -411,8 +464,8 @@ export function createSpecifierResolver(): ProjectResolver {
411
464
  specifier === "." ||
412
465
  specifier === ".."
413
466
  )
414
- found = probe(join(dirname(fromFile), specifier))
415
- else if (isAbsolute(specifier)) found = probe(specifier)
467
+ found = probe(join(dirname(fromFile), specifier), exists)
468
+ else if (isAbsolute(specifier)) found = probe(specifier, exists)
416
469
  // A `#` name is a package.json import first; a tsconfig may still map it.
417
470
  else if (specifier.startsWith("#"))
418
471
  found = viaImports(fromFile, specifier) ?? viaTsconfig(fromFile, specifier)