@nifrajs/cli 1.6.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +33 -1
  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 +136 -11
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +199 -12
  16. package/dist/cli.js.map +1 -1
  17. package/dist/doctor.d.ts +17 -0
  18. package/dist/doctor.d.ts.map +1 -1
  19. package/dist/doctor.js +146 -10
  20. package/dist/doctor.js.map +1 -1
  21. package/dist/levels-tool.d.ts +37 -0
  22. package/dist/levels-tool.d.ts.map +1 -0
  23. package/dist/levels-tool.js +183 -0
  24. package/dist/levels-tool.js.map +1 -0
  25. package/dist/manifest-tool.d.ts +15 -0
  26. package/dist/manifest-tool.d.ts.map +1 -0
  27. package/dist/manifest-tool.js +67 -0
  28. package/dist/manifest-tool.js.map +1 -0
  29. package/dist/mcp-http.js +1 -1
  30. package/dist/mcp-http.js.map +1 -1
  31. package/dist/mcp.d.ts +1 -0
  32. package/dist/mcp.d.ts.map +1 -1
  33. package/dist/mcp.js +31 -0
  34. package/dist/mcp.js.map +1 -1
  35. package/dist/recipes/1.8.0.d.ts +12 -0
  36. package/dist/recipes/1.8.0.d.ts.map +1 -0
  37. package/dist/recipes/1.8.0.js +20 -0
  38. package/dist/recipes/1.8.0.js.map +1 -0
  39. package/dist/recipes/index.d.ts +28 -0
  40. package/dist/recipes/index.d.ts.map +1 -0
  41. package/dist/recipes/index.js +27 -0
  42. package/dist/recipes/index.js.map +1 -0
  43. package/dist/upgrade.d.ts +82 -0
  44. package/dist/upgrade.d.ts.map +1 -0
  45. package/dist/upgrade.js +220 -0
  46. package/dist/upgrade.js.map +1 -0
  47. package/docs/examples.json +12 -0
  48. package/docs/llms-full.txt +524 -15
  49. package/docs/types.json +2409 -50
  50. package/package.json +9 -8
  51. package/src/assure.ts +88 -0
  52. package/src/capabilities-tool.ts +406 -0
  53. package/src/check.ts +155 -12
  54. package/src/cli.ts +206 -17
  55. package/src/doctor.ts +179 -11
  56. package/src/levels-tool.ts +232 -0
  57. package/src/manifest-tool.ts +103 -0
  58. package/src/mcp-http.ts +1 -1
  59. package/src/mcp.ts +37 -0
  60. package/src/recipes/1.8.0.ts +21 -0
  61. package/src/recipes/index.ts +52 -0
  62. package/src/upgrade.ts +269 -0
package/src/doctor.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
11
11
  * the package's own name, and tsconfig `paths` aliases are excluded — none of them are npm deps.
12
12
  */
13
+ import { realpath } from "node:fs/promises"
13
14
  import { builtinModules } from "node:module"
14
- import { dirname, join } from "node:path"
15
+ import { dirname, join, relative } from "node:path"
15
16
  import { type SourceFinding, stripComments, walkSource } from "./check.ts"
16
17
 
17
18
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
@@ -117,12 +118,28 @@ export interface DoctorResult {
117
118
  /** `false` when no `package.json` was found at cwd — doctor can't run (reported, not a crash). */
118
119
  readonly ran: boolean
119
120
  readonly findings: readonly DoctorFinding[]
121
+ /** Packages that resolve to more than one physical install across this workspace. */
122
+ readonly duplicateInstalls: readonly DuplicateInstallFinding[]
120
123
  /** Dependencies written by `--auto-fix` / MCP `autoFix:true`. */
121
124
  readonly fixed?: readonly DoctorAppliedFix[]
122
125
  /** Findings that were safe to report but not safe to write automatically. */
123
126
  readonly skippedFixes?: readonly DoctorSkippedFix[]
124
127
  }
125
128
 
