@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
@@ -14,7 +14,7 @@
14
14
  import { existsSync, readFileSync } from "node:fs"
15
15
  import { join } from "node:path"
16
16
  import { discoverRoutes } from "@nifrajs/web/fs"
17
- import { shadowedPages } from "@nifrajs/web/route-manifest"
17
+ import { BACKEND_ROUTE_EXPORTS, shadowedPages } from "@nifrajs/web/route-manifest"
18
18
  import type * as TSApi from "typescript"
19
19
  import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
20
20
  import { stripComments } from "../check-scan.ts"
@@ -159,10 +159,24 @@ export const statusLayoutRule: CheckRule = {
159
159
  },
160
160
  }
161
161
 
162
+ /** The two framework configs' sources, `undefined` for one the app lacks. */
163
+ export interface ApiPrefixSources {
164
+ readonly framework: string | undefined
165
+ readonly config: string | undefined
166
+ }
167
+
168
+ /** `apiPrefix` as the app runs it: nifra.config.ts's own, else backend/framework.ts's, which every
169
+ * command takes for an option nifra.config.ts leaves unset. */
170
+ function effectiveApiPrefix({ framework, config }: ApiPrefixSources): StaticApiPrefix {
171
+ const own: StaticApiPrefix =
172
+ config === undefined ? { kind: "default" } : readStaticApiPrefix(config)
173
+ return own.kind !== "default" || framework === undefined ? own : readStaticApiPrefix(framework)
174
+ }
175
+
162
176
  /**
163
- * NF-C035: `backend/app.ts` exports a backend and the framework config sets no `apiPrefix`, so the
164
- * backend is not served over HTTP - a browser call to `/api` reaches page routing. Loader-only is a
165
- * valid choice; the finding asks for it to be stated. An unreadable `apiPrefix` is NF-C027's to report.
177
+ * NF-C035: `backend/app.ts` exports a backend that is not served over HTTP, so a browser call to `/api`
178
+ * reaches page routing. The framework config sets no `apiPrefix`; loader-only is a valid choice, and
179
+ * the finding asks for it to be stated. An unreadable `apiPrefix` is NF-C027's.
166
180
  */
