@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
package/src/cli.ts CHANGED
@@ -126,6 +126,9 @@ Usage:
126
126
  one left. Exits 1 while current-code errors are open.
127
127
  nifra logs [--since <n>] [--level <l>] [--grep <s>] The dev server's console, server and
128
128
  browser, each line tagged with its request.
129
+ nifra explain [<message>] [--stack <s>] [--name <n>] A structured diagnostic for an error: its
130
+ code, the frame in your source, and the cause and fix when
131
+ nifra knows them. No message: the dev server's latest error.
129
132
  nifra cdn-check <url> Check a deployed page behind a CDN: served from cache, no
130
133
  internal or CDN-only headers reaching the visitor, and soft
131
134
  navigations get page data, not the cached document. Exits 1
@@ -273,20 +276,23 @@ Usage:
273
276
  this CLI prints the command for that release's CLI. Fail-closed
274
277
  on an unknown version or a rollback (--allow-downgrade
275
278
  overrides). Deterministic + idempotent.
276
- nifra migrate layout [--write] [--json]
279
+ nifra migrate layout [--write] [--zones] [--json]
277
280
  Move an app onto the frontend/backend split: split each route
278
281
  into x.tsx + x.backend.ts, fold _middleware.ts into
279
282
  _layout.backend.ts, move backend.ts/framework.ts under backend/,
280
- and zone the other modules into frontend/, backend/ or shared/.
281
- Dry-run by default; --write applies.
283
+ and zone the other modules into frontend/, backend/ or shared/
284
+ (--zones keeps them in place and lists them in package.json
285
+ "nifra.zones"). Dry-run by default; --write applies.
282
286
  nifra types [--check] [--json] Generate each route's ./+types module under .nifra/types: its
283
287
  params, the data its output schemas let through, and api typed
284
288
  by backend/app.ts. dev, build and check refresh them too;
285
289
  --check fails when one is stale.
286
- nifra schemas [--write] [--json] Draft loaderOutput/actionOutput from each loader's and
290
+ nifra schemas [--write] [--check] [--json]
291
+ Draft loaderOutput/actionOutput from each loader's and
287
292
  action's return type for review; --write adds the missing
288
293
  ones (marked nifra:generated) and refreshes generated ones.
289
- Lists the returned fields a hand-written schema drops.
294
+ Lists the returned fields a hand-written schema drops;
295
+ --check fails on a missing, stale or any-typed one.
290
296
  nifra port [--target <t>] [--json] Portability linter: print a feature × deploy-target capability
291
297
  [--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
292
298
  globals, node: builtins) with file:line evidence. --target auto-
