@nifrajs/cli 1.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "The nifra CLI — zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,13 +41,14 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "1.11.0",
45
- "@nifrajs/core": "1.11.0",
46
- "@nifrajs/mcp": "1.11.0",
47
- "@nifrajs/runner": "1.11.0",
48
- "@nifrajs/schema": "1.11.0",
49
- "@nifrajs/web": "1.11.0",
50
- "create-nifra": "1.11.0"
44
+ "@nifrajs/client": "1.12.0",
45
+ "@nifrajs/core": "1.12.0",
46
+ "@nifrajs/mcp": "1.12.0",
47
+ "@nifrajs/runner": "1.12.0",
48
+ "@nifrajs/schema": "1.12.0",
49
+ "@nifrajs/testing": "1.12.0",
50
+ "@nifrajs/web": "1.12.0",
51
+ "create-nifra": "1.12.0"
51
52
  },
52
53
  "repository": {
53
54
  "type": "git",
package/src/check.ts CHANGED
@@ -770,6 +770,7 @@ export interface CheckDiagnostic {
770
770
  | "server-only-import"
771
771
  | "response-route"
772
772
  | "undeclared-dependency"
773
+ | "duplicate-install"
773
774
  | "server-manifest-drift"
774
775
  | "manifest-drift"
775
776
  | "capability-assurance"
@@ -1139,6 +1140,24 @@ export async function collectCheckResult(
1139
1140
  },
1140
1141
  })
1141
1142
  }
1143
+ for (const finding of dr.duplicateInstalls) {
1144
+ const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ")
1145
+ diagnostics.push({
1146
+ rule: "duplicate-install",
1147
+ severity: "error",
1148
+ message: `${finding.package} resolves to multiple physical copies (${copies}) - module identity is unsafe`,
1149
+ fix: "align dependency ranges and reinstall from the workspace root",
1150
+ suggestion: {
1151
+ kind: "manual",
1152
+ title: `Deduplicate ${finding.package}`,
1153
+ steps: [
1154
+ "Align workspace dependency and peer ranges on one compatible version.",
1155
+ "Remove stale nested installs and run the package manager from the workspace root.",
1156
+ "Run `nifra doctor` again; do not suppress same-version duplicate paths.",
1157
+ ],
1158
+ },
1159
+ })
1160
+ }
1142
1161
  }
1143
1162
  // #7: a committed server-manifest.ts that drifted from routes/ — name the exact missing/extra routes.
1144
1163
  for (const f of manifestDrift) {
@@ -1317,6 +1336,7 @@ export async function runCheck(
1317
1336
  ["server-only-import", "server-only import in a route module"],
1318
1337
  ["response-route", "route returns a raw Response (typed client → data: never)"],
1319
1338
  ["undeclared-dependency", "undeclared dependency in package.json"],
1339
+ ["duplicate-install", "duplicate identity-sensitive dependency install"],
1320
1340
  ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
1321
1341
  ["manifest-drift", "versioned trust manifest drift"],
1322
1342
  ["capability-assurance", "effect/capability assurance"],
package/src/cli.ts CHANGED
@@ -59,7 +59,7 @@ Usage:
59
59
  agent: nifra_context, nifra_routes (routes+schemas as JSON),
60
60
  nifra_run (backend), nifra_render (SSR a page), nifra_docs,
61
61
  nifra_example (verified snippets), nifra_scaffold (route→file),
62
- nifra_check (drift gate + fixes), nifra_doctor (undeclared deps).
62
+ nifra_check (drift gate + fixes), nifra_doctor (deps + duplicate installs).
63
63
  nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) —
64
64
  self-host on a VPS so any remote agent can learn nifra. Default :8787.
65
65
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
@@ -91,8 +91,8 @@ Usage:
91
91
  Compute the verification ladder (L0 typed contract → L1 route
92
92
  assurance → L2 capability lockfile → L3 route manifest → L4
93
93
  invariant-tested); --min <n> fails the exit code below level n.
94
- nifra doctor [--json] [--auto-fix] Flag packages imported in source but missing from package.json
95
- (resolve at Bun runtime, break tsc + standalone install);
94
+ nifra doctor [--json] [--auto-fix] Flag undeclared imports and multiple physical copies of
95
+ @nifrajs/core/React identity-sensitive dependencies;
96
96
  --auto-fix writes safe local-version dependency fixes.
97
97
  nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
98
98
  [--write] [--no-verify] matching dependency pin (preserving ^/~/exact) + apply exact
@@ -114,7 +114,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
114
114
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
115
115
 
116
116
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
117
- const CLI_VERSION = "1.11.0"
117
+ const CLI_VERSION = "1.12.0"
118
118
 
119
119
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
120
120
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
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
  }
@@ -15,8 +15,8 @@ import { existsSync } from "node:fs"
15
15
  import { resolve } from "node:path"
16
16
  import { evaluateRouteAssurance } from "@nifrajs/core/assurance"
17
17
  import { type evaluateCapabilityAssurance, snapshotCapabilities } from "@nifrajs/core/capabilities"
18
- import { runContractInvariants } from "@nifrajs/core/invariants"
19
18
  import { buildNifraManifest, parseNifraManifest } from "@nifrajs/core/manifest"
19
+ import { type ContractTestApp, runAdversarialContract } from "@nifrajs/testing"
20
20
  import { loadAssuranceConfig } from "./assure.ts"
21
21
  import {
22
22
  collectCapabilityProjectReport,
@@ -153,17 +153,27 @@ export async function collectVerificationLevels(
153
153
  "no isolated invariant executor configured — add `invariants.executor` in nifra.assurance.ts",
154
154
  )
155
155
  } else {
156
- const invariants = await runContractInvariants(config.source as object, {
156
+ const invariants = await runAdversarialContract(config.source as ContractTestApp, {
157
157
  seed: options.seed ?? 1,
158
- executor: config.invariants.executor,
158
+ runtimes: [{ name: "isolated", fetch: config.invariants.executor }],
159
+ expectedValidationStatuses: [400, 422],
160
+ requireCoverage: true,
159
161
  })
160
- if (!invariants.ok) l4Reasons.push(...invariants.findings.map((f) => f.message).slice(0, 5))
161
- if (invariants.skipped.length > 0) {
162
+ if (!invariants.ok) {
162
163
  l4Reasons.push(
163
- ...invariants.skipped
164
+ ...invariants.failures
164
165
  .slice(0, 5)
165
166
  .map(
166
- (route) => `${route.method} ${route.path} was not invariant-tested: ${route.reason}`,
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}`,
167
177
  ),
168
178
  )
169
179
  }
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.11.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."