@nifrajs/cli 2.14.1 → 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 (76) hide show
  1. package/dist/check.d.ts.map +1 -1
  2. package/dist/check.js +27 -7
  3. package/dist/check.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +40 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/command-catalog.d.ts +6 -0
  8. package/dist/command-catalog.d.ts.map +1 -1
  9. package/dist/command-catalog.js +20 -2
  10. package/dist/command-catalog.js.map +1 -1
  11. package/dist/doctor.d.ts +42 -0
  12. package/dist/doctor.d.ts.map +1 -1
  13. package/dist/doctor.js +64 -7
  14. package/dist/doctor.js.map +1 -1
  15. package/dist/env-file.d.ts +17 -0
  16. package/dist/env-file.d.ts.map +1 -0
  17. package/dist/env-file.js +113 -0
  18. package/dist/env-file.js.map +1 -0
  19. package/dist/fix-recipes.d.ts.map +1 -1
  20. package/dist/fix-recipes.js +91 -15
  21. package/dist/fix-recipes.js.map +1 -1
  22. package/dist/internal/reserved-segment-codemod.d.ts +36 -0
  23. package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
  24. package/dist/internal/reserved-segment-codemod.js +134 -0
  25. package/dist/internal/reserved-segment-codemod.js.map +1 -0
  26. package/dist/internal/typescript-import.d.ts +1 -1
  27. package/dist/internal/typescript-import.d.ts.map +1 -1
  28. package/dist/internal/typescript-import.js +62 -11
  29. package/dist/internal/typescript-import.js.map +1 -1
  30. package/dist/introspect.d.ts +26 -1
  31. package/dist/introspect.d.ts.map +1 -1
  32. package/dist/introspect.js +74 -24
  33. package/dist/introspect.js.map +1 -1
  34. package/dist/mcp-http.js +1 -1
  35. package/dist/mcp-http.js.map +1 -1
  36. package/dist/mcp.d.ts +8 -0
  37. package/dist/mcp.d.ts.map +1 -1
  38. package/dist/mcp.js +68 -6
  39. package/dist/mcp.js.map +1 -1
  40. package/dist/recipes/3.0.0.d.ts +10 -0
  41. package/dist/recipes/3.0.0.d.ts.map +1 -0
  42. package/dist/recipes/3.0.0.js +23 -0
  43. package/dist/recipes/3.0.0.js.map +1 -0
  44. package/dist/recipes/index.d.ts.map +1 -1
  45. package/dist/recipes/index.js +2 -0
  46. package/dist/recipes/index.js.map +1 -1
  47. package/dist/release-verification.d.ts.map +1 -1
  48. package/dist/release-verification.js +15 -0
  49. package/dist/release-verification.js.map +1 -1
  50. package/dist/rules/routes.d.ts.map +1 -1
  51. package/dist/rules/routes.js +17 -30
  52. package/dist/rules/routes.js.map +1 -1
  53. package/dist/workspace-link.d.ts +37 -0
  54. package/dist/workspace-link.d.ts.map +1 -0
  55. package/dist/workspace-link.js +72 -0
  56. package/dist/workspace-link.js.map +1 -0
  57. package/docs/examples.json +1 -1
  58. package/docs/llms-full.txt +362 -20
  59. package/docs/types.json +933 -46
  60. package/package.json +9 -9
  61. package/src/check.ts +28 -7
  62. package/src/cli.ts +41 -2
  63. package/src/command-catalog.ts +22 -2
  64. package/src/doctor.ts +99 -8
  65. package/src/env-file.ts +121 -0
  66. package/src/fix-recipes.ts +101 -15
  67. package/src/internal/reserved-segment-codemod.ts +150 -0
  68. package/src/internal/typescript-import.ts +61 -11
  69. package/src/introspect.ts +82 -21
  70. package/src/mcp-http.ts +1 -1
  71. package/src/mcp.ts +81 -6
  72. package/src/recipes/3.0.0.ts +24 -0
  73. package/src/recipes/index.ts +2 -0
  74. package/src/release-verification.ts +17 -0
  75. package/src/rules/routes.ts +17 -30
  76. package/src/workspace-link.ts +80 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "2.14.1",
3
+ "version": "3.1.0",
4
4
  "description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -66,14 +66,14 @@
66
66
  "access": "public"
67
67
  },
