@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.
- package/dist/capabilities-tool.d.ts +54 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +146 -18
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +11 -11
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +235 -47
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +96 -406
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +244 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +1014 -0
- package/dist/command-catalog.js.map +1 -0
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +22 -6
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts +18 -11
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +30 -230
- package/dist/doctor.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +22 -5
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +15 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +23 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +59 -349
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +40 -0
- package/dist/project-facts.d.ts.map +1 -0
- package/dist/project-facts.js +36 -0
- package/dist/project-facts.js.map +1 -0
- package/dist/release-verification.d.ts +45 -0
- package/dist/release-verification.d.ts.map +1 -0
- package/dist/release-verification.js +303 -0
- package/dist/release-verification.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +8 -0
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +36 -0
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/index.d.ts +2 -3
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +12 -14
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.js +14 -14
- package/dist/rules/security.js.map +1 -1
- package/docs/types.json +28 -15
- package/package.json +9 -9
- package/src/capabilities-tool.ts +229 -21
- package/src/check.ts +286 -64
- package/src/cli.ts +106 -399
- package/src/command-catalog.ts +1415 -0
- package/src/contracts.ts +28 -7
- package/src/doctor.ts +56 -264
- package/src/load.ts +25 -6
- package/src/manifest-tool.ts +43 -12
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +82 -414
- package/src/project-facts.ts +86 -0
- package/src/release-verification.ts +377 -0
- package/src/rules/comment-markers.ts +36 -0
- package/src/rules/index.ts +3 -4
- package/src/rules/routes.ts +13 -14
- 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(
|
|
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<{
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1135
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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),
|
package/src/manifest-tool.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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}${
|
|
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
|
|
113
|
+
export async function collectManifestDiff(
|
|
92
114
|
cwd: string,
|
|
93
115
|
beforePath: string,
|
|
94
116
|
afterPath: string,
|
|
95
|
-
|
|
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
|
-
|
|
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.
|
|
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 =>
|