@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.
- package/README.md +33 -1
- 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 +136 -11
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +199 -12
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +146 -10
- package/dist/doctor.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 +183 -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 +524 -15
- package/docs/types.json +2409 -50
- package/package.json +9 -8
- package/src/assure.ts +88 -0
- package/src/capabilities-tool.ts +406 -0
- package/src/check.ts +155 -12
- package/src/cli.ts +206 -17
- package/src/doctor.ts +179 -11
- package/src/levels-tool.ts +232 -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/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
|
-
|
|
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(
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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.
|
|
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
|
+
}
|