@nifrajs/cli 2.12.1 → 2.13.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/src/cli.ts CHANGED
@@ -104,6 +104,8 @@ Usage:
104
104
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
105
105
  server-only imports in routes/). Run as "done"; --json for agents;
106
106
  --lints-only skips tsc for a near-instant inner-loop pass.
107
+ nifra verify [--release] [--json] Run the shared repository verification gate. --release runs the
108
+ full build, test, coverage, corpus, consumer, and cross-runtime gate.
107
109
  nifra fix [--code <NF-code>] Apply a registered diagnostic recipe, then print the remaining
108
110
  structured diagnostics.
109
111
  nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
@@ -175,7 +177,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
175
177
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
176
178
 
177
179
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
178
- const CLI_VERSION = "2.12.1"
180
+ const CLI_VERSION = "2.13.0"
179
181
 
180
182
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
181
183
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -649,6 +651,15 @@ async function main(): Promise<void> {
649
651
  process.exitCode = 1
650
652
  return
651
653
  }
654
+ if (command === "verify") {
655
+ const { runReleaseVerification } = await import("./release-verification.ts")
656
+ const ok = await runReleaseVerification(process.cwd(), {
657
+ mode: argv.includes("--release") ? "release" : "default",
658
+ json: argv.includes("--json"),
659
+ })
660
+ if (!ok) process.exitCode = 1
661
+ return
662
+ }
652
663
  if (command === "fix") {
653
664
  const codeIndex = argv.indexOf("--code")
654
665
  const code = codeIndex === -1 ? undefined : argv[codeIndex + 1]
package/src/doctor.ts CHANGED
@@ -17,9 +17,16 @@
17
17
  * undeclared import there fails a clean `bun install --frozen-lockfile` build just the same.
18
18
  */
19
19
  import type { Dirent } from "node:fs"
20
- import { lstat, readdir, realpath, stat } from "node:fs/promises"
20
+ import { readdir, stat } from "node:fs/promises"
21
21
  import { builtinModules } from "node:module"
22
22
  import { dirname, join, relative, sep } from "node:path"
23
+ import {
24
+ collectIdentityParity,
25
+ displayPath,
26
+ type IdentityParityCopy,
27
+ type IdentityParityFinding,
28
+ resolvedInstalledCopy,
29
+ } from "@nifrajs/web/internal/parity"
23
30
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
24
31
  import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
25
32
  import { type ResolvedTarget, resolveTarget } from "./port.ts"
@@ -178,17 +185,8 @@ export interface DoctorReadiness {
178
185
  readonly items: readonly DoctorReadinessItem[]
179
186
  }
180
187
 
181
- export interface DuplicateInstallCopy {
182
- /** Installed version, or `unknown` when package metadata is incomplete. */
183
- readonly version: string
184
- /** Physical package directory, relative to the doctor root when possible. */
185
- readonly path: string
186
- /** Workspace package roots whose normal Node/Bun resolution selects this copy. */
187
- readonly importers: readonly string[]
188
- }
189
-
190
- export interface DuplicateInstallFinding {
191
- readonly package: string
188
+ export interface DuplicateInstallCopy extends IdentityParityCopy {}
189
+ export interface DuplicateInstallFinding extends Omit<IdentityParityFinding, "copies"> {
192
190
  readonly copies: readonly DuplicateInstallCopy[]
193
191
  }
194
192
 
@@ -229,8 +227,6 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
229
227
  typeof value === "object" && value !== null && !Array.isArray(value)
230
228
 
231
229
  const MAX_WORKSPACE_IMPORTERS = 2_048
232
- const IDENTITY_SENSITIVE_PACKAGES = new Set(["@nifrajs/core", "react", "react-dom"])
233
-
234
230
  function workspacePatterns(pkg: Record<string, unknown>): string[] {
235
231
  const raw = pkg.workspaces
236
232
  const entries = Array.isArray(raw)
@@ -343,251 +339,25 @@ const scopeForFile = (scopes: readonly DoctorPackageScope[], file: string): Doct
343
339
  (file === scope.relativeRoot || file.startsWith(`${scope.relativeRoot}/`)),
344
340
  ) ?? (scopes.find((scope) => scope.relativeRoot === "") as DoctorPackageScope)
345
341
 
346
- function duplicateTargets(pkg: Record<string, unknown>): string[] {
347
- const targets = new Set<string>()
348
- for (const field of DEPENDENCY_FIELDS) {
349
- for (const name of depNames(pkg, field)) {
350
- if (name.startsWith("@nifrajs/") || IDENTITY_SENSITIVE_PACKAGES.has(name)) targets.add(name)
351
- }
352
- }
353
- return [...targets].sort()
354
- }
355
-
356
- async function resolvedInstalledCopy(
357
- importer: string,
358
- boundary: string,
359
- name: string,
360
- ): Promise<{ path: string; version: string } | undefined> {
361
- const parts = name.split("/")
362
- for (let dir = importer; ; dir = dirname(dir)) {
363
- const packageDir = join(dir, "node_modules", ...parts)
364
- const meta = await readJson(join(packageDir, "package.json"))
365
- if (meta !== undefined) {
366
- try {
367
- return {
368
- path: await realpath(packageDir),
369
- version:
370
- typeof meta.version === "string" && meta.version.length > 0 ? meta.version : "unknown",
371
- }
372
- } catch {
373
- return undefined
374
- }
375
- }
376
- if (dir === boundary) return undefined
377
- const parent = dirname(dir)
378
- if (parent === dir) return undefined
379
- }
380
- }
381
-
382
- const MAX_LINKED_PACKAGES = 64
383
- const MAX_LINK_PROBES = 4_096
384
-
385
- /** Is `path` inside `root` (or `root` itself)? Both must already be real paths. */
386
- const isInside = (root: string, path: string): boolean =>
387
- path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
388
-
389
- /**
390
- * The repo boundary for a linked package: the nearest ancestor holding a `.git`, so the upward
391
- * `node_modules` walk inside a sibling checkout stops where that checkout does instead of climbing
392
- * into a shared parent directory. Falls back to the package directory itself.
393
- */
394
- async function linkedRepoBoundary(packageRoot: string): Promise<string> {
395
- let dir = packageRoot
396
- for (let depth = 0; depth < 8; depth++) {
397
- if (await pathExists(join(dir, ".git"))) return dir
398
- const parent = dirname(dir)
399
- if (parent === dir) break
400
- dir = parent
401
- }
402
- return packageRoot
403
- }
404
-
405
- /**
406
- * Dependency roots that are symlinks OUT of the scanned workspace - a sibling checkout linked in with
407
- * `link:`/`file:`/`npm link`. Their own `node_modules` is a second physical install root, so a shared
408
- * package (`@nifrajs/core`, React) resolves to a different directory there than it does here. That is
409
- * a real duplicate: module identity is path-based, so registries, symbols, and hooks do not match
410
- * across the two copies - the exact split that reads as "my types collapsed for no reason".
411
- *
412
- * One level deep and bounded: links inside a linked package are not followed.
413
- */
414
- async function linkedPackageRoots(
415
- importerRoots: readonly string[],
416
- manifests: readonly Record<string, unknown>[],
417
- scanRoot: string,
418
- ): Promise<string[]> {
419
- const realScanRoot = await realpath(scanRoot).catch(() => scanRoot)
420
- const found = new Map<string, true>()
421
- let probes = 0
422
- for (const [index, importerRoot] of importerRoots.entries()) {
423
- const pkg = manifests[index]
424
- if (pkg === undefined) continue
425
- const names = new Set<string>()
426
- for (const field of DEPENDENCY_FIELDS) for (const name of depNames(pkg, field)) names.add(name)
427
- for (const name of names) {
428
- if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) return [...found.keys()]
429
- const candidate = join(importerRoot, "node_modules", ...name.split("/"))
430
- // `lstat` first: on macOS `realpath` also rewrites symlinked ANCESTORS (`/tmp` ->
431
- // `/private/tmp`), which would read as a link out of the workspace for every dependency.
432
- const link = await lstat(candidate).catch(() => undefined)
433
- if (link === undefined || !link.isSymbolicLink()) continue
434
- const resolved = await realpath(candidate).catch(() => undefined)
435
- if (resolved === undefined || isInside(realScanRoot, resolved)) continue
436
- found.set(resolved, true)
437
- }
438
- }
439
- return [...found.keys()]
440
- }
441
-
442
- const displayPath = (cwd: string, path: string): string => {
443
- const rel = relative(cwd, path)
444
- return rel === "" ? "." : rel
445
- }
446
-
447
- /**
448
- * Resolve the workspace root that actually governs `cwd`, or `undefined` when there is none.
449
- *
450
- * Everything below is anchored to a package that declares `workspaces`. In a monorepo you run
451
- * `nifra check` from the app - `apps/web` - and THAT manifest declares nothing, so the scan collapses
452
- * to the app itself and the sibling package holding the second copy is never probed. Running from an
453
- * app subdirectory is the normal case, which made this the configuration the check was blind in.
454
- *
455
- * An ancestor is adopted only when its `workspaces` patterns genuinely match `cwd`. A parent
456
- * directory that happens to contain a manifest is not automatically this project's root, and adopting
457
- * one would silently widen the scan to an unrelated tree. The walk also stops at a `.git` boundary
458
- * (checked after the manifest, so a repo root that IS the workspace root still counts) and at the
459
- * filesystem root.
460
- */
461
- /** Whether `path` exists at all. `stat`, not a file read: a `.git` is a directory in a normal
462
- * checkout and a FILE in a worktree or submodule, and both mark the same boundary. */
463
- const pathExists = (path: string): Promise<boolean> =>
464
- stat(path).then(
465
- () => true,
466
- () => false,
467
- )
468
-
469
- async function findWorkspaceRoot(
470
- cwd: string,
471
- ): Promise<{ root: string; package: Record<string, unknown> } | undefined> {
472
- let dir = dirname(cwd)
473
- for (;;) {
474
- const manifestPath = join(dir, "package.json")
475
- const manifest = await readJson(manifestPath)
476
- if (manifest !== undefined) {
477
- const patterns = workspacePatterns(manifest)
478
- if (patterns.length > 0 && (await patternsCover(dir, patterns, cwd))) {
479
- return { root: dir, package: manifest }
480
- }
481
- }
482
- // A `.git` here means we have reached this project's boundary; anything above belongs to a
483
- // different repo (or a parent checkout) and must not be adopted.
484
- if (await pathExists(join(dir, ".git"))) return undefined
485
- const parent = dirname(dir)
486
- if (parent === dir) return undefined
487
- dir = parent
488
- }
489
- }
490
-
491
- /** Whether any of `root`'s workspace patterns resolves to a manifest inside `target`'s package. */
492
- async function patternsCover(root: string, patterns: string[], target: string): Promise<boolean> {
493
- for (const pattern of patterns) {
494
- const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
495
- for await (const rel of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
496
- if (rel.split(/[\\/]/).includes("node_modules")) continue
497
- if (dirname(join(root, rel)) === target) return true
498
- }
499
- }
500
- return false
501
- }
342
+ /* Identity-sensitive install resolution is owned by @nifrajs/web/internal/parity. */
502
343
 
503
344
  /** Find identity-sensitive dependencies that resolve to multiple physical directories. Two copies at
504
345
  * the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based.
505
346
  *
506
347
  * `cwd` is where the user ran the check; the scan is anchored at the workspace root above it (see
507
- * {@link findWorkspaceRoot}) so sibling packages are probed, while paths are still REPORTED relative
508
- * to `cwd` so the findings read the way the user's terminal does. */
348
+ * The shared parity seam resolves the governing workspace root, while paths are still REPORTED
349
+ * relative to `cwd` so the findings read the way the user's terminal does. */
509
350
  export async function collectDuplicateInstalls(
510
351
  cwd: string,
511
352
  rootPackage: Record<string, unknown>,
512
353
  ): Promise<DuplicateInstallFinding[]> {
513
- const workspace = await findWorkspaceRoot(cwd)
514
- const scanRoot = workspace?.root ?? cwd
515
- const importers = await workspaceImporters(scanRoot, workspace?.package ?? rootPackage)
516
- const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
517
- const record = (
518
- name: string,
519
- copy: { path: string; version: string },
520
- importer: string,
521
- ): void => {
522
- let copies = byPackage.get(name)
523
- if (copies === undefined) {
524
- copies = new Map()
525
- byPackage.set(name, copies)
526
- }
527
- const entry = copies.get(copy.path) ?? { version: copy.version, importers: new Set<string>() }
528
- entry.importers.add(importer)
529
- copies.set(copy.path, entry)
530
- }
531
-
532
- const targets = new Set<string>()
533
- for (const importer of importers) {
534
- for (const name of duplicateTargets(importer.package)) {
535
- if (importer.package.name === name) continue
536
- targets.add(name)
537
- // Boundary is the SCAN root, not `cwd`: an importer can now sit beside or above the directory
538
- // the user ran from, and stopping the upward walk at `cwd` would abandon it before reaching the
539
- // hoisted copy it actually resolves.
540
- const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
541
- if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
542
- }
543
- }
544
-
545
- // Probe the workspace root for every target too, even when the root manifest doesn't declare it.
546
- // A hoisted root copy is reachable from any package that lacks its own nested one, so leaving it
547
- // out hides the very split it should catch: every declaring package nested onto one copy while the
548
- // root resolved a different one, which reads as a single copy when only declarers are consulted.
549
- for (const name of targets) {
550
- const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
551
- if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
552
- }
553
-
554
- // Sibling checkouts linked in (`link:`/`file:`/`npm link`) install their own copies. Their
555
- // resolution is what the runtime and `tsc` actually use for code that crosses the link, so a split
556
- // there is as real as one inside this workspace - and invisible to a scan bounded by the workspace.
557
- const linkedRoots = await linkedPackageRoots(
558
- [...importers.map((importer) => importer.root), scanRoot],
559
- [...importers.map((importer) => importer.package), workspace?.package ?? rootPackage],
560
- scanRoot,
354
+ const result = await collectIdentityParity(cwd, rootPackage, { useWorkspaceRoot: true })
355
+ return result.findings.map(
356
+ (finding): DuplicateInstallFinding => ({
357
+ ...finding,
358
+ copies: finding.copies.map((copy): DuplicateInstallCopy => ({ ...copy })),
359
+ }),
561
360
  )
562
- for (const linkedRoot of linkedRoots) {
563
- const linkedPackage = await readJson(join(linkedRoot, "package.json"))
564
- const boundary = await linkedRepoBoundary(linkedRoot)
565
- const linkedTargets = new Set([
566
- ...targets,
567
- ...(linkedPackage === undefined ? [] : duplicateTargets(linkedPackage)),
568
- ])
569
- for (const name of linkedTargets) {
570
- if (linkedPackage?.name === name) continue
571
- const copy = await resolvedInstalledCopy(linkedRoot, boundary, name)
572
- if (copy !== undefined) record(name, copy, displayPath(cwd, linkedRoot))
573
- }
574
- }
575
-
576
- const findings: DuplicateInstallFinding[] = []
577
- for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
578
- if (copies.size < 2) continue
579
- findings.push({
580
- package: name,
581
- copies: [...copies.entries()]
582
- .sort(([a], [b]) => a.localeCompare(b))
583
- .map(([path, copy]) => ({
584
- version: copy.version,
585
- path: displayPath(cwd, path),
586
- importers: [...copy.importers].sort(),
587
- })),
588
- })
589
- }
590
- return findings
591
361
  }
592
362
 
593
363
  /** A workspace-linked dependency whose build artifact is older than its source. */
@@ -1131,18 +901,9 @@ export async function runDoctor(
1131
901
  for (const copy of finding.copies) {
1132
902
  console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
1133
903
  }
1134
- if (finding.package === "@nifrajs/core") {
1135
- console.log(
1136
- " ↳ a second @nifrajs/core breaks module identity: `typeof backend` collapses to `any`",
1137
- )
1138
- console.log(
1139
- " at `.merge()` and route-type inference silently degrades - usually that error's root cause.",
1140
- )
1141
- }
904
+ console.log(` ${finding.explanation}`)
905
+ console.log(` fix: ${finding.remediation}`)
1142
906
  }
1143
- console.log(
1144
- "\n Align dependency ranges and reinstall from the workspace root; doctor never deletes installs.",
1145
- )
1146
907
  }
1147
908
  if (result.readiness?.strict === true && result.readiness.ok === false) {
1148
909
  console.log("\n✗ production readiness is incomplete under --strict")
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "2.12.1"
32
+ const VERSION = "2.13.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>