@nifrajs/cli 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +28 -3
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +958 -71
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
@@ -0,0 +1,383 @@
1
+ /**
2
+ * NF-C039: contract operations the source implements but `backend/app.ts` does not serve. The contract
3
+ * lock, the typed client checks and `nifra routes` read the backend that file exports, so an
4
+ * `implement(contract, handlers)` app built anywhere else is invisible to every one of them.
5
+ *
6
+ * The served routes come from the backend the contract-lock check already loaded; this rule runs no
7
+ * project code of its own. Without a lock the backend is not loaded and the comparison is skipped.
8
+ */
9
+ import { existsSync, readFileSync, realpathSync } from "node:fs"
10
+ import { join, relative } from "node:path"
11
+ import type * as TSApi from "typescript"
12
+ import { BACKEND_APP_FILE } from "../app-files.ts"
13
+ import { type Diagnostic, diagnostic } from "../diagnostics.ts"
14
+ import { createSpecifierResolver, type SpecifierResolver } from "../internal/project-specifiers.ts"
15
+ import { scriptKindOf } from "../internal/script-kind.ts"
16
+ import type { CheckRule } from "./index.ts"
17
+ import { loadRuleTypeScript } from "./typescript.ts"
18
+
19
+ const CONTRACT_MODULE = "@nifrajs/core/contract"
20
+ const CODE_FILE = /\.[cm]?[jt]sx?$/
21
+ // `export { implement } from "@nifrajs/core/contract"` or `export * from ...`: a project module that
22
+ // passes the contract API through.
23
+ const PASSES_THROUGH = (name: string): RegExp =>
24
+ new RegExp(
25
+ `export\\s*(?:\\*|\\{[^}]*\\b${name}\\b[^}]*\\})\\s*from\\s*["']${CONTRACT_MODULE}["']`,
26
+ )
27
+ const LISTED = 6
28
+
29
+ interface Operation {
30
+ readonly name: string
31
+ readonly method: string
32
+ readonly path: string
33
+ }
34
+
35
+ interface Implementation {
36
+ readonly file: string
37
+ readonly line: number
38
+ readonly contract: string
39
+ readonly operations: readonly Operation[]
40
+ /** Why some operations could not be read, when any could not. */
41
+ readonly unreadable: readonly string[]
42
+ }
43
+
44
+ /** The contract API names a file binds: local name -> `implement` or `defineContract`. */
45
+ interface Bindings {
46
+ readonly named: ReadonlyMap<string, "implement" | "defineContract">
47
+ /** Namespace imports of the contract module: `c` in `c.implement(...)`. */
48
+ readonly namespaces: ReadonlySet<string>
49
+ /** Local name -> the specifier and imported name, for an import from a project module. */
50
+ readonly imported: ReadonlyMap<string, { readonly specifier: string; readonly name: string }>
51
+ }
52
+
53
+ function unwrap(ts: typeof TSApi, node: TSApi.Expression): TSApi.Expression {
54
+ let current = node
55
+ while (
56
+ ts.isAsExpression(current) ||
57
+ ts.isSatisfiesExpression(current) ||
58
+ ts.isParenthesizedExpression(current)
59
+ )
60
+ current = current.expression
61
+ return current
62
+ }
63
+
64
+ function bindings(
65
+ ts: typeof TSApi,
66
+ tree: TSApi.SourceFile,
67
+ file: string,
68
+ root: string,
69
+ resolve: SpecifierResolver,
70
+ ): Bindings {
71
+ const named = new Map<string, "implement" | "defineContract">()
72
+ const namespaces = new Set<string>()
73
+ const imported = new Map<string, { specifier: string; name: string }>()
74
+ for (const statement of tree.statements) {
75
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
76
+ continue
77
+ const specifier = statement.moduleSpecifier.text
78
+ const clause = statement.importClause
79
+ if (clause === undefined || clause.isTypeOnly) continue
80
+ const bound = clause.namedBindings
81
+ if (bound !== undefined && ts.isNamespaceImport(bound)) {
82
+ if (specifier === CONTRACT_MODULE) namespaces.add(bound.name.text)
83
+ continue
84
+ }
85
+ if (bound === undefined) continue
86
+ // A project module may pass the API through; one hop is followed.
87
+ let passesThrough: ((name: string) => boolean) | undefined
88
+ if (specifier !== CONTRACT_MODULE) {
89
+ const target = resolve(join(root, file), specifier)
90
+ const text = target === undefined ? undefined : readText(target)
91
+ passesThrough =
92
+ text === undefined ? () => false : (name: string) => PASSES_THROUGH(name).test(text)
93
+ }
94
+ for (const element of bound.elements) {
95
+ if (element.isTypeOnly) continue
96
+ const name = (element.propertyName ?? element.name).text
97
+ if (name === "implement" || name === "defineContract") {
98
+ if (passesThrough === undefined || passesThrough(name)) named.set(element.name.text, name)
99
+ } else if (passesThrough !== undefined) imported.set(element.name.text, { specifier, name })
100
+ }
101
+ }
102
+ return { named, namespaces, imported }
103
+ }
104
+
105
+ function readText(path: string): string | undefined {
106
+ try {
107
+ return readFileSync(path, "utf8")
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
112
+
113
+ /** Whether `callee` names the contract API's `api` function under these bindings. */
114
+ function calls(
115
+ ts: typeof TSApi,
116
+ callee: TSApi.Expression,
117
+ bound: Bindings,
118
+ api: "implement" | "defineContract",
119
+ ): boolean {
120
+ if (ts.isIdentifier(callee)) return bound.named.get(callee.text) === api
121
+ return (
122
+ ts.isPropertyAccessExpression(callee) &&
123
+ ts.isIdentifier(callee.expression) &&
124
+ bound.namespaces.has(callee.expression.text) &&
125
+ callee.name.text === api
126
+ )
127
+ }
128
+
129
+ /** The operations an object literal of a contract declares, and what could not be read. */
130
+ function operationsOf(
131
+ ts: typeof TSApi,
132
+ literal: TSApi.ObjectLiteralExpression,
133
+ ): { operations: Operation[]; unreadable: string[] } {
134
+ const operations: Operation[] = []
135
+ const unreadable: string[] = []
136
+ for (const property of literal.properties) {
137
+ if (!ts.isPropertyAssignment(property)) {
138
+ unreadable.push("an operation spread or written as a method")
139
+ continue
140
+ }
141
+ const name = property.name.getText().replace(/^["']|["']$/g, "")
142
+ const op = unwrap(ts, property.initializer)
143
+ if (!ts.isObjectLiteralExpression(op)) {
144
+ unreadable.push(`operation "${name}" is not an object literal`)
145
+ continue
146
+ }
147
+ const field = (key: string): string | undefined => {
148
+ for (const member of op.properties) {
149
+ if (!ts.isPropertyAssignment(member) || member.name.getText() !== key) continue
150
+ const value = unwrap(ts, member.initializer)
151
+ return ts.isStringLiteralLike(value) ? value.text : undefined
152
+ }
153
+ return undefined
154
+ }
155
+ const method = field("method")
156
+ const path = field("path")
157
+ if (method === undefined || path === undefined)
158
+ unreadable.push(`operation "${name}" has a method or path that is not a string literal`)
159
+ else operations.push({ name, method: method.toUpperCase(), path })
160
+ }
161
+ return { operations, unreadable }
162
+ }
163
+
164
+ interface Parsed {
165
+ readonly tree: TSApi.SourceFile
166
+ readonly bound: Bindings
167
+ }
168
+
169
+ export const contractVisibilityRule: CheckRule = {
170
+ code: "NF-C039",
171
+ title: "Implemented contract operation the backend does not serve",
172
+ async scan(ctx) {
173
+ // The backend compared against is this package's, so a file in a nested package is not read.
174
+ const ownPackage = new Map<string, boolean>()
175
+ const inOwnPackage = (dir: string): boolean => {
176
+ if (dir === "") return true
177
+ const cached = ownPackage.get(dir)
178
+ if (cached !== undefined) return cached
179
+ const own =
180
+ !existsSync(join(ctx.root, dir, "package.json")) &&
181
+ inOwnPackage(dir.slice(0, Math.max(dir.lastIndexOf("/"), 0)))
182
+ ownPackage.set(dir, own)
183
+ return own
184
+ }
185
+ const candidates = ctx.sources.files.filter((file) => {
186
+ if (!CODE_FILE.test(file) || file.endsWith(".d.ts")) return false
187
+ if (!inOwnPackage(file.slice(0, Math.max(file.lastIndexOf("/"), 0)))) return false
188
+ const text = ctx.sources.read(file)
189
+ return text?.includes("implement") === true
190
+ })
191
+ if (candidates.length === 0) return []
192
+ const loaded = await loadRuleTypeScript(ctx)
193
+ const ts = loaded.compiler
194
+ if (ts === undefined) return []
195
+ const resolve = createSpecifierResolver()
196
+ // The resolver answers with real paths, so the root is read as one to name files project-relative.
197
+ const realRoot = (() => {
198
+ try {
199
+ return realpathSync(ctx.root)
200
+ } catch {
201
+ return ctx.root
202
+ }
203
+ })()
204
+ const parsedCache = new Map<string, Parsed | undefined>()
205
+ /** A project file, by its project-relative name, parsed with its contract bindings. */
206
+ const parse = (file: string, text: string | undefined): Parsed | undefined => {
207
+ if (parsedCache.has(file)) return parsedCache.get(file)
208
+ const tree =
209
+ text === undefined
210
+ ? undefined
211
+ : ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKindOf(ts, file))
212
+ const parsed =
213
+ tree === undefined
214
+ ? undefined
215
+ : { tree, bound: bindings(ts, tree, file, ctx.root, resolve) }
216
+ parsedCache.set(file, parsed)
217
+ return parsed
218
+ }
219
+ /** The object literal `defineContract({...})` gives the const `name` in `file`, followed one hop
220
+ * into a project module it is imported from. */
221
+ const contractLiteral = (
222
+ file: string,
223
+ parsed: Parsed,
224
+ name: string,
225
+ hops = 0,
226
+ ): TSApi.ObjectLiteralExpression | undefined => {
227
+ for (const statement of parsed.tree.statements) {
228
+ if (!ts.isVariableStatement(statement)) continue
229
+ for (const declaration of statement.declarationList.declarations) {
230
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
231
+ if (declaration.initializer === undefined) return undefined
232
+ const init = unwrap(ts, declaration.initializer)
233
+ if (ts.isObjectLiteralExpression(init)) return init
234
+ if (
235
+ !ts.isCallExpression(init) ||
236
+ !calls(ts, init.expression, parsed.bound, "defineContract")
237
+ )
238
+ return undefined
239
+ const arg = init.arguments[0]
240
+ const literal = arg === undefined ? undefined : unwrap(ts, arg)
241
+ return literal !== undefined && ts.isObjectLiteralExpression(literal)
242
+ ? literal
243
+ : undefined
244
+ }
245
+ }
246
+ const from = parsed.bound.imported.get(name)
247
+ if (from === undefined || hops > 0) return undefined
248
+ const target = resolve(join(ctx.root, file), from.specifier)
249
+ if (target === undefined) return undefined
250
+ const rel = relative(realRoot, target).replaceAll("\\", "/")
251
+ const next = parse(rel, readText(target))
252
+ return next === undefined ? undefined : contractLiteral(rel, next, from.name, hops + 1)
253
+ }
254
+
255
+ const implementations: Implementation[] = []
256
+ for (const file of candidates) {
257
+ const parsed = parse(file, ctx.sources.read(file))
258
+ if (
259
+ parsed === undefined ||
260
+ (parsed.bound.named.size === 0 && parsed.bound.namespaces.size === 0)
261
+ )
262
+ continue
263
+ const visit = (node: TSApi.Node): void => {
264
+ if (ts.isCallExpression(node) && calls(ts, node.expression, parsed.bound, "implement")) {
265
+ const line = parsed.tree.getLineAndCharacterOfPosition(node.getStart()).line + 1
266
+ const arg = node.arguments[0] === undefined ? undefined : unwrap(ts, node.arguments[0])
267
+ let literal: TSApi.ObjectLiteralExpression | undefined
268
+ let contract = arg?.getText() ?? "(none)"
269
+ if (arg !== undefined && ts.isIdentifier(arg))
270
+ literal = contractLiteral(file, parsed, arg.text)
271
+ else if (arg !== undefined && ts.isObjectLiteralExpression(arg)) {
272
+ literal = arg
273
+ contract = "an inline contract"
274
+ } else if (
275
+ arg !== undefined &&
276
+ ts.isCallExpression(arg) &&
277
+ calls(ts, arg.expression, parsed.bound, "defineContract")
278
+ ) {
279
+ const inner = arg.arguments[0] === undefined ? undefined : unwrap(ts, arg.arguments[0])
280
+ if (inner !== undefined && ts.isObjectLiteralExpression(inner)) literal = inner
281
+ contract = "an inline contract"
282
+ }
283
+ if (literal === undefined) {
284
+ implementations.push({
285
+ file,
286
+ line,
287
+ contract,
288
+ operations: [],
289
+ unreadable: [
290
+ `the contract ${contract} is not a defineContract({...}) the scan can read`,
291
+ ],
292
+ })
293
+ } else implementations.push({ file, line, contract, ...operationsOf(ts, literal) })
294
+ }
295
+ ts.forEachChild(node, visit)
296
+ }
297
+ visit(parsed.tree)
298
+ }
299
+ if (implementations.length === 0) return []
300
+
301
+ const findings: Diagnostic[] = []
302
+ for (const { file, line, contract, unreadable } of implementations) {
303
+ if (unreadable.length === 0) continue
304
+ findings.push(
305
+ diagnostic({
306
+ code: "NF-C039",
307
+ severity: "warn",
308
+ file,
309
+ line,
310
+ message: `implement(${contract}): the static scan cannot read every operation (${unreadable.join("; ")}), so NF-C039 cannot tell whether backend/app.ts serves them. Write each operation's method and path as string literals in a defineContract({...}) const`,
311
+ verify: "nifra check --lints-only",
312
+ }),
313
+ )
314
+ }
315
+
316
+ if (!existsSync(join(ctx.root, BACKEND_APP_FILE))) {
317
+ const count = implementations.reduce((sum, item) => sum + item.operations.length, 0)
318
+ const first = implementations[0]
319
+ if (count > 0 && first !== undefined) {
320
+ findings.push(
321
+ diagnostic({
322
+ code: "NF-C039",
323
+ severity: "warn",
324
+ file: first.file,
325
+ line: first.line,
326
+ message: `${count} contract operation(s) are implemented in source, but there is no ${BACKEND_APP_FILE}, so the contract lock, the typed client checks and nifra routes see none of them. Export the implementing app from ${BACKEND_APP_FILE} as \`backend\`, built with stub dependencies if it needs any`,
327
+ verify: "nifra check --lints-only",
328
+ }),
329
+ )
330
+ }
331
+ return findings
332
+ }
333
+
334
+ const served = ctx.project.check.contracts.served
335
+ if (served === undefined) return findings
336
+ const routes = served.map((key) => {
337
+ const at = key.indexOf(" ")
338
+ return { method: key.slice(0, at), path: key.slice(at + 1) }
339
+ })
340
+ const servedKeys = new Set(served)
341
+ const servedAt = (prefix: string, op: Operation): boolean =>
342
+ op.path === "/"
343
+ ? servedKeys.has(`${op.method} ${prefix === "" ? "/" : prefix}`) ||
344
+ servedKeys.has(`${op.method} ${prefix}/`)
345
+ : servedKeys.has(`${op.method} ${prefix}${op.path}`)
346
+ // A mount or group prefix serves every operation of the app under it, so one prefix must serve
347
+ // them all. Matching each operation on its own lets `/` match any route and `/:id` any `.../:id`.
348
+ const unserved = (operations: readonly Operation[]): readonly Operation[] => {
349
+ const prefixes = new Set([""])
350
+ for (const op of operations) {
351
+ for (const route of routes) {
352
+ if (route.method !== op.method) continue
353
+ if (op.path === "/") prefixes.add(route.path.replace(/\/$/, ""))
354
+ else if (route.path.endsWith(op.path)) prefixes.add(route.path.slice(0, -op.path.length))
355
+ }
356
+ }
357
+ let fewest = operations
358
+ for (const prefix of prefixes) {
359
+ const missing = operations.filter((op) => !servedAt(prefix, op))
360
+ if (missing.length < fewest.length) fewest = missing
361
+ }
362
+ return fewest
363
+ }
364
+ for (const { file, line, contract, operations } of implementations) {
365
+ const missing = unserved(operations)
366
+ if (missing.length === 0) continue
367
+ const listed = missing.slice(0, LISTED).map((op) => `${op.method} ${op.path}`)
368
+ const more = missing.length > LISTED ? ` and ${missing.length - LISTED} more` : ""
369
+ findings.push(
370
+ diagnostic({
371
+ code: "NF-C039",
372
+ severity: "warn",
373
+ file,
374
+ line,
375
+ message: `implement(${contract}) serves ${missing.length} operation(s) that ${BACKEND_APP_FILE} does not: ${listed.join(", ")}${more}. The contract lock, the typed client checks and nifra routes read only the backend that file exports, so they cannot see these. Mount this app in ${BACKEND_APP_FILE}, built with stub dependencies if it needs any`,
376
+ evidence: missing.map((op) => `${op.name}: ${op.method} ${op.path}`),
377
+ verify: "nifra check",
378
+ }),
379
+ )
380
+ }
381
+ return findings
382
+ },
383
+ }