@nifrajs/cli 4.0.0 → 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 (332) 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 +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  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 +28 -3
  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 +958 -71
  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/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
@@ -22,15 +22,22 @@ import { type Diagnostic, diagnostic, normalizeSeverity, toSarifLog } from "./di
22
22
  import type { DoctorResult } from "./doctor.ts"
23
23
  import { type I18nCheckOutput, i18nCheckPassed, renderI18nCheck } from "./i18n-check.ts"
24
24
  import type { VerificationLevelsResult } from "./levels-tool.ts"
25
- import type { LoadedApp } from "./load.ts"
25
+ import type { AppLoader, IntrospectedApp } from "./load.ts"
26
26
  import type { ManifestEmitCommandResult } from "./manifest-tool.ts"
27
27
  import type { LayoutMigrationResult } from "./migrate-layout.ts"
28
+ import type { OutputSchemaStatus, OutputSchemasReport } from "./output-schemas.ts"
28
29
  import { collectPortResult, type PortResult, renderReport } from "./port.ts"
29
30
  import type { ReplayResult } from "./replay.ts"
30
31
  import { reviewSpec } from "./review.ts"
31
32
  import type { RouteTypesReport } from "./route-types.ts"
32
33
  import type { SmokeReport } from "./smoke.ts"
33
34
  import type { StylexMigrationResult } from "./stylex-migrate.ts"