68
68
  "dependencies": {
69
- "@nifrajs/client": "2.14.1",
70
- "@nifrajs/core": "2.14.1",
71
- "@nifrajs/mcp": "2.14.1",
72
- "@nifrajs/runner": "2.14.1",
73
- "@nifrajs/schema": "2.14.1",
74
- "@nifrajs/testing": "2.14.1",
75
- "@nifrajs/web": "2.14.1",
76
- "create-nifra": "2.14.1"
69
+ "@nifrajs/client": "3.1.0",
70
+ "@nifrajs/core": "3.1.0",
71
+ "@nifrajs/mcp": "3.1.0",
72
+ "@nifrajs/runner": "3.1.0",
73
+ "@nifrajs/schema": "3.1.0",
74
+ "@nifrajs/testing": "3.1.0",
75
+ "@nifrajs/web": "3.1.0",
76
+ "create-nifra": "3.1.0"
77
77
  },
78
78
  "repository": {
79
79
  "type": "git",
package/src/check.ts CHANGED
@@ -2585,7 +2585,7 @@ export async function collectCheckResult(
2585
2585
  diagnostics.push({
2586
2586
  rule: "duplicate-install",
2587
2587
  severity: "error",
2588
- message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}`,
2588
+ message: `${finding.package} identity preflight found ${finding.cause} (${copies}) - ${finding.explanation}${finding.topology === undefined ? "" : `. Topology: ${finding.topology}`}${finding.scope === undefined ? "" : `. Scope: ${finding.scope}`}`,
2589
2589
  fix: finding.remediation,
2590
2590
  suggestion: {
2591
2591
  kind: "manual",
@@ -2593,29 +2593,50 @@ export async function collectCheckResult(
2593
2593
  steps: [
2594
2594
  "Align workspace dependency and peer ranges on one compatible version.",
2595
2595
  "Remove stale nested installs and run the package manager from the workspace root.",
2596
- "Run `nifra doctor` again; do not suppress same-version duplicate paths.",
2596
+ 'If the copies come from a linked sibling repository and collapsing them is not an option, declare the package: "nifra": { "singleCopy": ["<package>"] } in package.json, plus the bunfig.toml preload.',
2597
+ "Run `nifra doctor` again; do not suppress an undeclared same-version duplicate path.",
2597
2598
  ],
2598
2599
  },
2599
2600
  })
2600
2601
  }
2602
+ // Declared single-copy: the resolver collapses these before anything loads, so the build is sound
2603
+ // and the gate stays green. It is still worth a line - the guarantee lives in a declaration now,
2604
+ // and a reader who deletes it gets a null hook dispatcher with no obvious cause.
2605
+ for (const finding of dr.deduplicatedInstalls ?? []) {
2606
+ const copies = finding.copies.map((copy) => copy.path).join("; ")
2607
+ diagnostics.push({
2608
+ rule: "duplicate-install",
2609
+ severity: "warning",
2610
+ message: `${finding.package} is installed at ${finding.copies.length} paths (${copies}) and is declared single-copy - nifra resolves every duplicate to this app's copy`,
2611
+ fix: finding.remediation,
2612
+ })
2613
+ }
2601
2614
  // Advisory (never fails the gate): while actively editing a linked package its dist is always
2602
2615
  // momentarily behind. The finding earns its keep when a dev server is about to start against it -
2603
2616
  // Bun reads live `src` while Vite's SSR runner reads the artifact, so a stale one 500s inside
2604
2617
  // framework/shared-package code and reads exactly like an upstream regression.
