@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.
Files changed (193) hide show
  1. package/README.md +7 -1
  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 +133 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +741 -148
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +21 -14
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +288 -73
  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/dev-bun-config.d.ts +17 -6
  31. package/dist/dev-bun-config.d.ts.map +1 -1
  32. package/dist/dev-bun-config.js +51 -14
  33. package/dist/dev-bun-config.js.map +1 -1
  34. package/dist/diagnostics.d.ts +23 -0
  35. package/dist/diagnostics.d.ts.map +1 -0
  36. package/dist/diagnostics.js +9 -0
  37. package/dist/diagnostics.js.map +1 -0
  38. package/dist/diff-tool.d.ts +2 -0
  39. package/dist/diff-tool.d.ts.map +1 -1
  40. package/dist/diff-tool.js +1 -1
  41. package/dist/diff-tool.js.map +1 -1
  42. package/dist/docs-search.d.ts +8 -3
  43. package/dist/docs-search.d.ts.map +1 -1
  44. package/dist/docs-search.js +36 -15
  45. package/dist/docs-search.js.map +1 -1
  46. package/dist/doctor.d.ts +38 -2
  47. package/dist/doctor.d.ts.map +1 -1
  48. package/dist/doctor.js +189 -7
  49. package/dist/doctor.js.map +1 -1
  50. package/dist/examples.d.ts +1 -2
  51. package/dist/examples.d.ts.map +1 -1
  52. package/dist/examples.js +37 -16
  53. package/dist/examples.js.map +1 -1
  54. package/dist/fix-recipes.d.ts +12 -0
  55. package/dist/fix-recipes.d.ts.map +1 -0
  56. package/dist/fix-recipes.js +106 -0
  57. package/dist/fix-recipes.js.map +1 -0
  58. package/dist/init-agents.d.ts.map +1 -1
  59. package/dist/init-agents.js +60 -4
  60. package/dist/init-agents.js.map +1 -1
  61. package/dist/internal/source-facts.d.ts +15 -0
  62. package/dist/internal/source-facts.d.ts.map +1 -0
  63. package/dist/internal/source-facts.js +82 -0
  64. package/dist/internal/source-facts.js.map +1 -0
  65. package/dist/internal/typescript-import.d.ts +9 -0
  66. package/dist/internal/typescript-import.d.ts.map +1 -1
  67. package/dist/internal/typescript-import.js +38 -0
  68. package/dist/internal/typescript-import.js.map +1 -1
  69. package/dist/introspect.d.ts +6 -2
  70. package/dist/introspect.d.ts.map +1 -1
  71. package/dist/introspect.js +60 -5
  72. package/dist/introspect.js.map +1 -1
  73. package/dist/load.d.ts +10 -0
  74. package/dist/load.d.ts.map +1 -1
  75. package/dist/load.js +1 -0
  76. package/dist/load.js.map +1 -1
  77. package/dist/manifest-tool.d.ts.map +1 -1
  78. package/dist/manifest-tool.js +14 -9
  79. package/dist/manifest-tool.js.map +1 -1
  80. package/dist/mcp-docs-tools.d.ts.map +1 -1
  81. package/dist/mcp-docs-tools.js +3 -0
  82. package/dist/mcp-docs-tools.js.map +1 -1
  83. package/dist/mcp-http.js +1 -1
  84. package/dist/mcp-root.d.ts +83 -0
  85. package/dist/mcp-root.d.ts.map +1 -0
  86. package/dist/mcp-root.js +221 -0
  87. package/dist/mcp-root.js.map +1 -0
  88. package/dist/mcp-run.d.ts.map +1 -1
  89. package/dist/mcp-run.js +18 -4
  90. package/dist/mcp-run.js.map +1 -1
  91. package/dist/mcp.d.ts +27 -4
  92. package/dist/mcp.d.ts.map +1 -1
  93. package/dist/mcp.js +360 -27
  94. package/dist/mcp.js.map +1 -1
  95. package/dist/pipeline-guard.d.ts +25 -10
  96. package/dist/pipeline-guard.d.ts.map +1 -1
  97. package/dist/pipeline-guard.js +35 -15
  98. package/dist/pipeline-guard.js.map +1 -1
  99. package/dist/pipeline-report.d.ts +34 -0
  100. package/dist/pipeline-report.d.ts.map +1 -0
  101. package/dist/pipeline-report.js +282 -0
  102. package/dist/pipeline-report.js.map +1 -0
  103. package/dist/project-path.d.ts +3 -0
  104. package/dist/project-path.d.ts.map +1 -0
  105. package/dist/project-path.js +36 -0
  106. package/dist/project-path.js.map +1 -0
  107. package/dist/replay.d.ts +12 -0
  108. package/dist/replay.d.ts.map +1 -0
  109. package/dist/replay.js +36 -0
  110. package/dist/replay.js.map +1 -0
  111. package/dist/rules/codes.d.ts +42 -0
  112. package/dist/rules/codes.d.ts.map +1 -0
  113. package/dist/rules/codes.js +52 -0
  114. package/dist/rules/codes.js.map +1 -0
  115. package/dist/rules/comment-markers.d.ts +10 -0
  116. package/dist/rules/comment-markers.d.ts.map +1 -0
  117. package/dist/rules/comment-markers.js +24 -0
  118. package/dist/rules/comment-markers.js.map +1 -0
  119. package/dist/rules/index.d.ts +31 -0
  120. package/dist/rules/index.d.ts.map +1 -0
  121. package/dist/rules/index.js +133 -0
  122. package/dist/rules/index.js.map +1 -0
  123. package/dist/rules/legacy.d.ts +7 -0
  124. package/dist/rules/legacy.d.ts.map +1 -0
  125. package/dist/rules/legacy.js +85 -0
  126. package/dist/rules/legacy.js.map +1 -0
  127. package/dist/rules/routes.d.ts +12 -0
  128. package/dist/rules/routes.d.ts.map +1 -0
  129. package/dist/rules/routes.js +142 -0
  130. package/dist/rules/routes.js.map +1 -0
  131. package/dist/rules/security.d.ts +10 -0
  132. package/dist/rules/security.d.ts.map +1 -0
  133. package/dist/rules/security.js +471 -0
  134. package/dist/rules/security.js.map +1 -0
  135. package/dist/search-terms.d.ts +2 -0
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -1
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/types-search.d.ts +1 -2
  140. package/dist/types-search.d.ts.map +1 -1
  141. package/dist/types-search.js +33 -16
  142. package/dist/types-search.js.map +1 -1
  143. package/dist/verification.d.ts +9 -0
  144. package/dist/verification.d.ts.map +1 -1
  145. package/dist/verification.js +17 -1
  146. package/dist/verification.js.map +1 -1
  147. package/dist/work-graph.d.ts +123 -0
  148. package/dist/work-graph.d.ts.map +1 -0
  149. package/dist/work-graph.js +465 -0
  150. package/dist/work-graph.js.map +1 -0
  151. package/docs/examples.json +32 -2
  152. package/docs/llms-full.txt +1105 -27
  153. package/docs/types.json +1772 -137
  154. package/package.json +34 -9
  155. package/src/assure-hydration.ts +606 -0
  156. package/src/assure.ts +316 -2
  157. package/src/capabilities-tool.ts +80 -1
  158. package/src/check.ts +923 -169
  159. package/src/cli.ts +297 -76
  160. package/src/contract-proof.ts +194 -0
  161. package/src/contracts.ts +157 -0
  162. package/src/dev-bun-config.ts +57 -14
  163. package/src/diagnostics.ts +32 -0
  164. package/src/diff-tool.ts +1 -1
  165. package/src/docs-search.ts +45 -15
  166. package/src/doctor.ts +286 -8
  167. package/src/examples.ts +43 -15
  168. package/src/fix-recipes.ts +126 -0
  169. package/src/init-agents.ts +61 -4
  170. package/src/internal/source-facts.ts +133 -0
  171. package/src/internal/typescript-import.ts +29 -0
  172. package/src/introspect.ts +81 -6
  173. package/src/load.ts +11 -0
  174. package/src/manifest-tool.ts +16 -10
  175. package/src/mcp-docs-tools.ts +3 -0
  176. package/src/mcp-http.ts +1 -1
  177. package/src/mcp-root.ts +271 -0
  178. package/src/mcp-run.ts +19 -4
  179. package/src/mcp.ts +440 -34
  180. package/src/pipeline-guard.ts +44 -14
  181. package/src/pipeline-report.ts +341 -0
  182. package/src/project-path.ts +36 -0
  183. package/src/replay.ts +44 -0
  184. package/src/rules/codes.ts +57 -0
  185. package/src/rules/comment-markers.ts +26 -0
  186. package/src/rules/index.ts +175 -0
  187. package/src/rules/legacy.ts +93 -0
  188. package/src/rules/routes.ts +157 -0
  189. package/src/rules/security.ts +505 -0
  190. package/src/search-terms.ts +9 -4
  191. package/src/types-search.ts +39 -15
  192. package/src/verification.ts +18 -1
  193. package/src/work-graph.ts +613 -0
