@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,613 @@
1
+ /**
2
+ * Static verification work graph for agents.
3
+ *
4
+ * The graph is intentionally a compact, token-only description of project structure. It connects
5
+ * reflected routes to schemas, capabilities, assurance evidence, source/test files, and static
6
+ * manifests. It never probes a running application and never persists payloads.
7
+ */
8
+
9
+ import { existsSync } from "node:fs"
10
+ import { readFile, stat } from "node:fs/promises"
11
+ import { basename, resolve } from "node:path"
12
+ import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
13
+ import { Glob } from "bun"
14
+ import { digestRoute } from "./contracts.ts"
15
+
16
+ export type WorkGraphNodeKind =
17
+ | "route"
18
+ | "schema"
19
+ | "capability"
20
+ | "assurance"
21
+ | "test"
22
+ | "file"
23
+ | "manifest"
24
+
25
+ export interface WorkGraphNode {
26
+ readonly id: string
27
+ readonly kind: WorkGraphNodeKind
28
+ readonly label: string
29
+ readonly digest: string
30
+ readonly files: readonly string[]
31
+ }
32
+
33
+ export interface WorkGraphEdge {
34
+ readonly from: string
35
+ readonly to: string
36
+ readonly relation: "contains" | "declares" | "enforces" | "implements" | "tests" | "describes"
37
+ }
38
+
39
+ export interface WorkGraph {
40
+ readonly version: 1
41
+ readonly digest: string
42
+ readonly nodes: readonly WorkGraphNode[]
43
+ readonly edges: readonly WorkGraphEdge[]
44
+ readonly freshness: BuildFreshness
45
+ }
46
+
47
+ export interface WorkGraphSourceFile {
48
+ readonly path: string
49
+ readonly content: string
50
+ readonly kind?: "source" | "test" | "manifest"
51
+ }
52
+
53
+ export interface WorkGraphBuildInput {
54
+ readonly source: unknown
55
+ readonly files?: readonly WorkGraphSourceFile[]
56
+ readonly freshness?: BuildFreshness
57
+ }
58
+
59
+ export interface BuildFreshness {
60
+ readonly ok: boolean
61
+ readonly buildDir?: string
62
+ readonly newestSourceMs?: number
63
+ readonly newestBuildMs?: number
64
+ readonly reason?: string
65
+ }
66
+
67
+ export class StaleBuildError extends Error {
68
+ constructor(readonly freshness: BuildFreshness) {
69
+ super(freshness.reason ?? "nifra work graph requires a fresh build")
70
+ this.name = "StaleBuildError"
71
+ }
72
+ }
73
+
74
+ export interface ImpactReport {
75
+ readonly changedFiles: readonly string[]
76
+ readonly impactedNodes: readonly string[]
77
+ readonly impactedRoutes: readonly string[]
78
+ }
79
+
80
+ export type ProofKind = "typecheck" | "contract" | "assurance" | "suite"
81
+
82
+ export interface ProofStep {
83
+ readonly id: string
84
+ readonly kind: ProofKind
85
+ readonly level: number
86
+ readonly cost: number
87
+ readonly command: string
88
+ readonly nodeIds: readonly string[]
89
+ readonly fix?: string
90
+ }
91
+
92
+ export interface ProofPlan {
93
+ readonly steps: readonly ProofStep[]
94
+ readonly targetLevel: number
95
+ }
96
+
97
+ export type ProofStatus = "pass" | "fail" | "skip" | "pending"
98
+
99
+ export interface ProofEvidence {
100
+ readonly id: string
101
+ readonly status: ProofStatus
102
+ readonly level: number
103
+ readonly nodeIds: readonly string[]
104
+ readonly command: string
105
+ readonly fix?: string
106
+ }
107
+
108
+ export interface EvidenceBundle {
109
+ readonly version: 1
110
+ readonly graphDigest: string
111
+ readonly changedFiles: readonly string[]
112
+ readonly targetLevel: number
113
+ readonly plan: ProofPlan
114
+ readonly proofs: readonly ProofEvidence[]
115
+ readonly stop: {
116
+ readonly done: boolean
117
+ readonly missing: readonly string[]
118
+ readonly next?: ProofStep
119
+ }
120
+ }
121
+
122
+ export interface ProjectWorkGraphResult {
123
+ readonly graph: WorkGraph
124
+ readonly impact: ImpactReport
125
+ readonly plan: ProofPlan
126
+ readonly evidence: EvidenceBundle
127
+ }
128
+
129
+ const SOURCE_GLOB = new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}")
130
+ const IGNORED = /(^|\/)(node_modules|dist|build|\.nifra|\.git|\.wrangler|coverage)\//
131
+ const MANIFEST_NAMES = new Set([
132
+ "server-manifest.ts",
133
+ "nifra-manifest.json",
134
+ "nifra-manifest.ts",
135
+ "manifest.json",
136
+ ])
137
+
138
+ export function evaluateBuildFreshness(input: {
139
+ readonly hasArtifact: boolean
140
+ readonly newestSourceMs: number
141
+ readonly newestBuildMs: number
142
+ readonly buildDir?: string
143
+ }): BuildFreshness {
144
+ if (!input.hasArtifact) {
145
+ return {
146
+ ok: false,
147
+ ...(input.buildDir === undefined ? {} : { buildDir: input.buildDir }),
148
+ newestSourceMs: input.newestSourceMs,
149
+ newestBuildMs: input.newestBuildMs,
150
+ reason: "nifra work graph refused: no built artifact found; run `nifra build` first",
151
+ }
152
+ }
153
+ if (input.newestSourceMs >= input.newestBuildMs) {
154
+ return {
155
+ ok: false,
156
+ ...(input.buildDir === undefined ? {} : { buildDir: input.buildDir }),
157
+ newestSourceMs: input.newestSourceMs,
158
+ newestBuildMs: input.newestBuildMs,
159
+ reason: "nifra work graph refused: the build is stale; run `nifra build` before graphing",
160
+ }
161
+ }
162
+ return {
163
+ ok: true,
164
+ ...(input.buildDir === undefined ? {} : { buildDir: input.buildDir }),
165
+ newestSourceMs: input.newestSourceMs,
166
+ newestBuildMs: input.newestBuildMs,
167
+ }
168
+ }
169
+
170
+ export async function inspectBuildFreshness(cwd: string): Promise<BuildFreshness> {
171
+ const sourcePaths: string[] = []
172
+ for (const name of ["backend.ts", "framework.ts", "nifra.config.ts"]) {
173
+ const path = resolve(cwd, name)
174
+ if (existsSync(path)) sourcePaths.push(path)
175
+ }
176
+ for await (const path of SOURCE_GLOB.scan({ cwd, dot: false })) {
177
+ if (!IGNORED.test(path) && path.startsWith("routes/")) sourcePaths.push(resolve(cwd, path))
178
+ }
179
+ const sourceTimes = await fileTimes(sourcePaths)
180
+ for (const buildDirName of ["dist", "build"]) {
181
+ const buildDir = resolve(cwd, buildDirName)
182
+ if (!existsSync(buildDir)) continue
183
+ const artifacts: string[] = []
184
+ for await (const path of new Glob("**/*.{js,mjs,cjs}").scan({ cwd: buildDir, dot: false })) {
185
+ artifacts.push(resolve(buildDir, path))
186
+ }
187
+ if (artifacts.length === 0) continue
188
+ const buildTimes = await fileTimes(artifacts)
189
+ return evaluateBuildFreshness({
190
+ hasArtifact: true,
191
+ newestSourceMs: sourceTimes,
192
+ newestBuildMs: buildTimes,
193
+ buildDir: buildDirName,
194
+ })
195
+ }
196
+ return evaluateBuildFreshness({
197
+ hasArtifact: false,
198
+ newestSourceMs: sourceTimes,
199
+ newestBuildMs: 0,
200
+ })
201
+ }
202
+
203
+ export async function collectProjectWorkGraph(
204
+ cwd: string,
205
+ options: { readonly changedFiles?: readonly string[]; readonly minLevel?: number } = {},
206
+ ): Promise<ProjectWorkGraphResult> {
207
+ const freshness = await inspectBuildFreshness(cwd)
208
+ if (!freshness.ok) throw new StaleBuildError(freshness)
209
+ const backendPath = resolve(cwd, "backend.ts")
210
+ if (!existsSync(backendPath)) throw new Error(`[nifra] no backend.ts in ${cwd}`)
211
+ const loadedValue: unknown = await import(`${backendPath}?nifra-work-graph=${Date.now()}`)
212
+ const loaded = recordOf(loadedValue)
213
+ if (loaded?.backend === undefined)
214
+ throw new Error(`[nifra] ${backendPath} does not export backend`)
215
+ const files = await collectProjectFiles(cwd)
216
+ return buildProjectWorkGraph({ source: loaded.backend, files, freshness }, options)
217
+ }
218
+
219
+ export async function buildProjectWorkGraph(
220
+ input: WorkGraphBuildInput,
221
+ options: { readonly changedFiles?: readonly string[]; readonly minLevel?: number } = {},
222
+ ): Promise<ProjectWorkGraphResult> {
223
+ const freshness = input.freshness ?? { ok: true }
224
+ if (!freshness.ok) throw new StaleBuildError(freshness)
225
+ const graph = await buildWorkGraph(input)
226
+ const impact = queryImpact(graph, options.changedFiles ?? [])
227
+ const plan = planProofs(graph, impact, options.minLevel ?? 1)
228
+ const evidence = createEvidenceBundle(graph, impact, plan)
229
+ return Object.freeze({ graph, impact, plan, evidence })
230
+ }
231
+
232
+ export async function buildWorkGraph(input: WorkGraphBuildInput): Promise<WorkGraph> {
233
+ const routes = reflectRoutes(input.source)
234
+ const files = input.files ?? []
235
+ const nodes = new Map<string, WorkGraphNode>()
236
+ const edges: WorkGraphEdge[] = []
237
+ const addNode = (node: WorkGraphNode): void => {
238
+ if (!nodes.has(node.id)) nodes.set(node.id, node)
239
+ }
240
+ const addEdge = (edge: WorkGraphEdge): void => {
241
+ if (
242
+ !edges.some(
243
+ (item) => item.from === edge.from && item.to === edge.to && item.relation === edge.relation,
244
+ )
245
+ )
246
+ edges.push(edge)
247
+ }
248
+ for (const file of files) {
249
+ const kind =
250
+ file.kind ??
251
+ (isTestFile(file.path)
252
+ ? "test"
253
+ : MANIFEST_NAMES.has(basename(file.path))
254
+ ? "manifest"
255
+ : "source")
256
+ if (kind === "source") addNode(await fileNode(file, "file"))
257
+ if (kind === "test") addNode(await fileNode(file, "test"))
258
+ if (kind === "manifest") addNode(await fileNode(file, "manifest"))
259
+ }
260
+ const sourceFiles = files.filter((file) => (file.kind ?? "source") === "source")
261
+ const testFiles = files.filter(
262
+ (file) => (file.kind ?? "source") === "test" || isTestFile(file.path),
263
+ )
264
+ const manifestFiles = files.filter(
265
+ (file) => (file.kind ?? "source") === "manifest" || MANIFEST_NAMES.has(basename(file.path)),
266
+ )
267
+ for (const route of routes) {
268
+ const routeKey = `${route.method} ${route.path}`
269
+ const routeId = `route:${routeKey}`
270
+ const routeDigest = await digestRoute(route)
271
+ const routeFiles = filesForRoute(route, sourceFiles)
272
+ addNode({
273
+ id: routeId,
274
+ kind: "route",
275
+ label: routeKey,
276
+ digest: await digestJson({ route: routeKey, routeDigest }),
277
+ files: routeFiles,
278
+ })
279
+ for (const file of routeFiles)
280
+ addEdge({ from: `file:${file}`, to: routeId, relation: "implements" })
281
+ if (routeFiles.length === 0 && sourceFiles.some((file) => file.path === "backend.ts"))
282
+ addEdge({ from: "file:backend.ts", to: routeId, relation: "implements" })
283
+ for (const [name, schema] of Object.entries(route.schema ?? {})) {
284
+ if (schema === undefined || name === "errors") continue
285
+ const schemaId = `schema:${routeKey}:${name}`
286
+ addNode({
287
+ id: schemaId,
288
+ kind: "schema",
289
+ label: `${routeKey} ${name}`,
290
+ digest: await digestJson(schema.jsonSchema ?? schema.fields ?? null),
291
+ files: routeFiles,
292
+ })
293
+ addEdge({ from: routeId, to: schemaId, relation: "contains" })
294
+ }
295
+ for (const capability of route.capabilities ?? []) {
296
+ const capabilityId = `capability:${capability}`
297
+ addNode({
298
+ id: capabilityId,
299
+ kind: "capability",
300
+ label: capability,
301
+ digest: await digestJson(capability),
302
+ files: routeFiles,
303
+ })
304
+ addEdge({ from: routeId, to: capabilityId, relation: "declares" })
305
+ }
306
+ for (const assurance of route.assurance ?? []) {
307
+ const assuranceId = `assurance:${routeKey}:${assurance.id}`
308
+ addNode({
309
+ id: assuranceId,
310
+ kind: "assurance",
311
+ label: `${routeKey} ${assurance.id}`,
312
+ digest: await digestJson(assurance),
313
+ files: routeFiles,
314
+ })
315
+ addEdge({ from: routeId, to: assuranceId, relation: "enforces" })
316
+ }
317
+ for (const file of testFiles) {
318
+ if (!mentionsRoute(file.content, route)) continue
319
+ const testId = `test:${file.path}`
320
+ addEdge({ from: testId, to: routeId, relation: "tests" })
321
+ }
322
+ for (const file of manifestFiles)
323
+ addEdge({ from: `manifest:${file.path}`, to: routeId, relation: "describes" })
324
+ }
325
+ const sortedNodes = [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id))
326
+ const sortedEdges = edges.sort((a, b) =>
327
+ `${a.from}\n${a.to}\n${a.relation}`.localeCompare(`${b.from}\n${b.to}\n${b.relation}`),
328
+ )
329
+ const digest = await digestJson({ nodes: sortedNodes, edges: sortedEdges })
330
+ return Object.freeze({
331
+ version: 1,
332
+ digest,
333
+ nodes: Object.freeze(sortedNodes),
334
+ edges: Object.freeze(sortedEdges),
335
+ freshness: input.freshness ?? { ok: true },
336
+ })
337
+ }
338
+
339
+ export function queryImpact(graph: WorkGraph, changedFiles: readonly string[]): ImpactReport {
340
+ const normalized = [
341
+ ...new Set(changedFiles.map((file) => file.replace(/^\.\//, "").replaceAll("\\", "/"))),
342
+ ]
343
+ const seeds = new Set<string>()
344
+ for (const file of normalized) {
345
+ for (const node of graph.nodes) {
346
+ if (
347
+ (node.kind === "file" || node.kind === "test" || node.kind === "manifest") &&
348
+ (node.id === `file:${file}` ||
349
+ node.id === `test:${file}` ||
350
+ node.id === `manifest:${file}` ||
351
+ node.files.includes(file))
352
+ )
353
+ seeds.add(node.id)
354
+ }
355
+ }
356
+ if (normalized.includes("backend.ts"))
357
+ for (const node of graph.nodes) if (node.kind === "route") seeds.add(node.id)
358
+ const impacted = new Set(seeds)
359
+ const queue = [...seeds]
360
+ while (queue.length > 0) {
361
+ const current = queue.shift()!
362
+ for (const edge of graph.edges) {
363
+ if (edge.from !== current || impacted.has(edge.to)) continue
364
+ impacted.add(edge.to)
365
+ queue.push(edge.to)
366
+ }
367
+ }
368
+ const impactedRoutes = graph.nodes
369
+ .filter((node) => node.kind === "route" && impacted.has(node.id))
370
+ .map((node) => node.label)
371
+ .sort()
372
+ return Object.freeze({
373
+ changedFiles: Object.freeze(normalized.sort()),
374
+ impactedNodes: Object.freeze([...impacted].sort()),
375
+ impactedRoutes: Object.freeze(impactedRoutes),
376
+ })
377
+ }
378
+
379
+ export function planProofs(graph: WorkGraph, impact: ImpactReport, targetLevel = 1): ProofPlan {
380
+ if (!Number.isInteger(targetLevel) || targetLevel < 0 || targetLevel > 4)
381
+ throw new RangeError("work graph: targetLevel must be an integer from 0 to 4")
382
+ const nodeIds = impact.impactedNodes
383
+ const routeIds = graph.nodes
384
+ .filter((node) => node.kind === "route" && nodeIds.includes(node.id))
385
+ .map((node) => node.id)
386
+ const steps: ProofStep[] = []
387
+ if (nodeIds.length > 0) {
388
+ steps.push({
389
+ id: "proof:typecheck",
390
+ kind: "typecheck",
391
+ level: 0,
392
+ cost: 1,
393
+ command: "nifra check --lints-only",
394
+ nodeIds,
395
+ })
396
+ steps.push({
397
+ id: "proof:contract",
398
+ kind: "contract",
399
+ level: 1,
400
+ cost: 2,
401
+ command: "nifra contracts check --json",
402
+ nodeIds: routeIds,
403
+ })
404
+ steps.push({
405
+ id: "proof:assurance",
406
+ kind: "assurance",
407
+ level: 2,
408
+ cost: 3,
409
+ command: "nifra assure --json --strict",
410
+ nodeIds: routeIds,
411
+ })
412
+ steps.push({
413
+ id: "proof:suite",
414
+ kind: "suite",
415
+ level: 3,
416
+ cost: 4,
417
+ command: "nifra test",
418
+ nodeIds,
419
+ })
420
+ }
421
+ return Object.freeze({
422
+ steps: Object.freeze(
423
+ steps.filter((step) => step.level <= Math.max(targetLevel, 0) || targetLevel > 3),
424
+ ),
425
+ targetLevel,
426
+ })
427
+ }
428
+
429
+ export function createEvidenceBundle(
430
+ graph: WorkGraph,
431
+ impact: ImpactReport,
432
+ plan: ProofPlan,
433
+ proofs: readonly ProofEvidence[] = [],
434
+ ): EvidenceBundle {
435
+ const evidence = [...proofs].sort((a, b) => a.id.localeCompare(b.id))
436
+ // Levels are not cumulative (assure does not typecheck), so every planned step needs its own
437
+ // passing proof; a higher-level pass never subsumes a lower one.
438
+ const done =
439
+ impact.impactedNodes.length === 0 ||
440
+ plan.steps.every((step) =>
441
+ evidence.some((item) => item.id === step.id && item.status === "pass"),
442
+ )
443
+ const missing = done
444
+ ? []
445
+ : plan.steps
446
+ .filter((step) => !evidence.some((item) => item.id === step.id && item.status === "pass"))
447
+ .map((step) => step.command)
448
+ const next = done
449
+ ? undefined
450
+ : plan.steps.find(
451
+ (step) => !evidence.some((item) => item.id === step.id && item.status === "pass"),
452
+ )
453
+ return Object.freeze({
454
+ version: 1,
455
+ graphDigest: graph.digest,
456
+ changedFiles: impact.changedFiles,
457
+ targetLevel: plan.targetLevel,
458
+ plan,
459
+ proofs: Object.freeze(evidence),
460
+ stop: Object.freeze({
461
+ done,
462
+ missing: Object.freeze(missing),
463
+ ...(next === undefined ? {} : { next }),
464
+ }),
465
+ })
466
+ }
467
+
468
+ export function recordProof(bundle: EvidenceBundle, proof: ProofEvidence): EvidenceBundle {
469
+ const graph: WorkGraph = {
470
+ version: 1,
471
+ digest: bundle.graphDigest,
472
+ nodes: [],
473
+ edges: [],
474
+ freshness: { ok: true },
475
+ }
476
+ const impact: ImpactReport = {
477
+ changedFiles: bundle.changedFiles,
478
+ impactedNodes: bundle.stop.done ? [] : ["pending"],
479
+ impactedRoutes: [],
480
+ }
481
+ return createEvidenceBundle(graph, impact, bundle.plan, [
482
+ ...bundle.proofs.filter((item) => item.id !== proof.id),
483
+ proof,
484
+ ])
485
+ }
486
+
487
+ export function renderWorkGraphJson(result: ProjectWorkGraphResult): string {
488
+ return JSON.stringify(result, null, 2)
489
+ }
490
+
491
+ export async function runWorkGraph(
492
+ cwd: string,
493
+ options: {
494
+ readonly changedFiles?: readonly string[]
495
+ readonly minLevel?: number
496
+ readonly json?: boolean
497
+ } = {},
498
+ ): Promise<boolean> {
499
+ const result = await collectProjectWorkGraph(cwd, options)
500
+ console.log(options.json === false ? renderWorkGraphText(result) : renderWorkGraphJson(result))
501
+ return result.evidence.stop.done
502
+ }
503
+
504
+ export function renderWorkGraphText(result: ProjectWorkGraphResult): string {
505
+ const lines = [
506
+ `work graph ${result.graph.digest.slice(0, 12)} (${result.graph.nodes.length} nodes, ${result.graph.edges.length} edges)`,
507
+ `changed: ${result.impact.changedFiles.length === 0 ? "none" : result.impact.changedFiles.join(", ")}`,
508
+ `impacted routes: ${result.impact.impactedRoutes.length === 0 ? "none" : result.impact.impactedRoutes.join(", ")}`,
509
+ `stop: ${result.evidence.stop.done ? "done" : "pending"}`,
510
+ ]
511
+ if (!result.evidence.stop.done)
512
+ lines.push(`next: ${result.evidence.stop.next?.command ?? "no proof available"}`)
513
+ return lines.join("\n")
514
+ }
515
+
516
+ async function collectProjectFiles(cwd: string): Promise<WorkGraphSourceFile[]> {
517
+ const files: WorkGraphSourceFile[] = []
518
+ for await (const path of SOURCE_GLOB.scan({ cwd, dot: false })) {
519
+ if (IGNORED.test(path)) continue
520
+ const full = resolve(cwd, path)
521
+ try {
522
+ const content = await readFile(full, "utf8")
523
+ files.push({
524
+ path,
525
+ content,
526
+ ...(isTestFile(path)
527
+ ? { kind: "test" as const }
528
+ : MANIFEST_NAMES.has(basename(path))
529
+ ? { kind: "manifest" as const }
530
+ : {}),
531
+ })
532
+ } catch {
533
+ // A file can disappear while an agent is editing. The next graph run sees the settled tree.
534
+ }
535
+ }
536
+ return files
537
+ }
538
+
539
+ async function fileTimes(paths: readonly string[]): Promise<number> {
540
+ let newest = 0
541
+ for (const path of paths) {
542
+ try {
543
+ newest = Math.max(newest, (await stat(path)).mtimeMs)
544
+ } catch {
545
+ // Missing source files are simply absent from the timestamp scan.
546
+ }
547
+ }
548
+ return newest
549
+ }
550
+
551
+ async function fileNode(
552
+ file: WorkGraphSourceFile,
553
+ kind: "file" | "test" | "manifest",
554
+ ): Promise<WorkGraphNode> {
555
+ return {
556
+ id: `${kind}:${file.path}`,
557
+ kind,
558
+ label: file.path,
559
+ digest: await digestJson(file.content),
560
+ files: [file.path],
561
+ }
562
+ }
563
+
564
+ function filesForRoute(
565
+ route: ReflectedRoute,
566
+ files: readonly WorkGraphSourceFile[],
567
+ ): readonly string[] {
568
+ const matches = files
569
+ .filter(
570
+ (file) =>
571
+ file.path === "backend.ts" ||
572
+ (file.path.startsWith("routes/") && file.content.includes(route.path)),
573
+ )
574
+ .map((file) => file.path)
575
+ return [...new Set(matches)].sort()
576
+ }
577
+
578
+ /**
579
+ * A missing edge silently drops a test from the proof plan, so matching is recall-first: a literal
580
+ * path mention, or the static prefix of a parameterised path (tests call "/orders/123", not
581
+ * "/orders/:id"). Bare method words are never a signal on their own - "get" appears everywhere.
582
+ */
583
+ function mentionsRoute(content: string, route: ReflectedRoute): boolean {
584
+ if (route.path === "/") return /["'`]\/["'`]/.test(content)
585
+ if (content.includes(route.path)) return true
586
+ const prefix = staticPathPrefix(route.path)
587
+ return prefix !== undefined && content.includes(prefix)
588
+ }
589
+
590
+ function staticPathPrefix(path: string): string | undefined {
591
+ const staticSegments: string[] = []
592
+ for (const segment of path.split("/")) {
593
+ if (segment.startsWith(":") || segment.startsWith("*")) break
594
+ if (segment !== "") staticSegments.push(segment)
595
+ }
596
+ return staticSegments.length === 0 ? undefined : `/${staticSegments.join("/")}/`
597
+ }
598
+
599
+ function isTestFile(path: string): boolean {
600
+ return /(^|\/)(test|tests|__tests__)(\/|$)|\.(test|spec)\.[^.]+$/.test(path)
601
+ }
602
+
603
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
604
+ return value !== null && typeof value === "object" && !Array.isArray(value)
605
+ ? Object.fromEntries(Object.entries(value))
606
+ : undefined
607
+ }
608
+
609
+ async function digestJson(value: unknown): Promise<string> {
610
+ const bytes = new TextEncoder().encode(JSON.stringify(value))
611
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
612
+ return [...digest].map((item) => item.toString(16).padStart(2, "0")).join("")
613
+ }