@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
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Project-root resolution for the stdio MCP server - the answer to the "silently rooted at the wrong
3
+ * project" footgun. The server never trusts a bare `process.cwd()` again:
4
+ *
5
+ * 1. An explicit `nifra mcp <dir>` argument is taken literally (human configuration wins).
6
+ * 2. A cwd guess walks UP to the nearest nifra marker (a `package.json` depending on `@nifrajs/*`,
7
+ * or a `nifra.config.ts` monorepo root), so running from a subdirectory still finds the project.
8
+ * 3. The client's MCP `roots` (its workspace folders) are requested after the handshake. When the
9
+ * guess found no project - or found one DISJOINT from every workspace root - and exactly one
10
+ * workspace root is a nifra project, the server adopts it.
11
+ * 4. What cannot be resolved fails CLOSED: project-scoped tools refuse with a remediation message
12
+ * instead of describing whatever directory the server happened to start in.
13
+ *
14
+ * Everything here is pure state + fs probes; the I/O wiring lives in `./mcp.ts`.
15
+ */
16
+
17
+ import { readFile } from "node:fs/promises"
18
+ import { dirname, sep } from "node:path"
19
+ import { fileURLToPath } from "node:url"
20
+
21
+ /** How the current root was chosen. `arg` = explicit `nifra mcp <dir>`; `cwd` = the spawn directory;
22
+ * `parent` = a marker found walking up from cwd; `client-root` = adopted from the client's MCP roots. */
23
+ export type McpRootSource = "arg" | "cwd" | "parent" | "client-root"
24
+
25
+ export interface McpRootState {
26
+ /** Absolute directory every project-scoped tool operates on. */
27
+ readonly root: string
28
+ readonly source: McpRootSource
29
+ /** Whether `root` carries a nifra marker. `false` fails project tools closed. */
30
+ readonly isProject: boolean
31
+ /** The client's workspace roots (absolute paths), or `null` until (unless) the client answers
32
+ * `roots/list`. `null` disables mismatch detection - no data is not a mismatch. */
33
+ readonly clientRoots: readonly string[] | null
34
+ }
35
+
36
+ /** A directory is a nifra project when its `package.json` depends on any `@nifrajs/*` package, or it
37
+ * is a `nifra.config.ts` monorepo root. Backend-only projects (no web config, no `routes/`) count -
38
+ * the marker is the dependency, not the app shape. */
39
+ export async function isNifraProjectDir(dir: string): Promise<boolean> {
40
+ if (await Bun.file(`${dir}${sep}nifra.config.ts`).exists()) return true
41
+ try {
42
+ const pkg = JSON.parse(await readFile(`${dir}${sep}package.json`, "utf8")) as Record<
43
+ string,
44
+ unknown
45
+ >
46
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
47
+ const deps = pkg[section]
48
+ if (typeof deps !== "object" || deps === null) continue
49
+ for (const name of Object.keys(deps)) if (name.startsWith("@nifrajs/")) return true
50
+ }
51
+ } catch {
52
+ // No package.json, or unparseable - not a project marker either way.
53
+ }
54
+ return false
55
+ }
56
+
57
+ /** Nearest ancestor (including `start` itself) that is a nifra project, or `null`. */
58
+ export async function findNifraRoot(start: string): Promise<string | null> {
59
+ let dir = start
60
+ for (;;) {
61
+ if (await isNifraProjectDir(dir)) return dir
62
+ const parent = dirname(dir)
63
+ if (parent === dir) return null
64
+ dir = parent
65
+ }
66
+ }
67
+
68
+ /** Initial root state. An explicit dir is taken literally (no walk-up - the human named it); a cwd
69
+ * guess walks up so a subdirectory spawn still lands on the project. */
70
+ export async function resolveRootState(
71
+ requested: string,
72
+ explicit: boolean,
73
+ ): Promise<McpRootState> {
74
+ if (explicit) {
75
+ return {
76
+ root: requested,
77
+ source: "arg",
78
+ isProject: await isNifraProjectDir(requested),
79
+ clientRoots: null,
80
+ }
81
+ }
82
+ const found = await findNifraRoot(requested)
83
+ if (found !== null) {
84
+ return {
85
+ root: found,
86
+ source: found === requested ? "cwd" : "parent",
87
+ isProject: true,
88
+ clientRoots: null,
89
+ }
90
+ }
91
+ return { root: requested, source: "cwd", isProject: false, clientRoots: null }
92
+ }
93
+
94
+ /** `file://` roots from a `roots/list` result, as absolute paths. Non-file and malformed URIs are
95
+ * skipped - the client owns that list, this server only reads it. */
96
+ export function pathsFromRootsResult(result: unknown): string[] {
97
+ if (typeof result !== "object" || result === null) return []
98
+ const roots = (result as { roots?: unknown }).roots
99
+ if (!Array.isArray(roots)) return []
100
+ const paths: string[] = []
101
+ for (const entry of roots) {
102
+ const uri = (entry as { uri?: unknown })?.uri
103
+ if (typeof uri !== "string" || !uri.startsWith("file://")) continue
104
+ try {
105
+ paths.push(fileURLToPath(uri))
106
+ } catch {
107
+ // Malformed URI - skip.
108
+ }
109
+ }
110
+ return paths
111
+ }
112
+
113
+ const contains = (parent: string, child: string): boolean =>
114
+ parent === child || child.startsWith(parent + sep)
115
+
116
+ /** True when the server root and the client's workspace are DISJOINT - neither contains the other.
117
+ * A server rooted at a monorepo whose sub-app is the open workspace (or vice versa) is not a
118
+ * mismatch; a "server points at repo A, workspace is repo B" split is. */
119
+ export function rootMismatch(state: McpRootState): boolean {
120
+ if (state.clientRoots === null || state.clientRoots.length === 0) return false
121
+ return !state.clientRoots.some(
122
+ (workspaceRoot) => contains(workspaceRoot, state.root) || contains(state.root, workspaceRoot),
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Fold the client's workspace roots into the state, adopting one of them when the current root is
128
+ * wrong and the correction is unambiguous: the root was GUESSED (never an explicit `nifra mcp <dir>`),
129
+ * it is not a project - or is one disjoint from the whole workspace - and exactly ONE workspace root
130
+ * is a nifra project. Ambiguity (zero or several candidates) adopts nothing; the guard's remediation
131
+ * message lists the candidates instead.
132
+ */
133
+ export async function applyClientRoots(
134
+ state: McpRootState,
135
+ paths: readonly string[],
136
+ ): Promise<McpRootState> {
137
+ const next: McpRootState = { ...state, clientRoots: paths }
138
+ if (state.source === "arg") return next
139
+ if (next.isProject && !rootMismatch(next)) return next
140
+ const candidates: string[] = []
141
+ for (const path of paths) if (await isNifraProjectDir(path)) candidates.push(path)
142
+ if (candidates.length !== 1) return next
143
+ return {
144
+ root: candidates[0] as string,
145
+ source: "client-root",
146
+ isProject: true,
147
+ clientRoots: paths,
148
+ }
149
+ }
150
+
151
+ /** The nifra project roots among the client's workspace roots - the remediation list when adoption
152
+ * was ambiguous. */
153
+ export async function nifraCandidates(paths: readonly string[]): Promise<string[]> {
154
+ const candidates: string[] = []
155
+ for (const path of paths) if (await isNifraProjectDir(path)) candidates.push(path)
156
+ return candidates
157
+ }
158
+
159
+ const REMEDIATION =
160
+ "Fix: start the client in the project directory, register the server project-scoped " +
161
+ "(`nifra init-agents` writes .mcp.json), or point it explicitly: `nifra mcp <dir>`."
162
+
163
+ /** Per-call verdict for the tool guard: `blocked` set = project tools refuse with that message;
164
+ * otherwise `note` is appended to every project tool result so the effective root stays visible. */
165
+ export interface McpRootVerdict {
166
+ readonly blocked?: string
167
+ readonly note: string
168
+ }
169
+
170
+ export async function rootVerdict(state: McpRootState): Promise<McpRootVerdict> {
171
+ const workspace =
172
+ state.clientRoots !== null && state.clientRoots.length > 0
173
+ ? ` Client workspace roots: ${state.clientRoots.join(", ")}.`
174
+ : ""
175
+ if (!state.isProject) {
176
+ const candidates = state.clientRoots !== null ? await nifraCandidates(state.clientRoots) : []
177
+ const hint =
178
+ candidates.length > 1
179
+ ? ` Several workspace roots are nifra projects (${candidates.join(", ")}) - pass one: \`nifra mcp <dir>\`.`
180
+ : ""
181
+ return {
182
+ blocked:
183
+ `No nifra project at ${state.root} (no package.json with an @nifrajs/* dependency, no nifra.config.ts). ` +
184
+ `This server only serves the project it is rooted in.${workspace}${hint} ${REMEDIATION}`,
185
+ note: "",
186
+ }
187
+ }
188
+ if (rootMismatch(state)) {
189
+ if (state.source === "arg") {
190
+ // Explicit human configuration wins - annotate, don't block.
191
+ return {
192
+ note: `[nifra] project root: ${state.root} (explicit dir, outside the client workspace)`,
193
+ }
194
+ }
195
+ return {
196
+ blocked:
197
+ `Wrong project: this MCP server is rooted at ${state.root}, which is disjoint from your ` +
198
+ `client's workspace.${workspace} Results would describe a different codebase. ${REMEDIATION}`,
199
+ note: "",
200
+ }
201
+ }
202
+ return { note: `[nifra] project root: ${state.root}` }
203
+ }
204
+
205
+ /** The `initialize`/`server-discover` instructions line announcing the effective root. */
206
+ export function rootInstructions(state: McpRootState, drift?: ToolingDrift): string {
207
+ if (!state.isProject) {
208
+ return `WARNING: no nifra project at ${state.root} - project tools will refuse until the server is started in (or pointed at) a nifra project. ${REMEDIATION}`
209
+ }
210
+ return `Serving the nifra project at ${state.root}.${drift === undefined ? "" : ` ${driftNote(drift)}`}`
211
+ }
212
+
213
+ /** A version split between the CLI answering and the nifra the project actually builds with. */
214
+ export interface ToolingDrift {
215
+ /** The running CLI's version. */
216
+ readonly cli: string
217
+ /** The version installed in the project. */
218
+ readonly project: string
219
+ /** Which installed package was compared (`@nifrajs/cli` when present, else `@nifrajs/core`). */
220
+ readonly package: string
221
+ }
222
+
223
+ /** One line, on `initialize` and on every project tool result: the answer may describe a different
224
+ * nifra than the project compiles against - stale types, checks, and docs all read as authoritative. */
225
+ export const driftNote = (drift: ToolingDrift): string =>
226
+ `WARNING: this server runs nifra CLI ${drift.cli}, but the project installs ${drift.package} ${drift.project}. ` +
227
+ "Types, checks, and docs may describe a different version than your code builds with. " +
228
+ "Fix: run the project's own CLI (`bunx --bun nifra mcp` from the project directory, or point the client at ./node_modules/.bin/nifra)."
229
+
230
+ /** Feature-level agreement. Patch drift is not reported: it never changes the surface being described. */
231
+ const featureVersion = (version: string): string => version.split(".").slice(0, 2).join(".")
232
+
233
+ async function installedVersion(root: string, name: string): Promise<string | undefined> {
234
+ let dir = root
235
+ for (let depth = 0; depth < 8; depth++) {
236
+ try {
237
+ const meta = JSON.parse(
238
+ await readFile(
239
+ `${dir}${sep}node_modules${sep}${name.split("/").join(sep)}${sep}package.json`,
240
+ "utf8",
241
+ ),
242
+ ) as { version?: unknown }
243
+ if (typeof meta.version === "string" && meta.version.length > 0) return meta.version
244
+ } catch {
245
+ // Not installed at this level - keep walking up (a monorepo app hoists to the repo root).
246
+ }
247
+ const parent = dirname(dir)
248
+ if (parent === dir) return undefined
249
+ dir = parent
250
+ }
251
+ return undefined
252
+ }
253
+
254
+ /**
255
+ * Compare the running CLI against the nifra the project installs. An agent that asks an older (or
256
+ * newer) CLI about a project gets confident answers about a surface the project does not have -
257
+ * the version sprawl is invisible because every answer still looks authoritative.
258
+ */
259
+ export async function detectToolingDrift(
260
+ root: string,
261
+ cliVersion: string,
262
+ ): Promise<ToolingDrift | undefined> {
263
+ for (const name of ["@nifrajs/cli", "@nifrajs/core"]) {
264
+ const project = await installedVersion(root, name)
265
+ if (project === undefined) continue
266
+ return featureVersion(project) === featureVersion(cliVersion)
267
+ ? undefined
268
+ : { cli: cliVersion, project, package: name }
269
+ }
270
+ return undefined
271
+ }
package/src/mcp-run.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * exported so it's unit-testable in-process; the entry below wires it to stdin/stdout.
6
6
  */
7
7
 
8
- import { existsSync } from "node:fs"
9
- import { resolve, sep } from "node:path"
8
+ import { existsSync, realpathSync } from "node:fs"
9
+ import { isAbsolute, relative, resolve, sep } from "node:path"
10
10
  import { pathToFileURL } from "node:url"
11
11
  import { type AppLike, runApp } from "@nifrajs/runner"
12
12
 
@@ -26,8 +26,23 @@ export async function loadBackend(
26
26
  }
27
27
  // The `entry` arg comes from MCP args; importing it executes it. Keep it inside the project root so a
28
28
  // crafted `entry` (`../../x`, an absolute path) can't run arbitrary modules outside the project.
29
- const root = resolve(cwd)
30
- if (found !== root && !found.startsWith(root + sep)) {
29
+ const root = realpathSync(resolve(cwd))
30
+ let canonicalFound: string
31
+ try {
32
+ canonicalFound = realpathSync(found)
33
+ } catch {
34
+ return { error: `refusing to load an unresolved backend entry: ${entry}` }
35
+ }
36
+ const rel = relative(root, canonicalFound)
37
+ // `isAbsolute` is not redundant with the `..` test: across two Windows drives `relative()` gives up
38
+ // and returns the absolute target, which starts with neither `..` nor the separator - and then
39
+ // `resolve(root, rel)` returns that same absolute path, so the round-trip check agrees with it too.
40
+ if (
41
+ rel.startsWith("..") ||
42
+ rel === sep ||
43
+ isAbsolute(rel) ||
44
+ resolve(root, rel) !== canonicalFound
45
+ ) {
31
46
  return { error: `refusing to load a backend entry outside the project root: ${entry}` }
32
47
  }
33
48