@nifrajs/cli 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +21 -2
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +778 -64
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. package/src/work-graph.ts +17 -12
@@ -2,7 +2,7 @@ import { RESERVED_KEY_READOUT, reservedKeyFor } from "@nifrajs/client"
2
2
  import { RoutePatternOverlapLimitError, routePatternOverlap } from "@nifrajs/core"
3
3
  import { expandOptionalParams, paramConstraint } from "@nifrajs/core/pattern"
4
4
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
5
- import { commentBlockHasMarker } from "./comment-markers.ts"
5
+ import { commentBlockExpectation, MISSING_EXPECTATION_REASON } from "./comment-markers.ts"
6
6
  import type { CheckRule, RuleContext } from "./index.ts"
7
7
 
8
8
  /**
@@ -23,10 +23,13 @@ import type { CheckRule, RuleContext } from "./index.ts"
23
23
  * own copy is a lint that can disagree with the compiler it is explaining.
24
24
  */
25
25
 
26
- /** Opt-out pragma for a route deliberately served only to NON-typed-client consumers. */
27
- const RESERVED_SEGMENT_PRAGMA = "nifra-expect reserved-segment"
28
- const ROUTE_OVERLAP_PRAGMA = "nifra-expect route-overlap"
29
- const PARAM_MODIFIER_PRAGMA = "nifra-expect param-modifier"
26
+ /** What marks a route deliberately served only to NON-typed-client consumers. */
27
+ const RESERVED_SEGMENT_EXPECTATION = ["NF-C018", "reserved-segment"]
28
+ // An intended overlap answers both findings about the pair: proven (NF-C024) or unprovable (NF-C025).
29
+ const ROUTE_OVERLAP_EXPECTATION = ["NF-C024", "NF-C025", "route-overlap"]
30
+ const PARAM_MODIFIER_EXPECTATION = ["NF-C026", "param-modifier"]
31
+ // A deliberately computed path is accepted with or without a reason: the finding is information.
32
+ const DYNAMIC_PATH_EXPECTATION = ["NF-C036"]
30
33
 
