@nifrajs/cli 2.12.1 → 2.14.1

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 (74) hide show
  1. package/dist/capabilities-tool.d.ts +54 -1
  2. package/dist/capabilities-tool.d.ts.map +1 -1
  3. package/dist/capabilities-tool.js +146 -18
  4. package/dist/capabilities-tool.js.map +1 -1
  5. package/dist/check.d.ts +11 -11
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +235 -47
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.d.ts +11 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +96 -406
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +244 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1014 -0
  16. package/dist/command-catalog.js.map +1 -0
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.d.ts.map +1 -1
  19. package/dist/contracts.js +22 -6
  20. package/dist/contracts.js.map +1 -1
  21. package/dist/doctor.d.ts +18 -11
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +30 -230
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/load.d.ts.map +1 -1
  26. package/dist/load.js +22 -5
  27. package/dist/load.js.map +1 -1
  28. package/dist/manifest-tool.d.ts +15 -1
  29. package/dist/manifest-tool.d.ts.map +1 -1
  30. package/dist/manifest-tool.js +23 -9
  31. package/dist/manifest-tool.js.map +1 -1
  32. package/dist/mcp-http.js +1 -1
  33. package/dist/mcp.d.ts +8 -0
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/mcp.js +59 -349
  36. package/dist/mcp.js.map +1 -1
  37. package/dist/project-facts.d.ts +40 -0
  38. package/dist/project-facts.d.ts.map +1 -0
  39. package/dist/project-facts.js +36 -0
  40. package/dist/project-facts.js.map +1 -0
  41. package/dist/release-verification.d.ts +45 -0
  42. package/dist/release-verification.d.ts.map +1 -0
  43. package/dist/release-verification.js +303 -0
  44. package/dist/release-verification.js.map +1 -0
  45. package/dist/rules/comment-markers.d.ts +8 -0
  46. package/dist/rules/comment-markers.d.ts.map +1 -1
  47. package/dist/rules/comment-markers.js +36 -0
  48. package/dist/rules/comment-markers.js.map +1 -1
  49. package/dist/rules/index.d.ts +2 -3
  50. package/dist/rules/index.d.ts.map +1 -1
  51. package/dist/rules/index.js.map +1 -1
  52. package/dist/rules/routes.d.ts.map +1 -1
  53. package/dist/rules/routes.js +12 -14
  54. package/dist/rules/routes.js.map +1 -1
  55. package/dist/rules/security.js +14 -14
  56. package/dist/rules/security.js.map +1 -1
  57. package/docs/types.json +28 -15
  58. package/package.json +9 -9
  59. package/src/capabilities-tool.ts +229 -21
  60. package/src/check.ts +286 -64
  61. package/src/cli.ts +106 -399
  62. package/src/command-catalog.ts +1415 -0
  63. package/src/contracts.ts +28 -7
  64. package/src/doctor.ts +56 -264
  65. package/src/load.ts +25 -6
  66. package/src/manifest-tool.ts +43 -12
  67. package/src/mcp-http.ts +1 -1
  68. package/src/mcp.ts +82 -414
  69. package/src/project-facts.ts +86 -0
  70. package/src/release-verification.ts +377 -0
  71. package/src/rules/comment-markers.ts +36 -0
  72. package/src/rules/index.ts +3 -4
  73. package/src/rules/routes.ts +13 -14
  74. package/src/rules/security.ts +14 -14
package/src/contracts.ts CHANGED
@@ -41,8 +41,7 @@ async function sha256(value: string): Promise<string> {
41
41
  return [...digest].map((item) => item.toString(16).padStart(2, "0")).join("")
42
42
  }
43
43
 
