@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
package/src/check.ts CHANGED
@@ -16,8 +16,10 @@
16
16
  * anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
17
17
  */
18
18
 
19
- import { existsSync, readFileSync } from "node:fs"
20
- import { dirname, isAbsolute, join, relative } from "node:path"
19
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmdirSync, rmSync } from "node:fs"
20
+ import { tmpdir } from "node:os"
21
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
22
+ import { specifierPackage } from "@nifrajs/web/zones"
21
23
  import { BACKEND_APP_FILE } from "./app-files.ts"
22
24
  import {
23
25
  type CheckAssuranceContext,
@@ -26,16 +28,22 @@ import {
26
28
  type CheckResult,
27
29
  collectCheckDiagnostics,
28
30
  type RuleOverride,
31
+ safeIgnoreGlobMatch,
29
32
  } from "./check-diagnostics.ts"
33
+ import { appDirs, clientModuleRoot } from "./client-module.ts"
30
34
  import { checkContractsLock, DEFAULT_CONTRACTS_LOCK } from "./contracts.ts"
31
35
  import { type Diagnostic, normalizeSeverity, toSarifLog } from "./diagnostics.ts"
36
+ import { createApiOnlyProbe, createNifraProjectProbe } from "./internal/nifra-project.ts"
37
+ import { createSpecifierResolver } from "./internal/project-specifiers.ts"
32
38
  import { createSourceFacts } from "./internal/source-facts.ts"
33
39
  import { loadProjectTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
40
+ import { forwardTerminalSignals, OWN_PROCESS_GROUP, signalProcessGroup } from "./mcp-io.ts"
34
41
  // Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
35
42
  // close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
36
43
  import type { ContractCheckFacts, ProjectFactsSeed } from "./project-facts.ts"
37
44
  import { RULE_CODES } from "./rules/codes.ts"
38
45
  import { sourceIndex } from "./rules/index.ts"
46
+ import { filesOutsideProgram, splitListedFiles } from "./typecheck-coverage.ts"
39
47
 
40
48
  export type {
41
49
  CheckAnalysisInput,
@@ -50,24 +58,31 @@ export type {
50
58
  } from "./check-diagnostics.ts"
51
59
 
52
60
  import {
61
+ COMPONENT_FILE,
62
+ componentScripts,
53
63
  createProjectSqlImports,
64
+ createServerOnlyChains,
54
65
  isBrowserSource,
55
66
  type ModuleReader,
56
67
  type ModuleResolver,
57
- resolveServerOnlyChains,
58
68
  type SourceFinding,
69
+ type SqlFinding,
59
70
  type StaticRouteFinding,
60
71
  scanFetchText,
61
- scanInterpolatedSql,
62
72
  scanRemovedImports,
63
73
  scanResponseRoutes,
64
74
  scanServerManifestDrift,
75
+ scanSqlSites,
65
76
  scanStaticRouteText,
66
77
  scanStreamText,
78
+ scanUnresolvedRoutePaths,
67
79
  scanUntypedClient,
68
80
  type TransitiveServerImportFinding,
81
+ type TypedClientFinding,
82
+ type UnresolvedRoutePathFinding,
69
83
  walkSource,
70
84
  } from "./check-scan.ts"
85
+ import { componentCheckReceipts } from "./component-check.ts"
71
86
 
72
87
  export * from "./check-scan.ts"
73
88
 
@@ -80,6 +95,10 @@ interface TypecheckResult {
80
95
  /** tsconfig.json exists but no `typescript` install was found walking up from cwd. The contract
81
96
  * gate could not run - the caller surfaces this as a FAILING diagnostic, never a silent skip. */
82
97
  readonly missingTypeScript?: boolean
98
+ /** Every file in the compiler's program, absolute. Absent when the compiler listed none. */
99
+ readonly programFiles?: readonly string[]
100
+ /** Zoned TypeScript files outside the program, project-relative: the typecheck never read them. */
101
+ readonly outsideProgram?: readonly string[]
83
102
  }
84
103
 
85
104
  /**
@@ -117,38 +136,70 @@ async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckRe
117
136
  note: "typescript not installed (run: bun add -d typescript)",
118
137
  }
119
138
  }
139
+ const first = await runTsc(cwd, tscBin, tsconfig, true, signal)
140
+ // A compiler that rejects `--listFiles` still gets its typecheck; only the coverage check is lost.
141
+ if (!first.ok && !first.cancelled && /TS50(?:23|25)\b[^\n]*listFiles/.test(first.output ?? ""))
142
+ return runTsc(cwd, tscBin, tsconfig, false, signal)
143
+ return first
144
+ }
145
+
146
+ async function runTsc(
147
+ cwd: string,
148
+ tscBin: string,
149
+ tsconfig: string,
150
+ listFiles: boolean,
151
+ signal?: AbortSignal,
152
+ ): Promise<TypecheckResult> {
120
153
  if (signal?.aborted) return { ran: true, ok: false, cancelled: true, output: "cancelled" }
121
- const proc = Bun.spawn([process.execPath, tscBin, "--noEmit", "-p", tsconfig], {
122
- cwd,
123
- stdout: "pipe",
124
- stderr: "pipe",
125
- })
126
- let cancelled = false
127
- const abort = (): void => {
128
- cancelled = true
129
- proc.kill()
130
- }
131
- signal?.addEventListener("abort", abort, { once: true })
132
- let out = ""
133
- let err = ""
134
- let code: number | null = null
154
+ const args = [process.execPath, tscBin, "--noEmit", "-p", tsconfig]
155
+ if (listFiles) args.push("--listFiles")
156
+ // tsc calls process.exit after printing. Bun can discard buffered pipe writes at that point,
157
+ // especially while the parent's other scans occupy the event loop. Files keep the full program.
158
+ const outputDirectory = mkdtempSync(join(tmpdir(), "nifra-tsc-"))
159
+ const stdout = Bun.file(join(outputDirectory, "stdout"))
160
+ const stderr = Bun.file(join(outputDirectory, "stderr"))
135
161
  try {
136
- const result = await Promise.all([
137
- new Response(proc.stdout).text(),
138
- new Response(proc.stderr).text(),
139
- proc.exited,
140
- ])
141
- out = result[0]
142
- err = result[1]
143
- code = result[2]
162
+ const proc = Bun.spawn(args, {
163
+ cwd,
164
+ stdout,
165
+ stderr,
166
+ // Its own group lets cancellation also stop a compiler process the tsc entry started.
167
+ detached: OWN_PROCESS_GROUP,
168
+ })
169
+ let cancelled = false
170
+ const stop = (): void => signalProcessGroup(proc, "SIGTERM")
171
+ const abort = (): void => {
172
+ cancelled = true
173
+ stop()
174
+ }
175
+ signal?.addEventListener("abort", abort, { once: true })
176
+ const releaseSignals = forwardTerminalSignals(stop)
177
+ let out = ""
178
+ let err = ""
179
+ let code: number | null = null
180
+ try {
181
+ code = await proc.exited
182
+ const result = await Promise.all([stdout.text(), stderr.text()])
183
+ out = result[0]
184
+ err = result[1]
185
+ } finally {
186
+ signal?.removeEventListener("abort", abort)
187
+ releaseSignals()
188
+ }
189
+ if (cancelled) return { ran: true, ok: false, output: "cancelled", cancelled: true }
190
+ const listed = listFiles ? splitListedFiles(out) : undefined
191
+ return {
192
+ ran: true,
193
+ ok: code === 0,
194
+ output: `${listed?.output ?? out}${err}`.trim(),
195
+ ...(listed !== undefined && listed.programFiles.length > 0
196
+ ? { programFiles: listed.programFiles }
197
+ : {}),
198
+ }
144
199
  } finally {
145
- signal?.removeEventListener("abort", abort)
146
- }
147
- return {
148
- ran: true,
149
- ok: code === 0 && !cancelled,
150
- output: cancelled ? "cancelled" : `${out}${err}`.trim(),
151
- ...(cancelled ? { cancelled: true } : {}),
200
+ rmSync(join(outputDirectory, "stdout"), { force: true })
201
+ rmSync(join(outputDirectory, "stderr"), { force: true })
202
+ rmdirSync(outputDirectory)
152
203
  }
153
204
  }
154
205
 
@@ -244,12 +295,7 @@ async function collectContractFacts(cwd: string): Promise<ContractCheckFacts> {
244
295
  const hasLock = existsSync(join(cwd, DEFAULT_CONTRACTS_LOCK))
245
296
  if (!hasBackend && !hasLock) return { present: false, vacuous: false, diagnostics: [] }
246
297
  try {
247
- const result = await checkContractsLock(cwd)
248
- return {
249
- present: result.present,
250
- vacuous: result.vacuous,
251
- diagnostics: result.diagnostics,
252
- }
298
+ return await checkContractsLock(cwd)
253
299
  } catch (error) {
254
300
  if (error instanceof Error && error.message === "contract source not configured") {
255
301
  return { present: false, vacuous: false, diagnostics: [] }
@@ -263,43 +309,80 @@ async function collectContractFacts(cwd: string): Promise<ContractCheckFacts> {
263
309
  }
264
310
  }
265
311
 
312
+ /** Record the zoned files the compiler's program left out, minus the typecheck rule's `ignore` globs. */
313
+ function withProgramCoverage(
314
+ cwd: string,
315
+ result: TypecheckResult,
316
+ sourceFiles: ReadonlyArray<{ readonly file: string }>,
317
+ checkConfig: CheckConfig,
318
+ ): TypecheckResult {
319
+ const { programFiles, ...rest } = result
320
+ if (programFiles === undefined) return result
321
+ const ignore = (checkConfig.rules.typecheck ?? checkConfig.rules["NF-C001"])?.ignore
322
+ const outside = filesOutsideProgram(
323
+ cwd,
324
+ sourceFiles.map((source) => source.file),
325
+ programFiles,
326
+ ).filter((file) => !ignore?.some((pattern) => safeIgnoreGlobMatch(pattern, file)))
327
+ return outside.length === 0 ? rest : { ...rest, outsideProgram: outside }
328
+ }
329
+
266
330
  /** Build the rule input from one source walk and the other check-wide scans. */
267
331
  async function buildProjectScan(
268
332
  cwd: string,
269
333
  opts: Pick<CheckCollectionOptions, "lintsOnly" | "signal" | "loadTypeScript">,
270
334
  ): Promise<ProjectScan> {
271
- const fetches: SourceFinding[] = []
272
- const streams: SourceFinding[] = []
335
+ const fetches: TypedClientFinding[] = []
336
+ const streams: TypedClientFinding[] = []
273
337
  const staticRoutes: StaticRouteFinding[] = []
274
338
  const untypedClients: SourceFinding[] = []
275
339
  const removedImports: SourceFinding[] = []
276
340
  const serverImports: TransitiveServerImportFinding[] = []
277
341
  const responseRoutes: SourceFinding[] = []
278
- const interpolatedSql: SourceFinding[] = []
342
+ const interpolatedSql: SqlFinding[] = []
343
+ const routePaths: UnresolvedRoutePathFinding[] = []
279
344
  const browserModules: Array<{ rel: string; content: string }> = []
280
345
  const sourceFiles: Array<{ file: string; content: string }> = []
346
+ const inNifraProject = createNifraProjectProbe(cwd)
347
+ // An API-only project has no browser bundle for server code to reach.
348
+ const apiOnly = createApiOnlyProbe(cwd)
281
349
  const {
282
350
  config: checkConfig,
283
351
  error: checkConfigError,
284
352
  warnings: checkConfigWarnings,
285
353
  } = await loadCheckConfig(cwd)
286
- const [typecheckResult, _, doctor, manifestDrift, contracts] = await Promise.all([
354
+ const [typecheckRun, _, doctor, manifestDrift, contracts] = await Promise.all([
287
355
  opts.lintsOnly
288
356
  ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "lints-only mode" })
289
357
  : typecheck(cwd, opts.signal),
290
- walkSource(cwd, (rel, content) => {
291
- sourceFiles.push({ file: rel, content })
292
- fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
293
- streams.push(...scanStreamText(rel, content, checkConfig.externalMounts))
294
- untypedClients.push(...scanUntypedClient(rel, content))
295
- removedImports.push(...scanRemovedImports(rel, content))
296
- if (isBrowserSource(rel)) browserModules.push({ rel, content })
297
- }),
358
+ walkSource(
359
+ cwd,
360
+ (rel, content) => {
361
+ sourceFiles.push({ file: rel, content })
362
+ fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
363
+ streams.push(...scanStreamText(rel, content, checkConfig.externalMounts))
364
+ untypedClients.push(...scanUntypedClient(rel, content))
365
+ removedImports.push(...scanRemovedImports(rel, content))
366
+ const dir = dirname(rel).replace(/^\.$/, "")
367
+ if (isBrowserSource(rel) && inNifraProject(dir) && !apiOnly(dir))
368
+ browserModules.push({ rel, content })
369
+ },
370
+ { scripts: true },
371
+ ),
298
372
  import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
299
373
  scanServerManifestDrift(cwd),
300
374
  collectContractFacts(cwd),
301
375
  ])
302
376
 
377
+ // tsc compiles TypeScript sources only; the JavaScript and component files the scan reads are no
378
+ // program's to cover.
379
+ const typecheckResult = withProgramCoverage(
380
+ cwd,
381
+ typecheckRun,
382
+ sourceFiles.filter(({ file }) => /\.[cm]?tsx?$/.test(file)),
383
+ checkConfig,
384
+ )
385
+
303
386
  // TypeScript 7's unstable API materializes source files through one project session. Build the
304
387
  // source index first so every syntax-backed scanner (including the independent rule registry)
305
388
  // shares that session instead of spawning one compiler process per rule.
@@ -309,37 +392,95 @@ async function buildProjectScan(
309
392
  const sqlCompiler = loadedTypeScript.compiler
310
393
  const sourceFacts = sqlCompiler === undefined ? undefined : createSourceFacts(sqlCompiler)
311
394
  const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd)
395
+ const project = new Map(
396
+ sourceFiles.map(({ file, content }) => [file.replaceAll("\\", "/"), content]),
397
+ )
312
398
  for (const { file, content } of sourceFiles) {
313
- staticRoutes.push(...scanStaticRouteText(file, content, sourceFacts))
399
+ staticRoutes.push(...scanStaticRouteText(file, content, sourceFacts, project))
400
+ routePaths.push(...scanUnresolvedRoutePaths(file, content, project, sourceFacts))
314
401
  responseRoutes.push(...scanResponseRoutes(file, content, sourceFacts))
315
402
  if (sqlCompiler !== undefined)
316
- interpolatedSql.push(...scanInterpolatedSql(file, content, sqlCompiler, sqlImports))
403
+ interpolatedSql.push(...scanSqlSites(file, content, sqlCompiler, sqlImports))
317
404
  }
318
405
 
319
406
  // Resolved modules stay project-relative, as the scanned files are: the zone of a path is read
320
407
  // from its folders, and a checkout under a folder named `backend/` must not make it all backend.
321
- const resolveModule: ModuleResolver = (fromFile, specifier) => {
408
+ // The resolver answers with real paths, so the project root is read as one too.
409
+ const realCwd = (() => {
322
410
  try {
323
- const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile)
324
- const resolved = Bun.resolveSync(specifier, dirname(fromAbs))
325
- const rel = relative(cwd, resolved).replaceAll("\\", "/")
326
- return rel.startsWith("../") || isAbsolute(rel) ? resolved : rel
411
+ return realpathSync(cwd)
327
412
  } catch {
328
- return undefined
413
+ return cwd
329
414
  }
415
+ })()
416
+ const resolveSpecifier = createSpecifierResolver()
417
+ const projectPath = (path: string): string => {
418
+ const rel = relative(realCwd, path).replaceAll("\\", "/")
419
+ return rel.startsWith("../") || isAbsolute(rel) ? path : rel
420
+ }
421
+ const resolveModule: ModuleResolver = (fromFile, specifier) => {
422
+ const resolved = resolveSpecifier(
423
+ isAbsolute(fromFile) ? fromFile : join(realCwd, fromFile),
424
+ specifier,
425
+ )
426
+ return resolved === undefined ? undefined : projectPath(resolved)
330
427
  }
331
428
  const readModule: ModuleReader = (path) => {
429
+ let text: string
332
430
  try {
333
- return readFileSync(isAbsolute(path) ? path : join(cwd, path), "utf8")
431
+ text = readFileSync(isAbsolute(path) ? path : join(cwd, path), "utf8")
334
432
  } catch {
335
433
  return undefined
336
434
  }
435
+ return COMPONENT_FILE.test(path) ? componentScripts(text) : text
337
436
  }
338
- for (const { rel, content } of browserModules) {
339
- serverImports.push(
340
- ...resolveServerOnlyChains(rel, content, resolveModule, readModule, sourceFacts),
341
- )
437
+ const components = await componentCheckReceipts(cwd, source)
438
+ const componentReceipts = components.receipts
439
+ // An app's `clientModule` is browser code wherever it lives: the generated client entry imports it.
440
+ const browserRoots = new Set<string>()
441
+ for (const app of appDirs(source.files)) {
442
+ const entry = clientModuleRoot(cwd, app, resolveSpecifier)
443
+ if (entry === undefined) continue
444
+ const rel = projectPath(entry.file)
445
+ if (isBrowserSource(rel) || browserRoots.has(rel)) continue
446
+ const content = readModule(rel)
447
+ if (content === undefined) continue
448
+ browserRoots.add(rel)
449
+ browserModules.push({ rel, content })
450
+ }
451
+ // Node's lookup: the nearest `node_modules/<name>` at or above the importing file.
452
+ const backendPackage = (fromFile: string, specifier: string): boolean => {
453
+ const name = specifierPackage(specifier)
454
+ if (name === undefined) return false
455
+ let dir = dirname(isAbsolute(fromFile) ? fromFile : join(realCwd, fromFile))
456
+ for (;;) {
457
+ const manifest = join(dir, "node_modules", name, "package.json")
458
+ if (existsSync(manifest)) {
459
+ try {
460
+ const nifra: unknown = JSON.parse(readFileSync(manifest, "utf8"))?.nifra
461
+ return (
462
+ typeof nifra === "object" &&
463
+ nifra !== null &&
464
+ Reflect.get(nifra, "environment") === "backend"
465
+ )
466
+ } catch {
467
+ return false
468
+ }
469
+ }
470
+ const parent = dirname(dir)
471
+ if (parent === dir) return false
472
+ dir = parent
473
+ }
342
474
  }
475
+ const serverOnlyChains = createServerOnlyChains({
476
+ resolve: resolveModule,
477
+ read: readModule,
478
+ facts: sourceFacts,
479
+ componentReceipts,
480
+ backendPackage,
481
+ })
482
+ for (const { rel, content } of browserModules)
483
+ serverImports.push(...serverOnlyChains(rel, content, browserRoots.has(rel)))
343
484
 
344
485
  const facts: ProjectFactsSeed = {
345
486
  source,
@@ -351,6 +492,10 @@ async function buildProjectScan(
351
492
  check: {
352
493
  typecheck: typecheckResult,
353
494
  sqlCompilerAvailable: sqlCompiler !== undefined,
495
+ componentCheckUnsupported:
496
+ components.unsupported === undefined
497
+ ? undefined
498
+ : { version: components.unsupported.version, message: components.unsupported.message },
354
499
  ...(loadedTypeScript.unsupported === undefined
355
500
  ? {}
356
501
  : { unsupportedTypeScriptVersion: loadedTypeScript.unsupported.version }),
@@ -368,6 +513,7 @@ async function buildProjectScan(
368
513
  removedImports,
369
514
  responseRoutes,
370
515
  interpolatedSql,
516
+ routePaths,
371
517
  },
372
518
  }
373
519
  return { facts }
@@ -381,9 +527,11 @@ async function buildProjectScan(
381
527
  * report, `--json`, and the MCP tool all render from. */
382
528
  /** Run the scan, freeze its facts, and project them into the public diagnostic result. */
383
529
  export async function collectCheckResult(
384
- cwd: string,
530
+ root: string,
385
531
  opts: CheckCollectionOptions = {},
386
532
  ): Promise<CheckResult> {
533
+ // The zone classifier works on absolute paths; under a relative root every file read as unzoned.
534
+ const cwd = resolve(root)
387
535
  const scan = await buildProjectScan(cwd, opts)
388
536
  try {
389
537
  return await collectCheckDiagnostics(cwd, scan, opts)
@@ -416,7 +564,7 @@ const REPORT_SECTIONS = [
416
564
  /**
417
565
  * Render the human-readable check report as lines. Pure (no I/O, no cwd) so tests can assert
418
566
  * report/exit-code parity: every diagnostic in `result.diagnostics` appears in the output, and the
419
- * trailer states the error/advisory counts that produced `ok`.
567
+ * trailer states the error, advisory and info counts that produced `ok`.
420
568
  */
421
569
  export function renderCheckReport(result: CheckResult): string[] {
422
570
  const lines: string[] = []
@@ -425,7 +573,7 @@ export function renderCheckReport(result: CheckResult): string[] {
425
573
  result.typecheck === "pass"
426
574
  ? "✓ typecheck passed"
427
575
  : result.typecheck === "fail"
428
- ? "✗ typecheck failed - the frontend/backend contract is broken"
576
+ ? `✗ typecheck failed - ${result.typecheckNote ?? "the frontend/backend contract is broken"}`
429
577
  : `⚠ typecheck SKIPPED - ${result.typecheckNote ?? "no tsconfig / typescript not installed"} (the contract gate did not run)`,
430
578
  )
431
579
  // Stated on every run, passing or not. "Which bundler is this app on" decides which plugin slot is
@@ -467,13 +615,17 @@ export function renderCheckReport(result: CheckResult): string[] {
467
615
  // resolve condition the Bun dev bundler can't take does not), so the counts are split rather than
468
616
  // rounded up to the worse of the two.
469
617
  const errors = ds.filter((d) => d.severity === "error").length
470
- const advisory = ds.length - errors
618
+ const warnings = ds.filter((d) => d.severity === "warning").length
619
+ const infos = ds.length - errors - warnings
620
+ const rest = belowError(warnings, infos)
471
621
  lines.push(
472
622
  ds.length === 0
473
623
  ? `✓ ${label}: none`
474
- : errors === 0
475
- ? `⚠ ${label}: ${advisory} (advisory)`
476
- : `✗ ${label}: ${errors}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`,
624
+ : errors + warnings === 0
625
+ ? `• ${label}: ${ds.length} (info)`
626
+ : errors === 0
627
+ ? `⚠ ${label}: ${warnings} (advisory)${infos > 0 ? ` (+${infos} info)` : ""}`
628
+ : `✗ ${label}: ${errors}${rest === "" ? "" : ` (+${rest})`}`,
477
629
  )
478
630
  }
479
631
  for (const d of ds) {
@@ -520,18 +672,27 @@ export function renderCheckReport(result: CheckResult): string[] {
520
672
  )
521
673
  }
522
674
  const errors = result.diagnostics.filter((d) => d.severity === "error").length
523
- const advisory = result.diagnostics.length - errors
675
+ const warnings = result.diagnostics.filter((d) => d.severity === "warning").length
676
+ const rest = belowError(warnings, result.diagnostics.length - errors - warnings)
524
677
  lines.push(
525
678
  "",
526
679
  result.ok
527
- ? advisory > 0
528
- ? `✓ check passed (${advisory} advisory)`
529
- : "✓ check passed"
530
- : `✗ check failed: ${errors} error${errors === 1 ? "" : "s"}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`,
680
+ ? rest === ""
681
+ ? "✓ check passed"
682
+ : `✓ check passed (${rest})`
683
+ : `✗ check failed: ${errors} error${errors === 1 ? "" : "s"}${rest === "" ? "" : ` (+${rest})`}`,
531
684
  )
532
685
  return lines
533
686
  }
534
687
 
688
+ /** The findings that do not fail the check, named by severity: an info finding is not advisory. */
689
+ function belowError(warnings: number, infos: number): string {
690
+ const parts: string[] = []
691
+ if (warnings > 0) parts.push(`${warnings} advisory`)
692
+ if (infos > 0) parts.push(`${infos} info`)
693
+ return parts.join(", ")
694
+ }
695
+
535
696
  /** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
536
697
  export async function runCheck(
537
698
  cwd: string,