@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/README.md CHANGED
@@ -97,3 +97,11 @@ claude mcp add nifra -- nifra mcp
97
97
 
98
98
  Run the client from (or point it at) your project root. The protocol is hand-rolled
99
99
  (newline-delimited JSON-RPC 2.0) — no SDK dependency.
100
+
101
+ ## For AI agents
102
+
103
+ Start with [`LLM.md`](./LLM.md) — this package's contract card (the exports you call + its footguns),
104
+ one cheap read instead of the whole corpus. For the wider framework: the repo's
105
+ [`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
106
+ [`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
107
+ done-gate, or `nifra mcp` to give the agent live project tools.
package/dist/check.d.ts CHANGED
@@ -22,6 +22,12 @@ export interface SourceFinding {
22
22
  }
23
23
  /** @deprecated kept for back-compat with existing tests; prefer {@link SourceFinding}. */
24
24
  export type FetchFinding = SourceFinding;
25
+ /** A server-only-import finding, carrying the offending module specifier on top of the base location so
26
+ * the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
27
+ export interface ServerImportFinding extends SourceFinding {
28
+ /** The server-only module specifier the route top-level-imports (e.g. `pg`, `node:fs`, `../db`). */
29
+ readonly specifier: string;
30
+ }
25
31
  export interface StaticRouteFinding extends SourceFinding {
26
32
  readonly method: string;
27
33
  readonly path: string;
@@ -44,8 +50,52 @@ export declare function scanStaticRouteText(file: string, content: string): Stat
44
50
  /** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
45
51
  export declare function scanUntypedClient(file: string, content: string): SourceFinding[];
46
52
  /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
47
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Pure. */
48
- export declare function scanServerOnlyImports(file: string, content: string): SourceFinding[];
53
+ * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
54
+ * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
55
+ export declare function scanServerOnlyImports(file: string, content: string): ServerImportFinding[];
56
+ /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
57
+ * Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
58
+ export declare function parseStaticImports(content: string): string[];
59
+ /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
60
+ * server-only package), or `undefined` if it isn't a by-name sink. Pure — the label is the specifier
61
+ * itself (it's already the actionable name). */
62
+ export declare function directSinkSpecifier(spec: string): string | undefined;
63
+ /** One transitive server-only finding: the route module, the route's offending top-level import (the
64
+ * first hop / `specifier`), the line + snippet of that import, and the FULL chain to the sink. */
65
+ export interface TransitiveServerImportFinding extends ServerImportFinding {
66
+ /** `[routeFile, ...as-written specifiers…, sink]` — the shortest path the walk found. Length 2 means
67
+ * the route imports the sink directly (same as the regex scan's direct edge). */
68
+ readonly chain: readonly string[];
69
+ /** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
70
+ * so the chain is the honest direct edge rather than a fabricated deeper path. */
71
+ readonly fallback: boolean;
72
+ }
73
+ /** A resolver from `(fromFile, specifier)` to an absolute module path, or `undefined` when it can't be
74
+ * resolved precisely (bare pkg, path alias, missing file). Abstracted so the BFS is unit-testable with a
75
+ * fake graph (no real fs). The production resolver wraps `Bun.resolveSync`. */
76
+ export type ModuleResolver = (fromFile: string, specifier: string) => string | undefined;
77
+ /** Reads a resolved module's source, or `undefined` if unreadable. Abstracted for the same reason. */
78
+ export type ModuleReader = (absPath: string) => string | undefined;
79
+ /**
80
+ * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
81
+ * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
82
+ * node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
83
+ * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
84
+ * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
85
+ * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
86
+ * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
87
+ * — pure given those, so it's unit-testable with a fake graph.
88
+ */
89
+ export declare function walkServerOnlyChain(routeFile: string, routeContent: string, resolve: ModuleResolver, read: ModuleReader): readonly string[] | undefined;
90
+ /**
91
+ * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
92
+ * reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
93
+ * finding carries the full chain; otherwise — when the first hop is itself a direct by-name sink the
94
+ * regex scan already flags, or when a relative import can't be resolved — it falls back to the DIRECT
95
+ * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
96
+ * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
97
+ */
98
+ export declare function resolveServerOnlyChains(file: string, content: string, resolve: ModuleResolver, read: ModuleReader): TransitiveServerImportFinding[];
49
99
  /** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
50
100
  * the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
51
101
  export declare function scanResponseRoutes(file: string, content: string): SourceFinding[];
@@ -54,9 +104,26 @@ export declare function scanResponseRoutes(file: string, content: string): Sourc
54
104
  export declare function walkSource(cwd: string, visit: (rel: string, content: string) => void): Promise<void>;
55
105
  /** Collect own-API `fetch()` findings across the project (kept for back-compat + reuse). */
56
106
  export declare function scanProject(cwd: string): Promise<SourceFinding[]>;
107
+ export interface ManifestDriftFinding {
108
+ /** The committed server-manifest file (relative to cwd). */
109
+ readonly file: string;
110
+ /** Route files in `routes/` missing from the manifest (stale manifest — these routes won't serve). */
111
+ readonly missing: readonly string[];
112
+ /** Route files the manifest imports that no longer exist in `routes/` (a dangling import). */
113
+ readonly extra: readonly string[];
114
+ }
115
+ /**
116
+ * Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
117
+ * from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
118
+ * prefix (so a manifest that imports `../routes/x` is checked against the sibling `routes/`), its route
119
+ * imports are parsed (`parseManifestRouteFiles`), and the set is diffed against the actual route files
120
+ * on disk. A clean manifest yields no finding. Returns one finding per drifted manifest. Never throws on
121
+ * a per-manifest miss (an unreadable routes dir is simply skipped).
122
+ */
123
+ export declare function scanServerManifestDrift(cwd: string): Promise<ManifestDriftFinding[]>;
57
124
  /** A single machine-readable check failure — the unit an agent (or CI) acts on. */
58
125
  export interface CheckDiagnostic {
59
- readonly rule: "typecheck" | "typed-client" | "untyped-client" | "server-only-import" | "response-route" | "undeclared-dependency";
126
+ readonly rule: "typecheck" | "typed-client" | "untyped-client" | "server-only-import" | "response-route" | "undeclared-dependency" | "server-manifest-drift";
60
127
  /** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
61
128
  * does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
62
129
  * `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
@@ -71,6 +138,18 @@ export interface CheckDiagnostic {
71
138
  /** A richer, agent-oriented fix hint. Diffs are only emitted when the edit is mechanical and local;
72
139
  * ambiguous cases give concrete steps instead of pretending the checker can safely rewrite code. */
73
140
  readonly suggestion?: CheckSuggestion;
141
+ /**
142
+ * The import chain that pulls server-only code into the browser bundle, as display labels
143
+ * `[routeFile, …as-written specifiers…, sink]`. Set only on `server-only-import`.
144
+ *
145
+ * #4.4: this is now the FULL **transitive** chain — a bounded import-resolution walk (`Bun.resolveSync`
146
+ * from each file's dir, BFS the local module graph) follows `route → ../data → ../db → node:crypto`,
147
+ * matching the build leak-guard's depth (`detectNodeBuiltinsInClient` in `@nifrajs/web/build`). A
148
+ * length-2 chain (`[routeFile, specifier]`) means the route imports the sink directly. When a hop can't
149
+ * be resolved precisely (a bare pkg, a tsconfig path alias), the walk degrades to the honest direct
150
+ * edge rather than fabricating a deeper path — never a lie.
151
+ */
152
+ readonly chain?: readonly string[];
74
153
  }
75
154
  export interface CheckSuggestion {
76
155
  readonly kind: "edit" | "command" | "manual";
@@ -1 +1 @@
1
- {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"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"}
package/dist/check.js CHANGED
@@ -15,7 +15,8 @@
15
15
  * `nifra_check` MCP tool, so an agent acts on diagnostics instead of scraping prose). Exits non-zero if
16
16
  * anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
17
17
  */
18
- import { join } from "node:path";
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, isAbsolute, join } from "node:path";
19
20
  import { Glob } from "bun";
