@nifrajs/cli 2.10.0 → 2.12.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 (193) hide show
  1. package/README.md +7 -1
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +133 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +741 -148
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +21 -14
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +288 -73
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/dev-bun-config.d.ts +17 -6
  31. package/dist/dev-bun-config.d.ts.map +1 -1
  32. package/dist/dev-bun-config.js +51 -14
  33. package/dist/dev-bun-config.js.map +1 -1
  34. package/dist/diagnostics.d.ts +23 -0
  35. package/dist/diagnostics.d.ts.map +1 -0
  36. package/dist/diagnostics.js +9 -0
  37. package/dist/diagnostics.js.map +1 -0
  38. package/dist/diff-tool.d.ts +2 -0
  39. package/dist/diff-tool.d.ts.map +1 -1
  40. package/dist/diff-tool.js +1 -1
  41. package/dist/diff-tool.js.map +1 -1
  42. package/dist/docs-search.d.ts +8 -3
  43. package/dist/docs-search.d.ts.map +1 -1
  44. package/dist/docs-search.js +36 -15
  45. package/dist/docs-search.js.map +1 -1
  46. package/dist/doctor.d.ts +38 -2
  47. package/dist/doctor.d.ts.map +1 -1
  48. package/dist/doctor.js +189 -7
  49. package/dist/doctor.js.map +1 -1
  50. package/dist/examples.d.ts +1 -2
  51. package/dist/examples.d.ts.map +1 -1
  52. package/dist/examples.js +37 -16
  53. package/dist/examples.js.map +1 -1
  54. package/dist/fix-recipes.d.ts +12 -0
  55. package/dist/fix-recipes.d.ts.map +1 -0
  56. package/dist/fix-recipes.js +106 -0
  57. package/dist/fix-recipes.js.map +1 -0
  58. package/dist/init-agents.d.ts.map +1 -1
  59. package/dist/init-agents.js +60 -4
  60. package/dist/init-agents.js.map +1 -1
  61. package/dist/internal/source-facts.d.ts +15 -0
  62. package/dist/internal/source-facts.d.ts.map +1 -0
  63. package/dist/internal/source-facts.js +82 -0
  64. package/dist/internal/source-facts.js.map +1 -0
  65. package/dist/internal/typescript-import.d.ts +9 -0
  66. package/dist/internal/typescript-import.d.ts.map +1 -1
  67. package/dist/internal/typescript-import.js +38 -0
  68. package/dist/internal/typescript-import.js.map +1 -1
  69. package/dist/introspect.d.ts +6 -2
  70. package/dist/introspect.d.ts.map +1 -1
  71. package/dist/introspect.js +60 -5
  72. package/dist/introspect.js.map +1 -1
  73. package/dist/load.d.ts +10 -0
  74. package/dist/load.d.ts.map +1 -1
  75. package/dist/load.js +1 -0
  76. package/dist/load.js.map +1 -1
  77. package/dist/manifest-tool.d.ts.map +1 -1
  78. package/dist/manifest-tool.js +14 -9
  79. package/dist/manifest-tool.js.map +1 -1
  80. package/dist/mcp-docs-tools.d.ts.map +1 -1
  81. package/dist/mcp-docs-tools.js +3 -0
  82. package/dist/mcp-docs-tools.js.map +1 -1
  83. package/dist/mcp-http.js +1 -1
  84. package/dist/mcp-root.d.ts +83 -0
  85. package/dist/mcp-root.d.ts.map +1 -0
  86. package/dist/mcp-root.js +221 -0
  87. package/dist/mcp-root.js.map +1 -0
  88. package/dist/mcp-run.d.ts.map +1 -1
  89. package/dist/mcp-run.js +18 -4
  90. package/dist/mcp-run.js.map +1 -1
  91. package/dist/mcp.d.ts +27 -4
  92. package/dist/mcp.d.ts.map +1 -1
  93. package/dist/mcp.js +360 -27
  94. package/dist/mcp.js.map +1 -1
  95. package/dist/pipeline-guard.d.ts +25 -10
  96. package/dist/pipeline-guard.d.ts.map +1 -1
  97. package/dist/pipeline-guard.js +35 -15
  98. package/dist/pipeline-guard.js.map +1 -1
  99. package/dist/pipeline-report.d.ts +34 -0
  100. package/dist/pipeline-report.d.ts.map +1 -0
  101. package/dist/pipeline-report.js +282 -0
  102. package/dist/pipeline-report.js.map +1 -0
  103. package/dist/project-path.d.ts +3 -0
  104. package/dist/project-path.d.ts.map +1 -0
  105. package/dist/project-path.js +36 -0
  106. package/dist/project-path.js.map +1 -0
  107. package/dist/replay.d.ts +12 -0
  108. package/dist/replay.d.ts.map +1 -0
  109. package/dist/replay.js +36 -0
  110. package/dist/replay.js.map +1 -0
  111. package/dist/rules/codes.d.ts +42 -0
  112. package/dist/rules/codes.d.ts.map +1 -0
  113. package/dist/rules/codes.js +52 -0
  114. package/dist/rules/codes.js.map +1 -0
  115. package/dist/rules/comment-markers.d.ts +10 -0
  116. package/dist/rules/comment-markers.d.ts.map +1 -0
  117. package/dist/rules/comment-markers.js +24 -0
  118. package/dist/rules/comment-markers.js.map +1 -0
  119. package/dist/rules/index.d.ts +31 -0
  120. package/dist/rules/index.d.ts.map +1 -0
  121. package/dist/rules/index.js +133 -0
  122. package/dist/rules/index.js.map +1 -0
  123. package/dist/rules/legacy.d.ts +7 -0
  124. package/dist/rules/legacy.d.ts.map +1 -0
  125. package/dist/rules/legacy.js +85 -0
  126. package/dist/rules/legacy.js.map +1 -0
  127. package/dist/rules/routes.d.ts +12 -0
  128. package/dist/rules/routes.d.ts.map +1 -0
  129. package/dist/rules/routes.js +142 -0
  130. package/dist/rules/routes.js.map +1 -0
  131. package/dist/rules/security.d.ts +10 -0
  132. package/dist/rules/security.d.ts.map +1 -0
  133. package/dist/rules/security.js +471 -0
  134. package/dist/rules/security.js.map +1 -0
  135. package/dist/search-terms.d.ts +2 -0
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -1
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/types-search.d.ts +1 -2
  140. package/dist/types-search.d.ts.map +1 -1
  141. package/dist/types-search.js +33 -16
  142. package/dist/types-search.js.map +1 -1
  143. package/dist/verification.d.ts +9 -0
  144. package/dist/verification.d.ts.map +1 -1
  145. package/dist/verification.js +17 -1
  146. package/dist/verification.js.map +1 -1
  147. package/dist/work-graph.d.ts +123 -0
  148. package/dist/work-graph.d.ts.map +1 -0
  149. package/dist/work-graph.js +465 -0
  150. package/dist/work-graph.js.map +1 -0
  151. package/docs/examples.json +32 -2
  152. package/docs/llms-full.txt +1105 -27
  153. package/docs/types.json +1772 -137
  154. package/package.json +34 -9
  155. package/src/assure-hydration.ts +606 -0
  156. package/src/assure.ts +316 -2
  157. package/src/capabilities-tool.ts +80 -1
  158. package/src/check.ts +923 -169
  159. package/src/cli.ts +297 -76
  160. package/src/contract-proof.ts +194 -0
  161. package/src/contracts.ts +157 -0
  162. package/src/dev-bun-config.ts +57 -14
  163. package/src/diagnostics.ts +32 -0
  164. package/src/diff-tool.ts +1 -1
  165. package/src/docs-search.ts +45 -15
  166. package/src/doctor.ts +286 -8
  167. package/src/examples.ts +43 -15
  168. package/src/fix-recipes.ts +126 -0
  169. package/src/init-agents.ts +61 -4
  170. package/src/internal/source-facts.ts +133 -0
  171. package/src/internal/typescript-import.ts +29 -0
  172. package/src/introspect.ts +81 -6
  173. package/src/load.ts +11 -0
  174. package/src/manifest-tool.ts +16 -10
  175. package/src/mcp-docs-tools.ts +3 -0
  176. package/src/mcp-http.ts +1 -1
  177. package/src/mcp-root.ts +271 -0
  178. package/src/mcp-run.ts +19 -4
  179. package/src/mcp.ts +440 -34
  180. package/src/pipeline-guard.ts +44 -14
  181. package/src/pipeline-report.ts +341 -0
  182. package/src/project-path.ts +36 -0
  183. package/src/replay.ts +44 -0
  184. package/src/rules/codes.ts +57 -0
  185. package/src/rules/comment-markers.ts +26 -0
  186. package/src/rules/index.ts +175 -0
  187. package/src/rules/legacy.ts +93 -0
  188. package/src/rules/routes.ts +157 -0
  189. package/src/rules/security.ts +505 -0
  190. package/src/search-terms.ts +9 -4
  191. package/src/types-search.ts +39 -15
  192. package/src/verification.ts +18 -1
  193. package/src/work-graph.ts +613 -0
