@nifrajs/cli 2.10.0 → 2.12.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 (193) hide show
  1. package/README.md +7 -1
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +133 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +741 -148
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +21 -14
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +288 -73
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/dev-bun-config.d.ts +17 -6
  31. package/dist/dev-bun-config.d.ts.map +1 -1
  32. package/dist/dev-bun-config.js +51 -14
  33. package/dist/dev-bun-config.js.map +1 -1
  34. package/dist/diagnostics.d.ts +23 -0
  35. package/dist/diagnostics.d.ts.map +1 -0
  36. package/dist/diagnostics.js +9 -0
  37. package/dist/diagnostics.js.map +1 -0
  38. package/dist/diff-tool.d.ts +2 -0
  39. package/dist/diff-tool.d.ts.map +1 -1
  40. package/dist/diff-tool.js +1 -1
  41. package/dist/diff-tool.js.map +1 -1
  42. package/dist/docs-search.d.ts +8 -3
  43. package/dist/docs-search.d.ts.map +1 -1
  44. package/dist/docs-search.js +36 -15
  45. package/dist/docs-search.js.map +1 -1
  46. package/dist/doctor.d.ts +38 -2
  47. package/dist/doctor.d.ts.map +1 -1
  48. package/dist/doctor.js +189 -7
  49. package/dist/doctor.js.map +1 -1
  50. package/dist/examples.d.ts +1 -2
  51. package/dist/examples.d.ts.map +1 -1
  52. package/dist/examples.js +37 -16
  53. package/dist/examples.js.map +1 -1
  54. package/dist/fix-recipes.d.ts +12 -0
  55. package/dist/fix-recipes.d.ts.map +1 -0
  56. package/dist/fix-recipes.js +106 -0
  57. package/dist/fix-recipes.js.map +1 -0
  58. package/dist/init-agents.d.ts.map +1 -1
  59. package/dist/init-agents.js +60 -4
  60. package/dist/init-agents.js.map +1 -1
  61. package/dist/internal/source-facts.d.ts +15 -0
  62. package/dist/internal/source-facts.d.ts.map +1 -0
  63. package/dist/internal/source-facts.js +82 -0
  64. package/dist/internal/source-facts.js.map +1 -0
  65. package/dist/internal/typescript-import.d.ts +9 -0
  66. package/dist/internal/typescript-import.d.ts.map +1 -1
  67. package/dist/internal/typescript-import.js +38 -0
  68. package/dist/internal/typescript-import.js.map +1 -1
  69. package/dist/introspect.d.ts +6 -2
  70. package/dist/introspect.d.ts.map +1 -1
  71. package/dist/introspect.js +60 -5
  72. package/dist/introspect.js.map +1 -1
  73. package/dist/load.d.ts +10 -0
  74. package/dist/load.d.ts.map +1 -1
  75. package/dist/load.js +1 -0
  76. package/dist/load.js.map +1 -1
  77. package/dist/manifest-tool.d.ts.map +1 -1
  78. package/dist/manifest-tool.js +14 -9
  79. package/dist/manifest-tool.js.map +1 -1
  80. package/dist/mcp-docs-tools.d.ts.map +1 -1
  81. package/dist/mcp-docs-tools.js +3 -0
  82. package/dist/mcp-docs-tools.js.map +1 -1
  83. package/dist/mcp-http.js +1 -1
  84. package/dist/mcp-root.d.ts +83 -0
  85. package/dist/mcp-root.d.ts.map +1 -0
  86. package/dist/mcp-root.js +221 -0
  87. package/dist/mcp-root.js.map +1 -0
  88. package/dist/mcp-run.d.ts.map +1 -1
  89. package/dist/mcp-run.js +18 -4
  90. package/dist/mcp-run.js.map +1 -1
  91. package/dist/mcp.d.ts +27 -4
  92. package/dist/mcp.d.ts.map +1 -1
  93. package/dist/mcp.js +360 -27
  94. package/dist/mcp.js.map +1 -1
  95. package/dist/pipeline-guard.d.ts +25 -10
  96. package/dist/pipeline-guard.d.ts.map +1 -1
  97. package/dist/pipeline-guard.js +35 -15
  98. package/dist/pipeline-guard.js.map +1 -1
  99. package/dist/pipeline-report.d.ts +34 -0
  100. package/dist/pipeline-report.d.ts.map +1 -0
  101. package/dist/pipeline-report.js +282 -0
  102. package/dist/pipeline-report.js.map +1 -0
  103. package/dist/project-path.d.ts +3 -0
  104. package/dist/project-path.d.ts.map +1 -0
  105. package/dist/project-path.js +36 -0
  106. package/dist/project-path.js.map +1 -0
  107. package/dist/replay.d.ts +12 -0
  108. package/dist/replay.d.ts.map +1 -0
  109. package/dist/replay.js +36 -0
  110. package/dist/replay.js.map +1 -0
  111. package/dist/rules/codes.d.ts +42 -0
  112. package/dist/rules/codes.d.ts.map +1 -0
  113. package/dist/rules/codes.js +52 -0
  114. package/dist/rules/codes.js.map +1 -0
  115. package/dist/rules/comment-markers.d.ts +10 -0
  116. package/dist/rules/comment-markers.d.ts.map +1 -0
  117. package/dist/rules/comment-markers.js +24 -0
  118. package/dist/rules/comment-markers.js.map +1 -0
  119. package/dist/rules/index.d.ts +31 -0
  120. package/dist/rules/index.d.ts.map +1 -0
  121. package/dist/rules/index.js +133 -0
  122. package/dist/rules/index.js.map +1 -0
  123. package/dist/rules/legacy.d.ts +7 -0
  124. package/dist/rules/legacy.d.ts.map +1 -0
  125. package/dist/rules/legacy.js +85 -0
  126. package/dist/rules/legacy.js.map +1 -0
  127. package/dist/rules/routes.d.ts +12 -0
  128. package/dist/rules/routes.d.ts.map +1 -0
  129. package/dist/rules/routes.js +142 -0
  130. package/dist/rules/routes.js.map +1 -0
  131. package/dist/rules/security.d.ts +10 -0
  132. package/dist/rules/security.d.ts.map +1 -0
  133. package/dist/rules/security.js +471 -0
  134. package/dist/rules/security.js.map +1 -0
  135. package/dist/search-terms.d.ts +2 -0
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -1
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/types-search.d.ts +1 -2
  140. package/dist/types-search.d.ts.map +1 -1
  141. package/dist/types-search.js +33 -16
  142. package/dist/types-search.js.map +1 -1
  143. package/dist/verification.d.ts +9 -0
  144. package/dist/verification.d.ts.map +1 -1
  145. package/dist/verification.js +17 -1
  146. package/dist/verification.js.map +1 -1
  147. package/dist/work-graph.d.ts +123 -0
  148. package/dist/work-graph.d.ts.map +1 -0
  149. package/dist/work-graph.js +465 -0
  150. package/dist/work-graph.js.map +1 -0
  151. package/docs/examples.json +32 -2
  152. package/docs/llms-full.txt +1105 -27
  153. package/docs/types.json +1772 -137
  154. package/package.json +34 -9
  155. package/src/assure-hydration.ts +606 -0
  156. package/src/assure.ts +316 -2
  157. package/src/capabilities-tool.ts +80 -1
  158. package/src/check.ts +923 -169
  159. package/src/cli.ts +297 -76
  160. package/src/contract-proof.ts +194 -0
  161. package/src/contracts.ts +157 -0
  162. package/src/dev-bun-config.ts +57 -14
  163. package/src/diagnostics.ts +32 -0
  164. package/src/diff-tool.ts +1 -1
  165. package/src/docs-search.ts +45 -15
  166. package/src/doctor.ts +286 -8
  167. package/src/examples.ts +43 -15
  168. package/src/fix-recipes.ts +126 -0
  169. package/src/init-agents.ts +61 -4
  170. package/src/internal/source-facts.ts +133 -0
  171. package/src/internal/typescript-import.ts +29 -0
  172. package/src/introspect.ts +81 -6
  173. package/src/load.ts +11 -0
  174. package/src/manifest-tool.ts +16 -10
  175. package/src/mcp-docs-tools.ts +3 -0
  176. package/src/mcp-http.ts +1 -1
  177. package/src/mcp-root.ts +271 -0
  178. package/src/mcp-run.ts +19 -4
  179. package/src/mcp.ts +440 -34
  180. package/src/pipeline-guard.ts +44 -14
  181. package/src/pipeline-report.ts +341 -0
  182. package/src/project-path.ts +36 -0
  183. package/src/replay.ts +44 -0
  184. package/src/rules/codes.ts +57 -0
  185. package/src/rules/comment-markers.ts +26 -0
  186. package/src/rules/index.ts +175 -0
  187. package/src/rules/legacy.ts +93 -0
  188. package/src/rules/routes.ts +157 -0
  189. package/src/rules/security.ts +505 -0
  190. package/src/search-terms.ts +9 -4
  191. package/src/types-search.ts +39 -15
  192. package/src/verification.ts +18 -1
  193. package/src/work-graph.ts +613 -0