20
21
  // `fetch( <ws> ('|"|`) / (not /)` — a string/template arg starting with a single `/` is a relative URL,
21
22
  // i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
@@ -287,7 +288,8 @@ export function scanUntypedClient(file, content) {
287
288
  return out;
288
289
  }
289
290
  /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
290
- * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Pure. */
291
+ * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
292
+ * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
291
293
  export function scanServerOnlyImports(file, content) {
292
294
  if (!ROUTE_FILE.test(file))
293
295
  return [];
@@ -295,10 +297,196 @@ export function scanServerOnlyImports(file, content) {
295
297
  const lines = content.split("\n");
296
298
  STATIC_IMPORT.lastIndex = 0;
297
299
  for (let m = STATIC_IMPORT.exec(content); m !== null; m = STATIC_IMPORT.exec(content)) {
298
- if (!SERVER_ONLY.test(m[1] ?? ""))
300
+ const specifier = m[1] ?? "";
301
+ if (!SERVER_ONLY.test(specifier))
299
302
  continue;
300
303
  const line = lineAt(content, m.index);
301
- out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
304
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
305
+ }
306
+ return out;
307
+ }
308
+ // ---------------------------------------------------------------------------------------------------
309
+ // #4.4 — TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
310
+ // pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
311
+ // `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
312
+ // `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
313
+ // import-resolution walk: from a flagged route, resolve its local imports (`Bun.resolveSync`), BFS the
314
+ // local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
315
+ // that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
316
+ // ---------------------------------------------------------------------------------------------------
317
+ // Server-only specifiers that are TERMINAL sinks — a bare `node:`/`bun:` builtin or a known server-only
318
+ // npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
319
+ // as SERVER_ONLY, minus the relative `../db` arm — a relative `db` module IS local source we resolve.)
320
+ const SERVER_ONLY_SINK = /^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/;
321
+ // The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
322
+ const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
323
+ // The explicit poison-import marker (`@nifrajs/web/server-only`) — a module opting into the client-leak
324
+ // guard. A resolved file whose source carries this side-effect import is a server-only sink.
325
+ const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/;
326
+ // Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
327
+ // few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
328
+ const TRANSITIVE_MAX_DEPTH = 8;
329
+ const TRANSITIVE_MAX_VISITED = 200;
330
+ /** A relative module specifier (`./x`, `../y`) — the only kind we resolve + walk into (a bare specifier
331
+ * is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
332
+ const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
333
+ // A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT — the outer
334
+ // per-route loop and the inner BFS both scan imports — and a single shared global-flag regex carries
335
+ // `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
336
+ // loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
337
+ const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags);
338
+ /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect),
339
+ * each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
340
+ * dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
341
+ * transitive walk. Pure. */
342
+ function staticImportEdges(content) {
343
+ const edges = [];
344
+ const re = staticImportRegex();
345
+ for (let m = re.exec(content); m !== null; m = re.exec(content)) {
346
+ if (m[1] !== undefined)
347
+ edges.push({ specifier: m[1], index: m.index });
348
+ }
349
+ return edges;
350
+ }
351
+ /** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
352
+ * Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
353
+ export function parseStaticImports(content) {
354
+ return staticImportEdges(content).map((e) => e.specifier);
355
+ }
356
+ /** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
357
+ * server-only package), or `undefined` if it isn't a by-name sink. Pure — the label is the specifier
358
+ * itself (it's already the actionable name). */
359
+ export function directSinkSpecifier(spec) {
360
+ return SERVER_ONLY_SINK.test(spec) ? spec : undefined;
361
+ }
362
+ /**
363
+ * BFS the LOCAL module graph from a route file for the SHORTEST import chain that reaches a server-only
364
+ * sink, returning `[routeFile, …as-written specifiers…, sink]` or `undefined` if none is reachable. A
365
+ * node's outgoing edges are its static imports; an edge is followed only when it's a RELATIVE specifier
366
+ * that `resolve` maps to a readable local file (so the walk never descends into node_modules or chases an
367
+ * unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
368
+ * `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
369
+ * it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
370
+ * — pure given those, so it's unit-testable with a fake graph.
371
+ */
372
+ export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
373
+ const seen = new Set([routeFile]);
374
+ let frontier = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }];
375
+ let visited = 0;
376
+ while (frontier.length > 0) {
377
+ const next = [];
378
+ for (const node of frontier) {
379
+ if (node.depth >= TRANSITIVE_MAX_DEPTH)
380
+ continue;
381
+ for (const spec of parseStaticImports(node.content)) {
382
+ // (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
383
+ // since BFS reaches the nearest sink before any deeper one).
384
+ const sink = directSinkSpecifier(spec);
385
+ if (sink !== undefined)
386
+ return [...node.chain, sink];
387
+ // (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
388
+ // specifier that isn't a known sink is a leaf — don't follow it into node_modules.
389
+ if (!isRelativeSpecifier(spec))
390
+ continue;
391
+ const abs = resolve(node.abs, spec);
392
+ if (abs === undefined || seen.has(abs))
393
+ continue;
394
+ // (c) A resolved `*.server` module is a server-only sink by the `.server` convention — the chain
395
+ // ends at it (named by the as-written specifier).
396
+ if (SERVER_MODULE_FILE.test(abs))
397
+ return [...node.chain, spec];
398
+ const content = read(abs);
399
+ if (content === undefined)
400
+ continue; // unreadable → can't walk; treat as a leaf
401
+ // (d) A resolved module that opts into the `server-only` marker is a sink too.
402
+ if (SERVER_ONLY_MARKER_IMPORT.test(content))
403
+ return [...node.chain, spec];
404
+ if (visited >= TRANSITIVE_MAX_VISITED)
405
+ continue;
406
+ visited++;
407
+ seen.add(abs);
408
+ next.push({ abs, content, chain: [...node.chain, spec], depth: node.depth + 1 });
409
+ }
410
+ }
411
+ frontier = next;
412
+ }
413
+ return undefined;
414
+ }
415
+ /**
416
+ * Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
417
+ * reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
418
+ * finding carries the full chain; otherwise — when the first hop is itself a direct by-name sink the
419
+ * regex scan already flags, or when a relative import can't be resolved — it falls back to the DIRECT
420
+ * edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
421
+ * de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
422
+ */
423
+ export function resolveServerOnlyChains(file, content, resolve, read) {
424
+ if (!ROUTE_FILE.test(file))
425
+ return [];
426
+ const lines = content.split("\n");
427
+ const out = [];
428
+ const flaggedSpecifiers = new Set();
429
+ // Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
430
+ // the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
431
+ // imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
432
+ for (const { specifier, index } of staticImportEdges(content)) {
433
+ if (flaggedSpecifiers.has(specifier))
434
+ continue;
435
+ const line = lineAt(content, index);
436
+ const snippet = (lines[line - 1] ?? "").trim();
437
+ // (1) A direct by-name sink (`node:fs`, `postgres`) — the route imports it itself; chain is the
438
+ // direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
439
+ if (directSinkSpecifier(specifier) !== undefined) {
440
+ flaggedSpecifiers.add(specifier);
441
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
442
+ continue;
443
+ }
444
+ // (2) A relative local import — try the transitive walk from the resolved dependency. If it reaches
445
+ // a sink, emit the full chain rooted at THIS route's import line.
446
+ if (isRelativeSpecifier(specifier)) {
447
+ const abs = resolve(file, specifier);
448
+ if (abs === undefined) {
449
+ // Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
450
+ // flags directly, fall back to the direct edge (precise resolve impossible — say so via
451
+ // `fallback`). Any other unresolvable relative import we can't assert is server-only — skip it.
452
+ if (SERVER_ONLY.test(specifier)) {
453
+ flaggedSpecifiers.add(specifier);
454
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
455
+ }
456
+ continue;
457
+ }
458
+ // The `.server` / marker sink can be the first hop itself.
459
+ if (SERVER_MODULE_FILE.test(abs)) {
460
+ flaggedSpecifiers.add(specifier);
461
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
462
+ continue;
463
+ }
464
+ const depContent = read(abs);
465
+ if (depContent === undefined)
466
+ continue;
467
+ if (SERVER_ONLY_MARKER_IMPORT.test(depContent)) {
468
+ flaggedSpecifiers.add(specifier);
469
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
470
+ continue;
471
+ }
472
+ // Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
473
+ const subChain = walkServerOnlyChain(abs, depContent, resolve, read);
474
+ if (subChain !== undefined) {
475
+ flaggedSpecifiers.add(specifier);
476
+ // subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
477
+ // route's as-written specifier so the chain reads `routes/x → ../data → ../db → node:crypto`.
478
+ const chain = [file, specifier, ...subChain.slice(1)];
479
+ out.push({ file, line, snippet, specifier, chain, fallback: false });
480
+ continue;
481
+ }
482
+ // (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
483
+ // found no deeper sink (e.g. the file wasn't readable past the first hop) — fall back to the
484
+ // direct edge so we still surface the known-server-only convention rather than going silent.
485
+ if (SERVER_ONLY.test(specifier)) {
486
+ flaggedSpecifiers.add(specifier);
487
+ out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
488
+ }
489
+ }
302
490
  }
303
491
  return out;
304
492
  }
