@nifrajs/cli 2.1.0 → 2.3.0
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 +25 -25
- package/dist/assure.d.ts +2 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +17 -10
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +7 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +68 -16
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +64 -22
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +400 -83
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +37 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +291 -53
- package/dist/cli.js.map +1 -1
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.js +7 -7
- package/dist/docs-search.d.ts +3 -3
- package/dist/docs-search.js +6 -6
- package/dist/doctor.d.ts +8 -4
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +74 -14
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +5 -5
- package/dist/examples.js +5 -5
- package/dist/init-agents.d.ts +14 -14
- package/dist/init-agents.js +14 -14
- package/dist/internal/typescript-import.d.ts +24 -0
- package/dist/internal/typescript-import.d.ts.map +1 -0
- package/dist/internal/typescript-import.js +32 -0
- package/dist/internal/typescript-import.js.map +1 -0
- package/dist/introspect.d.ts +12 -12
- package/dist/introspect.js +24 -24
- package/dist/levels-tool.d.ts +8 -8
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -30
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +17 -6
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +45 -7
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +4 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +14 -10
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +5 -5
- package/dist/mcp-http.js +7 -7
- package/dist/mcp-protocol.d.ts +2 -2
- package/dist/mcp-protocol.js +2 -2
- package/dist/mcp-render.d.ts +3 -3
- package/dist/mcp-render.js +15 -7
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-run.d.ts +2 -2
- package/dist/mcp-run.js +2 -2
- package/dist/mcp.d.ts +14 -14
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +119 -47
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +1 -1
- package/dist/openapi-tool.js +2 -2
- package/dist/pipeline-guard.d.ts +83 -0
- package/dist/pipeline-guard.d.ts.map +1 -0
- package/dist/pipeline-guard.js +136 -0
- package/dist/pipeline-guard.js.map +1 -0
- package/dist/port.d.ts +9 -9
- package/dist/port.js +27 -27
- package/dist/recipes/1.8.0.d.ts +2 -2
- package/dist/recipes/1.8.0.js +5 -5
- package/dist/recipes/index.d.ts +2 -2
- package/dist/recipes/index.js +1 -1
- package/dist/scaffold.d.ts +3 -3
- package/dist/scaffold.js +8 -8
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.d.ts.map +1 -0
- package/dist/sync-routes.js +55 -0
- package/dist/sync-routes.js.map +1 -0
- package/dist/types-search.d.ts +6 -6
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +53 -8
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +7 -7
- package/dist/upgrade.js +12 -12
- package/dist/verification.d.ts +51 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +80 -0
- package/dist/verification.js.map +1 -0
- package/docs/examples.json +178 -88
- package/docs/llms-full.txt +1766 -766
- package/docs/types.json +2171 -2807
- package/package.json +22 -11
- package/src/assure.ts +17 -18
- package/src/capabilities-tool.ts +82 -16
- package/src/check.ts +493 -100
- package/src/cli.ts +306 -54
- package/src/diff-tool.ts +8 -8
- package/src/docs-search.ts +6 -6
- package/src/doctor.ts +81 -16
- package/src/examples.ts +7 -7
- package/src/init-agents.ts +17 -17
- package/src/internal/typescript-import.ts +33 -0
- package/src/introspect.ts +28 -28
- package/src/levels-tool.ts +37 -31
- package/src/load.ts +65 -12
- package/src/manifest-tool.ts +4 -2
- package/src/mcp-docs-tools.ts +20 -10
- package/src/mcp-http.ts +7 -7
- package/src/mcp-protocol.ts +2 -2
- package/src/mcp-render.ts +15 -7
- package/src/mcp-run.ts +2 -2
- package/src/mcp.ts +127 -47
- package/src/openapi-tool.ts +2 -2
- package/src/pipeline-guard.ts +186 -0
- package/src/port.ts +31 -31
- package/src/recipes/1.8.0.ts +5 -5
- package/src/recipes/index.ts +2 -2
- package/src/scaffold.ts +8 -8
- package/src/sync-routes.ts +69 -0
- package/src/types-search.ts +57 -8
- package/src/upgrade.ts +12 -12
- package/src/verification.ts +123 -0
package/dist/check.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra check`
|
|
2
|
+
* `nifra check` - the agent's (and CI's) definition of done. It makes the guarantees that keep a nifra
|
|
3
3
|
* app drift-proof actually *fire*, instead of relying on the agent to remember them:
|
|
4
4
|
*
|
|
5
|
-
* 1. **typecheck** (`tsc --noEmit`)
|
|
5
|
+
* 1. **typecheck** (`tsc --noEmit`) - the frontend↔backend contract is compiler-enforced. The typed
|
|
6
6
|
* client derives request + response types from the routes, so a shape mismatch is a type error.
|
|
7
|
-
* 2. **typed-client lint**
|
|
7
|
+
* 2. **typed-client lint** - flags hand-rolled `fetch()` to this app's *own* API (a relative URL),
|
|
8
8
|
* which bypasses `client<typeof app>` so the compiler can't see the drift.
|
|
9
|
-
* 3. **server-only-import lint**
|
|
9
|
+
* 3. **server-only-import lint** - flags a top-level import of server-only code (a DB driver, `node:`/
|
|
10
10
|
* `bun:` builtins, the `./db` module) into a `routes/` page module. Those modules are bundled for
|
|
11
|
-
* the browser too, so the import ships server code to the client and breaks the build
|
|
11
|
+
* the browser too, so the import ships server code to the client and breaks the build - the #1
|
|
12
12
|
* full-stack footgun. Reach server resources via `c.db` / `ctx.api`, never a top-level import.
|
|
13
13
|
*
|
|
14
14
|
* `collectCheckResult` returns a structured, machine-readable result (consumed by `--json` and the
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
import { existsSync, readFileSync } from "node:fs";
|
|
19
19
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
20
20
|
import { Glob } from "bun";
|
|
21
|
-
|
|
21
|
+
import { importTypeScript } from "./internal/typescript-import.js";
|
|
22
|
+
// `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
|
|
22
23
|
// i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
|
|
23
24
|
// `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
|
|
24
|
-
// alone on purpose
|
|
25
|
+
// alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
|
|
25
26
|
const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
|
|
26
27
|
const FETCH_CALL = /(?<![.\w])fetch\s*\(/g;
|
|
27
28
|
const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
@@ -29,7 +30,7 @@ const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"]);
|
|
|
29
30
|
// Don't scan deps, build output, generated client entries, or tests (which legitimately drive `fetch`).
|
|
30
31
|
// `dist(-<runtime>)?` also covers per-runtime output dirs (dist-bun/dist-node/dist-deno/dist-vercel).
|
|
31
32
|
const IGNORED = /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
32
|
-
// A file under `routes/`
|
|
33
|
+
// A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
|
|
33
34
|
const ROUTE_FILE = /(^|\/)routes\//;
|
|
34
35
|
// Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
|
|
35
36
|
// DB drivers/ORМ server entrypoints, and the conventional `./db` module the scaffold generates.
|
|
@@ -192,13 +193,13 @@ function scanRoutePattern(file, content, code, pattern) {
|
|
|
192
193
|
return out;
|
|
193
194
|
}
|
|
194
195
|
/**
|
|
195
|
-
* Blank, with spaces (newlines preserved, so every byte offset
|
|
196
|
+
* Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
|
|
196
197
|
* - `//` line comments and block comments;
|
|
197
|
-
* - the CONTENTS of backtick template literals
|
|
198
|
+
* - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
|
|
198
199
|
* generators), never a real statement to lint.
|
|
199
200
|
* Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
|
|
200
201
|
* char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
|
|
201
|
-
* could mis-skip
|
|
202
|
+
* could mis-skip - in practice the constructs these scanners look for sit before any such regex.
|
|
202
203
|
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
203
204
|
*/
|
|
204
205
|
export function stripComments(src) {
|
|
@@ -442,10 +443,177 @@ export function scanStaticRouteText(file, content) {
|
|
|
442
443
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
|
|
443
444
|
].sort(bySite);
|
|
444
445
|
}
|
|
445
|
-
|
|
446
|
+
/**
|
|
447
|
+
* SQL built by interpolating a value into the statement text. The value is not a parameter, it is
|
|
448
|
+
* STATEMENT, so anything the caller controls can end the literal and continue as SQL.
|
|
449
|
+
*
|
|
450
|
+
* Two things this deliberately does not flag, because both are safe and flagging them would train
|
|
451
|
+
* people to ignore the rule:
|
|
452
|
+
*
|
|
453
|
+
* - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
|
|
454
|
+
* substitutions separately and binds them; that is the parameterised form, not a bypass of it.
|
|
455
|
+
* - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
|
|
456
|
+
* argued for.
|
|
457
|
+
*
|
|
458
|
+
* A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
|
|
459
|
+
* escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
|
|
460
|
+
* is to take a statement as text, and a substitution in one is the exact thing the name warns about.
|
|
461
|
+
*/
|
|
462
|
+
const SQL_METHODS = new Set([
|
|
463
|
+
"query",
|
|
464
|
+
"queryRaw",
|
|
465
|
+
"prepare",
|
|
466
|
+
"exec",
|
|
467
|
+
"execute",
|
|
468
|
+
"run",
|
|
469
|
+
"unsafe",
|
|
470
|
+
"$queryRawUnsafe",
|
|
471
|
+
"$executeRawUnsafe",
|
|
472
|
+
"raw",
|
|
473
|
+
]);
|
|
474
|
+
const SQL_KEYWORD = /\b(select|insert\s+into|update|delete\s+from|drop\s+table|create\s+table|alter\s+table|truncate|from|where|values|set|join|union)\b/i;
|
|
475
|
+
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
|
|
476
|
+
/** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
|
|
477
|
+
function sqlExpressionShape(ts, node) {
|
|
478
|
+
if (ts.isParenthesizedExpression(node))
|
|
479
|
+
return sqlExpressionShape(ts, node.expression);
|
|
480
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
481
|
+
return {
|
|
482
|
+
staticText: node.text,
|
|
483
|
+
dynamic: false,
|
|
484
|
+
concatenated: false,
|
|
485
|
+
literal: true,
|
|
486
|
+
parameterizedTag: false,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
490
|
+
const tag = node.tag;
|
|
491
|
+
const parameterizedTag = (ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
492
|
+
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
|
|
493
|
+
const template = node.template;
|
|
494
|
+
const staticText = ts.isTemplateExpression(template)
|
|
495
|
+
? [template.head.text, ...template.templateSpans.map((span) => span.literal.text)].join("")
|
|
496
|
+
: template.text;
|
|
497
|
+
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
498
|
+
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
499
|
+
// own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
|
|
500
|
+
// deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
|
|
501
|
+
// same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
|
|
502
|
+
//
|
|
503
|
+
// Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
|
|
504
|
+
// a name invented for Nifra's own adapters - a second forgeable name that no user has a
|
|
505
|
+
// convention for, added so Nifra's source would pass its own rule. Names earn their place here by
|
|
506
|
+
// being what drivers already call the thing, not by being convenient for the framework.
|
|
507
|
+
//
|
|
508
|
+
// Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
|
|
509
|
+
// is treated as plain interpolation.
|
|
510
|
+
return {
|
|
511
|
+
staticText,
|
|
512
|
+
dynamic: ts.isTemplateExpression(template) && !parameterizedTag,
|
|
513
|
+
concatenated: false,
|
|
514
|
+
literal: !ts.isTemplateExpression(template),
|
|
515
|
+
parameterizedTag,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
if (ts.isTemplateExpression(node)) {
|
|
519
|
+
return {
|
|
520
|
+
staticText: [node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(""),
|
|
521
|
+
dynamic: true,
|
|
522
|
+
concatenated: false,
|
|
523
|
+
literal: false,
|
|
524
|
+
parameterizedTag: false,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
528
|
+
const left = sqlExpressionShape(ts, node.left);
|
|
529
|
+
const right = sqlExpressionShape(ts, node.right);
|
|
530
|
+
return {
|
|
531
|
+
staticText: left.staticText + right.staticText,
|
|
532
|
+
dynamic: left.dynamic || right.dynamic || !left.literal || !right.literal,
|
|
533
|
+
concatenated: true,
|
|
534
|
+
literal: left.literal && right.literal,
|
|
535
|
+
parameterizedTag: false,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
staticText: "",
|
|
540
|
+
dynamic: true,
|
|
541
|
+
concatenated: false,
|
|
542
|
+
literal: false,
|
|
543
|
+
parameterizedTag: false,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function sqlMethod(ts, call) {
|
|
547
|
+
if (ts.isPropertyAccessExpression(call.expression)) {
|
|
548
|
+
const method = call.expression.name.text;
|
|
549
|
+
return SQL_METHODS.has(method)
|
|
550
|
+
? { method, receiver: call.expression.expression.getText() }
|
|
551
|
+
: undefined;
|
|
552
|
+
}
|
|
553
|
+
if (ts.isElementAccessExpression(call.expression) &&
|
|
554
|
+
call.expression.argumentExpression !== undefined &&
|
|
555
|
+
ts.isStringLiteral(call.expression.argumentExpression)) {
|
|
556
|
+
const method = call.expression.argumentExpression.text;
|
|
557
|
+
return SQL_METHODS.has(method)
|
|
558
|
+
? { method, receiver: call.expression.expression.getText() }
|
|
559
|
+
: undefined;
|
|
560
|
+
}
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
|
|
564
|
+
/** Scan one file's text for SQL assembled by interpolation. Pure + line-accurate. */
|
|
565
|
+
export function scanInterpolatedSql(file, content, ts) {
|
|
566
|
+
const out = [];
|
|
567
|
+
const lines = content.split("\n");
|
|
568
|
+
const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS;
|
|
569
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
|
|
570
|
+
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
571
|
+
// misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
|
|
572
|
+
const parseDiagnostics = source.parseDiagnostics;
|
|
573
|
+
if (parseDiagnostics !== undefined && parseDiagnostics.length > 0)
|
|
574
|
+
return out;
|
|
575
|
+
const visit = (node) => {
|
|
576
|
+
if (ts.isCallExpression(node)) {
|
|
577
|
+
const sink = sqlMethod(ts, node);
|
|
578
|
+
const argument = node.arguments[0];
|
|
579
|
+
if (sink !== undefined && argument !== undefined) {
|
|
580
|
+
const shape = sqlExpressionShape(ts, argument);
|
|
581
|
+
const unsafeEscape = ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag;
|
|
582
|
+
const assembledSql = shape.dynamic &&
|
|
583
|
+
(SQL_KEYWORD.test(shape.staticText) ||
|
|
584
|
+
(shape.concatenated && SQL_RECEIVER.test(sink.receiver)));
|
|
585
|
+
if (unsafeEscape || assembledSql) {
|
|
586
|
+
const index = node.getStart(source);
|
|
587
|
+
const line = source.getLineAndCharacterOfPosition(index).line + 1;
|
|
588
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
ts.forEachChild(node, visit);
|
|
593
|
+
};
|
|
594
|
+
visit(source);
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Collect interpolated-SQL findings across the project.
|
|
599
|
+
*
|
|
600
|
+
* Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
|
|
601
|
+
* with the compiler, so without it there is nothing to report and, more to the point, no basis for
|
|
602
|
+
* reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
|
|
603
|
+
* answer a security rule must never give when it did not run.
|
|
604
|
+
*/
|
|
605
|
+
export async function scanProjectSql(cwd) {
|
|
606
|
+
const ts = await importTypeScript();
|
|
607
|
+
if (ts === undefined)
|
|
608
|
+
return undefined;
|
|
609
|
+
const out = [];
|
|
610
|
+
await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts)));
|
|
611
|
+
return out.sort(bySite);
|
|
612
|
+
}
|
|
613
|
+
// `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
|
|
446
614
|
// the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
|
|
447
615
|
// `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
|
|
448
|
-
// contract form `client(contract, url)` starts with an identifier, not a quote
|
|
616
|
+
// contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
|
|
449
617
|
const CLIENT_CALL = /(?<![.\w])client\s*\(/g;
|
|
450
618
|
/** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
|
|
451
619
|
export function scanUntypedClient(file, content) {
|
|
@@ -463,6 +631,55 @@ export function scanUntypedClient(file, content) {
|
|
|
463
631
|
}
|
|
464
632
|
return out;
|
|
465
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* Specifiers that no longer exist, and what replaced them.
|
|
636
|
+
*
|
|
637
|
+
* These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
|
|
638
|
+
* was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
|
|
639
|
+
* the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
|
|
640
|
+
* into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
|
|
641
|
+
* that names neither the cause nor the replacement.
|
|
642
|
+
*
|
|
643
|
+
* A lint is the right shape for this: it runs before boot, names the file and line, and states the
|
|
644
|
+
* replacement - which is what neither the resolver error nor the runtime failure does.
|
|
645
|
+
*/
|
|
646
|
+
const REMOVED_IMPORTS = [
|
|
647
|
+
{
|
|
648
|
+
specifier: "@nifrajs/budget",
|
|
649
|
+
since: "2.0",
|
|
650
|
+
replacement: 'import from "@nifrajs/core/budget" - the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
specifier: "@nifrajs/core/ws",
|
|
654
|
+
since: "2.0",
|
|
655
|
+
sideEffectOnly: true,
|
|
656
|
+
replacement: 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
|
|
657
|
+
},
|
|
658
|
+
];
|
|
659
|
+
/** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
|
|
660
|
+
* one with bindings (`import { y } from "x"`). */
|
|
661
|
+
function isSideEffectImport(content, index) {
|
|
662
|
+
// `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
|
|
663
|
+
// a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
|
|
664
|
+
// no `from` in between.
|
|
665
|
+
return /^import\s*["'`]/.test(content.slice(index, index + 32));
|
|
666
|
+
}
|
|
667
|
+
/** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
|
|
668
|
+
* of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
|
|
669
|
+
export function scanRemovedImports(file, content) {
|
|
670
|
+
const out = [];
|
|
671
|
+
const lines = content.split("\n");
|
|
672
|
+
for (const edge of staticImportEdges(content)) {
|
|
673
|
+
const removed = REMOVED_IMPORTS.find((entry) => edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`));
|
|
674
|
+
if (removed === undefined)
|
|
675
|
+
continue;
|
|
676
|
+
if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index))
|
|
677
|
+
continue;
|
|
678
|
+
const line = lineAt(content, edge.index);
|
|
679
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() });
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
466
683
|
/** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
|
|
467
684
|
* `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
|
|
468
685
|
* the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
|
|
@@ -486,7 +703,7 @@ export function scanServerOnlyImports(file, content) {
|
|
|
486
703
|
return out;
|
|
487
704
|
}
|
|
488
705
|
// ---------------------------------------------------------------------------------------------------
|
|
489
|
-
// #4.4
|
|
706
|
+
// #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
|
|
490
707
|
// pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
|
|
491
708
|
// `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
|
|
492
709
|
// `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
|
|
@@ -494,24 +711,24 @@ export function scanServerOnlyImports(file, content) {
|
|
|
494
711
|
// local module graph, and build the shortest chain to a server-only SINK. It's best-effort: an import
|
|
495
712
|
// that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
|
|
496
713
|
// ---------------------------------------------------------------------------------------------------
|
|
497
|
-
// Server-only specifiers that are TERMINAL sinks
|
|
714
|
+
// Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
|
|
498
715
|
// npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
|
|
499
|
-
// as SERVER_ONLY, minus the relative `../db` arm
|
|
716
|
+
// as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
|
|
500
717
|
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/;
|
|
501
718
|
// The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
|
|
502
719
|
const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/;
|
|
503
|
-
// The explicit poison-import marker (`@nifrajs/web/server-only`)
|
|
720
|
+
// The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
|
|
504
721
|
// guard. A resolved file whose source carries this side-effect import is a server-only sink.
|
|
505
722
|
const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/;
|
|
506
723
|
// Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
|
|
507
724
|
// few hops in practice; the bound stops a pathological graph from blowing up the per-file scan.
|
|
508
725
|
const TRANSITIVE_MAX_DEPTH = 8;
|
|
509
726
|
const TRANSITIVE_MAX_VISITED = 200;
|
|
510
|
-
/** A relative module specifier (`./x`, `../y`)
|
|
727
|
+
/** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
|
|
511
728
|
* is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
|
|
512
729
|
const isRelativeSpecifier = (spec) => spec.startsWith("./") || spec.startsWith("../");
|
|
513
|
-
// A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT
|
|
514
|
-
// per-route loop and the inner BFS both scan imports
|
|
730
|
+
// A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
|
|
731
|
+
// per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
|
|
515
732
|
// `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
|
|
516
733
|
// loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
|
|
517
734
|
const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags);
|
|
@@ -538,7 +755,7 @@ export function parseStaticImports(content) {
|
|
|
538
755
|
return staticImportEdges(content).map((e) => e.specifier);
|
|
539
756
|
}
|
|
540
757
|
/** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
|
|
541
|
-
* server-only package), or `undefined` if it isn't a by-name sink. Pure
|
|
758
|
+
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
542
759
|
* itself (it's already the actionable name). */
|
|
543
760
|
export function directSinkSpecifier(spec) {
|
|
544
761
|
return SERVER_ONLY_SINK.test(spec) ? spec : undefined;
|
|
@@ -551,7 +768,7 @@ export function directSinkSpecifier(spec) {
|
|
|
551
768
|
* unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
|
|
552
769
|
* `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
|
|
553
770
|
* it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
|
|
554
|
-
*
|
|
771
|
+
* - pure given those, so it's unit-testable with a fake graph.
|
|
555
772
|
*/
|
|
556
773
|
export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
|
|
557
774
|
const seen = new Set([routeFile]);
|
|
@@ -569,13 +786,13 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
|
|
|
569
786
|
if (sink !== undefined)
|
|
570
787
|
return [...node.chain, sink];
|
|
571
788
|
// (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
|
|
572
|
-
// specifier that isn't a known sink is a leaf
|
|
789
|
+
// specifier that isn't a known sink is a leaf - don't follow it into node_modules.
|
|
573
790
|
if (!isRelativeSpecifier(spec))
|
|
574
791
|
continue;
|
|
575
792
|
const abs = resolve(node.abs, spec);
|
|
576
793
|
if (abs === undefined || seen.has(abs))
|
|
577
794
|
continue;
|
|
578
|
-
// (c) A resolved `*.server` module is a server-only sink by the `.server` convention
|
|
795
|
+
// (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
|
|
579
796
|
// ends at it (named by the as-written specifier).
|
|
580
797
|
if (SERVER_MODULE_FILE.test(abs))
|
|
581
798
|
return [...node.chain, spec];
|
|
@@ -599,8 +816,8 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
|
|
|
599
816
|
/**
|
|
600
817
|
* Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
|
|
601
818
|
* reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
|
|
602
|
-
* finding carries the full chain; otherwise
|
|
603
|
-
* regex scan already flags, or when a relative import can't be resolved
|
|
819
|
+
* finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
|
|
820
|
+
* regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
|
|
604
821
|
* edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
|
|
605
822
|
* de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
|
|
606
823
|
*/
|
|
@@ -618,21 +835,21 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
|
|
|
618
835
|
continue;
|
|
619
836
|
const line = lineAt(content, index);
|
|
620
837
|
const snippet = (lines[line - 1] ?? "").trim();
|
|
621
|
-
// (1) A direct by-name sink (`node:fs`, `postgres`)
|
|
838
|
+
// (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
|
|
622
839
|
// direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
|
|
623
840
|
if (directSinkSpecifier(specifier) !== undefined) {
|
|
624
841
|
flaggedSpecifiers.add(specifier);
|
|
625
842
|
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false });
|
|
626
843
|
continue;
|
|
627
844
|
}
|
|
628
|
-
// (2) A relative local import
|
|
845
|
+
// (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
|
|
629
846
|
// a sink, emit the full chain rooted at THIS route's import line.
|
|
630
847
|
if (isRelativeSpecifier(specifier)) {
|
|
631
848
|
const abs = resolve(file, specifier);
|
|
632
849
|
if (abs === undefined) {
|
|
633
850
|
// Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
|
|
634
|
-
// flags directly, fall back to the direct edge (precise resolve impossible
|
|
635
|
-
// `fallback`). Any other unresolvable relative import we can't assert is server-only
|
|
851
|
+
// flags directly, fall back to the direct edge (precise resolve impossible - say so via
|
|
852
|
+
// `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
|
|
636
853
|
if (SERVER_ONLY.test(specifier)) {
|
|
637
854
|
flaggedSpecifiers.add(specifier);
|
|
638
855
|
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true });
|
|
@@ -664,7 +881,7 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
|
|
|
664
881
|
continue;
|
|
665
882
|
}
|
|
666
883
|
// (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
|
|
667
|
-
// found no deeper sink (e.g. the file wasn't readable past the first hop)
|
|
884
|
+
// found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
|
|
668
885
|
// direct edge so we still surface the known-server-only convention rather than going silent.
|
|
669
886
|
if (SERVER_ONLY.test(specifier)) {
|
|
670
887
|
flaggedSpecifiers.add(specifier);
|
|
@@ -675,7 +892,7 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
|
|
|
675
892
|
return out;
|
|
676
893
|
}
|
|
677
894
|
// A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
|
|
678
|
-
// typed client infer `data: never` (`Jsonify<Response>` is `never`)
|
|
895
|
+
// typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
|
|
679
896
|
// silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
|
|
680
897
|
// (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
|
|
681
898
|
const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g;
|
|
@@ -711,12 +928,12 @@ const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response";
|
|
|
711
928
|
/** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
|
|
712
929
|
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
|
|
713
930
|
/**
|
|
714
|
-
* Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call
|
|
931
|
+
* Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
|
|
715
932
|
* (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
|
|
716
933
|
* is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
|
|
717
|
-
* that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full
|
|
934
|
+
* that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
|
|
718
935
|
* findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
|
|
719
|
-
* repo / git errors, so the scan simply degrades to the {@link IGNORED} regex
|
|
936
|
+
* repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
|
|
720
937
|
*/
|
|
721
938
|
async function gitIgnored(cwd, rels) {
|
|
722
939
|
if (rels.length === 0)
|
|
@@ -743,7 +960,7 @@ async function gitIgnored(cwd, rels) {
|
|
|
743
960
|
}
|
|
744
961
|
}
|
|
745
962
|
export async function walkSource(cwd, visit) {
|
|
746
|
-
// List candidates first (cheap
|
|
963
|
+
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
747
964
|
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
748
965
|
const rels = [];
|
|
749
966
|
for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
|
|
@@ -764,14 +981,14 @@ export async function scanProject(cwd) {
|
|
|
764
981
|
await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)));
|
|
765
982
|
return out.sort(bySite);
|
|
766
983
|
}
|
|
767
|
-
// #7
|
|
984
|
+
// #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
|
|
768
985
|
// list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
|
|
769
|
-
// regenerated, the worker serves a stale route table
|
|
986
|
+
// regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
|
|
770
987
|
// We diff each committed manifest's imported route files against the live `routes/` tree.
|
|
771
|
-
// The marker comment `generateServerManifest` emits at the top of the file
|
|
988
|
+
// The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
|
|
772
989
|
// manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
|
|
773
990
|
const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest";
|
|
774
|
-
// The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`)
|
|
991
|
+
// The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
|
|
775
992
|
// locate the routes dir relative to the manifest, and to strip to route-relative keys.
|
|
776
993
|
const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/;
|
|
777
994
|
// Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
|
|
@@ -792,13 +1009,13 @@ export async function scanServerManifestDrift(cwd) {
|
|
|
792
1009
|
continue;
|
|
793
1010
|
const source = await Bun.file(join(cwd, rel)).text();
|
|
794
1011
|
if (!source.includes(GENERATED_MARKER))
|
|
795
|
-
continue; // not a generated manifest
|
|
1012
|
+
continue; // not a generated manifest - skip
|
|
796
1013
|
const prefixMatch = ROUTES_PREFIX.exec(source);
|
|
797
1014
|
if (prefixMatch?.[1] === undefined)
|
|
798
1015
|
continue; // no `routes/` imports → nothing to diff (empty app)
|
|
799
1016
|
const routesPrefix = prefixMatch[1];
|
|
800
1017
|
const manifestFiles = parseManifestRouteFiles(source, routesPrefix);
|
|
801
|
-
// The routes dir sits at `<manifest dir>/<prefix>`
|
|
1018
|
+
// The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
|
|
802
1019
|
const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
803
1020
|
const routesDir = join(cwd, manifestDir, routesPrefix);
|
|
804
1021
|
let discovered;
|
|
@@ -808,7 +1025,7 @@ export async function scanServerManifestDrift(cwd) {
|
|
|
808
1025
|
.filter((f) => ROUTE_FILE_EXT.test(f));
|
|
809
1026
|
}
|
|
810
1027
|
catch {
|
|
811
|
-
continue; // routes dir gone/unreadable
|
|
1028
|
+
continue; // routes dir gone/unreadable - not a drift we can assess
|
|
812
1029
|
}
|
|
813
1030
|
const drift = diffManifestRoutes(manifestFiles, discovered);
|
|
814
1031
|
if (!isManifestInSync(drift)) {
|
|
@@ -886,14 +1103,17 @@ async function loadCheckConfig(cwd) {
|
|
|
886
1103
|
};
|
|
887
1104
|
}
|
|
888
1105
|
}
|
|
889
|
-
const UNTYPED_CLIENT_HINT = 'client("…") without a type argument
|
|
890
|
-
const FETCH_HINT = "hand-rolled fetch() to your own API
|
|
891
|
-
const SERVER_IMPORT_HINT = "server-only import in a route module (bundled for the browser)
|
|
1106
|
+
const UNTYPED_CLIENT_HINT = 'client("…") without a type argument - write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift';
|
|
1107
|
+
const FETCH_HINT = "hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift";
|
|
1108
|
+
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";
|
|
892
1109
|
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); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this";
|
|
893
|
-
const UNDECLARED_DEP_HINT = "imported package is not declared in package.json dependencies
|
|
894
|
-
const
|
|
895
|
-
const
|
|
896
|
-
const
|
|
1110
|
+
const UNDECLARED_DEP_HINT = "imported package is not declared in package.json dependencies - run bun add to declare it";
|
|
1111
|
+
const SQL_COMPILER_MISSING_HINT = "the interpolated-SQL rule did NOT run - it parses source with the TypeScript compiler, which is an optional peer and is not installed here. This report says nothing about SQL injection either way. Install it with `bun add -d typescript`";
|
|
1112
|
+
const SQL_INTERPOLATION_EXAMPLE = "$" + "{value}";
|
|
1113
|
+
const INTERPOLATED_SQL_HINT = `SQL built by interpolating a value into the statement text - the value becomes statement, not a parameter, so anything the caller controls can end the literal and continue as SQL. Pass it as a bound parameter (\`?\` / \`$1\` and an argument), or use your driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`), which binds the substitutions for you`;
|
|
1114
|
+
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";
|
|
1115
|
+
const TRUST_MANIFEST_DRIFT_HINT = "nifra.manifest.json is missing, invalid, or out of sync - run `nifra manifest emit`, review it, and commit the regenerated trust artifact";
|
|
1116
|
+
const CAPABILITY_HINT = "effect/capability assurance failed - align the route declaration with approved adapter provenance; never bypass an owned effect seam";
|
|
897
1117
|
function oneLineDiff(file, line, before, after) {
|
|
898
1118
|
return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`;
|
|
899
1119
|
}
|
|
@@ -986,10 +1206,10 @@ function ownFetchSuggestion(f, routes) {
|
|
|
986
1206
|
function serverImportSuggestion(specifier, chain, fallback) {
|
|
987
1207
|
const sink = chain[chain.length - 1] ?? specifier;
|
|
988
1208
|
// Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
|
|
989
|
-
// ../db → node:crypto`) and which module to cut
|
|
1209
|
+
// ../db → node:crypto`) and which module to cut - not just the route's own top-level import.
|
|
990
1210
|
const chainStep = chain.length > 2
|
|
991
1211
|
? fallback
|
|
992
|
-
? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely
|
|
1212
|
+
? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely - trace it from \`${specifier}\`).`
|
|
993
1213
|
: `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.`
|
|
994
1214
|
: undefined;
|
|
995
1215
|
return {
|
|
@@ -1020,16 +1240,22 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1020
1240
|
const fetches = [];
|
|
1021
1241
|
const staticRoutes = [];
|
|
1022
1242
|
const untypedClients = [];
|
|
1243
|
+
const removedImports = [];
|
|
1023
1244
|
const serverImports = [];
|
|
1024
1245
|
const responseRoutes = [];
|
|
1246
|
+
const interpolatedSql = [];
|
|
1025
1247
|
// Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
|
|
1026
|
-
// (#4.4)
|
|
1248
|
+
// (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
|
|
1027
1249
|
const routeModules = [];
|
|
1028
1250
|
// Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
|
|
1029
1251
|
// walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
|
|
1030
1252
|
const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd);
|
|
1253
|
+
// The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
|
|
1254
|
+
// once, before the walk, so a project without it is told the rule did not run rather than being
|
|
1255
|
+
// handed a clean report the rule never produced.
|
|
1256
|
+
const sqlCompiler = await (opts.loadTypeScript ?? importTypeScript)();
|
|
1031
1257
|
// lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
|
|
1032
|
-
// lints
|
|
1258
|
+
// lints - the agent inner-loop mode; the full gate stays the definition of done.
|
|
1033
1259
|
const [tc, _, dr, manifestDrift] = await Promise.all([
|
|
1034
1260
|
opts.lintsOnly
|
|
1035
1261
|
? Promise.resolve({ ran: false, ok: true, note: "skipped (lintsOnly)" })
|
|
@@ -1038,9 +1264,12 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1038
1264
|
fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts));
|
|
1039
1265
|
staticRoutes.push(...scanStaticRouteText(rel, content));
|
|
1040
1266
|
untypedClients.push(...scanUntypedClient(rel, content));
|
|
1267
|
+
removedImports.push(...scanRemovedImports(rel, content));
|
|
1041
1268
|
if (ROUTE_FILE.test(rel))
|
|
1042
1269
|
routeModules.push({ rel, content });
|
|
1043
1270
|
responseRoutes.push(...scanResponseRoutes(rel, content));
|
|
1271
|
+
if (sqlCompiler !== undefined)
|
|
1272
|
+
interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler));
|
|
1044
1273
|
}),
|
|
1045
1274
|
import("./doctor.js").then((m) => m.collectDoctorResult(cwd)),
|
|
1046
1275
|
scanServerManifestDrift(cwd),
|
|
@@ -1053,7 +1282,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1053
1282
|
const resolveModule = (fromFile, specifier) => {
|
|
1054
1283
|
try {
|
|
1055
1284
|
// `fromFile` is a cwd-RELATIVE route path on the first hop (`routes/x.tsx`) but ABSOLUTE on the
|
|
1056
|
-
// deeper hops the walk takes (it carries resolved absolute paths)
|
|
1285
|
+
// deeper hops the walk takes (it carries resolved absolute paths) - resolve the dir for each.
|
|
1057
1286
|
const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile);
|
|
1058
1287
|
return Bun.resolveSync(specifier, dirname(fromAbs));
|
|
1059
1288
|
}
|
|
@@ -1126,25 +1355,37 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1126
1355
|
severity: "error",
|
|
1127
1356
|
file: f.file,
|
|
1128
1357
|
line: f.line,
|
|
1129
|
-
message: `${f.snippet}
|
|
1358
|
+
message: `${f.snippet} - ${FETCH_HINT}`,
|
|
1130
1359
|
fix: FETCH_HINT,
|
|
1131
1360
|
suggestion: ownFetchSuggestion(f, routes),
|
|
1132
1361
|
});
|
|
1133
1362
|
}
|
|
1363
|
+
for (const f of removedImports.sort(bySite)) {
|
|
1364
|
+
const entry = REMOVED_IMPORTS.find((candidate) => f.snippet.includes(`"${candidate.specifier}`) ||
|
|
1365
|
+
f.snippet.includes(`'${candidate.specifier}`));
|
|
1366
|
+
diagnostics.push({
|
|
1367
|
+
rule: "removed-import",
|
|
1368
|
+
severity: "error",
|
|
1369
|
+
file: f.file,
|
|
1370
|
+
line: f.line,
|
|
1371
|
+
message: `${f.snippet} - removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
|
|
1372
|
+
fix: entry?.replacement ?? "see the changelog",
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1134
1375
|
for (const f of untypedClients.sort(bySite)) {
|
|
1135
1376
|
diagnostics.push({
|
|
1136
1377
|
rule: "untyped-client",
|
|
1137
1378
|
severity: "error",
|
|
1138
1379
|
file: f.file,
|
|
1139
1380
|
line: f.line,
|
|
1140
|
-
message: `${f.snippet}
|
|
1381
|
+
message: `${f.snippet} - ${UNTYPED_CLIENT_HINT}`,
|
|
1141
1382
|
fix: UNTYPED_CLIENT_HINT,
|
|
1142
1383
|
suggestion: untypedClientSuggestion(f),
|
|
1143
1384
|
});
|
|
1144
1385
|
}
|
|
1145
1386
|
for (const f of serverImports.sort(bySite)) {
|
|
1146
|
-
// #4.4: the FULL transitive chain the import-resolution walk found
|
|
1147
|
-
// node:crypto`, matching the build leak-guard's depth
|
|
1387
|
+
// #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
|
|
1388
|
+
// node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
|
|
1148
1389
|
// tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
|
|
1149
1390
|
// (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
|
|
1150
1391
|
const chain = f.chain;
|
|
@@ -1154,20 +1395,55 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1154
1395
|
severity: "error",
|
|
1155
1396
|
file: f.file,
|
|
1156
1397
|
line: f.line,
|
|
1157
|
-
message: `${f.snippet}
|
|
1398
|
+
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}`,
|
|
1158
1399
|
fix: SERVER_IMPORT_HINT,
|
|
1159
1400
|
chain,
|
|
1160
1401
|
suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
|
|
1161
1402
|
});
|
|
1162
1403
|
}
|
|
1163
|
-
|
|
1404
|
+
if (sqlCompiler === undefined) {
|
|
1405
|
+
// A security rule that could not run must say so. Silence here is indistinguishable from a clean
|
|
1406
|
+
// result, and this is the one rule whose clean result means "no SQL injection was found".
|
|
1407
|
+
diagnostics.push({
|
|
1408
|
+
rule: "interpolated-sql",
|
|
1409
|
+
severity: "warning",
|
|
1410
|
+
message: SQL_COMPILER_MISSING_HINT,
|
|
1411
|
+
fix: SQL_COMPILER_MISSING_HINT,
|
|
1412
|
+
suggestion: {
|
|
1413
|
+
kind: "command",
|
|
1414
|
+
title: "Install TypeScript so the SQL rule can run",
|
|
1415
|
+
command: ["bun add -d typescript"],
|
|
1416
|
+
},
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
for (const f of interpolatedSql.sort(bySite)) {
|
|
1420
|
+
diagnostics.push({
|
|
1421
|
+
rule: "interpolated-sql",
|
|
1422
|
+
severity: "error",
|
|
1423
|
+
file: f.file,
|
|
1424
|
+
line: f.line,
|
|
1425
|
+
message: `${f.snippet} - ${INTERPOLATED_SQL_HINT}`,
|
|
1426
|
+
fix: "bind the value as a parameter instead of interpolating it into the statement",
|
|
1427
|
+
suggestion: {
|
|
1428
|
+
kind: "manual",
|
|
1429
|
+
title: "Bind the value instead of interpolating it",
|
|
1430
|
+
steps: [
|
|
1431
|
+
"Replace the interpolation with a placeholder your driver understands (`?` for SQLite/MySQL, `$1` for Postgres).",
|
|
1432
|
+
"Pass the value as an argument alongside the statement, so the driver binds it.",
|
|
1433
|
+
`Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
|
|
1434
|
+
"An identifier that genuinely cannot be bound (a table or column name) must be checked against an allowlist you control, never taken from the request.",
|
|
1435
|
+
],
|
|
1436
|
+
},
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
// Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
|
|
1164
1440
|
for (const f of responseRoutes.sort(bySite)) {
|
|
1165
1441
|
diagnostics.push({
|
|
1166
1442
|
rule: "response-route",
|
|
1167
1443
|
severity: "warning",
|
|
1168
1444
|
file: f.file,
|
|
1169
1445
|
line: f.line,
|
|
1170
|
-
message: `${f.snippet}
|
|
1446
|
+
message: `${f.snippet} - ${RESPONSE_ROUTE_HINT}`,
|
|
1171
1447
|
fix: RESPONSE_ROUTE_HINT,
|
|
1172
1448
|
suggestion: responseRouteSuggestion(),
|
|
1173
1449
|
});
|
|
@@ -1179,7 +1455,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1179
1455
|
severity: "error",
|
|
1180
1456
|
file: f.file,
|
|
1181
1457
|
line: f.line,
|
|
1182
|
-
message: `imports ${f.package} which is not declared in package.json
|
|
1458
|
+
message: `imports ${f.package} which is not declared in package.json - ${UNDECLARED_DEP_HINT}`,
|
|
1183
1459
|
fix: `add ${f.package} to package.json dependencies`,
|
|
1184
1460
|
suggestion: {
|
|
1185
1461
|
kind: "command",
|
|
@@ -1207,7 +1483,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1207
1483
|
});
|
|
1208
1484
|
}
|
|
1209
1485
|
}
|
|
1210
|
-
// #7: a committed server-manifest.ts that drifted from routes/
|
|
1486
|
+
// #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
|
|
1211
1487
|
for (const f of manifestDrift) {
|
|
1212
1488
|
const parts = [];
|
|
1213
1489
|
if (f.missing.length > 0)
|
|
@@ -1218,13 +1494,13 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1218
1494
|
rule: "server-manifest-drift",
|
|
1219
1495
|
severity: "error",
|
|
1220
1496
|
file: f.file,
|
|
1221
|
-
message: `${f.file} drifted from routes/ (${parts.join("; ")})
|
|
1497
|
+
message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
|
|
1222
1498
|
fix: MANIFEST_DRIFT_HINT,
|
|
1223
1499
|
suggestion: {
|
|
1224
1500
|
kind: "manual",
|
|
1225
1501
|
title: "Regenerate the committed server manifest",
|
|
1226
1502
|
steps: [
|
|
1227
|
-
"Re-run your build (`nifra build --target <t>` or your build script)
|
|
1503
|
+
"Re-run your build (`nifra build --target <t>` or your build script) - it regenerates server-manifest.ts from the current routes/.",
|
|
1228
1504
|
"Commit the updated server-manifest.ts.",
|
|
1229
1505
|
],
|
|
1230
1506
|
},
|
|
@@ -1233,25 +1509,50 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1233
1509
|
// G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
|
|
1234
1510
|
// provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
|
|
1235
1511
|
// without nifra.assurance.ts retain the historical scan and hot path unchanged.
|
|
1512
|
+
const provided = opts.assurance;
|
|
1236
1513
|
const assuranceConfigPath = join(cwd, "nifra.assurance.ts");
|
|
1237
|
-
if (existsSync(assuranceConfigPath)) {
|
|
1514
|
+
if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
|
|
1238
1515
|
try {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
let
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1516
|
+
// Either the shared reflection from `collectProjectVerification` (no second pass) or, on the
|
|
1517
|
+
// standalone path, loaded + reflected here. Both branches yield the same config + evidence.
|
|
1518
|
+
let config;
|
|
1519
|
+
let project;
|
|
1520
|
+
let routeAssurance;
|
|
1521
|
+
if (provided !== undefined) {
|
|
1522
|
+
// A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
|
|
1523
|
+
// capability-config diagnostic the standalone catch produces.
|
|
1524
|
+
if (provided.error !== undefined)
|
|
1525
|
+
throw provided.error;
|
|
1526
|
+
config = provided.config;
|
|
1527
|
+
project = provided.capability;
|
|
1528
|
+
routeAssurance = provided.routeAssurance;
|
|
1529
|
+
}
|
|
1530
|
+
else {
|
|
1531
|
+
const { loadAssuranceConfig } = await import("./assure.js");
|
|
1532
|
+
config = await loadAssuranceConfig(cwd);
|
|
1533
|
+
if (config.capabilities !== undefined) {
|
|
1534
|
+
const { collectCapabilityProjectReport } = await import("./capabilities-tool.js");
|
|
1535
|
+
project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const capabilityReport = project?.report;
|
|
1539
|
+
if (config.capabilities !== undefined && project !== undefined) {
|
|
1246
1540
|
for (const finding of project.report.findings) {
|
|
1247
1541
|
const violation = finding.code === "forbidden-effect-import"
|
|
1248
1542
|
? project.violations.find((candidate) => candidate.method === finding.method && candidate.path === finding.path)
|
|
1249
1543
|
: undefined;
|
|
1544
|
+
const truncation = finding.code === "provenance-truncated"
|
|
1545
|
+
? project.truncations.find((candidate) => candidate.method === finding.method && candidate.path === finding.path)
|
|
1546
|
+
: undefined;
|
|
1250
1547
|
diagnostics.push({
|
|
1251
1548
|
rule: "capability-assurance",
|
|
1252
1549
|
severity: "error",
|
|
1253
|
-
...(violation !== undefined
|
|
1254
|
-
|
|
1550
|
+
...(violation !== undefined
|
|
1551
|
+
? { file: violation.module, chain: violation.chain }
|
|
1552
|
+
: truncation !== undefined
|
|
1553
|
+
? { chain: truncation.chain }
|
|
1554
|
+
: {}),
|
|
1555
|
+
message: `${finding.message} - ${CAPABILITY_HINT}`,
|
|
1255
1556
|
fix: CAPABILITY_HINT,
|
|
1256
1557
|
suggestion: {
|
|
1257
1558
|
kind: "manual",
|
|
@@ -1259,7 +1560,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1259
1560
|
steps: [
|
|
1260
1561
|
"Route effectful work through an import listed in capabilities.provenance.imports.",
|
|
1261
1562
|
"Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
|
|
1262
|
-
"For domain writes, add the
|
|
1563
|
+
"For domain writes, add the adapter the capability definition requires: `schema.idempotency` for the `request` tier, `.use(durableCommand({ journal }))` from @nifrajs/middleware for the `durable` tier.",
|
|
1263
1564
|
"Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
|
|
1264
1565
|
],
|
|
1265
1566
|
},
|
|
@@ -1267,9 +1568,13 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1267
1568
|
}
|
|
1268
1569
|
}
|
|
1269
1570
|
if (config.manifest !== undefined) {
|
|
1270
|
-
const { evaluateRouteAssurance } = await import("@nifrajs/core/assurance");
|
|
1271
1571
|
const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import("@nifrajs/core/manifest");
|
|
1272
|
-
const assurance =
|
|
1572
|
+
const assurance = routeAssurance ??
|
|
1573
|
+
(await import("@nifrajs/core/assurance")).evaluateRouteAssurance(config.source, config.policy, {
|
|
1574
|
+
...(config.capabilities !== undefined
|
|
1575
|
+
? { definitions: config.capabilities.definitions }
|
|
1576
|
+
: {}),
|
|
1577
|
+
});
|
|
1273
1578
|
const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json");
|
|
1274
1579
|
let message;
|
|
1275
1580
|
if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
|
|
@@ -1302,7 +1607,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1302
1607
|
rule: "manifest-drift",
|
|
1303
1608
|
severity: "error",
|
|
1304
1609
|
file: path,
|
|
1305
|
-
message: `${message}
|
|
1610
|
+
message: `${message} - ${TRUST_MANIFEST_DRIFT_HINT}`,
|
|
1306
1611
|
fix: TRUST_MANIFEST_DRIFT_HINT,
|
|
1307
1612
|
suggestion: {
|
|
1308
1613
|
kind: "command",
|
|
@@ -1334,7 +1639,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1334
1639
|
}
|
|
1335
1640
|
}
|
|
1336
1641
|
// Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
|
|
1337
|
-
// message that breaks the stdio transport. `ok` reflects the FULL set
|
|
1642
|
+
// message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
|
|
1338
1643
|
const total = diagnostics.length;
|
|
1339
1644
|
const max = opts.maxDiagnostics;
|
|
1340
1645
|
const shown = max !== undefined && total > max ? diagnostics.slice(0, max) : diagnostics;
|
|
@@ -1350,7 +1655,10 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1350
1655
|
}
|
|
1351
1656
|
/** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
|
|
1352
1657
|
export async function runCheck(cwd, opts = {}) {
|
|
1353
|
-
|
|
1658
|
+
// The check view over the one project verification: the same collector `assure` and `levels` read.
|
|
1659
|
+
const { collectProjectVerification } = await import("./verification.js");
|
|
1660
|
+
const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false });
|
|
1661
|
+
const result = await verification.check();
|
|
1354
1662
|
if (opts.json) {
|
|
1355
1663
|
console.log(JSON.stringify(result, null, 2));
|
|
1356
1664
|
return result.ok;
|
|
@@ -1359,7 +1667,7 @@ export async function runCheck(cwd, opts = {}) {
|
|
|
1359
1667
|
console.log(result.typecheck === "pass"
|
|
1360
1668
|
? "✓ typecheck passed"
|
|
1361
1669
|
: result.typecheck === "fail"
|
|
1362
|
-
? "✗ typecheck failed
|
|
1670
|
+
? "✗ typecheck failed - the frontend/backend contract is broken"
|
|
1363
1671
|
: "• typecheck skipped (no tsconfig / typescript not installed)");
|
|
1364
1672
|
if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
|
|
1365
1673
|
console.log(`• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`);
|
|
@@ -1370,6 +1678,7 @@ export async function runCheck(cwd, opts = {}) {
|
|
|
1370
1678
|
["typed-client", "hand-rolled fetch() to your own API"],
|
|
1371
1679
|
["untyped-client", 'client("…") missing its <typeof app> type argument'],
|
|
1372
1680
|
["server-only-import", "server-only import in a route module"],
|
|
1681
|
+
["interpolated-sql", "SQL built by interpolating a value into the statement"],
|
|
1373
1682
|
["response-route", "route returns a raw Response (typed client → data: never)"],
|
|
1374
1683
|
["undeclared-dependency", "undeclared dependency in package.json"],
|
|
1375
1684
|
["duplicate-install", "duplicate identity-sensitive dependency install"],
|
|
@@ -1412,6 +1721,14 @@ export async function runCheck(cwd, opts = {}) {
|
|
|
1412
1721
|
if (!existsSync(join(cwd, ".mcp.json"))) {
|
|
1413
1722
|
console.log("\ntip: no .mcp.json here - run `nifra init-agents` to wire this project's MCP + agent files (no-clobber).");
|
|
1414
1723
|
}
|
|
1724
|
+
// `public/` used to be served in dev (inherited from Vite) and not in production, so every app
|
|
1725
|
+
// hand-rolled static serving in its own server entry - or shipped a file that 404'd only once
|
|
1726
|
+
// deployed. `@nifrajs/web` owns it now, so an app carrying the workaround can delete it. A tip
|
|
1727
|
+
// rather than a finding: a hand-rolled handler still works (it simply runs first), so this is
|
|
1728
|
+
// not a failure, and telling an app it can delete code is the entire point.
|
|
1729
|
+
if (existsSync(join(cwd, "public"))) {
|
|
1730
|
+
console.log('\ntip: `public/` is now served by @nifrajs/web in dev AND production (publicDir, default "public"). If your server entry hand-rolls static serving, you can delete it.');
|
|
1731
|
+
}
|
|
1415
1732
|
return result.ok;
|
|
1416
1733
|
}
|
|
1417
1734
|
//# sourceMappingURL=check.js.map
|