@nifrajs/cli 2.11.0 → 2.12.1
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.
- package/README.md +6 -0
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +125 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +706 -145
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +194 -10
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +28 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +173 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +53 -4
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +358 -25
- package/dist/mcp.js.map +1 -1
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +906 -15
- package/docs/types.json +1682 -130
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +873 -168
- package/src/cli.ts +198 -11
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +259 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +75 -6
- package/src/load.ts +6 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +438 -32
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- 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
|
+
}
|