@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/src/check.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFileSync } from "node:fs"
|
|
20
|
-
import { dirname, isAbsolute, join } from "node:path"
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
20
|
+
import { dirname, isAbsolute, join, resolve } from "node:path"
|
|
21
21
|
import { Glob } from "bun"
|
|
22
22
|
|
|
23
23
|
export interface SourceFinding {
|
|
@@ -195,8 +195,13 @@ function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefined {
|
|
|
195
195
|
return { path, method, start: match.index, end: close + 1 }
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
function
|
|
199
|
-
return
|
|
198
|
+
function isPotentialBackendSource(code: string): boolean {
|
|
199
|
+
return (
|
|
200
|
+
code.includes("@nifrajs/core") ||
|
|
201
|
+
/(?<![.\w])server\s*\(/.test(code) ||
|
|
202
|
+
/(?:from\s*|import\s*)["']hono["']/.test(code) ||
|
|
203
|
+
/new\s+Hono(?:<[^>]+>)?\s*\(/.test(code)
|
|
204
|
+
)
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
function scanRoutePattern(
|
|
@@ -289,7 +294,7 @@ export function scanFetchText(file: string, content: string): SourceFinding[] {
|
|
|
289
294
|
/** Statically collect simple Nifra route registrations from source, without importing app code. */
|
|
290
295
|
export function scanStaticRouteText(file: string, content: string): StaticRouteFinding[] {
|
|
291
296
|
const code = stripComments(content)
|
|
292
|
-
if (!
|
|
297
|
+
if (!isPotentialBackendSource(code)) return []
|
|
293
298
|
return [
|
|
294
299
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
|
|
295
300
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
|
|
@@ -766,6 +771,9 @@ export interface CheckDiagnostic {
|
|
|
766
771
|
| "response-route"
|
|
767
772
|
| "undeclared-dependency"
|
|
768
773
|
| "server-manifest-drift"
|
|
774
|
+
| "manifest-drift"
|
|
775
|
+
| "capability-assurance"
|
|
776
|
+
| "capability-config"
|
|
769
777
|
/** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
|
|
770
778
|
* does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
|
|
771
779
|
* `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
|
|
@@ -826,6 +834,10 @@ const UNDECLARED_DEP_HINT =
|
|
|
826
834
|
"imported package is not declared in package.json dependencies — run bun add to declare it"
|
|
827
835
|
const MANIFEST_DRIFT_HINT =
|
|
828
836
|
"server-manifest.ts is out of sync with routes/ — re-run the build to regenerate it (a disk-less worker bakes this route table, so the drift is a silent edge break), then commit it"
|
|
837
|
+
const TRUST_MANIFEST_DRIFT_HINT =
|
|
838
|
+
"nifra.manifest.json is missing, invalid, or out of sync — run `nifra manifest emit`, review it, and commit the regenerated trust artifact"
|
|
839
|
+
const CAPABILITY_HINT =
|
|
840
|
+
"effect/capability assurance failed — align the route declaration with approved adapter provenance; never bypass an owned effect seam"
|
|
829
841
|
|
|
830
842
|
function oneLineDiff(file: string, line: number, before: string, after: string): string {
|
|
831
843
|
return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
|
|
@@ -1150,19 +1162,127 @@ export async function collectCheckResult(
|
|
|
1150
1162
|
})
|
|
1151
1163
|
}
|
|
1152
1164
|
|
|
1165
|
+
// G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
|
|
1166
|
+
// provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
|
|
1167
|
+
// without nifra.assurance.ts retain the historical scan and hot path unchanged.
|
|
1168
|
+
const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
|
|
1169
|
+
if (existsSync(assuranceConfigPath)) {
|
|
1170
|
+
try {
|
|
1171
|
+
const { loadAssuranceConfig } = await import("./assure.ts")
|
|
1172
|
+
const config = await loadAssuranceConfig(cwd)
|
|
1173
|
+
let capabilityReport:
|
|
1174
|
+
| Awaited<
|
|
1175
|
+
ReturnType<typeof import("./capabilities-tool.ts")["collectCapabilityProjectReport"]>
|
|
1176
|
+
>["report"]
|
|
1177
|
+
| undefined
|
|
1178
|
+
if (config.capabilities !== undefined) {
|
|
1179
|
+
const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
|
|
1180
|
+
const project = await collectCapabilityProjectReport(
|
|
1181
|
+
cwd,
|
|
1182
|
+
config.source,
|
|
1183
|
+
config.capabilities,
|
|
1184
|
+
)
|
|
1185
|
+
capabilityReport = project.report
|
|
1186
|
+
for (const finding of project.report.findings) {
|
|
1187
|
+
const violation =
|
|
1188
|
+
finding.code === "forbidden-effect-import"
|
|
1189
|
+
? project.violations.find(
|
|
1190
|
+
(candidate) =>
|
|
1191
|
+
candidate.method === finding.method && candidate.path === finding.path,
|
|
1192
|
+
)
|
|
1193
|
+
: undefined
|
|
1194
|
+
diagnostics.push({
|
|
1195
|
+
rule: "capability-assurance",
|
|
1196
|
+
severity: "error",
|
|
1197
|
+
...(violation !== undefined ? { file: violation.module, chain: violation.chain } : {}),
|
|
1198
|
+
message: `${finding.message} — ${CAPABILITY_HINT}`,
|
|
1199
|
+
fix: CAPABILITY_HINT,
|
|
1200
|
+
suggestion: {
|
|
1201
|
+
kind: "manual",
|
|
1202
|
+
title: "Restore declared effect provenance",
|
|
1203
|
+
steps: [
|
|
1204
|
+
"Route effectful work through an import listed in capabilities.provenance.imports.",
|
|
1205
|
+
"Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
|
|
1206
|
+
"For domain writes, add the request-idempotency or durable-command adapter required by the capability definition.",
|
|
1207
|
+
"Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
|
|
1208
|
+
],
|
|
1209
|
+
},
|
|
1210
|
+
})
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (config.manifest !== undefined) {
|
|
1214
|
+
const { evaluateRouteAssurance } = await import("@nifrajs/core/assurance")
|
|
1215
|
+
const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import(
|
|
1216
|
+
"@nifrajs/core/manifest"
|
|
1217
|
+
)
|
|
1218
|
+
const assurance = evaluateRouteAssurance(config.source, config.policy)
|
|
1219
|
+
const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
|
|
1220
|
+
let message: string | undefined
|
|
1221
|
+
if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
|
|
1222
|
+
message =
|
|
1223
|
+
"the configured assurance policy is failing, so a trusted manifest cannot be built"
|
|
1224
|
+
} else if (!existsSync(path)) {
|
|
1225
|
+
message = "the configured trust manifest is missing"
|
|
1226
|
+
} else {
|
|
1227
|
+
try {
|
|
1228
|
+
const current = await buildNifraManifest({
|
|
1229
|
+
source: config.source,
|
|
1230
|
+
assurance,
|
|
1231
|
+
...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
|
|
1232
|
+
})
|
|
1233
|
+
const storedText = await Bun.file(path).text()
|
|
1234
|
+
const stored = await parseNifraManifest(storedText, path)
|
|
1235
|
+
const expectedText = `${serializeNifraManifest(current)}\n`
|
|
1236
|
+
if (storedText !== expectedText || stored.contentHash !== current.contentHash) {
|
|
1237
|
+
message = "the configured trust manifest does not match live route reflection"
|
|
1238
|
+
}
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
message = `the configured trust manifest is invalid: ${error instanceof Error ? error.message : String(error)}`
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
if (message !== undefined) {
|
|
1244
|
+
diagnostics.push({
|
|
1245
|
+
rule: "manifest-drift",
|
|
1246
|
+
severity: "error",
|
|
1247
|
+
file: path,
|
|
1248
|
+
message: `${message} — ${TRUST_MANIFEST_DRIFT_HINT}`,
|
|
1249
|
+
fix: TRUST_MANIFEST_DRIFT_HINT,
|
|
1250
|
+
suggestion: {
|
|
1251
|
+
kind: "command",
|
|
1252
|
+
title: "Regenerate the signed-manifest input artifact",
|
|
1253
|
+
command: ["nifra", "manifest", "emit"],
|
|
1254
|
+
steps: [
|
|
1255
|
+
"Review the route, assurance, capability, and classification delta before committing it.",
|
|
1256
|
+
],
|
|
1257
|
+
},
|
|
1258
|
+
})
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
diagnostics.push({
|
|
1263
|
+
rule: "capability-config",
|
|
1264
|
+
severity: "error",
|
|
1265
|
+
file: "nifra.assurance.ts",
|
|
1266
|
+
message: `capability assurance config could not be evaluated: ${err instanceof Error ? err.message : String(err)}`,
|
|
1267
|
+
suggestion: {
|
|
1268
|
+
kind: "manual",
|
|
1269
|
+
title: "Repair the assurance config",
|
|
1270
|
+
steps: [
|
|
1271
|
+
"Ensure nifra.assurance.ts default-exports defineAssuranceConfig({ source, policy, capabilities }).",
|
|
1272
|
+
"Fix configuration/import errors; the provenance firewall fails closed when its policy cannot load.",
|
|
1273
|
+
],
|
|
1274
|
+
},
|
|
1275
|
+
})
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1153
1279
|
// Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
|
|
1154
1280
|
// message that breaks the stdio transport. `ok` reflects the FULL set — truncation never flips it.
|
|
1155
1281
|
const total = diagnostics.length
|
|
1156
1282
|
const max = opts.maxDiagnostics
|
|
1157
1283
|
const shown = max !== undefined && total > max ? diagnostics.slice(0, max) : diagnostics
|
|
1158
1284
|
return {
|
|
1159
|
-
ok:
|
|
1160
|
-
tc.ok &&
|
|
1161
|
-
fetches.length === 0 &&
|
|
1162
|
-
untypedClients.length === 0 &&
|
|
1163
|
-
serverImports.length === 0 &&
|
|
1164
|
-
manifestDrift.length === 0 &&
|
|
1165
|
-
(!dr.ran || dr.findings.length === 0),
|
|
1285
|
+
ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
|
|
1166
1286
|
typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
|
|
1167
1287
|
diagnostics: shown,
|
|
1168
1288
|
...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
|
|
@@ -1198,6 +1318,9 @@ export async function runCheck(
|
|
|
1198
1318
|
["response-route", "route returns a raw Response (typed client → data: never)"],
|
|
1199
1319
|
["undeclared-dependency", "undeclared dependency in package.json"],
|
|
1200
1320
|
["server-manifest-drift", "server-manifest.ts drifted from routes/"],
|
|
1321
|
+
["manifest-drift", "versioned trust manifest drift"],
|
|
1322
|
+
["capability-assurance", "effect/capability assurance"],
|
|
1323
|
+
["capability-config", "capability assurance config"],
|
|
1201
1324
|
] as const) {
|
|
1202
1325
|
const ds = counts(rule)
|
|
1203
1326
|
if (rule === "response-route") {
|
package/src/cli.ts
CHANGED
|
@@ -72,9 +72,34 @@ Usage:
|
|
|
72
72
|
request field or a removed response field breaks; widening a
|
|
73
73
|
request enum or adding a response field doesn't) and fails
|
|
74
74
|
closed. Exits non-zero on any breaking change — run it in CI.
|
|
75
|
+
nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
|
|
76
|
+
reflected backend route, and fail when required enforcement
|
|
77
|
+
evidence is missing/forbidden or a route is unclassified.
|
|
78
|
+
nifra capabilities snapshot [--out <file>] [--config <file>]
|
|
79
|
+
Write the deterministic, token-only capability lockfile, but
|
|
80
|
+
only after provenance + idempotency assurance passes.
|
|
81
|
+
nifra capabilities check [--lockfile <file>] [--config <file>] [--json]
|
|
82
|
+
CI gate: fail on raw effect-import bypasses, declaration/evidence
|
|
83
|
+
drift, unsafe GET/HEAD writes, idempotency gaps, or lockfile drift.
|
|
84
|
+
nifra manifest emit [--out <file>] [--config <file>] [--sign <key-ref>]
|
|
85
|
+
Emit a deterministic route trust artifact after assurance passes;
|
|
86
|
+
optionally write an Ed25519 signature via the configured KMS signer.
|
|
87
|
+
nifra manifest diff <before> <after> [--json]
|
|
88
|
+
Hash-verify and compare two manifests; fail on contract, assurance,
|
|
89
|
+
capability, or response-classification regressions.
|
|
90
|
+
nifra levels [--json] [--min <n>] [--config <file>]
|
|
91
|
+
Compute the verification ladder (L0 typed contract → L1 route
|
|
92
|
+
assurance → L2 capability lockfile → L3 route manifest → L4
|
|
93
|
+
invariant-tested); --min <n> fails the exit code below level n.
|
|
75
94
|
nifra doctor [--json] [--auto-fix] Flag packages imported in source but missing from package.json
|
|
76
95
|
(resolve at Bun runtime, break tsc + standalone install);
|
|
77
96
|
--auto-fix writes safe local-version dependency fixes.
|
|
97
|
+
nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
|
|
98
|
+
[--write] [--no-verify] matching dependency pin (preserving ^/~/exact) + apply exact
|
|
99
|
+
[--list] [--json] import moves across the workspace, then verify with nifra check.
|
|
100
|
+
Dry-run by default; --write applies then verifies (--no-verify
|
|
101
|
+
to skip). --list shows available targets. Fail-closed on an
|
|
102
|
+
unknown version. Deterministic + idempotent.
|
|
78
103
|
nifra port [--target <t>] [--json] Portability linter: print a feature × deploy-target capability
|
|
79
104
|
[--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
|
|
80
105
|
globals, node: builtins) with file:line evidence. --target auto-
|
|
@@ -89,7 +114,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
89
114
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
90
115
|
|
|
91
116
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
92
|
-
const CLI_VERSION = "1.
|
|
117
|
+
const CLI_VERSION = "1.11.0"
|
|
93
118
|
|
|
94
119
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
95
120
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
@@ -181,24 +206,28 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
|
|
|
181
206
|
if (report) console.log(`\n${renderSizeReport(result.size)}`)
|
|
182
207
|
}
|
|
183
208
|
|
|
184
|
-
/** Build
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
209
|
+
/** Build the app FACTORY for the `static` target's prerender pass. Mirrors `nifra start`: register the
|
|
210
|
+
* framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import) once, then return a factory that
|
|
211
|
+
* `createWebApp`s the app for the client build's manifest. The client entry MUST be the real content-hashed
|
|
212
|
+
* bundle (`client.entry`) — it's the hydration `<script src>` the prerendered HTML emits, so a placeholder
|
|
213
|
+
* would 404 and the pages would render but never hydrate (inert controls). Styles/route-preload are wired
|
|
214
|
+
* from the same manifest so the static HTML matches what `nifra start` serves. */
|
|
188
215
|
async function buildPrerenderApp(
|
|
189
216
|
app: LoadedApp,
|
|
190
|
-
): Promise<{ fetch(req: Request): Response | Promise<Response> }> {
|
|
217
|
+
): Promise<(client: BuiltManifest) => { fetch(req: Request): Response | Promise<Response> }> {
|
|
191
218
|
const { plugin } = await import("bun")
|
|
192
219
|
const { framework: fw, routesDir, backend } = app
|
|
193
220
|
for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
|
|
194
|
-
return
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
221
|
+
return (client) =>
|
|
222
|
+
createWebApp({
|
|
223
|
+
adapter: asAdapter(fw.adapter),
|
|
224
|
+
manifest: discoverRoutes(routesDir),
|
|
225
|
+
clientEntry: client.entry,
|
|
226
|
+
...(client.routes ? { routePreload: client.routes } : {}),
|
|
227
|
+
...(client.css ? { styles: client.css } : {}),
|
|
228
|
+
...(client.routeStyles ? { routeStyles: client.routeStyles } : {}),
|
|
229
|
+
...apiOf(backend),
|
|
230
|
+
})
|
|
202
231
|
}
|
|
203
232
|
|
|
204
233
|
interface BuiltManifest {
|
|
@@ -371,6 +400,166 @@ async function main(): Promise<void> {
|
|
|
371
400
|
}
|
|
372
401
|
return
|
|
373
402
|
}
|
|
403
|
+
// Route assurance intentionally loads only its explicit config (which imports the backend it audits),
|
|
404
|
+
// not the full SSR app. Like snapshot/diff, this works for API-only projects.
|
|
405
|
+
if (command === "assure") {
|
|
406
|
+
const { runAssurance } = await import("./assure.ts")
|
|
407
|
+
const configIdx = argv.indexOf("--config")
|
|
408
|
+
const config = configIdx !== -1 ? argv[configIdx + 1] : undefined
|
|
409
|
+
if (configIdx !== -1 && (config === undefined || config.startsWith("-"))) {
|
|
410
|
+
console.error("[nifra] --config needs a file path")
|
|
411
|
+
process.exitCode = 1
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
if (
|
|
416
|
+
!(await runAssurance(process.cwd(), {
|
|
417
|
+
json: argv.includes("--json"),
|
|
418
|
+
...(config !== undefined ? { config } : {}),
|
|
419
|
+
}))
|
|
420
|
+
)
|
|
421
|
+
process.exitCode = 1
|
|
422
|
+
} catch (err) {
|
|
423
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
424
|
+
process.exitCode = 1
|
|
425
|
+
}
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
if (command === "capabilities") {
|
|
429
|
+
const action = argv[1]
|
|
430
|
+
const valueAfter = (flag: string): string | undefined => {
|
|
431
|
+
const index = argv.indexOf(flag)
|
|
432
|
+
const value = index === -1 ? undefined : argv[index + 1]
|
|
433
|
+
if (index !== -1 && (value === undefined || value.startsWith("-"))) {
|
|
434
|
+
throw new Error(`[nifra] ${flag} needs a file path`)
|
|
435
|
+
}
|
|
436
|
+
return value
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
const { runCapabilityCheck, runCapabilitySnapshot } = await import("./capabilities-tool.ts")
|
|
440
|
+
const config = valueAfter("--config")
|
|
441
|
+
if (action === "snapshot") {
|
|
442
|
+
const out = valueAfter("--out")
|
|
443
|
+
if (
|
|
444
|
+
!(await runCapabilitySnapshot(process.cwd(), {
|
|
445
|
+
...(config !== undefined ? { config } : {}),
|
|
446
|
+
...(out !== undefined ? { out } : {}),
|
|
447
|
+
}))
|
|
448
|
+
)
|
|
449
|
+
process.exitCode = 1
|
|
450
|
+
} else if (action === "check") {
|
|
451
|
+
const lockfile = valueAfter("--lockfile")
|
|
452
|
+
if (
|
|
453
|
+
!(await runCapabilityCheck(process.cwd(), {
|
|
454
|
+
...(config !== undefined ? { config } : {}),
|
|
455
|
+
...(lockfile !== undefined ? { lockfile } : {}),
|
|
456
|
+
json: argv.includes("--json"),
|
|
457
|
+
}))
|
|
458
|
+
)
|
|
459
|
+
process.exitCode = 1
|
|
460
|
+
} else {
|
|
461
|
+
throw new Error("[nifra] capabilities needs `snapshot` or `check`")
|
|
462
|
+
}
|
|
463
|
+
} catch (err) {
|
|
464
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
465
|
+
process.exitCode = 1
|
|
466
|
+
}
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
if (command === "manifest") {
|
|
470
|
+
const action = argv[1]
|
|
471
|
+
const valueAfter = (flag: string): string | undefined => {
|
|
472
|
+
const index = argv.indexOf(flag)
|
|
473
|
+
const value = index === -1 ? undefined : argv[index + 1]
|
|
474
|
+
if (index !== -1 && (value === undefined || value.startsWith("-"))) {
|
|
475
|
+
throw new Error(`[nifra] ${flag} needs a value`)
|
|
476
|
+
}
|
|
477
|
+
return value
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const { runManifestDiff, runManifestEmit } = await import("./manifest-tool.ts")
|
|
481
|
+
if (action === "emit") {
|
|
482
|
+
const config = valueAfter("--config")
|
|
483
|
+
const out = valueAfter("--out")
|
|
484
|
+
const sign = valueAfter("--sign")
|
|
485
|
+
if (
|
|
486
|
+
!(await runManifestEmit(process.cwd(), {
|
|
487
|
+
...(config !== undefined ? { config } : {}),
|
|
488
|
+
...(out !== undefined ? { out } : {}),
|
|
489
|
+
...(sign !== undefined ? { sign } : {}),
|
|
490
|
+
}))
|
|
491
|
+
)
|
|
492
|
+
process.exitCode = 1
|
|
493
|
+
} else if (action === "diff") {
|
|
494
|
+
const paths = argv.slice(2).filter((arg) => !arg.startsWith("-"))
|
|
495
|
+
if (paths.length !== 2) throw new Error("[nifra] manifest diff needs <before> <after>")
|
|
496
|
+
if (
|
|
497
|
+
!(await runManifestDiff(process.cwd(), paths[0]!, paths[1]!, {
|
|
498
|
+
json: argv.includes("--json"),
|
|
499
|
+
}))
|
|
500
|
+
)
|
|
501
|
+
process.exitCode = 1
|
|
502
|
+
} else {
|
|
503
|
+
throw new Error("[nifra] manifest needs `emit` or `diff`")
|
|
504
|
+
}
|
|
505
|
+
} catch (err) {
|
|
506
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
507
|
+
process.exitCode = 1
|
|
508
|
+
}
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
if (command === "levels") {
|
|
512
|
+
const valueAfter = (flag: string): string | undefined => {
|
|
513
|
+
const index = argv.indexOf(flag)
|
|
514
|
+
const value = index === -1 ? undefined : argv[index + 1]
|
|
515
|
+
if (index !== -1 && (value === undefined || value.startsWith("-"))) {
|
|
516
|
+
throw new Error(`[nifra] ${flag} needs a value`)
|
|
517
|
+
}
|
|
518
|
+
return value
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
const { runLevels } = await import("./levels-tool.ts")
|
|
522
|
+
const config = valueAfter("--config")
|
|
523
|
+
const minRaw = valueAfter("--min")
|
|
524
|
+
const min = minRaw === undefined ? undefined : Number(minRaw)
|
|
525
|
+
if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
|
|
526
|
+
throw new Error("[nifra] --min must be an integer level between 0 and 4")
|
|
527
|
+
}
|
|
528
|
+
if (
|
|
529
|
+
!(await runLevels(process.cwd(), {
|
|
530
|
+
json: argv.includes("--json"),
|
|
531
|
+
...(config !== undefined ? { config } : {}),
|
|
532
|
+
...(min !== undefined ? { min } : {}),
|
|
533
|
+
}))
|
|
534
|
+
)
|
|
535
|
+
process.exitCode = 1
|
|
536
|
+
} catch (err) {
|
|
537
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
538
|
+
process.exitCode = 1
|
|
539
|
+
}
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
// `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
|
|
543
|
+
// recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` — it must run on any
|
|
544
|
+
// repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).
|
|
545
|
+
if (command === "upgrade") {
|
|
546
|
+
const { runUpgrade } = await import("./upgrade.ts")
|
|
547
|
+
const version = argv.slice(1).find((arg) => !arg.startsWith("-"))
|
|
548
|
+
try {
|
|
549
|
+
const ok = await runUpgrade(process.cwd(), {
|
|
550
|
+
...(version !== undefined ? { version } : {}),
|
|
551
|
+
write: argv.includes("--write"),
|
|
552
|
+
json: argv.includes("--json"),
|
|
553
|
+
list: argv.includes("--list"),
|
|
554
|
+
verify: !argv.includes("--no-verify"),
|
|
555
|
+
})
|
|
556
|
+
if (!ok) process.exitCode = 1
|
|
557
|
+
} catch (err) {
|
|
558
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
559
|
+
process.exitCode = 1
|
|
560
|
+
}
|
|
561
|
+
return
|
|
562
|
+
}
|
|
374
563
|
// `port` is a pure cwd-based portability linter (scans source, doesn't run the app) — like `check`/
|
|
375
564
|
// `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
|
|
376
565
|
if (command === "port") {
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nifra levels` — the verification ladder, computed from gates that already exist:
|
|
3
|
+
*
|
|
4
|
+
* L0 typed contract — `nifra check` passes (compiler-enforced frontend↔backend contract)
|
|
5
|
+
* L1 route assurance — `nifra.assurance.ts` present and every route satisfies its policy
|
|
6
|
+
* L2 capability lockfile — capability/effect assurance passes and the lockfile is in sync
|
|
7
|
+
* L3 route manifest — the emitted manifest hash-verifies and matches the current app
|
|
8
|
+
* L4 invariant-tested — every route passes through an explicitly isolated executor
|
|
9
|
+
*
|
|
10
|
+
* A level counts only when every level below it holds — the ladder is cumulative, so "L3" is one
|
|
11
|
+
* honest word for a whole posture. Levels are computed, never self-declared.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from "node:fs"
|
|
15
|
+
import { resolve } from "node:path"
|
|
16
|
+
import { evaluateRouteAssurance } from "@nifrajs/core/assurance"
|
|
17
|
+
import { type evaluateCapabilityAssurance, snapshotCapabilities } from "@nifrajs/core/capabilities"
|
|
18
|
+
import { runContractInvariants } from "@nifrajs/core/invariants"
|
|
19
|
+
import { buildNifraManifest, parseNifraManifest } from "@nifrajs/core/manifest"
|
|
20
|
+
import { loadAssuranceConfig } from "./assure.ts"
|
|
21
|
+
import {
|
|
22
|
+
collectCapabilityProjectReport,
|
|
23
|
+
diffCapabilitySnapshots,
|
|
24
|
+
parseCapabilityLockfile,
|
|
25
|
+
} from "./capabilities-tool.ts"
|
|
26
|
+
import { collectCheckResult } from "./check.ts"
|
|
27
|
+
import { DEFAULT_MANIFEST_FILE } from "./manifest-tool.ts"
|
|
28
|
+
|
|
29
|
+
export interface VerificationLevelStatus {
|
|
30
|
+
readonly level: number
|
|
31
|
+
readonly name: string
|
|
32
|
+
readonly ok: boolean
|
|
33
|
+
/** Why the level does not hold — a gate failure or a missing prerequisite. Empty when ok. */
|
|
34
|
+
readonly reasons: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VerificationLevelsResult {
|
|
38
|
+
/** Highest level whose whole ladder holds; -1 when even L0 fails. */
|
|
39
|
+
readonly achieved: number
|
|
40
|
+
readonly levels: readonly VerificationLevelStatus[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CollectLevelsOptions {
|
|
44
|
+
readonly config?: string
|
|
45
|
+
/** Deterministic seed for the L4 invariant run. Default 1. */
|
|
46
|
+
readonly seed?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compute the ladder. Each level is evaluated independently, then gated on the levels below. */
|
|
50
|
+
export async function collectVerificationLevels(
|
|
51
|
+
cwd: string,
|
|
52
|
+
options: CollectLevelsOptions = {},
|
|
53
|
+
): Promise<VerificationLevelsResult> {
|
|
54
|
+
const statuses: { level: number; name: string; ok: boolean; reasons: string[] }[] = []
|
|
55
|
+
|
|
56
|
+
// L0 — typed contract (`nifra check`).
|
|
57
|
+
const check = await collectCheckResult(cwd, { maxDiagnostics: 5 })
|
|
58
|
+
statuses.push({
|
|
59
|
+
level: 0,
|
|
60
|
+
name: "typed contract",
|
|
61
|
+
ok: check.ok,
|
|
62
|
+
reasons: check.ok ? [] : check.diagnostics.map((d) => `${d.rule}: ${d.message}`).slice(0, 5),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// L1-L4 all hang off the assurance config; without it the ladder stops at L0 by definition.
|
|
66
|
+
let config: Awaited<ReturnType<typeof loadAssuranceConfig>> | undefined
|
|
67
|
+
try {
|
|
68
|
+
config = await loadAssuranceConfig(cwd, options.config)
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
71
|
+
for (const [level, name] of [
|
|
72
|
+
[1, "route assurance"],
|
|
73
|
+
[2, "capability lockfile"],
|
|
74
|
+
[3, "route manifest"],
|
|
75
|
+
[4, "invariant-tested"],
|
|
76
|
+
] as const) {
|
|
77
|
+
statuses.push({ level, name, ok: false, reasons: [reason] })
|
|
78
|
+
}
|
|
79
|
+
return finalize(statuses)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// L1 — route assurance.
|
|
83
|
+
const assurance = evaluateRouteAssurance(config.source, config.policy)
|
|
84
|
+
statuses.push({
|
|
85
|
+
level: 1,
|
|
86
|
+
name: "route assurance",
|
|
87
|
+
ok: assurance.ok,
|
|
88
|
+
reasons: assurance.findings.map((f) => f.message).slice(0, 5),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// L2 — capability assurance + lockfile in sync.
|
|
92
|
+
const l2Reasons: string[] = []
|
|
93
|
+
let capabilityReport: ReturnType<typeof evaluateCapabilityAssurance> | undefined
|
|
94
|
+
if (config.capabilities === undefined) {
|
|
95
|
+
l2Reasons.push("nifra.assurance.ts declares no capabilities policy")
|
|
96
|
+
} else {
|
|
97
|
+
const project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
|
|
98
|
+
capabilityReport = project.report
|
|
99
|
+
if (!project.report.ok) {
|
|
100
|
+
l2Reasons.push(...project.report.findings.map((f) => f.message).slice(0, 5))
|
|
101
|
+
}
|
|
102
|
+
const lockfilePath = resolve(cwd, config.capabilities.lockfile ?? "capabilities.lock.json")
|
|
103
|
+
if (!existsSync(lockfilePath)) {
|
|
104
|
+
l2Reasons.push(`capability lockfile missing (${lockfilePath})`)
|
|
105
|
+
} else if (project.report.ok) {
|
|
106
|
+
const recorded = parseCapabilityLockfile(await Bun.file(lockfilePath).text(), lockfilePath)
|
|
107
|
+
const drift = diffCapabilitySnapshots(recorded, snapshotCapabilities(project.report))
|
|
108
|
+
if (drift.length > 0) l2Reasons.push(...drift.slice(0, 5))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
statuses.push({
|
|
112
|
+
level: 2,
|
|
113
|
+
name: "capability lockfile",
|
|
114
|
+
ok: l2Reasons.length === 0,
|
|
115
|
+
reasons: l2Reasons,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// L3 — emitted manifest hash-verifies AND matches the current app.
|
|
119
|
+
const l3Reasons: string[] = []
|
|
120
|
+
const manifestPath = resolve(cwd, config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
|
|
121
|
+
if (!existsSync(manifestPath)) {
|
|
122
|
+
l3Reasons.push(`manifest missing (${manifestPath}) — run \`nifra manifest emit\``)
|
|
123
|
+
} else {
|
|
124
|
+
try {
|
|
125
|
+
const recorded = await parseNifraManifest(await Bun.file(manifestPath).text(), manifestPath)
|
|
126
|
+
if (assurance.ok && (capabilityReport === undefined || capabilityReport.ok)) {
|
|
127
|
+
const current = await buildNifraManifest({
|
|
128
|
+
source: config.source,
|
|
129
|
+
assurance,
|
|
130
|
+
...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
|
|
131
|
+
})
|
|
132
|
+
if (current.contentHash !== recorded.contentHash) {
|
|
133
|
+
l3Reasons.push("manifest is stale — the app changed since it was emitted")
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
l3Reasons.push("manifest cannot be validated while assurance is failing")
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
l3Reasons.push(err instanceof Error ? err.message : String(err))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
statuses.push({
|
|
143
|
+
level: 3,
|
|
144
|
+
name: "route manifest",
|
|
145
|
+
ok: l3Reasons.length === 0,
|
|
146
|
+
reasons: l3Reasons,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// L4 — contract-generated invariants against an explicitly isolated executor.
|
|
150
|
+
const l4Reasons: string[] = []
|
|
151
|
+
if (config.invariants === undefined) {
|
|
152
|
+
l4Reasons.push(
|
|
153
|
+
"no isolated invariant executor configured — add `invariants.executor` in nifra.assurance.ts",
|
|
154
|
+
)
|
|
155
|
+
} else {
|
|
156
|
+
const invariants = await runContractInvariants(config.source as object, {
|
|
157
|
+
seed: options.seed ?? 1,
|
|
158
|
+
executor: config.invariants.executor,
|
|
159
|
+
})
|
|
160
|
+
if (!invariants.ok) l4Reasons.push(...invariants.findings.map((f) => f.message).slice(0, 5))
|
|
161
|
+
if (invariants.skipped.length > 0) {
|
|
162
|
+
l4Reasons.push(
|
|
163
|
+
...invariants.skipped
|
|
164
|
+
.slice(0, 5)
|
|
165
|
+
.map(
|
|
166
|
+
(route) => `${route.method} ${route.path} was not invariant-tested: ${route.reason}`,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
statuses.push({
|
|
172
|
+
level: 4,
|
|
173
|
+
name: "invariant-tested",
|
|
174
|
+
ok: l4Reasons.length === 0,
|
|
175
|
+
reasons: l4Reasons,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return finalize(statuses)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function finalize(
|
|
182
|
+
statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
|
|
183
|
+
): VerificationLevelsResult {
|
|
184
|
+
// Cumulative: the achieved level is the last rung with every rung below it also holding.
|
|
185
|
+
let achieved = -1
|
|
186
|
+
for (const status of statuses) {
|
|
187
|
+
if (!status.ok) break
|
|
188
|
+
achieved = status.level
|
|
189
|
+
}
|
|
190
|
+
return Object.freeze({
|
|
191
|
+
achieved,
|
|
192
|
+
levels: Object.freeze(
|
|
193
|
+
statuses.map((status) =>
|
|
194
|
+
Object.freeze({ ...status, reasons: Object.freeze([...status.reasons]) }),
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** CLI entry: print the ladder (or JSON) and return whether the requested floor is met. */
|
|
201
|
+
export async function runLevels(
|
|
202
|
+
cwd: string,
|
|
203
|
+
options: CollectLevelsOptions & { readonly json?: boolean; readonly min?: number } = {},
|
|
204
|
+
): Promise<boolean> {
|
|
205
|
+
const result = await collectVerificationLevels(cwd, options)
|
|
206
|
+
if (options.json === true) {
|
|
207
|
+
console.log(JSON.stringify(result, null, 2))
|
|
208
|
+
} else {
|
|
209
|
+
for (const status of result.levels) {
|
|
210
|
+
const mark = status.ok ? "✓" : "✗"
|
|
211
|
+
console.log(`${mark} L${status.level} ${status.name}`)
|
|
212
|
+
for (const reason of status.reasons) console.log(` - ${reason}`)
|
|
213
|
+
}
|
|
214
|
+
console.log(
|
|
215
|
+
result.achieved < 0
|
|
216
|
+
? "[nifra] verification level: none (L0 failing)"
|
|
217
|
+
: `[nifra] verification level: L${result.achieved}`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const min = options.min ?? 0
|
|
221
|
+
return result.achieved >= min
|
|
222
|
+
}
|