package/src/doctor.ts CHANGED
@@ -17,10 +17,12 @@
17
17
  * undeclared import there fails a clean `bun install --frozen-lockfile` build just the same.
18
18
  */
19
19
  import type { Dirent } from "node:fs"
20
- import { readdir, realpath, stat } from "node:fs/promises"
20
+ import { lstat, readdir, realpath, stat } from "node:fs/promises"
21
21
  import { builtinModules } from "node:module"
22
22
  import { dirname, join, relative, sep } from "node:path"
23
23
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
24
+ import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
25
+ import { type ResolvedTarget, resolveTarget } from "./port.ts"
24
26
 
25
27
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
26
28
  // own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
@@ -135,12 +137,47 @@ export interface DoctorResult {
135
137
  * Advisory (never folded into `ok`): while actively editing a linked package its dist is always
136
138
  * momentarily behind - the finding matters when a dev server starts against it. */
137
139
  readonly staleDists: readonly StaleDistFinding[]
140
+ /** Static production-readiness evidence for the selected deploy target. */
141
+ readonly readiness?: DoctorReadiness
142
+ /**
143
+ * Which bundler this app's `dev`/`build` phases run on, read statically, plus the config hazards
144
+ * that exist only because there are two. Absent when the directory is not a nifra app.
145
+ *
146
+ * Reported by doctor rather than only by `dev`/`build` because the answer governs how the rest of a
147
+ * project is read - which plugin slot is live, whether `conditions` reach the client bundle, which
148
+ * toolchain compiles a component - and asking for it should not require starting a server.
149
+ */
150
+ readonly pipeline?: PipelineReport
138
151
  /** Dependencies written by `--auto-fix` / MCP `autoFix:true`. */
139
152
  readonly fixed?: readonly DoctorAppliedFix[]
140
153
  /** Findings that were safe to report but not safe to write automatically. */
141
154
  readonly skippedFixes?: readonly DoctorSkippedFix[]
142
155
  }
