@nifrajs/cli 2.14.1 → 3.2.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 (203) hide show
  1. package/README.md +10 -0
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +6 -1
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts.map +1 -1
  6. package/dist/assure.js +3 -2
  7. package/dist/assure.js.map +1 -1
  8. package/dist/check-diagnostics.d.ts +165 -0
  9. package/dist/check-diagnostics.d.ts.map +1 -0
  10. package/dist/check-diagnostics.js +1009 -0
  11. package/dist/check-diagnostics.js.map +1 -0
  12. package/dist/check-scan.d.ts +245 -0
  13. package/dist/check-scan.d.ts.map +1 -0
  14. package/dist/check-scan.js +1599 -0
  15. package/dist/check-scan.js.map +1 -0
  16. package/dist/check.d.ts +4 -324
  17. package/dist/check.d.ts.map +1 -1
  18. package/dist/check.js +8 -2346
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +285 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +27 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +142 -2
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/doctor.d.ts +42 -0
  28. package/dist/doctor.d.ts.map +1 -1
  29. package/dist/doctor.js +64 -7
  30. package/dist/doctor.js.map +1 -1
  31. package/dist/env-file.d.ts +17 -0
  32. package/dist/env-file.d.ts.map +1 -0
  33. package/dist/env-file.js +113 -0
  34. package/dist/env-file.js.map +1 -0
  35. package/dist/fix-recipes.d.ts.map +1 -1
  36. package/dist/fix-recipes.js +91 -15
  37. package/dist/fix-recipes.js.map +1 -1
  38. package/dist/frontend-guidance.d.ts +57 -0
  39. package/dist/frontend-guidance.d.ts.map +1 -0
  40. package/dist/frontend-guidance.js +195 -0
  41. package/dist/frontend-guidance.js.map +1 -0
  42. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  43. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  44. package/dist/internal/reserved-segment-codemod.js +134 -0
  45. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  46. package/dist/internal/typescript-import.d.ts +1 -1
  47. package/dist/internal/typescript-import.d.ts.map +1 -1
  48. package/dist/internal/typescript-import.js +62 -11
  49. package/dist/internal/typescript-import.js.map +1 -1
  50. package/dist/introspect.d.ts +26 -1
  51. package/dist/introspect.d.ts.map +1 -1
  52. package/dist/introspect.js +74 -24
  53. package/dist/introspect.js.map +1 -1
  54. package/dist/levels-tool.d.ts.map +1 -1
  55. package/dist/levels-tool.js +6 -0
  56. package/dist/levels-tool.js.map +1 -1
  57. package/dist/load.d.ts.map +1 -1
  58. package/dist/load.js +4 -1
  59. package/dist/load.js.map +1 -1
  60. package/dist/mcp-context.d.ts +48 -0
  61. package/dist/mcp-context.d.ts.map +1 -0
  62. package/dist/mcp-context.js +215 -0
  63. package/dist/mcp-context.js.map +1 -0
  64. package/dist/mcp-docs-tools.d.ts.map +1 -1
  65. package/dist/mcp-docs-tools.js +31 -0
  66. package/dist/mcp-docs-tools.js.map +1 -1
  67. package/dist/mcp-exec.d.ts +74 -0
  68. package/dist/mcp-exec.d.ts.map +1 -0
  69. package/dist/mcp-exec.js +844 -0
  70. package/dist/mcp-exec.js.map +1 -0
  71. package/dist/mcp-http.js +1 -1
  72. package/dist/mcp-http.js.map +1 -1
  73. package/dist/mcp-io.d.ts +26 -0
  74. package/dist/mcp-io.d.ts.map +1 -0
  75. package/dist/mcp-io.js +100 -0
  76. package/dist/mcp-io.js.map +1 -0
  77. package/dist/mcp-reflect.d.ts +16 -0
  78. package/dist/mcp-reflect.d.ts.map +1 -0
  79. package/dist/mcp-reflect.js +101 -0
  80. package/dist/mcp-reflect.js.map +1 -0
  81. package/dist/mcp-root.d.ts +7 -0
  82. package/dist/mcp-root.d.ts.map +1 -1
  83. package/dist/mcp-root.js +51 -3
  84. package/dist/mcp-root.js.map +1 -1
  85. package/dist/mcp.d.ts +10 -84
  86. package/dist/mcp.d.ts.map +1 -1
  87. package/dist/mcp.js +13 -1170
  88. package/dist/mcp.js.map +1 -1
  89. package/dist/openapi-tool.d.ts +5 -10
  90. package/dist/openapi-tool.d.ts.map +1 -1
  91. package/dist/openapi-tool.js +21 -2
  92. package/dist/openapi-tool.js.map +1 -1
  93. package/dist/openapi-types.d.ts +21 -0
  94. package/dist/openapi-types.d.ts.map +1 -0
  95. package/dist/openapi-types.js +214 -0
  96. package/dist/openapi-types.js.map +1 -0
  97. package/dist/port.d.ts.map +1 -1
  98. package/dist/port.js +2 -1
  99. package/dist/port.js.map +1 -1
  100. package/dist/project-facts.d.ts +2 -1
  101. package/dist/project-facts.d.ts.map +1 -1
  102. package/dist/project-facts.js.map +1 -1
  103. package/dist/recipes/3.0.0.d.ts +10 -0
  104. package/dist/recipes/3.0.0.d.ts.map +1 -0
  105. package/dist/recipes/3.0.0.js +23 -0
  106. package/dist/recipes/3.0.0.js.map +1 -0
  107. package/dist/recipes/index.d.ts.map +1 -1
  108. package/dist/recipes/index.js +2 -0
  109. package/dist/recipes/index.js.map +1 -1
  110. package/dist/release-verification.d.ts +4 -1
  111. package/dist/release-verification.d.ts.map +1 -1
  112. package/dist/release-verification.js +6 -123
  113. package/dist/release-verification.js.map +1 -1
  114. package/dist/rules/codes.d.ts +4 -0
  115. package/dist/rules/codes.d.ts.map +1 -1
  116. package/dist/rules/codes.js +4 -0
  117. package/dist/rules/codes.js.map +1 -1
  118. package/dist/rules/islands.d.ts +4 -0
  119. package/dist/rules/islands.d.ts.map +1 -0
  120. package/dist/rules/islands.js +155 -0
  121. package/dist/rules/islands.js.map +1 -0
  122. package/dist/rules/nano.d.ts +6 -0
  123. package/dist/rules/nano.d.ts.map +1 -0
  124. package/dist/rules/nano.js +264 -0
  125. package/dist/rules/nano.js.map +1 -0
  126. package/dist/rules/routes.d.ts.map +1 -1
  127. package/dist/rules/routes.js +17 -30
  128. package/dist/rules/routes.js.map +1 -1
  129. package/dist/rules/security.d.ts.map +1 -1
  130. package/dist/rules/security.js +38 -4
  131. package/dist/rules/security.js.map +1 -1
  132. package/dist/scaffold.d.ts +12 -6
  133. package/dist/scaffold.d.ts.map +1 -1
  134. package/dist/scaffold.js +149 -10
  135. package/dist/scaffold.js.map +1 -1
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -3
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/stylex-migrate.d.ts +38 -0
  140. package/dist/stylex-migrate.d.ts.map +1 -0
  141. package/dist/stylex-migrate.js +1052 -0
  142. package/dist/stylex-migrate.js.map +1 -0
  143. package/dist/sync-manifest.d.ts.map +1 -1
  144. package/dist/sync-manifest.js +2 -1
  145. package/dist/sync-manifest.js.map +1 -1
  146. package/dist/verification-plan.d.ts +24 -0
  147. package/dist/verification-plan.d.ts.map +1 -0
  148. package/dist/verification-plan.js +116 -0
  149. package/dist/verification-plan.js.map +1 -0
  150. package/dist/work-graph.d.ts.map +1 -1
  151. package/dist/work-graph.js +6 -3
  152. package/dist/work-graph.js.map +1 -1
  153. package/dist/workspace-link.d.ts +37 -0
  154. package/dist/workspace-link.d.ts.map +1 -0
  155. package/dist/workspace-link.js +72 -0
  156. package/dist/workspace-link.js.map +1 -0
  157. package/docs/examples.json +37 -1
  158. package/docs/llms-full.txt +1598 -44
  159. package/docs/types.json +5157 -172
  160. package/package.json +9 -9
  161. package/src/assure-hydration.ts +6 -1
  162. package/src/assure.ts +4 -2
  163. package/src/check-diagnostics.ts +1219 -0
  164. package/src/check-scan.ts +1825 -0
  165. package/src/check.ts +47 -2731
  166. package/src/cli.ts +291 -10
  167. package/src/command-catalog.ts +177 -2
  168. package/src/doctor.ts +99 -8
  169. package/src/env-file.ts +121 -0
  170. package/src/fix-recipes.ts +101 -15
  171. package/src/frontend-guidance.ts +259 -0
  172. package/src/internal/reserved-segment-codemod.ts +150 -0
  173. package/src/internal/typescript-import.ts +61 -11
  174. package/src/introspect.ts +82 -21
  175. package/src/levels-tool.ts +6 -0
  176. package/src/load.ts +4 -1
  177. package/src/mcp-context.ts +266 -0
  178. package/src/mcp-docs-tools.ts +37 -0
  179. package/src/mcp-exec.ts +1040 -0
  180. package/src/mcp-http.ts +1 -1
  181. package/src/mcp-io.ts +118 -0
  182. package/src/mcp-reflect.ts +140 -0
  183. package/src/mcp-root.ts +62 -4
  184. package/src/mcp.ts +61 -1436
  185. package/src/openapi-tool.ts +32 -2
  186. package/src/openapi-types.ts +261 -0
  187. package/src/port.ts +2 -1
  188. package/src/project-facts.ts +2 -4
  189. package/src/recipes/3.0.0.ts +24 -0
  190. package/src/recipes/index.ts +2 -0
  191. package/src/release-verification.ts +22 -147
  192. package/src/rules/codes.ts +4 -0
  193. package/src/rules/islands.ts +171 -0
  194. package/src/rules/nano.ts +295 -0
  195. package/src/rules/routes.ts +17 -30
  196. package/src/rules/security.ts +40 -4
  197. package/src/scaffold.ts +169 -9
  198. package/src/search-terms.ts +7 -3
  199. package/src/stylex-migrate.ts +1139 -0
  200. package/src/sync-manifest.ts +2 -1
  201. package/src/verification-plan.ts +242 -0
  202. package/src/work-graph.ts +6 -3
  203. package/src/workspace-link.ts +80 -0