@@ -340,6 +528,59 @@ export async function scanProject(cwd) {
340
528
  await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)));
341
529
  return out.sort(bySite);
342
530
  }
531
+ // #7 — server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
532
+ // list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
533
+ // regenerated, the worker serves a stale route table — a silent edge break that no other check catches.
534
+ // We diff each committed manifest's imported route files against the live `routes/` tree.
535
+ // The marker comment `generateServerManifest` emits at the top of the file — identifies a generated
536
+ // manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
537
+ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest";
538
+ // The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) — used to
539
+ // locate the routes dir relative to the manifest, and to strip to route-relative keys.
540
+ const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/;
541
+ // Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
542
+ const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/;
543
+ /**
544
+ * Scan the project for committed, generated `server-manifest.ts` files and report any that have drifted
545
+ * from the live `routes/` tree. For each, the routes dir is derived from the manifest's own import
546
+ * prefix (so a manifest that imports `../routes/x` is checked against the sibling `routes/`), its route
547
+ * imports are parsed (`parseManifestRouteFiles`), and the set is diffed against the actual route files
548
+ * on disk. A clean manifest yields no finding. Returns one finding per drifted manifest. Never throws on
549
+ * a per-manifest miss (an unreadable routes dir is simply skipped).
550
+ */
551
+ export async function scanServerManifestDrift(cwd) {
552
+ const { parseManifestRouteFiles, diffManifestRoutes, isManifestInSync } = await import("@nifrajs/web/build");
553
+ const findings = [];
554
+ for await (const rel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
555
+ if (IGNORED.test(rel))
556
+ continue;
557
+ const source = await Bun.file(join(cwd, rel)).text();
558
+ if (!source.includes(GENERATED_MARKER))
559
+ continue; // not a generated manifest — skip
560
+ const prefixMatch = ROUTES_PREFIX.exec(source);
561
+ if (prefixMatch?.[1] === undefined)
562
+ continue; // no `routes/` imports → nothing to diff (empty app)
563
+ const routesPrefix = prefixMatch[1];
564
+ const manifestFiles = parseManifestRouteFiles(source, routesPrefix);
565
+ // The routes dir sits at `<manifest dir>/<prefix>` — resolve it relative to the manifest file.
566
+ const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
567
+ const routesDir = join(cwd, manifestDir, routesPrefix);
568
+ let discovered;
569
+ try {
570
+ discovered = (await Array.fromAsync(new Glob("**/*.{tsx,jsx,svelte,vue,mdx}").scan({ cwd: routesDir })))
571
+ .map((f) => f.replaceAll("\\", "/"))
572
+ .filter((f) => ROUTE_FILE_EXT.test(f));
573
+ }
574
+ catch {
575
+ continue; // routes dir gone/unreadable — not a drift we can assess
576
+ }
577
+ const drift = diffManifestRoutes(manifestFiles, discovered);
578
+ if (!isManifestInSync(drift)) {
579
+ findings.push({ file: rel, missing: drift.missing, extra: drift.extra });
580
+ }
581
+ }
582
+ return findings.sort((a, b) => a.file.localeCompare(b.file));
583
+ }
343
584
  /** Run the project's own `tsc --noEmit`, if TypeScript + a tsconfig are present. Never auto-installs. */
