@nifrajs/cli 0.1.0-alpha.1 → 0.1.0-alpha.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "The nifra CLI — zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,10 +41,11 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "workspace:*",
45
- "@nifrajs/runner": "workspace:*",
46
- "@nifrajs/schema": "workspace:*",
47
- "@nifrajs/web": "workspace:*"
44
+ "@nifrajs/client": "0.1.0-alpha.2",
45
+ "@nifrajs/runner": "0.1.0-alpha.2",
46
+ "@nifrajs/schema": "0.1.0-alpha.2",
47
+ "@nifrajs/web": "0.1.0-alpha.3",
48
+ "create-nifra": "0.1.0-alpha.2"
48
49
  },
49
50
  "repository": {
50
51
  "type": "git",
package/src/check.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  * anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
17
17
  */
18
18
 
19
- import { join } from "node:path"
19
+ import { readFileSync } from "node:fs"
20
+ import { dirname, isAbsolute, join } from "node:path"
20
21
  import { Glob } from "bun"
21
22
 
22
23
  export interface SourceFinding {
@@ -27,6 +28,13 @@ export interface SourceFinding {
27
28
  /** @deprecated kept for back-compat with existing tests; prefer {@link SourceFinding}. */
28
29
  export type FetchFinding = SourceFinding
29
30
 
31
+ /** A server-only-import finding, carrying the offending module specifier on top of the base location so
32
+ * the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
33
+ export interface ServerImportFinding extends SourceFinding {
34
+ /** The server-only module specifier the route top-level-imports (e.g. `pg`, `node:fs`, `../db`). */
35
+ readonly specifier: string
36
+ }
37
+
30
38
  // `fetch( <ws> ('|"|`) / (not /)` — a string/template arg starting with a single `/` is a relative URL,
31
39
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
32
40
  // `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
@@ -307,16 +315,238 @@ export function scanUntypedClient(file: string, content: string): SourceFinding[
307
315
  }
308
316
 
309
317
  /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
310
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Pure. */
311
- export function scanServerOnlyImports(file: string, content: string): SourceFinding[] {
318
+ * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
319
+ * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
320
+ export function scanServerOnlyImports(file: string, content: string): ServerImportFinding[] {
312
321
  if (!ROUTE_FILE.test(file)) return []
313
- const out: SourceFinding[] = []
322
+ const out: ServerImportFinding[] = []
314
323
  const lines = content.split("\n")
315
324
  STATIC_IMPORT.lastIndex = 0
316
325
  for (let m = STATIC_IMPORT.exec(content); m !== null; m = STATIC_IMPORT.exec(content)) {
317
- if (!SERVER_ONLY.test(m[1] ?? "")) continue
326
+ const specifier = m[1] ?? ""
327
+ if (!SERVER_ONLY.test(specifier)) continue
318
328
  const line = lineAt(content, m.index)
319
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
329
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
330
+ }
331
+ return out
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------------------------------
335
+ // #4.4 — TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
336
+ // pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
337
+ // `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
338
+ // `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
339
+ // import-resolution walk: from a flagged route, resolve its local imports (`Bun.resolveSync`), BFS the
340
+ // local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
341
+ // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
342
+ // ---------------------------------------------------------------------------------------------------
343
+
344
+ // Server-only specifiers that are TERMINAL sinks — a bare `node:`/`bun:` builtin or a known server-only
345
+ // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
346
+ // as SERVER_ONLY, minus the relative `../db` arm — a relative `db` module IS local source we resolve.)
347
+ const SERVER_ONLY_SINK =
348
+ /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
349
+ // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
350
+ const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/
351
+ // The explicit poison-import marker (`@nifrajs/web/server-only`) — a module opting into the client-leak
352
+ // guard. A resolved file whose source carries this side-effect import is a server-only sink.
353
+ const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
354
+ // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
355
+ // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
356
+ const TRANSITIVE_MAX_DEPTH = 8
357
+ const TRANSITIVE_MAX_VISITED = 200
358
+
359
+ /** A relative module specifier (`./x`, `../y`) — the only kind we resolve + walk into (a bare specifier
360
+ * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
361
+ const isRelativeSpecifier = (spec: string): boolean =>
362
+ spec.startsWith("./") || spec.startsWith("../")
363
+
364
+ // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT — the outer
365
+ // per-route loop and the inner BFS both scan imports — and a single shared global-flag regex carries
366
+ // `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
367
+ // loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
368
+ const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags)
369
+
370
+ /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect),
371
+ * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
372
+ * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
373
+ * transitive walk. Pure. */
374
+ function staticImportEdges(content: string): Array<{ specifier: string; index: number }> {
375
+ const edges: Array<{ specifier: string; index: number }> = []
376
+ const re = staticImportRegex()
377
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
378
+ if (m[1] !== undefined) edges.push({ specifier: m[1], index: m.index })
379
+ }
380
+ return edges
381
+ }
382
+
383
+ /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
384
+ * Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
385
+ export function parseStaticImports(content: string): string[] {
386
+ return staticImportEdges(content).map((e) => e.specifier)
387
+ }
388
+
389
+ /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
390
+ * server-only package), or `undefined` if it isn't a by-name sink. Pure — the label is the specifier
391
+ * itself (it's already the actionable name). */
392
+ export function directSinkSpecifier(spec: string): string | undefined {
393
+ return SERVER_ONLY_SINK.test(spec) ? spec : undefined
394
+ }
395
+
396
+ /** One transitive server-only finding: the route module, the route's offending top-level import (the
397
+ * first hop / `specifier`), the line + snippet of that import, and the FULL chain to the sink. */
398
+ export interface TransitiveServerImportFinding extends ServerImportFinding {
399
+ /** `[routeFile, ...as-written specifiers…, sink]` — the shortest path the walk found. Length 2 means
400
+ * the route imports the sink directly (same as the regex scan's direct edge). */
401
+ readonly chain: readonly string[]
402
+ /** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
403
+ * so the chain is the honest direct edge rather than a fabricated deeper path. */
404
+ readonly fallback: boolean
405
+ }
406
+
407
+ /** A resolver from `(fromFile, specifier)` to an absolute module path, or `undefined` when it can't be
408
+ * resolved precisely (bare pkg, path alias, missing file). Abstracted so the BFS is unit-testable with a
409
+ * fake graph (no real fs). The production resolver wraps `Bun.resolveSync`. */
410
+ export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined
411
+ /** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
412
+ export type ModuleReader = (absPath: string) => string | undefined
413
+
414
+ /**
415
+ * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
416
+ * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
417
+ * node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
418
+ * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
419
+ * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
420
+ * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
421
+ * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
422
+ * — pure given those, so it's unit-testable with a fake graph.
423
+ */
424
+ export function walkServerOnlyChain(
425
+ routeFile: string,
426
+ routeContent: string,
427
+ resolve: ModuleResolver,
428
+ read: ModuleReader,
429
+ ): readonly string[] | undefined {
430
+ // Frontier nodes carry the absolute file to inspect, the source to scan, the display chain so far
431
+ // (route + the as-written specifier of each hop), and the file the imports resolve relative to.
432
+ interface Node {
433
+ readonly abs: string
434
+ readonly content: string
435
+ readonly chain: readonly string[]
436
+ readonly depth: number
437
+ }
438
+ const seen = new Set<string>([routeFile])
439
+ let frontier: Node[] = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }]
440
+ let visited = 0
441
+ while (frontier.length > 0) {
442
+ const next: Node[] = []
443
+ for (const node of frontier) {
444
+ if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
445
+ for (const spec of parseStaticImports(node.content)) {
446
+ // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
447
+ // since BFS reaches the nearest sink before any deeper one).
448
+ const sink = directSinkSpecifier(spec)
449
+ if (sink !== undefined) return [...node.chain, sink]
450
+ // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
451
+ // specifier that isn't a known sink is a leaf — don't follow it into node_modules.
452
+ if (!isRelativeSpecifier(spec)) continue
453
+ const abs = resolve(node.abs, spec)
454
+ if (abs === undefined || seen.has(abs)) continue
455
+ // (c) A resolved `*.server` module is a server-only sink by the `.server` convention — the chain
456
+ // ends at it (named by the as-written specifier).
457
+ if (SERVER_MODULE_FILE.test(abs)) return [...node.chain, spec]
458
+ const content = read(abs)
459
+ if (content === undefined) continue // unreadable → can't walk; treat as a leaf
460
+ // (d) A resolved module that opts into the `server-only` marker is a sink too.
461
+ if (SERVER_ONLY_MARKER_IMPORT.test(content)) return [...node.chain, spec]
462
+ if (visited >= TRANSITIVE_MAX_VISITED) continue
463
+ visited++
464
+ seen.add(abs)
465
+ next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 })
466
+ }
467
+ }
468
+ frontier = next
469
+ }
470
+ return undefined
471
+ }
472
+
473
+ /**
474
+ * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
475
+ * reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
476
+ * finding carries the full chain; otherwise — when the first hop is itself a direct by-name sink the
477
+ * regex scan already flags, or when a relative import can't be resolved — it falls back to the DIRECT
478
+ * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
479
+ * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
480
+ */
481
+ export function resolveServerOnlyChains(
482
+ file: string,
483
+ content: string,
484
+ resolve: ModuleResolver,
485
+ read: ModuleReader,
486
+ ): TransitiveServerImportFinding[] {
487
+ if (!ROUTE_FILE.test(file)) return []
488
+ const lines = content.split("\n")
489
+ const out: TransitiveServerImportFinding[] = []
490
+ const flaggedSpecifiers = new Set<string>()
491
+ // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
492
+ // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
493
+ // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
494
+ for (const { specifier, index } of staticImportEdges(content)) {
495
+ if (flaggedSpecifiers.has(specifier)) continue
496
+ const line = lineAt(content, index)
497
+ const snippet = (lines[line - 1] ?? "").trim()
498
+ // (1) A direct by-name sink (`node:fs`, `postgres`) — the route imports it itself; chain is the
499
+ // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
500
+ if (directSinkSpecifier(specifier) !== undefined) {
501
+ flaggedSpecifiers.add(specifier)
502
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
503
+ continue
504
+ }
505
+ // (2) A relative local import — try the transitive walk from the resolved dependency. If it reaches
506
+ // a sink, emit the full chain rooted at THIS route's import line.
507
+ if (isRelativeSpecifier(specifier)) {
508
+ const abs = resolve(file, specifier)
509
+ if (abs === undefined) {
510
+ // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
511
+ // flags directly, fall back to the direct edge (precise resolve impossible — say so via
512
+ // `fallback`). Any other unresolvable relative import we can't assert is server-only — skip it.
513
+ if (SERVER_ONLY.test(specifier)) {
514
+ flaggedSpecifiers.add(specifier)
515
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
516
+ }
517
+ continue
518
+ }
519
+ // The `.server` / marker sink can be the first hop itself.
520
+ if (SERVER_MODULE_FILE.test(abs)) {
521
+ flaggedSpecifiers.add(specifier)
522
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
523
+ continue
524
+ }
525
+ const depContent = read(abs)
526
+ if (depContent === undefined) continue
527
+ if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
528
+ flaggedSpecifiers.add(specifier)
529
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
530
+ continue
531
+ }
532
+ // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
533
+ const subChain = walkServerOnlyChain(abs, depContent, resolve, read)
534
+ if (subChain !== undefined) {
535
+ flaggedSpecifiers.add(specifier)
536
+ // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
537
+ // route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
538
+ const chain = [file, specifier, ...subChain.slice(1)]
539
+ out.push({ file, line, snippet, specifier, chain, fallback: false })
540
+ continue
541
+ }
542
+ // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
543
+ // found no deeper sink (e.g. the file wasn't readable past the first hop) — fall back to the
544
+ // direct edge so we still surface the known-server-only convention rather than going silent.
545
+ if (SERVER_ONLY.test(specifier)) {
546
+ flaggedSpecifiers.add(specifier)
547
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
548
+ }
549
+ }
320
550
  }