@@ -0,0 +1,194 @@
1
+ import { existsSync, realpathSync } from "node:fs"
2
+ import { resolve, sep } from "node:path"
3
+ import type { AssuranceFinding, AssuranceReport, AssuredRoute } from "@nifrajs/core/assurance"
4
+ import type {
5
+ AssuredCapabilityRoute,
6
+ CapabilityAssuranceReport,
7
+ CapabilityFinding,
8
+ } from "@nifrajs/core/capabilities"
9
+ import { diffRouteSnapshots, type RouteChange, type RoutesDiff } from "@nifrajs/core/diff"
10
+ import type { CheckResult } from "./check.ts"
11
+ import { DEFAULT_SNAPSHOT_FILE, parseSnapshotFile, snapshotBackend } from "./diff-tool.ts"
12
+ import { collectProjectVerification } from "./verification.ts"
13
+
14
+ const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
15
+
16
+ export interface ContractProofRoute {
17
+ readonly method: string
18
+ readonly path: string
19
+ readonly changes: readonly RouteChange[]
20
+ readonly assurance?: {
21
+ readonly route?: AssuredRoute
22
+ readonly findings: readonly AssuranceFinding[]
23
+ }
24
+ readonly capability?: {
25
+ readonly route?: AssuredCapabilityRoute
26
+ readonly findings: readonly CapabilityFinding[]
27
+ }
28
+ }
29
+
30
+ export interface ContractProofVerification {
31
+ readonly assuranceConfigPresent: boolean
32
+ readonly configError?: string
33
+ readonly assurance?: {
34
+ readonly ok: boolean
35
+ readonly routeCount: number
36
+ readonly findings: readonly AssuranceFinding[]
37
+ }
38
+ readonly capability?: {
39
+ readonly ok: boolean
40
+ readonly routeCount: number
41
+ readonly findings: readonly CapabilityFinding[]
42
+ }
43
+ readonly check?: CheckResult
44
+ }
45
+
46
+ export interface ContractProofReport {
47
+ readonly baseline: string
48
+ readonly hasBreaking: boolean
49
+ readonly changes: readonly RouteChange[]
50
+ readonly routes: readonly ContractProofRoute[]
51
+ readonly verification: ContractProofVerification
52
+ }
53
+
54
+ export interface ContractProofOptions {
55
+ /** Baseline path relative to the project directory. Defaults to api-snapshot.json. */
56
+ readonly baselinePath?: string
57
+ /** Run the lazy typed-contract check only when explicitly requested. */
58
+ readonly check?: boolean
59
+ readonly signal?: AbortSignal
60
+ }
61
+
62
+ function safeBaselinePath(cwd: string, baselinePath: string): string {
63
+ const root = resolve(cwd)
64
+ const target = resolve(root, baselinePath)
65
+ if (target !== root && !target.startsWith(`${root}${sep}`)) {
66
+ throw new Error("contract proof baseline must stay inside the project directory")
67
+ }
68
+ return target
69
+ }
70
+
71
+ function errorMessage(error: unknown): string {
72
+ return error instanceof Error ? error.message : String(error)
73
+ }
74
+
75
+ function changedRoutes(
76
+ diff: RoutesDiff,
77
+ assurance: AssuranceReport | undefined,
78
+ capability: CapabilityAssuranceReport | undefined,
79
+ ): readonly ContractProofRoute[] {
80
+ const changes = new Map<string, RouteChange[]>()
81
+ for (const change of diff.changes) {
82
+ const key = routeKey(change.method, change.path)
83
+ const current = changes.get(key)
84
+ if (current === undefined) changes.set(key, [change])
85
+ else current.push(change)
86
+ }
87
+
88
+ const assured = new Map(
89
+ (assurance?.routes ?? []).map((route) => [routeKey(route.method, route.path), route]),
90
+ )
91
+ const assuranceFindings = new Map<string, AssuranceFinding[]>()
92
+ for (const finding of assurance?.findings ?? []) {
93
+ const key = routeKey(finding.method, finding.path)
94
+ const current = assuranceFindings.get(key)
95
+ if (current === undefined) assuranceFindings.set(key, [finding])
96
+ else current.push(finding)
97
+ }
98
+
99
+ const capable = new Map(
100
+ (capability?.routes ?? []).map((route) => [routeKey(route.method, route.path), route]),
101
+ )
102
+ const capabilityFindings = new Map<string, CapabilityFinding[]>()
103
+ for (const finding of capability?.findings ?? []) {
104
+ const key = routeKey(finding.method, finding.path)
105
+ const current = capabilityFindings.get(key)
106
+ if (current === undefined) capabilityFindings.set(key, [finding])
107
+ else current.push(finding)
108
+ }
109
+
110
+ return Object.freeze(
111
+ [...changes].map(([key, routeChanges]) => {
112
+ const first = routeChanges[0]
113
+ if (first === undefined) throw new Error("contract proof: missing route change")
114
+ const assuredRoute = assured.get(key)
115
+ const capableRoute = capable.get(key)
116
+ return {
117
+ method: first.method,
118
+ path: first.path,
119
+ changes: Object.freeze([...routeChanges]),
120
+ ...(assurance === undefined
121
+ ? {}
122
+ : {
123
+ assurance: {
124
+ ...(assuredRoute === undefined ? {} : { route: assuredRoute }),
125
+ findings: Object.freeze([...(assuranceFindings.get(key) ?? [])]),
126
+ },
127
+ }),
128
+ ...(capability === undefined
129
+ ? {}
130
+ : {
131
+ capability: {
132
+ ...(capableRoute === undefined ? {} : { route: capableRoute }),
133
+ findings: Object.freeze([...(capabilityFindings.get(key) ?? [])]),
134
+ },
135
+ }),
136
+ }
137
+ }),
138
+ )
139
+ }
140
+
141
+ /** Compose route diff, route assurance, capability evidence, and an optional check result. */
142
+ export async function collectContractProof(
143
+ cwd: string,
144
+ options: ContractProofOptions = {},
145
+ ): Promise<ContractProofReport> {
146
+ const baselinePath = options.baselinePath ?? DEFAULT_SNAPSHOT_FILE
147
+ const resolvedBaseline = safeBaselinePath(cwd, baselinePath)
148
+ if (!existsSync(resolvedBaseline)) {
149
+ throw new Error(`contract proof baseline not found: ${resolvedBaseline}`)
150
+ }
151
+ const realRoot = realpathSync(resolve(cwd))
152
+ const realBaseline = realpathSync(resolvedBaseline)
153
+ if (realBaseline !== realRoot && !realBaseline.startsWith(`${realRoot}${sep}`)) {
154
+ throw new Error("contract proof baseline must stay inside the project directory")
155
+ }
156
+ const baseline = parseSnapshotFile(await Bun.file(resolvedBaseline).text(), baselinePath)
157
+ const current = await snapshotBackend(cwd)
158
+ const diff = diffRouteSnapshots(baseline.routes, current)
159
+ const verification = await collectProjectVerification(cwd, {
160
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
161
+ })
162
+ const proof: ContractProofVerification = {
163
+ assuranceConfigPresent: verification.assuranceConfigPresent,
164
+ ...(verification.configError === undefined
165
+ ? {}
166
+ : { configError: errorMessage(verification.configError) }),
167
+ ...(verification.routeAssurance === undefined
168
+ ? {}
169
+ : {
170
+ assurance: {
171
+ ok: verification.routeAssurance.ok,
172
+ routeCount: verification.routeAssurance.routes.length,
173
+ findings: verification.routeAssurance.findings,
174
+ },
175
+ }),
176
+ ...(verification.capability === undefined
177
+ ? {}
178
+ : {
179
+ capability: {
180
+ ok: verification.capability.report.ok,
181
+ routeCount: verification.capability.report.routes.length,
182
+ findings: verification.capability.report.findings,
183
+ },
184
+ }),
185
+ ...(options.check === true ? { check: await verification.check() } : {}),
186
+ }
187
+ return Object.freeze({
188
+ baseline: baselinePath,
189
+ hasBreaking: diff.hasBreaking,
190
+ changes: diff.changes,
191
+ routes: changedRoutes(diff, verification.routeAssurance, verification.capability?.report),
192
+ verification: proof,
193
+ })
194
+ }
@@ -0,0 +1,157 @@
1
+ import { existsSync } from "node:fs"
2
+ import { resolve } from "node:path"
3
+ import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
4
+
5
+ export interface ContractDigest {
6
+ readonly request: string
7
+ readonly response: string
8
+ }
9
+
10
+ export interface ContractsLock {
11
+ readonly version: 1
12
+ readonly routes: Readonly<Record<string, ContractDigest>>
13
+ }
14
+
15
+ export const DEFAULT_CONTRACTS_LOCK = "contracts.lock.json"
16
+ const DIGEST = /^[a-f0-9]{64}$/
17
+
18
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value)
20
+ ? Object.fromEntries(Object.entries(value))
21
+ : undefined
22
+ }
23
+
24
+ function canonical(value: unknown, key?: string): unknown {
25
+ if (["description", "title", "default", "example", "examples"].includes(key ?? ""))
26
+ return undefined
27
+ if (Array.isArray(value)) return value.map((item) => canonical(item))
28
+ const record = recordOf(value)
29
+ if (record === undefined) return value
30
+ const out: Record<string, unknown> = {}
31
+ for (const name of Object.keys(record).sort()) {
32
+ const item = canonical(record[name], name)
33
+ if (item !== undefined) out[name] = item
34
+ }
35
+ return out
36
+ }
37
+
38
+ async function sha256(value: string): Promise<string> {
39
+ const bytes = new TextEncoder().encode(value)
40
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
41
+ return [...digest].map((item) => item.toString(16).padStart(2, "0")).join("")
42
+ }
43
+
44
+ function schemaValue(route: ReflectedRoute, side: "request" | "response"): unknown {
45
+ const schema = route.schema
46
+ if (schema === undefined) return {}
47
+ if (side === "request") {
48
+ return {
49
+ body: schema.body?.jsonSchema ?? schema.body?.fields ?? null,
50
+ query: schema.query?.jsonSchema ?? schema.query?.fields ?? null,
51
+ params: schema.params?.jsonSchema ?? schema.params?.fields ?? null,
52
+ }
53
+ }
54
+ return {
55
+ response: schema.response?.jsonSchema ?? schema.response?.fields ?? null,
56
+ sse: schema.sse?.jsonSchema ?? schema.sse?.fields ?? null,
57
+ errors: Object.fromEntries(
58
+ Object.entries(schema.errors ?? {}).map(([status, value]) => [
59
+ status,
60
+ value.jsonSchema ?? value.fields ?? null,
61
+ ]),
62
+ ),
63
+ }
64
+ }
65
+
66
+ export async function digestRoute(route: ReflectedRoute): Promise<ContractDigest> {
67
+ const request = JSON.stringify(canonical(schemaValue(route, "request")))
68
+ const response = JSON.stringify(canonical(schemaValue(route, "response")))
69
+ return { request: await sha256(request), response: await sha256(response) }
70
+ }
71
+
72
+ export async function buildContractsLock(source: unknown): Promise<ContractsLock> {
73
+ const routes = Object.fromEntries(
74
+ await Promise.all(
75
+ reflectRoutes(source).map(
76
+ async (route) => [`${route.method} ${route.path}`, await digestRoute(route)] as const,
77
+ ),
78
+ ),
79
+ )
80
+ return {
81
+ version: 1,
82
+ routes: Object.fromEntries(Object.entries(routes).sort(([a], [b]) => a.localeCompare(b))),
83
+ }
84
+ }
85
+
86
+ async function loadBackend(cwd: string): Promise<unknown> {
87
+ const backendPath = resolve(cwd, "backend.ts")
88
+ if (!existsSync(backendPath)) throw new Error(`[nifra] no backend.ts in ${cwd}`)
89
+ const loadedValue: unknown = await import(backendPath)
90
+ const loaded = recordOf(loadedValue)
91
+ if (loaded?.backend === undefined)
92
+ throw new Error(`[nifra] ${backendPath} does not export backend`)
93
+ return loaded.backend
94
+ }
95
+
96
+ export async function snapshotContracts(
97
+ cwd: string,
98
+ out = DEFAULT_CONTRACTS_LOCK,
99
+ ): Promise<ContractsLock> {
100
+ const lock = await buildContractsLock(await loadBackend(cwd))
101
+ await Bun.write(resolve(cwd, out), `${JSON.stringify(lock, null, 2)}\n`)
102
+ return lock
103
+ }
104
+
105
+ export function parseContractsLock(value: unknown): ContractsLock {
106
+ const record = recordOf(value)
107
+ if (record === undefined) throw new TypeError("contracts lock must be an object")
108
+ const routesValue = record.routes
109
+ const routesRecord = recordOf(routesValue)
110
+ if (record.version !== 1 || routesRecord === undefined)
111
+ throw new TypeError("contracts lock must have version 1 and routes")
112
+ const routes: Record<string, ContractDigest> = {}
113
+ for (const [key, raw] of Object.entries(routesRecord)) {
114
+ const item = recordOf(raw)
115
+ const request = item?.request
116
+ const response = item?.response
117
+ if (
118
+ request === undefined ||
119
+ typeof request !== "string" ||
120
+ response === undefined ||
121
+ typeof response !== "string" ||
122
+ !DIGEST.test(request) ||
123
+ !DIGEST.test(response)
124
+ )
125
+ throw new TypeError(`invalid contract ${key}`)
126
+ routes[key] = { request, response }
127
+ }
128
+ return { version: 1, routes }
129
+ }
130
+
131
+ export async function checkContractsLock(
132
+ cwd: string,
133
+ file = DEFAULT_CONTRACTS_LOCK,
134
+ ): Promise<{ present: boolean; diagnostics: Array<{ route?: string; message: string }> }> {
135
+ const path = resolve(cwd, file)
136
+ if (!existsSync(path)) return { present: false, diagnostics: [] }
137
+ const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
138
+ const current = await buildContractsLock(await loadBackend(cwd))
139
+ const diagnostics: Array<{ route?: string; message: string }> = []
140
+ const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
141
+ for (const key of [...keys].sort()) {
142
+ const before = parsed.routes[key]
143
+ const after = current.routes[key]
144
+ if (
145
+ before === undefined ||
146
+ after === undefined ||
147
+ before.request !== after.request ||
148
+ before.response !== after.response
149
+ ) {
150
+ diagnostics.push({
151
+ route: key,
152
+ message: `contract changed for ${key}; if intentional run \`nifra contracts snapshot\` and include the lock update in the same change.`,
153
+ })
154
+ }
155
+ }
156
+ return { present: true, diagnostics }
157
+ }
@@ -7,10 +7,15 @@
7
7
  * would ship to the browser WHOLE (body, DB handles, secrets) instead of as the RPC stub the