@@ -315,7 +321,7 @@ set in the process environment is never overwritten by a file.
315
321
  ${renderCommandCatalogHelp()}`
316
322
 
317
323
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
318
- const CLI_VERSION = "5.0.0"
324
+ const CLI_VERSION = "5.1.0"
319
325
 
320
326
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
321
327
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -366,6 +372,14 @@ export function formatCliError(err: unknown): string {
366
372
  return String(err)
367
373
  }
368
374
 
375
+ /** A failed command's message on stderr and, for a `--json` caller parsing stdout, as JSON there. */
376
+ function reportFailure(err: unknown, argv: readonly string[]): void {
377
+ const message = formatCliError(err)
378
+ console.error(message)
379
+ if (argv.includes("--json") || argv.includes("--json=true"))
380
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2))
381
+ }
382
+
369
383
  async function dev(app: LoadedApp, flags: Flags): Promise<void> {
370
384
  if (app.framework.componentPipeline !== undefined) {
371
385
  if (flags.bun)
@@ -954,35 +968,73 @@ async function start(app: LoadedApp, flags: Flags): Promise<void> {
954
968
  await import(serverFile)
955
969
  }
956
970
 
971
+ const VALUE_FLAGS: Readonly<Record<string, "port" | "out" | "target">> = {
972
+ "--port": "port",
973
+ "-p": "port",
974
+ "--out": "out",
975
+ "--target": "target",
976
+ }
977
+
978
+ type SwitchField = "poll" | "report" | "vite" | "bun" | "allowDuplicateIdentity" | "noIndicator"
979
+
980
+ const SWITCH_FLAGS: Readonly<Record<string, SwitchField>> = {
981
+ "--poll": "poll",
982
+ "--report": "report",
983
+ "--vite": "vite",
984
+ "--bun": "bun",
985
+ "--allow-duplicate-identity": "allowDuplicateIdentity",
986
+ "--no-indicator": "noIndicator",
987
+ }
988
+
989
+ /** The flags of `nifra dev`, `build` and `start`, as `--flag value` or `--flag=value`. Anything else is
990
+ * refused: an ignored `--target=vercel` would build the default target and exit 0. */
957
991
  export function parseFlags(args: readonly string[]): Flags {
958
992
  // Port precedence: `--port`/`-p` (most specific) > `PORT` env > the framework default. The default is
959
993
  // the SAME uncommon port for `nifra dev` and `nifra start` (DEFAULT_DEV_PORT) so a project's URL is
960
994
  // stable across commands and doesn't collide with the usual 3000/5173/8080 crowd.
961
995
  let port = Number(Bun.env.PORT ?? DEFAULT_DEV_PORT)
962
996
  let out: string | undefined
963
- let poll = Bun.env.CHOKIDAR_USEPOLLING === "1"
964
997
  let target: string | undefined
965
- let report = false
966
- let vite = false
967
- let bun = false
968
- let allowDuplicateIdentity = false
969
- let noIndicator = false
998
+ const switches: Record<SwitchField, boolean> = {
999
+ poll: Bun.env.CHOKIDAR_USEPOLLING === "1",
1000
+ report: false,
1001
+ vite: false,
1002
+ bun: false,
1003
+ allowDuplicateIdentity: false,
1004
+ noIndicator: false,
1005
+ }
970
1006
  for (let i = 0; i < args.length; i++) {
971
- const a = args[i]
972
- if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
973
- else if (a === "--out" && args[i + 1]) out = args[++i] as string
974
- else if (a === "--poll") poll = true
975
- else if (a === "--target" && args[i + 1]) target = args[++i] as string
976
- else if (a === "--report") report = true
977
- else if (a === "--vite") vite = true
978
- else if (a === "--bun") bun = true
979
- else if (a === "--allow-duplicate-identity") allowDuplicateIdentity = true
980
- else if (a === "--no-indicator") noIndicator = true
1007
+ const arg = args[i] as string
1008
+ const equals = arg.startsWith("--") ? arg.indexOf("=") : -1
1009
+ const name = equals === -1 ? arg : arg.slice(0, equals)
1010
+ const valueField = Object.hasOwn(VALUE_FLAGS, name) ? VALUE_FLAGS[name] : undefined
1011
+ if (valueField !== undefined) {
1012
+ const value = equals === -1 ? args[++i] : arg.slice(equals + 1)
1013
+ if (value === undefined || value === "" || (equals === -1 && value.startsWith("-")))
1014
+ throw new Error(`[nifra] ${name} needs a value`)
1015
+ if (valueField === "port") port = Number(value)
1016
+ else if (valueField === "out") out = value
1017
+ else target = value
1018
+ continue
1019
+ }
1020
+ const switchField = Object.hasOwn(SWITCH_FLAGS, name) ? SWITCH_FLAGS[name] : undefined
1021
+ if (switchField !== undefined) {
1022
+ const value = equals === -1 ? "true" : arg.slice(equals + 1)
1023
+ if (value !== "true" && value !== "false")
1024
+ throw new Error(`[nifra] ${name} takes no value (or =true / =false)`)
1025
+ switches[switchField] = value === "true"
1026
+ continue
1027
+ }
1028
+ throw new Error(
1029
+ arg.startsWith("-")
1030
+ ? `[nifra] unknown option: ${arg} - see \`nifra help\``
1031
+ : `[nifra] unexpected argument: ${arg} - see \`nifra help\``,
1032
+ )
981
1033
  }