344
585
  async function typecheck(cwd, signal) {
345
586
  const tsconfig = join(cwd, "tsconfig.json");
@@ -392,6 +633,7 @@ const FETCH_HINT = "hand-rolled fetch() to your own API — call it through clie
392
633
  const SERVER_IMPORT_HINT = "server-only import in a route module (bundled for the browser) — reach it via c.db / ctx.api inside a loader, never a top-level import";
393
634
  const RESPONSE_ROUTE_HINT = "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)";
394
635
  const UNDECLARED_DEP_HINT = "imported package is not declared in package.json dependencies — run bun add to declare it";
636
+ const MANIFEST_DRIFT_HINT = "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";
395
637
  function oneLineDiff(file, line, before, after) {
396
638
  return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`;
397
639
  }
@@ -481,14 +723,23 @@ function ownFetchSuggestion(f, routes) {
481
723
  ],
482
724
  };
483
725
  }
484
- function serverImportSuggestion() {
726
+ function serverImportSuggestion(specifier, chain, fallback) {
727
+ const sink = chain[chain.length - 1] ?? specifier;
728
+ // Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
729
+ // ../db → node:crypto`) and which module to cut — not just the route's own top-level import.
730
+ const chainStep = chain.length > 2
731
+ ? fallback
732
+ ? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely — trace it from \`${specifier}\`).`
733
+ : `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.`
734
+ : undefined;
485
735
  return {
486
736
  kind: "manual",
487
737
  title: "Move server-only code behind the route server boundary",
488
738
  steps: [
489
- "Remove the top-level server-only import from the route module.",
739
+ ...(chainStep !== undefined ? [chainStep] : []),
740
+ `Remove the top-level \`import … from "${specifier}"\` from this route module (it's bundled for the browser).`,
490
741
  "Access backend/data work through the route `loader`/`action` context (`api`, `env`, or project server context).",
491
- "If a direct module import is unavoidable, lazy-load it inside the server-only loader/action path.",
742
+ `If a direct module import is unavoidable, lazy-load it (\`await import("${specifier}")\`) inside the server-only loader/action path.`,
492
743
  ],
493
744
  };
494
745
  }