package/src/doctor.ts CHANGED
@@ -25,12 +25,14 @@ import {
25
25
  displayPath,
26
26
  type IdentityParityCopy,
27
27
  type IdentityParityFinding,
28
+ identityParityBasis,
28
29
  resolvedInstalledCopy,
29
30
  } from "@nifrajs/web/internal/parity"
30
31
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
31
32
  import { detectToolingDrift, type ToolingDrift } from "./mcp-root.ts"
32
33
  import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
33
34
  import { type ResolvedTarget, resolveTarget } from "./port.ts"
35
+ import { buildScriptName } from "./workspace-link.ts"
34
36
 
35
37
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
36
38
  // own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
@@ -141,6 +143,25 @@ export interface DoctorResult {
141
143
  readonly findings: readonly DoctorFinding[]
142
144
  /** Packages that resolve to more than one physical install across this workspace. */
143
145
  readonly duplicateInstalls: readonly DuplicateInstallFinding[]
146
+ /**
147
+ * Duplicate installs the app declared single-copy (`"nifra": { "singleCopy": [...] }`), which nifra
148
+ * collapses to one copy at resolve time. Reported so the topology stays visible, never folded into
149
+ * `ok`: the copies exist on disk but cannot both load. Absent on an older result shape.
150
+ */
151
+ readonly deduplicatedInstalls?: readonly DuplicateInstallFinding[]
152
+ /**
153
+ * Which tree the identity scan actually looked at, in the same words the build/dev preflight uses.
154
+ * Both run the same seam now; printing the basis is what lets a reader confirm that instead of
155
+ * having to trust it. Absent on an older result shape.
156
+ */
157
+ readonly identityBasis?: string
158
+ /**
159
+ * The identity scan stopped at the workspace-enumeration cap, so "no duplicates" means "none in the
160
+ * part that was scanned". Never folded into `ok` - a huge workspace is not a defect - but it is
161
+ * always printed, because an unqualified clean bill from a partial scan is the failure mode this
162
+ * whole report exists to avoid.
163
+ */
164
+ readonly identityScanTruncated?: boolean
144
165
  /** Workspace-linked deps whose `default`-condition artifact lags their `bun`-condition source.
145
166
  * Advisory (never folded into `ok`): while actively editing a linked package its dist is always
146
167
  * momentarily behind - the finding matters when a dev server starts against it. */
@@ -360,18 +381,52 @@ export async function collectDuplicateInstalls(
360
381
  cwd: string,
361
382
  rootPackage: Record<string, unknown>,
362
383
  ): Promise<DuplicateInstallFinding[]> {
363
- const result = await collectIdentityParity(cwd, rootPackage, { useWorkspaceRoot: true })
364
- return result.findings.map(
365
- (finding): DuplicateInstallFinding => ({
366
- ...finding,
367
- copies: finding.copies.map((copy): DuplicateInstallCopy => ({ ...copy })),
368
- }),
369
- )
384
+ return (await collectAllDuplicateInstalls(cwd, rootPackage)).duplicates
385
+ }
386
+
387
+ const asDuplicateInstall = (finding: IdentityParityFinding): DuplicateInstallFinding => ({
388
+ ...finding,
389
+ copies: finding.copies.map((copy): DuplicateInstallCopy => ({ ...copy })),
390
+ })
391
+
392
+ /**
393
+ * Both halves of the identity scan: what still breaks, and what a declaration already collapses -
394
+ * plus the basis they were decided on.
395
+ *
396
+ * The basis travels with the findings because doctor and the build/dev preflight answer the same
397
+ * question, and a reader who gets two answers needs to see which tree each one looked at. They now
398
+ * scan identically (one shared seam, one workspace resolution), so the line is corroboration rather
399
+ * than an excuse - and it is the only way a TRUNCATED scan can be told apart from a clean one.
400
+ */
401
+ export async function collectAllDuplicateInstalls(
402
+ cwd: string,
403
+ rootPackage: Record<string, unknown>,
404
+ ): Promise<{
405
+ readonly duplicates: DuplicateInstallFinding[]
406
+ readonly deduplicated: DuplicateInstallFinding[]
407
+ readonly basis: string
408
+ readonly truncated: boolean
409
+ }> {
410
+ const result = await collectIdentityParity(cwd, rootPackage)
411
+ return {
412
+ duplicates: result.findings.map(asDuplicateInstall),
413
+ deduplicated: result.deduplicated.map(asDuplicateInstall),
414
+ basis: identityParityBasis(result),
415
+ truncated: result.truncated,
416
+ }
370
417
  }
371
418
 
372
419
  /** A workspace-linked dependency whose build artifact is older than its source. */
373
420
  export interface StaleDistFinding {
374
421
  readonly package: string
422
+ /** Where the linked package actually lives, relative to the doctor root when possible. A workspace
423
+ * link routinely points into a SIBLING repo, so "rebuild it" is not actionable without the path -
424
+ * this is the directory the rebuild runs in. */
425
+ readonly packageDir: string
426
+ /** The package's own build script name (`scripts.build` etc.), or `undefined` when it declares none -
427
+ * then the rebuild cannot be automated and the report says so instead of suggesting a command that
428
+ * would fail. */
429
+ readonly buildScript: string | undefined
375
430
  /** The `default`-condition artifact (what Vite's SSR runner, vitest, and node consumers load),
376
431
  * relative to the doctor root when possible. `missing: true` means it was never built at all. */
377
432
  readonly distFile: string
@@ -482,6 +537,8 @@ export async function collectStaleWorkspaceDists(
482
537
  newest = candidate
483
538
  }
484
539
  if (newest === undefined) continue
540
+ const packageDir = displayPath(cwd, copy.path)
541
+ const buildScript = buildScriptName(meta)
485
542
  // Report the package once, anchored on its stalest (or missing) artifact.
486
543
  let worst: StaleDistFinding | undefined
487
544
  for (const pair of pairs) {
@@ -491,6 +548,8 @@ export async function collectStaleWorkspaceDists(
491
548
  distStat === undefined
492
549
  ? {
493
550
  package: name,
551
+ packageDir,
552
+ buildScript,
494
553
  distFile: displayPath(cwd, distPath),
495
554
  missing: true,
496
555
  sourceFile: displayPath(cwd, newest.file),
@@ -499,6 +558,8 @@ export async function collectStaleWorkspaceDists(
499
558
  : distStat.mtimeMs < newest.mtimeMs
500
559
  ? {
501
560
  package: name,
561
+ packageDir,
562
+ buildScript,
502
563
  distFile: displayPath(cwd, distPath),
503
564
  missing: false,
504
565
  sourceFile: displayPath(cwd, newest.file),
@@ -704,7 +765,8 @@ export async function collectDoctorResult(
704
765
  { includeTests: true },
705
766
  )
706
767
  findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
707
- const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
768
+ const identity = await collectAllDuplicateInstalls(cwd, pkg)
769
+ const duplicateInstalls = identity.duplicates
708
770
  const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
709
771
  const pipeline = await collectPipelineReport(cwd)
710
772
  const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
@@ -724,6 +786,9 @@ export async function collectDoctorResult(
724
786
  ran: true,
725
787
  findings,
726
788
  duplicateInstalls,
789
+ deduplicatedInstalls: identity.deduplicated,
790
+ identityBasis: identity.basis,
791
+ ...(identity.truncated ? { identityScanTruncated: true } : {}),
727
792
  staleDists,
728
793
  readiness,
729
794
  ...(toolingDrift !== undefined ? { toolingDrift } : {}),
@@ -906,6 +971,14 @@ export async function runDoctor(
906
971
  console.log(
907
972
  "✓ every imported package is declared and identity-sensitive installs are deduplicated",
908
973
  )
974
+ if (result.identityBasis !== undefined) console.log(` identity scan: ${result.identityBasis}`)
975
+ // A partial scan that prints a bare ✓ is a lie by omission - the packages past the cap were never
976
+ // looked at, and the reader has no way to know that from the tick alone.
977
+ if (result.identityScanTruncated === true) {
978
+ console.log(
979
+ " ! the identity scan was truncated, so this is not a clean bill for the whole workspace",
980
+ )
981
+ }
909
982
  return true
910
983
  }
911
984
  // Group by package so the fix ("add X to dependencies") is stated once with its import sites.
@@ -926,6 +999,7 @@ export async function runDoctor(
926
999
  console.log(
927
1000
  `${byPkg.size > 0 ? "\n" : ""}✗ identity-sensitive packages resolve to multiple physical copies:\n`,
928
1001
  )
1002
+ if (result.identityBasis !== undefined) console.log(` scan: ${result.identityBasis}\n`)
929
1003
  for (const finding of result.duplicateInstalls) {
930
1004
  console.log(` ${finding.package} [${finding.cause}]`)
931
1005
  console.log(` versions: ${finding.versions.join(", ")}`)
@@ -933,9 +1007,26 @@ export async function runDoctor(
933
1007
  console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
934
1008
  }
935
1009
  console.log(` ${finding.explanation}`)
1010
+ // The shape of the split decides which fix can work, so it is printed above the fix itself.
1011
+ if (finding.topology !== undefined) console.log(` topology: ${finding.topology}`)
1012
+ // Why a copy this directory never imports still counts. Without it, a workspace-wide answer
1013
+ // read from inside one app looks like the tool checking the wrong project.
1014
+ if (finding.scope !== undefined) console.log(` scope: ${finding.scope}`)
936
1015
  console.log(` fix: ${finding.remediation}`)
937
1016
  }
938
1017
  }
1018
+ // Not a failure and not silence: the copies are real, and whoever reads this report needs to know
1019
+ // the graph is held together by a declaration rather than by the install.
1020
+ if (result.deduplicatedInstalls !== undefined && result.deduplicatedInstalls.length > 0) {
1021
+ console.log("\n• identity-sensitive packages deduplicated by declaration:\n")
1022
+ for (const finding of result.deduplicatedInstalls) {
1023
+ console.log(` ${finding.package} @ ${finding.versions.join(", ")}`)
1024
+ for (const copy of finding.copies) {
1025
+ console.log(` ${copy.path} ← ${copy.importers.join(", ")}`)
1026
+ }
1027
+ console.log(` ${finding.remediation}`)
1028
+ }
1029
+ }
939
1030
  if (result.readiness?.strict === true && result.readiness.ok === false) {
940
1031
  console.log("\n✗ production readiness is incomplete under --strict")
941
1032
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `--env-file <path>` for every `nifra` command.
3
+ *
4
+ * Most nifra commands (`check`, `assure`, `levels`, `routes`, `capabilities`, `manifest`, `contracts`,
5
+ * `openapi`, `types`) reflect a project by IMPORTING it, and a production-grade app validates its
6
+ * environment at module scope. Without the app's environment those commands do not report a gate
7
+ * failure - the app's own validator kills the process before nifra reaches its first check, and the
8
+ * only output is the app's `FATAL: invalid environment` with nothing tying it to the command that was
9
+ * run. This flag supplies that environment the same way `bun --env-file` / `node --env-file` do, so a
10
+ * project whose env lives in an uncommitted `.env.local` stays checkable.
11
+ *
12
+ * Two deliberate rules:
13
+ * - A variable already present in `process.env` WINS. The shell (and CI's secret store) is the more
14
+ * explicit source; a checked-in defaults file must never shadow it.
15
+ * - A missing file is an error, never a silent no-op. `--env-file .env.prod` that quietly did nothing
16
+ * would make the command look like it verified an environment it never loaded.
17
+ */
18
+ import { readFile } from "node:fs/promises"
19
+ import { resolve } from "node:path"
20
+
21
+ const ENV_FILE_FLAG = "--env-file"
22
+
23
+ /**
24
+ * A `KEY=VALUE` line. `export ` is tolerated so a file that is also `source`-able works. The key is
25
+ * POSIX-shaped on purpose: it keeps a stray `- foo` bullet or a YAML `key: value` line from being read
26
+ * as an assignment when the wrong file is passed.
27
+ */
28
+ const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/
29
+
30
+ /** Strip one layer of matching quotes; a double-quoted value also expands `\n`, `\r`, `\t` and `\"`. */
31
+ function unquote(raw: string): string {
32
+ const value = raw.trim()
33
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
34
+ return value
35
+ .slice(1, -1)
36
+ .replace(/\\([nrt"\\])/g, (_, char: string) =>
37
+ char === "n" ? "\n" : char === "r" ? "\r" : char === "t" ? "\t" : char,
38
+ )
39
+ }
40
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1)
41
+ // Unquoted: an inline ` #` starts a comment, matching every dotenv reader. A `#` with no leading
42
+ // space is part of the value (URLs and generated secrets contain one).
43
+ const comment = value.search(/\s#/)
44
+ return (comment === -1 ? value : value.slice(0, comment)).trim()
45
+ }
46
+
47
+ /** Parse dotenv text into a plain record. Later assignments to the same key win, as in a shell. */
48
+ export function parseEnvFile(text: string): Record<string, string> {
49
+ const values: Record<string, string> = {}
50
+ for (const line of text.split(/\r?\n/)) {
51
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue
52
+ const match = ASSIGNMENT.exec(line)
53
+ if (match === null) continue
54
+ values[match[1] as string] = unquote(match[2] as string)
55
+ }
56
+ return values
57
+ }
58
+
59
+ /**
60
+ * Pull every `--env-file <path>` / `--env-file=<path>` out of `argv`, leaving the rest for the command
61
+ * parser (which rejects options it does not know). Repeatable; later files override earlier ones, the
62
+ * same precedence `node --env-file` uses.
63
+ */
64
+ export function takeEnvFileFlags(argv: readonly string[]): {
65
+ readonly argv: readonly string[]
66
+ readonly files: readonly string[]
67
+ } {
68
+ const rest: string[] = []
69
+ const files: string[] = []
70
+ for (let index = 0; index < argv.length; index++) {
71
+ const arg = argv[index] as string
72
+ if (arg === ENV_FILE_FLAG) {
73
+ const value = argv[index + 1]
74
+ if (value === undefined || value.startsWith("-")) {
75
+ throw new Error(`[nifra] ${ENV_FILE_FLAG} needs a path, e.g. ${ENV_FILE_FLAG} .env.local`)
76
+ }
77
+ files.push(value)
78
+ index++
79
+ continue
80
+ }
81
+ if (arg.startsWith(`${ENV_FILE_FLAG}=`)) {
82
+ const value = arg.slice(ENV_FILE_FLAG.length + 1)
83
+ if (value === "") {
84
+ throw new Error(`[nifra] ${ENV_FILE_FLAG} needs a path, e.g. ${ENV_FILE_FLAG}=.env.local`)
85
+ }
86
+ files.push(value)
87
+ continue
88
+ }
89
+ rest.push(arg)
90
+ }
91
+ return { argv: rest, files }
92
+ }
93
+
94
+ /**
95
+ * Read each file in order and merge it into `process.env`, never overwriting a variable the process
96
+ * already has. Returns the resolved paths that were applied so a command can say what it loaded.
97
+ */
98
+ export async function applyEnvFiles(
99
+ cwd: string,
100
+ files: readonly string[],
101
+ ): Promise<readonly string[]> {
102
+ const applied: string[] = []
103
+ const merged: Record<string, string> = {}
104
+ for (const file of files) {
105
+ const path = resolve(cwd, file)
106
+ let text: string
107
+ try {
108
+ text = await readFile(path, "utf8")
109
+ } catch {
110
+ throw new Error(`[nifra] --env-file not found or unreadable: ${path}`)
111
+ }
112
+ // Files merge left to right, so a later `--env-file` overrides an earlier one; the merged result
113
+ // is applied afterwards so that precedence never depends on which file happened to define a key.
114
+ Object.assign(merged, parseEnvFile(text))
115
+ applied.push(path)
116
+ }
117
+ for (const [key, value] of Object.entries(merged)) {
118
+ if (process.env[key] === undefined) process.env[key] = value
119
+ }
120
+ return applied
121
+ }
@@ -1,8 +1,8 @@
1
- import { existsSync } from "node:fs"
2
1
  import { readFile, writeFile } from "node:fs/promises"
3
2
  import { join } from "node:path"
4
3
  import type { Diagnostic } from "./diagnostics.ts"
5
4
  import { resolveInsideProject } from "./project-path.ts"
5
+ import { resolveWorkspaceLinkedPackage } from "./workspace-link.ts"
6
6
 
7
7
  export interface FixRecipe {
8
8
  readonly id: string
@@ -91,29 +91,115 @@ registerFixRecipe({
91
91
  verify: "nifra doctor --json",
92
92
  async apply(root, diagnostic) {
93
93
  const packageName = diagnostic.evidence?.[0]
94
- if (packageName === undefined || !/^@?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?$/.test(packageName))
95
- return []
96
- // The segment regex above admits "." and "..", which would resolve outside node_modules.
97
- if (packageName.split("/").some((segment) => segment === "." || segment === "..")) return []
98
- const packageDir = await resolveInsideProject(root, join("node_modules", packageName))
99
- if (packageDir === undefined) return []
100
- const packageJson = await resolveInsideProject(
101
- root,
102
- join("node_modules", packageName, "package.json"),
103
- )
104
- if (packageJson === undefined || !existsSync(packageJson)) return []
105
- const proc = Bun.spawn(["bun", "run", "build"], {
106
- cwd: packageDir,
94
+ if (packageName === undefined) return []
95
+ // Resolved from the NAME through the project's own node_modules chain, not from any path carried
96
+ // by the diagnostic. A workspace link points outside the project by definition - that escape is
97
+ // what makes the artifact able to go stale - so `resolveInsideProject` would reject every package
98
+ // this diagnostic can ever name. `resolveWorkspaceLinkedPackage` states its own rules instead:
99
+ // reachable through the project's install, not a registry tarball, and declaring its own build.
100
+ const linked = await resolveWorkspaceLinkedPackage(root, packageName)
101
+ // Refusing is a result the caller has to see. Returning "changed nothing" made `nifra fix
102
+ // --code NF-C010` a silent no-op on the only findings that produce it.
103
+ if (!linked.ok) throw new Error(`[nifra] cannot rebuild ${packageName}: ${linked.reason}`)
104
+ const proc = Bun.spawn(["bun", "run", linked.buildScript], {
105
+ cwd: linked.dir,
107
106
  stdout: "ignore",
108
107
  stderr: "pipe",
109
108
  })
110
109
  const error = await new Response(proc.stderr).text()
111
110
  const exitCode = await proc.exited
112
- if (exitCode !== 0) throw new Error(error.trim() || `build failed for ${packageName}`)
111
+ if (exitCode !== 0) {
112
+ throw new Error(
113
+ `[nifra] \`bun run ${linked.buildScript}\` failed in ${linked.dir}: ${error.trim() || `exit code ${exitCode}`}`,
114
+ )
115
+ }
113
116
  return [diagnostic.evidence?.[1] ?? packageName]
114
117
  },
115
118
  })
116
119
 
120
+ /**
121
+ * How many typecheck passes the reserved-segment codemod will run. A nested collision
122
+ * (`/api/delete/then`) only reveals its second site once the first is rewritten, so one pass is not
123
+ * always enough - but each pass is a full `tsc`, so this converges or stops rather than looping.
124
+ */
125
+ const RESERVED_SEGMENT_PASSES = 3
126
+
127
+ registerFixRecipe({
128
+ id: "client.reserved-segment",
129
+ description:
130
+ "Rewrite typed-client call sites broken by a reserved-named route segment to the call spelling.",
131
+ verify: "tsc --noEmit",
132
+ async apply(root) {
133
+ const { parseCollisionSites, resolveTscBin, rewriteFile } = await import(
134
+ "./internal/reserved-segment-codemod.ts"
135
+ )
136
+ const tsconfig = join(root, "tsconfig.json")
137
+ if (!(await Bun.file(tsconfig).exists()))
138
+ throw new Error(
139
+ "[nifra] no tsconfig.json here - the reserved-segment codemod reads the compiler's own list of broken call sites, so it needs a typecheckable project",
140
+ )
141
+ const tscBin = resolveTscBin(root)
142
+ if (tscBin === undefined)
143
+ throw new Error(
144
+ "[nifra] no `typescript` install found from this directory upward - run `bun add -d typescript`, then rerun the fix",
145
+ )
146
+
147
+ const changed = new Set<string>()
148
+ const skipped: string[] = []
149
+ for (let pass = 0; pass < RESERVED_SEGMENT_PASSES; pass += 1) {
150
+ const proc = Bun.spawn(["bun", tscBin, "--noEmit", "--pretty", "false", "-p", tsconfig], {
151
+ cwd: root,
152
+ stdout: "pipe",
153
+ stderr: "pipe",
154
+ })
155
+ const [stdout, stderr] = await Promise.all([
156
+ new Response(proc.stdout).text(),
157
+ new Response(proc.stderr).text(),
158
+ ])
159
+ await proc.exited
160
+ const sites = parseCollisionSites(`${stdout}${stderr}`)
161
+ if (sites.length === 0) break
162
+
163
+ const byFile = new Map<string, (typeof sites)[number][]>()
164
+ for (const site of sites) {
165
+ const existing = byFile.get(site.file)
166
+ if (existing === undefined) byFile.set(site.file, [site])
167
+ else existing.push(site)
168
+ }
169
+
170
+ let wrote = false
171
+ skipped.length = 0
172
+ for (const [file, fileSites] of byFile) {
173
+ // Resolved through the project boundary: the compiler reports paths from its own rootDir, and
174
+ // a rewrite must never escape the project it was invoked in.
175
+ const path = await resolveInsideProject(root, file)
176
+ if (path === undefined) continue
177
+ const source = await readFile(path, "utf8")
178
+ const result = rewriteFile(source, fileSites)
179
+ for (const site of result.skipped) skipped.push(`${file}:${site.line}:${site.column}`)
180
+ if (result.source === source) continue
181
+ await writeFile(path, result.source, "utf8")
182
+ changed.add(file)
183
+ wrote = true
184
+ }
185
+ // Nothing rewritten this pass means the remaining sites are all shapes the codemod declines to
186
+ // touch. Another `tsc` would report the same list, so stop instead of burning the budget.
187
+ if (!wrote) break
188
+ }
189
+
190
+ if (skipped.length > 0) {
191
+ const rewritten =
192
+ changed.size === 0
193
+ ? "no site was rewritten"
194
+ : `rewrote ${changed.size} file${changed.size === 1 ? "" : "s"}`
195
+ throw new Error(
196
+ `[nifra] ${rewritten}; ${skipped.length} reserved-segment site${skipped.length === 1 ? "" : "s"} need${skipped.length === 1 ? "s" : ""} a manual edit (the collision is not reached by a plain \`.segment\` property access there): ${skipped.join(", ")}`,
197
+ )
198
+ }
199
+ return [...changed]
200
+ },
201
+ })
202
+
117
203
  export async function applyDiagnosticRecipe(
118
204
  root: string,
119
205
  diagnostic: Diagnostic,