@nifrajs/cli 2.11.0 → 2.12.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 +6 -0
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +125 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +706 -145
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +194 -10
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +28 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +173 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +53 -4
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +358 -25
- package/dist/mcp.js.map +1 -1
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +906 -15
- package/docs/types.json +1682 -130
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +873 -168
- package/src/cli.ts +198 -11
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +259 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +75 -6
- package/src/load.ts +6 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +438 -32
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- package/src/work-graph.ts +613 -0
package/dist/check.js
CHANGED
|
@@ -15,10 +15,18 @@
|
|
|
15
15
|
* `nifra_check` MCP tool, so an agent acts on diagnostics instead of scraping prose). Exits non-zero if
|
|
16
16
|
* anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
|
|
17
17
|
*/
|
|
18
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
18
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
19
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
20
|
+
import { snapshotProjectEvidence } from "@nifrajs/core/evidence";
|
|
20
21
|
import { Glob } from "bun";
|
|
21
|
-
import {
|
|
22
|
+
import { diagnostic } from "./diagnostics.js";
|
|
23
|
+
import { createSourceFacts } from "./internal/source-facts.js";
|
|
24
|
+
import { importProjectTypeScript } from "./internal/typescript-import.js";
|
|
25
|
+
import { RULE_CODES } from "./rules/codes.js";
|
|
26
|
+
import { parseRulePacks, runRuleRegistry, sourceIndex } from "./rules/index.js";
|
|
27
|
+
import { LEGACY_RULE_CODES, legacyRules } from "./rules/legacy.js";
|
|
28
|
+
import { routeRules } from "./rules/routes.js";
|
|
29
|
+
import { securityRules } from "./rules/security.js";
|
|
22
30
|
// `fetch( <ws> ('|"|`) / (not /)` - a string/template arg starting with a single `/` is a relative URL,
|
|
23
31
|
// i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
|
|
24
32
|
// `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
|
|
@@ -201,7 +209,7 @@ function isPotentialBackendSource(code) {
|
|
|
201
209
|
/(?:from\s*|import\s*)["']hono["']/.test(code) ||
|
|
202
210
|
/new\s+Hono(?:<[^>]+>)?\s*\(/.test(code));
|
|
203
211
|
}
|
|
204
|
-
function scanRoutePattern(file, content, code, pattern) {
|
|
212
|
+
function scanRoutePattern(file, content, code, pattern, facts) {
|
|
205
213
|
const out = [];
|
|
206
214
|
const lines = content.split("\n");
|
|
207
215
|
pattern.lastIndex = 0;
|
|
@@ -212,6 +220,14 @@ function scanRoutePattern(file, content, code, pattern) {
|
|
|
212
220
|
const path = parseQuotedLiteral(`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`);
|
|
213
221
|
if (path === undefined || !path.startsWith("/") || path.startsWith("//"))
|
|
214
222
|
continue;
|
|
223
|
+
if (facts !== undefined) {
|
|
224
|
+
const source = facts.parse(file, content);
|
|
225
|
+
// A parseable source model is authoritative for candidate locations. If parsing is unavailable
|
|
226
|
+
// or the source is malformed, retain the lexical finding and fail closed.
|
|
227
|
+
if (source !== undefined &&
|
|
228
|
+
facts.isRouteRegistrationAt(source, m.index, method, path) !== true)
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
215
231
|
const line = lineAt(content, m.index);
|
|
216
232
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path });
|
|
217
233
|
}
|
|
@@ -459,13 +475,13 @@ export function scanFetchText(file, content, externalMounts = []) {
|
|
|
459
475
|
return out;
|
|
460
476
|
}
|
|
461
477
|
/** Statically collect simple Nifra route registrations from source, without importing app code. */
|
|
462
|
-
export function scanStaticRouteText(file, content) {
|
|
478
|
+
export function scanStaticRouteText(file, content, facts) {
|
|
463
479
|
const code = stripComments(content);
|
|
464
480
|
if (!isPotentialBackendSource(code))
|
|
465
481
|
return [];
|
|
466
482
|
return [
|
|
467
|
-
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
|
|
468
|
-
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
|
|
483
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
484
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
469
485
|
].sort(bySite);
|
|
470
486
|
}
|
|
471
487
|
/**
|
|
@@ -498,23 +514,154 @@ const SQL_METHODS = new Set([
|
|
|
498
514
|
]);
|
|
499
515
|
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;
|
|
500
516
|
const ALWAYS_UNSAFE = new Set(["unsafe", "$queryRawUnsafe", "$executeRawUnsafe", "raw"]);
|
|
501
|
-
/**
|
|
502
|
-
* `var`, and anything nested stay out on purpose: a
|
|
503
|
-
* provably cannot carry request data. */
|
|
504
|
-
function
|
|
505
|
-
const
|
|
517
|
+
/** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
|
|
518
|
+
* no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
|
|
519
|
+
* module-scope `const` is the only form that provably cannot carry request data. */
|
|
520
|
+
function sqlModuleScope(ts, id, source) {
|
|
521
|
+
const consts = new Map();
|
|
522
|
+
const imports = new Map();
|
|
523
|
+
const localExports = new Map();
|
|
524
|
+
const reExports = new Map();
|
|
525
|
+
const starExports = [];
|
|
506
526
|
for (const statement of source.statements) {
|
|
507
|
-
if (
|
|
508
|
-
|
|
509
|
-
|
|
527
|
+
if (ts.isVariableStatement(statement)) {
|
|
528
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0)
|
|
529
|
+
continue;
|
|
530
|
+
const exported = statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
|
|
531
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
532
|
+
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
|
|
533
|
+
continue;
|
|
534
|
+
consts.set(declaration.name.text, declaration.initializer);
|
|
535
|
+
if (exported)
|
|
536
|
+
localExports.set(declaration.name.text, declaration.name.text);
|
|
537
|
+
}
|
|
510
538
|
continue;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
539
|
+
}
|
|
540
|
+
if (ts.isImportDeclaration(statement)) {
|
|
541
|
+
const clause = statement.importClause;
|
|
542
|
+
if (clause === undefined || clause.isTypeOnly)
|
|
543
|
+
continue;
|
|
544
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier))
|
|
545
|
+
continue;
|
|
546
|
+
const bindings = clause.namedBindings;
|
|
547
|
+
// Only `import { X }` / `import { X as local }`. A default or namespace import binds a value
|
|
548
|
+
// whose contents this scan cannot prove, so it stays unbound and every use of it flags.
|
|
549
|
+
if (bindings === undefined || !ts.isNamedImports(bindings))
|
|
550
|
+
continue;
|
|
551
|
+
for (const element of bindings.elements) {
|
|
552
|
+
if (element.isTypeOnly)
|
|
553
|
+
continue;
|
|
554
|
+
imports.set(element.name.text, {
|
|
555
|
+
specifier: statement.moduleSpecifier.text,
|
|
556
|
+
exported: element.propertyName?.text ?? element.name.text,
|
|
557
|
+
});
|
|
514
558
|
}
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!ts.isExportDeclaration(statement) || statement.isTypeOnly)
|
|
562
|
+
continue;
|
|
563
|
+
const from = statement.moduleSpecifier;
|
|
564
|
+
if (from !== undefined && !ts.isStringLiteral(from))
|
|
565
|
+
continue;
|
|
566
|
+
const clause = statement.exportClause;
|
|
567
|
+
if (clause === undefined) {
|
|
568
|
+
// No clause and a specifier is `export * from "./m"`. `export * as ns from "./m"` carries a
|
|
569
|
+
// NamespaceExport clause instead and falls out below, unresolved, which is what we want.
|
|
570
|
+
if (from !== undefined)
|
|
571
|
+
starExports.push(from.text);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (!ts.isNamedExports(clause))
|
|
575
|
+
continue;
|
|
576
|
+
for (const element of clause.elements) {
|
|
577
|
+
if (element.isTypeOnly)
|
|
578
|
+
continue;
|
|
579
|
+
const local = element.propertyName?.text ?? element.name.text;
|
|
580
|
+
if (from === undefined)
|
|
581
|
+
localExports.set(element.name.text, local);
|
|
582
|
+
else
|
|
583
|
+
reExports.set(element.name.text, { specifier: from.text, exported: local });
|
|
515
584
|
}
|
|
516
585
|
}
|
|
517
|
-
return
|
|
586
|
+
return { id, consts, imports, localExports, reExports, starExports };
|
|
587
|
+
}
|
|
588
|
+
export const createSqlImports = (load) => ({
|
|
589
|
+
load,
|
|
590
|
+
scopes: new Map(),
|
|
591
|
+
});
|
|
592
|
+
/** Parse a module for the SQL scan, or `undefined` when it does not parse. Malformed source is already
|
|
593
|
+
* a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
|
|
594
|
+
* parse contributes no findings and proves no constant. */
|
|
595
|
+
function parseSqlSource(ts, file, content) {
|
|
596
|
+
const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS;
|
|
597
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
|
|
598
|
+
const parseDiagnostics = source.parseDiagnostics;
|
|
599
|
+
return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source;
|
|
600
|
+
}
|
|
601
|
+
/** Module-boundary hops one name may cross. Barrels are why it is not 1: `route → index → fragments`
|
|
602
|
+
* already spends two, and the third leaves room for one more layer without letting the scan wander the
|
|
603
|
+
* module graph. Past the cap the name is unresolved, so the interpolation flags. */
|
|
604
|
+
const SQL_IMPORT_HOPS = 3;
|
|
605
|
+
/** Load + parse the module a specifier names, through the run's cache. */
|
|
606
|
+
function loadSqlModuleScope(ts, imports, fromModule, specifier) {
|
|
607
|
+
const loaded = imports.load(fromModule, specifier);
|
|
608
|
+
if (loaded === undefined)
|
|
609
|
+
return undefined;
|
|
610
|
+
if (imports.scopes.has(loaded.id))
|
|
611
|
+
return imports.scopes.get(loaded.id);
|
|
612
|
+
const source = parseSqlSource(ts, loaded.id, loaded.content);
|
|
613
|
+
const scope = source === undefined ? undefined : sqlModuleScope(ts, loaded.id, source);
|
|
614
|
+
imports.scopes.set(loaded.id, scope);
|
|
615
|
+
return scope;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Follow an exported name to its `const` initializer, across re-export hops.
|
|
619
|
+
*
|
|
620
|
+
* Handles the three forms a fragments module actually uses: `export const NAME = …`, the two-statement
|
|
621
|
+
* `const NAME = …; export { NAME }` (including the pass-through `import { A } …; export { A }`), and
|
|
622
|
+
* barrels - `export { X } from "./m"` and `export * from "./m"`. Bounded by {@link SQL_IMPORT_HOPS} and
|
|
623
|
+
* a per-chain visited set, so a cyclic barrel terminates instead of recursing.
|
|
624
|
+
*
|
|
625
|
+
* Two `export *` sources providing the same name is a conflict, and the scan refuses rather than
|
|
626
|
+
* picking one: guessing here would silence a real finding.
|
|
627
|
+
*/
|
|
628
|
+
function resolveExportedConst(ts, imports, scope, name, hops, seen) {
|
|
629
|
+
if (hops > SQL_IMPORT_HOPS)
|
|
630
|
+
return undefined;
|
|
631
|
+
const key = `${scope.id}#${name}`;
|
|
632
|
+
if (seen.has(key))
|
|
633
|
+
return undefined;
|
|
634
|
+
const nextSeen = new Set(seen).add(key);
|
|
635
|
+
const follow = (edge) => {
|
|
636
|
+
const target = loadSqlModuleScope(ts, imports, scope.id, edge.specifier);
|
|
637
|
+
return target === undefined
|
|
638
|
+
? undefined
|
|
639
|
+
: resolveExportedConst(ts, imports, target, edge.exported, hops + 1, nextSeen);
|
|
640
|
+
};
|
|
641
|
+
const local = scope.localExports.get(name);
|
|
642
|
+
if (local !== undefined) {
|
|
643
|
+
const initializer = scope.consts.get(local);
|
|
644
|
+
if (initializer !== undefined)
|
|
645
|
+
return { expression: initializer, scope };
|
|
646
|
+
// `import { A } from "./a"; export { A }` - the same pass-through as `export { A } from "./a"`,
|
|
647
|
+
// written in two statements. Anything else the name could be (a `let`, a function, a class) has no
|
|
648
|
+
// initializer in `consts` and no import edge, so it lands on `undefined` and flags.
|
|
649
|
+
const via = scope.imports.get(local);
|
|
650
|
+
return via === undefined ? undefined : follow(via);
|
|
651
|
+
}
|
|
652
|
+
const reExported = scope.reExports.get(name);
|
|
653
|
+
if (reExported !== undefined)
|
|
654
|
+
return follow(reExported);
|
|
655
|
+
let found;
|
|
656
|
+
for (const specifier of scope.starExports) {
|
|
657
|
+
const hit = follow({ specifier, exported: name });
|
|
658
|
+
if (hit === undefined)
|
|
659
|
+
continue;
|
|
660
|
+
if (found !== undefined)
|
|
661
|
+
return undefined;
|
|
662
|
+
found = hit;
|
|
663
|
+
}
|
|
664
|
+
return found;
|
|
518
665
|
}
|
|
519
666
|
/** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
|
|
520
667
|
* Conservative on purpose: the OUTERMOST enclosing function/block subtree is scanned for ANY binding
|
|
@@ -561,19 +708,20 @@ const CONST_RESOLUTION_DEPTH = 5;
|
|
|
561
708
|
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
562
709
|
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
563
710
|
* ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
|
|
564
|
-
* either still feeds the keyword scan), `as`/`satisfies` wrappers,
|
|
565
|
-
*
|
|
566
|
-
*
|
|
711
|
+
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
|
|
712
|
+
* initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
|
|
713
|
+
* `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
|
|
714
|
+
* call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
|
|
567
715
|
* interpolation is flagged exactly as before.
|
|
568
716
|
*/
|
|
569
|
-
function resolveStaticSqlText(ts, node,
|
|
717
|
+
function resolveStaticSqlText(ts, node, scope, depth) {
|
|
570
718
|
if (depth > CONST_RESOLUTION_DEPTH)
|
|
571
719
|
return undefined;
|
|
572
720
|
if (ts.isParenthesizedExpression(node)) {
|
|
573
|
-
return resolveStaticSqlText(ts, node.expression,
|
|
721
|
+
return resolveStaticSqlText(ts, node.expression, scope, depth);
|
|
574
722
|
}
|
|
575
723
|
if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
576
|
-
return resolveStaticSqlText(ts, node.expression,
|
|
724
|
+
return resolveStaticSqlText(ts, node.expression, scope, depth);
|
|
577
725
|
}
|
|
578
726
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
579
727
|
return node.text;
|
|
@@ -582,7 +730,7 @@ function resolveStaticSqlText(ts, node, consts, depth) {
|
|
|
582
730
|
if (ts.isTemplateExpression(node)) {
|
|
583
731
|
let text = node.head.text;
|
|
584
732
|
for (const span of node.templateSpans) {
|
|
585
|
-
const part = resolveStaticSqlText(ts, span.expression,
|
|
733
|
+
const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1);
|
|
586
734
|
if (part === undefined)
|
|
587
735
|
return undefined;
|
|
588
736
|
text += part + span.literal.text;
|
|
@@ -590,29 +738,48 @@ function resolveStaticSqlText(ts, node, consts, depth) {
|
|
|
590
738
|
return text;
|
|
591
739
|
}
|
|
592
740
|
if (ts.isConditionalExpression(node)) {
|
|
593
|
-
const whenTrue = resolveStaticSqlText(ts, node.whenTrue,
|
|
594
|
-
const whenFalse = resolveStaticSqlText(ts, node.whenFalse,
|
|
741
|
+
const whenTrue = resolveStaticSqlText(ts, node.whenTrue, scope, depth + 1);
|
|
742
|
+
const whenFalse = resolveStaticSqlText(ts, node.whenFalse, scope, depth + 1);
|
|
595
743
|
if (whenTrue === undefined || whenFalse === undefined)
|
|
596
744
|
return undefined;
|
|
597
745
|
return `${whenTrue} ${whenFalse}`;
|
|
598
746
|
}
|
|
599
747
|
if (ts.isIdentifier(node)) {
|
|
600
|
-
const initializer = consts.get(node.text);
|
|
601
|
-
|
|
748
|
+
const initializer = scope.module.consts.get(node.text);
|
|
749
|
+
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined;
|
|
750
|
+
// Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
|
|
751
|
+
// module const nor an import binding.
|
|
752
|
+
if (initializer === undefined && edge === undefined)
|
|
602
753
|
return undefined;
|
|
754
|
+
// Shadowing is a property of where the identifier is WRITTEN, so one check is right on both sides
|
|
755
|
+
// of an import hop: an initializer in the imported module sits at module scope with nothing above
|
|
756
|
+
// it to shadow it, while a use inside a function in this file is still refused.
|
|
603
757
|
if (isShadowedAt(ts, node, node.text))
|
|
604
758
|
return undefined;
|
|
605
|
-
|
|
759
|
+
if (initializer !== undefined)
|
|
760
|
+
return resolveStaticSqlText(ts, initializer, scope, depth + 1);
|
|
761
|
+
if (edge === undefined || scope.imports === undefined)
|
|
762
|
+
return undefined;
|
|
763
|
+
const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier);
|
|
764
|
+
if (target === undefined)
|
|
765
|
+
return undefined;
|
|
766
|
+
const exported = resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set());
|
|
767
|
+
if (exported === undefined)
|
|
768
|
+
return undefined;
|
|
769
|
+
// The imported initializer's own identifiers read THAT module's consts, never this file's. A
|
|
770
|
+
// caller that happens to have its own `const DIR` must not silently supply the fragment's `DIR`,
|
|
771
|
+
// which is the one way cross-module resolution could resolve to text nobody wrote.
|
|
772
|
+
return resolveStaticSqlText(ts, exported.expression, { module: exported.scope, imports: scope.imports }, depth + 1);
|
|
606
773
|
}
|
|
607
774
|
return undefined;
|
|
608
775
|
}
|
|
609
776
|
/** A template's contribution to the shape: resolved span texts fold into `staticText` (so the
|
|
610
777
|
* keyword scan still sees what a `const` carries); any unresolvable span keeps it `dynamic`. */
|
|
611
|
-
function templateShape(ts, template,
|
|
778
|
+
function templateShape(ts, template, scope) {
|
|
612
779
|
let staticText = template.head.text;
|
|
613
780
|
let dynamic = false;
|
|
614
781
|
for (const span of template.templateSpans) {
|
|
615
|
-
const resolved =
|
|
782
|
+
const resolved = scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0);
|
|
616
783
|
if (resolved === undefined)
|
|
617
784
|
dynamic = true;
|
|
618
785
|
else
|
|
@@ -622,9 +789,9 @@ function templateShape(ts, template, consts) {
|
|
|
622
789
|
return { staticText, dynamic };
|
|
623
790
|
}
|
|
624
791
|
/** Describe only the first argument's syntax; comments/strings elsewhere cannot influence the result. */
|
|
625
|
-
function sqlExpressionShape(ts, node,
|
|
792
|
+
function sqlExpressionShape(ts, node, scope) {
|
|
626
793
|
if (ts.isParenthesizedExpression(node))
|
|
627
|
-
return sqlExpressionShape(ts, node.expression,
|
|
794
|
+
return sqlExpressionShape(ts, node.expression, scope);
|
|
628
795
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
629
796
|
return {
|
|
630
797
|
staticText: node.text,
|
|
@@ -640,7 +807,7 @@ function sqlExpressionShape(ts, node, consts) {
|
|
|
640
807
|
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql");
|
|
641
808
|
const template = node.template;
|
|
642
809
|
const templated = ts.isTemplateExpression(template)
|
|
643
|
-
? templateShape(ts, template, parameterizedTag ? undefined :
|
|
810
|
+
? templateShape(ts, template, parameterizedTag ? undefined : scope)
|
|
644
811
|
: { staticText: template.text, dynamic: false };
|
|
645
812
|
const staticText = templated.staticText;
|
|
646
813
|
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
@@ -665,7 +832,7 @@ function sqlExpressionShape(ts, node, consts) {
|
|
|
665
832
|
};
|
|
666
833
|
}
|
|
667
834
|
if (ts.isTemplateExpression(node)) {
|
|
668
|
-
const templated = templateShape(ts, node,
|
|
835
|
+
const templated = templateShape(ts, node, scope);
|
|
669
836
|
return {
|
|
670
837
|
staticText: templated.staticText,
|
|
671
838
|
dynamic: templated.dynamic,
|
|
@@ -675,8 +842,8 @@ function sqlExpressionShape(ts, node, consts) {
|
|
|
675
842
|
};
|
|
676
843
|
}
|
|
677
844
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
678
|
-
const left = sqlExpressionShape(ts, node.left,
|
|
679
|
-
const right = sqlExpressionShape(ts, node.right,
|
|
845
|
+
const left = sqlExpressionShape(ts, node.left, scope);
|
|
846
|
+
const right = sqlExpressionShape(ts, node.right, scope);
|
|
680
847
|
return {
|
|
681
848
|
staticText: left.staticText + right.staticText,
|
|
682
849
|
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
@@ -690,11 +857,11 @@ function sqlExpressionShape(ts, node, consts) {
|
|
|
690
857
|
}
|
|
691
858
|
// A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
|
|
692
859
|
// text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
|
|
693
|
-
// `db.query(`${COLS} FROM t WHERE id = $1`)` pass while
|
|
860
|
+
// `db.query(`${COLS} FROM t WHERE id = $1`)` pass while a mutable or unprovable name still flags.
|
|
694
861
|
// `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
|
|
695
862
|
// resolved const, because taking statement text from ANY variable is what their rule warns about.
|
|
696
|
-
if (
|
|
697
|
-
const resolved = resolveStaticSqlText(ts, node,
|
|
863
|
+
if (scope !== undefined) {
|
|
864
|
+
const resolved = resolveStaticSqlText(ts, node, scope, 0);
|
|
698
865
|
if (resolved !== undefined) {
|
|
699
866
|
return {
|
|
700
867
|
staticText: resolved,
|
|
@@ -731,27 +898,31 @@ function sqlMethod(ts, call) {
|
|
|
731
898
|
return undefined;
|
|
732
899
|
}
|
|
733
900
|
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i;
|
|
734
|
-
/**
|
|
735
|
-
|
|
901
|
+
/**
|
|
902
|
+
* Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
|
|
903
|
+
*
|
|
904
|
+
* `imports` opts into following a fragment `const` into another module. Without it the scan is
|
|
905
|
+
* same-file only, which is what the unit tests exercise and what any caller that has no module graph
|
|
906
|
+
* to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
|
|
907
|
+
*/
|
|
908
|
+
export function scanInterpolatedSql(file, content, ts, imports) {
|
|
736
909
|
const out = [];
|
|
737
910
|
const lines = content.split("\n");
|
|
738
|
-
const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS;
|
|
739
|
-
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind);
|
|
740
911
|
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
741
912
|
// misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
|
|
742
|
-
const
|
|
743
|
-
if (
|
|
913
|
+
const source = parseSqlSource(ts, file, content);
|
|
914
|
+
if (source === undefined)
|
|
744
915
|
return out;
|
|
745
|
-
// Module-scope
|
|
746
|
-
// request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
747
|
-
// while
|
|
748
|
-
const
|
|
916
|
+
// Module-scope bindings, read once per file: an interpolated `const` that resolves to compile-time
|
|
917
|
+
// text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
918
|
+
// `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
|
|
919
|
+
const scope = { module: sqlModuleScope(ts, file, source), imports };
|
|
749
920
|
const visit = (node) => {
|
|
750
921
|
if (ts.isCallExpression(node)) {
|
|
751
922
|
const sink = sqlMethod(ts, node);
|
|
752
923
|
const argument = node.arguments[0];
|
|
753
924
|
if (sink !== undefined && argument !== undefined) {
|
|
754
|
-
const shape = sqlExpressionShape(ts, argument,
|
|
925
|
+
const shape = sqlExpressionShape(ts, argument, scope);
|
|
755
926
|
const unsafeEscape = ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag;
|
|
756
927
|
const assembledSql = shape.dynamic &&
|
|
757
928
|
(SQL_KEYWORD.test(shape.staticText) ||
|
|
@@ -777,13 +948,65 @@ export function scanInterpolatedSql(file, content, ts) {
|
|
|
777
948
|
* answer a security rule must never give when it did not run.
|
|
778
949
|
*/
|
|
779
950
|
export async function scanProjectSql(cwd) {
|
|
780
|
-
const ts = await
|
|
951
|
+
const ts = await importProjectTypeScript(cwd);
|
|
781
952
|
if (ts === undefined)
|
|
782
953
|
return undefined;
|
|
783
954
|
const out = [];
|
|
784
|
-
|
|
955
|
+
const imports = createProjectSqlImports(cwd);
|
|
956
|
+
await walkSource(cwd, (rel, content) => out.push(...scanInterpolatedSql(rel, content, ts, imports)));
|
|
785
957
|
return out.sort(bySite);
|
|
786
958
|
}
|
|
959
|
+
/** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
|
|
960
|
+
* initializer to read, so following one could only ever produce a "proved" the source never wrote. */
|
|
961
|
+
const SQL_DECLARATION_FILE = /\.d\.[cm]?ts$/;
|
|
962
|
+
/** The extensions the SQL scan will parse - the same set {@link walkSource} globs. */
|
|
963
|
+
const SQL_SOURCE_FILE = /\.[cm]?tsx?$/;
|
|
964
|
+
/**
|
|
965
|
+
* The on-disk loader for cross-module `const` resolution, scoped to one project.
|
|
966
|
+
*
|
|
967
|
+
* Deliberately narrow, because every widening here is a way for a real finding to go quiet:
|
|
968
|
+
*
|
|
969
|
+
* - **Relative specifiers only.** A bare specifier names a dependency whose exports can change without
|
|
970
|
+
* this project changing, so "provably a `const` string" is not a property the project owns.
|
|
971
|
+
* - **Inside `cwd`, outside the ignored trees.** The resolved path is realpath'd and must still sit
|
|
972
|
+
* under the project root: a symlink pointing out of the tree is not source this scan reads. The
|
|
973
|
+
* `node_modules` / `dist` / build exclusions are the same ones the walk applies.
|
|
974
|
+
* - **Real source only.** A `.d.ts` carries no initializer, and a non-TS file is not parsed.
|
|
975
|
+
*
|
|
976
|
+
* A miss on any of those returns `undefined`, which leaves the name unresolved and the interpolation
|
|
977
|
+
* flagged - the pre-feature answer.
|
|
978
|
+
*/
|
|
979
|
+
export function createProjectSqlImports(cwd) {
|
|
980
|
+
const root = (() => {
|
|
981
|
+
try {
|
|
982
|
+
return realpathSync(resolve(cwd));
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
return resolve(cwd);
|
|
986
|
+
}
|
|
987
|
+
})();
|
|
988
|
+
return createSqlImports((fromModule, specifier) => {
|
|
989
|
+
if (!isRelativeSpecifier(specifier))
|
|
990
|
+
return undefined;
|
|
991
|
+
try {
|
|
992
|
+
// `fromModule` is the cwd-RELATIVE path the walk supplies for an entry file, and ABSOLUTE for
|
|
993
|
+
// every module reached through a hop (their ids are the resolved paths).
|
|
994
|
+
const fromAbs = isAbsolute(fromModule) ? fromModule : join(root, fromModule);
|
|
995
|
+
const resolved = realpathSync(Bun.resolveSync(specifier, dirname(fromAbs)));
|
|
996
|
+
if (!resolved.startsWith(root + sep))
|
|
997
|
+
return undefined;
|
|
998
|
+
const rel = resolved.slice(root.length + 1);
|
|
999
|
+
if (IGNORED_DIR.test(rel))
|
|
1000
|
+
return undefined;
|
|
1001
|
+
if (SQL_DECLARATION_FILE.test(resolved) || !SQL_SOURCE_FILE.test(resolved))
|
|
1002
|
+
return undefined;
|
|
1003
|
+
return { id: resolved, content: readFileSync(resolved, "utf8") };
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
return undefined; // unresolvable, unreadable, or outside the project
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
787
1010
|
// `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
|
|
788
1011
|
// the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
|
|
789
1012
|
// `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
|
|
@@ -857,7 +1080,7 @@ export function scanRemovedImports(file, content) {
|
|
|
857
1080
|
/** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
|
|
858
1081
|
* `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
|
|
859
1082
|
* the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
|
|
860
|
-
export function scanServerOnlyImports(file, content) {
|
|
1083
|
+
export function scanServerOnlyImports(file, content, facts) {
|
|
861
1084
|
if (!ROUTE_FILE.test(file))
|
|
862
1085
|
return [];
|
|
863
1086
|
const out = [];
|
|
@@ -871,6 +1094,13 @@ export function scanServerOnlyImports(file, content) {
|
|
|
871
1094
|
const specifier = m[1] ?? "";
|
|
872
1095
|
if (!SERVER_ONLY.test(specifier))
|
|
873
1096
|
continue;
|
|
1097
|
+
if (facts !== undefined) {
|
|
1098
|
+
const source = facts.parse(file, content);
|
|
1099
|
+
// Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
|
|
1100
|
+
// runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
|
|
1101
|
+
if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
874
1104
|
const line = lineAt(content, m.index);
|
|
875
1105
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier });
|
|
876
1106
|
}
|
|
@@ -910,7 +1140,7 @@ const staticImportRegex = () => new RegExp(STATIC_IMPORT.source, STATIC_IMPORT.f
|
|
|
910
1140
|
* each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
|
|
911
1141
|
* dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
|
|
912
1142
|
* transitive walk. Pure. */
|
|
913
|
-
function staticImportEdges(content) {
|
|
1143
|
+
function staticImportEdges(content, facts, file) {
|
|
914
1144
|
const edges = [];
|
|
915
1145
|
const re = staticImportRegex();
|
|
916
1146
|
const code = stripComments(content);
|
|
@@ -918,15 +1148,21 @@ function staticImportEdges(content) {
|
|
|
918
1148
|
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
919
1149
|
if (positions[m.index] === " ")
|
|
920
1150
|
continue;
|
|
921
|
-
if (m[1]
|
|
922
|
-
|
|
1151
|
+
if (m[1] === undefined)
|
|
1152
|
+
continue;
|
|
1153
|
+
if (facts !== undefined && file !== undefined) {
|
|
1154
|
+
const source = facts.parse(file, content);
|
|
1155
|
+
if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false)
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
edges.push({ specifier: m[1], index: m.index });
|
|
923
1159
|
}
|
|
924
1160
|
return edges;
|
|
925
1161
|
}
|
|
926
1162
|
/** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
|
|
927
1163
|
* Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
|
|
928
|
-
export function parseStaticImports(content) {
|
|
929
|
-
return staticImportEdges(content).map((e) => e.specifier);
|
|
1164
|
+
export function parseStaticImports(content, facts, file) {
|
|
1165
|
+
return staticImportEdges(content, facts, file).map((e) => e.specifier);
|
|
930
1166
|
}
|
|
931
1167
|
/** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
|
|
932
1168
|
* server-only package), or `undefined` if it isn't a by-name sink. Pure - the label is the specifier
|
|
@@ -944,7 +1180,7 @@ export function directSinkSpecifier(spec) {
|
|
|
944
1180
|
* it's linear and cycle-free. `routeFile`/`routeContent` seed the walk; `resolve`/`read` supply the graph
|
|
945
1181
|
* - pure given those, so it's unit-testable with a fake graph.
|
|
946
1182
|
*/
|
|
947
|
-
export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
|
|
1183
|
+
export function walkServerOnlyChain(routeFile, routeContent, resolve, read, facts) {
|
|
948
1184
|
const seen = new Set([routeFile]);
|
|
949
1185
|
let frontier = [{ abs: routeFile, content: routeContent, chain: [routeFile], depth: 0 }];
|
|
950
1186
|
let visited = 0;
|
|
@@ -953,7 +1189,7 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
|
|
|
953
1189
|
for (const node of frontier) {
|
|
954
1190
|
if (node.depth >= TRANSITIVE_MAX_DEPTH)
|
|
955
1191
|
continue;
|
|
956
|
-
for (const spec of parseStaticImports(node.content)) {
|
|
1192
|
+
for (const spec of parseStaticImports(node.content, facts, node.abs)) {
|
|
957
1193
|
// (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
|
|
958
1194
|
// since BFS reaches the nearest sink before any deeper one).
|
|
959
1195
|
const sink = directSinkSpecifier(spec);
|
|
@@ -995,7 +1231,7 @@ export function walkServerOnlyChain(routeFile, routeContent, resolve, read) {
|
|
|
995
1231
|
* edge (`fallback: true`), never a fabricated chain. Returns one finding per offending top-level import,
|
|
996
1232
|
* de-duplicated to the shortest chain per first-hop specifier. Non-route files yield `[]`.
|
|
997
1233
|
*/
|
|
998
|
-
export function resolveServerOnlyChains(file, content, resolve, read) {
|
|
1234
|
+
export function resolveServerOnlyChains(file, content, resolve, read, facts) {
|
|
999
1235
|
if (!ROUTE_FILE.test(file))
|
|
1000
1236
|
return [];
|
|
1001
1237
|
const lines = content.split("\n");
|
|
@@ -1004,7 +1240,7 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
|
|
|
1004
1240
|
// Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
|
|
1005
1241
|
// the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
|
|
1006
1242
|
// imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
|
|
1007
|
-
for (const { specifier, index } of staticImportEdges(content)) {
|
|
1243
|
+
for (const { specifier, index } of staticImportEdges(content, facts, file)) {
|
|
1008
1244
|
if (flaggedSpecifiers.has(specifier))
|
|
1009
1245
|
continue;
|
|
1010
1246
|
const line = lineAt(content, index);
|
|
@@ -1045,7 +1281,7 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
|
|
|
1045
1281
|
continue;
|
|
1046
1282
|
}
|
|
1047
1283
|
// Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
|
|
1048
|
-
const subChain = walkServerOnlyChain(abs, depContent, resolve, read);
|
|
1284
|
+
const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts);
|
|
1049
1285
|
if (subChain !== undefined) {
|
|
1050
1286
|
flaggedSpecifiers.add(specifier);
|
|
1051
1287
|
// subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
|
|
@@ -1072,7 +1308,7 @@ export function resolveServerOnlyChains(file, content, resolve, read) {
|
|
|
1072
1308
|
const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*json)\s*\(/g;
|
|
1073
1309
|
/** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
|
|
1074
1310
|
* the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
|
|
1075
|
-
export function scanResponseRoutes(file, content) {
|
|
1311
|
+
export function scanResponseRoutes(file, content, facts) {
|
|
1076
1312
|
// Strip comments + template literals first: a commented-out or doc-example `return new Response(`
|
|
1077
1313
|
// must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
|
|
1078
1314
|
const code = stripComments(content);
|
|
@@ -1082,6 +1318,13 @@ export function scanResponseRoutes(file, content) {
|
|
|
1082
1318
|
const lines = content.split("\n");
|
|
1083
1319
|
RESPONSE_RETURN.lastIndex = 0;
|
|
1084
1320
|
for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
|
|
1321
|
+
if (facts !== undefined) {
|
|
1322
|
+
const source = facts.parse(file, content);
|
|
1323
|
+
// A parseable AST rejects `return new Response(` text that only lives in a normal string. On
|
|
1324
|
+
// malformed source we keep the lexical candidate; the typecheck gate will report the syntax error.
|
|
1325
|
+
if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true)
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1085
1328
|
const line = lineAt(content, m.index);
|
|
1086
1329
|
// A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
|
|
1087
1330
|
// be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
|
|
@@ -1213,14 +1456,38 @@ export async function scanServerManifestDrift(cwd) {
|
|
|
1213
1456
|
}
|
|
1214
1457
|
return findings.sort((a, b) => a.file.localeCompare(b.file));
|
|
1215
1458
|
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Find the project's `tsc` the way module resolution would: `node_modules/typescript/bin/tsc` in
|
|
1461
|
+
* cwd, then each parent directory up to the filesystem root. A workspace package in a monorepo has
|
|
1462
|
+
* its TypeScript hoisted to the workspace root, so the literal `join(cwd, "node_modules", …)` probe
|
|
1463
|
+
* this replaces reported "typescript not installed" - and silently skipped the gate - whenever
|
|
1464
|
+
* `nifra check` ran from the package directory instead of the repo root.
|
|
1465
|
+
*/
|
|
1466
|
+
function resolveTscBin(cwd) {
|
|
1467
|
+
let dir = cwd;
|
|
1468
|
+
while (true) {
|
|
1469
|
+
const candidate = join(dir, "node_modules", "typescript", "bin", "tsc");
|
|
1470
|
+
if (existsSync(candidate))
|
|
1471
|
+
return candidate;
|
|
1472
|
+
const parent = dirname(dir);
|
|
1473
|
+
if (parent === dir)
|
|
1474
|
+
return undefined;
|
|
1475
|
+
dir = parent;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1216
1478
|
/** Run the project's own `tsc --noEmit`, if TypeScript + a tsconfig are present. Never auto-installs. */
|
|
1217
1479
|
async function typecheck(cwd, signal) {
|
|
1218
1480
|
const tsconfig = join(cwd, "tsconfig.json");
|
|
1219
|
-
const tscBin = join(cwd, "node_modules", "typescript", "bin", "tsc");
|
|
1220
1481
|
if (!(await Bun.file(tsconfig).exists()))
|
|
1221
1482
|
return { ran: false, ok: true, note: "no tsconfig.json" };
|
|
1222
|
-
|
|
1223
|
-
|
|
1483
|
+
const tscBin = resolveTscBin(cwd);
|
|
1484
|
+
if (tscBin === undefined) {
|
|
1485
|
+
return {
|
|
1486
|
+
ran: false,
|
|
1487
|
+
ok: false,
|
|
1488
|
+
missingTypeScript: true,
|
|
1489
|
+
note: "typescript not installed (run: bun add -d typescript)",
|
|
1490
|
+
};
|
|
1224
1491
|
}
|
|
1225
1492
|
if (signal?.aborted)
|
|
1226
1493
|
return { ran: true, ok: false, cancelled: true, output: "cancelled" };
|
|
@@ -1260,25 +1527,69 @@ async function typecheck(cwd, signal) {
|
|
|
1260
1527
|
}
|
|
1261
1528
|
// `src/x.tsx(12,5): error TS2322: <message>` → one structured diagnostic.
|
|
1262
1529
|
const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/;
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
*
|
|
1530
|
+
const OVERRIDE_SEVERITIES = ["error", "warn", "info", "off"];
|
|
1531
|
+
/** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed JSON → empty plus a
|
|
1532
|
+
* parse `error`; an invalid entry → skipped plus a `warnings` note the caller surfaces as a `warning`
|
|
1533
|
+
* diagnostic (never throws, never blocks the gate). Mount entries are normalized to bare path prefixes
|
|
1534
|
+
* (`/auth/**` and `/auth/` both become `/auth`). */
|
|
1266
1535
|
async function loadCheckConfig(cwd) {
|
|
1267
1536
|
const path = join(cwd, "nifra.check.json");
|
|
1537
|
+
const empty = { externalMounts: [], rules: {} };
|
|
1268
1538
|
if (!existsSync(path))
|
|
1269
|
-
return { config:
|
|
1539
|
+
return { config: empty, warnings: [] };
|
|
1270
1540
|
try {
|
|
1271
1541
|
const parsed = JSON.parse(await Bun.file(path).text());
|
|
1272
1542
|
const raw = Array.isArray(parsed.externalMounts) ? parsed.externalMounts : [];
|
|
1273
1543
|
const externalMounts = raw
|
|
1274
1544
|
.filter((m) => typeof m === "string" && m.startsWith("/"))
|
|
1275
1545
|
.map((m) => m.replace(/\/\*+$/, "").replace(/\/+$/, "") || "/");
|
|
1276
|
-
|
|
1546
|
+
const warnings = [];
|
|
1547
|
+
const rules = {};
|
|
1548
|
+
if (parsed.rules !== undefined) {
|
|
1549
|
+
if (typeof parsed.rules !== "object" ||
|
|
1550
|
+
parsed.rules === null ||
|
|
1551
|
+
Array.isArray(parsed.rules)) {
|
|
1552
|
+
warnings.push('`rules` must be an object of { "<rule>": { severity?, ignore? } } - ignored');
|
|
1553
|
+
}
|
|
1554
|
+
else {
|
|
1555
|
+
for (const [rule, value] of Object.entries(parsed.rules)) {
|
|
1556
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1557
|
+
warnings.push(`rules["${rule}"] must be an object - ignored`);
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
const entry = value;
|
|
1561
|
+
const override = {};
|
|
1562
|
+
if (entry.severity !== undefined) {
|
|
1563
|
+
if (typeof entry.severity === "string" &&
|
|
1564
|
+
OVERRIDE_SEVERITIES.includes(entry.severity)) {
|
|
1565
|
+
override.severity = entry.severity;
|
|
1566
|
+
}
|
|
1567
|
+
else {
|
|
1568
|
+
warnings.push(`rules["${rule}"].severity must be "error" | "warn" | "info" | "off" - ignored`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (entry.ignore !== undefined) {
|
|
1572
|
+
if (Array.isArray(entry.ignore) &&
|
|
1573
|
+
entry.ignore.every((g) => typeof g === "string")) {
|
|
1574
|
+
if (entry.ignore.length > 0)
|
|
1575
|
+
override.ignore = entry.ignore;
|
|
1576
|
+
}
|
|
1577
|
+
else {
|
|
1578
|
+
warnings.push(`rules["${rule}"].ignore must be an array of file globs - ignored`);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (override.severity !== undefined || override.ignore !== undefined)
|
|
1582
|
+
rules[rule] = override;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return { config: { externalMounts, rules }, warnings };
|
|
1277
1587
|
}
|
|
1278
1588
|
catch (error) {
|
|
1279
1589
|
return {
|
|
1280
|
-
config:
|
|
1590
|
+
config: empty,
|
|
1281
1591
|
error: error instanceof Error ? error.message : String(error),
|
|
1592
|
+
warnings: [],
|
|
1282
1593
|
};
|
|
1283
1594
|
}
|
|
1284
1595
|
}
|
|
@@ -1427,29 +1738,38 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1427
1738
|
// Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
|
|
1428
1739
|
// (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
|
|
1429
1740
|
const routeModules = [];
|
|
1741
|
+
const sourceFiles = [];
|
|
1430
1742
|
// Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
|
|
1431
1743
|
// walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
|
|
1432
|
-
const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd);
|
|
1744
|
+
const { config: checkConfig, error: checkConfigError, warnings: checkConfigWarnings, } = await loadCheckConfig(cwd);
|
|
1433
1745
|
// The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
|
|
1434
1746
|
// once, before the walk, so a project without it is told the rule did not run rather than being
|
|
1435
1747
|
// handed a clean report the rule never produced.
|
|
1436
|
-
const sqlCompiler = await (opts.loadTypeScript ??
|
|
1748
|
+
const sqlCompiler = await (opts.loadTypeScript ?? (() => importProjectTypeScript(cwd)))();
|
|
1749
|
+
// AST facts are a lazy refinement of lexical candidates. The parser is not invoked for a file until
|
|
1750
|
+
// one of the route/import/response scanners finds something worth disambiguating, and the cache is
|
|
1751
|
+
// shared across those rules for this check run.
|
|
1752
|
+
const sourceFacts = sqlCompiler === undefined ? undefined : createSourceFacts(sqlCompiler);
|
|
1753
|
+
// One loader + parse cache for the whole walk, so a shared `sql-fragments.ts` is read once no matter
|
|
1754
|
+
// how many route modules import a fragment out of it.
|
|
1755
|
+
const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd);
|
|
1437
1756
|
// lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
|
|
1438
1757
|
// lints - the agent inner-loop mode; the full gate stays the definition of done.
|
|
1439
1758
|
const [tc, _, dr, manifestDrift] = await Promise.all([
|
|
1440
1759
|
opts.lintsOnly
|
|
1441
|
-
? Promise.resolve({ ran: false, ok: true, note: "
|
|
1760
|
+
? Promise.resolve({ ran: false, ok: true, note: "lints-only mode" })
|
|
1442
1761
|
: typecheck(cwd, opts.signal),
|
|
1443
1762
|
walkSource(cwd, (rel, content) => {
|
|
1763
|
+
sourceFiles.push({ file: rel, content });
|
|
1444
1764
|
fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts));
|
|
1445
|
-
staticRoutes.push(...scanStaticRouteText(rel, content));
|
|
1765
|
+
staticRoutes.push(...scanStaticRouteText(rel, content, sourceFacts));
|
|
1446
1766
|
untypedClients.push(...scanUntypedClient(rel, content));
|
|
1447
1767
|
removedImports.push(...scanRemovedImports(rel, content));
|
|
1448
1768
|
if (ROUTE_FILE.test(rel))
|
|
1449
1769
|
routeModules.push({ rel, content });
|
|
1450
|
-
responseRoutes.push(...scanResponseRoutes(rel, content));
|
|
1770
|
+
responseRoutes.push(...scanResponseRoutes(rel, content, sourceFacts));
|
|
1451
1771
|
if (sqlCompiler !== undefined)
|
|
1452
|
-
interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler));
|
|
1772
|
+
interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler, sqlImports));
|
|
1453
1773
|
}),
|
|
1454
1774
|
import("./doctor.js").then((m) => m.collectDoctorResult(cwd)),
|
|
1455
1775
|
scanServerManifestDrift(cwd),
|
|
@@ -1479,9 +1799,10 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1479
1799
|
}
|
|
1480
1800
|
};
|
|
1481
1801
|
for (const { rel, content } of routeModules) {
|
|
1482
|
-
serverImports.push(...resolveServerOnlyChains(rel, content, resolveModule, readModule));
|
|
1802
|
+
serverImports.push(...resolveServerOnlyChains(rel, content, resolveModule, readModule, sourceFacts));
|
|
1483
1803
|
}
|
|
1484
1804
|
const diagnostics = [];
|
|
1805
|
+
const structuredExtras = [];
|
|
1485
1806
|
if (checkConfigError !== undefined) {
|
|
1486
1807
|
diagnostics.push({
|
|
1487
1808
|
rule: "check-config",
|
|
@@ -1491,6 +1812,31 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1491
1812
|
fix: "Fix the JSON syntax in nifra.check.json",
|
|
1492
1813
|
});
|
|
1493
1814
|
}
|
|
1815
|
+
for (const warning of checkConfigWarnings) {
|
|
1816
|
+
diagnostics.push({
|
|
1817
|
+
rule: "check-config",
|
|
1818
|
+
severity: "warning",
|
|
1819
|
+
file: "nifra.check.json",
|
|
1820
|
+
message: `nifra.check.json: ${warning}`,
|
|
1821
|
+
fix: "Fix the entry in nifra.check.json",
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
if (tc.missingTypeScript === true) {
|
|
1825
|
+
// A tsconfig with no reachable `typescript` install is a broken gate, not a benign skip: the
|
|
1826
|
+
// contract check the project asked for (by having a tsconfig) silently didn't run. Fail closed.
|
|
1827
|
+
diagnostics.push({
|
|
1828
|
+
rule: "typecheck",
|
|
1829
|
+
severity: "error",
|
|
1830
|
+
file: "tsconfig.json",
|
|
1831
|
+
message: "tsconfig.json is present but no `typescript` install was found from this directory upward - the typecheck gate did NOT run",
|
|
1832
|
+
fix: "bun add -d typescript",
|
|
1833
|
+
suggestion: {
|
|
1834
|
+
kind: "command",
|
|
1835
|
+
title: "Install TypeScript so the contract gate can run",
|
|
1836
|
+
command: ["bun", "add", "-d", "typescript"],
|
|
1837
|
+
},
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1494
1840
|
if (tc.ran && !tc.ok) {
|
|
1495
1841
|
const lines = (tc.output ?? "").split("\n");
|
|
1496
1842
|
let matched = false;
|
|
@@ -1677,6 +2023,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1677
2023
|
? `${f.package} was never built - ${f.distFile} is missing, but its export map serves it to Vite SSR/node consumers while Bun reads src - rebuild ${f.package}`
|
|
1678
2024
|
: `${f.package} has a stale build artifact - ${f.distFile} is ${f.behindSeconds}s older than ${f.sourceFile}, and Vite SSR/node consumers read the artifact while Bun reads src - rebuild ${f.package}`,
|
|
1679
2025
|
fix: `rebuild ${f.package}`,
|
|
2026
|
+
evidence: [f.package, f.distFile, f.sourceFile],
|
|
1680
2027
|
suggestion: {
|
|
1681
2028
|
kind: "manual",
|
|
1682
2029
|
title: `Rebuild ${f.package}`,
|
|
@@ -1716,6 +2063,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1716
2063
|
file: f.file,
|
|
1717
2064
|
message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
|
|
1718
2065
|
fix: MANIFEST_DRIFT_HINT,
|
|
2066
|
+
evidence: [...f.missing, ...f.extra],
|
|
1719
2067
|
suggestion: {
|
|
1720
2068
|
kind: "manual",
|
|
1721
2069
|
title: "Regenerate the committed server manifest",
|
|
@@ -1730,6 +2078,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1730
2078
|
// provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
|
|
1731
2079
|
// without nifra.assurance.ts retain the historical scan and hot path unchanged.
|
|
1732
2080
|
const provided = opts.assurance;
|
|
2081
|
+
let applicationRulePacks = [];
|
|
1733
2082
|
const assuranceConfigPath = join(cwd, "nifra.assurance.ts");
|
|
1734
2083
|
if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
|
|
1735
2084
|
try {
|
|
@@ -1738,6 +2087,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1738
2087
|
let config;
|
|
1739
2088
|
let project;
|
|
1740
2089
|
let routeAssurance;
|
|
2090
|
+
let evidence;
|
|
1741
2091
|
if (provided !== undefined) {
|
|
1742
2092
|
// A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
|
|
1743
2093
|
// capability-config diagnostic the standalone catch produces.
|
|
@@ -1746,6 +2096,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1746
2096
|
config = provided.config;
|
|
1747
2097
|
project = provided.capability;
|
|
1748
2098
|
routeAssurance = provided.routeAssurance;
|
|
2099
|
+
evidence = provided.evidence;
|
|
1749
2100
|
}
|
|
1750
2101
|
else {
|
|
1751
2102
|
const { loadAssuranceConfig } = await import("./assure.js");
|
|
@@ -1755,6 +2106,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1755
2106
|
project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities);
|
|
1756
2107
|
}
|
|
1757
2108
|
}
|
|
2109
|
+
applicationRulePacks = parseRulePacks(config.rulePacks);
|
|
1758
2110
|
const capabilityReport = project?.report;
|
|
1759
2111
|
if (config.capabilities !== undefined && project !== undefined) {
|
|
1760
2112
|
for (const finding of project.report.findings) {
|
|
@@ -1764,6 +2116,11 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1764
2116
|
const truncation = finding.code === "provenance-truncated"
|
|
1765
2117
|
? project.truncations.find((candidate) => candidate.method === finding.method && candidate.path === finding.path)
|
|
1766
2118
|
: undefined;
|
|
2119
|
+
// An unmatched seam is a policy defect, not a route defect: the fix is in the policy file,
|
|
2120
|
+
// so it gets its own steps instead of the route-side provenance guidance.
|
|
2121
|
+
const seamFix = finding.code === "unmatched-provenance-seam"
|
|
2122
|
+
? "Write the seam exactly as the code imports it, or delete the rule."
|
|
2123
|
+
: undefined;
|
|
1767
2124
|
diagnostics.push({
|
|
1768
2125
|
rule: "capability-assurance",
|
|
1769
2126
|
severity: "error",
|
|
@@ -1772,18 +2129,29 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1772
2129
|
: truncation !== undefined
|
|
1773
2130
|
? { chain: truncation.chain }
|
|
1774
2131
|
: {}),
|
|
1775
|
-
message: `${finding.message} - ${CAPABILITY_HINT}`,
|
|
1776
|
-
fix: CAPABILITY_HINT,
|
|
1777
|
-
suggestion:
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
2132
|
+
message: `${finding.message}${seamFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
|
|
2133
|
+
fix: seamFix ?? CAPABILITY_HINT,
|
|
2134
|
+
suggestion: seamFix === undefined
|
|
2135
|
+
? {
|
|
2136
|
+
kind: "manual",
|
|
2137
|
+
title: "Restore declared effect provenance",
|
|
2138
|
+
steps: [
|
|
2139
|
+
"Route effectful work through an import listed in capabilities.provenance.imports.",
|
|
2140
|
+
"Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
|
|
2141
|
+
"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.",
|
|
2142
|
+
"Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
|
|
2143
|
+
],
|
|
2144
|
+
}
|
|
2145
|
+
: {
|
|
2146
|
+
kind: "manual",
|
|
2147
|
+
title: "Point the provenance rule at a module that exists",
|
|
2148
|
+
steps: [
|
|
2149
|
+
"Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
|
|
2150
|
+
"Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
|
|
2151
|
+
"For a routeModules entry, give the project-relative path of the file that implements the route.",
|
|
2152
|
+
"Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
|
|
2153
|
+
],
|
|
2154
|
+
},
|
|
1787
2155
|
});
|
|
1788
2156
|
}
|
|
1789
2157
|
}
|
|
@@ -1795,6 +2163,10 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1795
2163
|
? { definitions: config.capabilities.definitions }
|
|
1796
2164
|
: {}),
|
|
1797
2165
|
});
|
|
2166
|
+
evidence ??= snapshotProjectEvidence(config.source, {
|
|
2167
|
+
assurance,
|
|
2168
|
+
...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
|
|
2169
|
+
});
|
|
1798
2170
|
const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json");
|
|
1799
2171
|
let message;
|
|
1800
2172
|
if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
|
|
@@ -1807,7 +2179,7 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1807
2179
|
else {
|
|
1808
2180
|
try {
|
|
1809
2181
|
const current = await buildNifraManifest({
|
|
1810
|
-
|
|
2182
|
+
evidence,
|
|
1811
2183
|
assurance,
|
|
1812
2184
|
...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
|
|
1813
2185
|
});
|
|
@@ -1858,68 +2230,218 @@ export async function collectCheckResult(cwd, opts = {}) {
|
|
|
1858
2230
|
});
|
|
1859
2231
|
}
|
|
1860
2232
|
}
|
|
2233
|
+
try {
|
|
2234
|
+
const { checkContractsLock } = await import("./contracts.js");
|
|
2235
|
+
if (!existsSync(join(cwd, "backend.ts")) && !existsSync(join(cwd, "contracts.lock.json"))) {
|
|
2236
|
+
throw new Error("contract source not configured");
|
|
2237
|
+
}
|
|
2238
|
+
const contract = await checkContractsLock(cwd);
|
|
2239
|
+
if (!contract.present) {
|
|
2240
|
+
structuredExtras.push(diagnostic({
|
|
2241
|
+
code: "NF-K001",
|
|
2242
|
+
severity: "info",
|
|
2243
|
+
message: "no contract lock; run `nifra contracts snapshot` to enable drift detection",
|
|
2244
|
+
verify: "nifra contracts snapshot",
|
|
2245
|
+
}));
|
|
2246
|
+
}
|
|
2247
|
+
else {
|
|
2248
|
+
for (const finding of contract.diagnostics) {
|
|
2249
|
+
diagnostics.push({
|
|
2250
|
+
rule: "contract-drift",
|
|
2251
|
+
severity: "error",
|
|
2252
|
+
code: "NF-K001",
|
|
2253
|
+
...(finding.route !== undefined ? { evidence: [finding.route] } : {}),
|
|
2254
|
+
message: finding.message,
|
|
2255
|
+
fix: "run `nifra contracts snapshot` after reviewing the contract change",
|
|
2256
|
+
verify: "nifra check --lints-only",
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
catch (error) {
|
|
2262
|
+
if (error instanceof Error && error.message === "contract source not configured") {
|
|
2263
|
+
// API-only source scans have no contract surface to snapshot.
|
|
2264
|
+
}
|
|
2265
|
+
else {
|
|
2266
|
+
diagnostics.push({
|
|
2267
|
+
rule: "contract-drift",
|
|
2268
|
+
severity: "error",
|
|
2269
|
+
code: "NF-K001",
|
|
2270
|
+
message: `contract lock could not be checked: ${error instanceof Error ? error.message : String(error)}`,
|
|
2271
|
+
verify: "nifra contracts snapshot",
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
const ruleContext = {
|
|
2276
|
+
root: cwd,
|
|
2277
|
+
sources: sourceIndex(sourceFiles),
|
|
2278
|
+
project: {
|
|
2279
|
+
legacyDiagnostics: diagnostics,
|
|
2280
|
+
assurance: opts.assurance,
|
|
2281
|
+
// Route registrations collected during the source walk, so the route-table rules
|
|
2282
|
+
// (rules/routes.ts) lint them without a second scan.
|
|
2283
|
+
staticRoutes,
|
|
2284
|
+
},
|
|
2285
|
+
};
|
|
2286
|
+
const structuredDiagnostics = [...structuredExtras];
|
|
2287
|
+
let registryDiagnostics = [];
|
|
2288
|
+
try {
|
|
2289
|
+
registryDiagnostics = await runRuleRegistry(ruleContext, [...legacyRules, ...securityRules, ...routeRules], applicationRulePacks);
|
|
2290
|
+
structuredDiagnostics.push(...registryDiagnostics);
|
|
2291
|
+
}
|
|
2292
|
+
catch (error) {
|
|
2293
|
+
registryDiagnostics = [
|
|
2294
|
+
diagnostic({
|
|
2295
|
+
code: "NF-C017",
|
|
2296
|
+
severity: "error",
|
|
2297
|
+
message: `rule registry failed closed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2298
|
+
fix: { recipe: "rule-pack.repair", command: "nifra check --lints-only" },
|
|
2299
|
+
verify: "nifra check --lints-only",
|
|
2300
|
+
}),
|
|
2301
|
+
];
|
|
2302
|
+
structuredDiagnostics.push(...registryDiagnostics);
|
|
2303
|
+
}
|
|
2304
|
+
for (const item of registryDiagnostics) {
|
|
2305
|
+
// The legacy adapters publish stable structured findings; the human compatibility view already
|
|
2306
|
+
// contains their original rule names. Security rules and application packs still need a legacy-view
|
|
2307
|
+
// row because they have no pre-registry diagnostic.
|
|
2308
|
+
if (legacyRules.some((rule) => rule.code === item.code))
|
|
2309
|
+
continue;
|
|
2310
|
+
diagnostics.push({
|
|
2311
|
+
rule: item.code,
|
|
2312
|
+
severity: item.severity === "error" ? "error" : "warning",
|
|
2313
|
+
code: item.code,
|
|
2314
|
+
...(item.file !== undefined ? { file: item.file } : {}),
|
|
2315
|
+
...(item.line !== undefined ? { line: item.line } : {}),
|
|
2316
|
+
message: item.message,
|
|
2317
|
+
...(item.evidence !== undefined ? { evidence: item.evidence } : {}),
|
|
2318
|
+
...(item.verify !== undefined ? { verify: item.verify } : {}),
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
// Per-rule overrides from `nifra.check.json` `rules`, applied centrally to BOTH diagnostic views
|
|
2322
|
+
// before `ok` is computed - never inside individual rules, so no rule can dodge (or double-apply)
|
|
2323
|
+
// them. A key matches the legacy rule name or the stable NF- code.
|
|
2324
|
+
const overrideFor = (...keys) => {
|
|
2325
|
+
for (const key of keys) {
|
|
2326
|
+
if (key !== undefined && checkConfig.rules[key] !== undefined)
|
|
2327
|
+
return checkConfig.rules[key];
|
|
2328
|
+
}
|
|
2329
|
+
return undefined;
|
|
2330
|
+
};
|
|
2331
|
+
const ignoreGlobs = new Map();
|
|
2332
|
+
const dropped = (override, file) => {
|
|
2333
|
+
if (override.severity === "off")
|
|
2334
|
+
return true;
|
|
2335
|
+
if (override.ignore === undefined || file === undefined)
|
|
2336
|
+
return false;
|
|
2337
|
+
let globs = ignoreGlobs.get(override);
|
|
2338
|
+
if (globs === undefined) {
|
|
2339
|
+
globs = override.ignore.map((pattern) => new Glob(pattern));
|
|
2340
|
+
ignoreGlobs.set(override, globs);
|
|
2341
|
+
}
|
|
2342
|
+
return globs.some((glob) => glob.match(file));
|
|
2343
|
+
};
|
|
2344
|
+
const codeToLegacy = new Map(Object.entries(LEGACY_RULE_CODES).map(([name, code]) => [code, name]));
|
|
2345
|
+
const finalDiagnostics = diagnostics.flatMap((d) => {
|
|
2346
|
+
const override = overrideFor(d.rule, d.code, LEGACY_RULE_CODES[d.rule]);
|
|
2347
|
+
if (override === undefined)
|
|
2348
|
+
return [d];
|
|
2349
|
+
if (dropped(override, d.file))
|
|
2350
|
+
return [];
|
|
2351
|
+
// "off" is fully handled by `dropped` above - only real severities reach the retag.
|
|
2352
|
+
if (override.severity === undefined || override.severity === "off")
|
|
2353
|
+
return [d];
|
|
2354
|
+
return [{ ...d, severity: override.severity === "warn" ? "warning" : override.severity }];
|
|
2355
|
+
});
|
|
2356
|
+
const finalStructured = structuredDiagnostics.flatMap((d) => {
|
|
2357
|
+
const override = overrideFor(d.code, codeToLegacy.get(d.code));
|
|
2358
|
+
if (override === undefined)
|
|
2359
|
+
return [d];
|
|
2360
|
+
if (dropped(override, d.file))
|
|
2361
|
+
return [];
|
|
2362
|
+
if (override.severity === undefined || override.severity === "off")
|
|
2363
|
+
return [d];
|
|
2364
|
+
return [Object.freeze({ ...d, severity: override.severity })];
|
|
2365
|
+
});
|
|
1861
2366
|
// Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
|
|
1862
2367
|
// message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
|
|
1863
|
-
const total =
|
|
2368
|
+
const total = finalDiagnostics.length;
|
|
1864
2369
|
const max = opts.maxDiagnostics;
|
|
1865
|
-
const shown = max !== undefined && total > max ?
|
|
1866
|
-
|
|
1867
|
-
ok: !
|
|
2370
|
+
const shown = max !== undefined && total > max ? finalDiagnostics.slice(0, max) : finalDiagnostics;
|
|
2371
|
+
const result = {
|
|
2372
|
+
ok: !finalDiagnostics.some((diagnostic) => diagnostic.severity === "error"),
|
|
1868
2373
|
typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
|
|
2374
|
+
...(!tc.ran && tc.note !== undefined ? { typecheckNote: tc.note } : {}),
|
|
1869
2375
|
diagnostics: shown,
|
|
1870
2376
|
...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
|
|
1871
2377
|
...(checkConfig.externalMounts.length > 0
|
|
1872
2378
|
? { externalMounts: checkConfig.externalMounts }
|
|
1873
2379
|
: {}),
|
|
2380
|
+
...(Object.keys(checkConfig.rules).length > 0 ? { ruleOverrides: checkConfig.rules } : {}),
|
|
1874
2381
|
...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
|
|
1875
2382
|
};
|
|
2383
|
+
Object.defineProperty(result, "structuredDiagnostics", {
|
|
2384
|
+
value: finalStructured.slice(0, shown.length + structuredExtras.length),
|
|
2385
|
+
enumerable: false,
|
|
2386
|
+
});
|
|
2387
|
+
return result;
|
|
1876
2388
|
}
|
|
1877
|
-
/**
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2389
|
+
/** The named rule sections of the human report, in print order. A rule absent from this list is NOT
|
|
2390
|
+
* dropped: {@link renderCheckReport} renders every remaining diagnostic in a generic section keyed by
|
|
2391
|
+
* its rule code, so a finding that can flip the exit code is never invisible in the default output. */
|
|
2392
|
+
const REPORT_SECTIONS = [
|
|
2393
|
+
["typecheck", "typecheck"],
|
|
2394
|
+
["typed-client", "hand-rolled fetch() to your own API"],
|
|
2395
|
+
["untyped-client", 'client("…") missing its <typeof app> type argument'],
|
|
2396
|
+
["server-only-import", "server-only import in a route module"],
|
|
2397
|
+
["interpolated-sql", "SQL built by interpolating a value into the statement"],
|
|
2398
|
+
["response-route", "route returns a raw Response (typed client → data: never)"],
|
|
2399
|
+
["undeclared-dependency", "undeclared dependency in package.json"],
|
|
2400
|
+
["duplicate-install", "duplicate identity-sensitive dependency install"],
|
|
2401
|
+
["stale-workspace-dist", "workspace-linked dist older than its source"],
|
|
2402
|
+
["pipeline", "bundler pipeline (Vite/Bun) config"],
|
|
2403
|
+
["server-manifest-drift", "server-manifest.ts drifted from routes/"],
|
|
2404
|
+
["manifest-drift", "versioned trust manifest drift"],
|
|
2405
|
+
["capability-assurance", "effect/capability assurance"],
|
|
2406
|
+
["capability-config", "capability assurance config"],
|
|
2407
|
+
["check-config", "nifra.check.json"],
|
|
2408
|
+
];
|
|
2409
|
+
/**
|
|
2410
|
+
* Render the human-readable check report as lines. Pure (no I/O, no cwd) so tests can assert
|
|
2411
|
+
* report/exit-code parity: every diagnostic in `result.diagnostics` appears in the output, and the
|
|
2412
|
+
* trailer states the error/advisory counts that produced `ok`.
|
|
2413
|
+
*/
|
|
2414
|
+
export function renderCheckReport(result) {
|
|
2415
|
+
const lines = [];
|
|
2416
|
+
lines.push("nifra check", "");
|
|
2417
|
+
lines.push(result.typecheck === "pass"
|
|
1889
2418
|
? "✓ typecheck passed"
|
|
1890
2419
|
: result.typecheck === "fail"
|
|
1891
2420
|
? "✗ typecheck failed - the frontend/backend contract is broken"
|
|
1892
|
-
:
|
|
2421
|
+
: `⚠ typecheck SKIPPED - ${result.typecheckNote ?? "no tsconfig / typescript not installed"} (the contract gate did not run)`);
|
|
1893
2422
|
// Stated on every run, passing or not. "Which bundler is this app on" decides which plugin slot is
|
|
1894
2423
|
// live and which toolchain compiles a component, so it belongs in the report rather than only in the
|
|
1895
2424
|
// dev server's banner - where CI never sees it.
|
|
1896
2425
|
if (result.pipeline !== undefined) {
|
|
1897
|
-
|
|
2426
|
+
lines.push(result.pipeline.pipeline === "unknown"
|
|
1898
2427
|
? `• bundler: not readable from ${result.pipeline.configFile} - ${result.pipeline.reason}`
|
|
1899
2428
|
: `• bundler: ${result.pipeline.pipeline} (${result.pipeline.reason})`);
|
|
1900
2429
|
}
|
|
1901
2430
|
if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
|
|
1902
|
-
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
["server-manifest-drift", "server-manifest.ts drifted from routes/"],
|
|
1917
|
-
["manifest-drift", "versioned trust manifest drift"],
|
|
1918
|
-
["capability-assurance", "effect/capability assurance"],
|
|
1919
|
-
["capability-config", "capability assurance config"],
|
|
1920
|
-
["check-config", "nifra.check.json"],
|
|
1921
|
-
]) {
|
|
1922
|
-
const ds = counts(rule);
|
|
2431
|
+
lines.push(`• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`);
|
|
2432
|
+
}
|
|
2433
|
+
if (result.ruleOverrides !== undefined) {
|
|
2434
|
+
const active = Object.entries(result.ruleOverrides).map(([rule, override]) => {
|
|
2435
|
+
const parts = [];
|
|
2436
|
+
if (override.severity !== undefined)
|
|
2437
|
+
parts.push(`severity=${override.severity}`);
|
|
2438
|
+
if (override.ignore !== undefined)
|
|
2439
|
+
parts.push(`ignore=${override.ignore.join(",")}`);
|
|
2440
|
+
return `${rule} (${parts.join(", ")})`;
|
|
2441
|
+
});
|
|
2442
|
+
lines.push(`• rule overrides from nifra.check.json: ${active.join("; ")}`);
|
|
2443
|
+
}
|
|
2444
|
+
const renderSection = (rule, label, ds) => {
|
|
1923
2445
|
if (rule !== "typecheck") {
|
|
1924
2446
|
// Marked by SEVERITY, not by rule name. `response-route` and `stale-workspace-dist` are advisory
|
|
1925
2447
|
// in whole; `pipeline` is the first rule that is advisory in part (a misplaced plugin fails, a
|
|
@@ -1927,31 +2449,70 @@ export async function runCheck(cwd, opts = {}) {
|
|
|
1927
2449
|
// rounded up to the worse of the two.
|
|
1928
2450
|
const errors = ds.filter((d) => d.severity === "error").length;
|
|
1929
2451
|
const advisory = ds.length - errors;
|
|
1930
|
-
|
|
2452
|
+
lines.push(ds.length === 0
|
|
1931
2453
|
? `✓ ${label}: none`
|
|
1932
2454
|
: errors === 0
|
|
1933
2455
|
? `⚠ ${label}: ${advisory} (advisory)`
|
|
1934
2456
|
: `✗ ${label}: ${errors}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`);
|
|
1935
2457
|
}
|
|
1936
2458
|
for (const d of ds) {
|
|
1937
|
-
|
|
2459
|
+
lines.push(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`);
|
|
1938
2460
|
if (d.suggestion !== undefined) {
|
|
1939
|
-
|
|
2461
|
+
lines.push(` fix: ${d.suggestion.title}`);
|
|
1940
2462
|
if (d.suggestion.command !== undefined) {
|
|
1941
|
-
|
|
2463
|
+
lines.push(` command: ${d.suggestion.command.join(" ")}`);
|
|
1942
2464
|
}
|
|
1943
2465
|
if (d.suggestion.diff !== undefined) {
|
|
1944
|
-
|
|
1945
|
-
.
|
|
1946
|
-
.map((line) => ` ${line}`)
|
|
1947
|
-
.join("\n"));
|
|
2466
|
+
for (const line of d.suggestion.diff.split("\n"))
|
|
2467
|
+
lines.push(` ${line}`);
|
|
1948
2468
|
}
|
|
1949
2469
|
for (const step of d.suggestion.steps ?? [])
|
|
1950
|
-
|
|
2470
|
+
lines.push(` - ${step}`);
|
|
1951
2471
|
}
|
|
1952
2472
|
}
|
|
2473
|
+
};
|
|
2474
|
+
for (const [rule, label] of REPORT_SECTIONS) {
|
|
2475
|
+
renderSection(rule, label, result.diagnostics.filter((d) => d.rule === rule));
|
|
2476
|
+
}
|
|
2477
|
+
// Generic section: diagnostics whose rule has no named section above (registry rules publishing
|
|
2478
|
+
// under their NF- code, application rule packs). These count toward `ok` exactly like the named
|
|
2479
|
+
// ones, so they get the same severity-marked rendering - only "✓ …: none" is skipped, because
|
|
2480
|
+
// the set of possible unlisted rules is open-ended.
|
|
2481
|
+
const named = new Set(REPORT_SECTIONS.map(([rule]) => rule));
|
|
2482
|
+
const extraRules = [];
|
|
2483
|
+
for (const d of result.diagnostics) {
|
|
2484
|
+
if (!named.has(d.rule) && !extraRules.includes(d.rule))
|
|
2485
|
+
extraRules.push(d.rule);
|
|
2486
|
+
}
|
|
2487
|
+
for (const rule of extraRules) {
|
|
2488
|
+
const title = RULE_CODES[rule];
|
|
2489
|
+
renderSection(rule, title !== undefined ? `${title} (${rule})` : rule, result.diagnostics.filter((d) => d.rule === rule));
|
|
2490
|
+
}
|
|
2491
|
+
if (result.truncated !== undefined) {
|
|
2492
|
+
lines.push(`• showing ${result.truncated.shown} of ${result.truncated.total} diagnostics (truncated)`);
|
|
2493
|
+
}
|
|
2494
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error").length;
|
|
2495
|
+
const advisory = result.diagnostics.length - errors;
|
|
2496
|
+
lines.push("", result.ok
|
|
2497
|
+
? advisory > 0
|
|
2498
|
+
? `✓ check passed (${advisory} advisory)`
|
|
2499
|
+
: "✓ check passed"
|
|
2500
|
+
: `✗ check failed: ${errors} error${errors === 1 ? "" : "s"}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`);
|
|
2501
|
+
return lines;
|
|
2502
|
+
}
|
|
2503
|
+
/** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
|
|
2504
|
+
export async function runCheck(cwd, opts = {}) {
|
|
2505
|
+
// The check view over the one project verification: the same collector `assure` and `levels` read.
|
|
2506
|
+
const { collectProjectVerification } = await import("./verification.js");
|
|
2507
|
+
const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false });
|
|
2508
|
+
const result = await verification.check();
|
|
2509
|
+
if (opts.json) {
|
|
2510
|
+
console.log(JSON.stringify(opts.structured === true && result.structuredDiagnostics !== undefined
|
|
2511
|
+
? { ...result, diagnostics: result.structuredDiagnostics }
|
|
2512
|
+
: result, null, 2));
|
|
2513
|
+
return result.ok;
|
|
1953
2514
|
}
|
|
1954
|
-
console.log(result.
|
|
2515
|
+
console.log(renderCheckReport(result).join("\n"));
|
|
1955
2516
|
// Discoverability nudge: a project with no `.mcp.json` hasn't wired its nifra MCP for coding agents.
|
|
1956
2517
|
// `nifra init-agents` writes it (+ .cursor/mcp.json + a CLAUDE.md preamble), no-clobber. A non-fatal
|
|
1957
2518
|
// one-line tip in the human report only (the `--json` path returns above, unaffected).
|