982
1034
  if (!Number.isFinite(port) || port < 0 || port > 65535) {
983
1035
  throw new Error(`[nifra] invalid --port: ${port}`)
984
1036
  }
985
- return { port, out, poll, target, report, vite, bun, allowDuplicateIdentity, noIndicator }
1037
+ return { port, out, target, ...switches }
986
1038
  }
987
1039
 
988
1040
  /**
@@ -1190,7 +1242,7 @@ async function main(): Promise<void> {
1190
1242
  // A thrown error is nifra's own reporting path, not a silent abort: clear the sentinel so the
1191
1243
  // hint stays reserved for the case where the process died inside the app import.
1192
1244
  markReflecting(undefined)
1193
- console.error(formatCliError(err))
1245
+ reportFailure(err, argv)
1194
1246
  const candidate =
1195
1247
  typeof err === "object" && err !== null && "exitCode" in err
1196
1248
  ? (err as { readonly exitCode?: unknown }).exitCode
@@ -1253,6 +1305,7 @@ async function main(): Promise<void> {
1253
1305
  console.error(formatCliError(err))
1254
1306
  process.exitCode = 1
1255
1307
  }
1308
+ exitIfHeldOpen(command)
1256
1309
  return
1257
1310
  }
1258
1311
  // `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
@@ -1275,9 +1328,10 @@ async function main(): Promise<void> {
1275
1328
  })
1276
1329
  if (!ok) process.exitCode = 1
1277
1330
  } catch (err) {
1278
- console.error(formatCliError(err))
1331
+ reportFailure(err, argv)
1279
1332
  process.exitCode = 1
1280
1333
  }
1334
+ exitIfHeldOpen(command)
1281
1335
  return
1282
1336
  }
1283
1337
  // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) - like `check`/
@@ -1288,17 +1342,32 @@ async function main(): Promise<void> {
1288
1342
  process.exitCode = 1
1289
1343
  return
1290
1344
  }
1345
+ if (argv.includes("--help") || argv.includes("-h")) {
1346
+ console.log(HELP)
1347
+ return
1348
+ }
1291
1349
  const flags = parseFlags(argv.slice(1))
1292
- const app = await loadApp(process.cwd(), flags.out)
1293
- if (command !== "start") {
1294
- const { refreshRouteTypes, watchRouteTypes } = await import("./route-types.ts")
1295
- refreshRouteTypes(app.cwd)
1296
- // A relaunched `nifra dev --bun` child runs beside its parent, which already watches.
1297
- if (command === "dev" && process.env.NIFRA_BUN_DEV_TOKEN === undefined) watchRouteTypes(app.cwd)
1350
+ try {
1351
+ const app = await loadApp(process.cwd(), flags.out)
1352
+ if (command !== "start" && process.env.NIFRA_BUN_DEV_TOKEN === undefined) {
1353
+ const { unservedRoutesWarning } = await import("./introspect.ts")
1354
+ const unserved = unservedRoutesWarning(app)
1355
+ if (unserved !== undefined) console.warn(unserved)
1356
+ }
1357
+ if (command !== "start") {
1358
+ const { refreshRouteTypes, watchRouteTypes } = await import("./route-types.ts")
1359
+ refreshRouteTypes(app.cwd)
1360
+ // A relaunched `nifra dev --bun` child runs beside its parent, which already watches.
1361
+ if (command === "dev" && process.env.NIFRA_BUN_DEV_TOKEN === undefined)
1362
+ watchRouteTypes(app.cwd)
1363
+ }
1364
+ if (command === "dev") await dev(app, flags)
1365
+ else if (command === "build") await buildForTarget(app, flags.target, flags)
1366
+ else await start(app, flags)
1367
+ } finally {
1368
+ // `dev` and `start` keep serving after they return; a build is over.
1369
+ if (command === "build") exitIfHeldOpen(command)
1298
1370
  }
1299
- if (command === "dev") await dev(app, flags)
1300
- else if (command === "build") await buildForTarget(app, flags.target, flags)
1301
- else await start(app, flags)
1302
1371
  }
1303
1372
 
1304
1373
  /**
@@ -1485,7 +1554,7 @@ if (isMainModule() || isBunDevReexecChild() || hasCliCommandToken()) {
1485
1554
  try {
1486
1555
  await main()
1487
1556
  } catch (err) {
1488
- console.error(formatCliError(err))
1557
+ reportFailure(err, cliArgv())
1489
1558
  process.exitCode = 1
1490
1559
  }
1491
1560
  }
@@ -4,8 +4,10 @@
4
4
  */