31
34
  interface StaticRouteFact {
32
35
  readonly file: string
@@ -92,14 +95,15 @@ export const reservedSegmentRule: CheckRule = {
92
95
  lines = (ctx.project.source.read(route.file) ?? "").split("\n")
93
96
  linesByFile.set(route.file, lines)
94
97
  }
95
- if (commentBlockHasMarker(lines, route.line, RESERVED_SEGMENT_PRAGMA)) continue
98
+ if (commentBlockExpectation(lines, route.line, RESERVED_SEGMENT_EXPECTATION) !== undefined)
99
+ continue
96
100
  findings.push(
97
101
  diagnostic({
98
102
  code: "NF-C018",
99
103
  severity: "warn",
100
104
  file: route.file,
101
105
  line: route.line,
102
- message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${RESERVED_KEY_READOUT}); dot access cannot reach it - call the parent node with the segment instead (\`${routeEscapeHint(route.path, segment)}\`), rename the segment, or mark an intentionally non-typed-client route with \`// ${RESERVED_SEGMENT_PRAGMA}\` above the registration. \`nifra fix --code NF-C018\` rewrites the broken call sites for you`,
106
+ message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${RESERVED_KEY_READOUT}); dot access cannot reach it - call the parent node with the segment instead (\`${routeEscapeHint(route.path, segment)}\`), rename the segment, or mark an intentionally non-typed-client route with \`// nifra-expect NF-C018: <reason>\` above the registration. \`nifra fix --code NF-C018\` rewrites the broken call sites for you`,
103
107
  evidence: [`${route.method} ${route.path}`, `segment: ${segment}`],
104
108
  // The route is fine; what needs editing is every typed-client call site the collision
105
109
  // broke, which the recipe finds from the compiler rather than from this one finding.
@@ -161,6 +165,54 @@ export const duplicateRouteRule: CheckRule = {
161
165
  },
162
166
  }
163
167
 
168
+ /** A segment the router reads as exactly its own text. */
169
+ const PLAIN_SEGMENT = /^[A-Za-z0-9_.~-]+$/
170
+
171
+ /** Routes keyed by their leading plain segments: two routes can overlap only if one's lead prefixes the other's. */
172
+ interface LeadNode {
173
+ readonly routes: number[]
174
+ readonly children: Map<string, LeadNode>
175
+ }
176
+
177
+ function leadOf(path: string): readonly string[] {
178
+ const lead: string[] = []
179
+ for (const segment of path.split("/").slice(1)) {
180
+ if (!PLAIN_SEGMENT.test(segment)) break
181
+ lead.push(segment)
182
+ }
183
+ return lead
184
+ }
185
+
186
+ /** The routes in `root` whose lead is compatible with `lead`, in registration order. */
187
+ function routesSharingLead(root: LeadNode, lead: readonly string[]): number[] {
188
+ const found: number[] = []
189
+ let node: LeadNode | undefined = root
190
+ for (const segment of lead) {
191
+ for (const route of node.routes) found.push(route)
192
+ node = node.children.get(segment)
193
+ if (node === undefined) return found.sort((a, b) => a - b)
194
+ }
195
+ const below = [node]
196
+ for (const next of below) {
197
+ for (const route of next.routes) found.push(route)
198
+ for (const child of next.children.values()) below.push(child)
199
+ }
200
+ return found.sort((a, b) => a - b)
201
+ }
202
+
203
+ function addLead(root: LeadNode, lead: readonly string[], route: number): void {
204
+ let node = root
205
+ for (const segment of lead) {
206
+ let child = node.children.get(segment)
207
+ if (child === undefined) {
208
+ child = { routes: [], children: new Map() }
209
+ node.children.set(segment, child)
210
+ }
211
+ node = child
212
+ }
213
+ node.routes.push(route)
214
+ }
215
+
164
216
  /**
165
217
  * NF-C024: two different same-method route patterns in one source file accept at least one of the
166
218
  * same paths. This is deliberately file-scoped, like NF-C019: the static scanner cannot prove
@@ -191,15 +243,34 @@ export const overlappingRouteRule: CheckRule = {
191
243
  return value
192
244
  }
193
245
 
246
+ const expectationByLine = new Map<number, ReturnType<typeof commentBlockExpectation>>()
247
+ const expectationAt = (line: number): ReturnType<typeof commentBlockExpectation> => {
248
+ if (!expectationByLine.has(line))
249
+ expectationByLine.set(
250
+ line,
251
+ commentBlockExpectation(lines(), line, ROUTE_OVERLAP_EXPECTATION),
252
+ )
253
+ return expectationByLine.get(line)
254
+ }
255
+ const leads = new Map<string, LeadNode>()
194
256
  for (let laterIndex = 0; laterIndex < routes.length; laterIndex += 1) {
195
257
  const later = routes[laterIndex]!
196
258
  const laterKey = `${later.method} ${later.path}`
197
- for (let earlierIndex = 0; earlierIndex < laterIndex; earlierIndex += 1) {
259
+ const lead = leadOf(later.path)
260
+ let methodLeads = leads.get(later.method)
261
+ if (methodLeads === undefined) {
262
+ methodLeads = { routes: [], children: new Map() }
263
+ leads.set(later.method, methodLeads)
264
+ }
265
+ const candidates = routesSharingLead(methodLeads, lead)
266
+ addLead(methodLeads, lead, laterIndex)
267
+ for (const earlierIndex of candidates) {
198
268
  const earlier = routes[earlierIndex]!
199
- if (earlier.method !== later.method || `${earlier.method} ${earlier.path}` === laterKey)
200
- continue
201
- if (commentBlockHasMarker(lines(), later.line, ROUTE_OVERLAP_PRAGMA)) continue
202
- if (commentBlockHasMarker(lines(), earlier.line, ROUTE_OVERLAP_PRAGMA)) continue
269
+ if (`${earlier.method} ${earlier.path}` === laterKey) continue
270
+ // Both are errors, so the comment silences only with a reason.
271
+ const expectations = [later.line, earlier.line].map(expectationAt)
272
+ if (expectations.some((expected) => expected?.reason !== undefined)) continue
273
+ const unreasoned = expectations.some((expected) => expected !== undefined)
203
274
 
204
275
  let witness: string | undefined
205
276
  try {
@@ -212,7 +283,7 @@ export const overlappingRouteRule: CheckRule = {
212
283
  severity: "error",
213
284
  file: later.file,
214
285
  line: later.line,
215
- message: `${laterKey} could not be proven disjoint from ${earlier.method} ${earlier.path}; bounded route-overlap analysis exceeded its safety budget - simplify the patterns or add the ${ROUTE_OVERLAP_PRAGMA} comment only after manual review`,
286
+ message: `${laterKey} could not be proven disjoint from ${earlier.method} ${earlier.path}; bounded route-overlap analysis exceeded its safety budget - simplify the patterns or add a \`// nifra-expect NF-C025: <reason>\` comment only after manual review${unreasoned ? `; ${MISSING_EXPECTATION_REASON}` : ""}`,
216
287
  evidence: [
217
288
  `${earlier.method} ${earlier.path}`,
218
289
  `${later.method} ${later.path}`,
@@ -237,7 +308,7 @@ export const overlappingRouteRule: CheckRule = {
237
308
  severity: "error",
238
309
  file: later.file,
239
310
  line: later.line,
240
- message: `${laterKey} overlaps ${earlier.method} ${earlier.path} (first at line ${earlier.line}); witness path: ${witness} - make the patterns disjoint, order them intentionally with a pragma, or add the ${ROUTE_OVERLAP_PRAGMA} comment above either registration`,
311
+ message: `${laterKey} overlaps ${earlier.method} ${earlier.path} (first at line ${earlier.line}); witness path: ${witness} - make the patterns disjoint, or add a \`// nifra-expect NF-C024: <reason>\` comment above either registration${unreasoned ? `; ${MISSING_EXPECTATION_REASON}` : ""}`,
241
312
  evidence: [
242
313
  `${earlier.method} ${earlier.path}`,
243
314
  `${later.method} ${later.path}`,
@@ -296,7 +367,8 @@ export const paramModifierRule: CheckRule = {
296
367
  lines = (ctx.project.source.read(route.file) ?? "").split("\n")
297
368
  linesByFile.set(route.file, lines)
298
369
  }
299
- if (commentBlockHasMarker(lines, route.line, PARAM_MODIFIER_PRAGMA)) continue
370
+ if (commentBlockExpectation(lines, route.line, PARAM_MODIFIER_EXPECTATION) !== undefined)
371
+ continue
300
372
  const character = text[text.length - 1]!
301
373
  findings.push(
302
374
  diagnostic({
@@ -304,7 +376,7 @@ export const paramModifierRule: CheckRule = {
304
376
  severity: "warn",
305
377
  file: route.file,
306
378
  line: route.line,
307
- message: `${route.method} ${route.path} - '${character}' after '${text.slice(0, -1)}' is matched as literal text, not as a param modifier${character === "?" ? "; a request path never contains '?', so this route cannot be reached" : ""}. Optional params are supported as the trailing whole segments of a path (\`/users/:id?\`, \`/d/:year?/:month?\`), and a constraint as one character class with an optional count (\`:id{[0-9]+}\`, \`:code{[A-Z]{2}}\`) or a list of two or more values (\`:ext{png|jpg}\`); for anything else register each path, or mark a deliberate literal with \`// ${PARAM_MODIFIER_PRAGMA}\` above the registration`,
379
+ message: `${route.method} ${route.path} - '${character}' after '${text.slice(0, -1)}' is matched as literal text, not as a param modifier${character === "?" ? "; a request path never contains '?', so this route cannot be reached" : ""}. Optional params are supported as the trailing whole segments of a path (\`/users/:id?\`, \`/d/:year?/:month?\`), and a constraint as one character class with an optional count (\`:id{[0-9]+}\`, \`:code{[A-Z]{2}}\`) or a list of two or more values (\`:ext{png|jpg}\`); for anything else register each path, or mark a deliberate literal with \`// nifra-expect NF-C026: <reason>\` above the registration`,
308
380
  evidence: [`${route.method} ${route.path}`, `literal: ${text}`],
309
381
  verify: "nifra check --lints-only",
310
382
  }),
@@ -314,9 +386,45 @@ export const paramModifierRule: CheckRule = {
314
386
  },
315
387
  }
316
388
 
389
+ /**
390
+ * NF-C036: a registration whose path the static scan cannot read. The route still serves; what it
391
+ * loses is every source-level analysis keyed by path - automatic capability provenance and the route
392
+ * lints above - so it is reported as information, naming the expression.
393
+ */
394
+ export const unresolvedRoutePathRule: CheckRule = {
395
+ code: "NF-C036",
396
+ title: "Route path the static scan cannot read",
397
+ async scan(ctx) {
398
+ const findings: Diagnostic[] = []
399
+ const linesByFile = new Map<string, readonly string[]>()
400
+ for (const route of ctx.project.sourceFindings.routePaths) {
401
+ let lines = linesByFile.get(route.file)
402
+ if (lines === undefined) {
403
+ lines = (ctx.project.source.read(route.file) ?? "").split("\n")
404
+ linesByFile.set(route.file, lines)
405
+ }
406
+ if (commentBlockExpectation(lines, route.line, DYNAMIC_PATH_EXPECTATION) !== undefined)
407
+ continue
408
+ findings.push(
409
+ diagnostic({
410
+ code: "NF-C036",
411
+ severity: "info",
412
+ file: route.file,
413
+ line: route.line,
414
+ message: `${route.method} ${route.expression} - the static scan cannot read this route path, so automatic capability provenance and the route lints do not see the route. Write the path as a string literal, or as a module-level \`const\` (optionally \`as const\`) in this file or exported from a project file it imports; mark a deliberately computed path with a \`// nifra-expect NF-C036: <reason>\` comment above the registration`,
415
+ evidence: [`${route.method} ${route.expression}`],
416
+ verify: "nifra check --lints-only",
417
+ }),
418
+ )
419
+ }
420
+ return findings
421
+ },
422
+ }
423
+
317
424
  export const routeRules = Object.freeze([
318
425
  reservedSegmentRule,
319
426
  duplicateRouteRule,
320
427
  overlappingRouteRule,
321
428
  paramModifierRule,
429
+ unresolvedRoutePathRule,
322
430
  ])
@@ -17,7 +17,7 @@ import { stripComments } from "../check-scan.ts"
17
17
  import { type Diagnostic, diagnostic } from "../diagnostics.ts"
18
18
  import { codeUnitOrder } from "../internal/code-unit-order.ts"
19
19
  import type { CheckRule } from "./index.ts"
20
- import { zonedFiles } from "./zones.ts"
20
+ import { buildText, zonedFiles } from "./zones.ts"
21
21
 
22
22
  const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
23
23
 
@@ -123,7 +123,7 @@ export const secretSourceRule: CheckRule = {
123
123
  for (const { file, app, classification } of files) {
124
124
  if (!apps.has(app)) apps.set(app, [])
125
125
  if (!BROWSER_CODE.has(classification.zone)) continue
126
- const text = ctx.sources.read(file)
126
+ const text = buildText(ctx, file)
127
127
  if (text === undefined) continue
128
128
  const name = app === "" ? file : file.slice(app.length + 1)
129
129
  apps.get(app)?.push({ name, text, firstParty: true })