@@ -510,9 +761,12 @@ export async function collectCheckResult(cwd, opts = {}) {
510
761
  const untypedClients = [];
511
762
  const serverImports = [];
512
763
  const responseRoutes = [];
764
+ // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
765
+ // (#4.4) — which needs fs-backed import resolution, not just per-file text — runs after the walk.
766
+ const routeModules = [];
513
767
  // lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
514
768
  // lints — the agent inner-loop mode; the full gate stays the definition of done.
515
- const [tc, _, dr] = await Promise.all([
769
+ const [tc, _, dr, manifestDrift] = await Promise.all([
516
770
  opts.lintsOnly
517
771
  ? Promise.resolve({ ran: false, ok: true, note: "skipped (lintsOnly)" })
518
772
  : typecheck(cwd, opts.signal),
@@ -520,11 +774,40 @@ export async function collectCheckResult(cwd, opts = {}) {
520
774
  fetches.push(...scanFetchText(rel, content));
521
775
  staticRoutes.push(...scanStaticRouteText(rel, content));
522
776
  untypedClients.push(...scanUntypedClient(rel, content));
523
- serverImports.push(...scanServerOnlyImports(rel, content));
777
+ if (ROUTE_FILE.test(rel))
778
+ routeModules.push({ rel, content });
524
779
  responseRoutes.push(...scanResponseRoutes(rel, content));
525
780
  }),
526
781
  import("./doctor.js").then((m) => m.collectDoctorResult(cwd)),
782
+ scanServerManifestDrift(cwd),
527
783
  ]);
784
+ // #4.4: resolve the FULL transitive server-only chain per route. The resolver/reader are fs-backed
785
+ // (`Bun.resolveSync` from the importing file's dir + a sync read), so the walk follows the real local
786
+ // module graph (`route → ../data → ../db → node:crypto`). Both are best-effort + total: a resolve/read
787
+ // miss returns `undefined`, and the walk falls back to the direct edge. The whole walk is bounded
788
+ // (depth + visited caps), so a deep/cyclic graph can't blow up the check.
789
+ const resolveModule = (fromFile, specifier) => {
790
+ try {
791
+ // `fromFile` is a cwd-RELATIVE route path on the first hop (`routes/x.tsx`) but ABSOLUTE on the
792
+ // deeper hops the walk takes (it carries resolved absolute paths) — resolve the dir for each.
793
+ const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile);
794
+ return Bun.resolveSync(specifier, dirname(fromAbs));
795
+ }
796
+ catch {
797
+ return undefined; // unresolvable (bare pkg without install, tsconfig path alias, missing file)
798
+ }
799
+ };
800
+ const readModule = (absPath) => {
801
+ try {
802
+ return readFileSync(absPath, "utf8");
803
+ }
804
+ catch {
805
+ return undefined;
806
+ }
807
+ };
808
+ for (const { rel, content } of routeModules) {
809
+ serverImports.push(...resolveServerOnlyChains(rel, content, resolveModule, readModule));
810
+ }
528
811
  const diagnostics = [];
529
812
  if (tc.ran && !tc.ok) {
530
813
  const lines = (tc.output ?? "").split("\n");
@@ -587,14 +870,21 @@ export async function collectCheckResult(cwd, opts = {}) {
587
870
  });
588
871
  }
