@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 +8 -0
- package/dist/check.d.ts +82 -3
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +326 -11
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +178 -17
- package/dist/cli.js.map +1 -1
- package/dist/init-agents.d.ts +58 -0
- package/dist/init-agents.d.ts.map +1 -0
- package/dist/init-agents.js +126 -0
- package/dist/init-agents.js.map +1 -0
- package/dist/introspect.d.ts +77 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +138 -9
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +99 -8
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +49 -21
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +12 -3
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js +21 -4
- package/dist/openapi-tool.js.map +1 -1
- package/dist/port.d.ts +144 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +498 -0
- package/dist/port.js.map +1 -0
- package/docs/examples.json +91 -133
- package/docs/llms-full.txt +1090 -35
- package/package.json +6 -5
- package/src/check.ts +398 -14
- package/src/cli.ts +176 -21
- package/src/init-agents.ts +176 -0
- package/src/introspect.ts +208 -8
- package/src/mcp-render.ts +106 -11
- package/src/mcp.ts +56 -20
- package/src/openapi-tool.ts +24 -4
- package/src/port.ts +663 -0
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).
|
|
48
|
-
|
|
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";
|
package/dist/check.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;
|
|
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 {
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|