@nifrajs/cli 2.8.0 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/check.ts CHANGED
@@ -45,10 +45,15 @@ const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
45
45
  const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
46
46
  const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
47
47
 
48
- // Don't scan deps, build output, generated client entries, or tests (which legitimately drive `fetch`).
49
- // `dist(-<runtime>)?` also covers per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel).
50
- const IGNORED =
51
- /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$/
48
+ // Don't scan deps, build output, or generated client entries. `dist(-<runtime>)?` also covers
49
+ // per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel). Never source, for any scan.
50
+ const IGNORED_DIR =
51
+ /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
52
+ // A test/spec module. Excluded from `nifra check`'s scans, which are about what SHIPS - a test
53
+ // legitimately drives `fetch`, hand-rolls a client, and calls a route directly. It is NOT excluded from
54
+ // `nifra doctor`: tsc typechecks tests, so an import a test declares nowhere is a real broken build.
55
+ const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
56
+ const IGNORED = new RegExp(`${IGNORED_DIR.source}|${TEST_FILE.source}`)
52
57
 
53
58
  // A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
54
59
  const ROUTE_FILE = /(^|\/)routes\//
@@ -1190,8 +1195,12 @@ export function scanResponseRoutes(file: string, content: string): SourceFinding
1190
1195
  /** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
1191
1196
  const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
1192
1197
 
1193
- /** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
1194
- * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
1198
+ /** Walk the project's `.ts`/`.tsx` source (skipping deps and build output), calling `visit` per file.
1199
+ * Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`.
1200
+ *
1201
+ * `includeTests` widens the surface to `*.test.ts`/`*.spec.ts`, which the default excludes. It exists
1202
+ * for `nifra doctor`: `tsc` compiles tests, so a test importing a package no manifest declares is a
1203
+ * build that a clean install cannot produce - the exact false confidence doctor is for. */
1195
1204
  /**
1196
1205
  * Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
1197
1206
  * (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
@@ -1228,12 +1237,14 @@ async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<str
1228
1237
  export async function walkSource(
1229
1238
  cwd: string,
1230
1239
  visit: (rel: string, content: string) => void,
1240
+ opts: { readonly includeTests?: boolean } = {},
1231
1241
  ): Promise<void> {
1242
+ const skip = opts.includeTests === true ? IGNORED_DIR : IGNORED
1232
1243
  // List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
1233
1244
  // one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
1234
1245
  const rels: string[] = []
1235
1246
  for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
1236
- if (!IGNORED.test(rel)) rels.push(rel)
1247
+ if (!skip.test(rel)) rels.push(rel)
1237
1248
  }
1238
1249
  const ignored = await gitIgnored(cwd, rels)
1239
1250
  for (const rel of rels) {
package/src/cli.ts CHANGED
@@ -148,7 +148,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
148
148
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
149
149
 
150
150
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
151
- const CLI_VERSION = "2.8.0"
151
+ const CLI_VERSION = "2.8.2"
152
152
 
153
153
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
154
154
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
package/src/doctor.ts CHANGED
@@ -9,6 +9,12 @@
9
9
  * declared by the package that imports it (for `tsc` and for that package to install on its own) even
10
10
  * when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
11
11
  * the package's own name, and tsconfig `paths` aliases are excluded - none of them are npm deps.
12
+ *
13
+ * The diff is against DECLARED dependency sets only; what happens to be installed on disk is never
14
+ * consulted. That is the point: a package pulled in transitively by some other dependency resolves fine
15
+ * locally while no manifest declares it, so resolvability is not evidence of declaration. The scan
16
+ * therefore also covers `*.test.ts`/`*.spec.ts`, which `nifra check` skips - `tsc` compiles them, so an
17
+ * undeclared import there fails a clean `bun install --frozen-lockfile` build just the same.
12
18
  */
13
19
  import type { Dirent } from "node:fs"
14
20
  import { readdir, realpath, stat } from "node:fs/promises"
@@ -679,12 +685,19 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
679
685
  const scopes = await doctorPackageScopes(cwd, pkg)
680
686
 
