@nifrajs/cli 1.6.0 → 1.11.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 (57) hide show
  1. package/README.md +25 -0
  2. package/dist/assure.d.ts +11 -0
  3. package/dist/assure.d.ts.map +1 -0
  4. package/dist/assure.js +67 -0
  5. package/dist/assure.js.map +1 -0
  6. package/dist/capabilities-tool.d.ts +33 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -0
  8. package/dist/capabilities-tool.js +320 -0
  9. package/dist/capabilities-tool.js.map +1 -0
  10. package/dist/check.d.ts +1 -1
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +117 -11
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +196 -9
  16. package/dist/cli.js.map +1 -1
  17. package/dist/levels-tool.d.ts +37 -0
  18. package/dist/levels-tool.d.ts.map +1 -0
  19. package/dist/levels-tool.js +180 -0
  20. package/dist/levels-tool.js.map +1 -0
  21. package/dist/manifest-tool.d.ts +15 -0
  22. package/dist/manifest-tool.d.ts.map +1 -0
  23. package/dist/manifest-tool.js +67 -0
  24. package/dist/manifest-tool.js.map +1 -0
  25. package/dist/mcp-http.js +1 -1
  26. package/dist/mcp-http.js.map +1 -1
  27. package/dist/mcp.d.ts +1 -0
  28. package/dist/mcp.d.ts.map +1 -1
  29. package/dist/mcp.js +31 -0
  30. package/dist/mcp.js.map +1 -1
  31. package/dist/recipes/1.8.0.d.ts +12 -0
  32. package/dist/recipes/1.8.0.d.ts.map +1 -0
  33. package/dist/recipes/1.8.0.js +20 -0
  34. package/dist/recipes/1.8.0.js.map +1 -0
  35. package/dist/recipes/index.d.ts +28 -0
  36. package/dist/recipes/index.d.ts.map +1 -0
  37. package/dist/recipes/index.js +27 -0
  38. package/dist/recipes/index.js.map +1 -0
  39. package/dist/upgrade.d.ts +82 -0
  40. package/dist/upgrade.d.ts.map +1 -0
  41. package/dist/upgrade.js +220 -0
  42. package/dist/upgrade.js.map +1 -0
  43. package/docs/examples.json +12 -0
  44. package/docs/llms-full.txt +419 -13
  45. package/docs/types.json +1923 -32
  46. package/package.json +8 -8
  47. package/src/assure.ts +88 -0
  48. package/src/capabilities-tool.ts +406 -0
  49. package/src/check.ts +135 -12
  50. package/src/cli.ts +203 -14
  51. package/src/levels-tool.ts +222 -0
  52. package/src/manifest-tool.ts +103 -0
  53. package/src/mcp-http.ts +1 -1
  54. package/src/mcp.ts +37 -0
  55. package/src/recipes/1.8.0.ts +21 -0
  56. package/src/recipes/index.ts +52 -0
  57. package/src/upgrade.ts +269 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "1.6.0",
3
+ "version": "1.11.0",
4
4
  "description": "The nifra CLI — zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,13 +41,13 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "1.6.0",
45
- "@nifrajs/core": "1.6.0",
46
- "@nifrajs/mcp": "1.6.0",
47
- "@nifrajs/runner": "1.6.0",
48
- "@nifrajs/schema": "1.6.0",
49
- "@nifrajs/web": "1.6.0",
50
- "create-nifra": "1.6.0"
44
+ "@nifrajs/client": "1.11.0",
45
+ "@nifrajs/core": "1.11.0",
46
+ "@nifrajs/mcp": "1.11.0",
47
+ "@nifrajs/runner": "1.11.0",
48
+ "@nifrajs/schema": "1.11.0",
49
+ "@nifrajs/web": "1.11.0",
50
+ "create-nifra": "1.11.0"
51
51
  },
