@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/src/check.ts
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,7 +18,11 @@
|
|
|
18
18
|
|
|
19
19
|
import { existsSync, readFileSync } from "node:fs"
|
|
20
20
|
import { dirname, isAbsolute, join, resolve } from "node:path"
|
|
21
|
+
import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
|
|
21
22
|
import { Glob } from "bun"
|
|
23
|
+
import type * as TSApi from "typescript"
|
|
24
|
+
import type { CapabilityProjectReport } from "./capabilities-tool.ts"
|
|
25
|
+
import { importTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
|
|
22
26
|
|
|
23
27
|
export interface SourceFinding {
|
|
24
28
|
readonly file: string
|
|
@@ -32,10 +36,10 @@ export interface ServerImportFinding extends SourceFinding {
|
|
|
32
36
|
readonly specifier: string
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
// `fetch( <ws> ('|"|`) / (not /)`
|
|
39
|
+
// `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
|
|
36
40
|
// i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
|
|
37
41
|
// `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
|
|
38
|
-
// alone on purpose
|
|
42
|
+
// alone on purpose - undecidable from source, and flagging it would punish legitimate external calls.
|
|
39
43
|
const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
40
44
|
const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
41
45
|
const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
|
@@ -46,7 +50,7 @@ const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
|
|
|
46
50
|
const IGNORED =
|
|
47
51
|
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$/
|
|
48
52
|
|
|
49
|
-
// A file under `routes/`
|
|
53
|
+
// A file under `routes/` - a page module bundled for the browser, where a server-only import is unsafe.
|
|
50
54
|
const ROUTE_FILE = /(^|\/)routes\//
|
|
51
55
|
|
|
52
56
|
// Module specifiers that must never be VALUE-imported into a route module: node:/bun: builtins, common
|
|
@@ -224,13 +228,13 @@ function scanRoutePattern(
|
|
|
224
228
|
}
|
|
225
229
|
|
|
226
230
|
/**
|
|
227
|
-
* Blank, with spaces (newlines preserved, so every byte offset
|
|
231
|
+
* Blank, with spaces (newlines preserved, so every byte offset - and line number - is unchanged):
|
|
228
232
|
* - `//` line comments and block comments;
|
|
229
|
-
* - the CONTENTS of backtick template literals
|
|
233
|
+
* - the CONTENTS of backtick template literals - code-as-text (doc `CodeBlock` examples, code
|
|
230
234
|
* generators), never a real statement to lint.
|
|
231
235
|
* Single/double-quoted strings are KEPT (a real import/Response specifier lives in one). A small
|
|
232
236
|
* char-state machine, not a full lexer: it doesn't model regex literals, so a regex containing a quote
|
|
233
|
-
* could mis-skip
|
|
237
|
+
* could mis-skip - in practice the constructs these scanners look for sit before any such regex.
|
|
234
238
|
* Shared by the source scanners here and by `nifra doctor` ({@link ./doctor.ts}).
|
|
235
239
|
*/
|
|
236
240
|
export function stripComments(src: string): string {
|
|
@@ -449,10 +453,203 @@ export function scanStaticRouteText(file: string, content: string): StaticRouteF
|
|
|
449
453
|
].sort(bySite)
|
|
450
454
|
}
|
|
451
455
|
|
|
452
|
-
|
|
456
|
+
/**
|
|
457
|
+
* SQL built by interpolating a value into the statement text. The value is not a parameter, it is
|
|
458
|
+
* STATEMENT, so anything the caller controls can end the literal and continue as SQL.
|
|
459
|
+
*
|
|
460
|
+
* Two things this deliberately does not flag, because both are safe and flagging them would train
|
|
461
|
+
* people to ignore the rule:
|
|
462
|
+
*
|
|
463
|
+
* - A TAGGED template - ``sql`... ${id} ...` `` in postgres.js, drizzle or kysely. The tag receives the
|
|
464
|
+
* substitutions separately and binds them; that is the parameterised form, not a bypass of it.
|
|
465
|
+
* - A literal with no substitution at all. `db.query("SELECT … WHERE id = ?")` is the shape being
|
|
466
|
+
* argued for.
|
|
467
|
+
*
|
|
468
|
+
* A SQL keyword is required in the literal, so `cache.query(`user:${id}`)` stays quiet. The named
|
|
469
|
+
* escape hatches (`$queryRawUnsafe`, `sql.unsafe`) are flagged on the call alone: their whole purpose
|
|
470
|
+
* is to take a statement as text, and a substitution in one is the exact thing the name warns about.
|
|
471
|
+
*/
|
|
472
|
+
const SQL_METHODS = new Set([
|
|
473
|
+
"query",
|
|
474
|
+
"queryRaw",
|
|
475
|
+
"prepare",
|
|
476
|
+
"exec",
|
|
477
|
+
"execute",
|
|
478
|
+
"run",
|
|
479
|
+
"unsafe",
|
|
480
|
+
"$queryRawUnsafe",
|
|
481
|
+
"$executeRawUnsafe",
|
|
482
|
+
"raw",
|
|
483
|
+
])
|
|
484
|
+
const SQL_KEYWORD =
|
|
485
|
+
/\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
|
|
486
|
+
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"])
|
|
487
|
+
|
|
488
|
+
interface SqlExpressionShape {
|
|
489
|
+
readonly staticText: string
|
|
490
|
+
readonly dynamic: boolean
|
|
491
|
+
readonly concatenated: boolean
|
|
492
|
+
readonly literal: boolean
|
|
493
|
+
readonly parameterizedTag: boolean
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
|
|
497
|
+
function sqlExpressionShape(ts: TypeScriptApi, node: TSApi.Expression): SqlExpressionShape {
|
|
498
|
+
if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression)
|
|
499
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
500
|
+
return {
|
|
501
|
+
staticText: node.text,
|
|
502
|
+
dynamic: false,
|
|
503
|
+
concatenated: false,
|
|
504
|
+
literal: true,
|
|
505
|
+
parameterizedTag: false,
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
509
|
+
const tag = node.tag
|
|
510
|
+
const parameterizedTag =
|
|
511
|
+
(ts.isIdentifier(tag) && tag.text === "sql") ||
|
|
512
|
+
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
|
|
513
|
+
const template = node.template
|
|
514
|
+
const staticText = ts.isTemplateExpression(template)
|
|
515
|
+
? [template.head.text, ...template.templateSpans.map((span) => span.literal.text)].join("")
|
|
516
|
+
: template.text
|
|
517
|
+
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
518
|
+
// that reads syntax and runs no type checker. `sql` is what postgres.js, drizzle, slonik and Bun's
|
|
519
|
+
// own driver all call theirs, and `Prisma.sql` is Prisma's; taking those at their word is a
|
|
520
|
+
// deliberate trade, and it is worth stating what it costs: a no-op function named `sql` in the
|
|
521
|
+
// same file is trusted too, so this rule finds mistakes, not an adversary who reads it first.
|
|
522
|
+
//
|
|
523
|
+
// Trusting anything else widens that for no gain. This list briefly also held `sqlIdentifiers`,
|
|
524
|
+
// a name invented for Nifra's own adapters - a second forgeable name that no user has a
|
|
525
|
+
// convention for, added so Nifra's source would pass its own rule. Names earn their place here by
|
|
526
|
+
// being what drivers already call the thing, not by being convenient for the framework.
|
|
527
|
+
//
|
|
528
|
+
// Everything else - `String.raw`, an identity tag, a member access that merely ends in `.sql` -
|
|
529
|
+
// is treated as plain interpolation.
|
|
530
|
+
return {
|
|
531
|
+
staticText,
|
|
532
|
+
dynamic: ts.isTemplateExpression(template) && !parameterizedTag,
|
|
533
|
+
concatenated: false,
|
|
534
|
+
literal: !ts.isTemplateExpression(template),
|
|
535
|
+
parameterizedTag,
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (ts.isTemplateExpression(node)) {
|
|
539
|
+
return {
|
|
540
|
+
staticText: [node.head.text, ...node.templateSpans.map((span) => span.literal.text)].join(""),
|
|
541
|
+
dynamic: true,
|
|
542
|
+
concatenated: false,
|
|
543
|
+
literal: false,
|
|
544
|
+
parameterizedTag: false,
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
548
|
+
const left = sqlExpressionShape(ts, node.left)
|
|
549
|
+
const right = sqlExpressionShape(ts, node.right)
|
|
550
|
+
return {
|
|
551
|
+
staticText: left.staticText + right.staticText,
|
|
552
|
+
dynamic: left.dynamic || right.dynamic || !left.literal || !right.literal,
|
|
553
|
+
concatenated: true,
|
|
554
|
+
literal: left.literal && right.literal,
|
|
555
|
+
parameterizedTag: false,
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
staticText: "",
|
|
560
|
+
dynamic: true,
|
|
561
|
+
concatenated: false,
|
|
562
|
+
literal: false,
|
|
563
|
+
parameterizedTag: false,
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function sqlMethod(
|
|
568
|
+
ts: TypeScriptApi,
|
|
569
|
+
call: TSApi.CallExpression,
|
|
570
|
+
): { method: string; receiver: string } | undefined {
|
|
571
|
+
if (ts.isPropertyAccessExpression(call.expression)) {
|
|
572
|
+
const method = call.expression.name.text
|
|
573
|
+
return SQL_METHODS.has(method)
|
|
574
|
+
? { method, receiver: call.expression.expression.getText() }
|
|
575
|
+
: undefined
|
|
576
|
+
}
|
|
577
|
+
if (
|
|
578
|
+
ts.isElementAccessExpression(call.expression) &&
|
|
579
|
+
call.expression.argumentExpression !== undefined &&
|
|
580
|
+
ts.isStringLiteral(call.expression.argumentExpression)
|
|
581
|
+
) {
|
|
582
|
+
const method = call.expression.argumentExpression.text
|
|
583
|
+
return SQL_METHODS.has(method)
|
|
584
|
+
? { method, receiver: call.expression.expression.getText() }
|
|
585
|
+
: undefined
|
|
586
|
+
}
|
|
587
|
+
return undefined
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
|
|
591
|
+
|
|
592
|
+
/** Scan one file's text for SQL assembled by interpolation. Pure + line-accurate. */
|
|
593
|
+
export function scanInterpolatedSql(
|
|
594
|
+
file: string,
|
|
595
|
+
content: string,
|
|
596
|
+
ts: TypeScriptApi,
|
|
597
|
+
): SourceFinding[] {
|
|
598
|
+
const out: SourceFinding[] = []
|
|
599
|
+
const lines = content.split("\n")
|
|
600
|
+
const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
|
|
601
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
|
|
602
|
+
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
603
|
+
// misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
|
|
604
|
+
const parseDiagnostics = (
|
|
605
|
+
source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
|
|
606
|
+
).parseDiagnostics
|
|
607
|
+
if (parseDiagnostics !== undefined && parseDiagnostics.length > 0) return out
|
|
608
|
+
const visit = (node: TSApi.Node): void => {
|
|
609
|
+
if (ts.isCallExpression(node)) {
|
|
610
|
+
const sink = sqlMethod(ts, node)
|
|
611
|
+
const argument = node.arguments[0]
|
|
612
|
+
if (sink !== undefined && argument !== undefined) {
|
|
613
|
+
const shape = sqlExpressionShape(ts, argument)
|
|
614
|
+
const unsafeEscape =
|
|
615
|
+
ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
|
|
616
|
+
const assembledSql =
|
|
617
|
+
shape.dynamic &&
|
|
618
|
+
(SQL_KEYWORD.test(shape.staticText) ||
|
|
619
|
+
(shape.concatenated && SQL_RECEIVER.test(sink.receiver)))
|
|
620
|
+
if (unsafeEscape || assembledSql) {
|
|
621
|
+
const index = node.getStart(source)
|
|
622
|
+
const line = source.getLineAndCharacterOfPosition(index).line + 1
|
|
623
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
ts.forEachChild(node, visit)
|
|
628
|
+
}
|
|
629
|
+
visit(source)
|
|
630
|
+
return out
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Collect interpolated-SQL findings across the project.
|
|
635
|
+
*
|
|
636
|
+
* Returns `undefined` - not an empty array - when TypeScript is not installed. The rule parses source
|
|
637
|
+
* with the compiler, so without it there is nothing to report and, more to the point, no basis for
|
|
638
|
+
* reporting nothing. An empty result would read as "no interpolated SQL found", which is the one
|
|
639
|
+
* answer a security rule must never give when it did not run.
|
|
640
|
+
*/
|
|
641
|
+
export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
|
|
642
|
+
const ts = await importTypeScript()
|
|
643
|
+
if (ts === undefined) return undefined
|
|
644
|
+
const out: SourceFinding[] = []
|
|
645
|
+
await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts)))
|
|
646
|
+
return out.sort(bySite)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
|
|
453
650
|
// the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
|
|
454
651
|
// `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
|
|
455
|
-
// contract form `client(contract, url)` starts with an identifier, not a quote
|
|
652
|
+
// contract form `client(contract, url)` starts with an identifier, not a quote - also unmatched.
|
|
456
653
|
const CLIENT_CALL = /(?<![.\w])client\s*\(/g
|
|
457
654
|
|
|
458
655
|
/** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
|
|
@@ -471,6 +668,69 @@ export function scanUntypedClient(file: string, content: string): SourceFinding[
|
|
|
471
668
|
return out
|
|
472
669
|
}
|
|
473
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Specifiers that no longer exist, and what replaced them.
|
|
673
|
+
*
|
|
674
|
+
* These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
|
|
675
|
+
* was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
|
|
676
|
+
* the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
|
|
677
|
+
* into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
|
|
678
|
+
* that names neither the cause nor the replacement.
|
|
679
|
+
*
|
|
680
|
+
* A lint is the right shape for this: it runs before boot, names the file and line, and states the
|
|
681
|
+
* replacement - which is what neither the resolver error nor the runtime failure does.
|
|
682
|
+
*/
|
|
683
|
+
const REMOVED_IMPORTS: ReadonlyArray<{
|
|
684
|
+
readonly specifier: string
|
|
685
|
+
readonly since: string
|
|
686
|
+
readonly replacement: string
|
|
687
|
+
/** Flag ONLY the bare side-effect form (`import "x"`). A module that still exports values is not
|
|
688
|
+
* removed - only its install-by-import behaviour is - so flagging every import of it would be
|
|
689
|
+
* wrong and would train people to ignore the rule. */
|
|
690
|
+
readonly sideEffectOnly?: boolean
|
|
691
|
+
}> = [
|
|
692
|
+
{
|
|
693
|
+
specifier: "@nifrajs/budget",
|
|
694
|
+
since: "2.0",
|
|
695
|
+
replacement:
|
|
696
|
+
'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',
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
specifier: "@nifrajs/core/ws",
|
|
700
|
+
since: "2.0",
|
|
701
|
+
sideEffectOnly: true,
|
|
702
|
+
replacement:
|
|
703
|
+
'a bare `import "@nifrajs/core/ws"` no longer installs the runtime - register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
|
|
704
|
+
},
|
|
705
|
+
]
|
|
706
|
+
|
|
707
|
+
/** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
|
|
708
|
+
* one with bindings (`import { y } from "x"`). */
|
|
709
|
+
function isSideEffectImport(content: string, index: number): boolean {
|
|
710
|
+
// `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
|
|
711
|
+
// a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
|
|
712
|
+
// no `from` in between.
|
|
713
|
+
return /^import\s*["'`]/.test(content.slice(index, index + 32))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
|
|
717
|
+
* of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
|
|
718
|
+
export function scanRemovedImports(file: string, content: string): SourceFinding[] {
|
|
719
|
+
const out: SourceFinding[] = []
|
|
720
|
+
const lines = content.split("\n")
|
|
721
|
+
for (const edge of staticImportEdges(content)) {
|
|
722
|
+
const removed = REMOVED_IMPORTS.find(
|
|
723
|
+
(entry) =>
|
|
724
|
+
edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
|
|
725
|
+
)
|
|
726
|
+
if (removed === undefined) continue
|
|
727
|
+
if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index)) continue
|
|
728
|
+
const line = lineAt(content, edge.index)
|
|
729
|
+
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
730
|
+
}
|
|
731
|
+
return out
|
|
732
|
+
}
|
|
733
|
+
|
|
474
734
|
/** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
|
|
475
735
|
* `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
|
|
476
736
|
* the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
|
|
@@ -492,7 +752,7 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
|
|
|
492
752
|
}
|
|
493
753
|
|
|
494
754
|
// ---------------------------------------------------------------------------------------------------
|
|
495
|
-
// #4.4
|
|
755
|
+
// #4.4 - TRANSITIVE import-chain resolution for `server-only-import`. `scanServerOnlyImports` above is a
|
|
496
756
|
// pure per-file regex scan: it sees only the route's DIRECT `import` line, so it reports the direct edge
|
|
497
757
|
// `routes/x → ../db`. The build leak-guard, which has the real module graph, reports the full transitive
|
|
498
758
|
// `route → ../data → ../db → pg`. These helpers give `nifra check` the same depth via a BOUNDED
|
|
@@ -501,14 +761,14 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
|
|
|
501
761
|
// that can't be precisely resolved (a bare pkg, a tsconfig path alias) falls back to the direct edge.
|
|
502
762
|
// ---------------------------------------------------------------------------------------------------
|
|
503
763
|
|
|
504
|
-
// Server-only specifiers that are TERMINAL sinks
|
|
764
|
+
// Server-only specifiers that are TERMINAL sinks - a bare `node:`/`bun:` builtin or a known server-only
|
|
505
765
|
// npm package. These are never local source we can walk into, so the chain ends here. (Same vocabulary
|
|
506
|
-
// as SERVER_ONLY, minus the relative `../db` arm
|
|
766
|
+
// as SERVER_ONLY, minus the relative `../db` arm - a relative `db` module IS local source we resolve.)
|
|
507
767
|
const SERVER_ONLY_SINK =
|
|
508
768
|
/^(?:node:|bun:)|^(?:postgres|pg|mysql2|ioredis|redis|better-sqlite3|mongodb|@libsql\/client)$|^drizzle-orm\/(?:node-postgres|postgres-js|bun-sqlite|libsql|mysql2|pglite)\b/
|
|
509
769
|
// The `.server` convention: a module named `*.server.ts(x)` is server-only (the client build empties it).
|
|
510
770
|
const SERVER_MODULE_FILE = /\.server(\.[cm]?[jt]sx?)?$/
|
|
511
|
-
// The explicit poison-import marker (`@nifrajs/web/server-only`)
|
|
771
|
+
// The explicit poison-import marker (`@nifrajs/web/server-only`) - a module opting into the client-leak
|
|
512
772
|
// guard. A resolved file whose source carries this side-effect import is a server-only sink.
|
|
513
773
|
const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
|
|
514
774
|
// Depth/visited caps keep the walk linear + cycle-safe. A route's server-only dependency sits within a
|
|
@@ -516,13 +776,13 @@ const SERVER_ONLY_MARKER_IMPORT = /import\s+["']@nifrajs\/web\/server-only["']/
|
|
|
516
776
|
const TRANSITIVE_MAX_DEPTH = 8
|
|
517
777
|
const TRANSITIVE_MAX_VISITED = 200
|
|
518
778
|
|
|
519
|
-
/** A relative module specifier (`./x`, `../y`)
|
|
779
|
+
/** A relative module specifier (`./x`, `../y`) - the only kind we resolve + walk into (a bare specifier
|
|
520
780
|
* is either a sink we recognise by name or a third-party dep we don't follow into node_modules). */
|
|
521
781
|
const isRelativeSpecifier = (spec: string): boolean =>
|
|
522
782
|
spec.startsWith("./") || spec.startsWith("../")
|
|
523
783
|
|
|
524
|
-
// A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT
|
|
525
|
-
// per-route loop and the inner BFS both scan imports
|
|
784
|
+
// A FRESH copy of the static-import regex per scan. The transitive walk is REENTRANT - the outer
|
|
785
|
+
// per-route loop and the inner BFS both scan imports - and a single shared global-flag regex carries
|
|
526
786
|
// `lastIndex` state, so reusing the module-level `STATIC_IMPORT` across nested calls corrupts the outer
|
|
527
787
|
// loop's position (it restarts forever). A fresh instance per call keeps each scan's state private.
|
|
528
788
|
const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.flags)
|
|
@@ -550,7 +810,7 @@ export function parseStaticImports(content: string): string[] {
|
|
|
550
810
|
}
|
|
551
811
|
|
|
552
812
|
/** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
|
|
553
|
-
* server-only package), or `undefined` if it isn't a by-name sink. Pure
|
|
813
|
+
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
554
814
|
* itself (it's already the actionable name). */
|
|
555
815
|
export function directSinkSpecifier(spec: string): string | undefined {
|
|
556
816
|
return SERVER_ONLY_SINK.test(spec) ? spec : undefined
|
|
@@ -559,7 +819,7 @@ export function directSinkSpecifier(spec: string): string | undefined {
|
|
|
559
819
|
/** One transitive server-only finding: the route module, the route's offending top-level import (the
|
|
560
820
|
* first hop / `specifier`), the line + snippet of that import, and the FULL chain to the sink. */
|
|
561
821
|
export interface TransitiveServerImportFinding extends ServerImportFinding {
|
|
562
|
-
/** `[routeFile, ...as-written specifiers…, sink]`
|
|
822
|
+
/** `[routeFile, ...as-written specifiers…, sink]` - the shortest path the walk found. Length 2 means
|
|
563
823
|
* the route imports the sink directly (same as the regex scan's direct edge). */
|
|
564
824
|
readonly chain: readonly string[]
|
|
565
825
|
/** True when a precise transitive resolve wasn't possible for the first hop (a bare pkg / path alias),
|
|
@@ -582,7 +842,7 @@ export type ModuleReader = (absPath: string) => string | undefined
|
|
|
582
842
|
* unresolvable alias). At each node, a by-name sink import (`node:fs`, `postgres`) OR a resolved
|
|
583
843
|
* `*.server` / `server-only`-marked dependency terminates the chain. Bounded by depth + a visited set, so
|
|
584
844
|
* it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
|
|
585
|
-
*
|
|
845
|
+
* - pure given those, so it's unit-testable with a fake graph.
|
|
586
846
|
*/
|
|
587
847
|
export function walkServerOnlyChain(
|
|
588
848
|
routeFile: string,
|
|
@@ -611,11 +871,11 @@ export function walkServerOnlyChain(
|
|
|
611
871
|
const sink = directSinkSpecifier(spec)
|
|
612
872
|
if (sink !== undefined) return [...node.chain, sink]
|
|
613
873
|
// (b) Only relative specifiers are local source we resolve + walk into. A bare third-party
|
|
614
|
-
// specifier that isn't a known sink is a leaf
|
|
874
|
+
// specifier that isn't a known sink is a leaf - don't follow it into node_modules.
|
|
615
875
|
if (!isRelativeSpecifier(spec)) continue
|
|
616
876
|
const abs = resolve(node.abs, spec)
|
|
617
877
|
if (abs === undefined || seen.has(abs)) continue
|
|
618
|
-
// (c) A resolved `*.server` module is a server-only sink by the `.server` convention
|
|
878
|
+
// (c) A resolved `*.server` module is a server-only sink by the `.server` convention - the chain
|
|
619
879
|
// ends at it (named by the as-written specifier).
|
|
620
880
|
if (SERVER_MODULE_FILE.test(abs)) return [...node.chain, spec]
|
|
621
881
|
const content = read(abs)
|
|
@@ -636,8 +896,8 @@ export function walkServerOnlyChain(
|
|
|
636
896
|
/**
|
|
637
897
|
* Resolve the FULL transitive server-only chain(s) for a route module, given a real fs-backed resolver +
|
|
638
898
|
* reader. For each of the route's top-level imports, if a precise transitive walk finds a sink, the
|
|
639
|
-
* finding carries the full chain; otherwise
|
|
640
|
-
* regex scan already flags, or when a relative import can't be resolved
|
|
899
|
+
* finding carries the full chain; otherwise - when the first hop is itself a direct by-name sink the
|
|
900
|
+
* regex scan already flags, or when a relative import can't be resolved - it falls back to the DIRECT
|
|
641
901
|
* edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
|
|
642
902
|
* de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
|
|
643
903
|
*/
|
|
@@ -658,21 +918,21 @@ export function resolveServerOnlyChains(
|
|
|
658
918
|
if (flaggedSpecifiers.has(specifier)) continue
|
|
659
919
|
const line = lineAt(content, index)
|
|
660
920
|
const snippet = (lines[line - 1] ?? "").trim()
|
|
661
|
-
// (1) A direct by-name sink (`node:fs`, `postgres`)
|
|
921
|
+
// (1) A direct by-name sink (`node:fs`, `postgres`) - the route imports it itself; chain is the
|
|
662
922
|
// direct edge. (Length-2 chain == the regex scan's existing `[route, specifier]`.)
|
|
663
923
|
if (directSinkSpecifier(specifier) !== undefined) {
|
|
664
924
|
flaggedSpecifiers.add(specifier)
|
|
665
925
|
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: false })
|
|
666
926
|
continue
|
|
667
927
|
}
|
|
668
|
-
// (2) A relative local import
|
|
928
|
+
// (2) A relative local import - try the transitive walk from the resolved dependency. If it reaches
|
|
669
929
|
// a sink, emit the full chain rooted at THIS route's import line.
|
|
670
930
|
if (isRelativeSpecifier(specifier)) {
|
|
671
931
|
const abs = resolve(file, specifier)
|
|
672
932
|
if (abs === undefined) {
|
|
673
933
|
// Unresolvable relative import. If it's the known server-only `../db` convention the regex scan
|
|
674
|
-
// flags directly, fall back to the direct edge (precise resolve impossible
|
|
675
|
-
// `fallback`). Any other unresolvable relative import we can't assert is server-only
|
|
934
|
+
// flags directly, fall back to the direct edge (precise resolve impossible - say so via
|
|
935
|
+
// `fallback`). Any other unresolvable relative import we can't assert is server-only - skip it.
|
|
676
936
|
if (SERVER_ONLY.test(specifier)) {
|
|
677
937
|
flaggedSpecifiers.add(specifier)
|
|
678
938
|
out.push({ file, line, snippet, specifier, chain: [file, specifier], fallback: true })
|
|
@@ -703,7 +963,7 @@ export function resolveServerOnlyChains(
|
|
|
703
963
|
continue
|
|
704
964
|
}
|
|
705
965
|
// (3) The relative `../db`-style convention the regex scan flags directly, but where the walk
|
|
706
|
-
// found no deeper sink (e.g. the file wasn't readable past the first hop)
|
|
966
|
+
// found no deeper sink (e.g. the file wasn't readable past the first hop) - fall back to the
|
|
707
967
|
// direct edge so we still surface the known-server-only convention rather than going silent.
|
|
708
968
|
if (SERVER_ONLY.test(specifier)) {
|
|
709
969
|
flaggedSpecifiers.add(specifier)
|
|
@@ -715,7 +975,7 @@ export function resolveServerOnlyChains(
|
|
|
715
975
|
}
|
|
716
976
|
|
|
717
977
|
// A route handler returning a raw `Response` (`=> new Response(`, `return Response.json(`, …) makes the
|
|
718
|
-
// typed client infer `data: never` (`Jsonify<Response>` is `never`)
|
|
978
|
+
// typed client infer `data: never` (`Jsonify<Response>` is `never`) - so frontend/backend drift detection
|
|
719
979
|
// silently vanishes for that route. Advisory, not a failure: a raw Response is sometimes intended
|
|
720
980
|
// (redirects, files, streams). Fix is to return a plain object, or declare `{ response: t.… }` to type it.
|
|
721
981
|
const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g
|
|
@@ -753,12 +1013,12 @@ const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
|
|
|
753
1013
|
/** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
|
|
754
1014
|
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
|
|
755
1015
|
/**
|
|
756
|
-
* Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call
|
|
1016
|
+
* Paths git considers ignored under `cwd`, in ONE batched `git check-ignore` call - so `.gitignore`
|
|
757
1017
|
* (root + nested + the global excludesfile) is honoured, not just the built-in {@link IGNORED} list. This
|
|
758
1018
|
* is what keeps the scan out of generated/build trees that live under a repo but aren't source: a monorepo
|
|
759
|
-
* that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full
|
|
1019
|
+
* that gitignores, say, `builder/projects/` (238 generated apps) would otherwise be walked in full - 40k+
|
|
760
1020
|
* findings, a 50 MB+ result that overwhelms the caller. Returns an EMPTY set when there's no git / not a
|
|
761
|
-
* repo / git errors, so the scan simply degrades to the {@link IGNORED} regex
|
|
1021
|
+
* repo / git errors, so the scan simply degrades to the {@link IGNORED} regex - never throws, never blocks.
|
|
762
1022
|
*/
|
|
763
1023
|
async function gitIgnored(cwd: string, rels: readonly string[]): Promise<Set<string>> {
|
|
764
1024
|
if (rels.length === 0) return new Set()
|
|
@@ -789,7 +1049,7 @@ export async function walkSource(
|
|
|
789
1049
|
cwd: string,
|
|
790
1050
|
visit: (rel: string, content: string) => void,
|
|
791
1051
|
): Promise<void> {
|
|
792
|
-
// List candidates first (cheap
|
|
1052
|
+
// List candidates first (cheap - no reads), drop the built-in ignores, then exclude gitignored paths in
|
|
793
1053
|
// one batch before the (expensive) reads. So a gitignored generated/build tree is never read or scanned.
|
|
794
1054
|
const rels: string[] = []
|
|
795
1055
|
for await (const rel of new Glob("**/*.{ts,tsx,mts,cts}").scan({ cwd, dot: false })) {
|
|
@@ -812,15 +1072,15 @@ export async function scanProject(cwd: string): Promise<SourceFinding[]> {
|
|
|
812
1072
|
return out.sort(bySite)
|
|
813
1073
|
}
|
|
814
1074
|
|
|
815
|
-
// #7
|
|
1075
|
+
// #7 - server-manifest drift. `server-manifest.ts` is a COMMITTED generated file (it bakes the route
|
|
816
1076
|
// list + client-entry hash for a disk-less worker). If `routes/` changes but the manifest isn't
|
|
817
|
-
// regenerated, the worker serves a stale route table
|
|
1077
|
+
// regenerated, the worker serves a stale route table - a silent edge break that no other check catches.
|
|
818
1078
|
// We diff each committed manifest's imported route files against the live `routes/` tree.
|
|
819
1079
|
|
|
820
|
-
// The marker comment `generateServerManifest` emits at the top of the file
|
|
1080
|
+
// The marker comment `generateServerManifest` emits at the top of the file - identifies a generated
|
|
821
1081
|
// manifest unambiguously (so a user file merely named `server-manifest.ts` isn't mistaken for one).
|
|
822
1082
|
const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
|
|
823
|
-
// The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`)
|
|
1083
|
+
// The first route-import specifier's prefix up to `routes/` (e.g. `./`, `../`, `./src/`) - used to
|
|
824
1084
|
// locate the routes dir relative to the manifest, and to strip to route-relative keys.
|
|
825
1085
|
const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
|
|
826
1086
|
// Route file extensions discovery recognises (mirrors `@nifrajs/web/fs`'s filter).
|
|
@@ -829,7 +1089,7 @@ const ROUTE_FILE_EXT = /\.(tsx|jsx|svelte|vue|mdx)$/
|
|
|
829
1089
|
export interface ManifestDriftFinding {
|
|
830
1090
|
/** The committed server-manifest file (relative to cwd). */
|
|
831
1091
|
readonly file: string
|
|
832
|
-
/** Route files in `routes/` missing from the manifest (stale manifest
|
|
1092
|
+
/** Route files in `routes/` missing from the manifest (stale manifest - these routes won't serve). */
|
|
833
1093
|
readonly missing: readonly string[]
|
|
834
1094
|
/** Route files the manifest imports that no longer exist in `routes/` (a dangling import). */
|
|
835
1095
|
readonly extra: readonly string[]
|
|
@@ -851,12 +1111,12 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
|
|
|
851
1111
|
for await (const rel of new Glob("**/server-manifest.ts").scan({ cwd, dot: false })) {
|
|
852
1112
|
if (IGNORED.test(rel)) continue
|
|
853
1113
|
const source = await Bun.file(join(cwd, rel)).text()
|
|
854
|
-
if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest
|
|
1114
|
+
if (!source.includes(GENERATED_MARKER)) continue // not a generated manifest - skip
|
|
855
1115
|
const prefixMatch = ROUTES_PREFIX.exec(source)
|
|
856
1116
|
if (prefixMatch?.[1] === undefined) continue // no `routes/` imports → nothing to diff (empty app)
|
|
857
1117
|
const routesPrefix = prefixMatch[1]
|
|
858
1118
|
const manifestFiles = parseManifestRouteFiles(source, routesPrefix)
|
|
859
|
-
// The routes dir sits at `<manifest dir>/<prefix>`
|
|
1119
|
+
// The routes dir sits at `<manifest dir>/<prefix>` - resolve it relative to the manifest file.
|
|
860
1120
|
const manifestDir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
|
|
861
1121
|
const routesDir = join(cwd, manifestDir, routesPrefix)
|
|
862
1122
|
let discovered: string[]
|
|
@@ -867,7 +1127,7 @@ export async function scanServerManifestDrift(cwd: string): Promise<ManifestDrif
|
|
|
867
1127
|
.map((f) => f.replaceAll("\\", "/"))
|
|
868
1128
|
.filter((f) => ROUTE_FILE_EXT.test(f))
|
|
869
1129
|
} catch {
|
|
870
|
-
continue // routes dir gone/unreadable
|
|
1130
|
+
continue // routes dir gone/unreadable - not a drift we can assess
|
|
871
1131
|
}
|
|
872
1132
|
const drift = diffManifestRoutes(manifestFiles, discovered)
|
|
873
1133
|
if (!isManifestInSync(drift)) {
|
|
@@ -932,13 +1192,15 @@ async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckRe
|
|
|
932
1192
|
// `src/x.tsx(12,5): error TS2322: <message>` → one structured diagnostic.
|
|
933
1193
|
const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
|
|
934
1194
|
|
|
935
|
-
/** A single machine-readable check failure
|
|
1195
|
+
/** A single machine-readable check failure - the unit an agent (or CI) acts on. */
|
|
936
1196
|
export interface CheckDiagnostic {
|
|
937
1197
|
readonly rule:
|
|
938
1198
|
| "typecheck"
|
|
939
1199
|
| "typed-client"
|
|
940
1200
|
| "untyped-client"
|
|
941
1201
|
| "server-only-import"
|
|
1202
|
+
| "removed-import"
|
|
1203
|
+
| "interpolated-sql"
|
|
942
1204
|
| "response-route"
|
|
943
1205
|
| "undeclared-dependency"
|
|
944
1206
|
| "duplicate-install"
|
|
@@ -947,14 +1209,14 @@ export interface CheckDiagnostic {
|
|
|
947
1209
|
| "capability-assurance"
|
|
948
1210
|
| "capability-config"
|
|
949
1211
|
| "check-config"
|
|
950
|
-
/** `error` fails the gate (a real contract break); `warning` is advisory
|
|
1212
|
+
/** `error` fails the gate (a real contract break); `warning` is advisory - surfaced to the agent but
|
|
951
1213
|
* does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
|
|
952
1214
|
* `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
|
|
953
1215
|
readonly severity: "error" | "warning"
|
|
954
1216
|
readonly file?: string
|
|
955
1217
|
readonly line?: number
|
|
956
1218
|
readonly message: string
|
|
957
|
-
/** The canonical, rule-level fix
|
|
1219
|
+
/** The canonical, rule-level fix - clean of the per-occurrence snippet, so an agent can apply it
|
|
958
1220
|
* directly. Set for the lint rules (they have one correct fix); omitted for `typecheck` (the fix is
|
|
959
1221
|
* specific to each type error). */
|
|
960
1222
|
readonly fix?: string
|
|
@@ -965,12 +1227,12 @@ export interface CheckDiagnostic {
|
|
|
965
1227
|
* The import chain that pulls server-only code into the browser bundle, as display labels
|
|
966
1228
|
* `[routeFile, …as-written specifiers…, sink]`. Set only on `server-only-import`.
|
|
967
1229
|
*
|
|
968
|
-
* #4.4: this is now the FULL **transitive** chain
|
|
1230
|
+
* #4.4: this is now the FULL **transitive** chain - a bounded import-resolution walk (`Bun.resolveSync`
|
|
969
1231
|
* from each file's dir, BFS the local module graph) follows `route → ../data → ../db → node:crypto`,
|
|
970
1232
|
* matching the build leak-guard's depth (`detectNodeBuiltinsInClient` in `@nifrajs/web/build`). A
|
|
971
1233
|
* length-2 chain (`[routeFile, specifier]`) means the route imports the sink directly. When a hop can't
|
|
972
1234
|
* be resolved precisely (a bare pkg, a tsconfig path alias), the walk degrades to the honest direct
|
|
973
|
-
* edge rather than fabricating a deeper path
|
|
1235
|
+
* edge rather than fabricating a deeper path - never a lie.
|
|
974
1236
|
*/
|
|
975
1237
|
readonly chain?: readonly string[]
|
|
976
1238
|
}
|
|
@@ -984,7 +1246,7 @@ export interface CheckSuggestion {
|
|
|
984
1246
|
readonly steps?: readonly string[]
|
|
985
1247
|
}
|
|
986
1248
|
|
|
987
|
-
/** The structured result of a full check
|
|
1249
|
+
/** The structured result of a full check - what `--json` prints and the `nifra_check` MCP tool returns. */
|
|
988
1250
|
export interface CheckResult {
|
|
989
1251
|
readonly ok: boolean
|
|
990
1252
|
readonly typecheck: "pass" | "fail" | "skipped"
|
|
@@ -993,12 +1255,31 @@ export interface CheckResult {
|
|
|
993
1255
|
* better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
|
|
994
1256
|
* deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
|
|
995
1257
|
readonly externalMounts?: readonly string[]
|
|
996
|
-
/** Set only when the caller passed `maxDiagnostics` and there were more
|
|
1258
|
+
/** Set only when the caller passed `maxDiagnostics` and there were more - `diagnostics` then holds the
|
|
997
1259
|
* first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
|
|
998
1260
|
* message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
|
|
999
1261
|
readonly truncated?: { readonly shown: number; readonly total: number }
|
|
1000
1262
|
}
|
|
1001
1263
|
|
|
1264
|
+
/**
|
|
1265
|
+
* Pre-resolved route-assurance inputs, so the same reflection that `nifra assure` / `nifra levels`
|
|
1266
|
+
* already ran can feed `check`'s capability + trust-manifest diagnostics instead of a second pass.
|
|
1267
|
+
* Supplied by {@link collectProjectVerification}. When omitted, `collectCheckResult` loads and computes
|
|
1268
|
+
* these itself (the standalone `nifra_check` MCP path); the two routes produce byte-identical results.
|
|
1269
|
+
*/
|
|
1270
|
+
export interface CheckAssuranceContext {
|
|
1271
|
+
/** Whether `nifra.assurance.ts` exists: the gate for running the assurance-fed diagnostics at all. */
|
|
1272
|
+
readonly present: boolean
|
|
1273
|
+
/** The loaded config, when it loaded. Absent when the file is missing or {@link error} is set. */
|
|
1274
|
+
readonly config?: AssuranceConfig
|
|
1275
|
+
/** The failure from loading/evaluating the config, surfaced as a `capability-config` diagnostic. */
|
|
1276
|
+
readonly error?: unknown
|
|
1277
|
+
/** `evaluateRouteAssurance` over the config's source + policy (drives the trust-manifest check). */
|
|
1278
|
+
readonly routeAssurance?: AssuranceReport
|
|
1279
|
+
/** Static capability provenance, when the config declares a capabilities policy. */
|
|
1280
|
+
readonly capability?: CapabilityProjectReport
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1002
1283
|
/** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
|
|
1003
1284
|
* the app is built or even importable, preserving check's pre-`loadApp` invariant. */
|
|
1004
1285
|
interface CheckConfig {
|
|
@@ -1027,21 +1308,25 @@ async function loadCheckConfig(cwd: string): Promise<{ config: CheckConfig; erro
|
|
|
1027
1308
|
}
|
|
1028
1309
|
|
|
1029
1310
|
const UNTYPED_CLIENT_HINT =
|
|
1030
|
-
'client("…") without a type argument
|
|
1311
|
+
'client("…") without a type argument - write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
|
|
1031
1312
|
const FETCH_HINT =
|
|
1032
|
-
"hand-rolled fetch() to your own API
|
|
1313
|
+
"hand-rolled fetch() to your own API - call it through client<typeof app> (from @nifrajs/client) so the compiler catches drift"
|
|
1033
1314
|
const SERVER_IMPORT_HINT =
|
|
1034
|
-
"server-only import in a route module (bundled for the browser)
|
|
1315
|
+
"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"
|
|
1035
1316
|
const RESPONSE_ROUTE_HINT =
|
|
1036
1317
|
"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"
|
|
1037
1318
|
const UNDECLARED_DEP_HINT =
|
|
1038
|
-
"imported package is not declared in package.json dependencies
|
|
1319
|
+
"imported package is not declared in package.json dependencies - run bun add to declare it"
|
|
1320
|
+
const SQL_COMPILER_MISSING_HINT =
|
|
1321
|
+
"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`"
|
|
1322
|
+
const SQL_INTERPOLATION_EXAMPLE = "$" + "{value}"
|
|
1323
|
+
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`
|
|
1039
1324
|
const MANIFEST_DRIFT_HINT =
|
|
1040
|
-
"server-manifest.ts is out of sync with routes/
|
|
1325
|
+
"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"
|
|
1041
1326
|
const TRUST_MANIFEST_DRIFT_HINT =
|
|
1042
|
-
"nifra.manifest.json is missing, invalid, or out of sync
|
|
1327
|
+
"nifra.manifest.json is missing, invalid, or out of sync - run `nifra manifest emit`, review it, and commit the regenerated trust artifact"
|
|
1043
1328
|
const CAPABILITY_HINT =
|
|
1044
|
-
"effect/capability assurance failed
|
|
1329
|
+
"effect/capability assurance failed - align the route declaration with approved adapter provenance; never bypass an owned effect seam"
|
|
1045
1330
|
|
|
1046
1331
|
function oneLineDiff(file: string, line: number, before: string, after: string): string {
|
|
1047
1332
|
return `--- ${file}:${line}\n+++ ${file}:${line}\n@@\n-${before}\n+${after}`
|
|
@@ -1143,11 +1428,11 @@ function serverImportSuggestion(
|
|
|
1143
1428
|
): CheckSuggestion {
|
|
1144
1429
|
const sink = chain[chain.length - 1] ?? specifier
|
|
1145
1430
|
// Surface the resolved chain in the fix steps so the agent sees the full path (`route → ../data →
|
|
1146
|
-
// ../db → node:crypto`) and which module to cut
|
|
1431
|
+
// ../db → node:crypto`) and which module to cut - not just the route's own top-level import.
|
|
1147
1432
|
const chainStep =
|
|
1148
1433
|
chain.length > 2
|
|
1149
1434
|
? fallback
|
|
1150
|
-
? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely
|
|
1435
|
+
? `Server-only code reaches this route through \`${chain.join(" → ")}\` (the deeper chain couldn't be resolved precisely - trace it from \`${specifier}\`).`
|
|
1151
1436
|
: `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.`
|
|
1152
1437
|
: undefined
|
|
1153
1438
|
return {
|
|
@@ -1184,21 +1469,34 @@ export async function collectCheckResult(
|
|
|
1184
1469
|
/** Cap the returned `diagnostics` to this many (adds {@link CheckResult.truncated}). The MCP tool sets
|
|
1185
1470
|
* it so a huge project can't produce a transport-breaking result; the CLI leaves it unset (all shown). */
|
|
1186
1471
|
readonly maxDiagnostics?: number
|
|
1472
|
+
/** How the interpolated-SQL rule gets its compiler. Injectable so the "not installed" path - the
|
|
1473
|
+
* one where a security rule reports that it did not run - is testable on a machine that has it. */
|
|
1474
|
+
readonly loadTypeScript?: () => Promise<TypeScriptApi | undefined>
|
|
1475
|
+
/** Route-assurance inputs already computed by {@link collectProjectVerification}. When present,
|
|
1476
|
+
* the capability + trust-manifest diagnostics reuse them instead of loading/reflecting a second
|
|
1477
|
+
* time; when absent, they are computed here (unchanged standalone behavior). */
|
|
1478
|
+
readonly assurance?: CheckAssuranceContext
|
|
1187
1479
|
} = {},
|
|
1188
1480
|
): Promise<CheckResult> {
|
|
1189
1481
|
const fetches: SourceFinding[] = []
|
|
1190
1482
|
const staticRoutes: StaticRouteFinding[] = []
|
|
1191
1483
|
const untypedClients: SourceFinding[] = []
|
|
1484
|
+
const removedImports: SourceFinding[] = []
|
|
1192
1485
|
const serverImports: TransitiveServerImportFinding[] = []
|
|
1193
1486
|
const responseRoutes: SourceFinding[] = []
|
|
1487
|
+
const interpolatedSql: SourceFinding[] = []
|
|
1194
1488
|
// Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
|
|
1195
|
-
// (#4.4)
|
|
1489
|
+
// (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
|
|
1196
1490
|
const routeModules: Array<{ rel: string; content: string }> = []
|
|
1197
1491
|
// Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
|
|
1198
1492
|
// walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
|
|
1199
1493
|
const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd)
|
|
1494
|
+
// The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
|
|
1495
|
+
// once, before the walk, so a project without it is told the rule did not run rather than being
|
|
1496
|
+
// handed a clean report the rule never produced.
|
|
1497
|
+
const sqlCompiler = await (opts.loadTypeScript ?? importTypeScript)()
|
|
1200
1498
|
// lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
|
|
1201
|
-
// lints
|
|
1499
|
+
// lints - the agent inner-loop mode; the full gate stays the definition of done.
|
|
1202
1500
|
const [tc, _, dr, manifestDrift] = await Promise.all([
|
|
1203
1501
|
opts.lintsOnly
|
|
1204
1502
|
? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "skipped (lintsOnly)" })
|
|
@@ -1207,8 +1505,11 @@ export async function collectCheckResult(
|
|
|
1207
1505
|
fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
|
|
1208
1506
|
staticRoutes.push(...scanStaticRouteText(rel, content))
|
|
1209
1507
|
untypedClients.push(...scanUntypedClient(rel, content))
|
|
1508
|
+
removedImports.push(...scanRemovedImports(rel, content))
|
|
1210
1509
|
if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
|
|
1211
1510
|
responseRoutes.push(...scanResponseRoutes(rel, content))
|
|
1511
|
+
if (sqlCompiler !== undefined)
|
|
1512
|
+
interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler))
|
|
1212
1513
|
}),
|
|
1213
1514
|
import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
|
|
1214
1515
|
scanServerManifestDrift(cwd),
|
|
@@ -1222,7 +1523,7 @@ export async function collectCheckResult(
|
|
|
1222
1523
|
const resolveModule: ModuleResolver = (fromFile, specifier) => {
|
|
1223
1524
|
try {
|
|
1224
1525
|
// `fromFile` is a cwd-RELATIVE route path on the first hop (`routes/x.tsx`) but ABSOLUTE on the
|
|
1225
|
-
// deeper hops the walk takes (it carries resolved absolute paths)
|
|
1526
|
+
// deeper hops the walk takes (it carries resolved absolute paths) - resolve the dir for each.
|
|
1226
1527
|
const fromAbs = isAbsolute(fromFile) ? fromFile : join(cwd, fromFile)
|
|
1227
1528
|
return Bun.resolveSync(specifier, dirname(fromAbs))
|
|
1228
1529
|
} catch {
|
|
@@ -1294,25 +1595,40 @@ export async function collectCheckResult(
|
|
|
1294
1595
|
severity: "error",
|
|
1295
1596
|
file: f.file,
|
|
1296
1597
|
line: f.line,
|
|
1297
|
-
message: `${f.snippet}
|
|
1598
|
+
message: `${f.snippet} - ${FETCH_HINT}`,
|
|
1298
1599
|
fix: FETCH_HINT,
|
|
1299
1600
|
suggestion: ownFetchSuggestion(f, routes),
|
|
1300
1601
|
})
|
|
1301
1602
|
}
|
|
1603
|
+
for (const f of removedImports.sort(bySite)) {
|
|
1604
|
+
const entry = REMOVED_IMPORTS.find(
|
|
1605
|
+
(candidate) =>
|
|
1606
|
+
f.snippet.includes(`"${candidate.specifier}`) ||
|
|
1607
|
+
f.snippet.includes(`'${candidate.specifier}`),
|
|
1608
|
+
)
|
|
1609
|
+
diagnostics.push({
|
|
1610
|
+
rule: "removed-import",
|
|
1611
|
+
severity: "error",
|
|
1612
|
+
file: f.file,
|
|
1613
|
+
line: f.line,
|
|
1614
|
+
message: `${f.snippet} - removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
|
|
1615
|
+
fix: entry?.replacement ?? "see the changelog",
|
|
1616
|
+
})
|
|
1617
|
+
}
|
|
1302
1618
|
for (const f of untypedClients.sort(bySite)) {
|
|
1303
1619
|
diagnostics.push({
|
|
1304
1620
|
rule: "untyped-client",
|
|
1305
1621
|
severity: "error",
|
|
1306
1622
|
file: f.file,
|
|
1307
1623
|
line: f.line,
|
|
1308
|
-
message: `${f.snippet}
|
|
1624
|
+
message: `${f.snippet} - ${UNTYPED_CLIENT_HINT}`,
|
|
1309
1625
|
fix: UNTYPED_CLIENT_HINT,
|
|
1310
1626
|
suggestion: untypedClientSuggestion(f),
|
|
1311
1627
|
})
|
|
1312
1628
|
}
|
|
1313
1629
|
for (const f of serverImports.sort(bySite)) {
|
|
1314
|
-
// #4.4: the FULL transitive chain the import-resolution walk found
|
|
1315
|
-
// node:crypto`, matching the build leak-guard's depth
|
|
1630
|
+
// #4.4: the FULL transitive chain the import-resolution walk found - `route → ../data → ../db →
|
|
1631
|
+
// node:crypto`, matching the build leak-guard's depth - instead of just the direct edge. The chain's
|
|
1316
1632
|
// tail is the actual server-only sink; the head is the route. When a precise resolve wasn't possible
|
|
1317
1633
|
// (a bare pkg / path alias), `fallback` is set and the chain degrades to the honest direct edge.
|
|
1318
1634
|
const chain = f.chain
|
|
@@ -1322,20 +1638,55 @@ export async function collectCheckResult(
|
|
|
1322
1638
|
severity: "error",
|
|
1323
1639
|
file: f.file,
|
|
1324
1640
|
line: f.line,
|
|
1325
|
-
message: `${f.snippet}
|
|
1641
|
+
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}`,
|
|
1326
1642
|
fix: SERVER_IMPORT_HINT,
|
|
1327
1643
|
chain,
|
|
1328
1644
|
suggestion: serverImportSuggestion(f.specifier, chain, f.fallback),
|
|
1329
1645
|
})
|
|
1330
1646
|
}
|
|
1331
|
-
|
|
1647
|
+
if (sqlCompiler === undefined) {
|
|
1648
|
+
// A security rule that could not run must say so. Silence here is indistinguishable from a clean
|
|
1649
|
+
// result, and this is the one rule whose clean result means "no SQL injection was found".
|
|
1650
|
+
diagnostics.push({
|
|
1651
|
+
rule: "interpolated-sql",
|
|
1652
|
+
severity: "warning",
|
|
1653
|
+
message: SQL_COMPILER_MISSING_HINT,
|
|
1654
|
+
fix: SQL_COMPILER_MISSING_HINT,
|
|
1655
|
+
suggestion: {
|
|
1656
|
+
kind: "command",
|
|
1657
|
+
title: "Install TypeScript so the SQL rule can run",
|
|
1658
|
+
command: ["bun add -d typescript"],
|
|
1659
|
+
},
|
|
1660
|
+
})
|
|
1661
|
+
}
|
|
1662
|
+
for (const f of interpolatedSql.sort(bySite)) {
|
|
1663
|
+
diagnostics.push({
|
|
1664
|
+
rule: "interpolated-sql",
|
|
1665
|
+
severity: "error",
|
|
1666
|
+
file: f.file,
|
|
1667
|
+
line: f.line,
|
|
1668
|
+
message: `${f.snippet} - ${INTERPOLATED_SQL_HINT}`,
|
|
1669
|
+
fix: "bind the value as a parameter instead of interpolating it into the statement",
|
|
1670
|
+
suggestion: {
|
|
1671
|
+
kind: "manual",
|
|
1672
|
+
title: "Bind the value instead of interpolating it",
|
|
1673
|
+
steps: [
|
|
1674
|
+
"Replace the interpolation with a placeholder your driver understands (`?` for SQLite/MySQL, `$1` for Postgres).",
|
|
1675
|
+
"Pass the value as an argument alongside the statement, so the driver binds it.",
|
|
1676
|
+
`Or switch to the driver's tagged template (sql\`… ${SQL_INTERPOLATION_EXAMPLE} …\`): the tag receives substitutions separately and binds them.`,
|
|
1677
|
+
"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.",
|
|
1678
|
+
],
|
|
1679
|
+
},
|
|
1680
|
+
})
|
|
1681
|
+
}
|
|
1682
|
+
// Advisory - surfaced but NOT folded into `ok`, so it never fails the gate (a raw Response is valid).
|
|
1332
1683
|
for (const f of responseRoutes.sort(bySite)) {
|
|
1333
1684
|
diagnostics.push({
|
|
1334
1685
|
rule: "response-route",
|
|
1335
1686
|
severity: "warning",
|
|
1336
1687
|
file: f.file,
|
|
1337
1688
|
line: f.line,
|
|
1338
|
-
message: `${f.snippet}
|
|
1689
|
+
message: `${f.snippet} - ${RESPONSE_ROUTE_HINT}`,
|
|
1339
1690
|
fix: RESPONSE_ROUTE_HINT,
|
|
1340
1691
|
suggestion: responseRouteSuggestion(),
|
|
1341
1692
|
})
|
|
@@ -1347,7 +1698,7 @@ export async function collectCheckResult(
|
|
|
1347
1698
|
severity: "error",
|
|
1348
1699
|
file: f.file,
|
|
1349
1700
|
line: f.line,
|
|
1350
|
-
message: `imports ${f.package} which is not declared in package.json
|
|
1701
|
+
message: `imports ${f.package} which is not declared in package.json - ${UNDECLARED_DEP_HINT}`,
|
|
1351
1702
|
fix: `add ${f.package} to package.json dependencies`,
|
|
1352
1703
|
suggestion: {
|
|
1353
1704
|
kind: "command",
|
|
@@ -1375,7 +1726,7 @@ export async function collectCheckResult(
|
|
|
1375
1726
|
})
|
|
1376
1727
|
}
|
|
1377
1728
|
}
|
|
1378
|
-
// #7: a committed server-manifest.ts that drifted from routes/
|
|
1729
|
+
// #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
|
|
1379
1730
|
for (const f of manifestDrift) {
|
|
1380
1731
|
const parts: string[] = []
|
|
1381
1732
|
if (f.missing.length > 0) parts.push(`missing from manifest: ${f.missing.join(", ")}`)
|
|
@@ -1384,13 +1735,13 @@ export async function collectCheckResult(
|
|
|
1384
1735
|
rule: "server-manifest-drift",
|
|
1385
1736
|
severity: "error",
|
|
1386
1737
|
file: f.file,
|
|
1387
|
-
message: `${f.file} drifted from routes/ (${parts.join("; ")})
|
|
1738
|
+
message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
|
|
1388
1739
|
fix: MANIFEST_DRIFT_HINT,
|
|
1389
1740
|
suggestion: {
|
|
1390
1741
|
kind: "manual",
|
|
1391
1742
|
title: "Regenerate the committed server manifest",
|
|
1392
1743
|
steps: [
|
|
1393
|
-
"Re-run your build (`nifra build --target <t>` or your build script)
|
|
1744
|
+
"Re-run your build (`nifra build --target <t>` or your build script) - it regenerates server-manifest.ts from the current routes/.",
|
|
1394
1745
|
"Commit the updated server-manifest.ts.",
|
|
1395
1746
|
],
|
|
1396
1747
|
},
|
|
@@ -1400,24 +1751,32 @@ export async function collectCheckResult(
|
|
|
1400
1751
|
// G+B+D+F: when the project opts into capability assurance, `nifra check` becomes the static
|
|
1401
1752
|
// provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
|
|
1402
1753
|
// without nifra.assurance.ts retain the historical scan and hot path unchanged.
|
|
1754
|
+
const provided = opts.assurance
|
|
1403
1755
|
const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
|
|
1404
|
-
if (existsSync(assuranceConfigPath)) {
|
|
1756
|
+
if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
|
|
1405
1757
|
try {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
let
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
)
|
|
1420
|
-
|
|
1758
|
+
// Either the shared reflection from `collectProjectVerification` (no second pass) or, on the
|
|
1759
|
+
// standalone path, loaded + reflected here. Both branches yield the same config + evidence.
|
|
1760
|
+
let config: AssuranceConfig
|
|
1761
|
+
let project: CapabilityProjectReport | undefined
|
|
1762
|
+
let routeAssurance: AssuranceReport | undefined
|
|
1763
|
+
if (provided !== undefined) {
|
|
1764
|
+
// A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
|
|
1765
|
+
// capability-config diagnostic the standalone catch produces.
|
|
1766
|
+
if (provided.error !== undefined) throw provided.error
|
|
1767
|
+
config = provided.config as AssuranceConfig
|
|
1768
|
+
project = provided.capability
|
|
1769
|
+
routeAssurance = provided.routeAssurance
|
|
1770
|
+
} else {
|
|
1771
|
+
const { loadAssuranceConfig } = await import("./assure.ts")
|
|
1772
|
+
config = await loadAssuranceConfig(cwd)
|
|
1773
|
+
if (config.capabilities !== undefined) {
|
|
1774
|
+
const { collectCapabilityProjectReport } = await import("./capabilities-tool.ts")
|
|
1775
|
+
project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
const capabilityReport = project?.report
|
|
1779
|
+
if (config.capabilities !== undefined && project !== undefined) {
|
|
1421
1780
|
for (const finding of project.report.findings) {
|
|
1422
1781
|
const violation =
|
|
1423
1782
|
finding.code === "forbidden-effect-import"
|
|
@@ -1426,11 +1785,22 @@ export async function collectCheckResult(
|
|
|
1426
1785
|
candidate.method === finding.method && candidate.path === finding.path,
|
|
1427
1786
|
)
|
|
1428
1787
|
: undefined
|
|
1788
|
+
const truncation =
|
|
1789
|
+
finding.code === "provenance-truncated"
|
|
1790
|
+
? project.truncations.find(
|
|
1791
|
+
(candidate) =>
|
|
1792
|
+
candidate.method === finding.method && candidate.path === finding.path,
|
|
1793
|
+
)
|
|
1794
|
+
: undefined
|
|
1429
1795
|
diagnostics.push({
|
|
1430
1796
|
rule: "capability-assurance",
|
|
1431
1797
|
severity: "error",
|
|
1432
|
-
...(violation !== undefined
|
|
1433
|
-
|
|
1798
|
+
...(violation !== undefined
|
|
1799
|
+
? { file: violation.module, chain: violation.chain }
|
|
1800
|
+
: truncation !== undefined
|
|
1801
|
+
? { chain: truncation.chain }
|
|
1802
|
+
: {}),
|
|
1803
|
+
message: `${finding.message} - ${CAPABILITY_HINT}`,
|
|
1434
1804
|
fix: CAPABILITY_HINT,
|
|
1435
1805
|
suggestion: {
|
|
1436
1806
|
kind: "manual",
|
|
@@ -1438,7 +1808,7 @@ export async function collectCheckResult(
|
|
|
1438
1808
|
steps: [
|
|
1439
1809
|
"Route effectful work through an import listed in capabilities.provenance.imports.",
|
|
1440
1810
|
"Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
|
|
1441
|
-
"For domain writes, add the
|
|
1811
|
+
"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.",
|
|
1442
1812
|
"Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
|
|
1443
1813
|
],
|
|
1444
1814
|
},
|
|
@@ -1446,11 +1816,20 @@ export async function collectCheckResult(
|
|
|
1446
1816
|
}
|
|
1447
1817
|
}
|
|
1448
1818
|
if (config.manifest !== undefined) {
|
|
1449
|
-
const { evaluateRouteAssurance } = await import("@nifrajs/core/assurance")
|
|
1450
1819
|
const { buildNifraManifest, parseNifraManifest, serializeNifraManifest } = await import(
|
|
1451
1820
|
"@nifrajs/core/manifest"
|
|
1452
1821
|
)
|
|
1453
|
-
const assurance =
|
|
1822
|
+
const assurance =
|
|
1823
|
+
routeAssurance ??
|
|
1824
|
+
(await import("@nifrajs/core/assurance")).evaluateRouteAssurance(
|
|
1825
|
+
config.source,
|
|
1826
|
+
config.policy,
|
|
1827
|
+
{
|
|
1828
|
+
...(config.capabilities !== undefined
|
|
1829
|
+
? { definitions: config.capabilities.definitions }
|
|
1830
|
+
: {}),
|
|
1831
|
+
},
|
|
1832
|
+
)
|
|
1454
1833
|
const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
|
|
1455
1834
|
let message: string | undefined
|
|
1456
1835
|
if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
|
|
@@ -1480,7 +1859,7 @@ export async function collectCheckResult(
|
|
|
1480
1859
|
rule: "manifest-drift",
|
|
1481
1860
|
severity: "error",
|
|
1482
1861
|
file: path,
|
|
1483
|
-
message: `${message}
|
|
1862
|
+
message: `${message} - ${TRUST_MANIFEST_DRIFT_HINT}`,
|
|
1484
1863
|
fix: TRUST_MANIFEST_DRIFT_HINT,
|
|
1485
1864
|
suggestion: {
|
|
1486
1865
|
kind: "command",
|
|
@@ -1512,7 +1891,7 @@ export async function collectCheckResult(
|
|
|
1512
1891
|
}
|
|
1513
1892
|
|
|
1514
1893
|
// Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
|
|
1515
|
-
// message that breaks the stdio transport. `ok` reflects the FULL set
|
|
1894
|
+
// message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
|
|
1516
1895
|
const total = diagnostics.length
|
|
1517
1896
|
const max = opts.maxDiagnostics
|
|
1518
1897
|
const shown = max !== undefined && total > max ? diagnostics.slice(0, max) : diagnostics
|
|
@@ -1532,7 +1911,10 @@ export async function runCheck(
|
|
|
1532
1911
|
cwd: string,
|
|
1533
1912
|
opts: { readonly json?: boolean; readonly lintsOnly?: boolean } = {},
|
|
1534
1913
|
): Promise<boolean> {
|
|
1535
|
-
|
|
1914
|
+
// The check view over the one project verification: the same collector `assure` and `levels` read.
|
|
1915
|
+
const { collectProjectVerification } = await import("./verification.ts")
|
|
1916
|
+
const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false })
|
|
1917
|
+
const result = await verification.check()
|
|
1536
1918
|
if (opts.json) {
|
|
1537
1919
|
console.log(JSON.stringify(result, null, 2))
|
|
1538
1920
|
return result.ok
|
|
@@ -1543,7 +1925,7 @@ export async function runCheck(
|
|
|
1543
1925
|
result.typecheck === "pass"
|
|
1544
1926
|
? "✓ typecheck passed"
|
|
1545
1927
|
: result.typecheck === "fail"
|
|
1546
|
-
? "✗ typecheck failed
|
|
1928
|
+
? "✗ typecheck failed - the frontend/backend contract is broken"
|
|
1547
1929
|
: "• typecheck skipped (no tsconfig / typescript not installed)",
|
|
1548
1930
|
)
|
|
1549
1931
|
if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
|
|
@@ -1558,6 +1940,7 @@ export async function runCheck(
|
|
|
1558
1940
|
["typed-client", "hand-rolled fetch() to your own API"],
|
|
1559
1941
|
["untyped-client", 'client("…") missing its <typeof app> type argument'],
|
|
1560
1942
|
["server-only-import", "server-only import in a route module"],
|
|
1943
|
+
["interpolated-sql", "SQL built by interpolating a value into the statement"],
|
|
1561
1944
|
["response-route", "route returns a raw Response (typed client → data: never)"],
|
|
1562
1945
|
["undeclared-dependency", "undeclared dependency in package.json"],
|
|
1563
1946
|
["duplicate-install", "duplicate identity-sensitive dependency install"],
|
|
@@ -1602,5 +1985,15 @@ export async function runCheck(
|
|
|
1602
1985
|
"\ntip: no .mcp.json here - run `nifra init-agents` to wire this project's MCP + agent files (no-clobber).",
|
|
1603
1986
|
)
|
|
1604
1987
|
}
|
|
1988
|
+
// `public/` used to be served in dev (inherited from Vite) and not in production, so every app
|
|
1989
|
+
// hand-rolled static serving in its own server entry - or shipped a file that 404'd only once
|
|
1990
|
+
// deployed. `@nifrajs/web` owns it now, so an app carrying the workaround can delete it. A tip
|
|
1991
|
+
// rather than a finding: a hand-rolled handler still works (it simply runs first), so this is
|
|
1992
|
+
// not a failure, and telling an app it can delete code is the entire point.
|
|
1993
|
+
if (existsSync(join(cwd, "public"))) {
|
|
1994
|
+
console.log(
|
|
1995
|
+
'\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.',
|
|
1996
|
+
)
|
|
1997
|
+
}
|
|
1605
1998
|
return result.ok
|
|
1606
1999
|
}
|