@nifrajs/cli 1.5.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 +1924 -33
  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/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 isPotentialNifraBackendSource(code: string): boolean {
199
- return code.includes("@nifrajs/core") || /(?<![.\w])server\s*\(/.test(code)
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 (!isPotentialNifraBackendSource(code)) return []
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.5.0"
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 a fetch-capable app instance for the `static` target's prerender pass. Mirrors `nifra start`:
185
- * register the framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import), discover routes,
186
- * and wire the in-process backend so build-time loaders can read data. The returned `Server` already
187
- * satisfies `buildTarget`'s `PrerenderAppLike` (a `fetch(req)` yielding a `Response`). */
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 createWebApp({
195
- adapter: asAdapter(fw.adapter),
196
- manifest: discoverRoutes(routesDir),
197
- // The client entry is irrelevant to the static HTML (it's a hydration script tag) — a placeholder
198
- // is fine; the real hashed bundle is emitted to /assets by buildTarget's client build.
199
- clientEntry: "/assets/_nifra-entry.js",
200
- ...apiOf(backend),
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
+ }