35
+ import {
36
+ renderSyncManifestResults,
37
+ type SyncManifestResult,
38
+ syncManifestsOk,
39
+ syncServerManifests,
40
+ } from "./sync-manifest.ts"
34
41
  import {
35
42
  collectProjectWorkGraph,
36
43
  type ProjectWorkGraphResult,
@@ -74,8 +81,8 @@ export interface CommandCtx {
74
81
  readonly signal?: AbortSignal
75
82
  readonly progress?: (message: string) => void
76
83
  readonly cliVersion?: string
77
- /** Adapter-provided cache for commands that need the loaded web app. */
78
- readonly loadApp?: () => Promise<LoadedApp>
84
+ /** Adapter-provided cache for commands that need the loaded app. */
85
+ readonly loadApp?: AppLoader
79
86
  }
80
87
 
81
88
  export interface CommandSpec<Input, Output> {
@@ -322,6 +329,8 @@ interface RoutesInput {
322
329
  readonly json?: boolean | undefined
323
330
  readonly graph?: boolean | undefined
324
331
  readonly modes?: boolean | undefined
332
+ readonly hooks?: boolean | undefined
333
+ readonly since?: string | undefined
325
334
  readonly target?: string | undefined
326
335
  readonly dir?: string | undefined
327
336
  }
@@ -384,6 +393,12 @@ interface TypesInput {
384
393
  readonly dir?: string | undefined
385
394
  }
386
395
 
396
+ interface SchemasInput {
397
+ readonly write?: boolean | undefined
398
+ readonly json?: boolean | undefined
399
+ readonly dir?: string | undefined
400
+ }
401
+
387
402
  interface SnapshotInput {
388
403
  readonly out?: string | undefined
389
404
  readonly json?: boolean | undefined
@@ -452,7 +467,7 @@ interface AssureCommandOutput {
452
467
  interface LevelsCommandOutput extends VerificationLevelsResult {}
453
468
  interface RoutesCommandOutput {
454
469
  readonly ok: boolean
455
- readonly mode: "default" | "graph" | "modes"
470
+ readonly mode: "default" | "graph" | "modes" | "hooks"
456
471
  readonly data: unknown
457
472
  readonly text: string
458
473
  }
@@ -487,6 +502,9 @@ interface DiffCommandOutput {
487
502
  readonly hasBreaking: boolean
488
503
  readonly changes: readonly unknown[]
489
504
  }
505
+ interface RouteDiffCommandOutput extends DiffCommandOutput {
506
+ readonly text: string
507
+ }
490
508
  interface ContractsCommandOutput {
491
509
  readonly ok: boolean
492
510
  readonly lock?: ContractsLock
@@ -498,6 +516,10 @@ interface SyncCommandOutput {
498
516
  readonly ok: true
499
517
  readonly results: readonly unknown[]
500
518
  }
519
+ interface SyncManifestCommandOutput {
520
+ readonly ok: boolean
521
+ readonly results: readonly SyncManifestResult[]
522
+ }
501
523
 
502
524
  interface SmokeCommandOutput extends SmokeReport {}
503
525
 
@@ -637,15 +659,20 @@ const ROUTES_SCHEMA = input<RoutesInput>(
637
659
  json: { type: "boolean" },
638
660
  graph: { type: "boolean" },
639
661
  modes: { type: "boolean" },
662
+ hooks: { type: "boolean" },
663
+ since: { type: "string" },
640
664
  target: { type: "string" },
641
665
  dir: { type: "string" },
642
666
  }),
643
667
  (value) => {
644
668
  const raw = withDir(record(value))
645
- if (raw.graph === true && raw.modes === true)
646
- throw new TypeError("graph and modes cannot be combined")
669
+ if ([raw.graph, raw.modes, raw.hooks].filter((flag) => flag === true).length > 1)
670
+ throw new TypeError("graph, modes and hooks cannot be combined")
671
+ if (raw.since !== undefined && raw.hooks !== true)
672
+ throw new TypeError("since compares hook reports and needs hooks")
647
673
  return {
648
- ...parseBooleanFlags(raw, ["json", "graph", "modes"]),
674
+ ...parseBooleanFlags(raw, ["json", "graph", "modes", "hooks"]),
675
+ since: optionalString(raw.since, "since"),
649
676
  target: optionalString(raw.target, "target"),
650
677
  ...(raw.dir === undefined ? {} : { dir: raw.dir }),
651
678
  }
@@ -771,6 +798,17 @@ const TYPES_SCHEMA = input<TypesInput>(
771
798
  },
772
799
  )
773
800
 
801
+ const SCHEMAS_SCHEMA = input<SchemasInput>(
802
+ objectSchema({ write: { type: "boolean" }, json: { type: "boolean" }, dir: { type: "string" } }),
803
+ (value) => {
804
+ const raw = withDir(record(value))
805
+ return {
806
+ ...parseBooleanFlags(raw, ["write", "json"]),
807
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
808
+ }
809
+ },
810
+ )
811
+
774
812
  const SNAPSHOT_SCHEMA = input<SnapshotInput>(
775
813
  objectSchema({ out: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
776
814
  (value) => {
@@ -934,9 +972,10 @@ const I18N_SCHEMA = input<I18nInput>(
934
972
  },
935
973
  )
936
974
 
937
- function loadAppFor(ctx: CommandCtx): Promise<LoadedApp> {
938
- if (ctx.loadApp !== undefined) return ctx.loadApp()
939
- return import("./load.ts").then(({ loadApp }) => loadApp(ctx.cwd))
975
+ /** The app as the backend readers see it: an API-only app is its `backend/app.ts` alone. */
976
+ function backendAppFor(ctx: CommandCtx): Promise<IntrospectedApp> {
977
+ if (ctx.loadApp !== undefined) return ctx.loadApp.backend()
978
+ return import("./load.ts").then(({ loadBackendApp }) => loadBackendApp(ctx.cwd))
940
979
  }
941
980
 
942
981
  function structuredDiagnostics(out: CheckCommandOutput): readonly Diagnostic[] {
@@ -1185,7 +1224,8 @@ const manifestSpec: CommandSpec<ManifestInput, ManifestEmitCommandResult | DiffC
1185
1224
 
1186
1225
  const routesSpec: CommandSpec<RoutesInput, RoutesCommandOutput> = {
1187
1226
  name: "routes",
1188
- summary: "List, graph, or target-check every page and API route the app serves.",
1227
+ summary:
1228
+ "List, graph, or target-check every page and API route the app serves, or the hooks each runs.",
1189
1229
  input: ROUTES_SCHEMA,
1190
1230
  output: output({
1191
1231
  type: "object",
@@ -1199,12 +1239,47 @@ const routesSpec: CommandSpec<RoutesInput, RoutesCommandOutput> = {
1199
1239
  { name: "json", field: "json", type: "boolean" },
1200
1240
  { name: "graph", field: "graph", type: "boolean" },
1201
1241
  { name: "modes", field: "modes", type: "boolean" },
1242
+ { name: "hooks", field: "hooks", type: "boolean" },
1243
+ { name: "since", field: "since", type: "string" },
1202
1244
  { name: "target", field: "target", type: "string" },
1203
1245
  ],
1204
1246
  },
1205
1247
  async run(value, ctx) {
1206
- const app = await loadAppFor(ctx)
1248
+ if (value.hooks === true) {
1249
+ const app = await backendAppFor(ctx)
1250
+ const hooks = await import("./hooks-report.ts")
1251
+ const report = await hooks.collectHookReport(app.backend)
1252
+ if (value.since === undefined) {
1253
+ return { ok: true, mode: "hooks", data: report, text: hooks.renderHookReport(report) }
1254
+ }
1255
+ const saved = hooks.parseHookReport(
1256
+ await Bun.file(resolve(ctx.cwd, value.since))
1257
+ .text()
1258
+ .catch(() => ""),
1259
+ )
1260
+ if (saved === undefined) {
1261
+ return {
1262
+ ok: false,
1263
+ mode: "hooks",
1264
+ data: { report, error: `${value.since} is not a saved hook report` },
1265
+ text: `[nifra] ${value.since} is not a saved hook report (write one with \`nifra routes --hooks --json\`)`,
1266
+ }
1267
+ }
1268
+ const changes = hooks.diffHookReports(saved, report)
1269
+ return {
1270
+ ok: true,
1271
+ mode: "hooks",
1272
+ data: { report, changes },
1273
+ text: hooks.renderHookDiff(changes),
1274
+ }
1275
+ }
1276
+ const app = await backendAppFor(ctx)
1207
1277
  if (value.modes === true) {
1278
+ if (app.framework === undefined) {
1279
+ throw new Error(
1280
+ `[nifra] routes --modes reads page routes, and ${ctx.cwd} is an API-only app (backend/app.ts, no web config)`,
1281
+ )
1282
+ }
1208
1283
  const { buildRouteManifest, renderRouteManifest } = await import(
1209
1284
  "@nifrajs/web/route-manifest"
1210
1285
  )
@@ -1254,7 +1329,7 @@ const contextSpec: CommandSpec<ContextInput, ContextCommandOutput> = {
1254
1329
  async run(value, ctx) {
1255
1330
  return {
1256
1331
  ok: true,
1257
- text: (await import("./introspect.ts")).describeProject(await loadAppFor(ctx), {
1332
+ text: (await import("./introspect.ts")).describeProject(await backendAppFor(ctx), {
1258
1333
  ...(value.path === undefined ? {} : { path: value.path }),
1259
1334
  ...(value.kind === undefined ? {} : { kind: value.kind }),
1260
1335
  }),
@@ -1282,10 +1357,7 @@ const openApiSpec: CommandSpec<OpenApiInput, OpenApiCommandOutput> = {
1282
1357
  ],
1283
1358
  },
1284
1359
  async run(value, ctx) {
1285
- const app =
1286
- ctx.loadApp !== undefined
1287
- ? await ctx.loadApp()
1288
- : await import("./load.ts").then(({ loadBackendApp }) => loadBackendApp(ctx.cwd))
1360
+ const app = await backendAppFor(ctx)
1289
1361
  const { renderOpenApiWithTypes } = await import("./openapi-tool.ts")
1290
1362
  const format = value.format ?? "json"
1291
1363
  return {
@@ -1420,6 +1492,8 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1420
1492
  splits: { type: "array" },
1421
1493
  moves: { type: "array" },
1422
1494
  rewritten: { type: "array" },
1495
+ tooling: { type: "array" },
1496
+ mode: { type: "string" },
1423
1497
  },
1424
1498
  required: ["ok", "write", "issues"],
1425
1499
  }),
@@ -1449,13 +1523,14 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1449
1523
  if (out.kind === "layout") {
1450
1524
  return [
1451
1525
  out.ok
1452
- ? `✓ ${out.splits.length} route file${out.splits.length === 1 ? "" : "s"} split, ${out.moves.length} file${out.moves.length === 1 ? "" : "s"} moved`
1526
+ ? `✓ ${out.mode === "api" ? "API-only app" : `${out.splits.length} route file${out.splits.length === 1 ? "" : "s"} split`}, ${out.moves.length} file${out.moves.length === 1 ? "" : "s"} moved`
1453
1527
  : `⚠ ${out.issues.length} issue${out.issues.length === 1 ? "" : "s"} need a decision; everything else is planned`,
1454
1528
  ...out.splits.map(
1455
1529
  (split) => ` split ${split.file} → ${split.backend} (${split.moved.join(", ")})`,
1456
1530
  ),
1457
1531
  ...out.moves.map((move) => ` move ${move.from} → ${move.to}`),
1458
1532
  ...out.rewritten.map((file) => ` edit ${file} (imports)`),
1533
+ ...out.tooling.map((edit) => ` edit ${edit.file} (${edit.change})`),
1459
1534
  ...out.issues.map((issue) => ` ⚠ ${issue.file}: ${issue.reason}`),
1460
1535
  ...(out.write ? [] : [" dry run: pass --write to apply"]),
1461
1536
  ]
@@ -1576,6 +1651,75 @@ const typesSpec: CommandSpec<TypesInput, RouteTypesReport> = {
1576
1651
  success: (out) => out.ok,
1577
1652
  }
1578
1653
 
1654
+ const SCHEMA_STATUS_LINE: Readonly<Record<OutputSchemaStatus, string>> = {
1655
+ missing: "+ would write",
1656
+ generated: "+ wrote",
1657
+ stale: "↻ stale",
1658
+ regenerated: "↻ rewrote",
1659
+ current: "✓ current",
1660
+ declared: "· hand-written",
1661
+ "no-data": "· no data",
1662
+ unreadable: "⚠ unreadable",
1663
+ }
1664
+
1665
+ const schemasSpec: CommandSpec<SchemasInput, OutputSchemasReport> = {
1666
+ name: "schemas",
1667
+ summary:
1668
+ "Write a reviewable `loaderOutput`/`actionOutput` from the loader's or action's return type where a route has none (`--write`), keep generated ones current, and list returned fields a hand-written schema drops.",
1669
+ input: SCHEMAS_SCHEMA,
1670
+ output: output({
1671
+ type: "object",
1672
+ properties: {
1673
+ ok: { type: "boolean" },
1674
+ write: { type: "boolean" },
1675
+ entries: { type: "array" },
1676
+ written: { type: "array" },
1677
+ warnings: { type: "array" },
1678
+ },
1679
+ required: ["ok", "write", "entries", "written", "warnings"],
1680
+ }),
1681
+ transports: ["cli"],
1682
+ stability: "stable",
1683
+ argv: {
1684
+ flags: [
1685
+ { name: "write", field: "write", type: "boolean" },
1686
+ { name: "json", field: "json", type: "boolean" },
1687
+ { name: "dir", field: "dir", type: "string" },
1688
+ ],
1689
+ },
1690
+ async run(value, ctx) {
1691
+ const { outputSchemas } = await import("./output-schemas.ts")
1692
+ return outputSchemas(resolve(ctx.cwd, value.dir ?? "."), { write: value.write === true })
1693
+ },
1694
+ render: (out) => {
1695
+ const shown = out.entries.filter(
1696
+ (entry) =>
1697
+ entry.status !== "current" &&
1698
+ entry.status !== "no-data" &&
1699
+ (entry.status !== "declared" || entry.dropped.length > 0),
1700
+ )
1701
+ const current = out.entries.filter((entry) => entry.status === "current").length
1702
+ return [
1703
+ ...out.warnings.map((warning) => `⚠ ${warning}`),
1704
+ `${out.ok ? "✓" : "✖"} ${out.entries.length} output schema${out.entries.length === 1 ? "" : "s"} read, ${current} generated and current`,
1705
+ ...shown.map((entry) => {
1706
+ const detail =
1707
+ entry.reason ??
1708
+ (entry.edited === true
1709
+ ? "edited by hand under its nifra:generated line"
1710
+ : entry.dropped.length > 0
1711
+ ? `drops ${entry.dropped.join(", ")}`
1712
+ : undefined)
1713
+ return ` ${SCHEMA_STATUS_LINE[entry.status]} ${entry.file}:${entry.line} ${entry.schema}${detail === undefined ? "" : ` - ${detail}`}`
1714
+ }),
1715
+ ...(out.write || !out.entries.some((e) => e.status === "missing" || e.status === "stale")
1716
+ ? []
1717
+ : [" dry run: pass --write to apply"]),
1718
+ ]
1719
+ },
1720
+ success: (out) => out.ok,
1721
+ }
1722
+
1579
1723
  const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
1580
1724
  name: "snapshot",
1581
1725
  summary: "Write the backend API contract as a versioned JSON baseline.",
@@ -1600,18 +1744,16 @@ const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
1600
1744
  },
1601
1745
  async run(value, ctx) {
1602
1746
  const mod = await import("./diff-tool.ts")
1603
- const routes = await mod.snapshotBackend(ctx.cwd)
1604
1747
  const file = value.out ?? mod.DEFAULT_SNAPSHOT_FILE
1605
- const snapshot = { nifraSnapshot: 1 as const, routes }
1606
- await Bun.write(resolve(ctx.cwd, file), `${JSON.stringify(snapshot, null, 2)}\n`)
1607
- return { ok: true, file, routes: routes.length, snapshot }
1748
+ const snapshot = await mod.writeSnapshot(ctx.cwd, file)
1749
+ return { ok: true, file, routes: snapshot.routes.length, snapshot }
1608
1750
  },
1609
1751
  render: (out) => [
1610
1752
  `[nifra] wrote ${out.routes} route${out.routes === 1 ? "" : "s"} to ${out.file}`,
1611
1753
  ],
1612
1754
  }
1613
1755
 
1614
- const diffSpec: CommandSpec<DiffInput, DiffCommandOutput> = {
1756
+ const diffSpec: CommandSpec<DiffInput, RouteDiffCommandOutput> = {
1615
1757
  name: "diff",
1616
1758
  summary: "Compare the current backend contract with a baseline and fail on breaking changes.",
1617
1759
  input: DIFF_SCHEMA,
@@ -1628,21 +1770,17 @@ const diffSpec: CommandSpec<DiffInput, DiffCommandOutput> = {
1628
1770
  stability: "stable",
1629
1771
  argv: { positionals: ["baseline"], flags: [{ name: "json", field: "json", type: "boolean" }] },
1630
1772
  async run(value, ctx) {
1631
- const mod = await import("./diff-tool.ts")
1632
- const baseline = value.baseline ?? mod.DEFAULT_SNAPSHOT_FILE
1633
- const file = await Bun.file(resolve(ctx.cwd, baseline)).text()
1634
- const before = mod.parseSnapshotFile(file, baseline)
1635
- const current = await mod.snapshotBackend(ctx.cwd)
1636
- const { diffRouteSnapshots } = await import("@nifrajs/core/diff")
1637
- const diff = diffRouteSnapshots(before.routes, current)
1638
- return { ok: !diff.hasBreaking, hasBreaking: diff.hasBreaking, changes: diff.changes }
1773
+ const { diffAgainstBaseline, formatDiff } = await import("./diff-tool.ts")
1774
+ const diff = await diffAgainstBaseline(ctx.cwd, value.baseline)
1775
+ return {
1776
+ ok: !diff.hasBreaking,
1777
+ hasBreaking: diff.hasBreaking,
1778
+ changes: diff.changes,
1779
+ text: formatDiff(diff),
1780
+ }
1639
1781
  },
1640
- render: (out) => [
1641
- out.ok ? "✓ no breaking changes" : "✖ breaking changes",
1642
- ...(out.changes as readonly { method: string; path: string; message: string }[]).map(
1643
- (c) => `${c.method} ${c.path}: ${c.message}`,
1644
- ),
1645
- ],
1782
+ render: (out) => [out.text],
1783
+ json: ({ ok, hasBreaking, changes }) => ({ ok, hasBreaking, changes }),
1646
1784
  success: (out) => out.ok,
1647
1785
  }
1648
1786
 
@@ -1674,7 +1812,7 @@ const contractsSpec: CommandSpec<ContractsInput, ContractsCommandOutput> = {
1674
1812
  success: (out) => out.ok,
1675
1813
  }
1676
1814
 
1677
- const syncManifestSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
1815
+ const syncManifestSpec: CommandSpec<SyncInput, SyncManifestCommandOutput> = {
1678
1816
  name: "sync-manifest",
1679
1817
  summary: "Regenerate generated server-manifest route tables without a full build.",
1680
1818
  input: SYNC_SCHEMA,
@@ -1687,16 +1825,11 @@ const syncManifestSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
1687
1825
  stability: "stable",
1688
1826
  argv: { flags: [{ name: "json", field: "json", type: "boolean" }] },
1689
1827
  async run(_value, ctx) {
1690
- return {
1691
- ok: true,
1692
- results: await (await import("./sync-manifest.ts")).syncServerManifests(ctx.cwd),
1693
- }
1828
+ const results = await syncServerManifests(ctx.cwd)
1829
+ return { ok: syncManifestsOk(results), results }
1694
1830
  },
1695
- render: (out) => [
1696
- out.results.length === 0
1697
- ? "nifra sync-manifest: no generated server-manifest.ts found under this directory."
1698
- : `✓ checked ${out.results.length} generated server manifest${out.results.length === 1 ? "" : "s"}`,
1699
- ],
1831
+ render: (out) => renderSyncManifestResults(out.results),
1832
+ success: (out) => out.ok,
1700
1833
  }
1701
1834
 
1702
1835
  const syncRoutesSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
@@ -1875,6 +2008,7 @@ export const commandSpecs = Object.freeze([
1875
2008
  migrateSpec,
1876
2009
  targetSpec,
1877
2010
  typesSpec,
2011
+ schemasSpec,
1878
2012
  snapshotSpec,
1879
2013
  diffSpec,
1880
2014
  contractsSpec,
@@ -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
  }