@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.
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
package/src/examples.ts CHANGED
@@ -23,6 +23,29 @@ export interface ScoredExample extends Example {
23
23
  readonly score: number
24
24
  }
25
25
 
26
+ interface PreparedExample {
27
+ readonly example: Example
28
+ readonly labelTokens: ReadonlySet<string>
29
+ readonly codeLower: string
30
+ }
31
+
32
+ const preparedExamplesCache = new WeakMap<readonly Example[], readonly PreparedExample[]>()
33
+ const prepareExamples = (examples: readonly Example[]): readonly PreparedExample[] => {
34
+ const cached = preparedExamplesCache.get(examples)
35
+ if (cached !== undefined) return cached
36
+ const prepared = examples.map((example) => ({
37
+ example,
38
+ labelTokens: new Set([
39
+ ...tokenize(example.slug),
40
+ ...tokenize(example.topic),
41
+ ...tokenize(example.name),
42
+ ]),
43
+ codeLower: example.code.toLowerCase(),
44
+ }))
45
+ preparedExamplesCache.set(examples, prepared)
46
+ return prepared
47
+ }
48
+
26
49
  /** Score examples against the query: a term in the slug/topic/name (the curated, searchable labels)
27
50
  * is worth far more than a mention in the code body; body hits cap per term so one long snippet can't
28
51
  * outweigh a precisely-tagged one. */