2605
2618
  for (const f of dr.staleDists) {
2619
+ // The rebuild is only actionable with the LOCATION: a workspace link routinely points into a
2620
+ // sibling repo, so "rebuild @nifrajs/core" left the developer to go find the checkout and guess
2621
+ // the script. Name the directory and the package's own script, or say plainly that it declares
2622
+ // none - never suggest a command that would fail.
2623
+ const rebuild =
2624
+ f.buildScript === undefined
2625
+ ? `${f.packageDir} declares no build script - build it the way that package expects`
2626
+ : `cd ${f.packageDir} && bun run ${f.buildScript} (or \`nifra fix --code NF-C010\`)`
2606
2627
  diagnostics.push({
2607
2628
  rule: "stale-workspace-dist",
2608
2629
  severity: "warning",
2609
2630
  message: f.missing
2610
- ? `${f.package} was never built - ${f.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src - rebuild ${f.package}`
2611
- : `${f.package} has a stale build artifact - ${f.distFile} is ${f.behindSeconds}s older than ${f.sourceFile}, and Vite SSR/node consumers read the artifact while Bun reads src - rebuild ${f.package}`,
2612
- fix: `rebuild ${f.package}`,
2613
- evidence: [f.package, f.distFile, f.sourceFile],
2631
+ ? `${f.package} was never built - ${f.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src - ${rebuild}`
2632
+ : `${f.package} has a stale build artifact - ${f.distFile} is ${f.behindSeconds}s older than ${f.sourceFile}, and Vite SSR/node consumers read the artifact while Bun reads src - ${rebuild}`,
2633
+ fix: rebuild,
2634
+ evidence: [f.package, f.distFile, f.sourceFile, f.packageDir],
2614
2635
  suggestion: {
2615
2636
  kind: "manual",
2616
2637
  title: `Rebuild ${f.package}`,
2617
2638
  steps: [
2618
- `Run the package's build (usually \`bun run build\` in its directory) so ${f.distFile} matches its source again.`,
2639
+ rebuild,
2619
2640
  "Only workspace-linked installs drift; npm tarballs are immutable and never flagged.",
2620
2641
  ],
2621
2642
  },
package/src/cli.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import { discoverRoutes } from "@nifrajs/web/fs"
22
22
  import type { BunPlugin } from "bun"
23
23
  import { bindCommandArgv, findCommandSpec, renderCommandCatalogHelp } from "./command-catalog.ts"
24
+ import { applyEnvFiles, takeEnvFileFlags } from "./env-file.ts"
24
25
  import { type LoadedApp, loadApp, type NifraFramework } from "./load.ts"
25
26
  import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
26
27
 
@@ -188,10 +189,16 @@ routes/ from the current directory. Run from your project root.
188
189
  Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with \`--port <n>\` (alias \`-p\`) or the
189
190
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).
190
191
 
192
+ Env: \`--env-file <path>\` works on EVERY command and may be repeated (later files win). Commands that
193
+ reflect your app import it, so an app that validates its environment at module scope needs that
194
+ environment present - without it the app aborts before nifra runs a single check. A variable already
195
+ set in the process environment is never overwritten by a file.
196
+ e.g. \`nifra check --env-file .env.local\`
197
+
191
198
  ${renderCommandCatalogHelp()}`
192
199
 
193
200
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
194
- const CLI_VERSION = "2.14.1"
201
+ const CLI_VERSION = "3.1.0"
195
202
 
196
203
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
197
204
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -661,8 +668,34 @@ export function assureBundleRequested(argv: readonly string[]): boolean {
661
668
  )
662
669
  }
663
670
 
671
+ /**
672
+ * A command that reflects the project imports it, and an app that validates its environment at module
673
+ * scope calls `process.exit` from inside that import. Nothing is throwable at that point, so the whole
674
+ * output is the app's own abort message with no hint that a nifra command triggered it. Name the cause
675
+ * on the way out and point at the flag that supplies the environment.
676
+ */
677
+ function installReflectionExitHint(): (command: string | undefined) => void {
678
+ let inFlight: string | undefined
679
+ process.on("exit", (code) => {
680
+ if (inFlight === undefined || code === 0) return
681
+ process.stderr.write(
682
+ `\n[nifra] \`nifra ${inFlight}\` reflects this project by importing it, and the import exited ` +
683
+ `with code ${code} before the command produced a result - the message above came from your app, not nifra.\n` +
684
+ ` If it is the app's environment validation, supply the environment and re-run:\n` +
685
+ ` nifra ${inFlight} --env-file .env.local\n`,
686
+ )
687
+ })
688
+ return (command) => {
689
+ inFlight = command
690
+ }
691
+ }
692
+
664
693
  async function main(): Promise<void> {
665
- const argv = Bun.argv.slice(2)
694
+ const { argv: rawArgv, files: envFiles } = takeEnvFileFlags(Bun.argv.slice(2))
695
+ // Applied before any command runs: a reflecting command imports the app on its first await, and the
696
+ // app reads its environment at module scope, so the variables must already be in place by then.
697
+ if (envFiles.length > 0) await applyEnvFiles(process.cwd(), envFiles)
698
+ const argv = rawArgv
666
699
  const command = argv[0]
667
700
  if (command === undefined || command === "--help" || command === "-h" || command === "help") {
668
701
  console.log(HELP)
@@ -709,9 +742,12 @@ async function main(): Promise<void> {
709
742
  }
710
743
  const catalogSpec = command === undefined ? undefined : findCommandSpec(command)
711
744
  if (catalogSpec?.transports.includes("cli")) {
745
+ const markReflecting = installReflectionExitHint()
712
746
  try {
713
747
  const input = bindCommandArgv(catalogSpec, argv.slice(1))
748
+ markReflecting(command)
714
749
  const output = await catalogSpec.run(input, { cwd: process.cwd(), cliVersion: CLI_VERSION })
750
+ markReflecting(undefined)
715
751
  const asRecord = output as unknown as Record<string, unknown>
716
752
  const wantsJson = asRecord.json === true || argv.includes("--json")
717
753
  if (wantsJson) {
@@ -723,6 +759,9 @@ async function main(): Promise<void> {
723
759
  if (catalogSpec.success !== undefined && !catalogSpec.success(output, input))
724
760
  process.exitCode = 1
725
761
  } catch (err) {
762
+ // A thrown error is nifra's own reporting path, not a silent abort: clear the sentinel so the
763
+ // hint stays reserved for the case where the process died inside the app import.
764
+ markReflecting(undefined)
726
765
  console.error(formatCliError(err))
727
766
  process.exitCode = 1
728
767
  }
@@ -376,6 +376,9 @@ interface ContextCommandOutput {
376
376
  interface FixCommandOutput {
377
377
  readonly ok: boolean
378
378
  readonly changed: readonly string[]
379
+ /** Recipes that refused, with the reason. A recipe that cannot act says so here instead of
380
+ * aborting the run or - worse - reporting nothing at all. */
381
+ readonly failed: readonly { readonly code: string; readonly reason: string }[]
379
382
  readonly diagnostics: readonly unknown[]
380
383
  }
381
384
  interface SnapshotCommandOutput {
@@ -1071,6 +1074,7 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1071
1074
  properties: {
1072
1075
  ok: { type: "boolean" },
1073
1076
  changed: { type: "array" },
1077
+ failed: { type: "array" },
1074
1078
  diagnostics: { type: "array" },
1075
1079
  },
1076
1080
  required: ["ok", "diagnostics"],
@@ -1091,18 +1095,34 @@ const fixSpec: CommandSpec<FixInput, FixCommandOutput> = {
1091
1095
  ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
1092
1096
  })
1093
1097
  const changed: string[] = []
1098
+ const failed: { code: string; reason: string }[] = []
1094
1099
  for (const diagnostic of result.structuredDiagnostics ?? [])
1095
1100
  if (value.code === undefined || diagnostic.code === value.code)
1096
- changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
1101
+ // One recipe that cannot act must not cancel the others, and must not pass silently either:
1102
+ // record why and keep going, so a run over many diagnostics still reports every outcome.
1103
+ try {
1104
+ changed.push(...(await applyDiagnosticRecipe(ctx.cwd, diagnostic)))
1105
+ } catch (error) {
1106
+ failed.push({
1107
+ code: diagnostic.code,
1108
+ reason: error instanceof Error ? error.message : String(error),
1109
+ })
1110
+ }
1097
1111
  const final = await collectCheckResult(ctx.cwd, {
1098
1112
  lintsOnly: true,
1099
1113
  ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
1100
1114
  })
1101
- return { ok: final.ok, changed, diagnostics: final.structuredDiagnostics ?? final.diagnostics }
1115
+ return {
1116
+ ok: final.ok,
1117
+ changed,
1118
+ failed,
1119
+ diagnostics: final.structuredDiagnostics ?? final.diagnostics,
1120
+ }
1102
1121
  },
1103
1122
  render: (out) => [
1104
1123
  out.ok ? "✓ fixes applied" : "✖ unresolved diagnostics remain",
1105
1124
  ...out.changed.map((file) => ` changed ${file}`),
1125
+ ...out.failed.map((f) => ` could not fix ${f.code}: ${f.reason}`),
1106
1126
  ...out.diagnostics.map((d) => ` ${String((d as { code?: unknown }).code ?? "diagnostic")}`),
1107
1127
  ],
1108
1128
  success: (out) => out.ok,
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 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
+ }