589
872
  for (const f of serverImports.sort(bySite)) {
873
+ // #4.4: the FULL transitive chain the import-resolution walk found — `route → ../data → ../db →
874
+ // node:crypto`, matching the build leak-guard's depth — instead of just the direct edge. The chain's
875
+ // tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
876
+ // (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
877
+ const chain = f.chain;
878
+ const sink = chain[chain.length - 1] ?? f.specifier;
590
879
  diagnostics.push({
591
880
  rule: "server-only-import",
592
881
  severity: "error",
593
882
  file: f.file,
594
883
  line: f.line,
595
- message: `${f.snippet} — ${SERVER_IMPORT_HINT}`,
884
+ 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}`,
596
885
  fix: SERVER_IMPORT_HINT,
597
- suggestion: serverImportSuggestion(),
886
+ chain,
887
+ suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
598
888
  });
599
889
  }
600
890
  // Advisory — surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
@@ -626,11 +916,35 @@ export async function collectCheckResult(cwd, opts = {}) {
626
916
  });
627
917
  }
628
918
  }
919
+ // #7: a committed server-manifest.ts that drifted from routes/ — name the exact missing/extra routes.
920
+ for (const f of manifestDrift) {
921
+ const parts = [];
922
+ if (f.missing.length > 0)
923
+ parts.push(`missing from manifest: ${f.missing.join(", ")}`);
924
+ if (f.extra.length > 0)
925
+ parts.push(`stale in manifest: ${f.extra.join(", ")}`);
926
+ diagnostics.push({
927
+ rule: "server-manifest-drift",
928
+ severity: "error",
929
+ file: f.file,
930
+ message: `${f.file} drifted from routes/ (${parts.join("; ")}) — ${MANIFEST_DRIFT_HINT}`,
931
+ fix: MANIFEST_DRIFT_HINT,
932
+ suggestion: {
933
+ kind: "manual",
934
+ title: "Regenerate the committed server manifest",
935
+ steps: [
936
+ "Re-run your build (`nifra build --target <t>` or your build script) — it regenerates server-manifest.ts from the current routes/.",
937
+ "Commit the updated server-manifest.ts.",
938
+ ],
939
+ },
940
+ });
941
+ }
629
942
  return {
630
943
  ok: tc.ok &&
631
944
  fetches.length === 0 &&
632
945
  untypedClients.length === 0 &&
633
946
  serverImports.length === 0 &&
947
+ manifestDrift.length === 0 &&
634
948
  (!dr.ran || dr.findings.length === 0),
635
949
  typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
636
950
  diagnostics,
@@ -657,6 +971,7 @@ export async function runCheck(cwd, opts = {}) {
657
971
  ["server-only-import", "server-only import in a route module"],
658
972
  ["response-route", "route returns a raw Response (typed client → data: never)"],
659
973
  ["undeclared-dependency", "undeclared dependency in package.json"],
974
+ ["server-manifest-drift", "server-manifest.ts drifted from routes/"],
660
975
  ]) {
661
976
  const ds = counts(rule);
662
977
  if (rule === "response-route") {