8
8
  * production client build emits, and a `*.server` module would ship instead of being emptied.
9
9
  *
10
+ * The same channel is the only way an app's OWN `clientPlugins` (SFC compilers and the like) can reach
11
+ * that bundler: the CLI holds them as plugin OBJECTS, and bunfig accepts only module paths - so the
12
+ * generated module re-imports the app's config and composes them in.
13
+ *
10
14
  * So the CLI generates, under `.nifra/dev-bun/`:
11
15
  * - `boundary-plugin.ts` - default-exports one plugin composing the SAME `serverFnStubPlugin` +
12
- * `serverOnlyEmptyPlugin` the production build uses (imported from `@nifrajs/web/build`, resolved
13
- * from the app - identical stubs, not a re-implementation);
16
+ * `serverOnlyEmptyPlugin` + CSS Modules transform the production build uses (imported from
17
+ * `@nifrajs/web`, resolved from the app - identical transforms, not re-implementations), plus the
18
+ * app's own `clientPlugins`;
14
19
  * - `bunfig.toml` - the app's ENTIRE bunfig carried over verbatim (jsx, loaders, defines, install
15
20
  * settings - dropping any of them would give dev a different Bun than the app configured), with
16
21
  * `[serve.static] plugins` merged to put the boundary plugin first and path-bearing entries