@@ -18,7 +18,8 @@
18
18
  * cwd (no `..` traversal escaping the project root).
19
19
  */
20
20
 
21
- import { mkdir, readFile, writeFile } from "node:fs/promises"
21
+ import type { Stats } from "node:fs"
22
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
22
23
  import { isAbsolute, relative, resolve } from "node:path"
23
24
  import {
24
25
  AGENTS_MD_PATH,
@@ -68,6 +69,44 @@ const fileExists = (path: string): Promise<boolean> =>
68
69
  .then(() => true)
69
70
  .catch(() => false)
70
71
 
72
+ /** `lstat`, with "it isn't there" reported as `undefined` instead of a throw. Every other failure
73
+ * (EACCES, ELOOP, ...) still propagates - only absence is an expected outcome here. */
74
+ async function lstatOrMissing(path: string): Promise<Stats | undefined> {
75
+ try {
76
+ return await lstat(path)
77
+ } catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined
79
+ throw error
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Refuse to write through a symlinked ancestor. `safeJoin` only proves the *textual* path stays
85
+ * under the project root; a symlinked `.cursor` still lands the write wherever it points. The walk
86
+ * stops at the first missing segment - `mkdir` will create the rest, so there is nothing to check.
87
+ */
88
+ async function assertSafeParents(cwd: string, abs: string): Promise<void> {
89
+ const rel = relative(cwd, resolve(abs, ".."))
90
+ let current = cwd
91
+ for (const part of rel.split(/[\\/]/).filter(Boolean)) {
92
+ current = resolve(current, part)
93
+ const stat = await lstatOrMissing(current)
94
+ if (stat === undefined) break
95
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
96
+ throw new Error(`[nifra] refusing unsafe parent path: ${part}`)
97
+ }
98
+ }
99
+ }
100
+
101
+ /** Refuse to write anything that is not a plain absent-or-regular file: a symlink here would
102
+ * redirect the write, and a directory or device node would make it fail in a confusing way. */
103
+ async function assertRegularTarget(abs: string, rel: string): Promise<void> {
104
+ const stat = await lstatOrMissing(abs)
105
+ if (stat !== undefined && (stat.isSymbolicLink() || !stat.isFile())) {
106
+ throw new Error(`[nifra] refusing to write non-regular file: ${rel}`)
107
+ }
108
+ }
109
+
71
110
  /**
72
111
  * Write a whole-file generator's output to `path` unless it already exists and `force` is false.
73
112
  * Returns the per-file result for the report.
@@ -79,15 +118,31 @@ async function writeOwned(
79
118
  force: boolean,
80
119
  ): Promise<InitAgentsFileResult> {
81
120
  const abs = safeJoin(cwd, rel)
121
+ await assertSafeParents(cwd, abs)
122
+ await assertRegularTarget(abs, rel)
82
123
  if (!force && (await fileExists(abs))) {
83
124
  return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
84
125
  }
85
126
  // `.cursor/mcp.json` needs its parent dir; `recursive` is a no-op for the root-level files.
86
127
  await mkdir(resolve(abs, ".."), { recursive: true })
87
- await writeFile(abs, content)
128
+ await atomicWrite(abs, content)
88
129
  return { path: rel, action: "wrote" }
89
130
  }
90
131
 
132
+ /** Write via a sibling temp file so a crash mid-write can't leave a half-written config in place.
133
+ * `wx` fails rather than following an existing name, and the temp is cleaned up on any failure so a
134
+ * botched run doesn't litter the user's project. */
135
+ async function atomicWrite(abs: string, content: string): Promise<void> {
136
+ const tmp = `${abs}.nifra-${process.pid}-${Date.now()}`
137
+ await writeFile(tmp, content, { flag: "wx" })
138
+ try {
139
+ await rename(tmp, abs)
140
+ } catch (error) {
141
+ await rm(tmp, { force: true })
142
+ throw error
143
+ }
144
+ }
145
+
91
146
  /**
92
147
  * AGENTS.md is additive, not owned: if it exists and already has the MCP section, leave it; if it exists
93
148
  * without the section, append the section (preserving the user's conventions); if it's absent, write a
@@ -96,6 +151,8 @@ async function writeOwned(
96
151
  */
97
152
  async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
98
153
  const abs = safeJoin(cwd, AGENTS_MD_PATH)
154
+ await assertSafeParents(cwd, abs)
155
+ await assertRegularTarget(abs, AGENTS_MD_PATH)
99
156
  const section = agentsMcpSection()
100
157
  let existing: string | undefined
101
158
  try {
@@ -104,7 +161,7 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
104
161
  existing = undefined // no AGENTS.md yet
105
162
  }
106
163
  if (existing === undefined) {
107
- await writeFile(
164
+ await atomicWrite(
108
165
  abs,
109
166
  `# AGENTS.md\n\nGuidance for AI coding agents working in this repo.\n\n${section}\n`,
110
167
  )
@@ -115,7 +172,7 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
115
172
  return { path: AGENTS_MD_PATH, action: "present", note: "already has an MCP section" }
116
173
  }
117
174
  const sep = existing.endsWith("\n") ? "\n" : "\n\n"
118
- await writeFile(abs, `${existing}${sep}${section}\n`)
175
+ await atomicWrite(abs, `${existing}${sep}${section}\n`)
119
176
  return { path: AGENTS_MD_PATH, action: "appended" }
120
177
  }
121
178
 
@@ -0,0 +1,133 @@
1
+ import type * as TSApi from "typescript"
2
+ import type { TypeScriptApi } from "./typescript-import.ts"
3
+
4
+ /**
5
+ * The deliberately small AST seam used by source lints. It is not a project-wide TypeScript
6
+ * Program: a rule asks for a file only after its lexical fast path found a candidate. That keeps
7
+ * cold checks and projects without a TypeScript install on the existing conservative path.
8
+ */
9
+ export interface SourceFacts {
10
+ parse(file: string, content: string): TSApi.SourceFile | undefined
11
+ isValueImportAt(
12
+ source: TSApi.SourceFile,
13
+ position: number,
14
+ specifier: string,
15
+ ): boolean | undefined
16
+ isRouteRegistrationAt(
17
+ source: TSApi.SourceFile,
18
+ position: number,
19
+ method: string,
20
+ path: string,
21
+ ): boolean | undefined
22
+ isResponseSyntaxAt(source: TSApi.SourceFile, position: number): boolean | undefined
23
+ }
24
+
25
+ function nodeAt(ts: TypeScriptApi, source: TSApi.SourceFile, position: number): TSApi.Node {
26
+ let best: TSApi.Node = source
27
+ const visit = (node: TSApi.Node): void => {
28
+ if (position < node.pos || position >= node.end) return
29
+ best = node
30
+ ts.forEachChild(node, visit)
31
+ }
32
+ visit(source)
33
+ return best
34
+ }
35
+
36
+ function ancestorAt(
37
+ ts: TypeScriptApi,
38
+ source: TSApi.SourceFile,
39
+ position: number,
40
+ predicate: (node: TSApi.Node) => boolean,
41
+ ): TSApi.Node | undefined {
42
+ let node: TSApi.Node = nodeAt(ts, source, Math.max(0, Math.min(position, source.end - 1)))
43
+ for (;;) {
44
+ if (predicate(node)) return node
45
+ if (node === source) return undefined
46
+ node = node.parent
47
+ }
48
+ }
49
+
50
+ function rawResponseExpression(ts: TypeScriptApi, node: TSApi.Expression): boolean {
51
+ if (ts.isNewExpression(node)) {
52
+ return ts.isIdentifier(node.expression) && node.expression.text === "Response"
53
+ }
54
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return false
55
+ return (
56
+ ts.isIdentifier(node.expression.expression) &&
57
+ node.expression.expression.text === "Response" &&
58
+ node.expression.name.text === "json"
59
+ )
60
+ }
61
+
62
+ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
63
+ const cache = new Map<string, { content: string; source: TSApi.SourceFile | undefined }>()
64
+
65
+ const parse = (file: string, content: string): TSApi.SourceFile | undefined => {
66
+ const cached = cache.get(file)
67
+ if (cached?.content === content) return cached.source
68
+ const kind = /\.tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
69
+ const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
70
+ const diagnostics = (
71
+ source as TSApi.SourceFile & { parseDiagnostics?: readonly TSApi.Diagnostic[] }
72
+ ).parseDiagnostics
73
+ const parsed = diagnostics !== undefined && diagnostics.length > 0 ? undefined : source
74
+ cache.set(file, { content, source: parsed })
75
+ return parsed
76
+ }
77
+
78
+ const isValueImportAt = (
79
+ source: TSApi.SourceFile,
80
+ position: number,
81
+ specifier: string,
82
+ ): boolean | undefined => {
83
+ const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration) as
84
+ | TSApi.ImportDeclaration
85
+ | undefined
86
+ if (declaration === undefined || !ts.isStringLiteral(declaration.moduleSpecifier))
87
+ return undefined
88
+ if (declaration.moduleSpecifier.text !== specifier) return undefined
89
+ const clause = declaration.importClause
90
+ if (clause === undefined) return true // side-effect import
91
+ if (clause.isTypeOnly) return false
92
+ if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings)) {
93
+ return clause.namedBindings.elements.some((element) => !element.isTypeOnly)
94
+ }
95
+ return true // default and namespace bindings are runtime imports
96
+ }
97
+
98
+ const isRouteRegistrationAt = (
99
+ source: TSApi.SourceFile,
100
+ position: number,
101
+ method: string,
102
+ path: string,
103
+ ): boolean | undefined => {
104
+ const call = ancestorAt(ts, source, position, ts.isCallExpression) as
105
+ | TSApi.CallExpression
106
+ | undefined
107
+ if (call === undefined || !ts.isPropertyAccessExpression(call.expression)) return false
108
+ const name = call.expression.name.text.toUpperCase()
109
+ const first = call.arguments[0]
110
+ return (
111
+ name === method && first !== undefined && ts.isStringLiteralLike(first) && first.text === path
112
+ )
113
+ }
114
+
115
+ const isResponseSyntaxAt = (source: TSApi.SourceFile, position: number): boolean | undefined => {
116
+ const returnStatement = ancestorAt(ts, source, position, ts.isReturnStatement) as
117
+ | TSApi.ReturnStatement
118
+ | undefined
119
+ if (returnStatement !== undefined) {
120
+ return (
121
+ returnStatement.expression !== undefined &&
122
+ rawResponseExpression(ts, returnStatement.expression)
123
+ )
124
+ }
125
+ const arrow = ancestorAt(ts, source, position, ts.isArrowFunction) as
126
+ | TSApi.ArrowFunction
127
+ | undefined
128
+ if (arrow !== undefined && !ts.isBlock(arrow.body)) return rawResponseExpression(ts, arrow.body)
129
+ return false
130
+ }
131
+
132
+ return { parse, isValueImportAt, isRouteRegistrationAt, isResponseSyntaxAt }
133
+ }
@@ -31,3 +31,32 @@ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
31
31
  throw cause
