@nifrajs/cli 2.13.0 → 3.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 (114) hide show
  1. package/dist/capabilities-tool.d.ts +54 -1
  2. package/dist/capabilities-tool.d.ts.map +1 -1
  3. package/dist/capabilities-tool.js +146 -18
  4. package/dist/capabilities-tool.js.map +1 -1
  5. package/dist/check.d.ts +11 -11
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +260 -49
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.d.ts +11 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +130 -414
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +250 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1032 -0
  16. package/dist/command-catalog.js.map +1 -0
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.d.ts.map +1 -1
  19. package/dist/contracts.js +22 -6
  20. package/dist/contracts.js.map +1 -1
  21. package/dist/doctor.d.ts +55 -0
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +82 -9
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/env-file.d.ts +17 -0
  26. package/dist/env-file.d.ts.map +1 -0
  27. package/dist/env-file.js +113 -0
  28. package/dist/env-file.js.map +1 -0
  29. package/dist/fix-recipes.d.ts.map +1 -1
  30. package/dist/fix-recipes.js +91 -15
  31. package/dist/fix-recipes.js.map +1 -1
  32. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  33. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  34. package/dist/internal/reserved-segment-codemod.js +134 -0
  35. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  36. package/dist/internal/typescript-import.d.ts +1 -1
  37. package/dist/internal/typescript-import.d.ts.map +1 -1
  38. package/dist/internal/typescript-import.js +62 -11
  39. package/dist/internal/typescript-import.js.map +1 -1
  40. package/dist/introspect.d.ts +26 -1
  41. package/dist/introspect.d.ts.map +1 -1
  42. package/dist/introspect.js +74 -24
  43. package/dist/introspect.js.map +1 -1
  44. package/dist/load.d.ts.map +1 -1
  45. package/dist/load.js +22 -5
  46. package/dist/load.js.map +1 -1
  47. package/dist/manifest-tool.d.ts +15 -1
  48. package/dist/manifest-tool.d.ts.map +1 -1
  49. package/dist/manifest-tool.js +23 -9
  50. package/dist/manifest-tool.js.map +1 -1
  51. package/dist/mcp-http.js +1 -1
  52. package/dist/mcp-http.js.map +1 -1
  53. package/dist/mcp.d.ts +16 -0
  54. package/dist/mcp.d.ts.map +1 -1
  55. package/dist/mcp.js +127 -355
  56. package/dist/mcp.js.map +1 -1
  57. package/dist/project-facts.d.ts +40 -0
  58. package/dist/project-facts.d.ts.map +1 -0
  59. package/dist/project-facts.js +36 -0
  60. package/dist/project-facts.js.map +1 -0
  61. package/dist/recipes/3.0.0.d.ts +10 -0
  62. package/dist/recipes/3.0.0.d.ts.map +1 -0
  63. package/dist/recipes/3.0.0.js +23 -0
  64. package/dist/recipes/3.0.0.js.map +1 -0
  65. package/dist/recipes/index.d.ts.map +1 -1
  66. package/dist/recipes/index.js +2 -0
  67. package/dist/recipes/index.js.map +1 -1
  68. package/dist/release-verification.d.ts.map +1 -1
  69. package/dist/release-verification.js +15 -0
  70. package/dist/release-verification.js.map +1 -1
  71. package/dist/rules/comment-markers.d.ts +8 -0
  72. package/dist/rules/comment-markers.d.ts.map +1 -1
  73. package/dist/rules/comment-markers.js +36 -0
  74. package/dist/rules/comment-markers.js.map +1 -1
  75. package/dist/rules/index.d.ts +2 -3
  76. package/dist/rules/index.d.ts.map +1 -1
  77. package/dist/rules/index.js.map +1 -1
  78. package/dist/rules/routes.d.ts.map +1 -1
  79. package/dist/rules/routes.js +29 -44
  80. package/dist/rules/routes.js.map +1 -1
  81. package/dist/rules/security.js +14 -14
  82. package/dist/rules/security.js.map +1 -1
  83. package/dist/workspace-link.d.ts +37 -0
  84. package/dist/workspace-link.d.ts.map +1 -0
  85. package/dist/workspace-link.js +72 -0
  86. package/dist/workspace-link.js.map +1 -0
  87. package/docs/examples.json +1 -1
  88. package/docs/llms-full.txt +362 -20
  89. package/docs/types.json +956 -56
  90. package/package.json +9 -9
  91. package/src/capabilities-tool.ts +229 -21
  92. package/src/check.ts +312 -65
  93. package/src/cli.ts +140 -405
  94. package/src/command-catalog.ts +1435 -0
  95. package/src/contracts.ts +28 -7
  96. package/src/doctor.ts +134 -12
  97. package/src/env-file.ts +121 -0
  98. package/src/fix-recipes.ts +101 -15
  99. package/src/internal/reserved-segment-codemod.ts +150 -0
  100. package/src/internal/typescript-import.ts +61 -11
  101. package/src/introspect.ts +82 -21
  102. package/src/load.ts +25 -6
  103. package/src/manifest-tool.ts +43 -12
  104. package/src/mcp-http.ts +1 -1
  105. package/src/mcp.ts +163 -420
  106. package/src/project-facts.ts +86 -0
  107. package/src/recipes/3.0.0.ts +24 -0
  108. package/src/recipes/index.ts +2 -0
  109. package/src/release-verification.ts +17 -0
  110. package/src/rules/comment-markers.ts +36 -0
  111. package/src/rules/index.ts +3 -4
  112. package/src/rules/routes.ts +30 -44
  113. package/src/rules/security.ts +14 -14
  114. package/src/workspace-link.ts +80 -0