@@ -34,13 +57,12 @@ export function searchExamples(
34
57
  const terms = queryTermGroups(query)
35
58
  if (terms.length === 0) return []
36
59
  const scored: ScoredExample[] = []
37
- for (const ex of examples) {
38
- const labelTokens = new Set([...tokenize(ex.slug), ...tokenize(ex.topic), ...tokenize(ex.name)])
39
- const codeLower = ex.code.toLowerCase()
60
+ for (const prepared of prepareExamples(examples)) {
61
+ const ex = prepared.example
40
62
  let score = 0
41
63
  for (const term of terms) {
42
- if (tokenSetHas(labelTokens, term)) score += 8
43
- score += countBodyHits(codeLower, term, 5)
64
+ if (tokenSetHas(prepared.labelTokens, term)) score += 8
65
+ score += countBodyHits(prepared.codeLower, term, 5)
44
66
  }
45
67
  if (score > 0) scored.push({ ...ex, score })
46
68
  }
@@ -76,14 +98,20 @@ export function renderExamplesResult(
76
98
 
77
99
  /** Load the bundled example corpus. Resolves relative to this module so it works from `src/` (repo dev)
78
100
  * and `dist/` (published) alike - `docs/examples.json` sits at the package root next to both. */
79
- export async function loadExamplesCorpus(): Promise<Example[] | undefined> {
80
- const url = new URL("../docs/examples.json", import.meta.url)
81
- try {
82
- const text = await Bun.file(url).text()
83
- if (text.length === 0) return undefined
84
- const parsed = JSON.parse(text) as Example[]
85
- return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
86
- } catch {
87
- return undefined
88
- }
101
+ let examplesCorpusPromise: Promise<Example[] | undefined> | undefined
102
+
103
+ /** The published corpus is immutable, so keep one loaded array and its derived search fields. */
104
+ export function loadExamplesCorpus(): Promise<Example[] | undefined> {
105
+ examplesCorpusPromise ??= (async () => {
106
+ const url = new URL("../docs/examples.json", import.meta.url)
107
+ try {
108
+ const text = await Bun.file(url).text()
109
+ if (text.length === 0) return undefined
110
+ const parsed = JSON.parse(text) as Example[]
111
+ return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
112
+ } catch {
113
+ return undefined
114
+ }
115
+ })()
116
+ return examplesCorpusPromise
89
117
  }
@@ -0,0 +1,126 @@
1
+ import { existsSync } from "node:fs"
2
+ import { readFile, writeFile } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import type { Diagnostic } from "./diagnostics.ts"
5
+ import { resolveInsideProject } from "./project-path.ts"
6
+
7
+ export interface FixRecipe {
8
+ readonly id: string
9
+ readonly description: string
10
+ apply(root: string, diagnostic: Diagnostic): Promise<readonly string[]>
11
+ readonly verify: string
12
+ }
13
+
14
+ const recipes = new Map<string, FixRecipe>()
15
+
16
+ export function registerFixRecipe(recipe: FixRecipe): void {
17
+ if (recipes.has(recipe.id)) throw new Error(`duplicate fix recipe: ${recipe.id}`)
18
+ recipes.set(recipe.id, Object.freeze(recipe))
19
+ }
20
+
21
+ export function getFixRecipe(id: string): FixRecipe | undefined {
22
+ return recipes.get(id)
23
+ }
24
+
25
+ export function listFixRecipes(): readonly FixRecipe[] {
26
+ return Object.freeze([...recipes.values()])
27
+ }
28
+
29
+ registerFixRecipe({
30
+ id: "security.timing-safe-equal",
31
+ description: "Replace a direct secret comparison with a length check and timing-safe comparison.",
32
+ verify: "nifra check --lints-only",
33
+ async apply(root, diagnostic) {
34
+ if (diagnostic.file === undefined || diagnostic.line === undefined) return []
35
+ const path = await resolveInsideProject(root, diagnostic.file)
36
+ if (path === undefined) return []
37
+ const lines = (await readFile(path, "utf8")).split("\n")
38
+ const index = diagnostic.line - 1
39
+ const original = lines[index]
40
+ if (original === undefined || original.includes("@nifra-gate-reviewed")) return []
41
+ const comparison = /\b([A-Za-z_$][\w$]*)\s*(===|!==|==|!=)\s*([A-Za-z_$][\w$]*)/.exec(original)
42
+ if (comparison === null) return []
43
+ const left = comparison[1] as string
44
+ const operator = comparison[2] as string
45
+ const right = comparison[3] as string
46
+ const expression = `nifraTimingSafeEqual(${left}, ${right})`
47
+ const replacement = operator === "!==" || operator === "!=" ? `!${expression}` : expression
48
+ lines[index] = original.replace(comparison[0], replacement)
49
+ if (!lines.some((line) => line.includes("function nifraTimingSafeEqual"))) {
50
+ lines.unshift(
51
+ 'import { timingSafeEqual } from "node:crypto"',
52
+ "",
53
+ "function nifraTimingSafeEqual(left: string, right: string): boolean {",
54
+ " const leftBytes = Buffer.from(left)",
55
+ " const rightBytes = Buffer.from(right)",
56
+ " return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes)",
57
+ "}",
58
+ "",
59
+ )
60
+ }
61
+ await writeFile(path, lines.join("\n"), "utf8")
62
+ return [diagnostic.file]
63
+ },
64
+ })
65
+
66
+ registerFixRecipe({
67
+ id: "manifest.sync",
68
+ description: "Regenerate generated server manifests from the current route tree.",
69
+ verify: "nifra check --lints-only",
70
+ async apply(root) {
71
+ const { syncServerManifests } = await import("./sync-manifest.ts")
72
+ const results = await syncServerManifests(root)
73
+ return results.filter((result) => result.changed).map((result) => result.file)
74
+ },
75
+ })
76
+
77
+ registerFixRecipe({
78
+ id: "contracts.snapshot",
79
+ description: "Refresh the opt-in contract lock after reviewing an intentional route change.",
80
+ verify: "nifra contracts check",
81
+ async apply(root) {
82
+ const { snapshotContracts } = await import("./contracts.ts")
83
+ await snapshotContracts(root)
84
+ return ["contracts.lock.json"]
85
+ },
86
+ })
87
+
88
+ registerFixRecipe({
89
+ id: "workspace-dist.rebuild",
90
+ description: "Rebuild the workspace-linked package whose artifact is older than its source.",
91
+ verify: "nifra doctor --json",
92
+ async apply(root, diagnostic) {
93
+ const packageName = diagnostic.evidence?.[0]
94
+ if (packageName === undefined || !/^@?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?$/.test(packageName))
95
+ return []
96
+ // The segment regex above admits "." and "..", which would resolve outside node_modules.
97
+ if (packageName.split("/").some((segment) => segment === "." || segment === "..")) return []
98
+ const packageDir = await resolveInsideProject(root, join("node_modules", packageName))
99
+ if (packageDir === undefined) return []
100
+ const packageJson = await resolveInsideProject(
101
+ root,
102
+ join("node_modules", packageName, "package.json"),
103
+ )
104
+ if (packageJson === undefined || !existsSync(packageJson)) return []
105
+ const proc = Bun.spawn(["bun", "run", "build"], {
106
+ cwd: packageDir,
107
+ stdout: "ignore",
108
+ stderr: "pipe",
109
+ })
110
+ const error = await new Response(proc.stderr).text()
111
+ const exitCode = await proc.exited
112
+ if (exitCode !== 0) throw new Error(error.trim() || `build failed for ${packageName}`)
113
+ return [diagnostic.evidence?.[1] ?? packageName]
114
+ },
115
+ })
116
+
117
+ export async function applyDiagnosticRecipe(
118
+ root: string,
119
+ diagnostic: Diagnostic,
120
+ ): Promise<readonly string[]> {
121
+ const recipeId = diagnostic.fix?.recipe
122
+ if (recipeId === undefined) return []
123
+ const recipe = getFixRecipe(recipeId)
124
+ if (recipe === undefined) return []
125
+ return recipe.apply(root, diagnostic)
126
+ }
@@ -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,7 +5,13 @@
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"
@@ -13,7 +19,59 @@ import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
13
19
 
14
20
  /** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
15
21
  export function backendRoutes(backend: unknown): ReflectedRoute[] {
16
- 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
+ })
17
75
  }
18
76
 
19
77
  interface JsonSchemaNode {
@@ -266,15 +324,18 @@ export interface RouteJson {
266
324
  * Markdown brief. Optionally filtered to routes whose path starts with `pathPrefix`. Reuses the exact
267
325
  * same `call` form and TS-shaped contracts the Markdown brief shows.
268
326
  */
269
- export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
270
- 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 => {
271
332
  if (!v) return undefined
272
- const js = (v as SchemaReflection).jsonSchema
333
+ const js = v.jsonSchema
273
334
  return js !== undefined
274
335
  ? tsTypeOf(js)
275
336
  : "(validated; shape not introspectable from this validator)"
276
337
  }
277
- let routes = backendRoutes(app.backend)
338
+ let routes = [...evidence.routes]
278
339
  if (pathPrefix !== undefined && pathPrefix !== "") {
279
340
  routes = routes.filter((r) => r.path.startsWith(pathPrefix))
280
341
  }
@@ -297,6 +358,14 @@ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
297
358
  })
298
359
  }
299
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
+
300
369
  // ===================================================================================================
301
370
  // `nifra routes [--json]` - the focused, uniform view of every route the app serves, with methods.
302
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.
@@ -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
  })