@nifrajs/cli 4.0.2 → 5.0.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 (323) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +21 -2
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +778 -64
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. package/src/work-graph.ts +17 -12
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { resolve } from "node:path"
3
+ import {
4
+ isUnsupportedTypeScriptError,
5
+ type UnsupportedTypeScriptError,
6
+ } from "./internal/typescript-import.ts"
7
+ import type { SourceIndex } from "./rules/index.ts"
8
+
9
+ type ComponentSource = typeof import("@nifrajs/web/server-components/source")
10
+
11
+ export interface ComponentCheck {
12
+ readonly receipts: ReadonlySet<string>
13
+ /** Set when the project's TypeScript has no compiler API to read component modules with
14
+ * (TypeScript 7): no module gets a receipt, so the ordinary import rule judges each one. */
15
+ readonly unsupported: UnsupportedTypeScriptError | undefined
16
+ }
17
+
18
+ const COMPONENT_SERVER = /\.component\.server\.[cm]?[jt]sx?$/
19
+
20
+ /** A source-only proof. nifra check never executes nifra.config.ts. */
21
+ export async function componentCheckReceipts(
22
+ root: string,
23
+ source: SourceIndex,
24
+ load: () => Promise<ComponentSource> = () => import("@nifrajs/web/server-components/source"),
25
+ ): Promise<ComponentCheck> {
26
+ const receipts = new Set<string>()
27
+ const result: ComponentCheck = { receipts, unsupported: undefined }
28
+ if (!source.files.some((file) => COMPONENT_SERVER.test(file))) return result
29
+ const configFile = ["nifra.config.ts", "backend/framework.ts"].find((file) =>
30
+ existsSync(resolve(root, file)),
31
+ )
32
+ if (configFile === undefined) return result
33
+ let components: ComponentSource
34
+ try {
35
+ components = await load()
36
+ } catch (error) {
37
+ if (isUnsupportedTypeScriptError(error)) return { receipts, unsupported: error }
38
+ throw error
39
+ }
40
+ const pipeline = components.staticComponentPipeline(
41
+ readFileSync(resolve(root, configFile), "utf8"),
42
+ configFile,
43
+ )
44
+ if (typeof pipeline !== "object" || pipeline === null || Array.isArray(pipeline)) return result
45
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Static receipt inspection checks object shape before reading fields; the source scanner never executes configuration.
46
+ const options = pipeline as Record<string, unknown>
47
+ if (
48
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Static receipt inspection checks object shape before reading fields; the source scanner never executes configuration.
49
+ !["react", "vue", "svelte", "solid", "preact"].includes(options.adapter as string) ||
50
+ typeof options.entries !== "object" ||
51
+ options.entries === null ||
52
+ Array.isArray(options.entries)
53
+ )
54
+ return result
55
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Static receipt inspection checks object shape before reading fields; the source scanner never executes configuration.
56
+ const entries = options.entries as Record<string, unknown>
57
+ if (
58
+ typeof entries.client !== "string" ||
59
+ typeof entries.ssr !== "string" ||
60
+ (options.adapter === "react" && typeof entries.rsc !== "string") ||
61
+ Object.values(entries).some(
62
+ (file) => typeof file !== "string" || !existsSync(resolve(root, file)),
63
+ )
64
+ )
65
+ return result
66
+ for (const file of source.files) {
67
+ if (!COMPONENT_SERVER.test(file)) continue
68
+ const content = source.read(file)
69
+ if (content === undefined) continue
70
+ try {
71
+ components.compileComponentModule(content, resolve(root, file), root, "client")
72
+ receipts.add(file)
73
+ receipts.add(resolve(root, file))
74
+ } catch {
75
+ /* A module without a compilation receipt remains forbidden by the ordinary import rule. */
76
+ }
77
+ }
78
+ return result
79
+ }
@@ -1,14 +1,12 @@
1
- import { existsSync, realpathSync } from "node:fs"
2
- import { resolve, sep } from "node:path"
3
1
  import type { AssuranceFinding, AssuranceReport, AssuredRoute } from "@nifrajs/core/assurance"
4
2
  import type {
5
3
  AssuredCapabilityRoute,
6
4
  CapabilityAssuranceReport,
7
5
  CapabilityFinding,
8
6
  } from "@nifrajs/core/capabilities"
9
- import { diffRouteSnapshots, type RouteChange, type RoutesDiff } from "@nifrajs/core/diff"
7
+ import type { RouteChange, RoutesDiff } from "@nifrajs/core/diff"
10
8
  import type { CheckResult } from "./check.ts"