167
181
  export const implicitApiMountRule: CheckRule = {
168
182
  code: "NF-C035",
@@ -172,18 +186,16 @@ export const implicitApiMountRule: CheckRule = {
172
186
  // An API-only app serves its backend itself; there is no page routing to fall into.
173
187
  if (!existsSync(backendFile) || !hasWebConfig(ctx.root, ".")) return []
174
188
  if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
175
- const configFile = CONFIG_FILES.find((file) => existsSync(join(ctx.root, file)))
176
- if (
177
- configFile !== undefined &&
178
- readStaticApiPrefix(readFileSync(join(ctx.root, configFile), "utf8")).kind !== "default"
179
- ) {
180
- return []
181
- }
182
- const file = configFile ?? FRAMEWORK_FILE
189
+ const read = (file: string): string | undefined =>
190
+ existsSync(join(ctx.root, file)) ? readFileSync(join(ctx.root, file), "utf8") : undefined
191
+ const sources = { framework: read(FRAMEWORK_FILE), config: read(CONFIG_FILE) }
192
+ if (sources.framework === undefined && sources.config === undefined) return []
193
+ if (effectiveApiPrefix(sources).kind !== "default") return []
194
+ const file = sources.framework === undefined ? CONFIG_FILE : FRAMEWORK_FILE
183
195
  return [
184
196
  diagnostic({
185
197
  code: "NF-C035",
186
- severity: "info",
198
+ severity: "warn",
187
199
  file,
188
200
  message: `${BACKEND_APP_FILE}'s backend is not served over HTTP, because ${file} sets no apiPrefix: a browser call to /api reaches page routing instead. Add \`export const apiPrefix = "/api"\` to ${file} to serve it there, as 4.x did by default, or \`export const apiPrefix = ""\` to keep it loader-only.`,
189
201
  fix: { recipe: "framework.api-prefix", command: "nifra fix --code NF-C035" },
@@ -193,32 +205,54 @@ export const implicitApiMountRule: CheckRule = {
193
205
  },
194
206
  }
195
207
 
208
+ const appendLine = (source: string, line: string): string =>
209
+ source === "" ? line : `${source}${source.endsWith("\n") ? "" : "\n"}${line}`
210
+
196
211
  /** Append `export const apiPrefix = "/api"` to a framework config that sets none, creating it if needed. */
197
212
  export function withDefaultApiPrefix(source: string | undefined): string | undefined {
198
213
  if (source !== undefined && readStaticApiPrefix(source).kind !== "default") return undefined
199
- const line = 'export const apiPrefix = "/api"\n'
200
- if (source === undefined || source === "") return line
201
- return `${source}${source.endsWith("\n") ? "" : "\n"}${line}`
214
+ return appendLine(source ?? "", 'export const apiPrefix = "/api"\n')
215
+ }
216
+
217
+ /**
218
+ * The edit that serves the backend at `/api`: the mount in backend/framework.ts, where the server
219
+ * entry reads it, or in nifra.config.ts when it is the only config. A file that needs no edit comes
220
+ * back `undefined`; an `apiPrefix` either file sets is never replaced.
221
+ */
222
+ export function apiPrefixFix(sources: ApiPrefixSources): ApiPrefixSources {
223
+ if (effectiveApiPrefix(sources).kind !== "default")
224
+ return { framework: undefined, config: undefined }
225
+ return sources.framework === undefined
226
+ ? { framework: undefined, config: withDefaultApiPrefix(sources.config) }
227
+ : { framework: withDefaultApiPrefix(sources.framework), config: undefined }
202
228
  }
203
229
 
204
230
  const LOADER_EXPORT =
205
231
  /\bexport\s+(?:async\s+)?(?:const|let|var|function\*?)\s+loader\b|\bexport\s*\{[^}]*\bloader\b[^}]*\}/
206
232
  const SCRIPT_LAYOUT = /\.[cm]?[jt]sx?$/
207
233
 
208
- const hasModifier = (ts: typeof TSApi, node: TSApi.Node, kind: TSApi.SyntaxKind): boolean =>
209
- ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === kind)
234
+ // Read from the node, not `ts.getModifiers`: TypeScript 7's API has no getModifiers or canHaveModifiers.
235
+ const hasModifier = (
236
+ node: TSApi.VariableStatement | TSApi.FunctionDeclaration | TSApi.ClassDeclaration,
237
+ kind: TSApi.SyntaxKind,
238
+ ): boolean => node.modifiers?.some((m) => m.kind === kind) === true
210
239
 
211
- /** The function an exported `meta` names: a declaration, or a const initialized with one. */
212
- function exportedMetaFunction(
240
+ /** The function an exported `name` names: a declaration, or a const initialized with one. */
241
+ function exportedFunction(
213
242
  ts: typeof TSApi,
214
243
  tree: TSApi.SourceFile,
244
+ name: string,
215
245
  ): TSApi.FunctionDeclaration | TSApi.ArrowFunction | TSApi.FunctionExpression | undefined {
216
246
  for (const statement of tree.statements) {
217
- if (!hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword)) continue
218
- if (ts.isFunctionDeclaration(statement) && statement.name?.text === "meta") return statement
247
+ if (ts.isFunctionDeclaration(statement)) {
248
+ if (statement.name?.text === name && hasModifier(statement, ts.SyntaxKind.ExportKeyword))
249
+ return statement
250
+ continue
251
+ }
219
252
  if (!ts.isVariableStatement(statement)) continue
253
+ if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue
220
254
  for (const declaration of statement.declarationList.declarations) {
221
- if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "meta") continue
255
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
222
256
  let init = declaration.initializer
223
257
  while (
224
258
  init !== undefined &&
@@ -237,7 +271,7 @@ function exportedMetaFunction(
237
271
 
238
272
  /** Where an exported function `meta` reads its args' `data`: a destructured `data`, or `args.data`. */
239
273
  export function metaDataReads(ts: typeof TSApi, tree: TSApi.SourceFile): number[] {
240
- const meta = exportedMetaFunction(ts, tree)
274
+ const meta = exportedFunction(ts, tree, "meta")
241
275
  const param = meta?.parameters[0]
242
276
  if (param === undefined) return []
243
277
  const lineOf = (node: TSApi.Node) =>
@@ -326,9 +360,217 @@ export const layoutMetaDataRule: CheckRule = {
326
360
  },
327
361
  }
328
362
 
363
+ const GATE_EXPORT = /\bexport\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*true\b/
364
+ const MIDDLEWARE_EXPORT =
365
+ /\bexport\s+(?:async\s+)?(?:const|let|var|function\*?)\s+middleware\b|\bexport\s*\{[^}]*\bmiddleware\b[^}]*\}/
366
+
367
+ /** Lines where an exported `loader` refuses access: `redirect()`, `statusPage(401|403)`, `new Response()`. */
368
+ export function loaderRefusals(ts: typeof TSApi, tree: TSApi.SourceFile): number[] {
369
+ const loader = exportedFunction(ts, tree, "loader")
370
+ if (loader?.body === undefined) return []
371
+ const lines: number[] = []
372
+ const lineOf = (node: TSApi.Node): number =>
373
+ tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
374
+ const visit = (node: TSApi.Node): void => {
375
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
376
+ const status = node.arguments[0]
377
+ if (
378
+ node.expression.text === "redirect" ||
379
+ (node.expression.text === "statusPage" &&
380
+ status !== undefined &&
381
+ ts.isNumericLiteral(status) &&
382
+ (status.text === "401" || status.text === "403"))
383
+ )
384
+ lines.push(lineOf(node))
385
+ } else if (
386
+ ts.isNewExpression(node) &&
387
+ ts.isIdentifier(node.expression) &&
388
+ node.expression.text === "Response"
389
+ ) {
390
+ lines.push(lineOf(node))
391
+ }
392
+ ts.forEachChild(node, visit)
393
+ }
394
+ visit(loader.body)
395
+ return lines
396
+ }
397
+
398
+ /**
399
+ * NF-C042: a layout loader that refuses access - a redirect, a 401 or 403, a `Response` - in a layout
400
+ * that is not a gate and exports no middleware. A client navigation that keeps the layout skips that
401
+ * loader, and an action below it runs without it, so the check reads as a guard and is not one. A
402
+ * refusal inside a helper the loader calls is not seen; the development warning covers that.
403
+ */
404
+ export const ungatedLayoutRefusalRule: CheckRule = {
405
+ code: "NF-C042",
406
+ title: "Layout loader refuses access without a gate",
407
+ async scan(ctx) {
408
+ const routesDir = join(ctx.root, "routes")
409
+ if (!existsSync(routesDir)) return []
410
+ let manifest: ReturnType<typeof discoverRoutes>
411
+ try {
412
+ manifest = discoverRoutes(routesDir)
413
+ } catch {
414
+ return []
415
+ }
416
+ const layouts = Object.values(manifest.layouts)
417
+ .filter((layout) => SCRIPT_LAYOUT.test(layout.file))
418
+ .sort((a, b) => codeUnitOrder(a.file, b.file))
419
+ if (layouts.length === 0) return []
420
+ const sources = layouts.map((layout) =>
421
+ [layout.file, ...(layout.backend === undefined ? [] : [layout.backend])].map((file) => ({
422
+ file,
423
+ text: readFileSync(join(routesDir, file), "utf8"),
424
+ })),
425
+ )
426
+ if (!sources.flat().some((half) => LOADER_EXPORT.test(stripComments(half.text)))) return []
427
+ const loaded = await loadRuleTypeScript(ctx)
428
+ const ts = loaded.compiler
429
+ if (ts === undefined) return []
430
+ const findings: Diagnostic[] = []
431
+ for (const halves of sources) {
432
+ const code = halves.map((half) => stripComments(half.text))
433
+ if (code.some((text) => GATE_EXPORT.test(text) || MIDDLEWARE_EXPORT.test(text))) continue
434
+ for (const half of halves) {
435
+ if (!LOADER_EXPORT.test(stripComments(half.text))) continue
436
+ const tree = ts.createSourceFile(
437
+ half.file,
438
+ half.text,
439
+ ts.ScriptTarget.Latest,
440
+ true,
441
+ scriptKindOf(ts, half.file),
442
+ )
443
+ const [line] = loaderRefusals(ts, tree)
444
+ if (line === undefined) continue
445
+ findings.push(
446
+ diagnostic({
447
+ code: "NF-C042",
448
+ severity: "warn",
449
+ file: `routes/${half.file}`,
450
+ line,
451
+ message: `routes/${half.file}'s loader refuses access, but the layout is not a gate. A client navigation that keeps the layout skips this loader, and an action below it runs without it, so it does not protect the pages under it. Export \`gate = true\` from this file, or move the check into its \`middleware\`.`,
452
+ evidence: [
453
+ "gate = true: the loader runs on every request and before every action, ahead of the page loader",
454
+ "middleware: runs before every page and action below the directory",
455
+ ],
456
+ verify: "nifra check",
457
+ }),
458
+ )
459
+ }
460
+ }
461
+ return findings
462
+ },
463
+ }
464
+
465
+ /** The names a module exports at runtime, each with its line: declarations and `export { ... }` lists.
466
+ * `export *` names nothing it can read, and type-only exports carry no value. */
467
+ function runtimeExports(
468
+ ts: typeof TSApi,
469
+ tree: TSApi.SourceFile,
470
+ ): Array<{ readonly name: string; readonly line: number }> {
471
+ const found: Array<{ name: string; line: number }> = []
472
+ const add = (name: string, node: TSApi.Node): void => {
473
+ found.push({ name, line: tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1 })
474
+ }
475
+ for (const statement of tree.statements) {
476
+ if (ts.isExportDeclaration(statement)) {
477
+ if (statement.isTypeOnly || statement.exportClause === undefined) continue
478
+ if (!ts.isNamedExports(statement.exportClause)) continue
479
+ for (const element of statement.exportClause.elements) {
480
+ if (!element.isTypeOnly) add(element.name.text, element)
481
+ }
482
+ continue
483
+ }
484
+ if (
485
+ !(
486
+ ts.isVariableStatement(statement) ||
487
+ ts.isFunctionDeclaration(statement) ||
488
+ ts.isClassDeclaration(statement)
489
+ ) ||
490
+ !hasModifier(statement, ts.SyntaxKind.ExportKeyword) ||
491
+ hasModifier(statement, ts.SyntaxKind.DeclareKeyword)
492
+ )
493
+ continue
494
+ if (ts.isVariableStatement(statement)) {
495
+ for (const declaration of statement.declarationList.declarations) {
496
+ if (ts.isIdentifier(declaration.name)) add(declaration.name.text, declaration)
497
+ }
498
+ } else if (statement.name !== undefined) {
499
+ add(statement.name.text, statement)
500
+ }
501
+ }
502
+ return found
503
+ }
504
+
505
+ /**
506
+ * NF-C041: a route's frontend file (`x.tsx`, a layout, a status or error page) exports a name only
507
+ * the server reads, such as `loader` or `action`. The frontend file ships to the browser whole, so
508
+ * `nifra build` and the server refuse it; this reports it before either runs.
509
+ */
510
+ export const frontendServerExportRule: CheckRule = {
511
+ code: "NF-C041",
512
+ title: "Server-only export in a route frontend file",
513
+ async scan(ctx) {
514
+ const routesDir = join(ctx.root, "routes")
515
+ if (!existsSync(routesDir)) return []
516
+ let manifest: ReturnType<typeof discoverRoutes>
517
+ try {
518
+ manifest = discoverRoutes(routesDir)
519
+ } catch {
520
+ return []
521
+ }
522
+ const files = new Set<string>()
523
+ for (const entry of [
524
+ ...manifest.routes,
525
+ ...Object.values(manifest.layouts),
526
+ ...Object.values(manifest.errors ?? {}),
527
+ ...(manifest.notFound === undefined ? [] : [manifest.notFound]),
528
+ ...Object.values(manifest.notFounds ?? {}),
529
+ ...Object.values(manifest.statusPages ?? {}),
530
+ ...Object.values(manifest.loadings ?? {}),
531
+ ]) {
532
+ if (SCRIPT_LAYOUT.test(entry.file)) files.add(entry.file)
533
+ }
534
+ if (files.size === 0) return []
535
+ const loaded = await loadRuleTypeScript(ctx)
536
+ const ts = loaded.compiler
537
+ if (ts === undefined) return []
538
+ const findings: Diagnostic[] = []
539
+ for (const file of [...files].sort(codeUnitOrder)) {
540
+ const text = readFileSync(join(routesDir, file), "utf8")
541
+ const tree = ts.createSourceFile(
542
+ file,
543
+ text,
544
+ ts.ScriptTarget.Latest,
545
+ true,
546
+ scriptKindOf(ts, file),
547
+ )
548
+ for (const { name, line } of runtimeExports(ts, tree)) {
549
+ if (!BACKEND_ROUTE_EXPORTS.has(name)) continue
550
+ const backend = `routes/${file.replace(SCRIPT_LAYOUT, ".backend.ts")}`
551
+ findings.push(
552
+ diagnostic({
553
+ code: "NF-C041",
554
+ severity: "error",
555
+ file: `routes/${file}`,
556
+ line,
557
+ message: `routes/${file} exports "${name}", which runs on the server only, and a route's frontend file ships to the browser whole, so nifra build and the server refuse it. Move it to ${backend}.`,
558
+ evidence: [`export: ${name}`, `backend half: ${backend}`],
559
+ fix: { recipe: "route.split", command: "nifra fix --code NF-C041" },
560
+ verify: "nifra check",
561
+ }),
562
+ )
563
+ }
564
+ }
565
+ return findings
566
+ },
567
+ }
568
+
329
569
  export const pageRules = Object.freeze([
330
570
  shadowedPageRule,
331
571
  statusLayoutRule,
332
572
  implicitApiMountRule,
333
573
  layoutMetaDataRule,
574
+ frontendServerExportRule,
575
+ ungatedLayoutRefusalRule,
334
576
  ])
@@ -74,8 +74,9 @@ function unexpectedMessage(
74
74
  const parentOf = (dir: string): string => dir.slice(0, Math.max(dir.lastIndexOf("/"), 0))
75
75
 
76
76
  /**
77
- * Whether NF-S002 may treat `file` as browser code, which compares values the client already holds,
78
- * so a finding there is advisory. It must be browser code by path (a route's frontend half,
77
+ * Whether NF-S001 and NF-S002 may treat `file` as browser code, where a finding is advisory: a
78
+ * browser compares values the client already holds, and a gate there that fails open grants nothing
79
+ * the server does not check again. It must be browser code by path (a route's frontend half,
79
80
  * `frontend/`) and sit inside a nifra web app: a `routes/` folder in an API-only project or a package
80
81
  * without a web dependency runs on the server. `shared/` also runs on the server, and a file that
81
82
  * cannot be classified is treated as server - fail closed.
@@ -557,29 +558,46 @@ const GATE_NAMES = [
557
558
  /^(?:require|assert|authori[sz]e|authenticate)/i,
558
559
  /^can[A-Z]/,
559
560
  /.Guard$/,
560
- /^(?:ensure|verify|check|validate)(?:Auth|LoggedIn|Login|Session|Token|Jwt|JWT|Signature|Webhook|Hmac|HMAC|Password|Credential|Permission|Access|Role|Admin|Owner|Scope|Csrf|CSRF|ApiKey|Origin|Tenant|User|Member)/,
561
+ /^(?:ensure|verify|check|validate)(?:Auth\w*|LoggedIn|Login|Session|Token|Jwt|JWT|Signatures?|Webhook|Hmac|HMAC|Password|Credentials?|Permissions?|Access|Roles?|Admin(?:istrator)?|Owner(?:ship)?|Scopes?|Csrf|CSRF|ApiKey|Origin|Tenant|User|Member(?:ship)?)(?![a-z])/,
561
562
  ]
562
563
 
564
+ /** An authenticator returns who is calling, so its "nobody" result (`null`, `undefined`) is a denial. */
565
+ const AUTHENTICATOR = /^authenticate/i
566
+
567
+ /** Result-object fields whose `false` is the denial (`{ ok: false, error }`). */
568
+ const DECISION_FIELDS = new Set([
569
+ "ok",
570
+ "allowed",
571
+ "authorized",
572
+ "authenticated",
573
+ "valid",
574
+ "success",
575
+ "granted",
576
+ ])
577
+
563
578
  /** Helpers whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). */
564
579
  const FAILING_HELPERS = new Set(["fail", "deny", "reject", "forbid", "unauthorized", "abort"])
565
580
 
566
581
  /**
567
- * Whether a gate's catch block fails closed: it throws, returns `false`, a `new Response(…)` or a
568
- * `deny…(…)` call, or hands the failure to a {@link FAILING_HELPERS} helper. The last form is why
582
+ * Whether a gate's catch block fails closed: it throws, returns `false`, a result object whose decision
583
+ * field is `false`, a `new Response(…)` or a `deny…(…)` call (an authenticator also `null` or
584
+ * nothing), or hands the failure to a {@link FAILING_HELPERS} helper. The last form is why
569
585
  * conformance/invariant asserts that funnel every error through a `never`-returning `fail()` are not
570
586
  * fail-open - the scanner reads no types, so it trusts the helper by name, the same trade the SQL and
571
587
  * gate-name heuristics already make. Read from the syntax, so a "throw" in a comment or a string counts
572
588
  * for nothing, and a nested function's statements are not the catch's own.
573
589
  */
574
- function failsClosed(ts: typeof TSApi, block: TSApi.Block): boolean {
590
+ function failsClosed(ts: typeof TSApi, block: TSApi.Block, gate: string): boolean {
591
+ const authenticator = AUTHENTICATOR.test(gate)
575
592
  let closed = false
576
593
  const visit = (node: TSApi.Node): void => {
577
594
  if (closed || ts.isFunctionLike(node)) return
578
595
  if (
579
596
  ts.isThrowStatement(node) ||
580
597
  (ts.isReturnStatement(node) &&
581
- node.expression !== undefined &&
582
- isDenial(ts, node.expression)) ||
598
+ (node.expression === undefined
599
+ ? authenticator
600
+ : isDenial(ts, node.expression, authenticator))) ||
583
601
  (ts.isCallExpression(node) &&
584
602
  ts.isIdentifier(node.expression) &&
585
603
  FAILING_HELPERS.has(node.expression.text))
@@ -593,7 +611,7 @@ function failsClosed(ts: typeof TSApi, block: TSApi.Block): boolean {
593
611
  return closed
594
612
  }
595
613
 
596
- function isDenial(ts: typeof TSApi, expression: TSApi.Expression): boolean {
614
+ function isDenial(ts: typeof TSApi, expression: TSApi.Expression, authenticator: boolean): boolean {
597
615
  let value = expression
598
616
  while (
599
617
  ts.isParenthesizedExpression(value) ||
@@ -603,6 +621,22 @@ function isDenial(ts: typeof TSApi, expression: TSApi.Expression): boolean {
603
621
  )
604
622
  value = value.expression
605
623
  if (value.kind === ts.SyntaxKind.FalseKeyword) return true
624
+ if (
625
+ authenticator &&
626
+ (value.kind === ts.SyntaxKind.NullKeyword ||
627
+ ts.isVoidExpression(value) ||
628
+ (ts.isIdentifier(value) && value.text === "undefined"))
629
+ )
630
+ return true
631
+ if (ts.isObjectLiteralExpression(value)) {
632
+ return value.properties.some(
633
+ (property) =>
634
+ ts.isPropertyAssignment(property) &&
635
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
636
+ DECISION_FIELDS.has(property.name.text) &&
637
+ property.initializer.kind === ts.SyntaxKind.FalseKeyword,
638
+ )
639
+ }
606
640
  if (ts.isNewExpression(value))
607
641
  return ts.isIdentifier(value.expression) && value.expression.text === "Response"
608
642
  return (
@@ -621,23 +655,26 @@ export const failOpenGateRule: CheckRule = {
621
655
  const ts = loaded.compiler
622
656
  if (ts === undefined) return didNotRun("NF-S001", "Fail-open gate scan")
623
657
  const findings: Diagnostic[] = []
658
+ const webDirs = createWebAppProbe(ctx.root)
624
659
  for (const file of ctx.project.source.files) {
625
660
  const parsed = parsedFile(ts, ctx.project.source, file)
626
661
  if (parsed === undefined) continue
627
662
  const { tree, lines } = parsed
663
+ let browser: boolean | undefined
628
664
  const visit = (node: TSApi.Node): void => {
629
665
  if (ts.isCatchClause(node)) {
630
666
  let parent: TSApi.Node | undefined = node.parent
631
667
  while (parent !== undefined && !ts.isFunctionLike(parent)) parent = parent.parent
632
668
  const name = parent === undefined ? undefined : functionName(ts, parent)
633
669
  if (name !== undefined && GATE_NAMES.some((gate) => gate.test(name))) {
634
- if (!failsClosed(ts, node.block)) {
670
+ if (!failsClosed(ts, node.block, name)) {
635
671
  const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
636
672
  const reviewed = expectedReason(lines, line, "NF-S001") !== undefined
673
+ browser ??= provenBrowserFile(ctx.root, file, webDirs)
637
674
  findings.push(
638
675
  diagnostic({
639
676
  code: "NF-S001",
640
- severity: reviewed ? "info" : "error",
677
+ severity: reviewed ? "info" : browser ? "warn" : "error",
641
678
  file,
642
679
  line,
643
680
  message: reviewed
@@ -651,6 +688,7 @@ export const failOpenGateRule: CheckRule = {
651
688
  evidence: [
652
689
  name,
653
690
  "catch block has no denial or rethrow",
691
+ ...(browser ? ["browser code: the server's own gate decides"] : []),
654
692
  ...expectationEvidence(lines, line, "NF-S001"),
655
693
  ],
656
694
  verify: "nifra check --lints-only",
@@ -17,6 +17,7 @@ import {
17
17
  importRuleMessage,
18
18
  privateEnvReads,
19
19
  privateEnvReason,
20
+ specifierPackage,
20
21
  type ZoneClassifier,
21
22
  ZoneConfigError,
22
23
  } from "@nifrajs/web/zones"
@@ -26,6 +27,7 @@ import { appDirs, clientModuleRoot } from "../client-module.ts"
26
27
  import { componentCheckReceipts } from "../component-check.ts"
27
28
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
28
29
  import { codeUnitOrder } from "../internal/code-unit-order.ts"
30
+ import { installedManifest } from "../internal/installed-package.ts"
29
31
  import {
30
32
  createApiOnlyProbe,
31
33
  createKindProbe,
@@ -35,7 +37,9 @@ import {
35
37
  withApiKindHint,
36
38
  } from "../internal/nifra-project.ts"
37
39
  import { createSpecifierResolver } from "../internal/project-specifiers.ts"
40
+ import { createSourceFacts } from "../internal/source-facts.ts"
38
41
  import type { CheckRule, RuleContext } from "./index.ts"
42
+ import { loadRuleTypeScript } from "./typescript.ts"
39
43
 
40
44
  const ZONE_FOLDER = new Set(["routes", "frontend", "backend", "shared"])
41
45
  const FRONTEND = new Set(["route-frontend", "frontend"])
@@ -186,6 +190,15 @@ export const zoneImportRule: CheckRule = {
186
190
  // Outside any web app, a finding may only mean the project serves no pages: say how to declare it.
187
191
  const inWebApp = createWebAppProbe(ctx.root)
188
192
  const resolveSpecifier = createSpecifierResolver()
193
+ // Without a compiler nothing proves an import erased, so every import stays an edge.
194
+ const { compiler } = await loadRuleTypeScript(ctx)
195
+ const facts =
196
+ compiler === undefined
197
+ ? undefined
198
+ : createSourceFacts(compiler, {
199
+ keepsTypeOnlyImports: (file) =>
200
+ resolveSpecifier.keepsTypeOnlyImports(isAbsolute(file) ? file : join(ctx.root, file)),
201
+ })
189
202
  const queue: Importer[] = []
190
203
  const queued = new Set<string>()
191
204
  const follow = (importer: Importer): void => {
@@ -201,7 +214,7 @@ export const zoneImportRule: CheckRule = {
201
214
  // The browser entry imports the app's `clientModule`, so the build classifies it like any file it
202
215
  // loads, and loads what it imports.
203
216
  for (const app of appDirs(ctx.sources.files)) {
204
- const entry = clientModuleRoot(ctx.root, app, resolveSpecifier)
217
+ const entry = clientModuleRoot(ctx.root, app, resolveSpecifier, compiler)
205
218
  if (entry === undefined) continue
206
219
  let classification: Classification
207
220
  try {
@@ -235,11 +248,21 @@ export const zoneImportRule: CheckRule = {
235
248
  for (const { file, app, zone: from } of queue) {
236
249
  const content = ctx.sources.read(file)
237
250
  if (content === undefined) continue
238
- for (const { specifier, line } of importSites(content, file)) {
239
- const target = resolveSpecifier(join(ctx.root, file), specifier)
240
- if (target === undefined) continue
241
- const to = classifierFor(app).classify(target)
242
- const targetName = display(ctx.root, target)
251
+ for (const { specifier, line } of importSites(content, file, facts)) {
252
+ let target = resolveSpecifier(join(ctx.root, file), specifier)
253
+ let to: Classification
254
+ if (target === undefined) {
255
+ // An installed package is judged here only by the entry it names, when it gives each
256
+ // entry its own side; a package-wide side is NF-C004's in browser code.
257
+ const name = specifierPackage(specifier)
258
+ const manifest =
259
+ name === undefined ? undefined : installedManifest(join(ctx.root, file), name)
260
+ if (manifest === undefined) continue
261
+ to = classifierFor(app).classify(manifest, specifier)
262
+ if (to.entry === undefined) continue
263
+ target = manifest
264
+ } else to = classifierFor(app).classify(target, specifier)
265
+ const targetName = to.entry === undefined ? display(ctx.root, target) : specifier
243
266
  if (
244
267
  ["frontend", "route-frontend", "shared"].includes(from) &&
245
268
  (receipts.has(target) || receipts.has(targetName))
@@ -250,7 +273,7 @@ export const zoneImportRule: CheckRule = {
250
273
  if (to.package !== undefined) {
251
274
  if (reportedPackages.has(to.package)) continue
252
275
  reportedPackages.add(to.package)
253
- } else {
276
+ } else if (to.entry === undefined) {
254
277
  // A file in no zone loads what it imports too: the build refuses each one it reaches.
255
278
  follow({ file: targetName, app, zone: "error" })
256
279
  }