@nifrajs/cli 2.11.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 +6 -0
- 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 +125 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +706 -145
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +194 -10
- 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/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 +28 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +173 -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 +53 -4
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -1
- 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 +358 -25
- package/dist/mcp.js.map +1 -1
- 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 +906 -15
- package/docs/types.json +1682 -130
- 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 +873 -168
- package/src/cli.ts +198 -11
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +259 -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 +75 -6
- package/src/load.ts +6 -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 +438 -32
- 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/contracts.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
|
|
4
|
+
|
|
5
|
+
export interface ContractDigest {
|
|
6
|
+
readonly request: string
|
|
7
|
+
readonly response: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ContractsLock {
|
|
11
|
+
readonly version: 1
|
|
12
|
+
readonly routes: Readonly<Record<string, ContractDigest>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_CONTRACTS_LOCK = "contracts.lock.json"
|
|
16
|
+
const DIGEST = /^[a-f0-9]{64}$/
|
|
17
|
+
|
|
18
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
20
|
+
? Object.fromEntries(Object.entries(value))
|
|
21
|
+
: undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function canonical(value: unknown, key?: string): unknown {
|
|
25
|
+
if (["description", "title", "default", "example", "examples"].includes(key ?? ""))
|
|
26
|
+
return undefined
|
|
27
|
+
if (Array.isArray(value)) return value.map((item) => canonical(item))
|
|
28
|
+
const record = recordOf(value)
|
|
29
|
+
if (record === undefined) return value
|
|
30
|
+
const out: Record<string, unknown> = {}
|
|
31
|
+
for (const name of Object.keys(record).sort()) {
|
|
32
|
+
const item = canonical(record[name], name)
|
|
33
|
+
if (item !== undefined) out[name] = item
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sha256(value: string): Promise<string> {
|
|
39
|
+
const bytes = new TextEncoder().encode(value)
|
|
40
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
|
|
41
|
+
return [...digest].map((item) => item.toString(16).padStart(2, "0")).join("")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function schemaValue(route: ReflectedRoute, side: "request" | "response"): unknown {
|
|
45
|
+
const schema = route.schema
|
|
46
|
+
if (schema === undefined) return {}
|
|
47
|
+
if (side === "request") {
|
|
48
|
+
return {
|
|
49
|
+
body: schema.body?.jsonSchema ?? schema.body?.fields ?? null,
|
|
50
|
+
query: schema.query?.jsonSchema ?? schema.query?.fields ?? null,
|
|
51
|
+
params: schema.params?.jsonSchema ?? schema.params?.fields ?? null,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
response: schema.response?.jsonSchema ?? schema.response?.fields ?? null,
|
|
56
|
+
sse: schema.sse?.jsonSchema ?? schema.sse?.fields ?? null,
|
|
57
|
+
errors: Object.fromEntries(
|
|
58
|
+
Object.entries(schema.errors ?? {}).map(([status, value]) => [
|
|
59
|
+
status,
|
|
60
|
+
value.jsonSchema ?? value.fields ?? null,
|
|
61
|
+
]),
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
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")))
|
|
69
|
+
return { request: await sha256(request), response: await sha256(response) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function buildContractsLock(source: unknown): Promise<ContractsLock> {
|
|
73
|
+
const routes = Object.fromEntries(
|
|
74
|
+
await Promise.all(
|
|
75
|
+
reflectRoutes(source).map(
|
|
76
|
+
async (route) => [`${route.method} ${route.path}`, await digestRoute(route)] as const,
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
return {
|
|
81
|
+
version: 1,
|
|
82
|
+
routes: Object.fromEntries(Object.entries(routes).sort(([a], [b]) => a.localeCompare(b))),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadBackend(cwd: string): Promise<unknown> {
|
|
87
|
+
const backendPath = resolve(cwd, "backend.ts")
|
|
88
|
+
if (!existsSync(backendPath)) throw new Error(`[nifra] no backend.ts in ${cwd}`)
|
|
89
|
+
const loadedValue: unknown = await import(backendPath)
|
|
90
|
+
const loaded = recordOf(loadedValue)
|
|
91
|
+
if (loaded?.backend === undefined)
|
|
92
|
+
throw new Error(`[nifra] ${backendPath} does not export backend`)
|
|
93
|
+
return loaded.backend
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function snapshotContracts(
|
|
97
|
+
cwd: string,
|
|
98
|
+
out = DEFAULT_CONTRACTS_LOCK,
|
|
99
|
+
): Promise<ContractsLock> {
|
|
100
|
+
const lock = await buildContractsLock(await loadBackend(cwd))
|
|
101
|
+
await Bun.write(resolve(cwd, out), `${JSON.stringify(lock, null, 2)}\n`)
|
|
102
|
+
return lock
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseContractsLock(value: unknown): ContractsLock {
|
|
106
|
+
const record = recordOf(value)
|
|
107
|
+
if (record === undefined) throw new TypeError("contracts lock must be an object")
|
|
108
|
+
const routesValue = record.routes
|
|
109
|
+
const routesRecord = recordOf(routesValue)
|
|
110
|
+
if (record.version !== 1 || routesRecord === undefined)
|
|
111
|
+
throw new TypeError("contracts lock must have version 1 and routes")
|
|
112
|
+
const routes: Record<string, ContractDigest> = {}
|
|
113
|
+
for (const [key, raw] of Object.entries(routesRecord)) {
|
|
114
|
+
const item = recordOf(raw)
|
|
115
|
+
const request = item?.request
|
|
116
|
+
const response = item?.response
|
|
117
|
+
if (
|
|
118
|
+
request === undefined ||
|
|
119
|
+
typeof request !== "string" ||
|
|
120
|
+
response === undefined ||
|
|
121
|
+
typeof response !== "string" ||
|
|
122
|
+
!DIGEST.test(request) ||
|
|
123
|
+
!DIGEST.test(response)
|
|
124
|
+
)
|
|
125
|
+
throw new TypeError(`invalid contract ${key}`)
|
|
126
|
+
routes[key] = { request, response }
|
|
127
|
+
}
|
|
128
|
+
return { version: 1, routes }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function checkContractsLock(
|
|
132
|
+
cwd: string,
|
|
133
|
+
file = DEFAULT_CONTRACTS_LOCK,
|
|
134
|
+
): Promise<{ present: boolean; diagnostics: Array<{ route?: string; message: string }> }> {
|
|
135
|
+
const path = resolve(cwd, file)
|
|
136
|
+
if (!existsSync(path)) return { present: false, diagnostics: [] }
|
|
137
|
+
const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
|
|
138
|
+
const current = await buildContractsLock(await loadBackend(cwd))
|
|
139
|
+
const diagnostics: Array<{ route?: string; message: string }> = []
|
|
140
|
+
const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
|
|
141
|
+
for (const key of [...keys].sort()) {
|
|
142
|
+
const before = parsed.routes[key]
|
|
143
|
+
const after = current.routes[key]
|
|
144
|
+
if (
|
|
145
|
+
before === undefined ||
|
|
146
|
+
after === undefined ||
|
|
147
|
+
before.request !== after.request ||
|
|
148
|
+
before.response !== after.response
|
|
149
|
+
) {
|
|
150
|
+
diagnostics.push({
|
|
151
|
+
route: key,
|
|
152
|
+
message: `contract changed for ${key}; if intentional run \`nifra contracts snapshot\` and include the lock update in the same change.`,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { present: true, diagnostics }
|
|
157
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Stable, machine-actionable diagnostics shared by CLI gates and MCP renderers. */
|
|
2
|
+
|
|
3
|
+
export type Severity = "error" | "warn" | "info"
|
|
4
|
+
|
|
5
|
+
export interface DiagnosticFix {
|
|
6
|
+
readonly recipe: string
|
|
7
|
+
readonly command?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Diagnostic {
|
|
11
|
+
/** Stable forever. Never renumber or reuse. */
|
|
12
|
+
readonly code: string
|
|
13
|
+
readonly severity: Severity
|
|
14
|
+
readonly message: string
|
|
15
|
+
readonly file?: string
|
|
16
|
+
readonly line?: number
|
|
17
|
+
/** Short evidence strings that help an agent act without exposing payloads. */
|
|
18
|
+
readonly evidence?: readonly string[]
|
|
19
|
+
readonly fix?: DiagnosticFix
|
|
20
|
+
/** Command that proves the fix worked. */
|
|
21
|
+
readonly verify?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const diagnostic = (value: Diagnostic): Diagnostic => Object.freeze(value)
|
|
25
|
+
|
|
26
|
+
export function severityFails(severity: Severity, strict = false): boolean {
|
|
27
|
+
return severity === "error" || (strict && severity === "warn")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeSeverity(value: "error" | "warning" | "warn" | "info"): Severity {
|
|
31
|
+
return value === "warning" ? "warn" : value
|
|
32
|
+
}
|
package/src/diff-tool.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function formatDiff(diff: RoutesDiff): string {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** Import `backend.ts` from `cwd` and snapshot its routes. */
|
|
85
|
-
async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
|
|
85
|
+
export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
|
|
86
86
|
const backendPath = resolve(cwd, "backend.ts")
|
|
87
87
|
if (!existsSync(backendPath)) {
|
|
88
88
|
throw new Error(
|
package/src/docs-search.ts
CHANGED
|
@@ -17,6 +17,13 @@ export interface DocSection {
|
|
|
17
17
|
readonly body: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export interface PreparedDocSection extends DocSection {
|
|
21
|
+
readonly headingTokens: ReadonlySet<string>
|
|
22
|
+
readonly bodyLower: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sectionCache = new Map<string, readonly PreparedDocSection[]>()
|
|
26
|
+
|
|
20
27
|
/** Split the generated doc into its `##` sections (the `# title` intro becomes "Overview"). */
|
|
21
28
|
export function splitSections(markdown: string): DocSection[] {
|
|
22
29
|
const out: DocSection[] = []
|
|
@@ -40,6 +47,23 @@ export function splitSections(markdown: string): DocSection[] {
|
|
|
40
47
|
return out
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
function prepareSection(section: DocSection): PreparedDocSection {
|
|
51
|
+
return {
|
|
52
|
+
...section,
|
|
53
|
+
headingTokens: new Set(tokenize(section.heading)),
|
|
54
|
+
bodyLower: section.body.toLowerCase(),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Return the immutable-build section parse and derived search fields from the process cache. */
|
|
59
|
+
export function getCachedDocSections(markdown: string): readonly PreparedDocSection[] {
|
|
60
|
+
const cached = sectionCache.get(markdown)
|
|
61
|
+
if (cached !== undefined) return cached
|
|
62
|
+
const prepared = splitSections(markdown).map(prepareSection)
|
|
63
|
+
sectionCache.set(markdown, prepared)
|
|
64
|
+
return prepared
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
export interface ScoredSection extends DocSection {
|
|
44
68
|
readonly score: number
|
|
45
69
|
}
|
|
@@ -48,7 +72,7 @@ export interface ScoredSection extends DocSection {
|
|
|
48
72
|
* (headings are curated names for exactly these lookups); body occurrences cap per term so one
|
|
49
73
|
* spammy section can't drown a precisely-titled one. */
|
|
50
74
|
export function searchSections(
|
|
51
|
-
sections: readonly DocSection[],
|
|
75
|
+
sections: readonly (DocSection | PreparedDocSection)[],
|
|
52
76
|
query: string,
|
|
53
77
|
limit: number,
|
|
54
78
|
): ScoredSection[] {
|
|
@@ -56,14 +80,14 @@ export function searchSections(
|
|
|
56
80
|
if (terms.length === 0) return []
|
|
57
81
|
const scored: ScoredSection[] = []
|
|
58
82
|
for (const section of sections) {
|
|
59
|
-
const
|
|
60
|
-
|
|
83
|
+
const prepared =
|
|
84
|
+
"headingTokens" in section && "bodyLower" in section ? section : prepareSection(section)
|
|
61
85
|
let score = 0
|
|
62
86
|
for (const term of terms) {
|
|
63
|
-
if (tokenSetHas(headingTokens, term)) score += 8
|
|
64
|
-
score += countBodyHits(bodyLower, term, 5)
|
|
87
|
+
if (tokenSetHas(prepared.headingTokens, term)) score += 8
|
|
88
|
+
score += countBodyHits(prepared.bodyLower, term, 5)
|
|
65
89
|
}
|
|
66
|
-
if (score > 0) scored.push({
|
|
90
|
+
if (score > 0) scored.push({ heading: section.heading, body: section.body, score })
|
|
67
91
|
}
|
|
68
92
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
|
|
69
93
|
}
|
|
@@ -78,7 +102,7 @@ export function renderDocsResult(
|
|
|
78
102
|
query: string | undefined,
|
|
79
103
|
limit: number,
|
|
80
104
|
): string {
|
|
81
|
-
const sections =
|
|
105
|
+
const sections = getCachedDocSections(markdown)
|
|
82
106
|
if (query === undefined || query.trim() === "") {
|
|
83
107
|
const index = sections.map((s) => `- ${s.heading}`).join("\n")
|
|
84
108
|
return `# nifra docs - section index (pass \`query\` to fetch matching sections)\n\n${index}`
|
|
@@ -100,12 +124,18 @@ export function renderDocsResult(
|
|
|
100
124
|
|
|
101
125
|
/** Load the bundled corpus. Resolves relative to this module so it works from `src/` (repo dev)
|
|
102
126
|
* and `dist/` (published) alike - `docs/llms-full.txt` sits at the package root next to both. */
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
let docsCorpusPromise: Promise<string | undefined> | undefined
|
|
128
|
+
|
|
129
|
+
/** The published corpus is immutable, so one read and one parsed-section cache are sufficient. */
|
|
130
|
+
export function loadDocsCorpus(): Promise<string | undefined> {
|
|
131
|
+
docsCorpusPromise ??= (async () => {
|
|
132
|
+
const url = new URL("../docs/llms-full.txt", import.meta.url)
|
|
133
|
+
try {
|
|
134
|
+
const text = await Bun.file(url).text()
|
|
135
|
+
return text.length > 0 ? text : undefined
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
})()
|
|
140
|
+
return docsCorpusPromise
|
|
111
141
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -17,11 +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
24
|
import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
|
|
25
|
+
import { type ResolvedTarget, resolveTarget } from "./port.ts"
|
|
25
26
|
|
|
26
27
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
27
28
|
// own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
|
|
@@ -136,6 +137,8 @@ export interface DoctorResult {
|
|
|
136
137
|
* Advisory (never folded into `ok`): while actively editing a linked package its dist is always
|
|
137
138
|
* momentarily behind - the finding matters when a dev server starts against it. */
|
|
138
139
|
readonly staleDists: readonly StaleDistFinding[]
|
|
140
|
+
/** Static production-readiness evidence for the selected deploy target. */
|
|
141
|
+
readonly readiness?: DoctorReadiness
|
|
139
142
|
/**
|
|
140
143
|
* Which bundler this app's `dev`/`build` phases run on, read statically, plus the config hazards
|
|
141
144
|
* that exist only because there are two. Absent when the directory is not a nifra app.
|
|
@@ -151,6 +154,30 @@ export interface DoctorResult {
|
|
|
151
154
|
readonly skippedFixes?: readonly DoctorSkippedFix[]
|
|
152
155
|
}
|
|
153
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
|
+
|
|
154
181
|
export interface DuplicateInstallCopy {
|
|
155
182
|
/** Installed version, or `unknown` when package metadata is incomplete. */
|
|
156
183
|
readonly version: string
|
|
@@ -352,6 +379,66 @@ async function resolvedInstalledCopy(
|
|
|
352
379
|
}
|
|
353
380
|
}
|
|
354
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
|
+
|
|
355
442
|
const displayPath = (cwd: string, path: string): string => {
|
|
356
443
|
const rel = relative(cwd, path)
|
|
357
444
|
return rel === "" ? "." : rel
|
|
@@ -464,6 +551,28 @@ export async function collectDuplicateInstalls(
|
|
|
464
551
|
if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
|
|
465
552
|
}
|
|
466
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
|
+
|
|
467
576
|
const findings: DuplicateInstallFinding[] = []
|
|
468
577
|
for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
469
578
|
if (copies.size < 2) continue
|
|
@@ -631,6 +740,110 @@ export async function collectStaleWorkspaceDists(
|
|
|
631
740
|
return findings.sort((a, b) => a.package.localeCompare(b.package))
|
|
632
741
|
}
|
|
633
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
|
+
|
|
634
847
|
function depRecord(
|
|
635
848
|
pkg: Record<string, unknown>,
|
|
636
849
|
field: string,
|
|
@@ -687,7 +900,10 @@ async function inferDependencyFix(
|
|
|
687
900
|
}
|
|
688
901
|
|
|
689
902
|
/** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
|
|
690
|
-
export async function collectDoctorResult(
|
|
903
|
+
export async function collectDoctorResult(
|
|
904
|
+
cwd: string,
|
|
905
|
+
opts: { readonly target?: string; readonly strict?: boolean } = {},
|
|
906
|
+
): Promise<DoctorResult> {
|
|
691
907
|
const pkg = await readJson(join(cwd, "package.json"))
|
|
692
908
|
if (pkg === undefined)
|
|
693
909
|
return { ok: true, ran: false, findings: [], duplicateInstalls: [], staleDists: [] }
|
|
@@ -713,20 +929,29 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
|
|
|
713
929
|
const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
|
|
714
930
|
const pipeline = await collectPipelineReport(cwd)
|
|
715
931
|
const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
|
|
932
|
+
const readiness = await collectDoctorReadiness(cwd, opts)
|
|
716
933
|
// `staleDists` is advisory (see DoctorResult): it never fails `ok`.
|
|
717
934
|
return {
|
|
718
|
-
ok:
|
|
935
|
+
ok:
|
|
936
|
+
findings.length === 0 &&
|
|
937
|
+
duplicateInstalls.length === 0 &&
|
|
938
|
+
pipelineErrors.length === 0 &&
|
|
939
|
+
(opts.strict !== true || readiness.ok),
|
|
719
940
|
ran: true,
|
|
720
941
|
findings,
|
|
721
942
|
duplicateInstalls,
|
|
722
943
|
staleDists,
|
|
944
|
+
readiness,
|
|
723
945
|
...(pipeline.ran ? { pipeline } : {}),
|
|
724
946
|
}
|
|
725
947
|
}
|
|
726
948
|
|
|
727
949
|
/** Safely add undeclared imports to package.json when the version can be inferred without network I/O. */
|
|
728
|
-
export async function applyDoctorAutoFix(
|
|
729
|
-
|
|
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)
|
|
730
955
|
if (!before.ran || before.findings.length === 0) return before
|
|
731
956
|
|
|
732
957
|
const rootPackage = await readJson(join(cwd, "package.json"))
|
|
@@ -787,7 +1012,7 @@ export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
|
|
|
787
1012
|
if (changed) await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
788
1013
|
}
|
|
789
1014
|
|
|
790
|
-
const after = fixed.length > 0 ? await collectDoctorResult(cwd) : before
|
|
1015
|
+
const after = fixed.length > 0 ? await collectDoctorResult(cwd, opts) : before
|
|
791
1016
|
return {
|
|
792
1017
|
...after,
|
|
793
1018
|
...(fixed.length > 0 ? { fixed } : {}),
|
|
@@ -798,9 +1023,16 @@ export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
|
|
|
798
1023
|
/** Run doctor; print a report (`--json` for machine output) and return whether it passed. */
|
|
799
1024
|
export async function runDoctor(
|
|
800
1025
|
cwd: string,
|
|
801
|
-
opts: {
|
|
1026
|
+
opts: {
|
|
1027
|
+
readonly json?: boolean
|
|
1028
|
+
readonly autoFix?: boolean
|
|
1029
|
+
readonly strict?: boolean
|
|
1030
|
+
readonly target?: string
|
|
1031
|
+
} = {},
|
|
802
1032
|
): Promise<boolean> {
|
|
803
|
-
const result = opts.autoFix
|
|
1033
|
+
const result = opts.autoFix
|
|
1034
|
+
? await applyDoctorAutoFix(cwd, opts)
|
|
1035
|
+
: await collectDoctorResult(cwd, opts)
|
|
804
1036
|
if (opts.json) {
|
|
805
1037
|
console.log(JSON.stringify(result, null, 2))
|
|
806
1038
|
return result.ok
|
|
@@ -854,6 +1086,22 @@ export async function runDoctor(
|
|
|
854
1086
|
}
|
|
855
1087
|
console.log("")
|
|
856
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
|
+
}
|
|
857
1105
|
if (result.ok) {
|
|
858
1106
|
console.log(
|
|
859
1107
|
"✓ every imported package is declared and identity-sensitive installs are deduplicated",
|
|
@@ -896,6 +1144,9 @@ export async function runDoctor(
|
|
|
896
1144
|
"\n Align dependency ranges and reinstall from the workspace root; doctor never deletes installs.",
|
|
897
1145
|
)
|
|
898
1146
|
}
|
|
1147
|
+
if (result.readiness?.strict === true && result.readiness.ok === false) {
|
|
1148
|
+
console.log("\n✗ production readiness is incomplete under --strict")
|
|
1149
|
+
}
|
|
899
1150
|
if (byPkg.size > 0)
|
|
900
1151
|
console.log(
|
|
901
1152
|
"\nThese resolve at Bun runtime via hoisting/workspace but break `tsc` and a standalone install.",
|