@nifrajs/cli 0.1.0-alpha.1 → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +3 -2
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
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",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"@nifrajs/client": "workspace:*",
|
|
45
45
|
"@nifrajs/runner": "workspace:*",
|
|
46
46
|
"@nifrajs/schema": "workspace:*",
|
|
47
|
-
"@nifrajs/web": "workspace:*"
|
|
47
|
+
"@nifrajs/web": "workspace:*",
|
|
48
|
+
"create-nifra": "workspace:*"
|
|
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 {
|
|
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).
|
|
311
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|