5
5
  import { existsSync, readFileSync } from "node:fs"
6
6
  import { join } from "node:path"
7
+ import type * as TS from "typescript"
7
8
  import { CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
8
9
  import { stripComments } from "./check-scan.ts"
10
+ import { configPathReader, exportedInitializer } from "./internal/config-paths.ts"
9
11
  import type { SpecifierResolver } from "./internal/project-specifiers.ts"
10
12
 
11
13
  const CLIENT_MODULE_EXPORT =
@@ -34,28 +36,62 @@ export function appDirs(files: readonly string[]): string[] {
34
36
 
35
37
  /**
36
38
  * The file `clientModule` names in `app`, read the way the loader reads it: from `nifra.config.ts`
37
- * when it exists, else `backend/framework.ts`, and a relative path against the app root. `undefined`
38
- * for a package specifier, a value that is not a string literal, or a path to no file.
39
+ * when it exists, else `backend/framework.ts`; a string against the app root, and, with `ts`, a
40
+ * `resolve`/`join` of the config's own folder against that folder. `undefined` for a package
41
+ * specifier, a value built any other way, or a path to no file.
39
42
  */
40
43
  export function clientModuleRoot(
41
44
  root: string,
42
45
  app: string,
43
46
  resolve: SpecifierResolver,
47
+ ts?: typeof TS,
44
48
  ): ClientModuleRoot | undefined {
45
49
  const config = existsSync(join(root, app, CONFIG_FILE)) ? CONFIG_FILE : FRAMEWORK_FILE
46
50
  const path = join(root, app, config)
47
51
  if (!existsSync(path)) return undefined
48
52
  const text = readFileSync(path, "utf8")
49
- // `stripComments` keeps offsets, so the match index is a position in `text`.
50
- const match = CLIENT_MODULE_EXPORT.exec(stripComments(text))
51
- if (match === null) return undefined
52
- const specifier = match[1] ?? match[2] ?? ""
53
- const file = resolve(join(root, app, CONFIG_FILE), specifier)
53
+ const found = ts === undefined ? literalClientModule(text) : staticClientModule(ts, path, text)
54
+ if (found === undefined) return undefined
55
+ const file =
56
+ found.base === "app"
57
+ ? resolve(join(root, app, CONFIG_FILE), found.specifier)
58
+ : resolve(path, found.specifier.startsWith("../") ? found.specifier : `./${found.specifier}`)
54
59
  if (file === undefined) return undefined
55
60
  return {
56
61
  config: app === "" ? config : `${app}/${config}`,
57
- line: text.slice(0, match.index).split("\n").length,
58
- specifier,
62
+ line: text.slice(0, found.index).split("\n").length,
63
+ specifier: found.specifier,
59
64
  file,
60
65
  }
61
66
  }
67
+
68
+ interface FoundClientModule {
69
+ readonly base: "app" | "config"
70
+ readonly specifier: string
71
+ readonly index: number
72
+ }
73
+
74
+ function literalClientModule(text: string): FoundClientModule | undefined {
75
+ // `stripComments` keeps offsets, so the match index is a position in `text`.
76
+ const match = CLIENT_MODULE_EXPORT.exec(stripComments(text))
77
+ if (match === null) return undefined
78
+ return { base: "app", specifier: match[1] ?? match[2] ?? "", index: match.index }
79
+ }
80
+
81
+ function staticClientModule(
82
+ ts: typeof TS,
83
+ path: string,
84
+ text: string,
85
+ ): FoundClientModule | undefined {
86
+ let sf: TS.SourceFile
87
+ try {
88
+ sf = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
89
+ } catch {
90
+ // TypeScript 7 parses only what its session preloaded; the literal form still reads.
91
+ return literalClientModule(text)
92
+ }
93
+ const initializer = exportedInitializer(ts, sf, "clientModule")
94
+ const found = configPathReader(ts, sf)(initializer)
95
+ if (initializer === undefined || found === undefined) return undefined
96
+ return { base: found.base, specifier: found.path, index: initializer.getStart(sf) }
97
+ }
@@ -17,7 +17,7 @@ import { cdnCheckSpec } from "./cdn-check.ts"
17
17
  import { type CheckResult, renderCheckReport } from "./check.ts"
18
18
  import type { ContractsLock } from "./contracts.ts"
19
19
  import { dbAuditSpec, dbQuerySpec, dbRoleSpec, dbSchemaSpec } from "./db-tool.ts"
20
- import { errorsSpec, logsSpec } from "./dev-feed-tool.ts"
20
+ import { errorsSpec, explainSpec, logsSpec } from "./dev-feed-tool.ts"
21
21
  import { type Diagnostic, diagnostic, normalizeSeverity, toSarifLog } from "./diagnostics.ts"
22
22
  import type { DoctorResult } from "./doctor.ts"
23
23
  import { type I18nCheckOutput, i18nCheckPassed, renderI18nCheck } from "./i18n-check.ts"
@@ -367,6 +367,8 @@ interface MigrateInput {
367
367
  readonly from?: "tailwind" | undefined
368
368
  readonly to?: "stylex" | undefined
369
369
  readonly write?: boolean | undefined
370
+ /** `migrate layout`: list the folders modules would leave in package.json zones instead. */
371
+ readonly zones?: boolean | undefined
370
372
  readonly json?: boolean | undefined
371
373
  readonly dir?: string | undefined
372
374
  }
@@ -395,6 +397,7 @@ interface TypesInput {
395
397
 
396
398
  interface SchemasInput {
397
399
  readonly write?: boolean | undefined
400
+ readonly check?: boolean | undefined
398
401
  readonly json?: boolean | undefined
399
402
  readonly dir?: string | undefined
400
403
  }
@@ -486,6 +489,8 @@ interface FixCommandOutput {
486
489
  /** Recipes that refused, with the reason. A recipe that cannot act says so here instead of
487
490
  * aborting the run or - worse - reporting nothing at all. */
488
491
  readonly failed: readonly { readonly code: string; readonly reason: string }[]
492
+ /** Findings whose recipe runs only when its code is named, because its change needs review. */
493
+ readonly skipped: readonly { readonly code: string; readonly reason: string }[]
489
494
  readonly diagnostics: readonly unknown[]
490
495
  }
491
496
  type MigrateCommandOutput =
@@ -751,13 +756,14 @@ const MIGRATE_SCHEMA = input<MigrateInput>(
751
756
  from: { type: "string", enum: ["tailwind"] },
752
757
  to: { type: "string", enum: ["stylex"] },
753
758
  write: { type: "boolean" },
759
+ zones: { type: "boolean" },
754
760
  json: { type: "boolean" },
755
761
  dir: { type: "string" },
756
762
  }),
757
763
  (value) => {
758
764
  const raw = withDir(record(value))
759
765
  const flags = {
760
- ...parseBooleanFlags(raw, ["write", "json"]),
766
+ ...parseBooleanFlags(raw, ["write", "zones", "json"]),
761
767
  ...(raw.dir === undefined ? {} : { dir: raw.dir }),
762
768
  }
763
769
  const kind = optionalString(raw.kind, "kind")
@@ -766,6 +772,7 @@ const MIGRATE_SCHEMA = input<MigrateInput>(
766
772
  throw new TypeError("the migration must be `layout` (or --from tailwind --to stylex)")
767
773
  return { kind, ...flags }
768
774
  }
775
+ if (raw.zones === true) throw new TypeError("--zones applies to `migrate layout` only")
769
776
  const from = optionalString(raw.from, "from")
770
777
  const to = optionalString(raw.to, "to")
771
778
  if (from !== "tailwind") throw new TypeError("from must be tailwind")
@@ -799,11 +806,16 @@ const TYPES_SCHEMA = input<TypesInput>(
799
806
  )
800
807
 
801
808
  const SCHEMAS_SCHEMA = input<SchemasInput>(
802
- objectSchema({ write: { type: "boolean" }, json: { type: "boolean" }, dir: { type: "string" } }),
809
+ objectSchema({
810
+ write: { type: "boolean" },
811
+ check: { type: "boolean" },
812
+ json: { type: "boolean" },
813
+ dir: { type: "string" },
814
+ }),
803
815
  (value) => {
804
816
  const raw = withDir(record(value))
805
817
  return {
806
- ...parseBooleanFlags(raw, ["write", "json"]),
818
+ ...parseBooleanFlags(raw, ["write", "check", "json"]),
807
819
  ...(raw.dir === undefined ? {} : { dir: raw.dir }),
808
820
  }
809
821
  },
@@ -1418,6 +1430,7 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1418
1430
  ok: { type: "boolean" },
1419
1431
  changed: { type: "array" },
1420
1432
  failed: { type: "array" },
1433
+ skipped: { type: "array" },
1421
1434
  diagnostics: { type: "array" },
1422
1435
  },
1423
1436
  required: ["ok", "diagnostics"],
@@ -1432,25 +1445,37 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1432
1445
  },
1433
1446
  async run(value, ctx) {
1434
1447
  const { collectCheckResult } = await import("./check.ts")
1435
- const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
1448
+ const { applyDiagnosticRecipe, getFixRecipe } = await import("./fix-recipes.ts")
1436
1449
  const result = await collectCheckResult(ctx.cwd, {
1437
1450
  lintsOnly: true,
1438
1451
  ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
1439
1452
  })
1440
1453
  const changed: string[] = []
1441
1454
  const failed: { code: string; reason: string }[] = []
1442
- for (const diagnostic of result.structuredDiagnostics ?? [])
1443
- if (value.code === undefined || diagnostic.code === value.code)
1444
- // One recipe that cannot act must not cancel the others, and must not pass silently either:
1445
- // record why and keep going, so a run over many diagnostics still reports every outcome.
1446
- try {
1447
- changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
1448
- } catch (error) {
1449
- failed.push({
1455
+ const skipped: { code: string; reason: string }[] = []
1456
+ for (const diagnostic of result.structuredDiagnostics ?? []) {
1457
+ if (value.code !== undefined && diagnostic.code !== value.code) continue
1458
+ const recipe =
1459
+ diagnostic.fix?.recipe === undefined ? undefined : getFixRecipe(diagnostic.fix.recipe)
1460
+ if (value.code === undefined && recipe?.onlyWhenNamed === true) {
1461
+ if (!skipped.some((entry) => entry.code === diagnostic.code))
1462
+ skipped.push({
1450
1463
  code: diagnostic.code,
1451
- reason: error instanceof Error ? error.message : String(error),
1464
+ reason: `${recipe.description} Review the finding, then run \`nifra fix --code ${diagnostic.code}\`.`,
1452
1465
  })
1453
- }
1466
+ continue
1467
+ }
1468
+ // One recipe that cannot act must not cancel the others, and must not pass silently either:
1469
+ // record why and keep going, so a run over many diagnostics still reports every outcome.
1470
+ try {
1471
+ changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
1472
+ } catch (error) {
1473
+ failed.push({
1474
+ code: diagnostic.code,
1475
+ reason: error instanceof Error ? error.message : String(error),
1476
+ })
1477
+ }
1478
+ }
1454
1479
  const final = await collectCheckResult(ctx.cwd, {
1455
1480
  lintsOnly: true,
1456
1481
  ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
@@ -1459,6 +1484,7 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1459
1484
  ok: final.ok,
1460
1485
  changed,
1461
1486
  failed,
1487
+ skipped,
1462
1488
  diagnostics: final.structuredDiagnostics ?? final.diagnostics,
1463
1489
  }
1464
1490
  },
@@ -1466,6 +1492,7 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1466
1492
  out.ok ? "✓ fixes applied" : "✖ unresolved diagnostics remain",
1467
1493
  ...out.changed.map((file) => ` changed ${file}`),
1468
1494
  ...out.failed.map((f) => ` could not fix ${f.code}: ${f.reason}`),
1495
+ ...out.skipped.map((s) => ` needs review ${s.code}: ${s.reason}`),
1469
1496
  ...out.diagnostics.map((d) => ` ${String((d as { code?: unknown }).code ?? "diagnostic")}`),
1470
1497
  ],
1471
1498
  success: (out) => out.ok,
@@ -1493,6 +1520,7 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1493
1520
  moves: { type: "array" },
1494
1521
  rewritten: { type: "array" },
1495
1522
  tooling: { type: "array" },
1523
+ followUps: { type: "array" },
1496
1524
  mode: { type: "string" },
1497
1525
  },
1498
1526
  required: ["ok", "write", "issues"],
@@ -1505,6 +1533,7 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1505
1533
  { name: "from", field: "from", type: "string" },
1506
1534
  { name: "to", field: "to", type: "string" },
1507
1535
  { name: "write", field: "write", type: "boolean" },
1536
+ { name: "zones", field: "zones", type: "boolean" },
1508
1537
  { name: "json", field: "json", type: "boolean" },
1509
1538
  { name: "dir", field: "dir", type: "string" },
1510
1539
  ],
@@ -1514,7 +1543,8 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1514
1543
  const write = value.write === undefined ? {} : { write: value.write }
1515
1544
  if (value.kind === "layout") {
1516
1545
  const { migrateLayout } = await import("./migrate-layout.ts")
1517
- return { kind: "layout" as const, ...(await migrateLayout(dir, write)) }
1546
+ const options = { ...write, zones: value.zones === true }
1547
+ return { kind: "layout" as const, ...(await migrateLayout(dir, options)) }
1518
1548
  }
1519
1549
  const { migrateTailwindToStylex } = await import("./stylex-migrate.ts")
1520
1550
  return migrateTailwindToStylex(dir, write)
@@ -1526,12 +1556,22 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1526
1556
  ? `✓ ${out.mode === "api" ? "API-only app" : `${out.splits.length} route file${out.splits.length === 1 ? "" : "s"} split`}, ${out.moves.length} file${out.moves.length === 1 ? "" : "s"} moved`
1527
1557
  : `⚠ ${out.issues.length} issue${out.issues.length === 1 ? "" : "s"} need a decision; everything else is planned`,
1528
1558
  ...out.splits.map(
1529
- (split) => ` split ${split.file} → ${split.backend} (${split.moved.join(", ")})`,
1559
+ (split) =>
1560
+ ` split ${split.file} → ${split.backend} (${split.moved.join(", ")})${split.shared === undefined ? "" : `, shared data → ${split.shared}`}`,
1530
1561
  ),
1531
1562
  ...out.moves.map((move) => ` move ${move.from} → ${move.to}`),
1532
1563
  ...out.rewritten.map((file) => ` edit ${file} (imports)`),
1533
1564
  ...out.tooling.map((edit) => ` edit ${edit.file} (${edit.change})`),
1565
+ ...out.schemas
1566
+ .filter((entry) => entry.reason === undefined)
1567
+ .map(
1568
+ (entry) =>
1569
+ ` draft ${entry.file} ${entry.schema} - review what it lets the browser see${entry.omitted === undefined ? "" : `; leaves out ${entry.omitted.join(", ")}`}${entry.open === undefined ? "" : `; admits any value at ${entry.open.join(", ")}, typed any or unknown`}`,
1570
+ ),
1534
1571
  ...out.issues.map((issue) => ` ⚠ ${issue.file}: ${issue.reason}`),
1572
+ ...out.followUps.map(
1573
+ (step) => ` next ${step.file}: ${step.reason} - \`${step.command}\``,
1574
+ ),
1535
1575
  ...(out.write ? [] : [" dry run: pass --write to apply"]),
1536
1576
  ]
1537
1577
  }
