@nifrajs/cli 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +133 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +741 -148
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +21 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +288 -73
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/dev-bun-config.d.ts +17 -6
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +51 -14
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +38 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +189 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +60 -5
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +10 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +1 -0
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +360 -27
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +25 -10
- package/dist/pipeline-guard.d.ts.map +1 -1
- package/dist/pipeline-guard.js +35 -15
- package/dist/pipeline-guard.js.map +1 -1
- package/dist/pipeline-report.d.ts +34 -0
- package/dist/pipeline-report.d.ts.map +1 -0
- package/dist/pipeline-report.js +282 -0
- package/dist/pipeline-report.js.map +1 -0
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +1105 -27
- package/docs/types.json +1772 -137
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +923 -169
- package/src/cli.ts +297 -76
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/dev-bun-config.ts +57 -14
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +286 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +81 -6
- package/src/load.ts +11 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +440 -34
- package/src/pipeline-guard.ts +44 -14
- package/src/pipeline-report.ts +341 -0
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- package/src/work-graph.ts +613 -0
package/src/doctor.ts
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
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 { readdir, realpath, stat } from "node:fs/promises"
|
|
20
|
+
import { lstat, readdir, realpath, stat } from "node:fs/promises"
|
|
21
21
|
import { builtinModules } from "node:module"
|
|
22
22
|
import { dirname, join, relative, sep } from "node:path"
|
|
23
23
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
24
|
+
import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
|
|
25
|
+
import { type ResolvedTarget, resolveTarget } from "./port.ts"
|
|
24
26
|
|
|
25
27
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
26
28
|
// own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
|
|
@@ -135,12 +137,47 @@ export interface DoctorResult {
|
|
|
135
137
|
* Advisory (never folded into `ok`): while actively editing a linked package its dist is always
|
|
136
138
|
* momentarily behind - the finding matters when a dev server starts against it. */
|
|
137
139
|
readonly staleDists: readonly StaleDistFinding[]
|
|
140
|
+
/** Static production-readiness evidence for the selected deploy target. */
|
|
141
|
+
readonly readiness?: DoctorReadiness
|
|
142
|
+
/**
|
|
143
|
+
* Which bundler this app's `dev`/`build` phases run on, read statically, plus the config hazards
|
|
144
|
+
* that exist only because there are two. Absent when the directory is not a nifra app.
|
|
145
|
+
*
|
|
146
|
+
* Reported by doctor rather than only by `dev`/`build` because the answer governs how the rest of a
|
|
147
|
+
* project is read - which plugin slot is live, whether `conditions` reach the client bundle, which
|
|
148
|
+
* toolchain compiles a component - and asking for it should not require starting a server.
|
|
149
|
+
*/
|
|
150
|
+
readonly pipeline?: PipelineReport
|
|
138
151
|
/** Dependencies written by `--auto-fix` / MCP `autoFix:true`. */
|
|
139
152
|
readonly fixed?: readonly DoctorAppliedFix[]
|
|
140
153
|
/** Findings that were safe to report but not safe to write automatically. */
|
|
141
154
|
readonly skippedFixes?: readonly DoctorSkippedFix[]
|
|
142
155
|
}
|
|
143
156
|
|
|
157
|
+
export type DoctorReadinessStatus = "configured" | "absent" | "not-applicable"
|
|
158
|
+
|
|
159
|
+
export interface DoctorReadinessItem {
|
|
160
|
+
readonly id:
|
|
161
|
+
| "request-timeout"
|
|
162
|
+
| "admission"
|
|
163
|
+
| "request-id-tracing"
|
|
164
|
+
| "health-route"
|
|
165
|
+
| "log-redaction"
|
|
166
|
+
| "graceful-lifecycle"
|
|
167
|
+
readonly label: string
|
|
168
|
+
readonly status: DoctorReadinessStatus
|
|
169
|
+
readonly evidence?: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface DoctorReadiness {
|
|
173
|
+
readonly target: string
|
|
174
|
+
readonly targetSource: ResolvedTarget["source"] | null
|
|
175
|
+
readonly strict: boolean
|
|
176
|
+
/** True when every applicable rule has static evidence. */
|
|
177
|
+
readonly ok: boolean
|
|
178
|
+
readonly items: readonly DoctorReadinessItem[]
|
|
179
|
+
}
|
|
180
|
+
|
|
144
181
|
export interface DuplicateInstallCopy {
|
|
145
182
|
/** Installed version, or `unknown` when package metadata is incomplete. */
|
|
146
183
|
readonly version: string
|
|
@@ -342,6 +379,66 @@ async function resolvedInstalledCopy(
|
|
|
342
379
|
}
|
|
343
380
|
}
|
|
344
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
|
+
|
|
345
442
|
const displayPath = (cwd: string, path: string): string => {
|
|
346
443
|
const rel = relative(cwd, path)
|
|
347
444
|
return rel === "" ? "." : rel
|
|
@@ -454,6 +551,28 @@ export async function collectDuplicateInstalls(
|
|
|
454
551
|
if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
|
|
455
552
|
}
|
|
456
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,
|
|
561
|
+
)
|
|
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
|
+
|
|
457
576
|
const findings: DuplicateInstallFinding[] = []
|
|
458
577
|
for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
459
578
|
if (copies.size < 2) continue
|
|
@@ -621,6 +740,110 @@ export async function collectStaleWorkspaceDists(
|
|
|
621
740
|
return findings.sort((a, b) => a.package.localeCompare(b.package))
|
|
622
741
|
}
|
|
623
742
|
|
|
743
|
+
interface ReadinessSource {
|
|
744
|
+
readonly file: string
|
|
745
|
+
readonly code: string
|
|
746
|
+
readonly source: string
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function sourceLineAt(source: string, index: number): number {
|
|
750
|
+
let line = 1
|
|
751
|
+
for (let i = 0; i < index && i < source.length; i++) if (source[i] === "\n") line++
|
|
752
|
+
return line
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function readinessEvidence(
|
|
756
|
+
files: readonly ReadinessSource[],
|
|
757
|
+
pattern: RegExp,
|
|
758
|
+
source = "code",
|
|
759
|
+
): string | undefined {
|
|
760
|
+
for (const file of files) {
|
|
761
|
+
const content = source === "source" ? file.source : file.code
|
|
762
|
+
const match = pattern.exec(content)
|
|
763
|
+
if (match !== null) return `${file.file}:${sourceLineAt(content, match.index)}`
|
|
764
|
+
}
|
|
765
|
+
return undefined
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Read only source shapes used to report production readiness. Values and secrets are never loaded. */
|
|
769
|
+
async function collectDoctorReadiness(
|
|
770
|
+
cwd: string,
|
|
771
|
+
opts: { readonly target?: string; readonly strict?: boolean },
|
|
772
|
+
): Promise<DoctorReadiness> {
|
|
773
|
+
const resolved = await resolveTarget(cwd, opts.target)
|
|
774
|
+
const files: ReadinessSource[] = []
|
|
775
|
+
await walkSource(cwd, (file, source) => {
|
|
776
|
+
files.push({ file, source, code: codePositionMask(source) })
|
|
777
|
+
})
|
|
778
|
+
const edgeTarget = /^(?:cf-pages|vercel|workers|edge|static)$/.test(resolved?.target ?? "")
|
|
779
|
+
const configured = (
|
|
780
|
+
id: DoctorReadinessItem["id"],
|
|
781
|
+
label: string,
|
|
782
|
+
pattern: RegExp,
|
|
783
|
+
sourcePattern?: RegExp,
|
|
784
|
+
): DoctorReadinessItem => {
|
|
785
|
+
const evidence = readinessEvidence(files, pattern)
|
|
786
|
+
const routeEvidence =
|
|
787
|
+
evidence === undefined && sourcePattern !== undefined
|
|
788
|
+
? readinessEvidence(files, sourcePattern, "source")
|
|
789
|
+
: undefined
|
|
790
|
+
const found = evidence ?? routeEvidence
|
|
791
|
+
return found === undefined
|
|
792
|
+
? { id, label, status: "absent" }
|
|
793
|
+
: { id, label, status: "configured", evidence: found }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const items: DoctorReadinessItem[] = [
|
|
797
|
+
configured(
|
|
798
|
+
"request-timeout",
|
|
799
|
+
"request timeout or budget",
|
|
800
|
+
/\b(?:requestTimeoutMs|createRequestBudget|admitDeadline)\s*[:(]/,
|
|
801
|
+
),
|
|
802
|
+
// `admission: controller`, the shorthand `{ admission }` (trailing comma or closing brace), or
|
|
803
|
+
// the documented constructor call - shorthand is the idiomatic form when the controller is an
|
|
804
|
+
// import or module const.
|
|
805
|
+
configured(
|
|
806
|
+
"admission",
|
|
807
|
+
"capacity admission",
|
|
808
|
+
/\badmission\s*[:,}]|\bcreateAdmissionController\s*\(/,
|
|
809
|
+
),
|
|
810
|
+
configured(
|
|
811
|
+
"request-id-tracing",
|
|
812
|
+
"request id or tracing middleware",
|
|
813
|
+
/\b(?:requestId|tracing)\s*\(/,
|
|
814
|
+
),
|
|
815
|
+
configured(
|
|
816
|
+
"health-route",
|
|
817
|
+
"health route",
|
|
818
|
+
/\bhealthcheck\s*\(/,
|
|
819
|
+
// Conventional health paths: /health, /healthz (the k8s form healthcheck() is most often
|
|
820
|
+
// configured to serve), /health-check, and the underscore-prefixed variants, each also
|
|
821
|
+
// matching as a prefix (`/health/live`). `/healthy` etc. stay non-matches: the path must end
|
|
822
|
+
// (closing quote) or continue with a segment right after the recognized name.
|
|
823
|
+
/\.(?:get|head)\s*\(\s*["']\/_?health(?:z|-check)?(?:["']|\/)/,
|
|
824
|
+
),
|
|
825
|
+
// Asserts "a redacting logger is INSTALLED", so only the call forms count: the redacting
|
|
826
|
+
// constructors, or @nifrajs/middleware's `logger()` structured request logger (which logs only
|
|
827
|
+
// method/path/status/duration - nothing redactable). A bare `logger:` property proves nothing:
|
|
828
|
+
// `logger: console` matches it and redacts nothing.
|
|
829
|
+
configured("log-redaction", "log redaction", /\b(?:jsonLogger|redactLogFields|logger)\s*\(/),
|
|
830
|
+
edgeTarget
|
|
831
|
+
? { id: "graceful-lifecycle", label: "graceful lifecycle", status: "not-applicable" }
|
|
832
|
+
: configured(
|
|
833
|
+
"graceful-lifecycle",
|
|
834
|
+
"graceful lifecycle",
|
|
835
|
+
/\bgracefulSignals\s*:\s*true\b|\bonStop\s*\(/,
|
|
836
|
+
),
|
|
837
|
+
]
|
|
838
|
+
return {
|
|
839
|
+
target: resolved?.target ?? "unknown",
|
|
840
|
+
targetSource: resolved?.source ?? null,
|
|
841
|
+
strict: opts.strict === true,
|
|
842
|
+
ok: items.every((item) => item.status !== "absent"),
|
|
843
|
+
items,
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
624
847
|
function depRecord(
|
|
625
848
|
pkg: Record<string, unknown>,
|
|
626
849
|
field: string,
|
|
@@ -677,7 +900,10 @@ async function inferDependencyFix(
|
|
|
677
900
|
}
|
|
678
901
|
|
|
679
902
|
/** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
|
|
680
|
-
export async function collectDoctorResult(
|
|
903
|
+
export async function collectDoctorResult(
|
|
904
|
+
cwd: string,
|
|
905
|
+
opts: { readonly target?: string; readonly strict?: boolean } = {},
|
|
906
|
+
): Promise<DoctorResult> {
|
|
681
907
|
const pkg = await readJson(join(cwd, "package.json"))
|
|
682
908
|
if (pkg === undefined)
|
|
683
909
|
return { ok: true, ran: false, findings: [], duplicateInstalls: [], staleDists: [] }
|
|
@@ -701,19 +927,31 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
|
|
|
701
927
|
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
|
|
702
928
|
const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
|
|
703
929
|
const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
|
|
930
|
+
const pipeline = await collectPipelineReport(cwd)
|
|
931
|
+
const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
|
|
932
|
+
const readiness = await collectDoctorReadiness(cwd, opts)
|
|
704
933
|
// `staleDists` is advisory (see DoctorResult): it never fails `ok`.
|
|
705
934
|
return {
|
|
706
|
-
ok:
|
|
935
|
+
ok:
|
|
936
|
+
findings.length === 0 &&
|
|
937
|
+
duplicateInstalls.length === 0 &&
|
|
938
|
+
pipelineErrors.length === 0 &&
|
|
939
|
+
(opts.strict !== true || readiness.ok),
|
|
707
940
|
ran: true,
|
|
708
941
|
findings,
|
|
709
942
|
duplicateInstalls,
|
|
710
943
|
staleDists,
|
|
944
|
+
readiness,
|
|
945
|
+
...(pipeline.ran ? { pipeline } : {}),
|
|
711
946
|
}
|
|
712
947
|
}
|
|
713
948
|
|
|
714
949
|
/** Safely add undeclared imports to package.json when the version can be inferred without network I/O. */
|
|
715
|
-
export async function applyDoctorAutoFix(
|
|
716
|
-
|
|
950
|
+
export async function applyDoctorAutoFix(
|
|
951
|
+
cwd: string,
|
|
952
|
+
opts: { readonly target?: string; readonly strict?: boolean } = {},
|
|
953
|
+
): Promise<DoctorResult> {
|
|
954
|
+
const before = await collectDoctorResult(cwd, opts)
|
|
717
955
|
if (!before.ran || before.findings.length === 0) return before
|
|
718
956
|
|
|
719
957
|
const rootPackage = await readJson(join(cwd, "package.json"))
|
|
@@ -774,7 +1012,7 @@ export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
|
|
|
774
1012
|
if (changed) await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
775
1013
|
}
|
|
776
1014
|
|
|
777
|
-
const after = fixed.length > 0 ? await collectDoctorResult(cwd) : before
|
|
1015
|
+
const after = fixed.length > 0 ? await collectDoctorResult(cwd, opts) : before
|
|
778
1016
|
return {
|
|
779
1017
|
...after,
|
|
780
1018
|
...(fixed.length > 0 ? { fixed } : {}),
|
|
@@ -785,9 +1023,16 @@ export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
|
|
|
785
1023
|
/** Run doctor; print a report (`--json` for machine output) and return whether it passed. */
|
|
786
1024
|
export async function runDoctor(
|
|
787
1025
|
cwd: string,
|
|
788
|
-
opts: {
|
|
1026
|
+
opts: {
|
|
1027
|
+
readonly json?: boolean
|
|
1028
|
+
readonly autoFix?: boolean
|
|
1029
|
+
readonly strict?: boolean
|
|
1030
|
+
readonly target?: string
|
|
1031
|
+
} = {},
|
|
789
1032
|
): Promise<boolean> {
|
|
790
|
-
const result = opts.autoFix
|
|
1033
|
+
const result = opts.autoFix
|
|
1034
|
+
? await applyDoctorAutoFix(cwd, opts)
|
|
1035
|
+
: await collectDoctorResult(cwd, opts)
|
|
791
1036
|
if (opts.json) {
|
|
792
1037
|
console.log(JSON.stringify(result, null, 2))
|
|
793
1038
|
return result.ok
|
|
@@ -811,6 +1056,20 @@ export async function runDoctor(
|
|
|
811
1056
|
}
|
|
812
1057
|
console.log("")
|
|
813
1058
|
}
|
|
1059
|
+
// Printed before the findings and regardless of ok: which bundler owns this app is the frame the
|
|
1060
|
+
// rest of the report is read in, not a footnote to it.
|
|
1061
|
+
if (result.pipeline !== undefined) {
|
|
1062
|
+
const p = result.pipeline
|
|
1063
|
+
console.log(
|
|
1064
|
+
p.pipeline === "unknown"
|
|
1065
|
+
? `• bundler: could not be read from ${p.configFile} - ${p.reason}\n`
|
|
1066
|
+
: `• bundler: ${p.pipeline} (${p.reason})\n`,
|
|
1067
|
+
)
|
|
1068
|
+
for (const f of p.findings) {
|
|
1069
|
+
console.log(`${f.severity === "error" ? "✗" : "⚠"} ${f.file}:${f.line ?? 0} ${f.message}`)
|
|
1070
|
+
console.log(` fix: ${f.fix}\n`)
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
814
1073
|
// Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
|
|
815
1074
|
// it costs an hour of misdiagnosis when the dev server 500s inside the package.
|
|
816
1075
|
if (result.staleDists.length > 0) {
|
|
@@ -827,6 +1086,22 @@ export async function runDoctor(
|
|
|
827
1086
|
}
|
|
828
1087
|
console.log("")
|
|
829
1088
|
}
|
|
1089
|
+
if (result.readiness !== undefined) {
|
|
1090
|
+
const readiness = result.readiness
|
|
1091
|
+
console.log(
|
|
1092
|
+
`production readiness (${readiness.target}${readiness.targetSource === null ? "" : `, detected from ${readiness.targetSource}`}):\n`,
|
|
1093
|
+
)
|
|
1094
|
+
for (const item of readiness.items) {
|
|
1095
|
+
const marker =
|
|
1096
|
+
item.status === "configured" ? "✓" : item.status === "not-applicable" ? "-" : "✗"
|
|
1097
|
+
const evidence = item.evidence === undefined ? "" : ` (${item.evidence})`
|
|
1098
|
+
console.log(` ${marker} ${item.label}: ${item.status}${evidence}`)
|
|
1099
|
+
}
|
|
1100
|
+
if (readiness.strict && !readiness.ok) {
|
|
1101
|
+
console.log("\n --strict: every applicable readiness rule must be configured")
|
|
1102
|
+
}
|
|
1103
|
+
console.log("")
|
|
1104
|
+
}
|
|
830
1105
|
if (result.ok) {
|
|
831
1106
|
console.log(
|
|
832
1107
|
"✓ every imported package is declared and identity-sensitive installs are deduplicated",
|
|
@@ -869,6 +1144,9 @@ export async function runDoctor(
|
|
|
869
1144
|
"\n Align dependency ranges and reinstall from the workspace root; doctor never deletes installs.",
|
|
870
1145
|
)
|
|
871
1146
|
}
|
|
1147
|
+
if (result.readiness?.strict === true && result.readiness.ok === false) {
|
|
1148
|
+
console.log("\n✗ production readiness is incomplete under --strict")
|
|
1149
|
+
}
|
|
872
1150
|
if (byPkg.size > 0)
|
|
873
1151
|
console.log(
|
|
874
1152
|
"\nThese resolve at Bun runtime via hoisting/workspace but break `tsc` and a standalone install.",
|
package/src/examples.ts
CHANGED
|
@@ -23,6 +23,29 @@ export interface ScoredExample extends Example {
|
|
|
23
23
|
readonly score: number
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
interface PreparedExample {
|
|
27
|
+
readonly example: Example
|
|
28
|
+
readonly labelTokens: ReadonlySet<string>
|
|
29
|
+
readonly codeLower: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const preparedExamplesCache = new WeakMap<readonly Example[], readonly PreparedExample[]>()
|
|
33
|
+
const prepareExamples = (examples: readonly Example[]): readonly PreparedExample[] => {
|
|
34
|
+
const cached = preparedExamplesCache.get(examples)
|
|
35
|
+
if (cached !== undefined) return cached
|
|
36
|
+
const prepared = examples.map((example) => ({
|
|
37
|
+
example,
|
|
38
|
+
labelTokens: new Set([
|
|
39
|
+
...tokenize(example.slug),
|
|
40
|
+
...tokenize(example.topic),
|
|
41
|
+
...tokenize(example.name),
|
|
42
|
+
]),
|
|
43
|
+
codeLower: example.code.toLowerCase(),
|
|
44
|
+
}))
|
|
45
|
+
preparedExamplesCache.set(examples, prepared)
|
|
46
|
+
return prepared
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/** Score examples against the query: a term in the slug/topic/name (the curated, searchable labels)
|
|
27
50
|
* is worth far more than a mention in the code body; body hits cap per term so one long snippet can't
|
|
28
51
|
* outweigh a precisely-tagged one. */
|
|
@@ -34,13 +57,12 @@ export function searchExamples(
|
|
|
34
57
|
const terms = queryTermGroups(query)
|
|
35
58
|
if (terms.length === 0) return []
|
|
36
59
|
const scored: ScoredExample[] = []
|
|
37
|
-
for (const
|
|
38
|
-
const
|
|
39
|
-
const codeLower = ex.code.toLowerCase()
|
|
60
|
+
for (const prepared of prepareExamples(examples)) {
|
|
61
|
+
const ex = prepared.example
|
|
40
62
|
let score = 0
|
|
41
63
|
for (const term of terms) {
|
|
42
|
-
if (tokenSetHas(labelTokens, term)) score += 8
|
|
43
|
-
score += countBodyHits(codeLower, term, 5)
|
|
64
|
+
if (tokenSetHas(prepared.labelTokens, term)) score += 8
|
|
65
|
+
score += countBodyHits(prepared.codeLower, term, 5)
|
|
44
66
|
}
|
|
45
67
|
if (score > 0) scored.push({ ...ex, score })
|
|
46
68
|
}
|
|
@@ -76,14 +98,20 @@ export function renderExamplesResult(
|
|
|
76
98
|
|
|
77
99
|
/** Load the bundled example corpus. Resolves relative to this module so it works from `src/` (repo dev)
|
|
78
100
|
* and `dist/` (published) alike - `docs/examples.json` sits at the package root next to both. */
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
101
|
+
let examplesCorpusPromise: Promise<Example[] | undefined> | undefined
|
|
102
|
+
|
|
103
|
+
/** The published corpus is immutable, so keep one loaded array and its derived search fields. */
|
|
104
|
+
export function loadExamplesCorpus(): Promise<Example[] | undefined> {
|
|
105
|
+
examplesCorpusPromise ??= (async () => {
|
|
106
|
+
const url = new URL("../docs/examples.json", import.meta.url)
|
|
107
|
+
try {
|
|
108
|
+
const text = await Bun.file(url).text()
|
|
109
|
+
if (text.length === 0) return undefined
|
|
110
|
+
const parsed = JSON.parse(text) as Example[]
|
|
111
|
+
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
return examplesCorpusPromise
|
|
89
117
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import type { Diagnostic } from "./diagnostics.ts"
|
|
5
|
+
import { resolveInsideProject } from "./project-path.ts"
|
|
6
|
+
|
|
7
|
+
export interface FixRecipe {
|
|
8
|
+
readonly id: string
|
|
9
|
+
readonly description: string
|
|
10
|
+
apply(root: string, diagnostic: Diagnostic): Promise<readonly string[]>
|
|
11
|
+
readonly verify: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const recipes = new Map<string, FixRecipe>()
|
|
15
|
+
|
|
16
|
+
export function registerFixRecipe(recipe: FixRecipe): void {
|
|
17
|
+
if (recipes.has(recipe.id)) throw new Error(`duplicate fix recipe: ${recipe.id}`)
|
|
18
|
+
recipes.set(recipe.id, Object.freeze(recipe))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getFixRecipe(id: string): FixRecipe | undefined {
|
|
22
|
+
return recipes.get(id)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function listFixRecipes(): readonly FixRecipe[] {
|
|
26
|
+
return Object.freeze([...recipes.values()])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
registerFixRecipe({
|
|
30
|
+
id: "security.timing-safe-equal",
|
|
31
|
+
description: "Replace a direct secret comparison with a length check and timing-safe comparison.",
|
|
32
|
+
verify: "nifra check --lints-only",
|
|
33
|
+
async apply(root, diagnostic) {
|
|
34
|
+
if (diagnostic.file === undefined || diagnostic.line === undefined) return []
|
|
35
|
+
const path = await resolveInsideProject(root, diagnostic.file)
|
|
36
|
+
if (path === undefined) return []
|
|
37
|
+
const lines = (await readFile(path, "utf8")).split("\n")
|
|
38
|
+
const index = diagnostic.line - 1
|
|
39
|
+
const original = lines[index]
|
|
40
|
+
if (original === undefined || original.includes("@nifra-gate-reviewed")) return []
|
|
41
|
+
const comparison = /\b([A-Za-z_$][\w$]*)\s*(===|!==|==|!=)\s*([A-Za-z_$][\w$]*)/.exec(original)
|
|
42
|
+
if (comparison === null) return []
|
|
43
|
+
const left = comparison[1] as string
|
|
44
|
+
const operator = comparison[2] as string
|
|
45
|
+
const right = comparison[3] as string
|
|
46
|
+
const expression = `nifraTimingSafeEqual(${left}, ${right})`
|
|
47
|
+
const replacement = operator === "!==" || operator === "!=" ? `!${expression}` : expression
|
|
48
|
+
lines[index] = original.replace(comparison[0], replacement)
|
|
49
|
+
if (!lines.some((line) => line.includes("function nifraTimingSafeEqual"))) {
|
|
50
|
+
lines.unshift(
|
|
51
|
+
'import { timingSafeEqual } from "node:crypto"',
|
|
52
|
+
"",
|
|
53
|
+
"function nifraTimingSafeEqual(left: string, right: string): boolean {",
|
|
54
|
+
" const leftBytes = Buffer.from(left)",
|
|
55
|
+
" const rightBytes = Buffer.from(right)",
|
|
56
|
+
" return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes)",
|
|
57
|
+
"}",
|
|
58
|
+
"",
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
await writeFile(path, lines.join("\n"), "utf8")
|
|
62
|
+
return [diagnostic.file]
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
registerFixRecipe({
|
|
67
|
+
id: "manifest.sync",
|
|
68
|
+
description: "Regenerate generated server manifests from the current route tree.",
|
|
69
|
+
verify: "nifra check --lints-only",
|
|
70
|
+
async apply(root) {
|
|
71
|
+
const { syncServerManifests } = await import("./sync-manifest.ts")
|
|
72
|
+
const results = await syncServerManifests(root)
|
|
73
|
+
return results.filter((result) => result.changed).map((result) => result.file)
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
registerFixRecipe({
|
|
78
|
+
id: "contracts.snapshot",
|
|
79
|
+
description: "Refresh the opt-in contract lock after reviewing an intentional route change.",
|
|
80
|
+
verify: "nifra contracts check",
|
|
81
|
+
async apply(root) {
|
|
82
|
+
const { snapshotContracts } = await import("./contracts.ts")
|
|
83
|
+
await snapshotContracts(root)
|
|
84
|
+
return ["contracts.lock.json"]
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
registerFixRecipe({
|
|
89
|
+
id: "workspace-dist.rebuild",
|
|
90
|
+
description: "Rebuild the workspace-linked package whose artifact is older than its source.",
|
|
91
|
+
verify: "nifra doctor --json",
|
|
92
|
+
async apply(root, diagnostic) {
|
|
93
|
+
const packageName = diagnostic.evidence?.[0]
|
|
94
|
+
if (packageName === undefined || !/^@?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?$/.test(packageName))
|
|
95
|
+
return []
|
|
96
|
+
// The segment regex above admits "." and "..", which would resolve outside node_modules.
|
|
97
|
+
if (packageName.split("/").some((segment) => segment === "." || segment === "..")) return []
|
|
98
|
+
const packageDir = await resolveInsideProject(root, join("node_modules", packageName))
|
|
99
|
+
if (packageDir === undefined) return []
|
|
100
|
+
const packageJson = await resolveInsideProject(
|
|
101
|
+
root,
|
|
102
|
+
join("node_modules", packageName, "package.json"),
|
|
103
|
+
)
|
|
104
|
+
if (packageJson === undefined || !existsSync(packageJson)) return []
|
|
105
|
+
const proc = Bun.spawn(["bun", "run", "build"], {
|
|
106
|
+
cwd: packageDir,
|
|
107
|
+
stdout: "ignore",
|
|
108
|
+
stderr: "pipe",
|
|
109
|
+
})
|
|
110
|
+
const error = await new Response(proc.stderr).text()
|
|
111
|
+
const exitCode = await proc.exited
|
|
112
|
+
if (exitCode !== 0) throw new Error(error.trim() || `build failed for ${packageName}`)
|
|
113
|
+
return [diagnostic.evidence?.[1] ?? packageName]
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export async function applyDiagnosticRecipe(
|
|
118
|
+
root: string,
|
|
119
|
+
diagnostic: Diagnostic,
|
|
120
|
+
): Promise<readonly string[]> {
|
|
121
|
+
const recipeId = diagnostic.fix?.recipe
|
|
122
|
+
if (recipeId === undefined) return []
|
|
123
|
+
const recipe = getFixRecipe(recipeId)
|
|
124
|
+
if (recipe === undefined) return []
|
|
125
|
+
return recipe.apply(root, diagnostic)
|
|
126
|
+
}
|