@nifrajs/cli 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +7 -2
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +4 -2
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts +12 -2
  6. package/dist/assure.d.ts.map +1 -1
  7. package/dist/assure.js +59 -8
  8. package/dist/assure.js.map +1 -1
  9. package/dist/capabilities-tool.d.ts.map +1 -1
  10. package/dist/capabilities-tool.js +24 -7
  11. package/dist/capabilities-tool.js.map +1 -1
  12. package/dist/check-diagnostics.d.ts +2 -0
  13. package/dist/check-diagnostics.d.ts.map +1 -1
  14. package/dist/check-diagnostics.js.map +1 -1
  15. package/dist/check-scan.d.ts +8 -2
  16. package/dist/check-scan.d.ts.map +1 -1
  17. package/dist/check-scan.js +43 -10
  18. package/dist/check-scan.js.map +1 -1
  19. package/dist/check.d.ts.map +1 -1
  20. package/dist/check.js +39 -33
  21. package/dist/check.js.map +1 -1
  22. package/dist/cli.d.ts +2 -0
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +106 -48
  25. package/dist/cli.js.map +1 -1
  26. package/dist/client-module.d.ts +5 -3
  27. package/dist/client-module.d.ts.map +1 -1
  28. package/dist/client-module.js +34 -10
  29. package/dist/client-module.js.map +1 -1
  30. package/dist/command-catalog.d.ts +9 -1
  31. package/dist/command-catalog.d.ts.map +1 -1
  32. package/dist/command-catalog.js +67 -22
  33. package/dist/command-catalog.js.map +1 -1
  34. package/dist/dev-feed-tool.d.ts +18 -1
  35. package/dist/dev-feed-tool.d.ts.map +1 -1
  36. package/dist/dev-feed-tool.js +93 -0
  37. package/dist/dev-feed-tool.js.map +1 -1
  38. package/dist/fix-recipes.d.ts +2 -0
  39. package/dist/fix-recipes.d.ts.map +1 -1
  40. package/dist/fix-recipes.js +93 -12
  41. package/dist/fix-recipes.js.map +1 -1
  42. package/dist/init-agents.d.ts +16 -0
  43. package/dist/init-agents.d.ts.map +1 -1
  44. package/dist/init-agents.js +3 -2
  45. package/dist/init-agents.js.map +1 -1
  46. package/dist/internal/config-paths.d.ts +16 -0
  47. package/dist/internal/config-paths.d.ts.map +1 -0
  48. package/dist/internal/config-paths.js +124 -0
  49. package/dist/internal/config-paths.js.map +1 -0
  50. package/dist/internal/installed-package.d.ts +4 -0
  51. package/dist/internal/installed-package.d.ts.map +1 -0
  52. package/dist/internal/installed-package.js +17 -0
  53. package/dist/internal/installed-package.js.map +1 -0
  54. package/dist/internal/project-specifiers.d.ts +14 -1
  55. package/dist/internal/project-specifiers.d.ts.map +1 -1
  56. package/dist/internal/project-specifiers.js +54 -15
  57. package/dist/internal/project-specifiers.js.map +1 -1
  58. package/dist/internal/source-facts.d.ts +6 -1
  59. package/dist/internal/source-facts.d.ts.map +1 -1
  60. package/dist/internal/source-facts.js +11 -5
  61. package/dist/internal/source-facts.js.map +1 -1
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +5 -3
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +9 -0
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +32 -1
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/levels-tool.d.ts.map +1 -1
  70. package/dist/levels-tool.js +5 -8
  71. package/dist/levels-tool.js.map +1 -1
  72. package/dist/load.d.ts +2 -2
  73. package/dist/load.d.ts.map +1 -1
  74. package/dist/load.js +23 -3
  75. package/dist/load.js.map +1 -1
  76. package/dist/mcp-context.d.ts.map +1 -1
  77. package/dist/mcp-context.js +6 -21
  78. package/dist/mcp-context.js.map +1 -1
  79. package/dist/mcp-delegate.d.ts +2 -2
  80. package/dist/mcp-delegate.d.ts.map +1 -1
  81. package/dist/mcp-delegate.js +19 -7
  82. package/dist/mcp-delegate.js.map +1 -1
  83. package/dist/mcp-exec.d.ts +1 -0
  84. package/dist/mcp-exec.d.ts.map +1 -1
  85. package/dist/mcp-exec.js +8 -3
  86. package/dist/mcp-exec.js.map +1 -1
  87. package/dist/mcp-http.js +1 -1
  88. package/dist/mcp-io.d.ts +13 -0
  89. package/dist/mcp-io.d.ts.map +1 -1
  90. package/dist/mcp-io.js +23 -0
  91. package/dist/mcp-io.js.map +1 -1
  92. package/dist/mcp-path.d.ts +2 -1
  93. package/dist/mcp-path.d.ts.map +1 -1
  94. package/dist/mcp-path.js +9 -24
  95. package/dist/mcp-path.js.map +1 -1
  96. package/dist/mcp.d.ts.map +1 -1
  97. package/dist/mcp.js +40 -17
  98. package/dist/mcp.js.map +1 -1
  99. package/dist/migrate-layout.d.ts +40 -1
  100. package/dist/migrate-layout.d.ts.map +1 -1
  101. package/dist/migrate-layout.js +1316 -228
  102. package/dist/migrate-layout.js.map +1 -1
  103. package/dist/output-schemas.d.ts +34 -0
  104. package/dist/output-schemas.d.ts.map +1 -1
  105. package/dist/output-schemas.js +132 -21
  106. package/dist/output-schemas.js.map +1 -1
  107. package/dist/project-path.d.ts +6 -0
  108. package/dist/project-path.d.ts.map +1 -1
  109. package/dist/project-path.js +48 -14
  110. package/dist/project-path.js.map +1 -1
  111. package/dist/release-verification.d.ts +5 -0
  112. package/dist/release-verification.d.ts.map +1 -1
  113. package/dist/release-verification.js +14 -0
  114. package/dist/release-verification.js.map +1 -1
  115. package/dist/review.d.ts.map +1 -1
  116. package/dist/review.js +5 -0
  117. package/dist/review.js.map +1 -1
  118. package/dist/rules/codes.d.ts +3 -1
  119. package/dist/rules/codes.d.ts.map +1 -1
  120. package/dist/rules/codes.js +3 -1
  121. package/dist/rules/codes.js.map +1 -1
  122. package/dist/rules/data-guard.d.ts +6 -0
  123. package/dist/rules/data-guard.d.ts.map +1 -1
  124. package/dist/rules/data-guard.js +37 -0
  125. package/dist/rules/data-guard.js.map +1 -1
  126. package/dist/rules/legacy.d.ts.map +1 -1
  127. package/dist/rules/legacy.js +20 -1
  128. package/dist/rules/legacy.js.map +1 -1
  129. package/dist/rules/pages.d.ts +29 -3
  130. package/dist/rules/pages.d.ts.map +1 -1
  131. package/dist/rules/pages.js +236 -22
  132. package/dist/rules/pages.js.map +1 -1
  133. package/dist/rules/security.d.ts +3 -2
  134. package/dist/rules/security.d.ts.map +1 -1
  135. package/dist/rules/security.js +42 -11
  136. package/dist/rules/security.js.map +1 -1
  137. package/dist/rules/zones.d.ts.map +1 -1
  138. package/dist/rules/zones.js +31 -9
  139. package/dist/rules/zones.js.map +1 -1
  140. package/dist/stylex-migrate.d.ts.map +1 -1
  141. package/dist/stylex-migrate.js +127 -17
  142. package/dist/stylex-migrate.js.map +1 -1
  143. package/dist/test-tool.d.ts.map +1 -1
  144. package/dist/test-tool.js +3 -9
  145. package/dist/test-tool.js.map +1 -1
  146. package/dist/upgrade.d.ts +20 -5
  147. package/dist/upgrade.d.ts.map +1 -1
  148. package/dist/upgrade.js +86 -9
  149. package/dist/upgrade.js.map +1 -1
  150. package/dist/verification-plan.d.ts.map +1 -1
  151. package/dist/verification-plan.js +5 -2
  152. package/dist/verification-plan.js.map +1 -1
  153. package/dist/version-drift.d.ts.map +1 -1
  154. package/dist/version-drift.js +55 -3
  155. package/dist/version-drift.js.map +1 -1
  156. package/docs/examples.json +2 -2
  157. package/docs/llms-full.txt +481 -61
  158. package/docs/types.json +147 -70
  159. package/package.json +11 -11
  160. package/src/assure-hydration.ts +4 -1
  161. package/src/assure.ts +88 -10
  162. package/src/capabilities-tool.ts +33 -7
  163. package/src/check-diagnostics.ts +2 -0
  164. package/src/check-scan.ts +56 -9
  165. package/src/check.ts +55 -39
  166. package/src/cli.ts +104 -35
  167. package/src/client-module.ts +45 -9
  168. package/src/command-catalog.ts +77 -21
  169. package/src/dev-feed-tool.ts +112 -1
  170. package/src/fix-recipes.ts +101 -10
  171. package/src/init-agents.ts +4 -3
  172. package/src/internal/config-paths.ts +154 -0
  173. package/src/internal/installed-package.ts +15 -0
  174. package/src/internal/project-specifiers.ts +69 -16
  175. package/src/internal/source-facts.ts +23 -5
  176. package/src/internal/typescript-import.ts +5 -3
  177. package/src/introspect.ts +39 -1
  178. package/src/levels-tool.ts +7 -9
  179. package/src/load.ts +29 -3
  180. package/src/mcp-context.ts +5 -18
  181. package/src/mcp-delegate.ts +22 -10
  182. package/src/mcp-exec.ts +9 -2
  183. package/src/mcp-http.ts +1 -1
  184. package/src/mcp-io.ts +33 -0
  185. package/src/mcp-path.ts +8 -22
  186. package/src/mcp.ts +48 -19
  187. package/src/migrate-layout.ts +1496 -237
  188. package/src/output-schemas.ts +179 -23
  189. package/src/project-path.ts +44 -13
  190. package/src/release-verification.ts +20 -0
  191. package/src/review.ts +5 -0
  192. package/src/rules/codes.ts +3 -1
  193. package/src/rules/data-guard.ts +40 -0
  194. package/src/rules/legacy.ts +22 -1
  195. package/src/rules/pages.ts +266 -24
  196. package/src/rules/security.ts +49 -11
  197. package/src/rules/zones.ts +30 -7
  198. package/src/stylex-migrate.ts +117 -17
  199. package/src/test-tool.ts +3 -9
  200. package/src/upgrade.ts +96 -8
  201. package/src/verification-plan.ts +9 -2
  202. package/src/version-drift.ts +53 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -69,16 +69,16 @@