32
32
  }
33
33
  }
34
+
35
+ /**
36
+ * Import TypeScript resolved from the PROJECT root first, falling back to the CLI's own dependency
37
+ * tree. A bare `import("typescript")` resolves relative to this file - the CLI's install - so a
38
+ * globally-installed or bunx-run CLI could miss the project's compiler (or load a different major)
39
+ * depending on where the CLI happened to live, while the project's own `typescript` sat unused in
40
+ * its node_modules. Resolving from `root` makes the verdicts a function of the project, not of how
41
+ * the CLI was installed - the same cwd-invariance the typecheck gate's tsc resolution has.
42
+ */
43
+ export async function importProjectTypeScript(root: string): Promise<TypeScriptApi | undefined> {
44
+ let resolved: string
45
+ try {
46
+ resolved = Bun.resolveSync("typescript", root)
47
+ } catch {
48
+ // Unresolvable from the project (not installed there, or a non-Bun host without resolveSync):
49
+ // fall back to the CLI's own tree, which keeps the previous behavior as the floor.
50
+ return importTypeScript()
51
+ }
52
+ try {
53
+ return (await import(resolved)) as TypeScriptApi
54
+ } catch (cause) {
55
+ // Same contract as importTypeScript: a compiler that RESOLVES and then fails evaluating is a
56
+ // broken install, not a missing one - rethrow rather than silently checking with another copy.
57
+ const message = cause instanceof Error ? cause.message : String(cause)
58
+ if (/cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message))
59
+ return importTypeScript()
60
+ throw cause
61
+ }
62
+ }
package/src/introspect.ts CHANGED
@@ -5,14 +5,73 @@
5
5
  * *this* project's actual surface, not just generic docs.