package/src/contracts.ts CHANGED
@@ -41,8 +41,7 @@ async function sha256(value: string): Promise<string> {
41
41
  return [...digest].map((item) => item.toString(16).padStart(2, "0")).join("")
42
42
  }
43
43
 
44
- function schemaValue(route: ReflectedRoute, side: "request" | "response"): unknown {
45
- const schema = route.schema
44
+ function schemaValue(schema: ReflectedRoute["schema"], side: "request" | "response"): unknown {
46
45
  if (schema === undefined) return {}
47
46
  if (side === "request") {
48
47
  return {
@@ -64,11 +63,28 @@ function schemaValue(route: ReflectedRoute, side: "request" | "response"): unkno
64
63
  }
65
64
 
66
65
  export async function digestRoute(route: ReflectedRoute): Promise<ContractDigest> {
67
- const request = JSON.stringify(canonical(schemaValue(route, "request")))
68
- const response = JSON.stringify(canonical(schemaValue(route, "response")))
66
+ const request = JSON.stringify(canonical(schemaValue(route.schema, "request")))
67
+ const response = JSON.stringify(canonical(schemaValue(route.schema, "response")))
69
68
  return { request: await sha256(request), response: await sha256(response) }
70
69
  }
71
70
 
71
+ /** The digest a route with NO declared schema produces on both sides - `sha256("{}")`. A lock whose
72
+ * every route hashes to this is guarding nothing: drift detection can only ever compare empty schema to
73
+ * empty schema. Computed through the same canonical/stringify path as {@link digestRoute} so it tracks
74
+ * that path rather than a hard-coded literal. */
75
+ export async function emptySchemaDigest(): Promise<string> {
76
+ return sha256(JSON.stringify(canonical(schemaValue(undefined, "request"))))
77
+ }
78
+
79
+ /** Whether every route in `lock` hashes to the empty-schema digest (so no route declares a schema). An
80
+ * empty `routes` map is NOT vacuous in this sense - there is no route whose contract went unguarded. */
81
+ export async function isVacuousLock(lock: ContractsLock): Promise<boolean> {
82
+ const digests = Object.values(lock.routes)
83
+ if (digests.length === 0) return false
84
+ const empty = await emptySchemaDigest()
85
+ return digests.every((d) => d.request === empty && d.response === empty)
86
+ }
87
+
72
88
  export async function buildContractsLock(source: unknown): Promise<ContractsLock> {
73
89
  const routes = Object.fromEntries(
74
90
  await Promise.all(
@@ -131,11 +147,16 @@ export function parseContractsLock(value: unknown): ContractsLock {
131
147
  export async function checkContractsLock(
132
148
  cwd: string,
133
149
  file = DEFAULT_CONTRACTS_LOCK,
134
- ): Promise<{ present: boolean; diagnostics: Array<{ route?: string; message: string }> }> {
150
+ ): Promise<{
151
+ present: boolean
152
+ vacuous: boolean
153
+ diagnostics: Array<{ route?: string; message: string }>
154
+ }> {
135
155
  const path = resolve(cwd, file)
136
- if (!existsSync(path)) return { present: false, diagnostics: [] }
156
+ if (!existsSync(path)) return { present: false, vacuous: false, diagnostics: [] }
137
157
  const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
138
158
  const current = await buildContractsLock(await loadBackend(cwd))
159
+ const vacuous = await isVacuousLock(current)
139
160
  const diagnostics: Array<{ route?: string; message: string }> = []
140
161
  const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
141
162
  for (const key of [...keys].sort()) {
@@ -153,5 +174,5 @@ export async function checkContractsLock(
153
174
  })
154
175
  }
155
176
  }
156
- return { present: true, diagnostics }
177
+ return { present: true, vacuous, diagnostics }
157
178
  }
package/src/doctor.ts CHANGED
@@ -25,11 +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"
32
+ import { detectToolingDrift, type ToolingDrift } from "./mcp-root.ts"
31
33
  import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
32
34
  import { type ResolvedTarget, resolveTarget } from "./port.ts"
35
+ import { buildScriptName } from "./workspace-link.ts"
33
36
 
34
37
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
35
38
  // own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
@@ -140,10 +143,37 @@ export interface DoctorResult {
140
143
  readonly findings: readonly DoctorFinding[]
141
144
  /** Packages that resolve to more than one physical install across this workspace. */
142
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
143
165
  /** Workspace-linked deps whose `default`-condition artifact lags their `bun`-condition source.
144
166
  * Advisory (never folded into `ok`): while actively editing a linked package its dist is always
145
167
  * momentarily behind - the finding matters when a dev server starts against it. */
146
168
  readonly staleDists: readonly StaleDistFinding[]
169
+ /**
170
+ * The running CLI is a different feature version than the `@nifrajs/cli` (or `@nifrajs/core`) the
171
+ * project installs. Advisory (never folded into `ok`): a stale global/bunx binary answering about a
172
+ * project it does not match is an environment problem, not a defect in the project - but every
173
+ * answer it gives (types, checks, docs) reads as authoritative. Present only when `cliVersion` was
174
+ * supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
175
+ */
176
+ readonly toolingDrift?: ToolingDrift
147
177
  /** Static production-readiness evidence for the selected deploy target. */
148
178
  readonly readiness?: DoctorReadiness
149
179
  /**
@@ -351,18 +381,52 @@ export async function collectDuplicateInstalls(
351
381
  cwd: string,
352
382
  rootPackage: Record<string, unknown>,
353
383
  ): Promise<DuplicateInstallFinding[]> {
354
- const result = await collectIdentityParity(cwd, rootPackage, { useWorkspaceRoot: true })
355
- return result.findings.map(
356
- (finding): DuplicateInstallFinding => ({
357
- ...finding,
358
- copies: finding.copies.map((copy): DuplicateInstallCopy => ({ ...copy })),
359
- }),
360
- )
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
+ }
361
417
  }
362
418
 
363
419
  /** A workspace-linked dependency whose build artifact is older than its source. */
364
420
  export interface StaleDistFinding {
365
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
366
430
  /** The `default`-condition artifact (what Vite's SSR runner, vitest, and node consumers load),
367
431
  * relative to the doctor root when possible. `missing: true` means it was never built at all. */
368
432
  readonly distFile: string
@@ -473,6 +537,8 @@ export async function collectStaleWorkspaceDists(
473
537
  newest = candidate
474
538
  }
475
539
  if (newest === undefined) continue
540
+ const packageDir = displayPath(cwd, copy.path)
541
+ const buildScript = buildScriptName(meta)
476
542
  // Report the package once, anchored on its stalest (or missing) artifact.
477
543
  let worst: StaleDistFinding | undefined
478
544
  for (const pair of pairs) {
@@ -482,6 +548,8 @@ export async function collectStaleWorkspaceDists(
482
548
  distStat === undefined
483
549
  ? {
484
550
  package: name,
551
+ packageDir,
552
+ buildScript,
485
553
  distFile: displayPath(cwd, distPath),
486
554
  missing: true,
487
555
  sourceFile: displayPath(cwd, newest.file),
@@ -490,6 +558,8 @@ export async function collectStaleWorkspaceDists(
490
558
  : distStat.mtimeMs < newest.mtimeMs
491
559
  ? {
492
560
  package: name,
561
+ packageDir,
562
+ buildScript,
493
563
  distFile: displayPath(cwd, distPath),
494
564
  missing: false,
495
565
  sourceFile: displayPath(cwd, newest.file),
@@ -672,7 +742,7 @@ async function inferDependencyFix(
672
742
  /** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
673
743
  export async function collectDoctorResult(
674
744
  cwd: string,
675
- opts: { readonly target?: string; readonly strict?: boolean } = {},
745
+ opts: { readonly target?: string; readonly strict?: boolean; readonly cliVersion?: string } = {},
676
746
  ): Promise<DoctorResult> {
677
747
  const pkg = await readJson(join(cwd, "package.json"))
678
748
  if (pkg === undefined)
@@ -695,12 +765,18 @@ export async function collectDoctorResult(
695
765
  { includeTests: true },
696
766
  )
697
767
  findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
698
- const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
768
+ const identity = await collectAllDuplicateInstalls(cwd, pkg)
769
+ const duplicateInstalls = identity.duplicates
699
770
  const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
700
771
  const pipeline = await collectPipelineReport(cwd)
701
772
  const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
702
773
  const readiness = await collectDoctorReadiness(cwd, opts)
703
- // `staleDists` is advisory (see DoctorResult): it never fails `ok`.
774
+ // A stale binary answering about a project it does not match is an environment condition, so like
775
+ // `staleDists` it is reported but never folded into `ok`. Skipped entirely when the caller did not
776
+ // pass its own version (e.g. the MCP server, which already annotates every result with the drift).
777
+ const toolingDrift =
778
+ opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
779
+ // `staleDists` and `toolingDrift` are advisory (see DoctorResult): they never fail `ok`.
704
780
  return {
705
781
  ok:
706
782
  findings.length === 0 &&
@@ -710,8 +786,12 @@ export async function collectDoctorResult(
710
786
  ran: true,
711
787
  findings,
712
788
  duplicateInstalls,
789
+ deduplicatedInstalls: identity.deduplicated,
790
+ identityBasis: identity.basis,
791
+ ...(identity.truncated ? { identityScanTruncated: true } : {}),
713
792
  staleDists,
714
793
  readiness,
794
+ ...(toolingDrift !== undefined ? { toolingDrift } : {}),
715
795
  ...(pipeline.ran ? { pipeline } : {}),
716
796
  }
717
797
  }
@@ -719,7 +799,7 @@ export async function collectDoctorResult(
719
799
  /** Safely add undeclared imports to package.json when the version can be inferred without network I/O. */
720
800
  export async function applyDoctorAutoFix(
721
801
  cwd: string,
722
- opts: { readonly target?: string; readonly strict?: boolean } = {},
802
+ opts: { readonly target?: string; readonly strict?: boolean; readonly cliVersion?: string } = {},
723
803
  ): Promise<DoctorResult> {
724
804
  const before = await collectDoctorResult(cwd, opts)
725
805
  if (!before.ran || before.findings.length === 0) return before
@@ -798,6 +878,8 @@ export async function runDoctor(
798
878
  readonly autoFix?: boolean
799
879
  readonly strict?: boolean
800
880
  readonly target?: string
881
+ /** The running CLI's own version, so doctor can flag a binary that mismatches the project. */
882
+ readonly cliVersion?: string
801
883
  } = {},
802
884
  ): Promise<boolean> {
803
885
  const result = opts.autoFix
@@ -872,10 +954,31 @@ export async function runDoctor(
872
954
  }
873
955
  console.log("")
874
956
  }
957
+ // Advisory, printed regardless of ok: the binary answering is a different feature version than the
958
+ // one the project builds with, so its types/checks/docs describe a surface the code does not have.
959
+ if (result.toolingDrift !== undefined) {
960
+ const drift = result.toolingDrift
961
+ console.log(
962
+ `⚠ this CLI is nifra ${drift.cli}, but the project installs ${drift.package} ${drift.project} - ` +
963
+ "its types, checks, and docs may describe a different version than your code builds with.",
964
+ )
965
+ console.log(
966
+ " fix: run the project's own CLI (`bunx --bun nifra doctor` from the project directory, " +
967
+ "or ./node_modules/.bin/nifra)\n",
968
+ )
969
+ }
875
970
  if (result.ok) {
876
971
  console.log(
877
972
  "✓ every imported package is declared and identity-sensitive installs are deduplicated",
878
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
+ }
879
982
  return true
880
983
  }
881
984
  // Group by package so the fix ("add X to dependencies") is stated once with its import sites.
@@ -896,15 +999,34 @@ export async function runDoctor(
896
999
  console.log(
897
1000
  `${byPkg.size > 0 ? "\n" : ""}✗ identity-sensitive packages resolve to multiple physical copies:\n`,
898
1001
  )
1002
+ if (result.identityBasis !== undefined) console.log(` scan: ${result.identityBasis}\n`)
899
1003
  for (const finding of result.duplicateInstalls) {
900
- console.log(` ${finding.package}`)
1004
+ console.log(` ${finding.package} [${finding.cause}]`)
1005
+ console.log(` versions: ${finding.versions.join(", ")}`)
901
1006
  for (const copy of finding.copies) {
902
1007
  console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
903
1008
  }
904
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}`)
905
1015
  console.log(` fix: ${finding.remediation}`)
906
1016
  }
907
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
+ }
908
1030
  if (result.readiness?.strict === true && result.readiness.ok === false) {
909
1031
  console.log("\n✗ production readiness is incomplete under --strict")
910
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 token = argv[index] as string
72
+ if (token === 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 (token.startsWith(`${ENV_FILE_FLAG}=`)) {
82
+ const value = token.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(token)
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,