@nifrajs/cli 2.11.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.
- 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
package/src/assure.ts
CHANGED
|
@@ -1,9 +1,59 @@
|
|
|
1
1
|
/** `nifra assure` - evaluate a project's reflected routes against `nifra.assurance.ts`. */
|
|
2
2
|
|
|
3
3
|
import { existsSync } from "node:fs"
|
|
4
|
-
import { resolve } from "node:path"
|
|
4
|
+
import { join, resolve } from "node:path"
|
|
5
5
|
import { pathToFileURL } from "node:url"
|
|
6
6
|
import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
|
|
7
|
+
import type { Diagnostic } from "./diagnostics.ts"
|
|
8
|
+
|
|
9
|
+
export interface AssureSink {
|
|
10
|
+
record(bundle: AssureBundle): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GateResult {
|
|
14
|
+
readonly gate:
|
|
15
|
+
| "check"
|
|
16
|
+
| "doctor"
|
|
17
|
+
| "render"
|
|
18
|
+
| "hydration"
|
|
19
|
+
| "contracts"
|
|
20
|
+
| "size"
|
|
21
|
+
| "tests"
|
|
22
|
+
| string
|
|
23
|
+
readonly status: "pass" | "fail" | "skip"
|
|
24
|
+
readonly skipReason?: string
|
|
25
|
+
readonly durationMs: number
|
|
26
|
+
readonly diagnostics: readonly Diagnostic[]
|
|
27
|
+
readonly replay?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AssureBundle {
|
|
31
|
+
readonly version: 1
|
|
32
|
+
readonly createdAt: string
|
|
33
|
+
readonly commit: string
|
|
34
|
+
readonly gates: readonly GateResult[]
|
|
35
|
+
readonly verdict: "green" | "red"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MemoryAssureSink extends AssureSink {
|
|
39
|
+
readonly bundles: readonly AssureBundle[]
|
|
40
|
+
clear(): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createMemoryAssureSink(): MemoryAssureSink {
|
|
44
|
+
const bundles: AssureBundle[] = []
|
|
45
|
+
return {
|
|
46
|
+
record: async (bundle) => {
|
|
47
|
+
bundles.push(bundle)
|
|
48
|
+
},
|
|
49
|
+
get bundles() {
|
|
50
|
+
return Object.freeze([...bundles])
|
|
51
|
+
},
|
|
52
|
+
clear: () => {
|
|
53
|
+
bundles.length = 0
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
7
57
|
|
|
8
58
|
export const DEFAULT_ASSURANCE_CONFIG = "nifra.assurance.ts"
|
|
9
59
|
|
|
@@ -77,11 +127,275 @@ export async function collectAssuranceReport(
|
|
|
77
127
|
|
|
78
128
|
export async function runAssurance(
|
|
79
129
|
cwd: string,
|
|
80
|
-
options: {
|
|
130
|
+
options: {
|
|
131
|
+
readonly json?: boolean
|
|
132
|
+
readonly config?: string
|
|
133
|
+
readonly bundle?: boolean
|
|
134
|
+
readonly strict?: boolean
|
|
135
|
+
readonly out?: string
|
|
136
|
+
readonly hydration?: boolean
|
|
137
|
+
readonly interact?: boolean
|
|
138
|
+
} = {},
|
|
81
139
|
): Promise<boolean> {
|
|
140
|
+
if (options.bundle === true) {
|
|
141
|
+
const bundle = await collectAssureBundle(cwd, options)
|
|
142
|
+
const serialized = JSON.stringify(bundle, null, 2)
|
|
143
|
+
if (options.out !== undefined) await Bun.write(resolve(cwd, options.out), `${serialized}\n`)
|
|
144
|
+
else console.log(serialized)
|
|
145
|
+
return bundle.verdict === "green"
|
|
146
|
+
}
|
|
82
147
|
const report = await collectAssuranceReport(cwd, options.config)
|
|
83
148
|
console.log(
|
|
84
149
|
options.json === true ? JSON.stringify(report, null, 2) : formatAssuranceReport(report),
|
|
85
150
|
)
|
|
86
151
|
return report.ok
|
|
87
152
|
}
|
|
153
|
+
|
|
154
|
+
function elapsed(start: number): number {
|
|
155
|
+
return Math.max(0, Math.round(performance.now() - start))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function gitCommit(cwd: string): Promise<string> {
|
|
159
|
+
try {
|
|
160
|
+
const proc = Bun.spawn(["git", "rev-parse", "HEAD"], { cwd, stdout: "pipe", stderr: "ignore" })
|
|
161
|
+
const text = (await new Response(proc.stdout).text()).trim()
|
|
162
|
+
await proc.exited
|
|
163
|
+
return text || "unknown"
|
|
164
|
+
} catch {
|
|
165
|
+
return "unknown"
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function diagnosticFromMessage(
|
|
170
|
+
code: string,
|
|
171
|
+
message: string,
|
|
172
|
+
severity: "error" | "warn" | "info" = "error",
|
|
173
|
+
): Diagnostic {
|
|
174
|
+
return Object.freeze({ code, severity, message })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function statusFor(diagnostics: readonly Diagnostic[], strict: boolean): "pass" | "fail" {
|
|
178
|
+
return diagnostics.some(
|
|
179
|
+
(item) => item.severity === "error" || (strict && item.severity === "warn"),
|
|
180
|
+
)
|
|
181
|
+
? "fail"
|
|
182
|
+
: "pass"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function collectAssureBundle(
|
|
186
|
+
cwd: string,
|
|
187
|
+
options: {
|
|
188
|
+
readonly config?: string
|
|
189
|
+
readonly strict?: boolean
|
|
190
|
+
readonly hydration?: boolean
|
|
191
|
+
readonly interact?: boolean
|
|
192
|
+
} = {},
|
|
193
|
+
): Promise<AssureBundle> {
|
|
194
|
+
const strict = options.strict === true
|
|
195
|
+
const gates: GateResult[] = []
|
|
196
|
+
let assuranceConfig: AssuranceConfig | undefined
|
|
197
|
+
let assuranceConfigError: unknown
|
|
198
|
+
try {
|
|
199
|
+
assuranceConfig = await loadAssuranceConfig(cwd, options.config)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
assuranceConfigError = error
|
|
202
|
+
}
|
|
203
|
+
const addGate = async (
|
|
204
|
+
gate: GateResult["gate"],
|
|
205
|
+
run: () => Promise<{ diagnostics: Diagnostic[]; skipReason?: string; replay?: string }>,
|
|
206
|
+
): Promise<void> => {
|
|
207
|
+
const start = performance.now()
|
|
208
|
+
try {
|
|
209
|
+
const result = await run()
|
|
210
|
+
gates.push({
|
|
211
|
+
gate,
|
|
212
|
+
status: result.skipReason === undefined ? statusFor(result.diagnostics, strict) : "skip",
|
|
213
|
+
...(result.skipReason === undefined ? {} : { skipReason: result.skipReason }),
|
|
214
|
+
durationMs: elapsed(start),
|
|
215
|
+
diagnostics: Object.freeze(result.diagnostics),
|
|
216
|
+
...(result.replay === undefined ? {} : { replay: result.replay }),
|
|
217
|
+
})
|
|
218
|
+
} catch (error) {
|
|
219
|
+
gates.push({
|
|
220
|
+
gate,
|
|
221
|
+
status: "fail",
|
|
222
|
+
durationMs: elapsed(start),
|
|
223
|
+
diagnostics: [
|
|
224
|
+
diagnosticFromMessage(
|
|
225
|
+
gate === "contracts" ? "NF-K001" : gate === "hydration" ? "NF-H001" : "NF-A001",
|
|
226
|
+
error instanceof Error ? error.message : String(error),
|
|
227
|
+
),
|
|
228
|
+
],
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await addGate("check", async () => {
|
|
234
|
+
const { collectProjectVerification } = await import("./verification.ts")
|
|
235
|
+
const verification = await collectProjectVerification(cwd, {
|
|
236
|
+
...(options.config !== undefined ? { config: options.config } : {}),
|
|
237
|
+
preloaded:
|
|
238
|
+
assuranceConfig !== undefined
|
|
239
|
+
? { config: assuranceConfig }
|
|
240
|
+
: { error: assuranceConfigError },
|
|
241
|
+
})
|
|
242
|
+
const result = await verification.check()
|
|
243
|
+
const diagnostics = (result.structuredDiagnostics ?? []).map((item) => item)
|
|
244
|
+
return { diagnostics }
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
await addGate("doctor", async () => {
|
|
248
|
+
const { collectDoctorResult } = await import("./doctor.ts")
|
|
249
|
+
const result = await collectDoctorResult(cwd)
|
|
250
|
+
const diagnostics: Diagnostic[] = []
|
|
251
|
+
for (const finding of result.findings) {
|
|
252
|
+
diagnostics.push(
|
|
253
|
+
diagnosticFromMessage(
|
|
254
|
+
"NF-D001",
|
|
255
|
+
`imports ${finding.package} which is not declared`,
|
|
256
|
+
"error",
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
for (const finding of result.duplicateInstalls) {
|
|
261
|
+
diagnostics.push(
|
|
262
|
+
diagnosticFromMessage("NF-D001", `${finding.package} resolves to duplicate installs`),
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
return { diagnostics }
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
await addGate("contracts", async () => {
|
|
269
|
+
const { checkContractsLock } = await import("./contracts.ts")
|
|
270
|
+
const path = resolve(cwd, "contracts.lock.json")
|
|
271
|
+
if (!existsSync(path)) return { diagnostics: [], skipReason: "no contract lock configured" }
|
|
272
|
+
const result = await checkContractsLock(cwd)
|
|
273
|
+
return {
|
|
274
|
+
diagnostics: result.diagnostics.map((item) =>
|
|
275
|
+
diagnosticFromMessage("NF-K001", item.message, "error"),
|
|
276
|
+
),
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
await addGate("render", async () => {
|
|
281
|
+
if (!existsSync(join(cwd, "nifra.config.ts")) && !existsSync(join(cwd, "framework.ts")))
|
|
282
|
+
return { diagnostics: [], skipReason: "no client render target configured" }
|
|
283
|
+
const { renderPages } = await import("./mcp-render.ts")
|
|
284
|
+
const result = await renderPages(cwd, [{ path: "/" }])
|
|
285
|
+
if ("error" in result)
|
|
286
|
+
return {
|
|
287
|
+
diagnostics: [diagnosticFromMessage("NF-A001", "SSR render gate failed", "error")],
|
|
288
|
+
}
|
|
289
|
+
const pages = Array.isArray(result.results) ? result.results : []
|
|
290
|
+
const failure = pages.find(
|
|
291
|
+
(page): page is { status?: unknown } =>
|
|
292
|
+
typeof page === "object" &&
|
|
293
|
+
page !== null &&
|
|
294
|
+
Number((page as { status?: unknown }).status) >= 500,
|
|
295
|
+
)
|
|
296
|
+
return failure === undefined
|
|
297
|
+
? { diagnostics: [] }
|
|
298
|
+
: { diagnostics: [diagnosticFromMessage("NF-A001", "SSR render returned a server error")] }
|
|
299
|
+
})
|
|
300
|
+
await addGate("size", async () => {
|
|
301
|
+
const budget = assuranceConfig?.size
|
|
302
|
+
if (
|
|
303
|
+
budget === undefined ||
|
|
304
|
+
(budget.maxBytes === undefined && budget.maxGzipBytes === undefined)
|
|
305
|
+
)
|
|
306
|
+
return { diagnostics: [], skipReason: "no size budget configured" }
|
|
307
|
+
const outDir = resolve(cwd, budget.outDir ?? "dist")
|
|
308
|
+
if (!existsSync(outDir))
|
|
309
|
+
return {
|
|
310
|
+
diagnostics: [diagnosticFromMessage("NF-A001", "size gate output directory is missing")],
|
|
311
|
+
}
|
|
312
|
+
const files: string[] = []
|
|
313
|
+
for await (const file of new Bun.Glob("**/*").scan({ cwd: outDir, onlyFiles: true }))
|
|
314
|
+
files.push(join(outDir, file))
|
|
315
|
+
let bytes = 0
|
|
316
|
+
let gzipBytes = 0
|
|
317
|
+
for (const file of files) {
|
|
318
|
+
const data = await Bun.file(file).bytes()
|
|
319
|
+
bytes += data.byteLength
|
|
320
|
+
gzipBytes += Bun.gzipSync(data).byteLength
|
|
321
|
+
}
|
|
322
|
+
const overRaw = budget.maxBytes !== undefined && bytes > budget.maxBytes
|
|
323
|
+
const overGzip = budget.maxGzipBytes !== undefined && gzipBytes > budget.maxGzipBytes
|
|
324
|
+
return {
|
|
325
|
+
diagnostics:
|
|
326
|
+
overRaw || overGzip
|
|
327
|
+
? [
|
|
328
|
+
diagnosticFromMessage(
|
|
329
|
+
"NF-A001",
|
|
330
|
+
`output exceeds configured size budget (${bytes} bytes, ${gzipBytes} gzip bytes)`,
|
|
331
|
+
),
|
|
332
|
+
]
|
|
333
|
+
: [],
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
if (options.hydration === true) {
|
|
337
|
+
await addGate("hydration", async () => {
|
|
338
|
+
const { runHydrationAssurance } = await import("./assure-hydration.ts")
|
|
339
|
+
const result = await runHydrationAssurance(cwd, { interact: options.interact === true })
|
|
340
|
+
return {
|
|
341
|
+
diagnostics: [...result.diagnostics],
|
|
342
|
+
...(result.skipReason === undefined ? {} : { skipReason: result.skipReason }),
|
|
343
|
+
...(result.replays?.[0] === undefined ? {} : { replay: result.replays[0] }),
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
} else {
|
|
347
|
+
await addGate("hydration", async () => ({
|
|
348
|
+
diagnostics: [],
|
|
349
|
+
skipReason: "pass --hydration to run the hydration gate",
|
|
350
|
+
}))
|
|
351
|
+
}
|
|
352
|
+
const workloads = assuranceConfig?.idempotency ?? []
|
|
353
|
+
const testGateStart = performance.now()
|
|
354
|
+
if (workloads.length === 0) {
|
|
355
|
+
gates.push({
|
|
356
|
+
gate: "tests",
|
|
357
|
+
status: "skip",
|
|
358
|
+
skipReason: "no idempotency workloads configured",
|
|
359
|
+
durationMs: elapsed(testGateStart),
|
|
360
|
+
diagnostics: [],
|
|
361
|
+
})
|
|
362
|
+
} else {
|
|
363
|
+
const { proveIdempotency } = await import("@nifrajs/testing")
|
|
364
|
+
const diagnostics: Diagnostic[] = []
|
|
365
|
+
for (const workload of workloads) {
|
|
366
|
+
const proof = await proveIdempotency({ run: workload.run })
|
|
367
|
+
for (const divergence of proof.divergences) {
|
|
368
|
+
diagnostics.push(
|
|
369
|
+
diagnosticFromMessage(
|
|
370
|
+
"NF-A001",
|
|
371
|
+
`${workload.name} diverged at step ${divergence.step} field ${divergence.field}`,
|
|
372
|
+
"error",
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
gates.push({
|
|
378
|
+
gate: "tests",
|
|
379
|
+
status: statusFor(diagnostics, strict),
|
|
380
|
+
durationMs: elapsed(testGateStart),
|
|
381
|
+
diagnostics,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
const bundle: AssureBundle = Object.freeze({
|
|
385
|
+
version: 1,
|
|
386
|
+
createdAt: new Date().toISOString(),
|
|
387
|
+
commit: await gitCommit(cwd),
|
|
388
|
+
gates: Object.freeze(gates),
|
|
389
|
+
verdict: gates.some((gate) => gate.status === "fail") ? "red" : "green",
|
|
390
|
+
})
|
|
391
|
+
const sink = assuranceConfig?.assureSink
|
|
392
|
+
if (
|
|
393
|
+
typeof sink === "object" &&
|
|
394
|
+
sink !== null &&
|
|
395
|
+
"record" in sink &&
|
|
396
|
+
typeof sink.record === "function"
|
|
397
|
+
) {
|
|
398
|
+
await sink.record(bundle)
|
|
399
|
+
}
|
|
400
|
+
return bundle
|
|
401
|
+
}
|
package/src/capabilities-tool.ts
CHANGED
|
@@ -48,6 +48,30 @@ function specifierMatches(pattern: string, specifier: string): boolean {
|
|
|
48
48
|
return pattern.endsWith("/*") ? specifier.startsWith(pattern.slice(0, -1)) : specifier === pattern
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const SPECIFIER_EXTENSION = /\.(?:[cm]?[jt]sx?)$/
|
|
52
|
+
|
|
53
|
+
/** Reduce a specifier to the name a human would recognise: `./services/mail.ts` and `../mail/index.ts`
|
|
54
|
+
* both stem to `mail`, which is what makes a written-vs-actual mismatch suggestible. */
|
|
55
|
+
function specifierStem(specifier: string): string {
|
|
56
|
+
const segments = specifier
|
|
57
|
+
.replace(SPECIFIER_EXTENSION, "")
|
|
58
|
+
.split("/")
|
|
59
|
+
.filter((segment) => segment !== "" && segment !== "." && segment !== "..")
|
|
60
|
+
const last = segments.at(-1)
|
|
61
|
+
if (last === undefined) return specifier
|
|
62
|
+
return last === "index" ? (segments.at(-2) ?? last) : last
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const suggestionSuffix = (suggestions: readonly string[]): string =>
|
|
66
|
+
suggestions.length === 0 ? "" : ` - did you mean ${suggestions.map((s) => `"${s}"`).join(", ")}?`
|
|
67
|
+
|
|
68
|
+
function nearestSpecifiers(pattern: string, candidates: Iterable<string>): string[] {
|
|
69
|
+
const stem = specifierStem(pattern.endsWith("/*") ? pattern.slice(0, -2) : pattern)
|
|
70
|
+
const near = new Set<string>()
|
|
71
|
+
for (const candidate of candidates) if (specifierStem(candidate) === stem) near.add(candidate)
|
|
72
|
+
return [...near].sort().slice(0, 3)
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
|
|
52
76
|
|
|
53
77
|
export interface CapabilityImportViolation {
|
|
@@ -63,6 +87,20 @@ export interface CapabilityProjectReport {
|
|
|
63
87
|
readonly report: CapabilityAssuranceReport
|
|
64
88
|
readonly violations: readonly CapabilityImportViolation[]
|
|
65
89
|
readonly truncations: readonly CapabilityProvenanceTruncation[]
|
|
90
|
+
readonly unmatchedSeams: readonly CapabilityUnmatchedSeam[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A provenance rule that names something the project does not contain. Seam specifiers are matched
|
|
95
|
+
* as written, so `"./services/mail.ts"` against `import "./services/mail"` silently governs nothing -
|
|
96
|
+
* the effect stays invisible to assurance and every route using it still passes.
|
|
97
|
+
*/
|
|
98
|
+
export interface CapabilityUnmatchedSeam {
|
|
99
|
+
readonly kind: "import" | "route-module"
|
|
100
|
+
/** The specifier (import rule) or project-relative module path (routeModules entry) as written. */
|
|
101
|
+
readonly value: string
|
|
102
|
+
/** Closest specifiers/modules actually present, when one is recognisable. */
|
|
103
|
+
readonly suggestions: readonly string[]
|
|
66
104
|
}
|
|
67
105
|
|
|
68
106
|
export interface CapabilityProvenanceTruncation {
|
|
@@ -264,9 +302,40 @@ export async function collectCapabilityProjectReport(
|
|
|
264
302
|
violations.push(...walked.violations)
|
|
265
303
|
truncations.push(...walked.truncations)
|
|
266
304
|
}
|
|
305
|
+
|
|
306
|
+
// A seam rule that matches nothing is a policy typo, not a passing project: the effect it was
|
|
307
|
+
// written to govern keeps executing, unwatched, and every route touching it still reports clean.
|
|
308
|
+
// Checked against every scanned source (not just route-reachable ones) so a seam used by a job or
|
|
309
|
+
// a script is not reported as missing.
|
|
310
|
+
const scannedSpecifiers = new Set<string>()
|
|
311
|
+
for (const content of sources.values())
|
|
312
|
+
for (const specifier of scanEffectImports(content)) scannedSpecifiers.add(specifier)
|
|
313
|
+
const unmatchedSeams: CapabilityUnmatchedSeam[] = []
|
|
314
|
+
for (const rule of policy.provenance.imports) {
|
|
315
|
+
if (rule.optional === true) continue
|
|
316
|
+
if ([...scannedSpecifiers].some((specifier) => specifierMatches(rule.specifier, specifier)))
|
|
317
|
+
continue
|
|
318
|
+
unmatchedSeams.push({
|
|
319
|
+
kind: "import",
|
|
320
|
+
value: rule.specifier,
|
|
321
|
+
suggestions: Object.freeze(nearestSpecifiers(rule.specifier, scannedSpecifiers)),
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
for (const association of policy.provenance.routeModules ?? []) {
|
|
325
|
+
for (const module of association.modules) {
|
|
326
|
+
const normalized = module.replaceAll("\\", "/")
|
|
327
|
+
if (sources.has(normalized) || existsSync(join(cwd, normalized))) continue
|
|
328
|
+
unmatchedSeams.push({
|
|
329
|
+
kind: "route-module",
|
|
330
|
+
value: module,
|
|
331
|
+
suggestions: Object.freeze(nearestSpecifiers(module, sources.keys())),
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
267
336
|
const evaluated = evaluateCapabilityAssurance(source, policy, { routes: evidenceRoutes })
|
|
268
337
|
const report: CapabilityAssuranceReport =
|
|
269
|
-
violations.length === 0 && truncations.length === 0
|
|
338
|
+
violations.length === 0 && truncations.length === 0 && unmatchedSeams.length === 0
|
|
270
339
|
? evaluated
|
|
271
340
|
: Object.freeze({
|
|
272
341
|
...evaluated,
|
|
@@ -285,12 +354,22 @@ export async function collectCapabilityProjectReport(
|
|
|
285
354
|
path: truncation.path,
|
|
286
355
|
message: `${truncation.method} ${truncation.path} capability provenance hit the ${truncation.reason === "depth-limit" ? "import-depth" : "module-count"} safety limit via ${truncation.chain.join(" → ")}; assurance cannot prove the remaining graph`,
|
|
287
356
|
})),
|
|
357
|
+
...unmatchedSeams.map((seam) => ({
|
|
358
|
+
code: "unmatched-provenance-seam" as const,
|
|
359
|
+
method: "*",
|
|
360
|
+
path: "*",
|
|
361
|
+
message:
|
|
362
|
+
seam.kind === "import"
|
|
363
|
+
? `capabilities.provenance.imports "${seam.value}" matched no import in this project, so the effect it declares is ungoverned - specifiers are compared exactly (or as a trailing /* prefix), not resolved${suggestionSuffix(seam.suggestions)}`
|
|
364
|
+
: `capabilities.provenance.routeModules "${seam.value}" is not a file in this project, so the routes it associates stay uncovered${suggestionSuffix(seam.suggestions)}`,
|
|
365
|
+
})),
|
|
288
366
|
]),
|
|
289
367
|
})
|
|
290
368
|
return Object.freeze({
|
|
291
369
|
report,
|
|
292
370
|
violations: Object.freeze(violations),
|
|
293
371
|
truncations: Object.freeze(truncations),
|
|
372
|
+
unmatchedSeams: Object.freeze(unmatchedSeams),
|
|
294
373
|
})
|
|
295
374
|
}
|
|
296
375
|
|