6
6
  */
7
7
 
8
- import { type ReflectedRoute, reflectRoutes, type SchemaReflection } from "@nifrajs/core/reflection"
8
+ import {
9
+ type ProjectEvidenceSchema,
10
+ type ProjectEvidenceSchemaPart,
11
+ type ProjectEvidenceSnapshot,
12
+ snapshotProjectEvidence,
13
+ } from "@nifrajs/core/evidence"
14
+ import type { ReflectedRoute, SchemaReflection } from "@nifrajs/core/reflection"
9
15
  import type { Manifest } from "@nifrajs/web"
10
16
  import { discoverRoutes } from "@nifrajs/web/fs"
11
17
  import type { LoadedApp } from "./load.ts"
18
+ import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
12
19
 
13
20
  /** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
14
21
  export function backendRoutes(backend: unknown): ReflectedRoute[] {
15
- return [...reflectRoutes(backend)]
22
+ return reflectedRoutesFromEvidence(snapshotProjectEvidence(backend))
23
+ }
24
+
25
+ function schemaPartToReflection(
26
+ value: ProjectEvidenceSchemaPart | undefined,
27
+ ): SchemaReflection | undefined {
28
+ return value === undefined
29
+ ? undefined
30
+ : { standard: undefined, jsonSchema: value.jsonSchema, fields: value.fields }
31
+ }
32
+
33
+ function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): ReflectedRoute["schema"] {
34
+ if (schema === undefined) return undefined
35
+ const errors = Object.fromEntries(
36
+ Object.entries(schema.errors ?? {}).map(([status, value]) => [
37
+ status,
38
+ schemaPartToReflection(value) as SchemaReflection,
39
+ ]),
40
+ )
41
+ const headers = schemaPartToReflection(schema.headers)
42
+ const body = schemaPartToReflection(schema.body)
43
+ const query = schemaPartToReflection(schema.query)
44
+ const params = schemaPartToReflection(schema.params)
45
+ const response = schemaPartToReflection(schema.response)
46
+ const sse = schemaPartToReflection(schema.sse)
47
+ return {
48
+ ...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
49
+ ...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
50
+ ...(headers !== undefined ? { headers } : {}),
51
+ ...(body !== undefined ? { body } : {}),
52
+ ...(query !== undefined ? { query } : {}),
53
+ ...(params !== undefined ? { params } : {}),
54
+ ...(response !== undefined ? { response } : {}),
55
+ ...(Object.keys(errors).length > 0 ? { errors } : {}),
56
+ ...(sse !== undefined ? { sse } : {}),
57
+ }
58
+ }
59
+
60
+ /** Adapt the canonical token-only snapshot to the legacy reflection view used by Markdown helpers. */
61
+ export function reflectedRoutesFromEvidence(evidence: ProjectEvidenceSnapshot): ReflectedRoute[] {
62
+ return evidence.routes.map((route) => {
63
+ const schema = schemaFromEvidence(route.schema)
64
+ return {
65
+ method: route.method,
66
+ path: route.path,
67
+ ...(schema !== undefined ? { schema } : {}),
68
+ ...(route.assurance !== undefined ? { assurance: route.assurance } : {}),
69
+ ...(route.capabilities !== undefined ? { capabilities: route.capabilities } : {}),
70
+ ...(route.family === true ? { family: true } : {}),
71
+ ...(route.classification !== undefined ? { classification: route.classification } : {}),
72
+ ...(route.tool !== undefined ? { tool: route.tool } : {}),
73
+ }
74
+ })
16
75
  }