52
52
  "repository": {
53
53
  "type": "git",
package/src/assure.ts ADDED
@@ -0,0 +1,88 @@
1
+ /** `nifra assure` — evaluate a project's reflected routes against `nifra.assurance.ts`. */
2
+
3
+ import { existsSync } from "node:fs"
4
+ import { resolve } from "node:path"
5
+ import { pathToFileURL } from "node:url"
6
+ import {
7
+ type AssuranceConfig,
8
+ type AssuranceReport,
9
+ evaluateRouteAssurance,
10
+ } from "@nifrajs/core/assurance"
11
+
12
+ export const DEFAULT_ASSURANCE_CONFIG = "nifra.assurance.ts"
13
+
14
+ function isConfig(value: unknown): value is AssuranceConfig {
15
+ if (typeof value !== "object" || value === null) return false
16
+ const candidate = value as Partial<AssuranceConfig>
17
+ return (
18
+ candidate.source !== undefined &&
19
+ typeof candidate.policy === "object" &&
20
+ candidate.policy !== null
21
+ )
22
+ }
23
+
24
+ export async function loadAssuranceConfig(
25
+ cwd: string,
26
+ configPath = DEFAULT_ASSURANCE_CONFIG,
27
+ ): Promise<AssuranceConfig> {
28
+ const path = resolve(cwd, configPath)
29
+ if (!existsSync(path)) {
30
+ throw new Error(
31
+ `[nifra] route assurance config not found: ${path} — create ${DEFAULT_ASSURANCE_CONFIG} with a default defineAssuranceConfig({ source, policy }) export.`,
32
+ )
33
+ }
34
+ const specifier = pathToFileURL(path)
35
+ specifier.searchParams.set("nifra-assurance", String(Date.now()))
36
+ const loaded = (await import(specifier.href)) as { default?: unknown }
37
+ if (!isConfig(loaded.default)) {
38
+ throw new Error(
39
+ `[nifra] ${path} must default-export defineAssuranceConfig({ source, policy }).`,
40
+ )
41
+ }
42
+ return loaded.default
43
+ }
44
+
45
+ export function formatAssuranceReport(report: AssuranceReport): string {
46
+ if (report.ok) {
47
+ const capability = report.capabilities
48
+ ? ` Capability assurance covered ${report.capabilities.routes.length} route${report.capabilities.routes.length === 1 ? "" : "s"}.`
49
+ : ""
50
+ return `✓ route assurance: ${report.routes.length} route${report.routes.length === 1 ? "" : "s"} classified; all required evidence is present.${capability}`
51
+ }
52
+ const messages = [
53
+ ...report.findings.map((finding) => finding.message),
54
+ ...(report.capabilities?.findings.map((finding) => finding.message) ?? []),
55
+ ]
56
+ return `${messages.map((message) => `✖ ${message}`).join("\n")}\n\n${messages.length} assurance failure${messages.length === 1 ? "" : "s"} across ${report.routes.length} route${report.routes.length === 1 ? "" : "s"}.`
57
+ }
58
+
59
+ export async function collectAssuranceReport(
60
+ cwd: string,
61
+ configPath?: string,
62
+ ): Promise<AssuranceReport> {
63
+ const config = await loadAssuranceConfig(cwd, configPath)
64
+ const routeReport = evaluateRouteAssurance(config.source, config.policy)
65
+ if (config.capabilities === undefined) return routeReport
66
+ const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
67
+ const capabilityProject = await collectCapabilityProjectReport(
68
+ cwd,
69
+ config.source,
70
+ config.capabilities,
71
+ )
72
+ return Object.freeze({
73
+ ...routeReport,
74
+ ok: routeReport.ok && capabilityProject.report.ok,
75
+ capabilities: capabilityProject.report,
76
+ })
77
+ }
78
+
79
+ export async function runAssurance(
80
+ cwd: string,
81
+ options: { readonly json?: boolean; readonly config?: string } = {},
82
+ ): Promise<boolean> {
83
+ const report = await collectAssuranceReport(cwd, options.config)
84
+ console.log(
85
+ options.json === true ? JSON.stringify(report, null, 2) : formatAssuranceReport(report),
86
+ )
87
+ return report.ok
88
+ }
@@ -0,0 +1,406 @@
1
+ /** Static effect provenance, capability lockfile, and CLI commands. */
2
+
3
+ import { existsSync, readFileSync } from "node:fs"
4
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"
5
+ import { matchesAssuranceSelector } from "@nifrajs/core/assurance"
6
+ import {
7
+ type CapabilityAssuranceReport,
8
+ type CapabilityPolicy,
9
+ type CapabilitySnapshot,
10
+ type CapabilitySnapshotRoute,
11
+ defineCapabilityPolicy,
12
+ evaluateCapabilityAssurance,
13
+ snapshotCapabilities,
14
+ validCapabilityId,
15
+ } from "@nifrajs/core/capabilities"
16
+ import { reflectRoutes } from "@nifrajs/core/reflection"
17
+ import { loadAssuranceConfig } from "./assure.ts"
18
+ import { scanStaticRouteText, stripComments, walkSource } from "./check.ts"
19
+
20
+ const EFFECT_IMPORT =
21
+ /\bimport\s+(?!type\b)(?:[^'"();]*?\bfrom\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)|\brequire\s*\(\s*["']([^"']+)["']\s*\)|\bexport\s+(?!type\b)[^'";]*?\bfrom\s*["']([^"']+)["']/g
22
+ const TEMPLATE_EFFECT_IMPORT = /\b(?:import|require)\s*\(\s*`([^`$]*(?:\\.[^`$]*)*)`\s*\)/g
23
+
24
+ /** Value-bearing import edges relevant to effect provenance, in source order. */
25
+ export function scanEffectImports(content: string): string[] {
26
+ const code = stripComments(content)
27
+ const found: Array<{ index: number; specifier: string }> = []
28
+ const re = new RegExp(EFFECT_IMPORT.source, EFFECT_IMPORT.flags)
29
+ for (let match = re.exec(code); match !== null; match = re.exec(code)) {
30
+ const specifier = match[1] ?? match[2] ?? match[3] ?? match[4]
31
+ if (specifier !== undefined) found.push({ index: match.index, specifier })
32
+ }
33
+ // `stripComments` intentionally blanks template contents for other lints. Recover only a
34
+ // no-substitution template used directly as import()/require() input, and verify the keyword itself
35
+ // survived stripping so a commented/doc-template example cannot become provenance.
36
+ const templates = new RegExp(TEMPLATE_EFFECT_IMPORT.source, TEMPLATE_EFFECT_IMPORT.flags)
37
+ for (let match = templates.exec(content); match !== null; match = templates.exec(content)) {
38
+ if (code.slice(match.index, match.index + 6).trim() === "") continue
39
+ const specifier = match[1]
40
+ if (specifier !== undefined)
41
+ found.push({ index: match.index, specifier: specifier.replace(/\\`/g, "`") })
42
+ }
43
+ return found.sort((a, b) => a.index - b.index).map((item) => item.specifier)
44
+ }
45
+
46
+ function specifierMatches(pattern: string, specifier: string): boolean {
47
+ return pattern.endsWith("/*") ? specifier.startsWith(pattern.slice(0, -1)) : specifier === pattern
48
+ }
49
+
50
+ const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
51
+
52
+ export interface CapabilityImportViolation {
53
+ readonly method: string
54
+ readonly path: string
55
+ readonly module: string
56
+ readonly specifier: string
57
+ readonly reason: string
58
+ readonly chain: readonly string[]
59
+ }
60
+
61
+ export interface CapabilityProjectReport {
62
+ readonly report: CapabilityAssuranceReport
63
+ readonly violations: readonly CapabilityImportViolation[]
64
+ }
65
+
66
+ function resolveLocalModule(
67
+ cwd: string,
68
+ from: string,
69
+ specifier: string,
70
+ sources: Map<string, string>,
71
+ ): string | undefined {
72
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
73
+ const base = join(dirname(from), specifier).replaceAll("\\", "/")
74
+ const candidates = extname(base)
75
+ ? [base]
76
+ : [
77
+ base,
78
+ `${base}.ts`,
79
+ `${base}.tsx`,
80
+ `${base}.mts`,
81
+ `${base}.cts`,
82
+ `${base}/index.ts`,
83
+ `${base}/index.tsx`,
84
+ ]
85
+ const found = candidates.find((candidate) => sources.has(candidate))
86
+ if (found !== undefined) return found
87
+ }
88
+ try {
89
+ const fromAbsolute = join(cwd, from)
90
+ const resolved = Bun.resolveSync(specifier, dirname(fromAbsolute))
91
+ const rel = relative(cwd, resolved).replaceAll("\\", "/")
92
+ if (rel.startsWith("../") || isAbsolute(rel)) return undefined
93
+ if (!sources.has(rel) && existsSync(resolved)) {
94
+ try {
95
+ sources.set(rel, readFileSync(resolved, "utf8"))
96
+ } catch {
97
+ return undefined
98
+ }
99
+ }
100
+ return sources.has(rel) ? rel : undefined
101
+ } catch {
102
+ return undefined
103
+ }
104
+ }
105
+
106
+ async function readSources(cwd: string): Promise<Map<string, string>> {
107
+ const sources = new Map<string, string>()
108
+ await walkSource(cwd, (rel, content) => sources.set(rel.replaceAll("\\", "/"), content))
109
+ return sources
110
+ }
111
+
112
+ interface WalkResult {
113
+ readonly covered: boolean
114
+ readonly evidence: Array<{ id: string; kind: "static"; source: string }>
115
+ readonly violations: CapabilityImportViolation[]
116
+ }
117
+
118
+ async function walkRouteModules(
119
+ cwd: string,
120
+ modules: readonly string[],
121
+ method: string,
122
+ path: string,
123
+ policy: CapabilityPolicy,
124
+ sources: Map<string, string>,
125
+ ): Promise<WalkResult> {
126
+ const queue = modules.map((module) => ({ module, chain: [module] as string[] }))
127
+ const visited = new Set<string>()
128
+ const evidence: Array<{ id: string; kind: "static"; source: string }> = []
129
+ const violations: CapabilityImportViolation[] = []
130
+ let covered = false
131
+
132
+ while (queue.length > 0 && visited.size < 500) {
133
+ const current = queue.shift()
134
+ if (current === undefined || visited.has(current.module) || current.chain.length > 16) continue
135
+ visited.add(current.module)
136
+ let content = sources.get(current.module)
137
+ if (content === undefined) {
138
+ try {
139
+ content = await Bun.file(join(cwd, current.module)).text()
140
+ sources.set(current.module, content)
141
+ } catch {
142
+ continue
143
+ }
144
+ }
145
+ covered = true
146
+ for (const specifier of scanEffectImports(content)) {
147
+ let approvedEffectBoundary = false
148
+ for (const rule of policy.provenance.imports) {
149
+ if (!specifierMatches(rule.specifier, specifier)) continue
150
+ approvedEffectBoundary = true
151
+ for (const id of rule.capabilities) {
152
+ if (!evidence.some((item) => item.id === id && item.source === specifier)) {
153
+ evidence.push({ id, kind: "static", source: specifier })
154
+ }
155
+ }
156
+ }
157
+ for (const rule of policy.provenance.forbiddenImports) {
158
+ if (!specifierMatches(rule.specifier, specifier)) continue
159
+ violations.push({
160
+ method,
161
+ path,
162
+ module: current.module,
163
+ specifier,
164
+ reason: rule.reason,
165
+ chain: Object.freeze([...current.chain, specifier]),
166
+ })
167
+ }
168
+ // An explicitly mapped import is the owned effect seam. Its implementation may legitimately
169
+ // import the raw provider; assurance governs whether routes can reach the seam, not its internals.
170
+ if (approvedEffectBoundary) continue
171
+ const local = resolveLocalModule(cwd, current.module, specifier, sources)
172
+ if (local !== undefined && !visited.has(local)) {
173
+ queue.push({ module: local, chain: [...current.chain, specifier] })
174
+ }
175
+ }
176
+ }
177
+ return { covered, evidence, violations }
178
+ }
179
+
180
+ /** Build coverage-qualified static evidence for every reflected route. */
181
+ export async function collectCapabilityProjectReport(
182
+ cwd: string,
183
+ source: unknown,
184
+ policyInput: CapabilityPolicy,
185
+ ): Promise<CapabilityProjectReport> {
186
+ const policy = defineCapabilityPolicy(policyInput)
187
+ const sources = await readSources(cwd)
188
+ const automatic = new Map<string, Set<string>>()
189
+ for (const [file, content] of sources) {
190
+ for (const route of scanStaticRouteText(file, content)) {
191
+ const modules = automatic.get(routeKey(route.method, route.path)) ?? new Set<string>()
192
+ modules.add(file)
193
+ automatic.set(routeKey(route.method, route.path), modules)
194
+ }
195
+ }
196
+
197
+ const evidenceRoutes = []
198
+ const violations: CapabilityImportViolation[] = []
199
+ for (const route of reflectRoutes(source)) {
200
+ const modules = new Set(automatic.get(routeKey(route.method, route.path)) ?? [])
201
+ for (const association of policy.provenance.routeModules ?? []) {
202
+ if (
203
+ matchesAssuranceSelector(route, {
204
+ ...(association.match.methods !== undefined
205
+ ? { methods: association.match.methods as never }
206
+ : {}),
207
+ ...(association.match.paths !== undefined ? { paths: association.match.paths } : {}),
208
+ })
209
+ ) {
210
+ for (const module of association.modules) modules.add(module)
211
+ }
212
+ }
213
+ const walked = await walkRouteModules(
214
+ cwd,
215
+ [...modules],
216
+ route.method,
217
+ route.path,
218
+ policy,
219
+ sources,
220
+ )
221
+ evidenceRoutes.push({
222
+ method: route.method,
223
+ path: route.path,
224
+ covered: modules.size > 0 && walked.covered,
225
+ evidence: walked.evidence,
226
+ })
227
+ violations.push(...walked.violations)
228
+ }
229
+ const evaluated = evaluateCapabilityAssurance(source, policy, { routes: evidenceRoutes })
230
+ const report: CapabilityAssuranceReport =
231
+ violations.length === 0
232
+ ? evaluated
233
+ : Object.freeze({
234
+ ...evaluated,
235
+ ok: false,
236
+ findings: Object.freeze([
237
+ ...evaluated.findings,
238
+ ...violations.map((violation) => ({
239
+ code: "forbidden-effect-import" as const,
240
+ method: violation.method,
241
+ path: violation.path,
242
+ message: `${violation.method} ${violation.path} reaches forbidden ${violation.specifier} via ${violation.chain.join(" → ")}: ${violation.reason}`,
243
+ })),
244
+ ]),
245
+ })
246
+ return Object.freeze({ report, violations: Object.freeze(violations) })
247
+ }
248
+
249
+ export function parseCapabilityLockfile(content: string, sourcePath: string): CapabilitySnapshot {
250
+ let parsed: unknown
251
+ try {
252
+ parsed = JSON.parse(content)
253
+ } catch {
254
+ throw new Error(`[nifra] ${sourcePath} is not valid JSON`)
255
+ }
256
+ const candidate = parsed as Partial<CapabilitySnapshot> | null
257
+ if (
258
+ candidate === null ||
259
+ typeof candidate !== "object" ||
260
+ candidate.nifraCapabilities !== 1 ||
261
+ !Array.isArray(candidate.routes)
262
+ ) {
263
+ throw new Error(
264
+ `[nifra] ${sourcePath} is not a nifra capability lockfile — expected { "nifraCapabilities": 1, "routes": [...] }`,
265
+ )
266
+ }
267
+ for (const route of candidate.routes) {
268
+ if (
269
+ typeof route !== "object" ||
270
+ route === null ||
271
+ typeof (route as CapabilitySnapshotRoute).method !== "string" ||
272
+ typeof (route as CapabilitySnapshotRoute).path !== "string" ||
273
+ !Array.isArray((route as CapabilitySnapshotRoute).declared) ||
274
+ !Array.isArray((route as CapabilitySnapshotRoute).evidenced) ||
275
+ !Array.isArray((route as CapabilitySnapshotRoute).unproven)
276
+ ) {
277
+ throw new Error(`[nifra] ${sourcePath} contains an invalid capability route`)
278
+ }
279
+ for (const [label, values] of [
280
+ ["declared", (route as CapabilitySnapshotRoute).declared],
281
+ ["evidenced", (route as CapabilitySnapshotRoute).evidenced],
282
+ ["unproven", (route as CapabilitySnapshotRoute).unproven],
283
+ ] as const) {
284
+ if (
285
+ values.some((value) => typeof value !== "string" || !validCapabilityId(value)) ||
286
+ new Set(values).size !== values.length ||
287
+ [...values].sort().some((value, index) => value !== values[index])
288
+ ) {
289
+ throw new Error(`[nifra] ${sourcePath} has non-canonical ${label} capability tokens`)
290
+ }
291
+ }
292
+ }
293
+ return candidate as CapabilitySnapshot
294
+ }
295
+
296
+ function routeSnapshotKey(route: CapabilitySnapshotRoute): string {
297
+ return `${route.method}\n${route.path}`
298
+ }
299
+
300
+ function listDelta(label: string, before: readonly string[], after: readonly string[]): string[] {
301
+ const added = after.filter((value) => !before.includes(value))
302
+ const removed = before.filter((value) => !after.includes(value))
303
+ return [
304
+ ...(added.length > 0 ? [`${label} added ${added.join(", ")}`] : []),
305
+ ...(removed.length > 0 ? [`${label} removed ${removed.join(", ")}`] : []),
306
+ ]
307
+ }
308
+
309
+ /** Exact lockfile drift. Human approval is supplied by reviewing the checked-in lockfile change. */
310
+ export function diffCapabilitySnapshots(
311
+ before: CapabilitySnapshot,
312
+ after: CapabilitySnapshot,
313
+ ): readonly string[] {
314
+ const previous = new Map(before.routes.map((route) => [routeSnapshotKey(route), route]))
315
+ const current = new Map(after.routes.map((route) => [routeSnapshotKey(route), route]))
316
+ const changes: string[] = []
317
+ for (const [key, route] of current) {
318
+ const old = previous.get(key)
319
+ const prefix = `${route.method} ${route.path}`
320
+ if (old === undefined) {
321
+ changes.push(`${prefix}: route added`)
322
+ continue
323
+ }
324
+ for (const delta of listDelta("declared", old.declared, route.declared))
325
+ changes.push(`${prefix}: ${delta}`)
326
+ for (const delta of listDelta("evidenced", old.evidenced, route.evidenced))
327
+ changes.push(`${prefix}: ${delta}`)
328
+ for (const delta of listDelta("unproven", old.unproven, route.unproven))
329
+ changes.push(`${prefix}: ${delta}`)
330
+ }
331
+ for (const [key, route] of previous) {
332
+ if (!current.has(key)) changes.push(`${route.method} ${route.path}: route removed`)
333
+ }
334
+ return Object.freeze(changes)
335
+ }
336
+
337
+ async function currentProject(
338
+ cwd: string,
339
+ configPath?: string,
340
+ ): Promise<{
341
+ readonly policy: CapabilityPolicy
342
+ readonly project: CapabilityProjectReport
343
+ }> {
344
+ const config = await loadAssuranceConfig(cwd, configPath)
345
+ if (config.capabilities === undefined) {
346
+ throw new Error("[nifra] assurance config does not define capabilities")
347
+ }
348
+ return {
349
+ policy: config.capabilities,
350
+ project: await collectCapabilityProjectReport(cwd, config.source, config.capabilities),
351
+ }
352
+ }
353
+
354
+ function unsafeProject(project: CapabilityProjectReport): boolean {
355
+ return !project.report.ok || project.violations.length > 0
356
+ }
357
+
358
+ /** Write a lockfile only from a clean, fully-covered project report. */
359
+ export async function runCapabilitySnapshot(
360
+ cwd: string,
361
+ options: { readonly config?: string; readonly out?: string } = {},
362
+ ): Promise<boolean> {
363
+ const { policy, project } = await currentProject(cwd, options.config)
364
+ if (unsafeProject(project)) {
365
+ console.error("[nifra] refusing to snapshot failing capability assurance")
366
+ return false
367
+ }
368
+ const snapshot = snapshotCapabilities(project.report)
369
+ const path = resolve(cwd, options.out ?? policy.lockfile ?? "capabilities.lock.json")
370
+ await Bun.write(path, `${JSON.stringify(snapshot, null, 2)}\n`)
371
+ console.log(`[nifra] wrote capability lockfile to ${path}`)
372
+ return true
373
+ }
374
+
375
+ /** Fail on assurance violations or any drift from the checked-in capability lockfile. */
376
+ export async function runCapabilityCheck(
377
+ cwd: string,
378
+ options: { readonly config?: string; readonly lockfile?: string; readonly json?: boolean } = {},
379
+ ): Promise<boolean> {
380
+ const { policy, project } = await currentProject(cwd, options.config)
381
+ const path = resolve(cwd, options.lockfile ?? policy.lockfile ?? "capabilities.lock.json")
382
+ if (!existsSync(path)) throw new Error(`[nifra] capability lockfile not found: ${path}`)
383
+ const baseline = parseCapabilityLockfile(await Bun.file(path).text(), path)
384
+ const current = snapshotCapabilities(project.report)
385
+ const changes = diffCapabilitySnapshots(baseline, current)
386
+ const ok = !unsafeProject(project) && changes.length === 0
387
+ if (options.json === true) {
388
+ console.log(
389
+ JSON.stringify(
390
+ { ok, report: project.report, violations: project.violations, changes },
391
+ null,
392
+ 2,
393
+ ),
394
+ )
395
+ } else if (ok) {
396
+ console.log("✓ capability assurance and lockfile are current")
397
+ } else {
398
+ for (const finding of project.report.findings) console.log(`✖ ${finding.message}`)
399
+ for (const violation of project.violations)
400
+ console.log(
401
+ `✖ ${violation.method} ${violation.path}: ${violation.chain.join(" → ")} — ${violation.reason}`,
402
+ )
403
+ for (const change of changes) console.log(`✖ capability lock drift: ${change}`)
404
+ }
405
+ return ok
406
+ }