681
687
  const findings: DoctorFinding[] = []
682
- await walkSource(cwd, (rel, content) => {
683
- const scope = scopeForFile(scopes, rel)
684
- for (const f of scanUndeclaredImports(rel, content, scope.declared, scope.isAlias)) {
685
- findings.push({ file: f.file, line: f.line, package: f.snippet })
686
- }
687
- })
688
+ // `includeTests`: tests are part of the typechecked surface, so an import they declare nowhere is a
689
+ // real break. Excluding them is what let an undeclared `zod` in a `*.test.ts` pass doctor, pass a
690
+ // hoisted local `tsc`, and then fail CI on a clean install with `TS2307: Cannot find module 'zod'`.
691
+ await walkSource(
692
+ cwd,
693
+ (rel, content) => {
694
+ const scope = scopeForFile(scopes, rel)
695
+ for (const f of scanUndeclaredImports(rel, content, scope.declared, scope.isAlias)) {
696
+ findings.push({ file: f.file, line: f.line, package: f.snippet })
697
+ }
698
+ },
699
+ { includeTests: true },
700
+ )
688
701
  findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
689
702
  const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
690
703
  const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
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.8.0"
32
+ const VERSION = "2.8.2"
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 =>
package/src/scaffold.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * `nifra_example` for the body, rather than hand-writing an SFC we can't typecheck here.
10
10
  */
11
11
 
12
- import { mkdir, writeFile } from "node:fs/promises"
12
+ import { lstat, mkdir, realpath, writeFile } from "node:fs/promises"
13
13
  import { dirname, resolve, sep } from "node:path"
14
14
 
15
15
  export type Framework = "react" | "preact" | "solid" | "vue" | "svelte" | "vanilla"
@@ -122,6 +122,37 @@ function resolveInsideCwd(cwd: string, relativeFile: string): string {
122
122
  return target
123
123
  }
124
124
 
125
+ /** Lexical containment is not enough when an existing route directory is a symlink. Check every
126
+ * existing ancestor and its real path before scaffolding so an agent cannot redirect a write outside
127
+ * the selected project. The target itself is created with `wx`, so an existing target symlink is also
128
+ * never followed/overwritten. */
129
+ async function assertNoSymlinkedAncestors(root: string, target: string): Promise<void> {
130
+ const rootPath = resolve(root)
131
+ const realRoot = await realpath(rootPath)
132
+ let current = dirname(target)
133
+ while (current !== rootPath) {
134
+ if (!current.startsWith(`${rootPath}${sep}`)) {
135
+ throw new Error(`refusing to write outside project root: ${target}`)
136
+ }
137
+ try {
138
+ if ((await lstat(current)).isSymbolicLink()) {
139
+ throw new Error(`refusing to write through symlinked directory: ${current}`)
140
+ }
141
+ const actual = await realpath(current)
142
+ if (actual !== realRoot && !actual.startsWith(`${realRoot}${sep}`)) {
143
+ throw new Error(`refusing to write outside project root through: ${current}`)
144
+ }
145
+ } catch (err) {
146
+ if (err && typeof err === "object" && (err as { code?: string }).code === "ENOENT") {
147
+ current = dirname(current)
148
+ continue
149
+ }
150
+ throw err
151
+ }
152
+ current = dirname(current)
153
+ }
154
+ }
155
+
125
156
  /** Write a scaffolded route stub when the framework has a verified ready-to-write body. The write is
126
157
  * intentionally conservative: it refuses non-JSX stubs (where we only return contract guidance) and
127
158
  * uses `wx`, so an agent cannot overwrite user work by accident. */
@@ -139,7 +170,9 @@ export async function writeScaffoldRoute(
139
170
  }
140
171
  }
141
172
  const target = resolveInsideCwd(cwd, result.file)
173
+ await assertNoSymlinkedAncestors(cwd, target)
142
174
  await mkdir(dirname(target), { recursive: true })
175
+ await assertNoSymlinkedAncestors(cwd, target)
143
176
  try {
144
177
  await writeFile(target, result.content, { flag: "wx" })
145
178
  } catch (err) {