17
76
 
18
77
  interface JsonSchemaNode {
@@ -232,6 +291,11 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
232
291
  // Point agents at the verified surface, not recalled APIs - nifra is young, so training data is the
233
292
  // wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
234
293
  "> **Build against THIS surface + the verified tools - do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
294
+ // The authoritative answer, not the static reading `nifra_check`/`nifra_doctor` give: this runs on
295
+ // the LOADED config, so it is the same decision `nifra dev` and `nifra build` make. Stated in the
296
+ // index because it governs where a plugin goes and which toolchain compiles a component - an edit
297
+ // put in the other pipeline's slot is accepted and then silently never runs.
298
+ `**Bundler**: ${describePipeline(chooseBuildPipeline(app.resolvedPlugins))}. \`vitePlugins\` feed Vite; \`clientPlugins\`/\`serverPlugins\` feed Bun.build. Both phases (dev and build) use the same one.`,
235
299
  )
236
300
  }
237
301
  if (filter?.kind !== "pages") {
@@ -260,15 +324,18 @@ export interface RouteJson {
260
324
  * Markdown brief. Optionally filtered to routes whose path starts with `pathPrefix`. Reuses the exact
261
325
  * same `call` form and TS-shaped contracts the Markdown brief shows.
262
326
  */
263
- export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
264
- const shape = (v: unknown): string | undefined => {
327
+ export function routesToJsonFromEvidence(
328
+ evidence: ProjectEvidenceSnapshot,
329
+ pathPrefix?: string,
330
+ ): RouteJson[] {
331
+ const shape = (v: ProjectEvidenceSchemaPart | undefined): string | undefined => {
265
332
  if (!v) return undefined
266
- const js = (v as SchemaReflection).jsonSchema
333
+ const js = v.jsonSchema
267
334
  return js !== undefined
268
335
  ? tsTypeOf(js)
269
336
  : "(validated; shape not introspectable from this validator)"
270
337
  }
271
- let routes = backendRoutes(app.backend)
338
+ let routes = [...evidence.routes]
272
339
  if (pathPrefix !== undefined && pathPrefix !== "") {
273
340
  routes = routes.filter((r) => r.path.startsWith(pathPrefix))
274
341
  }
@@ -291,6 +358,14 @@ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
291
358
  })
292
359
  }
293
360
 
361
+ export function routesToJson(
362
+ app: LoadedApp,
363
+ pathPrefix?: string,
364
+ evidence?: ProjectEvidenceSnapshot,
365
+ ): RouteJson[] {
366
+ return routesToJsonFromEvidence(evidence ?? snapshotProjectEvidence(app.backend), pathPrefix)
367
+ }
368
+
294
369
  // ===================================================================================================
295
370
  // `nifra routes [--json]` - the focused, uniform view of every route the app serves, with methods.
296
371
  //
package/src/load.ts CHANGED
@@ -38,6 +38,12 @@ export interface NifraFramework {
38
38
  readonly adapter: unknown
39
39
  /** The client runtime module providing `mountRouter`, e.g. `"@nifrajs/web-react/client"`. */
40
40
  readonly clientModule: string
41
+ /** App-level middleware applied to the web app before its page routes are declared -
42
+ * e.g. `(app) => app.use(securityHeaders())`. Runs ahead of the `api`/`mounts` hooks.
43
+ *
44
+ * MUST be exported from `framework.ts`, not `nifra.config.ts`: `nifra build` emits a
45
+ * server entry that imports it, and that entry is edge-bundled. See the module header. */
46
+ readonly use?: (app: never) => void
41
47
  /** Vite plugins for `nifra dev`'s HMR, e.g. `[react()]` / `() => import("@vitejs/plugin-vue")…`. */
42
48
  readonly vitePlugins?: PluginsField
43
49
  /** Bun build plugins that compile routes for the CLIENT bundle (`nifra build`), e.g.
@@ -93,6 +99,10 @@ export interface ResolvedPlugins {
93
99
 
94
100
  export interface LoadedApp {
95
101
  readonly cwd: string
102
+ /** Absolute path of the config this app was loaded from - `nifra.config.ts`, or `framework.ts` as the
103
+ * fallback. `nifra dev`'s Bun pipeline re-imports it from a GENERATED module so the app's own
104
+ * `clientPlugins` reach Bun's dev-server bundler, which takes plugins only as module paths. */
105
+ readonly configPath: string
96
106
  readonly routesDir: string
97
107
  /** Build output dir (also where `nifra start` reads `manifest.json` + serves `/assets/*`). */
98
108
  readonly outDir: string
@@ -222,6 +232,7 @@ export async function loadApp(
222
232
 
223
233
  return {
224
234
  cwd,
235
+ configPath,
225
236
  routesDir: resolve(cwd, "routes"),
226
237
  outDir: resolve(cwd, outDirName),
227
238
  framework: { ...fw, clientModule } as NifraFramework,
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { existsSync } from "node:fs"
4
4
  import { resolve } from "node:path"
5
- import { evaluateRouteAssurance } from "@nifrajs/core/assurance"
6
5
  import {
7
6
  buildNifraManifest,
8
7
  diffNifraManifests,
@@ -12,8 +11,7 @@ import {
12
11
  serializeNifraManifestSignature,
13
12
  signNifraManifest,
14
13
  } from "@nifrajs/core/manifest"
15
- import { loadAssuranceConfig } from "./assure.ts"
16
- import { collectCapabilityProjectReport } from "./capabilities-tool.ts"
14
+ import { collectProjectVerification } from "./verification.ts"
17
15
 
18
16
  export const DEFAULT_MANIFEST_FILE = "nifra.manifest.json"
19
17
 
@@ -45,20 +43,28 @@ export async function runManifestEmit(
45
43
  cwd: string,
46
44
  options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
47
45
  ): Promise<boolean> {
48
- const config = await loadAssuranceConfig(cwd, options.config)
49
- const assurance = evaluateRouteAssurance(config.source, config.policy, {
50
- ...(config.capabilities !== undefined ? { definitions: config.capabilities.definitions } : {}),
46
+ const verification = await collectProjectVerification(cwd, {
47
+ ...(options.config !== undefined ? { config: options.config } : {}),
51
48
  })
52
- const capabilityProject =
53
- config.capabilities === undefined
54
- ? undefined
55
- : await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
49
+ if (verification.config === undefined) {
50
+ throw (
51
+ verification.configError ??
52
+ new Error("[nifra] assurance config could not be loaded for manifest emission")
53
+ )
54
+ }
55
+ const config = verification.config
56
+ const assurance = verification.routeAssurance
57
+ const capabilityProject = verification.capability
58
+ if (assurance === undefined) {
59
+ throw new Error("[nifra] assurance evaluation was unavailable for manifest emission")
60
+ }
56
61
  if (!assurance.ok || (capabilityProject !== undefined && !capabilityProject.report.ok)) {
57
62
  console.error("[nifra] refusing to emit a manifest from failing assurance")
58
63
  return false
59
64
  }
60
65
  const manifest = await buildNifraManifest({
61
66
  source: config.source,
67
+ ...(verification.evidence !== undefined ? { evidence: verification.evidence } : {}),
62
68
  assurance,
63
69
  ...(capabilityProject !== undefined ? { capabilities: capabilityProject.report } : {}),
64
70
  })
@@ -35,6 +35,7 @@ export function docsTools(
35
35
  properties: {
36
36
  query: {
37
37
  type: "string",
38
+ maxLength: 256,
38
39
  description: "Keywords to match against doc sections (omit for the index).",
39
40
  },
40
41
  limit: { type: "number", description: "Max sections to return (default 3, max 5)." },
@@ -64,6 +65,7 @@ export function docsTools(
64
65
  properties: {
65
66
  query: {
66
67
  type: "string",
68
+ maxLength: 256,
67
69
  description: "What you want an example of (omit for the index).",
68
70
  },
69
71
  limit: { type: "number", description: "Max examples to return (default 3, max 5)." },
@@ -94,6 +96,7 @@ export function docsTools(
94
96
  },
95
97
  query: {
96
98
  type: "string",
99
+ maxLength: 256,
97
100
  description:
98
101
  "Keyword search over names + signatures (when you don't know the exact name).",
99
102
  },
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "2.10.0"
32
+ const VERSION = "2.12.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>