129
+ export interface DuplicateInstallCopy {
130
+ /** Installed version, or `unknown` when package metadata is incomplete. */
131
+ readonly version: string
132
+ /** Physical package directory, relative to the doctor root when possible. */
133
+ readonly path: string
134
+ /** Workspace package roots whose normal Node/Bun resolution selects this copy. */
135
+ readonly importers: readonly string[]
136
+ }
137
+
138
+ export interface DuplicateInstallFinding {
139
+ readonly package: string
140
+ readonly copies: readonly DuplicateInstallCopy[]
141
+ }
142
+
126
143
  export interface DoctorAppliedFix {
127
144
  readonly package: string
128
145
  readonly field: "dependencies"
@@ -159,6 +176,132 @@ const depNames = (pkg: Record<string, unknown>, field: string): string[] => {
159
176
  const isRecord = (value: unknown): value is Record<string, unknown> =>
160
177
  typeof value === "object" && value !== null && !Array.isArray(value)
161
178
 
179
+ const MAX_WORKSPACE_IMPORTERS = 2_048
180
+ const IDENTITY_SENSITIVE_PACKAGES = new Set(["@nifrajs/core", "react", "react-dom"])
181
+
182
+ function workspacePatterns(pkg: Record<string, unknown>): string[] {
183
+ const raw = pkg.workspaces
184
+ const entries = Array.isArray(raw)
185
+ ? raw
186
+ : isRecord(raw) && Array.isArray(raw.packages)
187
+ ? raw.packages
188
+ : []
189
+ return entries
190
+ .filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
191
+ .sort()
192
+ }
193
+
194
+ /** Discover package roots declared by npm/Bun's two workspace manifest shapes. Bounded so a hostile
195
+ * `**` pattern cannot turn a diagnostic into an unbounded filesystem crawl. */
196
+ async function workspaceImporters(
197
+ cwd: string,
198
+ rootPackage: Record<string, unknown>,
199
+ ): Promise<Array<{ root: string; package: Record<string, unknown> }>> {
200
+ const manifests = new Set<string>([join(cwd, "package.json")])
201
+ for (const pattern of workspacePatterns(rootPackage)) {
202
+ const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
203
+ for await (const rel of new Bun.Glob(packagePattern).scan({ cwd, dot: false })) {
204
+ if (rel.split(/[\\/]/).includes("node_modules")) continue
205
+ manifests.add(join(cwd, rel))
206
+ // A pathological workspace pattern should not make doctor unbounded. Duplicate detection is
207
+ // skipped, while the existing source/declaration diagnostic still runs normally.
208
+ if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
209
+ }
210
+ }
211
+
212
+ const out: Array<{ root: string; package: Record<string, unknown> }> = []
213
+ for (const manifest of [...manifests].sort()) {
214
+ const pkg = await readJson(manifest)
215
+ if (pkg !== undefined) out.push({ root: dirname(manifest), package: pkg })
216
+ }
217
+ return out
218
+ }
219
+
220
+ function duplicateTargets(pkg: Record<string, unknown>): string[] {
221
+ const targets = new Set<string>()
222
+ for (const field of DEPENDENCY_FIELDS) {
223
+ for (const name of depNames(pkg, field)) {
224
+ if (name.startsWith("@nifrajs/") || IDENTITY_SENSITIVE_PACKAGES.has(name)) targets.add(name)
225
+ }
226
+ }
227
+ return [...targets].sort()
228
+ }
229
+
230
+ async function resolvedInstalledCopy(
231
+ importer: string,
232
+ boundary: string,
233
+ name: string,
234
+ ): Promise<{ path: string; version: string } | undefined> {
235
+ const parts = name.split("/")
236
+ for (let dir = importer; ; dir = dirname(dir)) {
237
+ const packageDir = join(dir, "node_modules", ...parts)
238
+ const meta = await readJson(join(packageDir, "package.json"))
239
+ if (meta !== undefined) {
240
+ try {
241
+ return {
242
+ path: await realpath(packageDir),
243
+ version:
244
+ typeof meta.version === "string" && meta.version.length > 0 ? meta.version : "unknown",
245
+ }
246
+ } catch {
247
+ return undefined
248
+ }
249
+ }
250
+ if (dir === boundary) return undefined
251
+ const parent = dirname(dir)
252
+ if (parent === dir) return undefined
253
+ }
254
+ }
255
+
256
+ const displayPath = (cwd: string, path: string): string => {
257
+ const rel = relative(cwd, path)
258
+ return rel === "" ? "." : rel
259
+ }
260
+
261
+ /** Find identity-sensitive dependencies that resolve to multiple physical directories. Two copies at
262
+ * the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based. */
263
+ export async function collectDuplicateInstalls(
264
+ cwd: string,
265
+ rootPackage: Record<string, unknown>,
266
+ ): Promise<DuplicateInstallFinding[]> {
267
+ const importers = await workspaceImporters(cwd, rootPackage)
268
+ const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
269
+ for (const importer of importers) {
270
+ for (const name of duplicateTargets(importer.package)) {
271
+ if (importer.package.name === name) continue
272
+ const copy = await resolvedInstalledCopy(importer.root, cwd, name)
273
+ if (copy === undefined) continue
274
+ let copies = byPackage.get(name)
275
+ if (copies === undefined) {
276
+ copies = new Map()
277
+ byPackage.set(name, copies)
278
+ }
279
+ const record = copies.get(copy.path) ?? {
280
+ version: copy.version,
281
+ importers: new Set<string>(),
282
+ }
283
+ record.importers.add(displayPath(cwd, importer.root))
284
+ copies.set(copy.path, record)
285
+ }
286
+ }
287
+
288
+ const findings: DuplicateInstallFinding[] = []
289
+ for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
290
+ if (copies.size < 2) continue
291
+ findings.push({
292
+ package: name,
293
+ copies: [...copies.entries()]
294
+ .sort(([a], [b]) => a.localeCompare(b))
295
+ .map(([path, copy]) => ({
296
+ version: copy.version,
297
+ path: displayPath(cwd, path),
298
+ importers: [...copy.importers].sort(),
299
+ })),
300
+ })
301
+ }
302
+ return findings
303
+ }
304
+
162
305
  function depRecord(
163
306
  pkg: Record<string, unknown>,
164
307
  field: string,
@@ -217,7 +360,7 @@ async function inferDependencyFix(
217
360
  /** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
218
361
  export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
219
362
  const pkg = await readJson(join(cwd, "package.json"))
220
- if (pkg === undefined) return { ok: true, ran: false, findings: [] }
363
+ if (pkg === undefined) return { ok: true, ran: false, findings: [], duplicateInstalls: [] }
221
364
 
222
365
  const declared = new Set<string>()
223
366
  if (typeof pkg.name === "string") declared.add(pkg.name) // a package may import its own name (exports map)
@@ -238,7 +381,13 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
238
381
  }
239
382
  })
240
383
  findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
241
- return { ok: findings.length === 0, ran: true, findings }
384
+ const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
385
+ return {
386
+ ok: findings.length === 0 && duplicateInstalls.length === 0,
387
+ ran: true,
388
+ findings,
389
+ duplicateInstalls,
390
+ }
242
391
  }
243
392
 
244
393
  /** Safely add undeclared imports to package.json when the version can be inferred without network I/O. */
@@ -327,7 +476,9 @@ export async function runDoctor(
327
476
  console.log("")
328
477
  }
329
478
  if (result.ok) {
330
- console.log("✓ every imported package is declared in package.json")
479
+ console.log(
480
+ "✓ every imported package is declared and identity-sensitive installs are deduplicated",
481
+ )
331
482
  return true
332
483
  }
333
484
  // Group by package so the fix ("add X to dependencies") is stated once with its import sites.
@@ -337,13 +488,30 @@ export async function runDoctor(
337
488
  list.push(f)
338
489
  byPkg.set(f.package, list)
339
490
  }
340
- console.log(`✗ ${byPkg.size} package(s) imported but not declared in package.json:\n`)
341
- for (const [pkg, sites] of [...byPkg.entries()].sort()) {
342
- console.log(` ${pkg} — add to dependencies (\`bun add ${pkg}\`)`)
343
- for (const s of sites) console.log(` ${s.file}:${s.line}`)
491
+ if (byPkg.size > 0) {
492
+ console.log(`✗ ${byPkg.size} package(s) imported but not declared in package.json:\n`)
493
+ for (const [pkg, sites] of [...byPkg.entries()].sort()) {
494
+ console.log(` ${pkg} - add to dependencies (\`bun add ${pkg}\`)`)
495
+ for (const s of sites) console.log(` ${s.file}:${s.line}`)
496
+ }
344
497
  }
345
- console.log(
346
- "\nThese resolve at Bun runtime via hoisting/workspace but break `tsc` and a standalone install.",
347
- )
498
+ if (result.duplicateInstalls.length > 0) {
499
+ console.log(
500
+ `${byPkg.size > 0 ? "\n" : ""}✗ identity-sensitive packages resolve to multiple physical copies:\n`,
501
+ )
502
+ for (const finding of result.duplicateInstalls) {
503
+ console.log(` ${finding.package}`)
504
+ for (const copy of finding.copies) {
505
+ console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
506
+ }
507
+ }
508
+ console.log(
509
+ "\n Align dependency ranges and reinstall from the workspace root; doctor never deletes installs.",
510
+ )
511
+ }
512
+ if (byPkg.size > 0)
513
+ console.log(
514
+ "\nThese resolve at Bun runtime via hoisting/workspace but break `tsc` and a standalone install.",
515
+ )
348
516
  return false
349
517
  }
@@ -0,0 +1,232 @@
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 { buildNifraManifest, parseNifraManifest } from "@nifrajs/core/manifest"
19
+ import { type ContractTestApp, runAdversarialContract } from "@nifrajs/testing"
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 runAdversarialContract(config.source as ContractTestApp, {
157
+ seed: options.seed ?? 1,
158
+ runtimes: [{ name: "isolated", fetch: config.invariants.executor }],
159
+ expectedValidationStatuses: [400, 422],
160
+ requireCoverage: true,
161
+ })
162
+ if (!invariants.ok) {
163
+ l4Reasons.push(
164
+ ...invariants.failures
165
+ .slice(0, 5)
166
+ .map(
167
+ (failure) =>
168
+ `${failure.runtime}: ${failure.id} - ${failure.message} (replay seed ${failure.replay.seed})`,
169
+ ),
170
+ )
171
+ l4Reasons.push(
172
+ ...invariants.gaps
173
+ .slice(0, Math.max(0, 5 - l4Reasons.length))
174
+ .map(
175
+ (gap) =>
176
+ `${gap.route ?? "contract"}${gap.target ? ` ${gap.target}` : ""}: ${gap.message}`,
177
+ ),
178
+ )
179
+ }
180
+ }
181
+ statuses.push({
182
+ level: 4,
183
+ name: "invariant-tested",
184
+ ok: l4Reasons.length === 0,
185
+ reasons: l4Reasons,
186
+ })
187
+
188
+ return finalize(statuses)
189
+ }
190
+
191
+ function finalize(
192
+ statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
193
+ ): VerificationLevelsResult {
194
+ // Cumulative: the achieved level is the last rung with every rung below it also holding.
195
+ let achieved = -1
196
+ for (const status of statuses) {
197
+ if (!status.ok) break
198
+ achieved = status.level
199
+ }
200
+ return Object.freeze({
201
+ achieved,
202
+ levels: Object.freeze(
203
+ statuses.map((status) =>
204
+ Object.freeze({ ...status, reasons: Object.freeze([...status.reasons]) }),
205
+ ),
206
+ ),
207
+ })
208
+ }
209
+
210
+ /** CLI entry: print the ladder (or JSON) and return whether the requested floor is met. */
211
+ export async function runLevels(
212
+ cwd: string,
213
+ options: CollectLevelsOptions & { readonly json?: boolean; readonly min?: number } = {},
214
+ ): Promise<boolean> {
215
+ const result = await collectVerificationLevels(cwd, options)
216
+ if (options.json === true) {
217
+ console.log(JSON.stringify(result, null, 2))
218
+ } else {
219
+ for (const status of result.levels) {
220
+ const mark = status.ok ? "✓" : "✗"
221
+ console.log(`${mark} L${status.level} ${status.name}`)
222
+ for (const reason of status.reasons) console.log(` - ${reason}`)
223
+ }
224
+ console.log(
225
+ result.achieved < 0
226
+ ? "[nifra] verification level: none (L0 failing)"
227
+ : `[nifra] verification level: L${result.achieved}`,
228
+ )
229
+ }
230
+ const min = options.min ?? 0
231
+ return result.achieved >= min
232
+ }
@@ -0,0 +1,103 @@
1
+ /** `nifra manifest` — emit/sign/diff the deployable route trust artifact. */
2
+
3
+ import { existsSync } from "node:fs"
4
+ import { resolve } from "node:path"
5
+ import { evaluateRouteAssurance } from "@nifrajs/core/assurance"
6
+ import {
7
+ buildNifraManifest,
8
+ diffNifraManifests,
9
+ type NifraManifestDiff,
10
+ parseNifraManifest,
11
+ serializeNifraManifest,
12
+ serializeNifraManifestSignature,
13
+ signNifraManifest,
14
+ } from "@nifrajs/core/manifest"
15
+ import { loadAssuranceConfig } from "./assure.ts"
16
+ import { collectCapabilityProjectReport } from "./capabilities-tool.ts"
17
+
18
+ export const DEFAULT_MANIFEST_FILE = "nifra.manifest.json"
19
+
20
+ export function formatManifestDiff(diff: NifraManifestDiff): string {
21
+ if (diff.changes.length === 0) return "No manifest changes."
22
+ const weight = { breaking: 0, compatible: 1, info: 2 } as const
23
+ const lines = [...diff.changes]
24
+ .sort(
25
+ (a, b) =>
26
+ weight[a.severity] - weight[b.severity] ||
27
+ a.path.localeCompare(b.path) ||
28
+ a.method.localeCompare(b.method),
29
+ )
30
+ .map(
31
+ (change) =>
32
+ `${change.severity === "breaking" ? "✖" : change.severity === "compatible" ? "✓" : "•"} ${change.method} ${change.path} [${change.section}] ${change.message}`,
33
+ )
34
+ const breaking = diff.changes.filter((change) => change.severity === "breaking").length
35
+ lines.push(
36
+ diff.hasBreaking
37
+ ? `${breaking} breaking manifest change${breaking === 1 ? "" : "s"}.`
38
+ : "No breaking manifest changes.",
39
+ )
40
+ return lines.join("\n")
41
+ }
42
+
43
+ /** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
44
+ export async function runManifestEmit(
45
+ cwd: string,
46
+ options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
47
+ ): Promise<boolean> {
48
+ const config = await loadAssuranceConfig(cwd, options.config)
49
+ const assurance = evaluateRouteAssurance(config.source, config.policy)
50
+ const capabilityProject =
51
+ config.capabilities === undefined
52
+ ? undefined
53
+ : await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
54
+ if (!assurance.ok || (capabilityProject !== undefined && !capabilityProject.report.ok)) {
55
+ console.error("[nifra] refusing to emit a manifest from failing assurance")
56
+ return false
57
+ }
58
+ const manifest = await buildNifraManifest({
59
+ source: config.source,
60
+ assurance,
61
+ ...(capabilityProject !== undefined ? { capabilities: capabilityProject.report } : {}),
62
+ })
63
+ const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
64
+ await Bun.write(path, `${serializeNifraManifest(manifest)}\n`)
65
+
66
+ if (options.sign !== undefined) {
67
+ if (config.manifest?.signer === undefined) {
68
+ throw new Error(
69
+ "[nifra] --sign requires manifest.signer in nifra.assurance.ts; keep private keys behind that KMS/HSM callback.",
70
+ )
71
+ }
72
+ const signer = await config.manifest.signer(options.sign)
73
+ const signature = await signNifraManifest(manifest, signer)
74
+ await Bun.write(`${path}.sig`, `${serializeNifraManifestSignature(signature)}\n`)
75
+ }
76
+ console.log(
77
+ `[nifra] wrote manifest to ${path}${options.sign === undefined ? "" : ` and ${path}.sig`}`,
78
+ )
79
+ return true
80
+ }
81
+
82
+ /** Diff two already-emitted, hash-verified artifacts. Suitable for deploy promotion gates. */
83
+ export async function runManifestDiff(
84
+ cwd: string,
85
+ beforePath: string,
86
+ afterPath: string,
87
+ options: { readonly json?: boolean } = {},
88
+ ): Promise<boolean> {
89
+ const before = resolve(cwd, beforePath)
90
+ const after = resolve(cwd, afterPath)
91
+ if (!existsSync(before)) throw new Error(`[nifra] manifest not found: ${before}`)
92
+ if (!existsSync(after)) throw new Error(`[nifra] manifest not found: ${after}`)
93
+ const diff = diffNifraManifests(
94
+ await parseNifraManifest(await Bun.file(before).text(), before),
95
+ await parseNifraManifest(await Bun.file(after).text(), after),
96
+ )
97
+ console.log(
98
+ options.json === true
99
+ ? JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2)
100
+ : formatManifestDiff(diff),
101
+ )
102
+ return !diff.hasBreaking
103
+ }
package/src/mcp-http.ts CHANGED
@@ -26,7 +26,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
26
26
  export type { TypeEntry } from "./types-search.ts"