143
156
 
157
+ export type DoctorReadinessStatus = "configured" | "absent" | "not-applicable"
158
+
159
+ export interface DoctorReadinessItem {
160
+ readonly id:
161
+ | "request-timeout"
162
+ | "admission"
163
+ | "request-id-tracing"
164
+ | "health-route"
165
+ | "log-redaction"
166
+ | "graceful-lifecycle"
167
+ readonly label: string
168
+ readonly status: DoctorReadinessStatus
169
+ readonly evidence?: string
170
+ }
171
+
172
+ export interface DoctorReadiness {
173
+ readonly target: string
174
+ readonly targetSource: ResolvedTarget["source"] | null
175
+ readonly strict: boolean
176
+ /** True when every applicable rule has static evidence. */
177
+ readonly ok: boolean
178
+ readonly items: readonly DoctorReadinessItem[]
179
+ }
180
+
144
181
  export interface DuplicateInstallCopy {
145
182
  /** Installed version, or `unknown` when package metadata is incomplete. */
146
183
  readonly version: string
@@ -342,6 +379,66 @@ async function resolvedInstalledCopy(
342
379
  }
343
380
  }
344
381
 
382
+ const MAX_LINKED_PACKAGES = 64
383
+ const MAX_LINK_PROBES = 4_096
384
+
385
+ /** Is `path` inside `root` (or `root` itself)? Both must already be real paths. */
386
+ const isInside = (root: string, path: string): boolean =>
387
+ path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
388
+
389
+ /**
390
+ * The repo boundary for a linked package: the nearest ancestor holding a `.git`, so the upward
391
+ * `node_modules` walk inside a sibling checkout stops where that checkout does instead of climbing
392
+ * into a shared parent directory. Falls back to the package directory itself.
393
+ */
394
+ async function linkedRepoBoundary(packageRoot: string): Promise<string> {
395
+ let dir = packageRoot
396
+ for (let depth = 0; depth < 8; depth++) {
397
+ if (await pathExists(join(dir, ".git"))) return dir
398
+ const parent = dirname(dir)
399
+ if (parent === dir) break
400
+ dir = parent
401
+ }
402
+ return packageRoot
403
+ }
404
+
405
+ /**
406
+ * Dependency roots that are symlinks OUT of the scanned workspace - a sibling checkout linked in with
407
+ * `link:`/`file:`/`npm link`. Their own `node_modules` is a second physical install root, so a shared
408
+ * package (`@nifrajs/core`, React) resolves to a different directory there than it does here. That is
409
+ * a real duplicate: module identity is path-based, so registries, symbols, and hooks do not match
410
+ * across the two copies - the exact split that reads as "my types collapsed for no reason".
411
+ *
412
+ * One level deep and bounded: links inside a linked package are not followed.
413
+ */
414
+ async function linkedPackageRoots(
415
+ importerRoots: readonly string[],
416
+ manifests: readonly Record<string, unknown>[],
417
+ scanRoot: string,
418
+ ): Promise<string[]> {
419
+ const realScanRoot = await realpath(scanRoot).catch(() => scanRoot)
420
+ const found = new Map<string, true>()
421
+ let probes = 0
422
+ for (const [index, importerRoot] of importerRoots.entries()) {
423
+ const pkg = manifests[index]
424
+ if (pkg === undefined) continue
425
+ const names = new Set<string>()
426
+ for (const field of DEPENDENCY_FIELDS) for (const name of depNames(pkg, field)) names.add(name)
427
+ for (const name of names) {
428
+ if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) return [...found.keys()]
429
+ const candidate = join(importerRoot, "node_modules", ...name.split("/"))
430
+ // `lstat` first: on macOS `realpath` also rewrites symlinked ANCESTORS (`/tmp` ->
431
+ // `/private/tmp`), which would read as a link out of the workspace for every dependency.
432
+ const link = await lstat(candidate).catch(() => undefined)
433
+ if (link === undefined || !link.isSymbolicLink()) continue
434
+ const resolved = await realpath(candidate).catch(() => undefined)
435
+ if (resolved === undefined || isInside(realScanRoot, resolved)) continue
436
+ found.set(resolved, true)
437
+ }
438
+ }
439
+ return [...found.keys()]
440
+ }
441
+
345
442
  const displayPath = (cwd: string, path: string): string => {
346
443
  const rel = relative(cwd, path)
347
444
  return rel === "" ? "." : rel
@@ -454,6 +551,28 @@ export async function collectDuplicateInstalls(
454
551
  if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
455
552
  }
456
553
 
554
+ // Sibling checkouts linked in (`link:`/`file:`/`npm link`) install their own copies. Their
555
+ // resolution is what the runtime and `tsc` actually use for code that crosses the link, so a split
556
+ // there is as real as one inside this workspace - and invisible to a scan bounded by the workspace.
557
+ const linkedRoots = await linkedPackageRoots(
558
+ [...importers.map((importer) => importer.root), scanRoot],
559
+ [...importers.map((importer) => importer.package), workspace?.package ?? rootPackage],
560
+ scanRoot,
561
+ )
562
+ for (const linkedRoot of linkedRoots) {
563
+ const linkedPackage = await readJson(join(linkedRoot, "package.json"))
564
+ const boundary = await linkedRepoBoundary(linkedRoot)
565
+ const linkedTargets = new Set([
566
+ ...targets,
567
+ ...(linkedPackage === undefined ? [] : duplicateTargets(linkedPackage)),
568
+ ])
569
+ for (const name of linkedTargets) {
570
+ if (linkedPackage?.name === name) continue
571
+ const copy = await resolvedInstalledCopy(linkedRoot, boundary, name)
572
+ if (copy !== undefined) record(name, copy, displayPath(cwd, linkedRoot))
573
+ }
574
+ }
575
+
457
576
  const findings: DuplicateInstallFinding[] = []
458
577
  for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
459
578
  if (copies.size < 2) continue
@@ -621,6 +740,110 @@ export async function collectStaleWorkspaceDists(
621
740
  return findings.sort((a, b) => a.package.localeCompare(b.package))
622
741
  }
623
742
 
743
+ interface ReadinessSource {
744
+ readonly file: string
745
+ readonly code: string
746
+ readonly source: string
747
+ }
748
+
749
+ function sourceLineAt(source: string, index: number): number {
750
+ let line = 1
751
+ for (let i = 0; i < index && i < source.length; i++) if (source[i] === "\n") line++
752
+ return line
753
+ }
754
+
755
+ function readinessEvidence(
756
+ files: readonly ReadinessSource[],
757
+ pattern: RegExp,
758
+ source = "code",
759
+ ): string | undefined {
760
+ for (const file of files) {
761
+ const content = source === "source" ? file.source : file.code
762
+ const match = pattern.exec(content)
763
+ if (match !== null) return `${file.file}:${sourceLineAt(content, match.index)}`
764
+ }
765
+ return undefined
766
+ }
767
+
768
+ /** Read only source shapes used to report production readiness. Values and secrets are never loaded. */
769
+ async function collectDoctorReadiness(
770
+ cwd: string,
771
+ opts: { readonly target?: string; readonly strict?: boolean },
772
+ ): Promise<DoctorReadiness> {
773
+ const resolved = await resolveTarget(cwd, opts.target)
774
+ const files: ReadinessSource[] = []
775
+ await walkSource(cwd, (file, source) => {
776
+ files.push({ file, source, code: codePositionMask(source) })
777
+ })
778
+ const edgeTarget = /^(?:cf-pages|vercel|workers|edge|static)$/.test(resolved?.target ?? "")
779
+ const configured = (
780
+ id: DoctorReadinessItem["id"],
781
+ label: string,
782
+ pattern: RegExp,
783
+ sourcePattern?: RegExp,
784
+ ): DoctorReadinessItem => {
785
+ const evidence = readinessEvidence(files, pattern)
786
+ const routeEvidence =
787
+ evidence === undefined && sourcePattern !== undefined
788
+ ? readinessEvidence(files, sourcePattern, "source")
789
+ : undefined
790
+ const found = evidence ?? routeEvidence
791
+ return found === undefined
792
+ ? { id, label, status: "absent" }
793
+ : { id, label, status: "configured", evidence: found }
794
+ }
795
+
796
+ const items: DoctorReadinessItem[] = [
797
+ configured(
798
+ "request-timeout",
799
+ "request timeout or budget",
800
+ /\b(?:requestTimeoutMs|createRequestBudget|admitDeadline)\s*[:(]/,
801
+ ),
802
+ // `admission: controller`, the shorthand `{ admission }` (trailing comma or closing brace), or
803
+ // the documented constructor call - shorthand is the idiomatic form when the controller is an
804
+ // import or module const.
805
+ configured(
806
+ "admission",
807
+ "capacity admission",
808
+ /\badmission\s*[:,}]|\bcreateAdmissionController\s*\(/,
809
+ ),
810
+ configured(
811
+ "request-id-tracing",
812
+ "request id or tracing middleware",
813
+ /\b(?:requestId|tracing)\s*\(/,
814
+ ),
815
+ configured(
816
+ "health-route",
817
+ "health route",
818
+ /\bhealthcheck\s*\(/,
819
+ // Conventional health paths: /health, /healthz (the k8s form healthcheck() is most often
820
+ // configured to serve), /health-check, and the underscore-prefixed variants, each also
821
+ // matching as a prefix (`/health/live`). `/healthy` etc. stay non-matches: the path must end
822
+ // (closing quote) or continue with a segment right after the recognized name.
823
+ /\.(?:get|head)\s*\(\s*["']\/_?health(?:z|-check)?(?:["']|\/)/,
824
+ ),
825
+ // Asserts "a redacting logger is INSTALLED", so only the call forms count: the redacting
826
+ // constructors, or @nifrajs/middleware's `logger()` structured request logger (which logs only
827
+ // method/path/status/duration - nothing redactable). A bare `logger:` property proves nothing:
828
+ // `logger: console` matches it and redacts nothing.
829
+ configured("log-redaction", "log redaction", /\b(?:jsonLogger|redactLogFields|logger)\s*\(/),
830
+ edgeTarget
831
+ ? { id: "graceful-lifecycle", label: "graceful lifecycle", status: "not-applicable" }
832
+ : configured(
833
+ "graceful-lifecycle",
834
+ "graceful lifecycle",
835
+ /\bgracefulSignals\s*:\s*true\b|\bonStop\s*\(/,
836
+ ),
837
+ ]
838
+ return {
839
+ target: resolved?.target ?? "unknown",
840
+ targetSource: resolved?.source ?? null,
841
+ strict: opts.strict === true,
842
+ ok: items.every((item) => item.status !== "absent"),
843
+ items,
844
+ }
845
+ }
846
+
624
847
  function depRecord(
625
848
  pkg: Record<string, unknown>,
626
849
  field: string,
@@ -677,7 +900,10 @@ async function inferDependencyFix(
677
900
  }
678
901
 
679
902
  /** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
680
- export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
903
+ export async function collectDoctorResult(
904
+ cwd: string,
905
+ opts: { readonly target?: string; readonly strict?: boolean } = {},
906
+ ): Promise<DoctorResult> {
681
907
  const pkg = await readJson(join(cwd, "package.json"))
682
908
  if (pkg === undefined)
683
909
  return { ok: true, ran: false, findings: [], duplicateInstalls: [], staleDists: [] }
@@ -701,19 +927,31 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
701
927
  findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
702
928
  const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
703
929
  const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
930
+ const pipeline = await collectPipelineReport(cwd)
931
+ const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
932
+ const readiness = await collectDoctorReadiness(cwd, opts)
704
933
  // `staleDists` is advisory (see DoctorResult): it never fails `ok`.
705
934
  return {
706
- ok: findings.length === 0 && duplicateInstalls.length === 0,
935
+ ok:
936
+ findings.length === 0 &&
937
+ duplicateInstalls.length === 0 &&
938
+ pipelineErrors.length === 0 &&
939
+ (opts.strict !== true || readiness.ok),
707
940
  ran: true,
708
941
  findings,
709
942
  duplicateInstalls,
710
943
  staleDists,
944
+ readiness,
945
+ ...(pipeline.ran ? { pipeline } : {}),
711
946
  }
712
947
  }
713
948
 
714
949
  /** Safely add undeclared imports to package.json when the version can be inferred without network I/O. */
715
- export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
716
- const before = await collectDoctorResult(cwd)
950
+ export async function applyDoctorAutoFix(
951
+ cwd: string,
952
+ opts: { readonly target?: string; readonly strict?: boolean } = {},
953
+ ): Promise<DoctorResult> {
954
+ const before = await collectDoctorResult(cwd, opts)
717
955
  if (!before.ran || before.findings.length === 0) return before
718
956
 
719
957
  const rootPackage = await readJson(join(cwd, "package.json"))
@@ -774,7 +1012,7 @@ export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
774
1012
  if (changed) await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
775
1013
  }
776
1014
 
777
- const after = fixed.length > 0 ? await collectDoctorResult(cwd) : before
1015
+ const after = fixed.length > 0 ? await collectDoctorResult(cwd, opts) : before
778
1016
  return {
779
1017
  ...after,
780
1018
  ...(fixed.length > 0 ? { fixed } : {}),
@@ -785,9 +1023,16 @@ export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
785
1023
  /** Run doctor; print a report (`--json` for machine output) and return whether it passed. */
786
1024
  export async function runDoctor(
787
1025
  cwd: string,
788
- opts: { readonly json?: boolean; readonly autoFix?: boolean } = {},
1026
+ opts: {
1027
+ readonly json?: boolean
1028
+ readonly autoFix?: boolean
1029
+ readonly strict?: boolean
1030
+ readonly target?: string
1031
+ } = {},
789
1032
  ): Promise<boolean> {
790
- const result = opts.autoFix ? await applyDoctorAutoFix(cwd) : await collectDoctorResult(cwd)
1033
+ const result = opts.autoFix
1034
+ ? await applyDoctorAutoFix(cwd, opts)
1035
+ : await collectDoctorResult(cwd, opts)
791
1036
  if (opts.json) {
792
1037
  console.log(JSON.stringify(result, null, 2))
793
1038
  return result.ok
@@ -811,6 +1056,20 @@ export async function runDoctor(
811
1056
  }
812
1057
  console.log("")
813
1058
  }
1059
+ // Printed before the findings and regardless of ok: which bundler owns this app is the frame the
1060
+ // rest of the report is read in, not a footnote to it.
1061
+ if (result.pipeline !== undefined) {
1062
+ const p = result.pipeline
1063
+ console.log(
1064
+ p.pipeline === "unknown"
1065
+ ? `• bundler: could not be read from ${p.configFile} - ${p.reason}\n`
1066
+ : `• bundler: ${p.pipeline} (${p.reason})\n`,
1067
+ )
1068
+ for (const f of p.findings) {
1069
+ console.log(`${f.severity === "error" ? "✗" : "⚠"} ${f.file}:${f.line ?? 0} ${f.message}`)
1070
+ console.log(` fix: ${f.fix}\n`)
1071
+ }
1072
+ }
814
1073
  // Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
815
1074
  // it costs an hour of misdiagnosis when the dev server 500s inside the package.
816
1075
  if (result.staleDists.length > 0) {
@@ -827,6 +1086,22 @@ export async function runDoctor(
827
1086
  }
828
1087
  console.log("")
829
1088
  }
1089
+ if (result.readiness !== undefined) {
1090
+ const readiness = result.readiness
1091
+ console.log(
1092
+ `production readiness (${readiness.target}${readiness.targetSource === null ? "" : `, detected from ${readiness.targetSource}`}):\n`,
1093
+ )
1094
+ for (const item of readiness.items) {
1095
+ const marker =
1096
+ item.status === "configured" ? "✓" : item.status === "not-applicable" ? "-" : "✗"
1097
+ const evidence = item.evidence === undefined ? "" : ` (${item.evidence})`
1098
+ console.log(` ${marker} ${item.label}: ${item.status}${evidence}`)
1099
+ }
1100
+ if (readiness.strict && !readiness.ok) {
1101
+ console.log("\n --strict: every applicable readiness rule must be configured")
1102
+ }
1103
+ console.log("")
1104
+ }
830
1105
  if (result.ok) {
831
1106
  console.log(
832
1107
  "✓ every imported package is declared and identity-sensitive installs are deduplicated",
@@ -869,6 +1144,9 @@ export async function runDoctor(
869
1144
  "\n Align dependency ranges and reinstall from the workspace root; doctor never deletes installs.",
870
1145
  )
871
1146
  }
1147
+ if (result.readiness?.strict === true && result.readiness.ok === false) {
1148
+ console.log("\n✗ production readiness is incomplete under --strict")
1149
+ }
872
1150
  if (byPkg.size > 0)
873
1151
  console.log(
874
1152
  "\nThese resolve at Bun runtime via hoisting/workspace but break `tsc` and a standalone install.",
package/src/examples.ts CHANGED
@@ -23,6 +23,29 @@ export interface ScoredExample extends Example {
23
23
  readonly score: number
24
24
  }
25
25
 
26
+ interface PreparedExample {
27
+ readonly example: Example
28
+ readonly labelTokens: ReadonlySet<string>
29
+ readonly codeLower: string
30
+ }
31
+
32
+ const preparedExamplesCache = new WeakMap<readonly Example[], readonly PreparedExample[]>()
33
+ const prepareExamples = (examples: readonly Example[]): readonly PreparedExample[] => {
34
+ const cached = preparedExamplesCache.get(examples)
35
+ if (cached !== undefined) return cached
36
+ const prepared = examples.map((example) => ({
37
+ example,
38
+ labelTokens: new Set([
39
+ ...tokenize(example.slug),
40
+ ...tokenize(example.topic),
41
+ ...tokenize(example.name),
42
+ ]),
43
+ codeLower: example.code.toLowerCase(),
44
+ }))
45
+ preparedExamplesCache.set(examples, prepared)
46
+ return prepared
47
+ }
48
+
26
49
  /** Score examples against the query: a term in the slug/topic/name (the curated, searchable labels)
27
50
  * is worth far more than a mention in the code body; body hits cap per term so one long snippet can't
28
51
  * outweigh a precisely-tagged one. */
@@ -34,13 +57,12 @@ export function searchExamples(
34
57
  const terms = queryTermGroups(query)
35
58
  if (terms.length === 0) return []
36
59
  const scored: ScoredExample[] = []
37
- for (const ex of examples) {
38
- const labelTokens = new Set([...tokenize(ex.slug), ...tokenize(ex.topic), ...tokenize(ex.name)])
39
- const codeLower = ex.code.toLowerCase()
60
+ for (const prepared of prepareExamples(examples)) {
61
+ const ex = prepared.example
40
62
  let score = 0
41
63
  for (const term of terms) {
42
- if (tokenSetHas(labelTokens, term)) score += 8
43
- score += countBodyHits(codeLower, term, 5)
64
+ if (tokenSetHas(prepared.labelTokens, term)) score += 8
65
+ score += countBodyHits(prepared.codeLower, term, 5)
44
66
  }
45
67
  if (score > 0) scored.push({ ...ex, score })
46
68
  }
@@ -76,14 +98,20 @@ export function renderExamplesResult(
76
98
 
77
99
  /** Load the bundled example corpus. Resolves relative to this module so it works from `src/` (repo dev)
78
100
  * and `dist/` (published) alike - `docs/examples.json` sits at the package root next to both. */
79
- export async function loadExamplesCorpus(): Promise<Example[] | undefined> {
80
- const url = new URL("../docs/examples.json", import.meta.url)
81
- try {
82
- const text = await Bun.file(url).text()
83
- if (text.length === 0) return undefined
84
- const parsed = JSON.parse(text) as Example[]
85
- return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
86
- } catch {
87
- return undefined
88
- }
101
+ let examplesCorpusPromise: Promise<Example[] | undefined> | undefined
102
+
103
+ /** The published corpus is immutable, so keep one loaded array and its derived search fields. */
104
+ export function loadExamplesCorpus(): Promise<Example[] | undefined> {
105
+ examplesCorpusPromise ??= (async () => {
106
+ const url = new URL("../docs/examples.json", import.meta.url)
107
+ try {
108
+ const text = await Bun.file(url).text()
109
+ if (text.length === 0) return undefined
110
+ const parsed = JSON.parse(text) as Example[]
111
+ return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
112
+ } catch {
113
+ return undefined
114
+ }
115
+ })()
116
+ return examplesCorpusPromise
89
117
  }
@@ -0,0 +1,126 @@
1
+ import { existsSync } from "node:fs"
2
+ import { readFile, writeFile } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import type { Diagnostic } from "./diagnostics.ts"
5
+ import { resolveInsideProject } from "./project-path.ts"
6
+
7
+ export interface FixRecipe {
8
+ readonly id: string
9
+ readonly description: string
10
+ apply(root: string, diagnostic: Diagnostic): Promise<readonly string[]>
11
+ readonly verify: string
12
+ }
13
+
14
+ const recipes = new Map<string, FixRecipe>()
15
+
16
+ export function registerFixRecipe(recipe: FixRecipe): void {
17
+ if (recipes.has(recipe.id)) throw new Error(`duplicate fix recipe: ${recipe.id}`)
18
+ recipes.set(recipe.id, Object.freeze(recipe))
19
+ }
20
+
21
+ export function getFixRecipe(id: string): FixRecipe | undefined {
22
+ return recipes.get(id)
23
+ }
24
+
25
+ export function listFixRecipes(): readonly FixRecipe[] {
26
+ return Object.freeze([...recipes.values()])
27
+ }
28
+
29
+ registerFixRecipe({
30
+ id: "security.timing-safe-equal",
31
+ description: "Replace a direct secret comparison with a length check and timing-safe comparison.",
32
+ verify: "nifra check --lints-only",
33
+ async apply(root, diagnostic) {
34
+ if (diagnostic.file === undefined || diagnostic.line === undefined) return []
35
+ const path = await resolveInsideProject(root, diagnostic.file)
36
+ if (path === undefined) return []
37
+ const lines = (await readFile(path, "utf8")).split("\n")
38
+ const index = diagnostic.line - 1
39
+ const original = lines[index]
40
+ if (original === undefined || original.includes("@nifra-gate-reviewed")) return []
41
+ const comparison = /\b([A-Za-z_$][\w$]*)\s*(===|!==|==|!=)\s*([A-Za-z_$][\w$]*)/.exec(original)
42
+ if (comparison === null) return []
43
+ const left = comparison[1] as string
44
+ const operator = comparison[2] as string
45
+ const right = comparison[3] as string
46
+ const expression = `nifraTimingSafeEqual(${left}, ${right})`
47
+ const replacement = operator === "!==" || operator === "!=" ? `!${expression}` : expression
48
+ lines[index] = original.replace(comparison[0], replacement)
49
+ if (!lines.some((line) => line.includes("function nifraTimingSafeEqual"))) {
50
+ lines.unshift(
51
+ 'import { timingSafeEqual } from "node:crypto"',
52
+ "",
53
+ "function nifraTimingSafeEqual(left: string, right: string): boolean {",
54
+ " const leftBytes = Buffer.from(left)",
55
+ " const rightBytes = Buffer.from(right)",
56
+ " return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes)",
57
+ "}",
58
+ "",
59
+ )
60
+ }
61
+ await writeFile(path, lines.join("\n"), "utf8")
62
+ return [diagnostic.file]
63
+ },
64
+ })
65
+
66
+ registerFixRecipe({
67
+ id: "manifest.sync",
68
+ description: "Regenerate generated server manifests from the current route tree.",
69
+ verify: "nifra check --lints-only",
70
+ async apply(root) {
71
+ const { syncServerManifests } = await import("./sync-manifest.ts")
72
+ const results = await syncServerManifests(root)
73
+ return results.filter((result) => result.changed).map((result) => result.file)
74
+ },
75
+ })
76
+
77
+ registerFixRecipe({
78
+ id: "contracts.snapshot",
79
+ description: "Refresh the opt-in contract lock after reviewing an intentional route change.",
80
+ verify: "nifra contracts check",
81
+ async apply(root) {
82
+ const { snapshotContracts } = await import("./contracts.ts")
83
+ await snapshotContracts(root)
84
+ return ["contracts.lock.json"]
85
+ },
86
+ })
87
+
88
+ registerFixRecipe({
89
+ id: "workspace-dist.rebuild",
90
+ description: "Rebuild the workspace-linked package whose artifact is older than its source.",
91
+ verify: "nifra doctor --json",
92
+ async apply(root, diagnostic) {
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,
107
+ stdout: "ignore",
108
+ stderr: "pipe",
109
+ })
110
+ const error = await new Response(proc.stderr).text()
111
+ const exitCode = await proc.exited
112
+ if (exitCode !== 0) throw new Error(error.trim() || `build failed for ${packageName}`)
113
+ return [diagnostic.evidence?.[1] ?? packageName]
114
+ },
115
+ })
116
+
117
+ export async function applyDiagnosticRecipe(
118
+ root: string,
119
+ diagnostic: Diagnostic,
120
+ ): Promise<readonly string[]> {
121
+ const recipeId = diagnostic.fix?.recipe
122
+ if (recipeId === undefined) return []
123
+ const recipe = getFixRecipe(recipeId)
124
+ if (recipe === undefined) return []
125
+ return recipe.apply(root, diagnostic)
126
+ }