@nifrajs/cli 2.11.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +6 -0
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +125 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +706 -145
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +23 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +194 -10
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/diagnostics.d.ts +23 -0
  31. package/dist/diagnostics.d.ts.map +1 -0
  32. package/dist/diagnostics.js +9 -0
  33. package/dist/diagnostics.js.map +1 -0
  34. package/dist/diff-tool.d.ts +2 -0
  35. package/dist/diff-tool.d.ts.map +1 -1
  36. package/dist/diff-tool.js +1 -1
  37. package/dist/diff-tool.js.map +1 -1
  38. package/dist/docs-search.d.ts +8 -3
  39. package/dist/docs-search.d.ts.map +1 -1
  40. package/dist/docs-search.js +36 -15
  41. package/dist/docs-search.js.map +1 -1
  42. package/dist/doctor.d.ts +28 -2
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +173 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/examples.d.ts +1 -2
  47. package/dist/examples.d.ts.map +1 -1
  48. package/dist/examples.js +37 -16
  49. package/dist/examples.js.map +1 -1
  50. package/dist/fix-recipes.d.ts +12 -0
  51. package/dist/fix-recipes.d.ts.map +1 -0
  52. package/dist/fix-recipes.js +106 -0
  53. package/dist/fix-recipes.js.map +1 -0
  54. package/dist/init-agents.d.ts.map +1 -1
  55. package/dist/init-agents.js +60 -4
  56. package/dist/init-agents.js.map +1 -1
  57. package/dist/internal/source-facts.d.ts +15 -0
  58. package/dist/internal/source-facts.d.ts.map +1 -0
  59. package/dist/internal/source-facts.js +82 -0
  60. package/dist/internal/source-facts.js.map +1 -0
  61. package/dist/internal/typescript-import.d.ts +9 -0
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +38 -0
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +6 -2
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +53 -4
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/load.d.ts +6 -0
  70. package/dist/load.d.ts.map +1 -1
  71. package/dist/load.js.map +1 -1
  72. package/dist/manifest-tool.d.ts.map +1 -1
  73. package/dist/manifest-tool.js +14 -9
  74. package/dist/manifest-tool.js.map +1 -1
  75. package/dist/mcp-docs-tools.d.ts.map +1 -1
  76. package/dist/mcp-docs-tools.js +3 -0
  77. package/dist/mcp-docs-tools.js.map +1 -1
  78. package/dist/mcp-http.js +1 -1
  79. package/dist/mcp-root.d.ts +83 -0
  80. package/dist/mcp-root.d.ts.map +1 -0
  81. package/dist/mcp-root.js +221 -0
  82. package/dist/mcp-root.js.map +1 -0
  83. package/dist/mcp-run.d.ts.map +1 -1
  84. package/dist/mcp-run.js +18 -4
  85. package/dist/mcp-run.js.map +1 -1
  86. package/dist/mcp.d.ts +27 -4
  87. package/dist/mcp.d.ts.map +1 -1
  88. package/dist/mcp.js +358 -25
  89. package/dist/mcp.js.map +1 -1
  90. package/dist/project-path.d.ts +3 -0
  91. package/dist/project-path.d.ts.map +1 -0
  92. package/dist/project-path.js +36 -0
  93. package/dist/project-path.js.map +1 -0
  94. package/dist/replay.d.ts +12 -0
  95. package/dist/replay.d.ts.map +1 -0
  96. package/dist/replay.js +36 -0
  97. package/dist/replay.js.map +1 -0
  98. package/dist/rules/codes.d.ts +42 -0
  99. package/dist/rules/codes.d.ts.map +1 -0
  100. package/dist/rules/codes.js +52 -0
  101. package/dist/rules/codes.js.map +1 -0
  102. package/dist/rules/comment-markers.d.ts +10 -0
  103. package/dist/rules/comment-markers.d.ts.map +1 -0
  104. package/dist/rules/comment-markers.js +24 -0
  105. package/dist/rules/comment-markers.js.map +1 -0
  106. package/dist/rules/index.d.ts +31 -0
  107. package/dist/rules/index.d.ts.map +1 -0
  108. package/dist/rules/index.js +133 -0
  109. package/dist/rules/index.js.map +1 -0
  110. package/dist/rules/legacy.d.ts +7 -0
  111. package/dist/rules/legacy.d.ts.map +1 -0
  112. package/dist/rules/legacy.js +85 -0
  113. package/dist/rules/legacy.js.map +1 -0
  114. package/dist/rules/routes.d.ts +12 -0
  115. package/dist/rules/routes.d.ts.map +1 -0
  116. package/dist/rules/routes.js +142 -0
  117. package/dist/rules/routes.js.map +1 -0
  118. package/dist/rules/security.d.ts +10 -0
  119. package/dist/rules/security.d.ts.map +1 -0
  120. package/dist/rules/security.js +471 -0
  121. package/dist/rules/security.js.map +1 -0
  122. package/dist/search-terms.d.ts +2 -0
  123. package/dist/search-terms.d.ts.map +1 -1
  124. package/dist/search-terms.js +5 -1
  125. package/dist/search-terms.js.map +1 -1
  126. package/dist/types-search.d.ts +1 -2
  127. package/dist/types-search.d.ts.map +1 -1
  128. package/dist/types-search.js +33 -16
  129. package/dist/types-search.js.map +1 -1
  130. package/dist/verification.d.ts +9 -0
  131. package/dist/verification.d.ts.map +1 -1
  132. package/dist/verification.js +17 -1
  133. package/dist/verification.js.map +1 -1
  134. package/dist/work-graph.d.ts +123 -0
  135. package/dist/work-graph.d.ts.map +1 -0
  136. package/dist/work-graph.js +465 -0
  137. package/dist/work-graph.js.map +1 -0
  138. package/docs/examples.json +32 -2
  139. package/docs/llms-full.txt +906 -15
  140. package/docs/types.json +1682 -130
  141. package/package.json +34 -9
  142. package/src/assure-hydration.ts +606 -0
  143. package/src/assure.ts +316 -2
  144. package/src/capabilities-tool.ts +80 -1
  145. package/src/check.ts +873 -168
  146. package/src/cli.ts +198 -11
  147. package/src/contract-proof.ts +194 -0
  148. package/src/contracts.ts +157 -0
  149. package/src/diagnostics.ts +32 -0
  150. package/src/diff-tool.ts +1 -1
  151. package/src/docs-search.ts +45 -15
  152. package/src/doctor.ts +259 -8
  153. package/src/examples.ts +43 -15
  154. package/src/fix-recipes.ts +126 -0
  155. package/src/init-agents.ts +61 -4
  156. package/src/internal/source-facts.ts +133 -0
  157. package/src/internal/typescript-import.ts +29 -0
  158. package/src/introspect.ts +75 -6
  159. package/src/load.ts +6 -0
  160. package/src/manifest-tool.ts +16 -10
  161. package/src/mcp-docs-tools.ts +3 -0
  162. package/src/mcp-http.ts +1 -1
  163. package/src/mcp-root.ts +271 -0
  164. package/src/mcp-run.ts +19 -4
  165. package/src/mcp.ts +438 -32
  166. package/src/project-path.ts +36 -0
  167. package/src/replay.ts +44 -0
  168. package/src/rules/codes.ts +57 -0
  169. package/src/rules/comment-markers.ts +26 -0
  170. package/src/rules/index.ts +175 -0
  171. package/src/rules/legacy.ts +93 -0
  172. package/src/rules/routes.ts +157 -0
  173. package/src/rules/security.ts +505 -0
  174. package/src/search-terms.ts +9 -4
  175. package/src/types-search.ts +39 -15
  176. package/src/verification.ts +18 -1
  177. package/src/work-graph.ts +613 -0
@@ -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(
@@ -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 headingTokens = new Set(tokenize(section.heading))
60
- const bodyLower = section.body.toLowerCase()
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({ ...section, score })
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 = splitSections(markdown)
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
- export async function loadDocsCorpus(): Promise<string | undefined> {
104
- const url = new URL("../docs/llms-full.txt", import.meta.url)
105
- try {
106
- const text = await Bun.file(url).text()
107
- return text.length > 0 ? text : undefined
108
- } catch {
109
- return undefined
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(cwd: string): Promise<DoctorResult> {
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: findings.length === 0 && duplicateInstalls.length === 0 && pipelineErrors.length === 0,
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(cwd: string): Promise<DoctorResult> {
729
- const before = await collectDoctorResult(cwd)
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: { readonly json?: boolean; readonly autoFix?: boolean } = {},
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 ? await applyDoctorAutoFix(cwd) : await collectDoctorResult(cwd)
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.",