321
551
  return out
322
552
  }
@@ -366,6 +596,71 @@ export async function scanProject(cwd: string): Promise<SourceFinding[]> {
366
596
  return out.sort(bySite)
367
597
  }
368
598
 
599
+ // #7 — server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
600
+ // list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
601
+ // regenerated, the worker serves a stale route table — a silent edge break that no other check catches.
602
+ // We diff each committed manifest's imported route files against the live `routes/` tree.
603
+
604
+ // The marker comment `generateServerManifest` emits at the top of the file — identifies a generated
605
+ // manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
606
+ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
607
+ // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) — used to
608
+ // locate the routes dir relative to the manifest, and to strip to route-relative keys.
609
+ const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
610
+ // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
611
+ const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
612
+
613
+ export interface ManifestDriftFinding {
614
+ /** The committed server-manifest file (relative to cwd). */
615
+ readonly file: string
616
+ /** Route files in `routes/` missing from the manifest (stale manifest — these routes won't serve). */
617
+ readonly missing: readonly string[]
618
+ /** Route files the manifest imports that no longer exist in `routes/` (a dangling import). */
619
+ readonly extra: readonly string[]
620
+ }
621
+
622
+ /**
623
+ * Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
624
+ * from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
625
+ * prefix (so a manifest that imports `../routes/x` is checked against the sibling `routes/`), its route
626
+ * imports are parsed (`parseManifestRouteFiles`), and the set is diffed against the actual route files
627
+ * on disk. A clean manifest yields no finding. Returns one finding per drifted manifest. Never throws on
628
+ * a per-manifest miss (an unreadable routes dir is simply skipped).
629
+ */
630
+ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDriftFinding[]> {
631
+ const { parseManifestRouteFiles, diffManifestRoutes, isManifestInSync } = await import(
632
+ "@nifrajs/web/build"
633
+ )
634
+ const findings: ManifestDriftFinding[] = []
635
+ for await (const rel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
636
+ if (IGNORED.test(rel)) continue
637
+ const source = await Bun.file(join(cwd, rel)).text()
638
+ if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest — skip
639
+ const prefixMatch = ROUTES_PREFIX.exec(source)
640
+ if (prefixMatch?.[1] === undefined) continue // no `routes/` imports → nothing to diff (empty app)
641
+ const routesPrefix = prefixMatch[1]
642
+ const manifestFiles = parseManifestRouteFiles(source, routesPrefix)
643
+ // The routes dir sits at `<manifest dir>/<prefix>` — resolve it relative to the manifest file.
644
+ const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
645
+ const routesDir = join(cwd, manifestDir, routesPrefix)
646
+ let discovered: string[]
647
+ try {
648
+ discovered = (
649
+ await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir }))
650
+ )
651
+ .map((f) => f.replaceAll("\\", "/"))
652
+ .filter((f) => ROUTE_FILE_EXT.test(f))
653
+ } catch {
654
+ continue // routes dir gone/unreadable — not a drift we can assess
655
+ }
656
+ const drift = diffManifestRoutes(manifestFiles, discovered)
657
+ if (!isManifestInSync(drift)) {
658
+ findings.push({ file: rel, missing: drift.missing, extra: drift.extra })
659
+ }
660
+ }
661
+ return findings.sort((a, b) => a.file.localeCompare(b.file))
662
+ }
663
+
369
664
  interface TypecheckResult {
370
665
  readonly ran: boolean
371
666
  readonly ok: boolean
@@ -430,6 +725,7 @@ export interface CheckDiagnostic {
430
725
  | "server-only-import"
431
726
  | "response-route"
432
727
  | "undeclared-dependency"
728
+ | "server-manifest-drift"
433
729
  /** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
434
730
  * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
435
731
  * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
@@ -444,6 +740,18 @@ export interface CheckDiagnostic {
444
740
  /** A richer, agent-oriented fix hint. Diffs are only emitted when the edit is mechanical and local;
445
741
  * ambiguous cases give concrete steps instead of pretending the checker can safely rewrite code. */
446
742
  readonly suggestion?: CheckSuggestion
743
+ /**
744
+ * The import chain that pulls server-only code into the browser bundle, as display labels
745
+ * `[routeFile, …as-written specifiers…, sink]`. Set only on `server-only-import`.
746
+ *
747
+ * #4.4: this is now the FULL **transitive** chain — a bounded import-resolution walk (`Bun.resolveSync`
748
+ * from each file's dir, BFS the local module graph) follows `route → ../data → ../db → node:crypto`,
749
+ * matching the build leak-guard's depth (`detectNodeBuiltinsInClient` in `@nifrajs/web/build`). A
750
+ * length-2 chain (`[routeFile, specifier]`) means the route imports the sink directly. When a hop can't
751
+ * be resolved precisely (a bare pkg, a tsconfig path alias), the walk degrades to the honest direct
752
+ * edge rather than fabricating a deeper path — never a lie.
753
+ */
754
+ readonly chain?: readonly string[]
447
755
  }
448
756
 
449
757
  export interface CheckSuggestion {
@@ -472,6 +780,8 @@ const RESPONSE_ROUTE_HINT =
472
780
  "route handler returns a raw Response — the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you), or add `{ response: t.… }` to the route if a raw Response is intended (file/redirect/stream)"
473
781
  const UNDECLARED_DEP_HINT =
474
782
  "imported package is not declared in package.json dependencies — run bun add to declare it"
783
+ const MANIFEST_DRIFT_HINT =
784
+ "server-manifest.ts is out of sync with routes/ — re-run the build to regenerate it (a disk-less worker bakes this route table, so the drift is a silent edge break), then commit it"
475
785
 
476
786
  function oneLineDiff(file: string, line: number, before: string, after: string): string {
477
787
  return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
@@ -566,14 +876,28 @@ function ownFetchSuggestion(
566
876
  }
567
877
  }
568
878
 
569
- function serverImportSuggestion(): CheckSuggestion {
879
+ function serverImportSuggestion(
880
+ specifier: string,
881
+ chain: readonly string[],
882
+ fallback: boolean,
883
+ ): CheckSuggestion {
884
+ const sink = chain[chain.length - 1] ?? specifier
885
+ // Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
886
+ // ../db → node:crypto`) and which module to cut — not just the route's own top-level import.
887
+ const chainStep =
888
+ chain.length > 2
889
+ ? fallback
890
+ ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely — trace it from \`${specifier}\`).`
891
+ : `Server-only code reaches this route transitively: \`${chain.join(" → ")}\`. The sink is \`${sink}\`; break the chain at the first hop (\`${specifier}\`) or move the sink behind the server boundary.`
892
+ : undefined
570
893
  return {
571
894
  kind: "manual",
572
895
  title: "Move server-only code behind the route server boundary",
573
896
  steps: [
574
- "Remove the top-level server-only import from the route module.",
897
+ ...(chainStep !== undefined ? [chainStep] : []),
898
+ `Remove the top-level \`import … from "${specifier}"\` from this route module (it's bundled for the browser).`,
575
899
  "Access backend/data work through the route `loader`/`action` context (`api`, `env`, or project server context).",
576
- "If a direct module import is unavoidable, lazy-load it inside the server-only loader/action path.",
900
+ `If a direct module import is unavoidable, lazy-load it (\`await import("${specifier}")\`) inside the server-only loader/action path.`,
577
901
  ],
578
902
  }
579
903
  }
@@ -598,11 +922,14 @@ export async function collectCheckResult(
598
922
  const fetches: SourceFinding[] = []
599
923
  const staticRoutes: StaticRouteFinding[] = []
600
924
  const untypedClients: SourceFinding[] = []
601
- const serverImports: SourceFinding[] = []
925
+ const serverImports: TransitiveServerImportFinding[] = []
602
926
  const responseRoutes: SourceFinding[] = []
927
+ // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
928
+ // (#4.4) — which needs fs-backed import resolution, not just per-file text — runs after the walk.
929
+ const routeModules: Array<{ rel: string; content: string }> = []
603
930
  // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
604
931
  // lints — the agent inner-loop mode; the full gate stays the definition of done.
605
- const [tc, _, dr] = await Promise.all([
932
+ const [tc, _, dr, manifestDrift] = await Promise.all([
606
933
  opts.lintsOnly
607
934
  ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "skipped (lintsOnly)" })
608
935
  : typecheck(cwd, opts.signal),
@@ -610,12 +937,39 @@ export async function collectCheckResult(
610
937
  fetches.push(...scanFetchText(rel, content))
611
938
  staticRoutes.push(...scanStaticRouteText(rel, content))
612
939
  untypedClients.push(...scanUntypedClient(rel, content))
613
- serverImports.push(...scanServerOnlyImports(rel, content))
940
+ if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
614
941
  responseRoutes.push(...scanResponseRoutes(rel, content))
615
942
  }),
616
943
  import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
944
+ scanServerManifestDrift(cwd),
617
945
  ])
618
946
 
947
+ // #4.4: resolve the FULL transitive server-only chain per route. The resolver/reader are fs-backed
948
+ // (`Bun.resolveSync` from the importing file's dir + a sync read), so the walk follows the real local
949
+ // module graph (`route → ../data → ../db → node:crypto`). Both are best-effort + total: a resolve/read
950
+ // miss returns `undefined`, and the walk falls back to the direct edge. The whole walk is bounded
951
+ // (depth + visited caps), so a deep/cyclic graph can't blow up the check.
952
+ const resolveModule: ModuleResolver = (fromFile, specifier) => {
953
+ try {
954
+ // `fromFile` is a cwd-RELATIVE route path on the first hop (`routes/x.tsx`) but ABSOLUTE on the
955
+ // deeper hops the walk takes (it carries resolved absolute paths) — resolve the dir for each.
956
+ const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile)
957
+ return Bun.resolveSync(specifier, dirname(fromAbs))
958
+ } catch {
959
+ return undefined // unresolvable (bare pkg without install, tsconfig path alias, missing file)
960
+ }
961
+ }
962
+ const readModule: ModuleReader = (absPath) => {
963
+ try {
964
+ return readFileSync(absPath, "utf8")
965
+ } catch {
966
+ return undefined
967
+ }
968
+ }
969
+ for (const { rel, content } of routeModules) {
970
+ serverImports.push(...resolveServerOnlyChains(rel, content, resolveModule, readModule))
971
+ }
972
+
619
973
  const diagnostics: CheckDiagnostic[] = []
620
974
  if (tc.ran && !tc.ok) {
621
975
  const lines = (tc.output ?? "").split("\n")
@@ -678,14 +1032,21 @@ export async function collectCheckResult(
678
1032
  })
679
1033
  }
680
1034
  for (const f of serverImports.sort(bySite)) {
1035
+ // #4.4: the FULL transitive chain the import-resolution walk found — `route → ../data → ../db →
1036
+ // node:crypto`, matching the build leak-guard's depth — instead of just the direct edge. The chain's
1037
+ // tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
1038
+ // (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
1039
+ const chain = f.chain
1040
+ const sink = chain[chain.length - 1] ?? f.specifier
681
1041
  diagnostics.push({
682
1042
  rule: "server-only-import",
683
1043
  severity: "error",
684
1044
  file: f.file,
685
1045
  line: f.line,
686
- message: `${f.snippet} — ${SERVER_IMPORT_HINT}`,
1046
+ message: `${f.snippet} — server-only "${sink}" reaches the browser bundle via ${chain.join(" → ")}${f.fallback ? " (direct edge — couldn't resolve the transitive chain precisely)" : ""}; ${SERVER_IMPORT_HINT}`,
687
1047
  fix: SERVER_IMPORT_HINT,
688
- suggestion: serverImportSuggestion(),
1048
+ chain,
1049
+ suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
689
1050
  })
690
1051
  }
691
1052
  // Advisory — surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
@@ -717,6 +1078,27 @@ export async function collectCheckResult(
717
1078
  })