44
- function schemaValue(route: ReflectedRoute, side: "request" | "response"): unknown {
45
- const schema = route.schema
44
+ function schemaValue(schema: ReflectedRoute["schema"], side: "request" | "response"): unknown {
46
45
  if (schema === undefined) return {}
47
46
  if (side === "request") {
48
47
  return {
@@ -64,11 +63,28 @@ function schemaValue(route: ReflectedRoute, side: "request" | "response"): unkno
64
63
  }
65
64
 
66
65
  export async function digestRoute(route: ReflectedRoute): Promise<ContractDigest> {
67
- const request = JSON.stringify(canonical(schemaValue(route, "request")))
68
- const response = JSON.stringify(canonical(schemaValue(route, "response")))
66
+ const request = JSON.stringify(canonical(schemaValue(route.schema, "request")))
67
+ const response = JSON.stringify(canonical(schemaValue(route.schema, "response")))
69
68
  return { request: await sha256(request), response: await sha256(response) }
70
69
  }
71
70
 
71
+ /** The digest a route with NO declared schema produces on both sides - `sha256("{}")`. A lock whose
72
+ * every route hashes to this is guarding nothing: drift detection can only ever compare empty schema to
73
+ * empty schema. Computed through the same canonical/stringify path as {@link digestRoute} so it tracks
74
+ * that path rather than a hard-coded literal. */
75
+ export async function emptySchemaDigest(): Promise<string> {
76
+ return sha256(JSON.stringify(canonical(schemaValue(undefined, "request"))))
77
+ }
78
+
79
+ /** Whether every route in `lock` hashes to the empty-schema digest (so no route declares a schema). An
80
+ * empty `routes` map is NOT vacuous in this sense - there is no route whose contract went unguarded. */
81
+ export async function isVacuousLock(lock: ContractsLock): Promise<boolean> {
82
+ const digests = Object.values(lock.routes)
83
+ if (digests.length === 0) return false
84
+ const empty = await emptySchemaDigest()
85
+ return digests.every((d) => d.request === empty && d.response === empty)
86
+ }
87
+
72
88
  export async function buildContractsLock(source: unknown): Promise<ContractsLock> {
73
89
  const routes = Object.fromEntries(
74
90
  await Promise.all(
@@ -131,11 +147,16 @@ export function parseContractsLock(value: unknown): ContractsLock {
131
147
  export async function checkContractsLock(
132
148
  cwd: string,
133
149
  file = DEFAULT_CONTRACTS_LOCK,
134
- ): Promise<{ present: boolean; diagnostics: Array<{ route?: string; message: string }> }> {
150
+ ): Promise<{
151
+ present: boolean
152
+ vacuous: boolean
153
+ diagnostics: Array<{ route?: string; message: string }>
154
+ }> {
135
155
  const path = resolve(cwd, file)
136
- if (!existsSync(path)) return { present: false, diagnostics: [] }
156
+ if (!existsSync(path)) return { present: false, vacuous: false, diagnostics: [] }
137
157
  const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
138
158
  const current = await buildContractsLock(await loadBackend(cwd))
159
+ const vacuous = await isVacuousLock(current)
139
160
  const diagnostics: Array<{ route?: string; message: string }> = []
140
161
  const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
141
162
  for (const key of [...keys].sort()) {
@@ -153,5 +174,5 @@ export async function checkContractsLock(
153
174
  })
154
175
  }
155
176
  }
156
- return { present: true, diagnostics }
177
+ return { present: true, vacuous, diagnostics }
157
178
  }
package/src/doctor.ts CHANGED
@@ -17,10 +17,18 @@
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"
31
+ import { detectToolingDrift, type ToolingDrift } from "./mcp-root.ts"
24
32
  import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
25
33
  import { type ResolvedTarget, resolveTarget } from "./port.ts"
26
34
 
@@ -137,6 +145,14 @@ export interface DoctorResult {
137
145
  * Advisory (never folded into `ok`): while actively editing a linked package its dist is always
138
146
  * momentarily behind - the finding matters when a dev server starts against it. */
139
147
  readonly staleDists: readonly StaleDistFinding[]
148
+ /**
149
+ * The running CLI is a different feature version than the `@nifrajs/cli` (or `@nifrajs/core`) the
150
+ * project installs. Advisory (never folded into `ok`): a stale global/bunx binary answering about a
151
+ * project it does not match is an environment problem, not a defect in the project - but every
152
+ * answer it gives (types, checks, docs) reads as authoritative. Present only when `cliVersion` was
153
+ * supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
154
+ */
155
+ readonly toolingDrift?: ToolingDrift
140
156
  /** Static production-readiness evidence for the selected deploy target. */
141
157
  readonly readiness?: DoctorReadiness
142
158
  /**
@@ -178,17 +194,8 @@ export interface DoctorReadiness {
178
194
  readonly items: readonly DoctorReadinessItem[]
179
195
  }
180
196
 
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
197
+ export interface DuplicateInstallCopy extends IdentityParityCopy {}
198
+ export interface DuplicateInstallFinding extends Omit<IdentityParityFinding, "copies"> {
192
199
  readonly copies: readonly DuplicateInstallCopy[]
193
200
  }
194
201
 
@@ -229,8 +236,6 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
229
236
  typeof value === "object" && value !== null && !Array.isArray(value)
230
237
 
231
238
  const MAX_WORKSPACE_IMPORTERS = 2_048
232
- const IDENTITY_SENSITIVE_PACKAGES = new Set(["@nifrajs/core", "react", "react-dom"])
233
-
234
239
  function workspacePatterns(pkg: Record<string, unknown>): string[] {
235
240
  const raw = pkg.workspaces
236
241
  const entries = Array.isArray(raw)
@@ -343,251 +348,25 @@ const scopeForFile = (scopes: readonly DoctorPackageScope[], file: string): Doct
343
348
  (file === scope.relativeRoot || file.startsWith(`${scope.relativeRoot}/`)),
344
349
  ) ?? (scopes.find((scope) => scope.relativeRoot === "") as DoctorPackageScope)
345
350
 
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
- }
351
+ /* Identity-sensitive install resolution is owned by @nifrajs/web/internal/parity. */
502
352
 
503
353
  /** Find identity-sensitive dependencies that resolve to multiple physical directories. Two copies at
504
354
  * the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based.
505
355
  *
506
356
  * `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. */
357
+ * The shared parity seam resolves the governing workspace root, while paths are still REPORTED
358
+ * relative to `cwd` so the findings read the way the user's terminal does. */
509
359
  export async function collectDuplicateInstalls(
510
360
  cwd: string,
511
361
  rootPackage: Record<string, unknown>,
512
362
  ): 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,
363
+ const result = await collectIdentityParity(cwd, rootPackage, { useWorkspaceRoot: true })
364
+ return result.findings.map(
365
+ (finding): DuplicateInstallFinding => ({
366
+ ...finding,
367
+ copies: finding.copies.map((copy): DuplicateInstallCopy => ({ ...copy })),
368
+ }),
561
369
  )
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
370
  }
592
371
 
593
372
  /** A workspace-linked dependency whose build artifact is older than its source. */
@@ -902,7 +681,7 @@ async function inferDependencyFix(
902
681
  /** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
903
682
  export async function collectDoctorResult(
904
683
  cwd: string,
905
- opts: { readonly target?: string; readonly strict?: boolean } = {},
684
+ opts: { readonly target?: string; readonly strict?: boolean; readonly cliVersion?: string } = {},
906
685
  ): Promise<DoctorResult> {
907
686
  const pkg = await readJson(join(cwd, "package.json"))
908
687
  if (pkg === undefined)
@@ -930,7 +709,12 @@ export async function collectDoctorResult(
930
709
  const pipeline = await collectPipelineReport(cwd)
931
710
  const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
932
711
  const readiness = await collectDoctorReadiness(cwd, opts)
933
- // `staleDists` is advisory (see DoctorResult): it never fails `ok`.
712
+ // A stale binary answering about a project it does not match is an environment condition, so like
713
+ // `staleDists` it is reported but never folded into `ok`. Skipped entirely when the caller did not
714
+ // pass its own version (e.g. the MCP server, which already annotates every result with the drift).
715
+ const toolingDrift =
716
+ opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
717
+ // `staleDists` and `toolingDrift` are advisory (see DoctorResult): they never fail `ok`.
934
718
  return {
935
719
  ok:
936
720
  findings.length === 0 &&
@@ -942,6 +726,7 @@ export async function collectDoctorResult(
942
726
  duplicateInstalls,
943
727
  staleDists,
944
728
  readiness,
729
+ ...(toolingDrift !== undefined ? { toolingDrift } : {}),
945
730
  ...(pipeline.ran ? { pipeline } : {}),
946
731
  }
947
732
  }
@@ -949,7 +734,7 @@ export async function collectDoctorResult(
949
734
  /** Safely add undeclared imports to package.json when the version can be inferred without network I/O. */
950
735
  export async function applyDoctorAutoFix(
951
736
  cwd: string,
952
- opts: { readonly target?: string; readonly strict?: boolean } = {},
737
+ opts: { readonly target?: string; readonly strict?: boolean; readonly cliVersion?: string } = {},
953
738
  ): Promise<DoctorResult> {
954
739
  const before = await collectDoctorResult(cwd, opts)
955
740
  if (!before.ran || before.findings.length === 0) return before
@@ -1028,6 +813,8 @@ export async function runDoctor(
1028
813
  readonly autoFix?: boolean
1029
814
  readonly strict?: boolean
1030
815
  readonly target?: string
816
+ /** The running CLI's own version, so doctor can flag a binary that mismatches the project. */
817
+ readonly cliVersion?: string
1031
818
  } = {},
1032
819
  ): Promise<boolean> {
1033
820
  const result = opts.autoFix
@@ -1102,6 +889,19 @@ export async function runDoctor(
1102
889
  }
1103
890
  console.log("")
1104
891
  }
892
+ // Advisory, printed regardless of ok: the binary answering is a different feature version than the
893
+ // one the project builds with, so its types/checks/docs describe a surface the code does not have.
894
+ if (result.toolingDrift !== undefined) {
895
+ const drift = result.toolingDrift
896
+ console.log(
897
+ `⚠ this CLI is nifra ${drift.cli}, but the project installs ${drift.package} ${drift.project} - ` +
898
+ "its types, checks, and docs may describe a different version than your code builds with.",
899
+ )
900
+ console.log(
901
+ " fix: run the project's own CLI (`bunx --bun nifra doctor` from the project directory, " +
902
+ "or ./node_modules/.bin/nifra)\n",
903
+ )
904
+ }
1105
905
  if (result.ok) {
1106
906
  console.log(
1107
907
  "✓ every imported package is declared and identity-sensitive installs are deduplicated",
@@ -1127,22 +927,14 @@ export async function runDoctor(
1127
927
  `${byPkg.size > 0 ? "\n" : ""}✗ identity-sensitive packages resolve to multiple physical copies:\n`,
1128
928
  )
1129
929
  for (const finding of result.duplicateInstalls) {
1130
- console.log(` ${finding.package}`)
930
+ console.log(` ${finding.package} [${finding.cause}]`)
931
+ console.log(` versions: ${finding.versions.join(", ")}`)
1131
932
  for (const copy of finding.copies) {
1132
933
  console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
1133
934
  }
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
- }
935
+ console.log(` ${finding.explanation}`)
936
+ console.log(` fix: ${finding.remediation}`)
1142
937
  }
1143
- console.log(
1144
- "\n Align dependency ranges and reinstall from the workspace root; doctor never deletes installs.",
1145
- )
1146
938
  }
1147
939
  if (result.readiness?.strict === true && result.readiness.ok === false) {
1148
940
  console.log("\n✗ production readiness is incomplete under --strict")
package/src/load.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * reach the edge bundle. A simple single-target app with no edge build can put it all in `framework.ts`.
14
14
  */
15
15
  import { existsSync } from "node:fs"
16
- import { resolve } from "node:path"
16
+ import { isAbsolute, resolve } from "node:path"
17
17
  import { checkPipelineSeparation } from "./pipeline-guard.ts"
18
18
 
19
19
  /**
@@ -210,11 +210,30 @@ export async function loadApp(
210
210
  // resolved to absolute here. It is embedded verbatim as an import specifier in the generated client
211
211
  // entry, and `nifra dev` and `nifra build` write that entry into DIFFERENT directories - so a relative
212
212
  // specifier would resolve against different bases and load in one phase but not the other. A bare or
213
- // package specifier (`@nifrajs/web-react/client`) is location-independent and passes through unchanged.
214
- const clientModule =
215
- fw.clientModule.startsWith("./") || fw.clientModule.startsWith("../")
216
- ? resolve(cwd, fw.clientModule)
217
- : fw.clientModule
213
+ // package specifier (`@nifrajs/web-react/client`) or an already-absolute path is location-independent
214
+ // and passes through unchanged.
215
+ let clientModule: string
216
+ if (fw.clientModule.startsWith("./") || fw.clientModule.startsWith("../")) {
217
+ clientModule = resolve(cwd, fw.clientModule)
218
+ } else {
219
+ // A specifier that is neither `./`-relative, scoped (`@…`), nor absolute is read as a BARE PACKAGE
220
+ // specifier - resolved against `node_modules`, not `cwd`. If a real local file sits at that path
221
+ // (a `./` was forgotten: `src/client.tsx` instead of `./src/client.tsx`), the bundler would ignore
222
+ // the file, fail to find a package, and surface an opaque "cannot resolve" deep in the build. Reject
223
+ // it here with the one-character fix rather than letting it fail silently.
224
+ if (
225
+ !isAbsolute(fw.clientModule) &&
226
+ !fw.clientModule.startsWith("@") &&
227
+ existsSync(resolve(cwd, fw.clientModule))
228
+ ) {
229
+ throw new Error(
230
+ `[nifra] clientModule "${fw.clientModule}" has no "./" prefix, so it is read as a bare package ` +
231
+ `specifier - but a local file exists at that path. Prefix it with "./" (e.g. ` +
232
+ `"./${fw.clientModule}") so \`nifra dev\` and \`nifra build\` resolve the same local entry.`,
233
+ )
234
+ }
235
+ clientModule = fw.clientModule
236
+ }
218
237
  const [vitePlugins, clientPlugins, serverPlugins] = await Promise.all([
219
238
  resolvePlugins(fw.vitePlugins),
220
239
  resolvePlugins(fw.clientPlugins),
@@ -5,6 +5,7 @@ import { resolve } from "node:path"
5
5
  import {
6
6
  buildNifraManifest,
7
7
  diffNifraManifests,
8
+ type NifraManifest,
8
9
  type NifraManifestDiff,
9
10
  parseNifraManifest,
10
11
  serializeNifraManifest,
@@ -38,11 +39,18 @@ export function formatManifestDiff(diff: NifraManifestDiff): string {
38
39
  return lines.join("\n")
39
40
  }
40
41
 
41
- /** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
42
- export async function runManifestEmit(
42
+ export interface ManifestEmitCommandResult {
43
+ readonly ok: boolean
44
+ readonly path: string
45
+ readonly manifest?: NifraManifest
46
+ readonly signaturePath?: string
47
+ }
48
+
49
+ /** Emit the manifest without printing. This is the execution seam used by all adapters. */
50
+ export async function collectManifestEmit(
43
51
  cwd: string,
44
52
  options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
45
- ): Promise<boolean> {
53
+ ): Promise<ManifestEmitCommandResult> {
46
54
  const verification = await collectProjectVerification(cwd, {
47
55
  ...(options.config !== undefined ? { config: options.config } : {}),
48
56
  })
@@ -58,9 +66,9 @@ export async function runManifestEmit(
58
66
  if (assurance === undefined) {
59
67
  throw new Error("[nifra] assurance evaluation was unavailable for manifest emission")
60
68
  }
69
+ const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
61
70
  if (!assurance.ok || (capabilityProject !== undefined && !capabilityProject.report.ok)) {
62
- console.error("[nifra] refusing to emit a manifest from failing assurance")
63
- return false
71
+ return { ok: false, path }
64
72
  }
65
73
  const manifest = await buildNifraManifest({
66
74
  source: config.source,
@@ -68,9 +76,9 @@ export async function runManifestEmit(
68
76
  assurance,
69
77
  ...(capabilityProject !== undefined ? { capabilities: capabilityProject.report } : {}),
70
78
  })
71
- const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
72
79
  await Bun.write(path, `${serializeNifraManifest(manifest)}\n`)
73
80
 
81
+ let signaturePath: string | undefined
74
82
  if (options.sign !== undefined) {
75
83
  if (config.manifest?.signer === undefined) {
76
84
  throw new Error(
@@ -79,29 +87,52 @@ export async function runManifestEmit(
79
87
  }
80
88
  const signer = await config.manifest.signer(options.sign)
81
89
  const signature = await signNifraManifest(manifest, signer)
82
- await Bun.write(`${path}.sig`, `${serializeNifraManifestSignature(signature)}\n`)
90
+ signaturePath = `${path}.sig`
91
+ await Bun.write(signaturePath, `${serializeNifraManifestSignature(signature)}\n`)
92
+ }
93
+ return { ok: true, path, manifest, ...(signaturePath === undefined ? {} : { signaturePath }) }
94
+ }
95
+
96
+ /** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
97
+ export async function runManifestEmit(
98
+ cwd: string,
99
+ options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
100
+ ): Promise<boolean> {
101
+ const result = await collectManifestEmit(cwd, options)
102
+ if (!result.ok) {
103
+ console.error("[nifra] refusing to emit a manifest from failing assurance")
104
+ return false
83
105
  }
84
106
  console.log(
85
- `[nifra] wrote manifest to ${path}${options.sign === undefined ? "" : ` and ${path}.sig`}`,
107
+ `[nifra] wrote manifest to ${result.path}${result.signaturePath === undefined ? "" : ` and ${result.signaturePath}`}`,
86
108
  )
87
109
  return true
88
110
  }
89
111
 
90
112
  /** Diff two already-emitted, hash-verified artifacts. Suitable for deploy promotion gates. */
91
- export async function runManifestDiff(
113
+ export async function collectManifestDiff(
92
114
  cwd: string,
93
115
  beforePath: string,
94
116
  afterPath: string,
95
- options: { readonly json?: boolean } = {},
96
- ): Promise<boolean> {
117
+ ): Promise<NifraManifestDiff> {
97
118
  const before = resolve(cwd, beforePath)
98
119
  const after = resolve(cwd, afterPath)
99
120
  if (!existsSync(before)) throw new Error(`[nifra] manifest not found: ${before}`)
100
121
  if (!existsSync(after)) throw new Error(`[nifra] manifest not found: ${after}`)
101
- const diff = diffNifraManifests(
122
+ return diffNifraManifests(
102
123
  await parseNifraManifest(await Bun.file(before).text(), before),
103
124
  await parseNifraManifest(await Bun.file(after).text(), after),
104
125
  )
126
+ }
127
+
128
+ /** Diff two already-emitted, hash-verified artifacts and print the result. */
129
+ export async function runManifestDiff(
130
+ cwd: string,
131
+ beforePath: string,
132
+ afterPath: string,
133
+ options: { readonly json?: boolean } = {},
134
+ ): Promise<boolean> {
135
+ const diff = await collectManifestDiff(cwd, beforePath, afterPath)
105
136
  console.log(
106
137
  options.json === true
107
138
  ? JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2)
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.14.1"
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 =>