@@ -1683,18 +1723,22 @@ const schemasSpec: CommandSpec<SchemasInput, OutputSchemasReport> = {
1683
1723
  argv: {
1684
1724
  flags: [
1685
1725
  { name: "write", field: "write", type: "boolean" },
1726
+ { name: "check", field: "check", type: "boolean" },
1686
1727
  { name: "json", field: "json", type: "boolean" },
1687
1728
  { name: "dir", field: "dir", type: "string" },
1688
1729
  ],
1689
1730
  },
1690
1731
  async run(value, ctx) {
1691
1732
  const { outputSchemas } = await import("./output-schemas.ts")
1692
- return outputSchemas(resolve(ctx.cwd, value.dir ?? "."), { write: value.write === true })
1733
+ return outputSchemas(resolve(ctx.cwd, value.dir ?? "."), {
1734
+ write: value.write === true,
1735
+ check: value.check === true,
1736
+ })
1693
1737
  },
1694
1738
  render: (out) => {
1695
1739
  const shown = out.entries.filter(
1696
1740
  (entry) =>
1697
- entry.status !== "current" &&
1741
+ (entry.status !== "current" || entry.open !== undefined) &&
1698
1742
  entry.status !== "no-data" &&
1699
1743
  (entry.status !== "declared" || entry.dropped.length > 0),
1700
1744
  )
@@ -1709,8 +1753,19 @@ const schemasSpec: CommandSpec<SchemasInput, OutputSchemasReport> = {
1709
1753
  ? "edited by hand under its nifra:generated line"
1710
1754
  : entry.dropped.length > 0
1711
1755
  ? `drops ${entry.dropped.join(", ")}`
1712
- : undefined)
1713
- return ` ${SCHEMA_STATUS_LINE[entry.status]} ${entry.file}:${entry.line} ${entry.schema}${detail === undefined ? "" : ` - ${detail}`}`
1756
+ : entry.omitted !== undefined
1757
+ ? `leaves out ${entry.omitted.join(", ")}`
1758
+ : undefined)
1759
+ const open =
1760
+ entry.open === undefined
1761
+ ? []
1762
+ : [
1763
+ ` admits any value at ${entry.open.join(", ")}: the type there is any or unknown, so the output guard drops nothing below it`,
1764
+ ]
1765
+ return [
1766
+ ` ${SCHEMA_STATUS_LINE[entry.status]} ${entry.file}:${entry.line} ${entry.schema}${detail === undefined ? "" : ` - ${detail}`}`,
1767
+ ...open,
1768
+ ].join("\n")
1714
1769
  }),
1715
1770
  ...(out.write || !out.entries.some((e) => e.status === "missing" || e.status === "stale")
1716
1771
  ? []
@@ -2020,6 +2075,7 @@ export const commandSpecs = Object.freeze([
2020
2075
  portSpec,
2021
2076
  i18nSpec,
2022
2077
  errorsSpec,
2078
+ explainSpec,
2023
2079
  logsSpec,
2024
2080
  cdnCheckSpec,
2025
2081
  dbSchemaSpec,