718
1079
  }
719
1080
  }
1081
+ // #7: a committed server-manifest.ts that drifted from routes/ — name the exact missing/extra routes.
1082
+ for (const f of manifestDrift) {
1083
+ const parts: string[] = []
1084
+ if (f.missing.length > 0) parts.push(`missing from manifest: ${f.missing.join(", ")}`)
1085
+ if (f.extra.length > 0) parts.push(`stale in manifest: ${f.extra.join(", ")}`)
1086
+ diagnostics.push({
1087
+ rule: "server-manifest-drift",
1088
+ severity: "error",
1089
+ file: f.file,
1090
+ message: `${f.file} drifted from routes/ (${parts.join("; ")}) — ${MANIFEST_DRIFT_HINT}`,
1091
+ fix: MANIFEST_DRIFT_HINT,
1092
+ suggestion: {
1093
+ kind: "manual",
1094
+ title: "Regenerate the committed server manifest",
1095
+ steps: [
1096
+ "Re-run your build (`nifra build --target <t>` or your build script) — it regenerates server-manifest.ts from the current routes/.",
1097
+ "Commit the updated server-manifest.ts.",
1098
+ ],
1099
+ },
1100
+ })
1101
+ }
720
1102
 
721
1103
  return {
722
1104
  ok:
@@ -724,6 +1106,7 @@ export async function collectCheckResult(
724
1106
  fetches.length === 0 &&
725
1107
  untypedClients.length === 0 &&
726
1108
  serverImports.length === 0 &&
1109
+ manifestDrift.length === 0 &&
727
1110
  (!dr.ran || dr.findings.length === 0),
728
1111
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
729
1112
  diagnostics,
@@ -758,6 +1141,7 @@ export async function runCheck(
758
1141
  ["server-only-import", "server-only import in a route module"],
759
1142
  ["response-route", "route returns a raw Response (typed client → data: never)"],
760
1143
  ["undeclared-dependency", "undeclared dependency in package.json"],
1144
+ ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
761
1145
  ] as const) {
762
1146
  const ds = counts(rule)
763
1147
  if (rule === "response-route") {