@@ -34,7 +39,7 @@
34
39
  */
35
40
  import { timingSafeEqual } from "node:crypto"
36
41
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
37
- import { resolve } from "node:path"
42
+ import { relative, resolve } from "node:path"
38
43
 
39
44
  /** TOML string literal: basic (double-quoted) form with the two escapes it needs. */
40
45
  function tomlString(value: string): string {
@@ -132,22 +137,56 @@ export function renderDevBunfig(
132
137
  )
133
138
  }
134
139
 
135
- /** The generated plugin module: compose the two production boundary plugins into one default
136
- * export. Import specifiers are bare so they resolve from the APP's install (the module lives
137
- * under the app's `.nifra/`), keeping dev stubs byte-identical to production's. */
138
- export function renderBoundaryPluginModule(): string {
139
- return `// Generated by \`nifra dev --bun\` - do not edit. Regenerated on every dev start.
140
- import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
140
+ /** A TS/JS import specifier for `target` as seen from `fromDir`, always `./`-prefixed and posix-slashed
141
+ * so it reads as a FILE path to the module loader rather than a package name. */
142
+ function relativeSpecifier(fromDir: string, target: string): string {
143
+ const rel = relative(fromDir, target).replaceAll("\\", "/")
144
+ return rel.startsWith(".") ? rel : `./${rel}`
145
+ }
141
146
 
147
+ /**
148
+ * The generated plugin module: compose the production boundary plugins, the production CSS Modules
149
+ * transform, and the APP's own `clientPlugins` into one default export.
150
+ *
151
+ * The `@nifrajs/*` specifiers are bare so they resolve from the APP's install (the module lives under
152
+ * the app's `.nifra/`), keeping dev transforms byte-identical to production's.
153
+ *
154
+ * `configPath` is the app's `nifra.config.ts` (or `framework.ts`). Importing it here is the only way an
155
+ * app's `clientPlugins` can reach Bun's dev-server bundler at all: that bundler takes plugins as module
156
+ * PATHS via bunfig, never as the plugin objects the CLI already holds in memory. Without this an app
157
+ * whose transforms live in `clientPlugins` had no working Bun dev loop - its client would be bundled
158
+ * with the transform silently missing. `setup` is async because `clientPlugins` may be a thunk.
159
+ */
160
+ export function renderBoundaryPluginModule(configPath?: string, pluginDir?: string): string {
161
+ const appImport =
162
+ configPath !== undefined && pluginDir !== undefined
163
+ ? `import * as appConfig from ${tomlString(relativeSpecifier(pluginDir, configPath))}\n`
164
+ : ""
165
+ const appSetup =
166
+ appImport === ""
167
+ ? ""
168
+ : ` // The app's OWN client transforms (SFC compilers etc.). \`clientPlugins\` may be a thunk.
169
+ const field = appConfig.clientPlugins
170
+ const appPlugins = (typeof field === "function" ? await field() : field) ?? []
171
+ for (const p of appPlugins) await p.setup(build)
172
+ `
173
+ return `// Generated by \`nifra dev\` (Bun pipeline) - do not edit. Regenerated on every dev start.
174
+ import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
175
+ import { cssModulesBunPlugin } from "@nifrajs/web/plugins/css-modules"
176
+ ${appImport}
142
177
  const fn = serverFnStubPlugin()
143
178
  const serverOnly = serverOnlyEmptyPlugin()
179
+ const cssModules = cssModulesBunPlugin("dom")
144
180
 
145
181
  export default {
146
182
  name: "nifra-dev-boundary",
147
- setup(build) {
183
+ async setup(build) {
184
+ // Boundary first: an app plugin must never get to transform a module that the boundary is about to
185
+ // empty or stub, or the transform decides what ships instead of the boundary.
148
186
  serverOnly.setup(build)
149
187
  fn.setup(build)
150
- },
188
+ cssModules.setup(build)
189
+ ${appSetup} },
151
190
  }
152
191
  `
153
192
  }
@@ -161,12 +200,16 @@ export interface BunDevConfig {
161
200
  const TOKEN_FILE = "launch-token"
162
201
 
163
202
  /** Write the generated config for an app; returns the bunfig path to re-exec with and the fresh
164
- * launch token to pass in the child's environment. */
165
- export async function writeBunDevConfig(appRoot: string): Promise<BunDevConfig> {
203
+ * launch token to pass in the child's environment. `configPath` (the app's `nifra.config.ts` /
204
+ * `framework.ts`) is what lets the app's own `clientPlugins` reach Bun's dev-server bundler. */
205
+ export async function writeBunDevConfig(
206
+ appRoot: string,
207
+ configPath?: string,
208
+ ): Promise<BunDevConfig> {
166
209
  const dir = resolve(appRoot, ".nifra", "dev-bun")
167
210
  mkdirSync(dir, { recursive: true })
168
211
  const pluginPath = resolve(dir, "boundary-plugin.ts")
169
- writeFileSync(pluginPath, renderBoundaryPluginModule())
212
+ writeFileSync(pluginPath, renderBoundaryPluginModule(configPath, dir))
170
213
  const userToml = await Bun.file(resolve(appRoot, "bunfig.toml"))
171
214
  .text()
172
215
  .catch(() => undefined)
@@ -0,0 +1,32 @@
1
+ /** Stable, machine-actionable diagnostics shared by CLI gates and MCP renderers. */
2
+
3
+ export type Severity = "error" | "warn" | "info"
4
+
5
+ export interface DiagnosticFix {
6
+ readonly recipe: string
7
+ readonly command?: string
8
+ }
9
+
10
+ export interface Diagnostic {
11
+ /** Stable forever. Never renumber or reuse. */
12
+ readonly code: string
13
+ readonly severity: Severity
14
+ readonly message: string
15
+ readonly file?: string
16
+ readonly line?: number
17
+ /** Short evidence strings that help an agent act without exposing payloads. */
18
+ readonly evidence?: readonly string[]
19
+ readonly fix?: DiagnosticFix
20
+ /** Command that proves the fix worked. */
21
+ readonly verify?: string
22
+ }
23
+
24
+ export const diagnostic = (value: Diagnostic): Diagnostic => Object.freeze(value)
25
+
26
+ export function severityFails(severity: Severity, strict = false): boolean {
27
+ return severity === "error" || (strict && severity === "warn")
28
+ }
29
+
30
+ export function normalizeSeverity(value: "error" | "warning" | "warn" | "info"): Severity {
31
+ return value === "warning" ? "warn" : value
32
+ }
package/src/diff-tool.ts CHANGED
@@ -82,7 +82,7 @@ export function formatDiff(diff: RoutesDiff): string {
82
82
  }
83
83
 
84
84
  /** Import `backend.ts` from `cwd` and snapshot its routes. */
85
- async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
85
+ export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
86
86
  const backendPath = resolve(cwd, "backend.ts")
87
87
  if (!existsSync(backendPath)) {
88
88
  throw new Error(
@@ -17,6 +17,13 @@ export interface DocSection {
17
17
  readonly body: string
18
18
  }
19
19
 
20
+ export interface PreparedDocSection extends DocSection {
21
+ readonly headingTokens: ReadonlySet<string>
22
+ readonly bodyLower: string
23
+ }
24
+
25
+ const sectionCache = new Map<string, readonly PreparedDocSection[]>()
26
+
20
27
  /** Split the generated doc into its `##` sections (the `# title` intro becomes "Overview"). */
21
28
  export function splitSections(markdown: string): DocSection[] {
22
29
  const out: DocSection[] = []
@@ -40,6 +47,23 @@ export function splitSections(markdown: string): DocSection[] {
40
47
  return out
41
48
  }
42
49
 
50
+ function prepareSection(section: DocSection): PreparedDocSection {
51
+ return {
52
+ ...section,
53
+ headingTokens: new Set(tokenize(section.heading)),
54
+ bodyLower: section.body.toLowerCase(),
55
+ }
56
+ }
57
+
58
+ /** Return the immutable-build section parse and derived search fields from the process cache. */
59
+ export function getCachedDocSections(markdown: string): readonly PreparedDocSection[] {
60
+ const cached = sectionCache.get(markdown)
61
+ if (cached !== undefined) return cached
62
+ const prepared = splitSections(markdown).map(prepareSection)
63
+ sectionCache.set(markdown, prepared)
64
+ return prepared
65
+ }
66
+
43
67
  export interface ScoredSection extends DocSection {
44
68
  readonly score: number
45
69
  }
@@ -48,7 +72,7 @@ export interface ScoredSection extends DocSection {
48
72
  * (headings are curated names for exactly these lookups); body occurrences cap per term so one
49
73
  * spammy section can't drown a precisely-titled one. */
50
74
  export function searchSections(
51
- sections: readonly DocSection[],
75
+ sections: readonly (DocSection | PreparedDocSection)[],
52
76
  query: string,
53
77
  limit: number,
54
78
  ): ScoredSection[] {
@@ -56,14 +80,14 @@ export function searchSections(
56
80
  if (terms.length === 0) return []
57
81
  const scored: ScoredSection[] = []
58
82
  for (const section of sections) {
59
- const headingTokens = new Set(tokenize(section.heading))
60
- const bodyLower = section.body.toLowerCase()
83
+ const prepared =
84
+ "headingTokens" in section && "bodyLower" in section ? section : prepareSection(section)
61
85
  let score = 0
62
86
  for (const term of terms) {
63
- if (tokenSetHas(headingTokens, term)) score += 8
64
- score += countBodyHits(bodyLower, term, 5)
87
+ if (tokenSetHas(prepared.headingTokens, term)) score += 8
88
+ score += countBodyHits(prepared.bodyLower, term, 5)
65
89
  }
66
- if (score > 0) scored.push({ ...section, score })
90
+ if (score > 0) scored.push({ heading: section.heading, body: section.body, score })
67
91
  }
68
92
  return scored.sort((a, b) => b.score - a.score).slice(0, limit)
69
93
  }
@@ -78,7 +102,7 @@ export function renderDocsResult(
78
102
  query: string | undefined,
79
103
  limit: number,
80
104
  ): string {
81
- const sections = splitSections(markdown)
105
+ const sections = getCachedDocSections(markdown)
82
106
  if (query === undefined || query.trim() === "") {
83
107
  const index = sections.map((s) => `- ${s.heading}`).join("\n")
84
108
  return `# nifra docs - section index (pass \`query\` to fetch matching sections)\n\n${index}`
@@ -100,12 +124,18 @@ export function renderDocsResult(
100
124
 
101
125
  /** Load the bundled corpus. Resolves relative to this module so it works from `src/` (repo dev)
102
126
  * and `dist/` (published) alike - `docs/llms-full.txt` sits at the package root next to both. */
103
- export async function loadDocsCorpus(): Promise<string | undefined> {
104
- const url = new URL("../docs/llms-full.txt", import.meta.url)
105
- try {
106
- const text = await Bun.file(url).text()
107
- return text.length > 0 ? text : undefined
108
- } catch {
109
- return undefined
110
- }
127
+ let docsCorpusPromise: Promise<string | undefined> | undefined
128
+
129
+ /** The published corpus is immutable, so one read and one parsed-section cache are sufficient. */
130
+ export function loadDocsCorpus(): Promise<string | undefined> {
131
+ docsCorpusPromise ??= (async () => {
132
+ const url = new URL("../docs/llms-full.txt", import.meta.url)
133
+ try {
134
+ const text = await Bun.file(url).text()
135
+ return text.length > 0 ? text : undefined
136
+ } catch {
137
+ return undefined
138
+ }
139
+ })()
140
+ return docsCorpusPromise
111
141
  }