@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.
- package/README.md +25 -0
- package/dist/assure.d.ts +11 -0
- package/dist/assure.d.ts.map +1 -0
- package/dist/assure.js +67 -0
- package/dist/assure.js.map +1 -0
- package/dist/capabilities-tool.d.ts +33 -0
- package/dist/capabilities-tool.d.ts.map +1 -0
- package/dist/capabilities-tool.js +320 -0
- package/dist/capabilities-tool.js.map +1 -0
- package/dist/check.d.ts +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +117 -11
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +196 -9
- package/dist/cli.js.map +1 -1
- package/dist/levels-tool.d.ts +37 -0
- package/dist/levels-tool.d.ts.map +1 -0
- package/dist/levels-tool.js +180 -0
- package/dist/levels-tool.js.map +1 -0
- package/dist/manifest-tool.d.ts +15 -0
- package/dist/manifest-tool.d.ts.map +1 -0
- package/dist/manifest-tool.js +67 -0
- package/dist/manifest-tool.js.map +1 -0
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +31 -0
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/1.8.0.d.ts +12 -0
- package/dist/recipes/1.8.0.d.ts.map +1 -0
- package/dist/recipes/1.8.0.js +20 -0
- package/dist/recipes/1.8.0.js.map +1 -0
- package/dist/recipes/index.d.ts +28 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +27 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/upgrade.d.ts +82 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/dist/upgrade.js +220 -0
- package/dist/upgrade.js.map +1 -0
- package/docs/examples.json +12 -0
- package/docs/llms-full.txt +419 -13
- package/docs/types.json +1923 -32
- package/package.json +8 -8
- package/src/assure.ts +88 -0
- package/src/capabilities-tool.ts +406 -0
- package/src/check.ts +135 -12
- package/src/cli.ts +203 -14
- package/src/levels-tool.ts +222 -0
- package/src/manifest-tool.ts +103 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +37 -0
- package/src/recipes/1.8.0.ts +21 -0
- package/src/recipes/index.ts +52 -0
- package/src/upgrade.ts +269 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
45
|
-
"@nifrajs/core": "1.
|
|
46
|
-
"@nifrajs/mcp": "1.
|
|
47
|
-
"@nifrajs/runner": "1.
|
|
48
|
-
"@nifrajs/schema": "1.
|
|
49
|
-
"@nifrajs/web": "1.
|
|
50
|
-
"create-nifra": "1.
|
|
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
|
+
}
|