27
27
 
28
28
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
29
- const VERSION = "1.6.0"
29
+ const VERSION = "1.12.0"
30
30
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
31
31
  const DOCS_HEALTH =
32
32
  "nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example."
package/src/mcp.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * so it can't hallucinate a drifted nifra API).
15
15
  * - `nifra_scaffold`— map a URL path to the correct `routes/` file + a contract-correct page stub.
16
16
  * - `nifra_check` — the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
17
+ * - `nifra_assure` — route classification + enforcement-evidence gate.
17
18
  * - `nifra_doctor` — package.json dependency drift detector, with safe local-version auto-fix.
18
19
  *
19
20
  * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
@@ -951,6 +952,42 @@ export function projectTools(
951
952
  )
952
953
  },
953
954
  },
955
+ {
956
+ name: "nifra_assure",
957
+ description:
958
+ "Evaluate nifra.assurance.ts and return the complete route-assurance report: every reflected route's first matching policy rule, enforcement evidence, missing/forbidden evidence, and the fail-closed ok bit. Use after adding or changing routes/security middleware; fix every finding before finishing.",
959
+ inputSchema: {
960
+ type: "object",
961
+ properties: {
962
+ config: {
963
+ type: "string",
964
+ description:
965
+ "Config path relative to the selected project directory. Default: nifra.assurance.ts.",
966
+ },
967
+ dir: {
968
+ type: "string",
969
+ description:
970
+ 'Evaluate this project subdirectory (relative to the MCP root), e.g. "apps/api". Default: the project root.',
971
+ },
972
+ },
973
+ additionalProperties: false,
974
+ },
975
+ handler: async (args) => {
976
+ const opts = args as { config?: string; dir?: string }
977
+ const target = resolveProjectDir(cwd, opts.dir)
978
+ if (target === null) return dirError(opts.dir)
979
+ const config = opts.config === undefined ? undefined : resolve(target, opts.config)
980
+ if (config !== undefined && config !== target && !config.startsWith(target + sep)) {
981
+ return JSON.stringify(
982
+ { ok: false, error: "config must stay inside the selected project directory" },
983
+ null,
984
+ 2,
985
+ )
986
+ }
987
+ const { collectAssuranceReport } = await import("./assure.ts")
988
+ return JSON.stringify(await collectAssuranceReport(target, config), null, 2)
989
+ },
990
+ },
954
991
  {
955
992
  name: "nifra_doctor",
956
993
  description:
@@ -0,0 +1,21 @@
1
+ import type { UpgradeRecipe } from "./index.ts"
2
+
3
+ /**
4
+ * Upgrade recipe for nifra 1.8.0.
5
+ *
6
+ * 1.8.0 is a fixed-group bump: every `@nifrajs/*` package moves in lockstep. The pin sweep sets them
7
+ * all to 1.8.0 (preserving each spec's `^`/`~`/exact style). 1.8.0 introduced no breaking import moves
8
+ * in the public packages — the new surfaces (route assurance, adversarial testing, `@nifrajs/events`)
9
+ * are additive — so `importMoves` is empty. The notes flag the additive follow-ups a bump can't do for
10
+ * you (wiring the new assurance config), keeping the runner honest about what it did and didn't touch.
11
+ */
12
+ export const recipe: UpgradeRecipe = {
13
+ version: "1.8.0",
14
+ pins: [{ match: "@nifrajs/", to: "1.8.0" }],
15
+ importMoves: [],
16
+ notes: [
17
+ "1.8.0 adds route assurance (@nifrajs/core + middleware) — optional. To enforce it in CI, add a `nifra.assurance.ts` policy and run `nifra assure`.",
18
+ "New package @nifrajs/events (portable versioned event contracts) — add it to package.json only if you adopt it; the pin sweep won't introduce a dependency you don't already declare.",
19
+ "@nifrajs/testing gained the adversarial contract lab — no migration needed; existing tests are unchanged.",
20
+ ],
21
+ }