11
- import { DEFAULT_SNAPSHOT_FILE, parseSnapshotFile, snapshotBackend } from "./diff-tool.ts"
9
+ import { DEFAULT_SNAPSHOT_FILE, diffAgainstBaseline } from "./diff-tool.ts"
12
10
  import { collectProjectVerification } from "./verification.ts"
13
11
 
14
12
  const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
@@ -59,15 +57,6 @@ export interface ContractProofOptions {
59
57
  readonly signal?: AbortSignal
60
58
  }
61
59
 
62
- function safeBaselinePath(cwd: string, baselinePath: string): string {
63
- const root = resolve(cwd)
64
- const target = resolve(root, baselinePath)
65
- if (target !== root && !target.startsWith(`${root}${sep}`)) {
66
- throw new Error("contract proof baseline must stay inside the project directory")
67
- }
68
- return target
69
- }
70
-
71
60
  function errorMessage(error: unknown): string {
72
61
  return error instanceof Error ? error.message : String(error)
73
62
  }
@@ -144,18 +133,7 @@ export async function collectContractProof(
144
133
  options: ContractProofOptions = {},
145
134
  ): Promise<ContractProofReport> {
146
135
  const baselinePath = options.baselinePath ?? DEFAULT_SNAPSHOT_FILE
147
- const resolvedBaseline = safeBaselinePath(cwd, baselinePath)
148
- if (!existsSync(resolvedBaseline)) {
149
- throw new Error(`contract proof baseline not found: ${resolvedBaseline}`)
150
- }
151
- const realRoot = realpathSync(resolve(cwd))
152
- const realBaseline = realpathSync(resolvedBaseline)
153
- if (realBaseline !== realRoot && !realBaseline.startsWith(`${realRoot}${sep}`)) {
154
- throw new Error("contract proof baseline must stay inside the project directory")
155
- }
156
- const baseline = parseSnapshotFile(await Bun.file(resolvedBaseline).text(), baselinePath)
157
- const current = await snapshotBackend(cwd)
158
- const diff = diffRouteSnapshots(baseline.routes, current)
136
+ const diff = await diffAgainstBaseline(cwd, baselinePath, { contain: true })
159
137
  const verification = await collectProjectVerification(cwd, {
160
138
  ...(options.signal === undefined ? {} : { signal: options.signal }),
161
139
  })
package/src/contracts.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { resolve } from "node:path"
3
+ import { NIFRA_ASSURANCE } from "@nifrajs/core/assurance"
3
4
  import {
4
5
  type ProjectEvidenceSnapshot,
5
6
  reflectedRoutesFromEvidence,
@@ -16,9 +17,18 @@ export interface ContractDigest {
16
17
 
17
18
  export interface ContractsLock {
18
19
  readonly version: 1
20
+ /** The {@link CONTRACT_DIGEST_FORMAT} that produced `routes`. Absent in a lock written before it was recorded. */
21
+ readonly digest?: number
19
22
  readonly routes: Readonly<Record<string, ContractDigest>>
20
23
  }
21
24
 
25
+ /**
26
+ * Bumped whenever {@link digestRoute} hashes the same schema differently. The lock records it, so a
27
+ * format change reads as "re-snapshot" instead of as drift on every route. 2: properties named like
28
+ * annotation keywords are hashed (4.0.1).
29
+ */
30
+ export const CONTRACT_DIGEST_FORMAT = 2
31
+
22
32
  export const DEFAULT_CONTRACTS_LOCK = "contracts.lock.json"
23
33
  const DIGEST = /^[a-f0-9]{64}$/
24
34
 
@@ -146,6 +156,7 @@ export async function buildContractsLock(
146
156
  )
147
157
  return {
148
158
  version: 1,
159
+ digest: CONTRACT_DIGEST_FORMAT,
149
160
  routes: Object.fromEntries(Object.entries(routes).sort(([a], [b]) => codeUnitOrder(a, b))),
150
161
  }
151
162
  }
@@ -160,6 +171,27 @@ async function loadBackend(cwd: string): Promise<unknown> {
160
171
  return loaded.backend
161
172
  }
162
173
 
174
+ const INPUT_PARTS = ["body", "query", "headers", "cookies", "params"] as const
175
+
176
+ /**
177
+ * A route that in-process auth guards, but through a hook that runs after its declared input is
178
+ * validated: an unauthenticated caller reaches the body parser and reads the validation issues.
179
+ * Gateway auth (declared provenance) never lets that caller in, and an explicit `validationOrder`
180
+ * is the author's choice.
181
+ */
182
+ export function validatesBeforeAuthentication(route: ReflectedRoute): boolean {
183
+ const schema = route.schema
184
+ if (schema === undefined || schema.validationOrder !== undefined) return false
185
+ if (!INPUT_PARTS.some((part) => schema[part] !== undefined)) return false
186
+ const auth = (route.assurance ?? []).filter(
187
+ (evidence) =>
188
+ evidence.id === NIFRA_ASSURANCE.AUTHENTICATED &&
189
+ (evidence.provenance ?? (evidence.source === "declared" ? "declared" : "runtime")) ===
190
+ "runtime",
191
+ )
192
+ return auth.length > 0 && auth.every((evidence) => evidence.source !== "nifra.authenticate")
193
+ }
194
+
163
195
  export async function snapshotContracts(
164
196
  cwd: string,
165
197
  out = DEFAULT_CONTRACTS_LOCK,
@@ -176,6 +208,9 @@ export function parseContractsLock(value: unknown): ContractsLock {
176
208
  const routesRecord = recordOf(routesValue)
177
209
  if (record.version !== 1 || routesRecord === undefined)
178
210
  throw new TypeError("contracts lock must have version 1 and routes")
211
+ const digest = record.digest
212
+ if (digest !== undefined && !(Number.isSafeInteger(digest) && Number(digest) > 0))
213
+ throw new TypeError("contracts lock digest must be a positive integer")
179
214
  const routes: Record<string, ContractDigest> = {}
180
215
  for (const [key, raw] of Object.entries(routesRecord)) {
181
216
  const item = recordOf(raw)
@@ -192,7 +227,7 @@ export function parseContractsLock(value: unknown): ContractsLock {
192
227
  throw new TypeError(`invalid contract ${key}`)
193
228
  routes[key] = { request, response }
194
229
  }
195
- return { version: 1, routes }
230
+ return typeof digest === "number" ? { version: 1, digest, routes } : { version: 1, routes }
196
231
  }
197
232
 
198
233
  export async function checkContractsLock(
@@ -202,13 +237,30 @@ export async function checkContractsLock(
202
237
  present: boolean
203
238
  vacuous: boolean
204
239
  diagnostics: Array<{ route?: string; message: string }>
240
+ /** `METHOD /path` of every route the backend serves, read when the lock made the check load it. */
241
+ served?: readonly string[]
242
+ /** `METHOD /path` of each route {@link validatesBeforeAuthentication} names, read alongside `served`. */
243
+ inputBeforeAuth?: readonly string[]
205
244
  }> {
206
245
  const path = resolve(cwd, file)
207
246
  if (!existsSync(path)) return { present: false, vacuous: false, diagnostics: [] }
208
247
  const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
209
- const current = await buildContractsLock(await loadBackend(cwd))
248
+ const evidence = snapshotProjectEvidence(await loadBackend(cwd))
249
+ const current = await buildContractsLock(undefined, evidence)
210
250
  const vacuous = await isVacuousLock(current)
251
+ const served = Object.keys(current.routes)
252
+ const inputBeforeAuth = reflectedRoutesFromEvidence(evidence)
253
+ .filter(validatesBeforeAuthentication)
254
+ .map((route) => `${route.method} ${route.path}`)
211
255
  const diagnostics: Array<{ route?: string; message: string }> = []
256
+ // Digests of two formats never compare, so this still fails; it just names the real cause.
257
+ if (parsed.digest !== undefined && parsed.digest !== CONTRACT_DIGEST_FORMAT) {
258
+ const newer = parsed.digest > CONTRACT_DIGEST_FORMAT
259
+ diagnostics.push({
260
+ message: `${file} was written with contract digest format ${parsed.digest}, but this nifra computes format ${CONTRACT_DIGEST_FORMAT}${newer ? " (a newer @nifrajs/cli wrote it; upgrade this one)" : ""}, so no route could be compared. Review the route schema changes since the lock was written, then run \`nifra contracts snapshot\` and commit the lock.`,
261
+ })
262
+ return { present: true, vacuous, diagnostics, served, inputBeforeAuth }
263
+ }
212
264
  const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
213
265
  for (const key of [...keys].sort()) {
214
266
  const before = parsed.routes[key]
@@ -225,5 +277,10 @@ export async function checkContractsLock(
225
277
  })
226
278
  }
227
279
  }
228
- return { present: true, vacuous, diagnostics }
280
+ if (diagnostics.length > 0 && parsed.digest === undefined) {
281
+ diagnostics.push({
282
+ message: `${file} records no contract digest format. A lock written before nifra 4.0.1 hashed schemas differently, so a route reported above may not have changed. Review them, then run \`nifra contracts snapshot\` to record the format.`,
283
+ })
284
+ }
285
+ return { present: true, vacuous, diagnostics, served, inputBeforeAuth }
229
286
  }
package/src/db-tool.ts CHANGED
@@ -34,8 +34,10 @@ import { readProjectEnv } from "./db-config.ts"
34
34
  import {
35
35
  CHILD_OUTPUT_MAX_BYTES,
36
36
  CHILD_TIMEOUT_MS,
37
+ OWN_PROCESS_GROUP,
37
38
  readBoundedLines,
38
39
  readBoundedStream,
40
+ signalProcessGroup,
39
41
  } from "./mcp-io.ts"
40
42
 
41
43
  /** Added to the declared `timeoutMs` before the subprocess is killed. */
@@ -134,12 +136,13 @@ export async function runDbChild(
134
136
  stdin: "pipe",
135
137
  stdout: "pipe",
136
138
  stderr: "pipe",
139
+ detached: OWN_PROCESS_GROUP,
137
140
  })
138
141
  let killedFor: "startup" | "deadline" | "cancel" | undefined
139
142
  const kill = (reason: NonNullable<typeof killedFor>): void => {
140
143
  if (killedFor !== undefined) return
141
144
  killedFor = reason
142
- proc.kill("SIGKILL")
145
+ signalProcessGroup(proc, "SIGKILL")
143
146
  }
144
147
  let deadlineMs: number | undefined
145
148
  let timer = setTimeout(() => kill("startup"), startupMs)
@@ -565,7 +568,7 @@ export interface DbRoleInput {
565
568
  export const dbRoleSpec: CommandSpec<DbRoleInput, DbToolOutput> = {
566
569
  name: "db-role",
567
570
  summary:
568
- "Print the SQL that creates a read-only Postgres role for the declared development database (pg_read_all_data on 14+, else GRANT SELECT, with a REVOKE per excluded table). Nothing is run.",
571
+ "Print the SQL that creates a read-only Postgres role for the declared development database (GRANT SELECT on each allowed schema, with a REVOKE per excluded table). Nothing is run.",
569
572
  input: {
570
573
  jsonSchema: {
571
574
  type: "object",
@@ -42,10 +42,29 @@
42
42
  import { timingSafeEqual } from "node:crypto"
43
43
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
44
44
  import { relative, resolve } from "node:path"
45
+ import { readSingleCopyDeclaration } from "@nifrajs/core/single-copy"
45
46
 
46
- /** TOML string literal: basic (double-quoted) form with the two escapes it needs. */
47
+ const TOML_ESCAPES: Readonly<Record<string, string>> = {
48
+ "\\": "\\\\",
49
+ '"': '\\"',
50
+ "\b": "\\b",
51
+ "\t": "\\t",
52
+ "\n": "\\n",
53
+ "\f": "\\f",
54
+ "\r": "\\r",
55
+ }
56
+
57
+ /** TOML string literal: basic (double-quoted) form. A basic string may hold no raw control
58
+ * character, so each one is escaped along with the quote and the backslash. */
47
59
  function tomlString(value: string): string {
48
- return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
60
+ let out = '"'
61
+ for (const char of value) {
62
+ const code = char.charCodeAt(0)
63
+ out +=
64
+ TOML_ESCAPES[char] ??
65
+ (code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, "0")}` : char)
66
+ }
67
+ return `${out}"`
49
68
  }
50
69
 
51
70
  /** A parsed bunfig: plain data from `Bun.TOML.parse`. */
@@ -198,24 +217,40 @@ function relativeSpecifier(fromDir: string, target: string): string {
198
217
  * `servePlugins` are the app's own bunfig `[serve.static] plugins`, composed last and in order rather
199
218
  * than listed beside this module, so that every plugin reaching the bundler registers through
200
219
  * `reserveDevSpecifiers` (see `@nifrajs/web/internal/dev-reserved`).
220
+ *
221
+ * `singleCopy` is whether `package.json` declares `"nifra": { "singleCopy": [...] }`. The declared
222
+ * resolver then runs where `buildClient` runs it, after the zone guard; an app that also lists
223
+ * `singleCopyPlugin()` in `clientPlugins` gets its own instance in that place, once.
201
224
  */
202
225
  export function renderBoundaryPluginModule(
203
226
  configPath?: string,
204
227
  pluginDir?: string,
205
228
  servePlugins: readonly string[] = [],
229
+ singleCopy = false,
206
230
  ): string {
207
231
  const appImport =
208
232
  configPath !== undefined && pluginDir !== undefined
209
233
  ? `import * as appConfig from ${tomlString(relativeSpecifier(pluginDir, configPath))}\n`
210
234
  : ""
211
- const appSetup =
235
+ const appPluginsSetup =
212
236
  appImport === ""
213
- ? ""
237
+ ? " const appPlugins = []\n"
214
238
  : ` // The app's OWN client transforms (SFC compilers etc.). \`clientPlugins\` may be a thunk.
215
239
  const field = appConfig.clientPlugins
216
240
  const appPlugins = (typeof field === "function" ? await field() : field) ?? []
217
- for (const p of appPlugins) await p.setup(guarded)
218
241
  `
242
+ const singleCopySetup = singleCopy
243
+ ? ` const listedSingleCopy = appPlugins.find((p) => p?.name === "nifra-single-copy")
244
+ ;(listedSingleCopy ?? singleCopyPlugin({ cwd: appRoot })).setup(guarded)
245
+ `
246
+ : ""
247
+ // Without a declaration the app's plugins are read where they are set up, as before.
248
+ const appSetup =
249
+ appImport === ""
250
+ ? ""
251
+ : singleCopy
252
+ ? " for (const p of appPlugins) if (p !== listedSingleCopy) await p.setup(guarded)\n"
253
+ : `${appPluginsSetup} for (const p of appPlugins) await p.setup(guarded)\n`
219
254
  const serveSetup =
220
255
  servePlugins.length === 0
221
256
  ? ""
@@ -232,10 +267,11 @@ export function renderBoundaryPluginModule(
232
267
  import { serverFnStubPlugin, zoneGuardPlugin } from "@nifrajs/web/build"
233
268
  import { reserveDevSpecifiers } from "@nifrajs/web/internal/dev-reserved"
234
269
  import { cssModulesBunPlugin } from "@nifrajs/web/plugins/css-modules"
235
- ${appImport}
270
+ ${singleCopy ? 'import { singleCopyPlugin } from "@nifrajs/core/single-copy"\n' : ""}${appImport}
236
271
  // This module lives at <app>/.nifra/dev-bun/, so the app root is two directories up.
272
+ const appRoot = Bun.fileURLToPath(new URL("../../", import.meta.url))
237
273
  const zones = zoneGuardPlugin({
238
- appRoot: Bun.fileURLToPath(new URL("../../", import.meta.url)),${
274
+ appRoot,${
239
275
  appImport === ""
240
276
  ? ""
241
277
  : `
@@ -251,10 +287,10 @@ export default {
251
287
  // Every plugin below registers through this view: a resolve filter matching the dev entry script
252
288
  // or Bun's built-in React refresh runtime breaks the page even when its handler declines.
253
289
  const guarded = reserveDevSpecifiers(build)
254
- // Boundary first: an app plugin must never get to transform a module that the boundary is about
290
+ ${singleCopy ? appPluginsSetup : ""} // Boundary first: an app plugin must never get to transform a module that the boundary is about
255
291
  // to refuse or stub, or the transform decides what ships instead of the boundary.
256
292
  zones.setup(guarded)
257
- fn.setup(guarded)
293
+ ${singleCopySetup} fn.setup(guarded)
258
294
  cssModules.setup(guarded)
259
295
  ${appSetup}${serveSetup} },
260
296
  }
@@ -287,7 +323,12 @@ export async function writeBunDevConfig(
287
323
  const user = parseUserBunfig(userToml)
288
324
  writeFileSync(
289
325
  pluginPath,
290
- renderBoundaryPluginModule(configPath, dir, userServePlugins(user, appRoot)),
326
+ renderBoundaryPluginModule(
327
+ configPath,
328
+ dir,
329
+ userServePlugins(user, appRoot),
330
+ readSingleCopyDeclaration(appRoot) !== undefined,
331
+ ),
291
332
  )
292
333
  const bunfigPath = resolve(dir, "bunfig.toml")
293
334
  writeFileSync(bunfigPath, renderDevBunfig(pluginPath, user, appRoot, define))
@@ -21,6 +21,9 @@ export interface DiagnosticCompatibility {
21
21
  readonly suggestion?: DiagnosticSuggestion
22
22
  /** The import path evidence used by the legacy server-only-import view. */
23
23
  readonly chain?: readonly string[]
24
+ /** The project files a server-only chain runs through, so a diff review keeps a finding whose
25
+ * route is unchanged but whose chain a changed module made. */
26
+ readonly modules?: readonly string[]
24
27
  /** Preserve the old optional `CheckDiagnostic.code` field exactly where it existed. */
25
28
  readonly includeCode?: boolean
26
29
  }
package/src/diff-tool.ts CHANGED
@@ -14,8 +14,8 @@
14
14
  * framework config or routes/ directory (unlike the eager `loadApp`).
15
15
  */
16
16
 
17
- import { existsSync } from "node:fs"
18
- import { resolve } from "node:path"
17
+ import { existsSync, realpathSync } from "node:fs"
18
+ import { isAbsolute, relative, resolve } from "node:path"
19
19
  import {
20
20
  diffRouteSnapshots,
21
21
  type RouteChange,
@@ -23,7 +23,7 @@ import {
23
23
  type RoutesDiff,
24
24
  snapshotRoutes,
25
25
  } from "@nifrajs/core/diff"
26
- import { BACKEND_APP_FILE } from "./app-files.ts"
26
+ import { assertCurrentLayout, BACKEND_APP_FILE } from "./app-files.ts"
27
27
 
28
28
  /** Snapshot file envelope - versioned so a future format change can migrate instead of misparse. */
29
29
  export interface SnapshotFile {
@@ -84,6 +84,7 @@ export function formatDiff(diff: RoutesDiff): string {
84
84
 
85
85
  /** Import `backend/app.ts` from `cwd` and snapshot its routes. */
86
86
  export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
87
+ assertCurrentLayout(cwd)
87
88
  const backendPath = resolve(cwd, BACKEND_APP_FILE)
88
89
  if (!existsSync(backendPath)) {
89
90
  throw new Error(
@@ -97,35 +98,41 @@ export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapsh
97
98
  return snapshotRoutes(backend)
98
99
  }
99
100
 
100
- /** `nifra snapshot`: write the current contract as the CI baseline. */
101
- export async function runSnapshot(cwd: string, options: { out?: string }): Promise<void> {
102
- const routes = await snapshotBackend(cwd)
103
- const file: SnapshotFile = { nifraSnapshot: 1, routes }
104
- const outPath = resolve(cwd, options.out ?? DEFAULT_SNAPSHOT_FILE)
105
- await Bun.write(outPath, `${JSON.stringify(file, null, 2)}\n`)
106
- console.log(`[nifra] wrote ${routes.length} route${routes.length === 1 ? "" : "s"} to ${outPath}`)
101
+ /** `nifra snapshot`: write the current contract to `out` (from `cwd`) as the CI baseline. */
102
+ export async function writeSnapshot(
103
+ cwd: string,
104
+ out: string = DEFAULT_SNAPSHOT_FILE,
105
+ ): Promise<SnapshotFile> {
106
+ const snapshot: SnapshotFile = { nifraSnapshot: 1, routes: await snapshotBackend(cwd) }
107
+ await Bun.write(resolve(cwd, out), `${JSON.stringify(snapshot, null, 2)}\n`)
108
+ return snapshot
109
+ }
110
+
111
+ const outside = (root: string, target: string): boolean => {
112
+ const rel = relative(root, target)
113
+ return rel.startsWith("..") || isAbsolute(rel)
107
114
  }
108
115
 
109
- /** `nifra diff`: compare the current contract against a baseline. Returns false on breaking changes. */
110
- export async function runDiff(
116
+ /**
117
+ * `nifra diff`: the current contract against the snapshot at `baselinePath` (from `cwd`). `contain`
118
+ * refuses a baseline outside `cwd`, symlinks followed, for a caller handed the path by someone else.
119
+ */
120
+ export async function diffAgainstBaseline(
111
121
  cwd: string,
112
- baselinePath: string,
113
- options: { json?: boolean },
114
- ): Promise<boolean> {
122
+ baselinePath: string = DEFAULT_SNAPSHOT_FILE,
123
+ options: { readonly contain?: boolean } = {},
124
+ ): Promise<RoutesDiff> {
115
125
  const resolved = resolve(cwd, baselinePath)
116
- const baselineFile = Bun.file(resolved)
117
- if (!(await baselineFile.exists())) {
126
+ const refuse = (): never => {
127
+ throw new Error(`[nifra] the baseline ${baselinePath} is outside the project directory`)
128
+ }
129
+ if (options.contain === true && outside(resolve(cwd), resolved)) refuse()
130
+ if (!existsSync(resolved)) {
118
131
  throw new Error(
119
132
  `[nifra] baseline not found: ${resolved} - create it on your main branch with \`nifra snapshot\`.`,
120
133
  )
121
134
  }
122
- const baseline = parseSnapshotFile(await baselineFile.text(), baselinePath)
123
- const current = await snapshotBackend(cwd)
124
- const diff = diffRouteSnapshots(baseline.routes, current)
125
- if (options.json === true) {
126
- console.log(JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2))
127
- } else {
128
- console.log(formatDiff(diff))
129
- }
130
- return !diff.hasBreaking
135
+ if (options.contain === true && outside(realpathSync(cwd), realpathSync(resolved))) refuse()
136
+ const baseline = parseSnapshotFile(await Bun.file(resolved).text(), baselinePath)
137
+ return diffRouteSnapshots(baseline.routes, await snapshotBackend(cwd))
131
138
  }
package/src/doctor.ts CHANGED
@@ -29,11 +29,20 @@ import {
29
29
  resolvedInstalledCopy,
30
30
  } from "@nifrajs/web/internal/parity"
31
31
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
32
+ import {
33
+ type HandBuiltManifest,
34
+ handBuiltManifestMessage,
35
+ handBuiltManifests,
36
+ type ManifestList,
37
+ manifestListsIn,
38
+ } from "./hand-built-manifest.ts"
32
39
  import { collectStaleMcpPins, type StaleMcpPin } from "./init-agents.ts"
33
40
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
41
+ import { createSpecifierResolver } from "./internal/project-specifiers.ts"
34
42
  import { detectToolingDrift, syncMcpCommand, type ToolingDrift } from "./mcp-root.ts"
35
43
  import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
36
44
  import { type ResolvedTarget, resolveTarget } from "./port.ts"
45
+ import { collectVersionDrift, type VersionDrift, versionDriftMessage } from "./version-drift.ts"
37
46
  import { buildScriptName } from "./workspace-link.ts"
38
47
 
39
48
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
@@ -178,6 +187,17 @@ export interface DoctorResult {
178
187
  * supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
179
188
  */
180
189
  readonly toolingDrift?: ToolingDrift
190
+ /**
191
+ * More than one version of the fixed release group across pins, installs and the running CLI.
192
+ * Advisory (never folded into `ok`); its fix is `nifra upgrade <highest>`. Computed by
193
+ * {@link collectVersionDrift}.
194
+ */
195
+ readonly versionDrift?: VersionDrift
196
+ /**
197
+ * `buildManifest(...)` calls whose spelled-out file list reaches a page but not its backend half, so
198
+ * that page runs without its loader, action and guards. Advisory (never folded into `ok`).
199
+ */
200
+ readonly handBuiltManifests?: readonly HandBuiltManifest[]
181
201
  /**
182
202
  * Agent files whose MCP launch pins an `@nifrajs/cli` other than the one the project installs, so
183
203
  * the agent's server answers for a release the code no longer builds with. Advisory (never folded
@@ -347,16 +367,6 @@ const declaredPackages = (pkg: Record<string, unknown>): ReadonlySet<string> =>
347
367
  return declared
348
368
  }
349
369
 
350
- const tsconfigPaths = async (
351
- root: string,
352
- ): Promise<Readonly<Record<string, unknown>> | undefined> => {
353
- const tsconfig = await readJson(join(root, "tsconfig.json"))
354
- const compilerOptions = tsconfig?.compilerOptions as
355
- | { paths?: Record<string, unknown> }
356
- | undefined
357
- return compilerOptions?.paths
358
- }
359
-
360
370
  /** Build per-package declaration scopes for a workspace. Longest roots are first so nested packages
361
371
  * own their source; files outside a workspace package remain owned by the root manifest. */
362
372
  async function doctorPackageScopes(
@@ -365,16 +375,16 @@ async function doctorPackageScopes(
365
375
  ): Promise<readonly DoctorPackageScope[]> {
366
376
  const importers = await workspaceImporters(cwd, rootPackage, true)
367
377
  const packages = importers.length > 0 ? importers : [{ root: cwd, package: rootPackage }]
368
- const rootPaths = await tsconfigPaths(cwd)
369
- const scopes = await Promise.all(
370
- packages.map(async (entry): Promise<DoctorPackageScope> => {
371
- const paths = (await tsconfigPaths(entry.root)) ?? rootPaths
372
- return {
373
- root: entry.root,
374
- relativeRoot: relative(cwd, entry.root).split("\\").join("/"),
375
- declared: declaredPackages(entry.package),
376
- isAlias: aliasMatcher(paths),
377
- }
378
+ // The nearest tsconfig, read as TypeScript does: comments, trailing commas and `extends` included.
379
+ const resolver = createSpecifierResolver()
380
+ const scopes = packages.map(
381
+ (entry): DoctorPackageScope => ({
382
+ root: entry.root,
383
+ relativeRoot: relative(cwd, entry.root).split("\\").join("/"),
384
+ declared: declaredPackages(entry.package),
385
+ isAlias: aliasMatcher(
386
+ Object.fromEntries(resolver.pathPatterns(entry.root).map((pattern) => [pattern, []])),
387
+ ),
378
388
  }),
379
389
  )
380
390
  return scopes.sort((a, b) => b.relativeRoot.length - a.relativeRoot.length)
@@ -690,7 +700,7 @@ async function collectDoctorReadiness(
690
700
  : configured(
691
701
  "graceful-lifecycle",
692
702
  "graceful lifecycle",
693
- /\bgracefulSignals\s*:\s*true\b|\bonStop\s*\(/,
703
+ /\bgracefulSignals\s*:\s*(?:true\b|\{)|\bonStop\s*\(/,
694
704
  ),
695
705
  ]
696
706
  return {
@@ -783,6 +793,7 @@ export async function collectDoctorResult(
783
793
  const scopes = await doctorPackageScopes(cwd, pkg)
784
794
 
785
795
  const findings: DoctorFinding[] = []
796
+ const manifestLists: ManifestList[] = []
786
797
  // A standalone app owns its tests, so they remain in the declaration check. A workspace root owns
787
798
  // the published package source while its root devDependencies intentionally own shared tests,
788
799
  // benchmarks, and scaffold copy-surfaces. Keep that boundary explicit instead of reporting a
@@ -813,6 +824,7 @@ export async function collectDoctorResult(
813
824
  for (const f of scanUndeclaredImports(rel, content, scope.declared, scope.isAlias)) {
814
825
  findings.push({ file: f.file, line: f.line, package: f.snippet })
815
826
  }
827
+ manifestLists.push(...manifestListsIn(rel, content))
816
828
  },
817
829
  { includeTests: scanScope.includeTests, ignore: workspaceSurface },
818
830
  )
@@ -829,6 +841,8 @@ export async function collectDoctorResult(
829
841
  const toolingDrift =
830
842
  opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
831
843
  const staleMcpPins = await collectStaleMcpPins(cwd).catch(() => undefined)
844
+ const versionDrift = collectVersionDrift(cwd, opts.cliVersion)
845
+ const handBuilt = handBuiltManifests(cwd, manifestLists)
832
846
  // `staleDists`, `toolingDrift` and `staleMcpPins` are advisory (see DoctorResult): they never fail `ok`.
833
847
  return {
834
848
  ok:
@@ -846,6 +860,8 @@ export async function collectDoctorResult(
846
860
  readiness,
847
861
  scanScope,
848
862
  ...(toolingDrift !== undefined ? { toolingDrift } : {}),
863
+ ...(versionDrift !== undefined ? { versionDrift } : {}),
864
+ ...(handBuilt.length > 0 ? { handBuiltManifests: handBuilt } : {}),
849
865
  ...(staleMcpPins !== undefined && staleMcpPins.files.length > 0 ? { staleMcpPins } : {}),
850
866
  ...(pipeline.ran ? { pipeline } : {}),
851
867
  }
@@ -1031,6 +1047,12 @@ export async function runDoctor(
1031
1047
  "or ./node_modules/.bin/nifra)\n",
1032
1048
  )
1033
1049
  }
1050
+ if (result.versionDrift !== undefined) {
1051
+ console.log(`⚠ ${versionDriftMessage(result.versionDrift)}\n`)
1052
+ }
1053
+ for (const finding of result.handBuiltManifests ?? []) {
1054
+ console.log(`⚠ ${handBuiltManifestMessage(finding)}\n`)
1055
+ }
1034
1056
  // Advisory: the agent's MCP server is launched from these pins, so a stale one answers for an old
1035
1057
  // release even when the CLI running doctor matches the project.
1036
1058
  if (result.staleMcpPins !== undefined) {
package/src/env-file.ts CHANGED
@@ -44,6 +44,13 @@ function unquote(raw: string): string {
44
44
  return (comment === -1 ? value : value.slice(0, comment)).trim()
45
45
  }
46
46
 
47
+ /**
48
+ * Set to the command name while a CLI command imports a project only to read its declarations
49
+ * (`nifra check`, `routes`, the `assure` report), so app code can skip work a read never needs, such
50
+ * as connecting to Redis.
51
+ */
52
+ export const REFLECT_ENV = "NIFRA_REFLECT"
53
+
47
54
  /** Parse dotenv text into a plain record. Later assignments to the same key win, as in a shell. */
48
55
  export function parseEnvFile(text: string): Record<string, string> {
49
56
  const values: Record<string, string> = {}