69
69
  "access": "public"
70
70
  },
71
71
  "dependencies": {
72
- "@nifrajs/client": "5.0.0",
73
- "@nifrajs/core": "5.0.0",
74
- "@nifrajs/agent-review": "5.0.0",
75
- "@nifrajs/i18n": "5.0.0",
76
- "@nifrajs/mcp": "5.0.0",
77
- "@nifrajs/mcp-db": "5.0.0",
78
- "@nifrajs/runner": "5.0.0",
79
- "@nifrajs/schema": "5.0.0",
80
- "@nifrajs/testing": "5.0.0",
81
- "@nifrajs/web": "5.0.0"
72
+ "@nifrajs/client": "5.1.0",
73
+ "@nifrajs/core": "5.1.0",
74
+ "@nifrajs/agent-review": "5.1.0",
75
+ "@nifrajs/i18n": "5.1.0",
76
+ "@nifrajs/mcp": "5.1.0",
77
+ "@nifrajs/mcp-db": "5.1.0",
78
+ "@nifrajs/runner": "5.1.0",
79
+ "@nifrajs/schema": "5.1.0",
80
+ "@nifrajs/testing": "5.1.0",
81
+ "@nifrajs/web": "5.1.0"
82
82
  },
83
83
  "repository": {
84
84
  "type": "git",
@@ -13,6 +13,7 @@ import { collectDoctorResult } from "./doctor.ts"
13
13
  import { loadApp, resolvePlugins } from "./load.ts"
14
14
  import {
15
15
  CHILD_OUTPUT_MAX_BYTES,
16
+ escalatingStop,
16
17
  forwardTerminalSignals,
17
18
  joinHeadTail,
18
19
  OWN_PROCESS_GROUP,
@@ -647,10 +648,11 @@ export async function runHydrationAssurance(
647
648
  detached: OWN_PROCESS_GROUP,
648
649
  },
649
650
  )
651
+ const stopper = escalatingStop((name) => signalProcessGroup(proc, name))
650
652
  let stopped: string | undefined
651
653
  const stop = (reason: string): void => {
652
654
  stopped ??= reason
653
- signalProcessGroup(proc, "SIGTERM")
655
+ stopper.stop()
654
656
  }
655
657
  const onAbort = (): void => stop("cancelled")
656
658
  control.signal?.addEventListener("abort", onAbort, { once: true })
@@ -684,6 +686,7 @@ export async function runHydrationAssurance(
684
686
  return failed(joinHeadTail(stderr).trim() || "no result")
685
687
  } finally {
686
688
  clearTimeout(timer)
689
+ stopper.dispose()
687
690
  control.signal?.removeEventListener("abort", onAbort)
688
691
  releaseSignals()
689
692
  }
package/src/assure.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  import { existsSync } from "node:fs"
4
4
  import { join, resolve } from "node:path"
5
5
  import { pathToFileURL } from "node:url"
6
- import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
6
+ import type { AssuranceConfig, AssuranceReport, AssuredRoute } from "@nifrajs/core/assurance"
7
7
  import { CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
8
8
  import type { Diagnostic } from "./diagnostics.ts"
9
+ import type { ProjectVerification } from "./verification.ts"
9
10
 
10
11
  export interface AssureSink {
11
12
  record(bundle: AssureBundle): Promise<void>
@@ -14,6 +15,7 @@ export interface AssureSink {
14
15
  export interface GateResult {
15
16
  readonly gate:
16
17
  | "check"
18
+ | "assurance"
17
19
  | "doctor"
18
20
  | "render"
19
21
  | "hydration"
@@ -91,6 +93,45 @@ export async function loadAssuranceConfig(
91
93
  return loaded.default
92
94
  }
93
95
 
96
+ /** Why declared response/error schemas go unenforced, phrased for the server that has to change. */
97
+ export interface ResponseContractGaps {
98
+ readonly typedOnly: readonly string[]
99
+ readonly warnOnly: readonly string[]
100
+ readonly typedOnlyReason: string | undefined
101
+ readonly warnOnlyReason: string | undefined
102
+ }
103
+
104
+ const sample = (routes: readonly string[]): string =>
105
+ routes.length <= 3
106
+ ? routes.join(", ")
107
+ : `${routes.slice(0, 3).join(", ")} and ${routes.length - 3} more`
108
+
109
+ export function responseContractGaps(routes: readonly AssuredRoute[]): ResponseContractGaps {
110
+ const key = (route: AssuredRoute): string => `${route.method} ${route.path}`
111
+ const typedOnly = routes.filter((route) => route.responseContract === "unchecked").map(key)
112
+ const warnOnly = routes.filter((route) => route.responseContract === "warn").map(key)
113
+ const covered = routes.filter(
114
+ (route) => route.responseContract === "enforce" || route.responseContract === "warn",
115
+ ).length
116
+ // A plugin that covers some routes is installed - just not where these were registered, which is
117
+ // the case "install responseContract" advice used to send people in circles over.
118
+ const typedOnlyReason =
119
+ typedOnly.length === 0
120
+ ? undefined
121
+ : covered > 0
122
+ ? `${typedOnly.length} declared route(s) are typed-only (${sample(typedOnly)}) although responseContract() covers ${covered} other(s): it covers routes registered after it on the same server, and routes merge()d into that server after it. Install responseContract("enforce") on the server that registers these, before them.`
123
+ : `${typedOnly.length} declared route(s) are typed-only (${sample(typedOnly)}); install responseContract("enforce") on the server that registers them, before them - a parent's also covers servers merge()d into it afterwards.`
124
+ const warnOnlyReason =
125
+ warnOnly.length === 0
126
+ ? undefined
127
+ : `${warnOnly.length} declared route(s) are warn-only and still send the original payload (${sample(warnOnly)}); switch responseContract("warn") to "enforce".`
128
+ return { typedOnly, warnOnly, typedOnlyReason, warnOnlyReason }
129
+ }
130
+
131
+ /** The policy line that turns an unenforced contract into a route-assurance failure. */
132
+ export const RESPONSE_CONTRACT_BASELINE_HINT =
133
+ 'to fail route assurance on these, add baseline: [{ name: "response-contract", match: { hasResponse: true }, require: ["nifra.response-contract"] }] to the policy'
134
+
94
135
  export function formatAssuranceReport(report: AssuranceReport): string {
95
136
  if (report.ok) {
96
137
  const contracted = report.routes.filter((route) => route.responseContract !== undefined)
@@ -247,20 +288,57 @@ export async function collectAssureBundle(
247
288
  }
248
289
  }
249
290
 
291
+ // One verification serves both gates that read the app, so the config is evaluated once.
292
+ let verification: Promise<ProjectVerification> | undefined
293
+ const verify = (): Promise<ProjectVerification> =>
294
+ (verification ??= import("./verification.ts").then(({ collectProjectVerification }) =>
295
+ collectProjectVerification(cwd, {
296
+ ...(options.config !== undefined ? { config: options.config } : {}),
297
+ preloaded:
298
+ assuranceConfig !== undefined
299
+ ? { config: assuranceConfig }
300
+ : { error: assuranceConfigError },
301
+ }),
302
+ ))
303
+
250
304
  await addGate("check", async () => {
251
- const { collectProjectVerification } = await import("./verification.ts")
252
- const verification = await collectProjectVerification(cwd, {
253
- ...(options.config !== undefined ? { config: options.config } : {}),
254
- preloaded:
255
- assuranceConfig !== undefined
256
- ? { config: assuranceConfig }
257
- : { error: assuranceConfigError },
258
- })
259
- const result = await verification.check()
305
+ const result = await (await verify()).check()
260
306
  const diagnostics = (result.structuredDiagnostics ?? []).map((item) => item)
261
307
  return { diagnostics }
262
308
  })
263
309
 
310
+ // Route assurance is the gate the bundle exists to carry: a failing policy fails it, and a declared
311
+ // response the runtime does not enforce is a warning, so `--strict` refuses to call it green.
312
+ await addGate("assurance", async () => {
313
+ const report = assuranceConfig === undefined ? undefined : (await verify()).routeAssurance
314
+ if (report === undefined)
315
+ return {
316
+ diagnostics: [],
317
+ skipReason: existsSync(resolve(cwd, options.config ?? DEFAULT_ASSURANCE_CONFIG))
318
+ ? "route assurance could not be evaluated (reported by the check gate)"
319
+ : "no route assurance config",
320
+ }
321
+ const diagnostics = report.findings.map((finding) =>
322
+ diagnosticFromMessage("NF-A001", finding.message, "error"),
323
+ )
324
+ const gaps = responseContractGaps(report.routes)
325
+ for (const [reason, routes] of [
326
+ [gaps.typedOnlyReason, gaps.typedOnly],
327
+ [gaps.warnOnlyReason, gaps.warnOnly],
328
+ ] as const) {
329
+ if (reason === undefined) continue
330
+ diagnostics.push(
331
+ Object.freeze({
332
+ code: "NF-A001",
333
+ severity: "warn" as const,
334
+ message: `${reason} (${RESPONSE_CONTRACT_BASELINE_HINT})`,
335
+ evidence: Object.freeze(routes.slice(0, 20)),
336
+ }),
337
+ )
338
+ }
339
+ return { diagnostics }
340
+ })
341
+
264
342
  await addGate("doctor", async () => {
265
343
  const { collectDoctorResult } = await import("./doctor.ts")
266
344
  const result = await collectDoctorResult(cwd)
@@ -18,7 +18,13 @@ import {
18
18
  } from "@nifrajs/core/capabilities"
19
19
  import { expandOptionalParams } from "@nifrajs/core/pattern"
20
20
  import { type ReflectedMount, type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
21
- import { scanStaticRouteText, scanStaticSocketText, stripComments, walkSource } from "./check.ts"
21
+ import {
22
+ scanSkippedRouteText,
23
+ scanStaticRouteText,
24
+ scanStaticSocketText,
25
+ stripComments,
26
+ walkSource,
27
+ } from "./check.ts"
22
28
 
23
29
  // Import and re-export clauses are spelled out (a default binding, then `* as ns` or a `{ … }` list)
24
30
  // rather than scanned lazily, which is quadratic on a long semicolon-free module.
@@ -340,6 +346,7 @@ export async function collectCapabilityProjectReport(
340
346
  const policy = defineCapabilityPolicy(policyInput)
341
347
  const sources = await readSources(cwd)
342
348
  const automatic = new Map<string, Set<string>>()
349
+ const skipped = new Map<string, string>()
343
350
  for (const [file, content] of sources) {
344
351
  for (const route of [
345
352
  ...scanStaticRouteText(file, content, undefined, sources),
@@ -352,6 +359,12 @@ export async function collectCapabilityProjectReport(
352
359
  automatic.set(routeKey(route.method, path), modules)
353
360
  }
354
361
  }
362
+ for (const route of scanSkippedRouteText(file, content, sources)) {
363
+ for (const path of staticRouteForms(route.path)) {
364
+ if (!skipped.has(routeKey(route.method, path)))
365
+ skipped.set(routeKey(route.method, path), `${file}:${route.line}`)
366
+ }
367
+ }
355
368
  }
356
369
 
357
370
  const evidenceRoutes = []
@@ -380,12 +393,25 @@ export async function collectCapabilityProjectReport(
380
393
  policy,
381
394
  sources,
382
395
  )
383
- evidenceRoutes.push({
384
- method: route.method,
385
- path: route.path,
386
- covered: modules.size > 0 && walked.covered,
387
- evidence: walked.evidence,
388
- })
396
+ const site = modules.size === 0 ? skipped.get(routeKey(route.method, route.path)) : undefined
397
+ const uncoveredReason =
398
+ modules.size > 0
399
+ ? undefined
400
+ : site === undefined
401
+ ? "no source file registers it with a path the static scan can read; map it with capabilities.provenance.routeModules"
402
+ : `${site} appears to register it on a server the static scan cannot identify (built by a helper that is neither \`server()\` nor imported from a module that calls it); call server() there, or map the route with capabilities.provenance.routeModules`
403
+ const covered = modules.size > 0 && walked.covered
404
+ evidenceRoutes.push(
405
+ uncoveredReason === undefined
406
+ ? { method: route.method, path: route.path, covered, evidence: walked.evidence }
407
+ : {
408
+ method: route.method,
409
+ path: route.path,
410
+ covered,
411
+ evidence: walked.evidence,
412
+ uncoveredReason,
413
+ },
414
+ )
389
415
  violations.push(...walked.violations)
390
416
  truncations.push(...walked.truncations)
391
417
  }
@@ -368,6 +368,8 @@ export interface CheckTypecheckResult {
368
368
  readonly note?: string
369
369
  readonly output?: string
370
370
  readonly missingTypeScript?: boolean
371
+ /** TypeScript sources found with no tsconfig.json to typecheck them. */
372
+ readonly sourcesWithoutTsconfig?: number
371
373
  readonly outsideProgram?: readonly string[]
372
374
  }
373
375
 
package/src/check-scan.ts CHANGED
@@ -352,15 +352,59 @@ export function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefin
352
352
  return { path, method, start: match.index, end: close + 1 }
353
353
  }
354
354
 
355
- function isPotentialBackendSource(code: string): boolean {
355
+ const SERVER_CONSTRUCTION = /(?<![.\w])server\s*\(|new\s+Hono(?:<[^>]+>)?\s*\(/
356
+
357
+ function isPotentialBackendSource(
358
+ code: string,
359
+ file?: string,
360
+ project?: ReadonlyMap<string, string>,
361
+ ): boolean {
356
362
  return (
357
363
  code.includes("@nifrajs/core") ||
358
- /(?<![.\w])server\s*\(/.test(code) ||
364
+ SERVER_CONSTRUCTION.test(code) ||
359
365
  /(?:from\s*|import\s*)["']hono["']/.test(code) ||
360
- /new\s+Hono(?:<[^>]+>)?\s*\(/.test(code)
366
+ (file !== undefined && project !== undefined && callsLocalServerFactory(code, file, project))
361
367
  )
362
368
  }
363
369
 
370
+ /**
371
+ * A module that builds its server through a local helper - `newServer().get(...)` with `newServer`
372
+ * imported from a project file that calls `server()` - names neither `@nifrajs/core` nor `server(`
373
+ * itself. One import hop is followed: the imported module must construct a server, so a helper module
374
+ * that merely mentions `@nifrajs/core` (a schema re-export) does not turn every importer into a
375
+ * backend and its `client.get("/x")` calls into routes.
376
+ */
377
+ function callsLocalServerFactory(
378
+ code: string,
379
+ file: string,
380
+ project: ReadonlyMap<string, string>,
381
+ ): boolean {
382
+ for (const [local, binding] of namedImports(code)) {
383
+ if (!new RegExp(`(?<![.\\w$])${local.replaceAll("$", "\\$")}\\s*\\(`).test(code)) continue
384
+ const target = projectModule(project, file, binding.specifier)
385
+ if (target !== undefined && SERVER_CONSTRUCTION.test(stripComments(project.get(target) ?? "")))
386
+ return true
387
+ }
388
+ return false
389
+ }
390
+
391
+ /**
392
+ * Literal route registrations in a file the backend-source gate turned away, so a route the scan
393
+ * could not cover can say where it probably lives. Nothing here is treated as a route.
394
+ */
395
+ export function scanSkippedRouteText(
396
+ file: string,
397
+ content: string,
398
+ project?: ReadonlyMap<string, string>,
399
+ ): StaticRouteFinding[] {
400
+ const code = stripComments(content)
401
+ if (isPotentialBackendSource(code, file, project)) return []
402
+ return [
403
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
404
+ ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
405
+ ]
406
+ }
407
+
364
408
  function scanRoutePattern(
365
409
  file: string,
366
410
  content: string,
@@ -727,7 +771,7 @@ export function scanStaticRouteText(
727
771
  project?: ReadonlyMap<string, string>,
728
772
  ): StaticRouteFinding[] {
729
773
  const code = stripComments(content)
730
- if (!isPotentialBackendSource(code)) return []
774
+ if (!isPotentialBackendSource(code, file, project)) return []
731
775
  return [
732
776
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
733
777
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
@@ -849,7 +893,7 @@ function scanRouteArguments(
849
893
  const routes: StaticRouteFinding[] = []
850
894
  const unresolved: UnresolvedRoutePathFinding[] = []
851
895
  const code = stripComments(content)
852
- if (!isPotentialBackendSource(code)) return { routes, unresolved }
896
+ if (!isPotentialBackendSource(code, file, project)) return { routes, unresolved }
853
897
  const lines = content.split("\n")
854
898
  const constants = moduleConstants(code, content)
855
899
  const imports = namedImports(code)
@@ -922,7 +966,7 @@ export function scanStaticSocketText(
922
966
  project?: ReadonlyMap<string, string>,
923
967
  ): StaticRouteFinding[] {
924
968
  const code = stripComments(content)
925
- if (!isPotentialBackendSource(code)) return []
969
+ if (!isPotentialBackendSource(code, file, project)) return []
926
970
  return [
927
971
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
928
972
  ...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
@@ -2939,7 +2983,8 @@ function staticImportEdges(
2939
2983
  for (let m = re.exec(code); m !== null; m = re.exec(code)) {
2940
2984
  if (positions[m.index] === " ") continue
2941
2985
  if (m[1] === undefined) continue
2942
- if (facts !== undefined && file !== undefined) {
2986
+ // Only a `type` modifier can erase a clause the pattern matched, so other clauses skip the parse.
2987
+ if (facts !== undefined && file !== undefined && /\btype\b/.test(m[0])) {
2943
2988
  const source = facts.parse(file, content)
2944
2989
  if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
2945
2990
  }
@@ -3020,12 +3065,14 @@ function importEdges(
3020
3065
  return edges.sort((a, b) => a.index - b.index)
3021
3066
  }
3022
3067
 
3023
- /** The runtime imports of a module with the line each sits on, in source order. Pure. */
3068
+ /** The runtime imports of a module with the line each sits on, in source order. With `facts`, an
3069
+ * import the compiler erases (`import { type X }`) is none. Pure. */
3024
3070
  export function importSites(
3025
3071
  content: string,
3026
3072
  file?: string,
3073
+ facts?: SourceFacts,
3027
3074
  ): Array<{ readonly specifier: string; readonly line: number }> {
3028
- return importEdges(content, undefined, file).map(({ specifier, index }) => ({
3075
+ return importEdges(content, facts, file).map(({ specifier, index }) => ({
3029
3076
  specifier,
3030
3077
  line: lineAt(content, index),
3031
3078
  }))
package/src/check.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmdirSync, rmSync } from "node:fs"
20
20
  import { tmpdir } from "node:os"
21
21
  import { dirname, isAbsolute, join, relative, resolve } from "node:path"
22
- import { specifierPackage } from "@nifrajs/web/zones"
22
+ import { declaredSide, specifierPackage } from "@nifrajs/web/zones"
23
23
  import { BACKEND_APP_FILE } from "./app-files.ts"
24
24
  import {
25
25
  type CheckAssuranceContext,
@@ -33,6 +33,7 @@ import {
33
33
  import { appDirs, clientModuleRoot } from "./client-module.ts"
34
34
  import { checkContractsLock, DEFAULT_CONTRACTS_LOCK } from "./contracts.ts"
35
35
  import { type Diagnostic, normalizeSeverity, toSarifLog } from "./diagnostics.ts"
36
+ import { installedManifest } from "./internal/installed-package.ts"
36
37
  import { createApiOnlyProbe, createNifraProjectProbe } from "./internal/nifra-project.ts"
37
38
  import { createSpecifierResolver } from "./internal/project-specifiers.ts"
38
39
  import { createSourceFacts } from "./internal/source-facts.ts"
@@ -95,6 +96,10 @@ interface TypecheckResult {
95
96
  /** tsconfig.json exists but no `typescript` install was found walking up from cwd. The contract
96
97
  * gate could not run - the caller surfaces this as a FAILING diagnostic, never a silent skip. */
97
98
  readonly missingTypeScript?: boolean
99
+ /** No tsconfig.json at the project root. Fails the check only when TypeScript sources exist. */
100
+ readonly missingTsconfig?: boolean
101
+ /** TypeScript sources the scan found with no tsconfig.json to typecheck them. */
102
+ readonly sourcesWithoutTsconfig?: number
98
103
  /** Every file in the compiler's program, absolute. Absent when the compiler listed none. */
99
104
  readonly programFiles?: readonly string[]
100
105
  /** Zoned TypeScript files outside the program, project-relative: the typecheck never read them. */
@@ -123,7 +128,7 @@ function resolveTscBin(cwd: string): string | undefined {
123
128
  async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckResult> {
124
129
  const tsconfig = join(cwd, "tsconfig.json")
125
130
  if (!(await Bun.file(tsconfig).exists()))
126
- return { ran: false, ok: true, note: "no tsconfig.json" }
131
+ return { ran: false, ok: true, missingTsconfig: true, note: "no tsconfig.json" }
127
132
  // Routes import their generated `./+types` modules; bring them up to date before tsc reads them.
128
133
  const { refreshRouteTypes } = await import("./route-types.ts")
129
134
  refreshRouteTypes(cwd, () => {})
@@ -309,6 +314,16 @@ async function collectContractFacts(cwd: string): Promise<ContractCheckFacts> {
309
314
  }
310
315
  }
311
316
 
317
+ /** A project whose TypeScript no tsconfig.json covers has no typecheck to pass: the gate fails. */
318
+ function withoutTsconfig(
319
+ result: TypecheckResult,
320
+ sourceFiles: ReadonlyArray<{ readonly file: string }>,
321
+ ): TypecheckResult {
322
+ if (result.missingTsconfig !== true) return result
323
+ const sources = sourceFiles.filter(({ file }) => !/\.d\.[cm]?ts$/.test(file)).length
324
+ return sources === 0 ? result : { ...result, ok: false, sourcesWithoutTsconfig: sources }
325
+ }
326
+
312
327
  /** Record the zoned files the compiler's program left out, minus the typecheck rule's `ignore` globs. */
313
328
  function withProgramCoverage(
314
329
  cwd: string,
@@ -376,11 +391,10 @@ async function buildProjectScan(
376
391
 
377
392
  // tsc compiles TypeScript sources only; the JavaScript and component files the scan reads are no
378
393
  // program's to cover.
379
- const typecheckResult = withProgramCoverage(
380
- cwd,
381
- typecheckRun,
382
- sourceFiles.filter(({ file }) => /\.[cm]?tsx?$/.test(file)),
383
- checkConfig,
394
+ const typeScriptSources = sourceFiles.filter(({ file }) => /\.[cm]?tsx?$/.test(file))
395
+ const typecheckResult = withoutTsconfig(
396
+ withProgramCoverage(cwd, typecheckRun, typeScriptSources, checkConfig),
397
+ typeScriptSources,
384
398
  )
385
399
 
386
400
  // TypeScript 7's unstable API materializes source files through one project session. Build the
@@ -390,7 +404,24 @@ async function buildProjectScan(
390
404
  const loadedTypeScript = await loadProjectTypeScript(cwd, opts.loadTypeScript, source)
391
405
  try {
392
406
  const sqlCompiler = loadedTypeScript.compiler
393
- const sourceFacts = sqlCompiler === undefined ? undefined : createSourceFacts(sqlCompiler)
407
+ // Resolved modules stay project-relative, as the scanned files are: the zone of a path is read
408
+ // from its folders, and a checkout under a folder named `backend/` must not make it all backend.
409
+ // The resolver answers with real paths, so the project root is read as one too.
410
+ const realCwd = (() => {
411
+ try {
412
+ return realpathSync(cwd)
413
+ } catch {
414
+ return cwd
415
+ }
416
+ })()
417
+ const resolveSpecifier = createSpecifierResolver()
418
+ const sourceFacts =
419
+ sqlCompiler === undefined
420
+ ? undefined
421
+ : createSourceFacts(sqlCompiler, {
422
+ keepsTypeOnlyImports: (file) =>
423
+ resolveSpecifier.keepsTypeOnlyImports(isAbsolute(file) ? file : join(realCwd, file)),
424
+ })
394
425
  const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd)
395
426
  const project = new Map(
396
427
  sourceFiles.map(({ file, content }) => [file.replaceAll("\\", "/"), content]),
@@ -403,17 +434,6 @@ async function buildProjectScan(
403
434
  interpolatedSql.push(...scanSqlSites(file, content, sqlCompiler, sqlImports))
404
435
  }
405
436
 
406
- // Resolved modules stay project-relative, as the scanned files are: the zone of a path is read
407
- // from its folders, and a checkout under a folder named `backend/` must not make it all backend.
408
- // The resolver answers with real paths, so the project root is read as one too.
409
- const realCwd = (() => {
410
- try {
411
- return realpathSync(cwd)
412
- } catch {
413
- return cwd
414
- }
415
- })()
416
- const resolveSpecifier = createSpecifierResolver()
417
437
  const projectPath = (path: string): string => {
418
438
  const rel = relative(realCwd, path).replaceAll("\\", "/")
419
439
  return rel.startsWith("../") || isAbsolute(rel) ? path : rel
@@ -439,7 +459,7 @@ async function buildProjectScan(
439
459
  // An app's `clientModule` is browser code wherever it lives: the generated client entry imports it.
440
460
  const browserRoots = new Set<string>()
441
461
  for (const app of appDirs(source.files)) {
442
- const entry = clientModuleRoot(cwd, app, resolveSpecifier)
462
+ const entry = clientModuleRoot(cwd, app, resolveSpecifier, sqlCompiler)
443
463
  if (entry === undefined) continue
444
464
  const rel = projectPath(entry.file)
445
465
  if (isBrowserSource(rel) || browserRoots.has(rel)) continue
@@ -448,28 +468,24 @@ async function buildProjectScan(
448
468
  browserRoots.add(rel)
449
469
  browserModules.push({ rel, content })
450
470
  }
451
- // Node's lookup: the nearest `node_modules/<name>` at or above the importing file.
471
+ // The side the package gives the entry the import names: package-wide, or per entry.
452
472
  const backendPackage = (fromFile: string, specifier: string): boolean => {
453
473
  const name = specifierPackage(specifier)
454
474
  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
475
+ const manifest = installedManifest(
476
+ isAbsolute(fromFile) ? fromFile : join(realCwd, fromFile),
477
+ name,
478
+ )
479
+ if (manifest === undefined) return false
480
+ try {
481
+ const nifra: unknown = JSON.parse(readFileSync(manifest, "utf8"))?.nifra
482
+ return (
483
+ typeof nifra === "object" &&
484
+ nifra !== null &&
485
+ declaredSide(Reflect.get(nifra, "environment"), specifier) === "backend"
486
+ )
487
+ } catch {
488
+ return false
473
489
  }
474
490
  }
475
491
  const serverOnlyChains = createServerOnlyChains({