@nifrajs/cli 2.10.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 +7 -1
- 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 +133 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +741 -148
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +21 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +288 -73
- 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/dev-bun-config.d.ts +17 -6
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +51 -14
- package/dist/dev-bun-config.js.map +1 -1
- 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 +38 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +189 -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 +60 -5
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +10 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +1 -0
- 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 +360 -27
- package/dist/mcp.js.map +1 -1
- package/dist/pipeline-guard.d.ts +25 -10
- package/dist/pipeline-guard.d.ts.map +1 -1
- package/dist/pipeline-guard.js +35 -15
- package/dist/pipeline-guard.js.map +1 -1
- package/dist/pipeline-report.d.ts +34 -0
- package/dist/pipeline-report.d.ts.map +1 -0
- package/dist/pipeline-report.js +282 -0
- package/dist/pipeline-report.js.map +1 -0
- 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 +1105 -27
- package/docs/types.json +1772 -137
- 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 +923 -169
- package/src/cli.ts +297 -76
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/dev-bun-config.ts +57 -14
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +286 -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 +81 -6
- package/src/load.ts +11 -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 +440 -34
- package/src/pipeline-guard.ts +44 -14
- package/src/pipeline-report.ts +341 -0
- 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/src/check.ts
CHANGED
|
@@ -16,13 +16,24 @@
|
|
|
16
16
|
* anything fails. Pure scanners (`scanFetchText`, `scanServerOnlyImports`) are unit-tested.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
20
|
-
import { dirname, isAbsolute, join, resolve } from "node:path"
|
|
19
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs"
|
|
20
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path"
|
|
21
21
|
import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
|
|
22
|
+
import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
|
|
22
23
|
import { Glob } from "bun"
|
|
23
24
|
import type * as TSApi from "typescript"
|
|
24
25
|
import type { CapabilityProjectReport } from "./capabilities-tool.ts"
|
|
25
|
-
import {
|
|
26
|
+
import { type Diagnostic, diagnostic } from "./diagnostics.ts"
|
|
27
|
+
import { createSourceFacts, type SourceFacts } from "./internal/source-facts.ts"
|
|
28
|
+
import { importProjectTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
|
|
29
|
+
// Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
|
|
30
|
+
// close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
|
|
31
|
+
import type { PipelineReport } from "./pipeline-report.ts"
|
|
32
|
+
import { RULE_CODES } from "./rules/codes.ts"
|
|
33
|
+
import { parseRulePacks, type RuleContext, runRuleRegistry, sourceIndex } from "./rules/index.ts"
|
|
34
|
+
import { LEGACY_RULE_CODES, legacyRules } from "./rules/legacy.ts"
|
|
35
|
+
import { routeRules } from "./rules/routes.ts"
|
|
36
|
+
import { securityRules } from "./rules/security.ts"
|
|
26
37
|
|
|
27
38
|
export interface SourceFinding {
|
|
28
39
|
readonly file: string
|
|
@@ -233,6 +244,7 @@ function scanRoutePattern(
|
|
|
233
244
|
content: string,
|
|
234
245
|
code: string,
|
|
235
246
|
pattern: RegExp,
|
|
247
|
+
facts?: SourceFacts,
|
|
236
248
|
): StaticRouteFinding[] {
|
|
237
249
|
const out: StaticRouteFinding[] = []
|
|
238
250
|
const lines = content.split("\n")
|
|
@@ -244,6 +256,16 @@ function scanRoutePattern(
|
|
|
244
256
|
`${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}${m[2] ?? ""}${pattern === ROUTE_REGISTRATION_DQ ? '"' : "'"}`,
|
|
245
257
|
)
|
|
246
258
|
if (path === undefined || !path.startsWith("/") || path.startsWith("//")) continue
|
|
259
|
+
if (facts !== undefined) {
|
|
260
|
+
const source = facts.parse(file, content)
|
|
261
|
+
// A parseable source model is authoritative for candidate locations. If parsing is unavailable
|
|
262
|
+
// or the source is malformed, retain the lexical finding and fail closed.
|
|
263
|
+
if (
|
|
264
|
+
source !== undefined &&
|
|
265
|
+
facts.isRouteRegistrationAt(source, m.index, method, path) !== true
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
247
269
|
const line = lineAt(content, m.index)
|
|
248
270
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), method, path })
|
|
249
271
|
}
|
|
@@ -467,12 +489,16 @@ export function scanFetchText(
|
|
|
467
489
|
}
|
|
468
490
|
|
|
469
491
|
/** Statically collect simple Nifra route registrations from source, without importing app code. */
|
|
470
|
-
export function scanStaticRouteText(
|
|
492
|
+
export function scanStaticRouteText(
|
|
493
|
+
file: string,
|
|
494
|
+
content: string,
|
|
495
|
+
facts?: SourceFacts,
|
|
496
|
+
): StaticRouteFinding[] {
|
|
471
497
|
const code = stripComments(content)
|
|
472
498
|
if (!isPotentialBackendSource(code)) return []
|
|
473
499
|
return [
|
|
474
|
-
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
|
|
475
|
-
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
|
|
500
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
501
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
476
502
|
].sort(bySite)
|
|
477
503
|
}
|
|
478
504
|
|
|
@@ -519,21 +545,228 @@ interface SqlExpressionShape {
|
|
|
519
545
|
/** Module-scope `const` initializers by name - the one binding form the SQL scanner resolves. */
|
|
520
546
|
type ModuleConsts = ReadonlyMap<string, TSApi.Expression>
|
|
521
547
|
|
|
522
|
-
/**
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
548
|
+
/** A name some other module exports, named exactly as the edge that reaches it writes it. */
|
|
549
|
+
interface ExportEdge {
|
|
550
|
+
readonly specifier: string
|
|
551
|
+
readonly exported: string
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* One module's provable module-scope bindings: its top-level `const` initializers plus the exact
|
|
556
|
+
* import/export edges this scan is allowed to follow.
|
|
557
|
+
*
|
|
558
|
+
* Everything absent from these maps stays unresolved, and an unresolved interpolation keeps flagging.
|
|
559
|
+
* That is the whole safety argument: resolution can only ever make this rule QUIETER, so a form the
|
|
560
|
+
* scan cannot prove must fall out as "unknown" rather than be guessed at. Deliberately absent:
|
|
561
|
+
* `export default`, `export let`/`var`, destructured or computed names, default and namespace imports,
|
|
562
|
+
* `export * as ns`, `require`, and dynamic `import()`.
|
|
563
|
+
*/
|
|
564
|
+
interface SqlModuleScope {
|
|
565
|
+
/** The module's identity - an absolute path from the loader, or the entry file's path as given. Keys
|
|
566
|
+
* the parsed-scope cache and the cycle guard, and is the base its own specifiers resolve against. */
|
|
567
|
+
readonly id: string
|
|
568
|
+
readonly consts: ModuleConsts
|
|
569
|
+
/** Local name bound by `import { X as local }` → the edge that provides it. */
|
|
570
|
+
readonly imports: ReadonlyMap<string, ExportEdge>
|
|
571
|
+
/** Exported name → the local name it reads (`export { a as b }` → `b` → `a`). */
|
|
572
|
+
readonly localExports: ReadonlyMap<string, string>
|
|
573
|
+
/** Exported name → another module's export (`export { a as b } from "./m"`). */
|
|
574
|
+
readonly reExports: ReadonlyMap<string, ExportEdge>
|
|
575
|
+
/** `export * from "./m"` specifiers, in source order. */
|
|
576
|
+
readonly starExports: readonly string[]
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Read one module's top-level `const` bindings and its import/export edges. Pure syntax - no checker,
|
|
580
|
+
* no filesystem. Destructured names, `let`, `var`, and anything nested stay out on purpose: a
|
|
581
|
+
* module-scope `const` is the only form that provably cannot carry request data. */
|
|
582
|
+
function sqlModuleScope(ts: TypeScriptApi, id: string, source: TSApi.SourceFile): SqlModuleScope {
|
|
583
|
+
const consts = new Map<string, TSApi.Expression>()
|
|
584
|
+
const imports = new Map<string, ExportEdge>()
|
|
585
|
+
const localExports = new Map<string, string>()
|
|
586
|
+
const reExports = new Map<string, ExportEdge>()
|
|
587
|
+
const starExports: string[] = []
|
|
527
588
|
for (const statement of source.statements) {
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
589
|
+
if (ts.isVariableStatement(statement)) {
|
|
590
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
|
|
591
|
+
const exported =
|
|
592
|
+
statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true
|
|
593
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
594
|
+
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
|
|
595
|
+
consts.set(declaration.name.text, declaration.initializer)
|
|
596
|
+
if (exported) localExports.set(declaration.name.text, declaration.name.text)
|
|
533
597
|
}
|
|
598
|
+
continue
|
|
599
|
+
}
|
|
600
|
+
if (ts.isImportDeclaration(statement)) {
|
|
601
|
+
const clause = statement.importClause
|
|
602
|
+
if (clause === undefined || clause.isTypeOnly) continue
|
|
603
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue
|
|
604
|
+
const bindings = clause.namedBindings
|
|
605
|
+
// Only `import { X }` / `import { X as local }`. A default or namespace import binds a value
|
|
606
|
+
// whose contents this scan cannot prove, so it stays unbound and every use of it flags.
|
|
607
|
+
if (bindings === undefined || !ts.isNamedImports(bindings)) continue
|
|
608
|
+
for (const element of bindings.elements) {
|
|
609
|
+
if (element.isTypeOnly) continue
|
|
610
|
+
imports.set(element.name.text, {
|
|
611
|
+
specifier: statement.moduleSpecifier.text,
|
|
612
|
+
exported: element.propertyName?.text ?? element.name.text,
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
continue
|
|
616
|
+
}
|
|
617
|
+
if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) continue
|
|
618
|
+
const from = statement.moduleSpecifier
|
|
619
|
+
if (from !== undefined && !ts.isStringLiteral(from)) continue
|
|
620
|
+
const clause = statement.exportClause
|
|
621
|
+
if (clause === undefined) {
|
|
622
|
+
// No clause and a specifier is `export * from "./m"`. `export * as ns from "./m"` carries a
|
|
623
|
+
// NamespaceExport clause instead and falls out below, unresolved, which is what we want.
|
|
624
|
+
if (from !== undefined) starExports.push(from.text)
|
|
625
|
+
continue
|
|
626
|
+
}
|
|
627
|
+
if (!ts.isNamedExports(clause)) continue
|
|
628
|
+
for (const element of clause.elements) {
|
|
629
|
+
if (element.isTypeOnly) continue
|
|
630
|
+
const local = element.propertyName?.text ?? element.name.text
|
|
631
|
+
if (from === undefined) localExports.set(element.name.text, local)
|
|
632
|
+
else reExports.set(element.name.text, { specifier: from.text, exported: local })
|
|
534
633
|
}
|
|
535
634
|
}
|
|
536
|
-
return
|
|
635
|
+
return { id, consts, imports, localExports, reExports, starExports }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** A module's identity + source, as resolved from a specifier. `id` must be stable and absolute: it
|
|
639
|
+
* keys the parsed-scope cache, seeds the cycle guard, and is what the module's own relative specifiers
|
|
640
|
+
* resolve against. */
|
|
641
|
+
export interface SqlModuleSource {
|
|
642
|
+
readonly id: string
|
|
643
|
+
readonly content: string
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Resolves a specifier written in the module `fromModule` to the target's identity + source, or
|
|
648
|
+
* `undefined` when it cannot be resolved, read, or proved to be in-project source.
|
|
649
|
+
*
|
|
650
|
+
* Injected rather than built in, for the same reason {@link ModuleResolver} is: it keeps
|
|
651
|
+
* {@link scanInterpolatedSql} pure and filesystem-free, so the resolution rules can be unit-tested
|
|
652
|
+
* against a virtual module graph. {@link createProjectSqlImports} is the on-disk implementation.
|
|
653
|
+
*/
|
|
654
|
+
export type SqlModuleLoader = (fromModule: string, specifier: string) => SqlModuleSource | undefined
|
|
655
|
+
|
|
656
|
+
/** Cross-module resolution state for one run: the loader plus a parsed-scope cache, so a fragments
|
|
657
|
+
* module imported by fifty callers is read and parsed once. A cached `undefined` is a REFUSAL (the
|
|
658
|
+
* module did not parse), cached so it is not re-attempted. Build with {@link createSqlImports}. */
|
|
659
|
+
export interface SqlImports {
|
|
660
|
+
readonly load: SqlModuleLoader
|
|
661
|
+
readonly scopes: Map<string, SqlModuleScope | undefined>
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export const createSqlImports = (load: SqlModuleLoader): SqlImports => ({
|
|
665
|
+
load,
|
|
666
|
+
scopes: new Map(),
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
/** Where an expression's identifiers are read: the module the expression physically lives in, plus the
|
|
670
|
+
* run's cross-module state. `imports === undefined` is same-file-only resolution - the behavior before
|
|
671
|
+
* imported fragments were followed, and still what an unconfigured `scanInterpolatedSql` call does. */
|
|
672
|
+
interface SqlScope {
|
|
673
|
+
readonly module: SqlModuleScope
|
|
674
|
+
readonly imports: SqlImports | undefined
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Parse a module for the SQL scan, or `undefined` when it does not parse. Malformed source is already
|
|
678
|
+
* a typecheck failure; parser recovery nodes are not evidence of anything, so a file that fails to
|
|
679
|
+
* parse contributes no findings and proves no constant. */
|
|
680
|
+
function parseSqlSource(
|
|
681
|
+
ts: TypeScriptApi,
|
|
682
|
+
file: string,
|
|
683
|
+
content: string,
|
|
684
|
+
): TSApi.SourceFile | undefined {
|
|
685
|
+
const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
|
|
686
|
+
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
|
|
687
|
+
const parseDiagnostics = (
|
|
688
|
+
source as TSApi.SourceFile & { readonly parseDiagnostics?: readonly TSApi.Diagnostic[] }
|
|
689
|
+
).parseDiagnostics
|
|
690
|
+
return parseDiagnostics !== undefined && parseDiagnostics.length > 0 ? undefined : source
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Module-boundary hops one name may cross. Barrels are why it is not 1: `route → index → fragments`
|
|
694
|
+
* already spends two, and the third leaves room for one more layer without letting the scan wander the
|
|
695
|
+
* module graph. Past the cap the name is unresolved, so the interpolation flags. */
|
|
696
|
+
const SQL_IMPORT_HOPS = 3
|
|
697
|
+
|
|
698
|
+
/** Load + parse the module a specifier names, through the run's cache. */
|
|
699
|
+
function loadSqlModuleScope(
|
|
700
|
+
ts: TypeScriptApi,
|
|
701
|
+
imports: SqlImports,
|
|
702
|
+
fromModule: string,
|
|
703
|
+
specifier: string,
|
|
704
|
+
): SqlModuleScope | undefined {
|
|
705
|
+
const loaded = imports.load(fromModule, specifier)
|
|
706
|
+
if (loaded === undefined) return undefined
|
|
707
|
+
if (imports.scopes.has(loaded.id)) return imports.scopes.get(loaded.id)
|
|
708
|
+
const source = parseSqlSource(ts, loaded.id, loaded.content)
|
|
709
|
+
const scope = source === undefined ? undefined : sqlModuleScope(ts, loaded.id, source)
|
|
710
|
+
imports.scopes.set(loaded.id, scope)
|
|
711
|
+
return scope
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** One exported name resolved to the `const` initializer behind it, together with the scope that
|
|
715
|
+
* initializer's OWN identifiers must be read in. */
|
|
716
|
+
interface ResolvedExport {
|
|
717
|
+
readonly expression: TSApi.Expression
|
|
718
|
+
readonly scope: SqlModuleScope
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Follow an exported name to its `const` initializer, across re-export hops.
|
|
723
|
+
*
|
|
724
|
+
* Handles the three forms a fragments module actually uses: `export const NAME = …`, the two-statement
|
|
725
|
+
* `const NAME = …; export { NAME }` (including the pass-through `import { A } …; export { A }`), and
|
|
726
|
+
* barrels - `export { X } from "./m"` and `export * from "./m"`. Bounded by {@link SQL_IMPORT_HOPS} and
|
|
727
|
+
* a per-chain visited set, so a cyclic barrel terminates instead of recursing.
|
|
728
|
+
*
|
|
729
|
+
* Two `export *` sources providing the same name is a conflict, and the scan refuses rather than
|
|
730
|
+
* picking one: guessing here would silence a real finding.
|
|
731
|
+
*/
|
|
732
|
+
function resolveExportedConst(
|
|
733
|
+
ts: TypeScriptApi,
|
|
734
|
+
imports: SqlImports,
|
|
735
|
+
scope: SqlModuleScope,
|
|
736
|
+
name: string,
|
|
737
|
+
hops: number,
|
|
738
|
+
seen: ReadonlySet<string>,
|
|
739
|
+
): ResolvedExport | undefined {
|
|
740
|
+
if (hops > SQL_IMPORT_HOPS) return undefined
|
|
741
|
+
const key = `${scope.id}#${name}`
|
|
742
|
+
if (seen.has(key)) return undefined
|
|
743
|
+
const nextSeen = new Set(seen).add(key)
|
|
744
|
+
const follow = (edge: ExportEdge): ResolvedExport | undefined => {
|
|
745
|
+
const target = loadSqlModuleScope(ts, imports, scope.id, edge.specifier)
|
|
746
|
+
return target === undefined
|
|
747
|
+
? undefined
|
|
748
|
+
: resolveExportedConst(ts, imports, target, edge.exported, hops + 1, nextSeen)
|
|
749
|
+
}
|
|
750
|
+
const local = scope.localExports.get(name)
|
|
751
|
+
if (local !== undefined) {
|
|
752
|
+
const initializer = scope.consts.get(local)
|
|
753
|
+
if (initializer !== undefined) return { expression: initializer, scope }
|
|
754
|
+
// `import { A } from "./a"; export { A }` - the same pass-through as `export { A } from "./a"`,
|
|
755
|
+
// written in two statements. Anything else the name could be (a `let`, a function, a class) has no
|
|
756
|
+
// initializer in `consts` and no import edge, so it lands on `undefined` and flags.
|
|
757
|
+
const via = scope.imports.get(local)
|
|
758
|
+
return via === undefined ? undefined : follow(via)
|
|
759
|
+
}
|
|
760
|
+
const reExported = scope.reExports.get(name)
|
|
761
|
+
if (reExported !== undefined) return follow(reExported)
|
|
762
|
+
let found: ResolvedExport | undefined
|
|
763
|
+
for (const specifier of scope.starExports) {
|
|
764
|
+
const hit = follow({ specifier, exported: name })
|
|
765
|
+
if (hit === undefined) continue
|
|
766
|
+
if (found !== undefined) return undefined
|
|
767
|
+
found = hit
|
|
768
|
+
}
|
|
769
|
+
return found
|
|
537
770
|
}
|
|
538
771
|
|
|
539
772
|
/** Whether `name`, read at `at`, might be re-declared by anything between the read and module scope.
|
|
@@ -585,46 +818,67 @@ const CONST_RESOLUTION_DEPTH = 5
|
|
|
585
818
|
* data. Pure syntax, no type checker - the same honest limit the rule already documents. What
|
|
586
819
|
* resolves: string/number literals, no-substitution templates, templates whose every span resolves,
|
|
587
820
|
* ternaries with both branches resolvable (BOTH branch texts are returned, so a hostile keyword in
|
|
588
|
-
* either still feeds the keyword scan), `as`/`satisfies` wrappers,
|
|
589
|
-
*
|
|
590
|
-
*
|
|
821
|
+
* either still feeds the keyword scan), `as`/`satisfies` wrappers, a module-scope `const` whose
|
|
822
|
+
* initializer resolves (recursively, depth-capped), and - when `scope.imports` is configured - such a
|
|
823
|
+
* `const` imported by name from another in-project module. Everything else - `let`, a parameter, a
|
|
824
|
+
* call, a member access, a shadowed name, a bare-specifier import - stays unresolved and the
|
|
591
825
|
* interpolation is flagged exactly as before.
|
|
592
826
|
*/
|
|
593
827
|
function resolveStaticSqlText(
|
|
594
828
|
ts: TypeScriptApi,
|
|
595
829
|
node: TSApi.Expression,
|
|
596
|
-
|
|
830
|
+
scope: SqlScope,
|
|
597
831
|
depth: number,
|
|
598
832
|
): string | undefined {
|
|
599
833
|
if (depth > CONST_RESOLUTION_DEPTH) return undefined
|
|
600
834
|
if (ts.isParenthesizedExpression(node)) {
|
|
601
|
-
return resolveStaticSqlText(ts, node.expression,
|
|
835
|
+
return resolveStaticSqlText(ts, node.expression, scope, depth)
|
|
602
836
|
}
|
|
603
837
|
if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
604
|
-
return resolveStaticSqlText(ts, node.expression,
|
|
838
|
+
return resolveStaticSqlText(ts, node.expression, scope, depth)
|
|
605
839
|
}
|
|
606
840
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
|
|
607
841
|
if (ts.isNumericLiteral(node)) return node.text
|
|
608
842
|
if (ts.isTemplateExpression(node)) {
|
|
609
843
|
let text = node.head.text
|
|
610
844
|
for (const span of node.templateSpans) {
|
|
611
|
-
const part = resolveStaticSqlText(ts, span.expression,
|
|
845
|
+
const part = resolveStaticSqlText(ts, span.expression, scope, depth + 1)
|
|
612
846
|
if (part === undefined) return undefined
|
|
613
847
|
text += part + span.literal.text
|
|
614
848
|
}
|
|
615
849
|
return text
|
|
616
850
|
}
|
|
617
851
|
if (ts.isConditionalExpression(node)) {
|
|
618
|
-
const whenTrue = resolveStaticSqlText(ts, node.whenTrue,
|
|
619
|
-
const whenFalse = resolveStaticSqlText(ts, node.whenFalse,
|
|
852
|
+
const whenTrue = resolveStaticSqlText(ts, node.whenTrue, scope, depth + 1)
|
|
853
|
+
const whenFalse = resolveStaticSqlText(ts, node.whenFalse, scope, depth + 1)
|
|
620
854
|
if (whenTrue === undefined || whenFalse === undefined) return undefined
|
|
621
855
|
return `${whenTrue} ${whenFalse}`
|
|
622
856
|
}
|
|
623
857
|
if (ts.isIdentifier(node)) {
|
|
624
|
-
const initializer = consts.get(node.text)
|
|
625
|
-
|
|
858
|
+
const initializer = scope.module.consts.get(node.text)
|
|
859
|
+
const edge = initializer === undefined ? scope.module.imports.get(node.text) : undefined
|
|
860
|
+
// Membership first: `isShadowedAt` walks a subtree, and most identifiers in a query are neither a
|
|
861
|
+
// module const nor an import binding.
|
|
862
|
+
if (initializer === undefined && edge === undefined) return undefined
|
|
863
|
+
// Shadowing is a property of where the identifier is WRITTEN, so one check is right on both sides
|
|
864
|
+
// of an import hop: an initializer in the imported module sits at module scope with nothing above
|
|
865
|
+
// it to shadow it, while a use inside a function in this file is still refused.
|
|
626
866
|
if (isShadowedAt(ts, node, node.text)) return undefined
|
|
627
|
-
return resolveStaticSqlText(ts, initializer,
|
|
867
|
+
if (initializer !== undefined) return resolveStaticSqlText(ts, initializer, scope, depth + 1)
|
|
868
|
+
if (edge === undefined || scope.imports === undefined) return undefined
|
|
869
|
+
const target = loadSqlModuleScope(ts, scope.imports, scope.module.id, edge.specifier)
|
|
870
|
+
if (target === undefined) return undefined
|
|
871
|
+
const exported = resolveExportedConst(ts, scope.imports, target, edge.exported, 1, new Set())
|
|
872
|
+
if (exported === undefined) return undefined
|
|
873
|
+
// The imported initializer's own identifiers read THAT module's consts, never this file's. A
|
|
874
|
+
// caller that happens to have its own `const DIR` must not silently supply the fragment's `DIR`,
|
|
875
|
+
// which is the one way cross-module resolution could resolve to text nobody wrote.
|
|
876
|
+
return resolveStaticSqlText(
|
|
877
|
+
ts,
|
|
878
|
+
exported.expression,
|
|
879
|
+
{ module: exported.scope, imports: scope.imports },
|
|
880
|
+
depth + 1,
|
|
881
|
+
)
|
|
628
882
|
}
|
|
629
883
|
return undefined
|
|
630
884
|
}
|
|
@@ -634,13 +888,13 @@ function resolveStaticSqlText(
|
|
|
634
888
|
function templateShape(
|
|
635
889
|
ts: TypeScriptApi,
|
|
636
890
|
template: TSApi.TemplateExpression,
|
|
637
|
-
|
|
891
|
+
scope: SqlScope | undefined,
|
|
638
892
|
): { staticText: string; dynamic: boolean } {
|
|
639
893
|
let staticText = template.head.text
|
|
640
894
|
let dynamic = false
|
|
641
895
|
for (const span of template.templateSpans) {
|
|
642
896
|
const resolved =
|
|
643
|
-
|
|
897
|
+
scope === undefined ? undefined : resolveStaticSqlText(ts, span.expression, scope, 0)
|
|
644
898
|
if (resolved === undefined) dynamic = true
|
|
645
899
|
else staticText += resolved
|
|
646
900
|
staticText += span.literal.text
|
|
@@ -652,9 +906,9 @@ function templateShape(
|
|
|
652
906
|
function sqlExpressionShape(
|
|
653
907
|
ts: TypeScriptApi,
|
|
654
908
|
node: TSApi.Expression,
|
|
655
|
-
|
|
909
|
+
scope?: SqlScope,
|
|
656
910
|
): SqlExpressionShape {
|
|
657
|
-
if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression,
|
|
911
|
+
if (ts.isParenthesizedExpression(node)) return sqlExpressionShape(ts, node.expression, scope)
|
|
658
912
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
659
913
|
return {
|
|
660
914
|
staticText: node.text,
|
|
@@ -671,7 +925,7 @@ function sqlExpressionShape(
|
|
|
671
925
|
(ts.isPropertyAccessExpression(tag) && tag.getText() === "Prisma.sql")
|
|
672
926
|
const template = node.template
|
|
673
927
|
const templated = ts.isTemplateExpression(template)
|
|
674
|
-
? templateShape(ts, template, parameterizedTag ? undefined :
|
|
928
|
+
? templateShape(ts, template, parameterizedTag ? undefined : scope)
|
|
675
929
|
: { staticText: template.text, dynamic: false }
|
|
676
930
|
const staticText = templated.staticText
|
|
677
931
|
// Only the ecosystem's binding tags are trusted, by NAME - which is the honest limit of a scanner
|
|
@@ -696,7 +950,7 @@ function sqlExpressionShape(
|
|
|
696
950
|
}
|
|
697
951
|
}
|
|
698
952
|
if (ts.isTemplateExpression(node)) {
|
|
699
|
-
const templated = templateShape(ts, node,
|
|
953
|
+
const templated = templateShape(ts, node, scope)
|
|
700
954
|
return {
|
|
701
955
|
staticText: templated.staticText,
|
|
702
956
|
dynamic: templated.dynamic,
|
|
@@ -706,8 +960,8 @@ function sqlExpressionShape(
|
|
|
706
960
|
}
|
|
707
961
|
}
|
|
708
962
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
709
|
-
const left = sqlExpressionShape(ts, node.left,
|
|
710
|
-
const right = sqlExpressionShape(ts, node.right,
|
|
963
|
+
const left = sqlExpressionShape(ts, node.left, scope)
|
|
964
|
+
const right = sqlExpressionShape(ts, node.right, scope)
|
|
711
965
|
return {
|
|
712
966
|
staticText: left.staticText + right.staticText,
|
|
713
967
|
// A resolved const operand is static text, not a dynamic part - so the old `!literal` test
|
|
@@ -721,11 +975,11 @@ function sqlExpressionShape(
|
|
|
721
975
|
}
|
|
722
976
|
// A resolvable expression (a module-`const` name, a literal ternary) contributes its compile-time
|
|
723
977
|
// text and stays non-dynamic - this is what lets `"SELECT " + COLS + " FROM t"` and
|
|
724
|
-
// `db.query(`${COLS} FROM t WHERE id = $1`)` pass while
|
|
978
|
+
// `db.query(`${COLS} FROM t WHERE id = $1`)` pass while a mutable or unprovable name still flags.
|
|
725
979
|
// `literal` stays false: the named escape hatches (`unsafe`, `$queryRawUnsafe`) keep flagging a
|
|
726
980
|
// resolved const, because taking statement text from ANY variable is what their rule warns about.
|
|
727
|
-
if (
|
|
728
|
-
const resolved = resolveStaticSqlText(ts, node,
|
|
981
|
+
if (scope !== undefined) {
|
|
982
|
+
const resolved = resolveStaticSqlText(ts, node, scope, 0)
|
|
729
983
|
if (resolved !== undefined) {
|
|
730
984
|
return {
|
|
731
985
|
staticText: resolved,
|
|
@@ -770,32 +1024,35 @@ function sqlMethod(
|
|
|
770
1024
|
|
|
771
1025
|
const SQL_RECEIVER = /(?:^|\.)(?:db|sql|prisma|drizzle|database|conn|connection|client|tx)$/i
|
|
772
1026
|
|
|
773
|
-
/**
|
|
1027
|
+
/**
|
|
1028
|
+
* Scan one file's text for SQL assembled by interpolation. Pure + line-accurate.
|
|
1029
|
+
*
|
|
1030
|
+
* `imports` opts into following a fragment `const` into another module. Without it the scan is
|
|
1031
|
+
* same-file only, which is what the unit tests exercise and what any caller that has no module graph
|
|
1032
|
+
* to offer gets. With it, only what {@link createProjectSqlImports} will hand back is followed.
|
|
1033
|
+
*/
|
|
774
1034
|
export function scanInterpolatedSql(
|
|
775
1035
|
file: string,
|
|
776
1036
|
content: string,
|
|
777
1037
|
ts: TypeScriptApi,
|
|
1038
|
+
imports?: SqlImports,
|
|
778
1039
|
): SourceFinding[] {
|
|
779
1040
|
const out: SourceFinding[] = []
|
|
780
1041
|
const lines = content.split("\n")
|
|
781
|
-
const kind = /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
|
|
782
|
-
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, kind)
|
|
783
1042
|
// Malformed source is already a typecheck failure. Do not turn parser recovery nodes into a second,
|
|
784
1043
|
// misleading SQL diagnostic (notably an unterminated template recovered as an interpolation).
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
// while imported, mutable, parameter, and shadowed names flag exactly as before.
|
|
792
|
-
const consts = moduleConstsOf(ts, source)
|
|
1044
|
+
const source = parseSqlSource(ts, file, content)
|
|
1045
|
+
if (source === undefined) return out
|
|
1046
|
+
// Module-scope bindings, read once per file: an interpolated `const` that resolves to compile-time
|
|
1047
|
+
// text cannot carry request data, so the shared-projection idiom (`const COLS = "id, name"` …
|
|
1048
|
+
// `${COLS}`) stays quiet while mutable, parameter, shadowed, and unprovable names flag as before.
|
|
1049
|
+
const scope: SqlScope = { module: sqlModuleScope(ts, file, source), imports }
|
|
793
1050
|
const visit = (node: TSApi.Node): void => {
|
|
794
1051
|
if (ts.isCallExpression(node)) {
|
|
795
1052
|
const sink = sqlMethod(ts, node)
|
|
796
1053
|
const argument = node.arguments[0]
|
|
797
1054
|
if (sink !== undefined && argument !== undefined) {
|
|
798
|
-
const shape = sqlExpressionShape(ts, argument,
|
|
1055
|
+
const shape = sqlExpressionShape(ts, argument, scope)
|
|
799
1056
|
const unsafeEscape =
|
|
800
1057
|
ALWAYS_UNSAFE.has(sink.method) && !shape.literal && !shape.parameterizedTag
|
|
801
1058
|
const assembledSql =
|
|
@@ -824,13 +1081,63 @@ export function scanInterpolatedSql(
|
|
|
824
1081
|
* answer a security rule must never give when it did not run.
|
|
825
1082
|
*/
|
|
826
1083
|
export async function scanProjectSql(cwd: string): Promise<SourceFinding[] | undefined> {
|
|
827
|
-
const ts = await
|
|
1084
|
+
const ts = await importProjectTypeScript(cwd)
|
|
828
1085
|
if (ts === undefined) return undefined
|
|
829
1086
|
const out: SourceFinding[] = []
|
|
830
|
-
|
|
1087
|
+
const imports = createProjectSqlImports(cwd)
|
|
1088
|
+
await walkSource(cwd, (rel, content) =>
|
|
1089
|
+
out.push(...scanInterpolatedSql(rel, content, ts, imports)),
|
|
1090
|
+
)
|
|
831
1091
|
return out.sort(bySite)
|
|
832
1092
|
}
|
|
833
1093
|
|
|
1094
|
+
/** A declaration file. Excluded from cross-module resolution: `declare const X: string` has no
|
|
1095
|
+
* initializer to read, so following one could only ever produce a "proved" the source never wrote. */
|
|
1096
|
+
const SQL_DECLARATION_FILE = /\.d\.[cm]?ts$/
|
|
1097
|
+
/** The extensions the SQL scan will parse - the same set {@link walkSource} globs. */
|
|
1098
|
+
const SQL_SOURCE_FILE = /\.[cm]?tsx?$/
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* The on-disk loader for cross-module `const` resolution, scoped to one project.
|
|
1102
|
+
*
|
|
1103
|
+
* Deliberately narrow, because every widening here is a way for a real finding to go quiet:
|
|
1104
|
+
*
|
|
1105
|
+
* - **Relative specifiers only.** A bare specifier names a dependency whose exports can change without
|
|
1106
|
+
* this project changing, so "provably a `const` string" is not a property the project owns.
|
|
1107
|
+
* - **Inside `cwd`, outside the ignored trees.** The resolved path is realpath'd and must still sit
|
|
1108
|
+
* under the project root: a symlink pointing out of the tree is not source this scan reads. The
|
|
1109
|
+
* `node_modules` / `dist` / build exclusions are the same ones the walk applies.
|
|
1110
|
+
* - **Real source only.** A `.d.ts` carries no initializer, and a non-TS file is not parsed.
|
|
1111
|
+
*
|
|
1112
|
+
* A miss on any of those returns `undefined`, which leaves the name unresolved and the interpolation
|
|
1113
|
+
* flagged - the pre-feature answer.
|
|
1114
|
+
*/
|
|
1115
|
+
export function createProjectSqlImports(cwd: string): SqlImports {
|
|
1116
|
+
const root = (() => {
|
|
1117
|
+
try {
|
|
1118
|
+
return realpathSync(resolve(cwd))
|
|
1119
|
+
} catch {
|
|
1120
|
+
return resolve(cwd)
|
|
1121
|
+
}
|
|
1122
|
+
})()
|
|
1123
|
+
return createSqlImports((fromModule, specifier) => {
|
|
1124
|
+
if (!isRelativeSpecifier(specifier)) return undefined
|
|
1125
|
+
try {
|
|
1126
|
+
// `fromModule` is the cwd-RELATIVE path the walk supplies for an entry file, and ABSOLUTE for
|
|
1127
|
+
// every module reached through a hop (their ids are the resolved paths).
|
|
1128
|
+
const fromAbs = isAbsolute(fromModule) ? fromModule : join(root, fromModule)
|
|
1129
|
+
const resolved = realpathSync(Bun.resolveSync(specifier, dirname(fromAbs)))
|
|
1130
|
+
if (!resolved.startsWith(root + sep)) return undefined
|
|
1131
|
+
const rel = resolved.slice(root.length + 1)
|
|
1132
|
+
if (IGNORED_DIR.test(rel)) return undefined
|
|
1133
|
+
if (SQL_DECLARATION_FILE.test(resolved) || !SQL_SOURCE_FILE.test(resolved)) return undefined
|
|
1134
|
+
return { id: resolved, content: readFileSync(resolved, "utf8") }
|
|
1135
|
+
} catch {
|
|
1136
|
+
return undefined // unresolvable, unreadable, or outside the project
|
|
1137
|
+
}
|
|
1138
|
+
})
|
|
1139
|
+
}
|
|
1140
|
+
|
|
834
1141
|
// `client("` / `client('` / `client(\`` - a URL-first call WITHOUT the `<typeof app>` generic:
|
|
835
1142
|
// the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
|
|
836
1143
|
// `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
|
|
@@ -919,7 +1226,11 @@ export function scanRemovedImports(file: string, content: string): SourceFinding
|
|
|
919
1226
|
/** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
|
|
920
1227
|
* `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
|
|
921
1228
|
* the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
|
|
922
|
-
export function scanServerOnlyImports(
|
|
1229
|
+
export function scanServerOnlyImports(
|
|
1230
|
+
file: string,
|
|
1231
|
+
content: string,
|
|
1232
|
+
facts?: SourceFacts,
|
|
1233
|
+
): ServerImportFinding[] {
|
|
923
1234
|
if (!ROUTE_FILE.test(file)) return []
|
|
924
1235
|
const out: ServerImportFinding[] = []
|
|
925
1236
|
const lines = content.split("\n")
|
|
@@ -930,6 +1241,13 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
|
|
|
930
1241
|
if (positions[m.index] === " ") continue
|
|
931
1242
|
const specifier = m[1] ?? ""
|
|
932
1243
|
if (!SERVER_ONLY.test(specifier)) continue
|
|
1244
|
+
if (facts !== undefined) {
|
|
1245
|
+
const source = facts.parse(file, content)
|
|
1246
|
+
// Inline `import { type X } from "…"` is erased just like `import type`; don't call it a
|
|
1247
|
+
// runtime leak. A parse failure keeps the old lexical finding so the security rule fails closed.
|
|
1248
|
+
if (source !== undefined && facts.isValueImportAt(source, m.index, specifier) === false)
|
|
1249
|
+
continue
|
|
1250
|
+
}
|
|
933
1251
|
const line = lineAt(content, m.index)
|
|
934
1252
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim(), specifier })
|
|
935
1253
|
}
|
|
@@ -976,22 +1294,31 @@ const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_
|
|
|
976
1294
|
* each with the match index (for line attribution). Mirrors {@link STATIC_IMPORT}, so `import type` +
|
|
977
1295
|
* dynamic `import()` are already excluded. Uses a fresh regex instance, so it's safe under the reentrant
|
|
978
1296
|
* transitive walk. Pure. */
|
|
979
|
-
function staticImportEdges(
|
|
1297
|
+
function staticImportEdges(
|
|
1298
|
+
content: string,
|
|
1299
|
+
facts?: SourceFacts,
|
|
1300
|
+
file?: string,
|
|
1301
|
+
): Array<{ specifier: string; index: number }> {
|
|
980
1302
|
const edges: Array<{ specifier: string; index: number }> = []
|
|
981
1303
|
const re = staticImportRegex()
|
|
982
1304
|
const code = stripComments(content)
|
|
983
1305
|
const positions = codePositionMask(content)
|
|
984
1306
|
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
985
1307
|
if (positions[m.index] === " ") continue
|
|
986
|
-
if (m[1]
|
|
1308
|
+
if (m[1] === undefined) continue
|
|
1309
|
+
if (facts !== undefined && file !== undefined) {
|
|
1310
|
+
const source = facts.parse(file, content)
|
|
1311
|
+
if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
|
|
1312
|
+
}
|
|
1313
|
+
edges.push({ specifier: m[1], index: m.index })
|
|
987
1314
|
}
|
|
988
1315
|
return edges
|
|
989
1316
|
}
|
|
990
1317
|
|
|
991
1318
|
/** Extract the static, non-type import specifiers from a module's source (the edges to follow/inspect).
|
|
992
1319
|
* Mirrors {@link STATIC_IMPORT}, so `import type` + dynamic `import()` are already excluded. Pure. */
|
|
993
|
-
export function parseStaticImports(content: string): string[] {
|
|
994
|
-
return staticImportEdges(content).map((e) => e.specifier)
|
|
1320
|
+
export function parseStaticImports(content: string, facts?: SourceFacts, file?: string): string[] {
|
|
1321
|
+
return staticImportEdges(content, facts, file).map((e) => e.specifier)
|
|
995
1322
|
}
|
|
996
1323
|
|
|
997
1324
|
/** The server-only SINK an import specifier names directly (a `node:`/`bun:` builtin or a known
|
|
@@ -1034,6 +1361,7 @@ export function walkServerOnlyChain(
|
|
|
1034
1361
|
routeContent: string,
|
|
1035
1362
|
resolve: ModuleResolver,
|
|
1036
1363
|
read: ModuleReader,
|
|
1364
|
+
facts?: SourceFacts,
|
|
1037
1365
|
): readonly string[] | undefined {
|
|
1038
1366
|
// Frontier nodes carry the absolute file to inspect, the source to scan, the display chain so far
|
|
1039
1367
|
// (route + the as-written specifier of each hop), and the file the imports resolve relative to.
|
|
@@ -1050,7 +1378,7 @@ export function walkServerOnlyChain(
|
|
|
1050
1378
|
const next: Node[] = []
|
|
1051
1379
|
for (const node of frontier) {
|
|
1052
1380
|
if (node.depth >= TRANSITIVE_MAX_DEPTH) continue
|
|
1053
|
-
for (const spec of parseStaticImports(node.content)) {
|
|
1381
|
+
for (const spec of parseStaticImports(node.content, facts, node.abs)) {
|
|
1054
1382
|
// (a) A by-name sink (builtin / known server-only pkg) → the chain ends here (shortest first,
|
|
1055
1383
|
// since BFS reaches the nearest sink before any deeper one).
|
|
1056
1384
|
const sink = directSinkSpecifier(spec)
|
|
@@ -1091,6 +1419,7 @@ export function resolveServerOnlyChains(
|
|
|
1091
1419
|
content: string,
|
|
1092
1420
|
resolve: ModuleResolver,
|
|
1093
1421
|
read: ModuleReader,
|
|
1422
|
+
facts?: SourceFacts,
|
|
1094
1423
|
): TransitiveServerImportFinding[] {
|
|
1095
1424
|
if (!ROUTE_FILE.test(file)) return []
|
|
1096
1425
|
const lines = content.split("\n")
|
|
@@ -1099,7 +1428,7 @@ export function resolveServerOnlyChains(
|
|
|
1099
1428
|
// Collect the route's import edges up front (fresh-regex scan) so the per-edge logic below can call
|
|
1100
1429
|
// the REENTRANT transitive walk without corrupting a shared regex's `lastIndex` (the walk also scans
|
|
1101
1430
|
// imports). Driving `STATIC_IMPORT.exec` here directly would restart this loop forever.
|
|
1102
|
-
for (const { specifier, index } of staticImportEdges(content)) {
|
|
1431
|
+
for (const { specifier, index } of staticImportEdges(content, facts, file)) {
|
|
1103
1432
|
if (flaggedSpecifiers.has(specifier)) continue
|
|
1104
1433
|
const line = lineAt(content, index)
|
|
1105
1434
|
const snippet = (lines[line - 1] ?? "").trim()
|
|
@@ -1138,7 +1467,7 @@ export function resolveServerOnlyChains(
|
|
|
1138
1467
|
continue
|
|
1139
1468
|
}
|
|
1140
1469
|
// Walk INTO the dependency: build the chain `[depAbs, …]` then re-root it at the route's import.
|
|
1141
|
-
const subChain = walkServerOnlyChain(abs, depContent, resolve, read)
|
|
1470
|
+
const subChain = walkServerOnlyChain(abs, depContent, resolve, read, facts)
|
|
1142
1471
|
if (subChain !== undefined) {
|
|
1143
1472
|
flaggedSpecifiers.add(specifier)
|
|
1144
1473
|
// subChain is `[depAbs, …hops…, sink]`; replace its head (the resolved dep path) with the
|
|
@@ -1167,7 +1496,11 @@ const RESPONSE_RETURN = /(?:=>\s*|return\s+)(?:new\s+Response|Response\s*\.\s*js
|
|
|
1167
1496
|
|
|
1168
1497
|
/** Scan a backend module (one that calls `server(`) for handlers returning a raw `Response`, which collapses
|
|
1169
1498
|
* the typed client's `data` to `never`. Pure + line-accurate; returns `[]` for files with no `server(` call. */
|
|
1170
|
-
export function scanResponseRoutes(
|
|
1499
|
+
export function scanResponseRoutes(
|
|
1500
|
+
file: string,
|
|
1501
|
+
content: string,
|
|
1502
|
+
facts?: SourceFacts,
|
|
1503
|
+
): SourceFinding[] {
|
|
1171
1504
|
// Strip comments + template literals first: a commented-out or doc-example `return new Response(`
|
|
1172
1505
|
// must not raise a spurious advisory. Lengths are preserved, so lineAt + the raw-line snippet align.
|
|
1173
1506
|
const code = stripComments(content)
|
|
@@ -1176,6 +1509,12 @@ export function scanResponseRoutes(file: string, content: string): SourceFinding
|
|
|
1176
1509
|
const lines = content.split("\n")
|
|
1177
1510
|
RESPONSE_RETURN.lastIndex = 0
|
|
1178
1511
|
for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
|
|
1512
|
+
if (facts !== undefined) {
|
|
1513
|
+
const source = facts.parse(file, content)
|
|
1514
|
+
// A parseable AST rejects `return new Response(` text that only lives in a normal string. On
|
|
1515
|
+
// malformed source we keep the lexical candidate; the typecheck gate will report the syntax error.
|
|
1516
|
+
if (source !== undefined && facts.isResponseSyntaxAt(source, m.index) !== true) continue
|
|
1517
|
+
}
|
|
1179
1518
|
const line = lineAt(content, m.index)
|
|
1180
1519
|
// A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
|
|
1181
1520
|
// be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
|
|
@@ -1334,16 +1673,42 @@ interface TypecheckResult {
|
|
|
1334
1673
|
readonly note?: string
|
|
1335
1674
|
readonly output?: string
|
|
1336
1675
|
readonly cancelled?: boolean
|
|
1676
|
+
/** tsconfig.json exists but no `typescript` install was found walking up from cwd. The contract
|
|
1677
|
+
* gate could not run - the caller surfaces this as a FAILING diagnostic, never a silent skip. */
|
|
1678
|
+
readonly missingTypeScript?: boolean
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Find the project's `tsc` the way module resolution would: `node_modules/typescript/bin/tsc` in
|
|
1683
|
+
* cwd, then each parent directory up to the filesystem root. A workspace package in a monorepo has
|
|
1684
|
+
* its TypeScript hoisted to the workspace root, so the literal `join(cwd, "node_modules", …)` probe
|
|
1685
|
+
* this replaces reported "typescript not installed" - and silently skipped the gate - whenever
|
|
1686
|
+
* `nifra check` ran from the package directory instead of the repo root.
|
|
1687
|
+
*/
|
|
1688
|
+
function resolveTscBin(cwd: string): string | undefined {
|
|
1689
|
+
let dir = cwd
|
|
1690
|
+
while (true) {
|
|
1691
|
+
const candidate = join(dir, "node_modules", "typescript", "bin", "tsc")
|
|
1692
|
+
if (existsSync(candidate)) return candidate
|
|
1693
|
+
const parent = dirname(dir)
|
|
1694
|
+
if (parent === dir) return undefined
|
|
1695
|
+
dir = parent
|
|
1696
|
+
}
|
|
1337
1697
|
}
|
|
1338
1698
|
|
|
1339
1699
|
/** Run the project's own `tsc --noEmit`, if TypeScript + a tsconfig are present. Never auto-installs. */
|
|
1340
1700
|
async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckResult> {
|
|
1341
1701
|
const tsconfig = join(cwd, "tsconfig.json")
|
|
1342
|
-
const tscBin = join(cwd, "node_modules", "typescript", "bin", "tsc")
|
|
1343
1702
|
if (!(await Bun.file(tsconfig).exists()))
|
|
1344
1703
|
return { ran: false, ok: true, note: "no tsconfig.json" }
|
|
1345
|
-
|
|
1346
|
-
|
|
1704
|
+
const tscBin = resolveTscBin(cwd)
|
|
1705
|
+
if (tscBin === undefined) {
|
|
1706
|
+
return {
|
|
1707
|
+
ran: false,
|
|
1708
|
+
ok: false,
|
|
1709
|
+
missingTypeScript: true,
|
|
1710
|
+
note: "typescript not installed (run: bun add -d typescript)",
|
|
1711
|
+
}
|
|
1347
1712
|
}
|
|
1348
1713
|
if (signal?.aborted) return { ran: true, ok: false, cancelled: true, output: "cancelled" }
|
|
1349
1714
|
const proc = Bun.spawn(["bun", tscBin, "--noEmit", "-p", tsconfig], {
|
|
@@ -1385,26 +1750,13 @@ const TSC_LINE = /^(.+?)\((\d+),\d+\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
|
|
|
1385
1750
|
|
|
1386
1751
|
/** A single machine-readable check failure - the unit an agent (or CI) acts on. */
|
|
1387
1752
|
export interface CheckDiagnostic {
|
|
1388
|
-
readonly rule:
|
|
1389
|
-
| "typecheck"
|
|
1390
|
-
| "typed-client"
|
|
1391
|
-
| "untyped-client"
|
|
1392
|
-
| "server-only-import"
|
|
1393
|
-
| "removed-import"
|
|
1394
|
-
| "interpolated-sql"
|
|
1395
|
-
| "response-route"
|
|
1396
|
-
| "undeclared-dependency"
|
|
1397
|
-
| "duplicate-install"
|
|
1398
|
-
| "stale-workspace-dist"
|
|
1399
|
-
| "server-manifest-drift"
|
|
1400
|
-
| "manifest-drift"
|
|
1401
|
-
| "capability-assurance"
|
|
1402
|
-
| "capability-config"
|
|
1403
|
-
| "check-config"
|
|
1753
|
+
readonly rule: string
|
|
1404
1754
|
/** `error` fails the gate (a real contract break); `warning` is advisory - surfaced to the agent but
|
|
1405
1755
|
* does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
|
|
1406
1756
|
* `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
|
|
1407
|
-
readonly severity: "error" | "warning"
|
|
1757
|
+
readonly severity: "error" | "warning" | "info"
|
|
1758
|
+
/** Stable diagnostic protocol code. The legacy `rule` remains for compatibility. */
|
|
1759
|
+
readonly code?: string
|
|
1408
1760
|
readonly file?: string
|
|
1409
1761
|
readonly line?: number
|
|
1410
1762
|
readonly message: string
|
|
@@ -1427,6 +1779,8 @@ export interface CheckDiagnostic {
|
|
|
1427
1779
|
* edge rather than fabricating a deeper path - never a lie.
|
|
1428
1780
|
*/
|
|
1429
1781
|
readonly chain?: readonly string[]
|
|
1782
|
+
readonly evidence?: readonly string[]
|
|
1783
|
+
readonly verify?: string
|
|
1430
1784
|
}
|
|
1431
1785
|
|
|
1432
1786
|
export interface CheckSuggestion {
|
|
@@ -1442,11 +1796,27 @@ export interface CheckSuggestion {
|
|
|
1442
1796
|
export interface CheckResult {
|
|
1443
1797
|
readonly ok: boolean
|
|
1444
1798
|
readonly typecheck: "pass" | "fail" | "skipped"
|
|
1799
|
+
/** Why `typecheck` is `"skipped"` (no tsconfig.json, typescript not installed, lints-only mode).
|
|
1800
|
+
* Absent when the typecheck ran. Echoed in the human report so a skip is never a dim mystery. */
|
|
1801
|
+
readonly typecheckNote?: string
|
|
1445
1802
|
readonly diagnostics: readonly CheckDiagnostic[]
|
|
1803
|
+
/** Normalized diagnostics with stable codes for agents and external renderers. */
|
|
1804
|
+
readonly structuredDiagnostics?: readonly Diagnostic[]
|
|
1805
|
+
/**
|
|
1806
|
+
* Which bundler this app's phases run on, read statically from the config, and how nifra concluded
|
|
1807
|
+
* it. Returned even when nothing is wrong: an agent reading this project has to know which plugin
|
|
1808
|
+
* slot is live and which toolchain compiles a component before its next edit, and every `pipeline`
|
|
1809
|
+
* diagnostic below is only interpretable against it. Absent when the directory is not a nifra app.
|
|
1810
|
+
*/
|
|
1811
|
+
readonly pipeline?: PipelineReport
|
|
1446
1812
|
/** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
|
|
1447
1813
|
* better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
|
|
1448
1814
|
* deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
|
|
1449
1815
|
readonly externalMounts?: readonly string[]
|
|
1816
|
+
/** Active per-rule overrides from `nifra.check.json` `rules`, echoed verbatim so a retagged or
|
|
1817
|
+
* suppressed finding stays auditable in `--json`, the MCP tool, and the human report - config can
|
|
1818
|
+
* lower (or raise) the gate, but never invisibly. */
|
|
1819
|
+
readonly ruleOverrides?: Readonly<Record<string, RuleOverride>>
|
|
1450
1820
|
/** Set only when the caller passed `maxDiagnostics` and there were more - `diagnostics` then holds the
|
|
1451
1821
|
* first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
|
|
1452
1822
|
* message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
|
|
@@ -1470,31 +1840,103 @@ export interface CheckAssuranceContext {
|
|
|
1470
1840
|
readonly routeAssurance?: AssuranceReport
|
|
1471
1841
|
/** Static capability provenance, when the config declares a capabilities policy. */
|
|
1472
1842
|
readonly capability?: CapabilityProjectReport
|
|
1843
|
+
/** Canonical token-only evidence reused by manifest and other offline projections. */
|
|
1844
|
+
readonly evidence?: ProjectEvidenceSnapshot
|
|
1473
1845
|
}
|
|
1474
1846
|
|
|
1475
1847
|
/** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
|
|
1476
1848
|
* the app is built or even importable, preserving check's pre-`loadApp` invariant. */
|
|
1477
1849
|
interface CheckConfig {
|
|
1478
1850
|
readonly externalMounts: readonly string[]
|
|
1851
|
+
readonly rules: Readonly<Record<string, RuleOverride>>
|
|
1479
1852
|
}
|
|
1480
1853
|
|
|
1481
|
-
/**
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
|
|
1854
|
+
/**
|
|
1855
|
+
* One entry of `nifra.check.json` `rules`, keyed by legacy rule name (`response-route`) or stable
|
|
1856
|
+
* NF- code (`NF-S002`) - one key retags the finding in both diagnostic views. `severity: "off"`
|
|
1857
|
+
* drops the rule's findings; `ignore` drops findings whose file matches any of the globs. Overrides
|
|
1858
|
+
* are applied centrally BEFORE `ok` is computed and echoed in the result and the human report -
|
|
1859
|
+
* configuration can lower (or raise) the gate, but never invisibly.
|
|
1860
|
+
*/
|
|
1861
|
+
export interface RuleOverride {
|
|
1862
|
+
readonly severity?: "error" | "warn" | "info" | "off"
|
|
1863
|
+
readonly ignore?: readonly string[]
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const OVERRIDE_SEVERITIES: readonly string[] = ["error", "warn", "info", "off"]
|
|
1867
|
+
|
|
1868
|
+
/** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed JSON → empty plus a
|
|
1869
|
+
* parse `error`; an invalid entry → skipped plus a `warnings` note the caller surfaces as a `warning`
|
|
1870
|
+
* diagnostic (never throws, never blocks the gate). Mount entries are normalized to bare path prefixes
|
|
1871
|
+
* (`/auth/**` and `/auth/` both become `/auth`). */
|
|
1872
|
+
async function loadCheckConfig(
|
|
1873
|
+
cwd: string,
|
|
1874
|
+
): Promise<{ config: CheckConfig; error?: string; warnings: readonly string[] }> {
|
|
1485
1875
|
const path = join(cwd, "nifra.check.json")
|
|
1486
|
-
|
|
1876
|
+
const empty: CheckConfig = { externalMounts: [], rules: {} }
|
|
1877
|
+
if (!existsSync(path)) return { config: empty, warnings: [] }
|
|
1487
1878
|
try {
|
|
1488
|
-
const parsed = JSON.parse(await Bun.file(path).text()) as {
|
|
1879
|
+
const parsed = JSON.parse(await Bun.file(path).text()) as {
|
|
1880
|
+
externalMounts?: unknown
|
|
1881
|
+
rules?: unknown
|
|
1882
|
+
}
|
|
1489
1883
|
const raw = Array.isArray(parsed.externalMounts) ? parsed.externalMounts : []
|
|
1490
1884
|
const externalMounts = raw
|
|
1491
1885
|
.filter((m): m is string => typeof m === "string" && m.startsWith("/"))
|
|
1492
1886
|
.map((m) => m.replace(/\/\*+$/, "").replace(/\/+$/, "") || "/")
|
|
1493
|
-
|
|
1887
|
+
const warnings: string[] = []
|
|
1888
|
+
const rules: Record<string, RuleOverride> = {}
|
|
1889
|
+
if (parsed.rules !== undefined) {
|
|
1890
|
+
if (
|
|
1891
|
+
typeof parsed.rules !== "object" ||
|
|
1892
|
+
parsed.rules === null ||
|
|
1893
|
+
Array.isArray(parsed.rules)
|
|
1894
|
+
) {
|
|
1895
|
+
warnings.push('`rules` must be an object of { "<rule>": { severity?, ignore? } } - ignored')
|
|
1896
|
+
} else {
|
|
1897
|
+
for (const [rule, value] of Object.entries(parsed.rules)) {
|
|
1898
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1899
|
+
warnings.push(`rules["${rule}"] must be an object - ignored`)
|
|
1900
|
+
continue
|
|
1901
|
+
}
|
|
1902
|
+
const entry = value as { severity?: unknown; ignore?: unknown }
|
|
1903
|
+
const override: {
|
|
1904
|
+
severity?: NonNullable<RuleOverride["severity"]>
|
|
1905
|
+
ignore?: readonly string[]
|
|
1906
|
+
} = {}
|
|
1907
|
+
if (entry.severity !== undefined) {
|
|
1908
|
+
if (
|
|
1909
|
+
typeof entry.severity === "string" &&
|
|
1910
|
+
OVERRIDE_SEVERITIES.includes(entry.severity)
|
|
1911
|
+
) {
|
|
1912
|
+
override.severity = entry.severity as NonNullable<RuleOverride["severity"]>
|
|
1913
|
+
} else {
|
|
1914
|
+
warnings.push(
|
|
1915
|
+
`rules["${rule}"].severity must be "error" | "warn" | "info" | "off" - ignored`,
|
|
1916
|
+
)
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
if (entry.ignore !== undefined) {
|
|
1920
|
+
if (
|
|
1921
|
+
Array.isArray(entry.ignore) &&
|
|
1922
|
+
entry.ignore.every((g): g is string => typeof g === "string")
|
|
1923
|
+
) {
|
|
1924
|
+
if (entry.ignore.length > 0) override.ignore = entry.ignore
|
|
1925
|
+
} else {
|
|
1926
|
+
warnings.push(`rules["${rule}"].ignore must be an array of file globs - ignored`)
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
if (override.severity !== undefined || override.ignore !== undefined)
|
|
1930
|
+
rules[rule] = override
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
return { config: { externalMounts, rules }, warnings }
|
|
1494
1935
|
} catch (error) {
|
|
1495
1936
|
return {
|
|
1496
|
-
config:
|
|
1937
|
+
config: empty,
|
|
1497
1938
|
error: error instanceof Error ? error.message : String(error),
|
|
1939
|
+
warnings: [],
|
|
1498
1940
|
}
|
|
1499
1941
|
}
|
|
1500
1942
|
}
|
|
@@ -1507,6 +1949,8 @@ const SERVER_IMPORT_HINT =
|
|
|
1507
1949
|
"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"
|
|
1508
1950
|
const RESPONSE_ROUTE_HINT =
|
|
1509
1951
|
"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"
|
|
1952
|
+
const PIPELINE_DOC_HINT =
|
|
1953
|
+
"nifra runs one bundler per phase - `vitePlugins` feed Vite, `clientPlugins`/`serverPlugins` feed Bun, and the file `nifra build` imports the adapter from is bundled into the server. See the Gotchas section of the Dev & HMR guide."
|
|
1510
1954
|
const UNDECLARED_DEP_HINT =
|
|
1511
1955
|
"imported package is not declared in package.json dependencies - run bun add to declare it"
|
|
1512
1956
|
const SQL_COMPILER_MISSING_HINT =
|
|
@@ -1680,28 +2124,41 @@ export async function collectCheckResult(
|
|
|
1680
2124
|
// Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
|
|
1681
2125
|
// (#4.4) - which needs fs-backed import resolution, not just per-file text - runs after the walk.
|
|
1682
2126
|
const routeModules: Array<{ rel: string; content: string }> = []
|
|
2127
|
+
const sourceFiles: Array<{ file: string; content: string }> = []
|
|
1683
2128
|
// Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
|
|
1684
2129
|
// walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
|
|
1685
|
-
const {
|
|
2130
|
+
const {
|
|
2131
|
+
config: checkConfig,
|
|
2132
|
+
error: checkConfigError,
|
|
2133
|
+
warnings: checkConfigWarnings,
|
|
2134
|
+
} = await loadCheckConfig(cwd)
|
|
1686
2135
|
// The interpolated-SQL rule parses with the TypeScript compiler, which is an optional peer. Resolved
|
|
1687
2136
|
// once, before the walk, so a project without it is told the rule did not run rather than being
|
|
1688
2137
|
// handed a clean report the rule never produced.
|
|
1689
|
-
const sqlCompiler = await (opts.loadTypeScript ??
|
|
2138
|
+
const sqlCompiler = await (opts.loadTypeScript ?? (() => importProjectTypeScript(cwd)))()
|
|
2139
|
+
// AST facts are a lazy refinement of lexical candidates. The parser is not invoked for a file until
|
|
2140
|
+
// one of the route/import/response scanners finds something worth disambiguating, and the cache is
|
|
2141
|
+
// shared across those rules for this check run.
|
|
2142
|
+
const sourceFacts = sqlCompiler === undefined ? undefined : createSourceFacts(sqlCompiler)
|
|
2143
|
+
// One loader + parse cache for the whole walk, so a shared `sql-fragments.ts` is read once no matter
|
|
2144
|
+
// how many route modules import a fragment out of it.
|
|
2145
|
+
const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd)
|
|
1690
2146
|
// lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
|
|
1691
2147
|
// lints - the agent inner-loop mode; the full gate stays the definition of done.
|
|
1692
2148
|
const [tc, _, dr, manifestDrift] = await Promise.all([
|
|
1693
2149
|
opts.lintsOnly
|
|
1694
|
-
? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "
|
|
2150
|
+
? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "lints-only mode" })
|
|
1695
2151
|
: typecheck(cwd, opts.signal),
|
|
1696
2152
|
walkSource(cwd, (rel, content) => {
|
|
2153
|
+
sourceFiles.push({ file: rel, content })
|
|
1697
2154
|
fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
|
|
1698
|
-
staticRoutes.push(...scanStaticRouteText(rel, content))
|
|
2155
|
+
staticRoutes.push(...scanStaticRouteText(rel, content, sourceFacts))
|
|
1699
2156
|
untypedClients.push(...scanUntypedClient(rel, content))
|
|
1700
2157
|
removedImports.push(...scanRemovedImports(rel, content))
|
|
1701
2158
|
if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
|
|
1702
|
-
responseRoutes.push(...scanResponseRoutes(rel, content))
|
|
2159
|
+
responseRoutes.push(...scanResponseRoutes(rel, content, sourceFacts))
|
|
1703
2160
|
if (sqlCompiler !== undefined)
|
|
1704
|
-
interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler))
|
|
2161
|
+
interpolatedSql.push(...scanInterpolatedSql(rel, content, sqlCompiler, sqlImports))
|
|
1705
2162
|
}),
|
|
1706
2163
|
import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
|
|
1707
2164
|
scanServerManifestDrift(cwd),
|
|
@@ -1730,10 +2187,13 @@ export async function collectCheckResult(
|
|
|
1730
2187
|
}
|
|
1731
2188
|
}
|
|
1732
2189
|
for (const { rel, content } of routeModules) {
|
|
1733
|
-
serverImports.push(
|
|
2190
|
+
serverImports.push(
|
|
2191
|
+
...resolveServerOnlyChains(rel, content, resolveModule, readModule, sourceFacts),
|
|
2192
|
+
)
|
|
1734
2193
|
}
|
|
1735
2194
|
|
|
1736
2195
|
const diagnostics: CheckDiagnostic[] = []
|
|
2196
|
+
const structuredExtras: Diagnostic[] = []
|
|
1737
2197
|
if (checkConfigError !== undefined) {
|
|
1738
2198
|
diagnostics.push({
|
|
1739
2199
|
rule: "check-config",
|
|
@@ -1743,6 +2203,32 @@ export async function collectCheckResult(
|
|
|
1743
2203
|
fix: "Fix the JSON syntax in nifra.check.json",
|
|
1744
2204
|
})
|
|
1745
2205
|
}
|
|
2206
|
+
for (const warning of checkConfigWarnings) {
|
|
2207
|
+
diagnostics.push({
|
|
2208
|
+
rule: "check-config",
|
|
2209
|
+
severity: "warning",
|
|
2210
|
+
file: "nifra.check.json",
|
|
2211
|
+
message: `nifra.check.json: ${warning}`,
|
|
2212
|
+
fix: "Fix the entry in nifra.check.json",
|
|
2213
|
+
})
|
|
2214
|
+
}
|
|
2215
|
+
if (tc.missingTypeScript === true) {
|
|
2216
|
+
// A tsconfig with no reachable `typescript` install is a broken gate, not a benign skip: the
|
|
2217
|
+
// contract check the project asked for (by having a tsconfig) silently didn't run. Fail closed.
|
|
2218
|
+
diagnostics.push({
|
|
2219
|
+
rule: "typecheck",
|
|
2220
|
+
severity: "error",
|
|
2221
|
+
file: "tsconfig.json",
|
|
2222
|
+
message:
|
|
2223
|
+
"tsconfig.json is present but no `typescript` install was found from this directory upward - the typecheck gate did NOT run",
|
|
2224
|
+
fix: "bun add -d typescript",
|
|
2225
|
+
suggestion: {
|
|
2226
|
+
kind: "command",
|
|
2227
|
+
title: "Install TypeScript so the contract gate can run",
|
|
2228
|
+
command: ["bun", "add", "-d", "typescript"],
|
|
2229
|
+
},
|
|
2230
|
+
})
|
|
2231
|
+
}
|
|
1746
2232
|
if (tc.ran && !tc.ok) {
|
|
1747
2233
|
const lines = (tc.output ?? "").split("\n")
|
|
1748
2234
|
let matched = false
|
|
@@ -1933,6 +2419,7 @@ export async function collectCheckResult(
|
|
|
1933
2419
|
? `${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}`
|
|
1934
2420
|
: `${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}`,
|
|
1935
2421
|
fix: `rebuild ${f.package}`,
|
|
2422
|
+
evidence: [f.package, f.distFile, f.sourceFile],
|
|
1936
2423
|
suggestion: {
|
|
1937
2424
|
kind: "manual",
|
|
1938
2425
|
title: `Rebuild ${f.package}`,
|
|
@@ -1944,6 +2431,21 @@ export async function collectCheckResult(
|
|
|
1944
2431
|
})
|
|
1945
2432
|
}
|
|
1946
2433
|
}
|
|
2434
|
+
// The two-pipeline rule, read from the config as TEXT (doctor collects it - see ./pipeline-report.ts).
|
|
2435
|
+
// `loadApp` already refuses a misplaced plugin, but only when something loads the app; a check that
|
|
2436
|
+
// never executes project code, and CI that never starts a dev server, would otherwise meet these for
|
|
2437
|
+
// the first time as a production server that built cleanly and died at startup.
|
|
2438
|
+
for (const f of dr.pipeline?.findings ?? []) {
|
|
2439
|
+
diagnostics.push({
|
|
2440
|
+
rule: "pipeline",
|
|
2441
|
+
severity: f.severity,
|
|
2442
|
+
file: f.file,
|
|
2443
|
+
...(f.line !== undefined ? { line: f.line } : {}),
|
|
2444
|
+
message: f.message,
|
|
2445
|
+
fix: f.fix,
|
|
2446
|
+
suggestion: { kind: "manual", title: f.fix, steps: [PIPELINE_DOC_HINT] },
|
|
2447
|
+
})
|
|
2448
|
+
}
|
|
1947
2449
|
// #7: a committed server-manifest.ts that drifted from routes/ - name the exact missing/extra routes.
|
|
1948
2450
|
for (const f of manifestDrift) {
|
|
1949
2451
|
const parts: string[] = []
|
|
@@ -1955,6 +2457,7 @@ export async function collectCheckResult(
|
|
|
1955
2457
|
file: f.file,
|
|
1956
2458
|
message: `${f.file} drifted from routes/ (${parts.join("; ")}) - ${MANIFEST_DRIFT_HINT}`,
|
|
1957
2459
|
fix: MANIFEST_DRIFT_HINT,
|
|
2460
|
+
evidence: [...f.missing, ...f.extra],
|
|
1958
2461
|
suggestion: {
|
|
1959
2462
|
kind: "manual",
|
|
1960
2463
|
title: "Regenerate the committed server manifest",
|
|
@@ -1970,6 +2473,7 @@ export async function collectCheckResult(
|
|
|
1970
2473
|
// provenance firewall as well as the typed-contract gate. Loading is explicit/config-owned; projects
|
|
1971
2474
|
// without nifra.assurance.ts retain the historical scan and hot path unchanged.
|
|
1972
2475
|
const provided = opts.assurance
|
|
2476
|
+
let applicationRulePacks: readonly import("./rules/index.ts").RulePack[] = []
|
|
1973
2477
|
const assuranceConfigPath = join(cwd, "nifra.assurance.ts")
|
|
1974
2478
|
if (provided !== undefined ? provided.present : existsSync(assuranceConfigPath)) {
|
|
1975
2479
|
try {
|
|
@@ -1978,6 +2482,7 @@ export async function collectCheckResult(
|
|
|
1978
2482
|
let config: AssuranceConfig
|
|
1979
2483
|
let project: CapabilityProjectReport | undefined
|
|
1980
2484
|
let routeAssurance: AssuranceReport | undefined
|
|
2485
|
+
let evidence: ProjectEvidenceSnapshot | undefined
|
|
1981
2486
|
if (provided !== undefined) {
|
|
1982
2487
|
// A load/evaluate failure travels as `provided.error`; re-throwing lands it in the same
|
|
1983
2488
|
// capability-config diagnostic the standalone catch produces.
|
|
@@ -1985,6 +2490,7 @@ export async function collectCheckResult(
|
|
|
1985
2490
|
config = provided.config as AssuranceConfig
|
|
1986
2491
|
project = provided.capability
|
|
1987
2492
|
routeAssurance = provided.routeAssurance
|
|
2493
|
+
evidence = provided.evidence
|
|
1988
2494
|
} else {
|
|
1989
2495
|
const { loadAssuranceConfig } = await import("./assure.ts")
|
|
1990
2496
|
config = await loadAssuranceConfig(cwd)
|
|
@@ -1993,6 +2499,7 @@ export async function collectCheckResult(
|
|
|
1993
2499
|
project = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
|
|
1994
2500
|
}
|
|
1995
2501
|
}
|
|
2502
|
+
applicationRulePacks = parseRulePacks(config.rulePacks)
|
|
1996
2503
|
const capabilityReport = project?.report
|
|
1997
2504
|
if (config.capabilities !== undefined && project !== undefined) {
|
|
1998
2505
|
for (const finding of project.report.findings) {
|
|
@@ -2010,6 +2517,12 @@ export async function collectCheckResult(
|
|
|
2010
2517
|
candidate.method === finding.method && candidate.path === finding.path,
|
|
2011
2518
|
)
|
|
2012
2519
|
: undefined
|
|
2520
|
+
// An unmatched seam is a policy defect, not a route defect: the fix is in the policy file,
|
|
2521
|
+
// so it gets its own steps instead of the route-side provenance guidance.
|
|
2522
|
+
const seamFix =
|
|
2523
|
+
finding.code === "unmatched-provenance-seam"
|
|
2524
|
+
? "Write the seam exactly as the code imports it, or delete the rule."
|
|
2525
|
+
: undefined
|
|
2013
2526
|
diagnostics.push({
|
|
2014
2527
|
rule: "capability-assurance",
|
|
2015
2528
|
severity: "error",
|
|
@@ -2018,18 +2531,30 @@ export async function collectCheckResult(
|
|
|
2018
2531
|
: truncation !== undefined
|
|
2019
2532
|
? { chain: truncation.chain }
|
|
2020
2533
|
: {}),
|
|
2021
|
-
message: `${finding.message} - ${CAPABILITY_HINT}`,
|
|
2022
|
-
fix: CAPABILITY_HINT,
|
|
2023
|
-
suggestion:
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2534
|
+
message: `${finding.message}${seamFix === undefined ? ` - ${CAPABILITY_HINT}` : ""}`,
|
|
2535
|
+
fix: seamFix ?? CAPABILITY_HINT,
|
|
2536
|
+
suggestion:
|
|
2537
|
+
seamFix === undefined
|
|
2538
|
+
? {
|
|
2539
|
+
kind: "manual",
|
|
2540
|
+
title: "Restore declared effect provenance",
|
|
2541
|
+
steps: [
|
|
2542
|
+
"Route effectful work through an import listed in capabilities.provenance.imports.",
|
|
2543
|
+
"Declare the exact capability token on the route; do not widen unrelated routes in the same file.",
|
|
2544
|
+
"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.",
|
|
2545
|
+
"Run `nifra capabilities snapshot` only after assurance passes, then review the lockfile diff.",
|
|
2546
|
+
],
|
|
2547
|
+
}
|
|
2548
|
+
: {
|
|
2549
|
+
kind: "manual",
|
|
2550
|
+
title: "Point the provenance rule at a module that exists",
|
|
2551
|
+
steps: [
|
|
2552
|
+
"Copy the specifier from the import statement itself - it is matched as written, with no extension or index resolution.",
|
|
2553
|
+
"Use a trailing `/*` when the seam is a directory of modules (`@myorg/db/*`).",
|
|
2554
|
+
"For a routeModules entry, give the project-relative path of the file that implements the route.",
|
|
2555
|
+
"Delete the rule if the seam it governed is gone; leaving it in place proves nothing.",
|
|
2556
|
+
],
|
|
2557
|
+
},
|
|
2033
2558
|
})
|
|
2034
2559
|
}
|
|
2035
2560
|
}
|
|
@@ -2048,6 +2573,10 @@ export async function collectCheckResult(
|
|
|
2048
2573
|
: {}),
|
|
2049
2574
|
},
|
|
2050
2575
|
)
|
|
2576
|
+
evidence ??= snapshotProjectEvidence(config.source, {
|
|
2577
|
+
assurance,
|
|
2578
|
+
...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
|
|
2579
|
+
})
|
|
2051
2580
|
const path = resolve(cwd, config.manifest.path ?? "nifra.manifest.json")
|
|
2052
2581
|
let message: string | undefined
|
|
2053
2582
|
if (!assurance.ok || (capabilityReport !== undefined && !capabilityReport.ok)) {
|
|
@@ -2058,7 +2587,7 @@ export async function collectCheckResult(
|
|
|
2058
2587
|
} else {
|
|
2059
2588
|
try {
|
|
2060
2589
|
const current = await buildNifraManifest({
|
|
2061
|
-
|
|
2590
|
+
evidence,
|
|
2062
2591
|
assurance,
|
|
2063
2592
|
...(capabilityReport !== undefined ? { capabilities: capabilityReport } : {}),
|
|
2064
2593
|
})
|
|
@@ -2108,94 +2637,319 @@ export async function collectCheckResult(
|
|
|
2108
2637
|
}
|
|
2109
2638
|
}
|
|
2110
2639
|
|
|
2640
|
+
try {
|
|
2641
|
+
const { checkContractsLock } = await import("./contracts.ts")
|
|
2642
|
+
if (!existsSync(join(cwd, "backend.ts")) && !existsSync(join(cwd, "contracts.lock.json"))) {
|
|
2643
|
+
throw new Error("contract source not configured")
|
|
2644
|
+
}
|
|
2645
|
+
const contract = await checkContractsLock(cwd)
|
|
2646
|
+
if (!contract.present) {
|
|
2647
|
+
structuredExtras.push(
|
|
2648
|
+
diagnostic({
|
|
2649
|
+
code: "NF-K001",
|
|
2650
|
+
severity: "info",
|
|
2651
|
+
message: "no contract lock; run `nifra contracts snapshot` to enable drift detection",
|
|
2652
|
+
verify: "nifra contracts snapshot",
|
|
2653
|
+
}),
|
|
2654
|
+
)
|
|
2655
|
+
} else {
|
|
2656
|
+
for (const finding of contract.diagnostics) {
|
|
2657
|
+
diagnostics.push({
|
|
2658
|
+
rule: "contract-drift",
|
|
2659
|
+
severity: "error",
|
|
2660
|
+
code: "NF-K001",
|
|
2661
|
+
...(finding.route !== undefined ? { evidence: [finding.route] } : {}),
|
|
2662
|
+
message: finding.message,
|
|
2663
|
+
fix: "run `nifra contracts snapshot` after reviewing the contract change",
|
|
2664
|
+
verify: "nifra check --lints-only",
|
|
2665
|
+
})
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
} catch (error) {
|
|
2669
|
+
if (error instanceof Error && error.message === "contract source not configured") {
|
|
2670
|
+
// API-only source scans have no contract surface to snapshot.
|
|
2671
|
+
} else {
|
|
2672
|
+
diagnostics.push({
|
|
2673
|
+
rule: "contract-drift",
|
|
2674
|
+
severity: "error",
|
|
2675
|
+
code: "NF-K001",
|
|
2676
|
+
message: `contract lock could not be checked: ${error instanceof Error ? error.message : String(error)}`,
|
|
2677
|
+
verify: "nifra contracts snapshot",
|
|
2678
|
+
})
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const ruleContext: RuleContext = {
|
|
2683
|
+
root: cwd,
|
|
2684
|
+
sources: sourceIndex(sourceFiles),
|
|
2685
|
+
project: {
|
|
2686
|
+
legacyDiagnostics: diagnostics,
|
|
2687
|
+
assurance: opts.assurance,
|
|
2688
|
+
// Route registrations collected during the source walk, so the route-table rules
|
|
2689
|
+
// (rules/routes.ts) lint them without a second scan.
|
|
2690
|
+
staticRoutes,
|
|
2691
|
+
},
|
|
2692
|
+
}
|
|
2693
|
+
const structuredDiagnostics = [...structuredExtras]
|
|
2694
|
+
let registryDiagnostics: Diagnostic[] = []
|
|
2695
|
+
try {
|
|
2696
|
+
registryDiagnostics = await runRuleRegistry(
|
|
2697
|
+
ruleContext,
|
|
2698
|
+
[...legacyRules, ...securityRules, ...routeRules],
|
|
2699
|
+
applicationRulePacks,
|
|
2700
|
+
)
|
|
2701
|
+
structuredDiagnostics.push(...registryDiagnostics)
|
|
2702
|
+
} catch (error) {
|
|
2703
|
+
registryDiagnostics = [
|
|
2704
|
+
diagnostic({
|
|
2705
|
+
code: "NF-C017",
|
|
2706
|
+
severity: "error",
|
|
2707
|
+
message: `rule registry failed closed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2708
|
+
fix: { recipe: "rule-pack.repair", command: "nifra check --lints-only" },
|
|
2709
|
+
verify: "nifra check --lints-only",
|
|
2710
|
+
}),
|
|
2711
|
+
]
|
|
2712
|
+
structuredDiagnostics.push(...registryDiagnostics)
|
|
2713
|
+
}
|
|
2714
|
+
for (const item of registryDiagnostics) {
|
|
2715
|
+
// The legacy adapters publish stable structured findings; the human compatibility view already
|
|
2716
|
+
// contains their original rule names. Security rules and application packs still need a legacy-view
|
|
2717
|
+
// row because they have no pre-registry diagnostic.
|
|
2718
|
+
if (legacyRules.some((rule) => rule.code === item.code)) continue
|
|
2719
|
+
diagnostics.push({
|
|
2720
|
+
rule: item.code,
|
|
2721
|
+
severity: item.severity === "error" ? "error" : "warning",
|
|
2722
|
+
code: item.code,
|
|
2723
|
+
...(item.file !== undefined ? { file: item.file } : {}),
|
|
2724
|
+
...(item.line !== undefined ? { line: item.line } : {}),
|
|
2725
|
+
message: item.message,
|
|
2726
|
+
...(item.evidence !== undefined ? { evidence: item.evidence } : {}),
|
|
2727
|
+
...(item.verify !== undefined ? { verify: item.verify } : {}),
|
|
2728
|
+
})
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// Per-rule overrides from `nifra.check.json` `rules`, applied centrally to BOTH diagnostic views
|
|
2732
|
+
// before `ok` is computed - never inside individual rules, so no rule can dodge (or double-apply)
|
|
2733
|
+
// them. A key matches the legacy rule name or the stable NF- code.
|
|
2734
|
+
const overrideFor = (...keys: (string | undefined)[]): RuleOverride | undefined => {
|
|
2735
|
+
for (const key of keys) {
|
|
2736
|
+
if (key !== undefined && checkConfig.rules[key] !== undefined) return checkConfig.rules[key]
|
|
2737
|
+
}
|
|
2738
|
+
return undefined
|
|
2739
|
+
}
|
|
2740
|
+
const ignoreGlobs = new Map<RuleOverride, Glob[]>()
|
|
2741
|
+
const dropped = (override: RuleOverride, file: string | undefined): boolean => {
|
|
2742
|
+
if (override.severity === "off") return true
|
|
2743
|
+
if (override.ignore === undefined || file === undefined) return false
|
|
2744
|
+
let globs = ignoreGlobs.get(override)
|
|
2745
|
+
if (globs === undefined) {
|
|
2746
|
+
globs = override.ignore.map((pattern) => new Glob(pattern))
|
|
2747
|
+
ignoreGlobs.set(override, globs)
|
|
2748
|
+
}
|
|
2749
|
+
return globs.some((glob) => glob.match(file))
|
|
2750
|
+
}
|
|
2751
|
+
const codeToLegacy = new Map(
|
|
2752
|
+
Object.entries(LEGACY_RULE_CODES).map(([name, code]) => [code, name]),
|
|
2753
|
+
)
|
|
2754
|
+
const finalDiagnostics = diagnostics.flatMap<CheckDiagnostic>((d) => {
|
|
2755
|
+
const override = overrideFor(d.rule, d.code, LEGACY_RULE_CODES[d.rule])
|
|
2756
|
+
if (override === undefined) return [d]
|
|
2757
|
+
if (dropped(override, d.file)) return []
|
|
2758
|
+
// "off" is fully handled by `dropped` above - only real severities reach the retag.
|
|
2759
|
+
if (override.severity === undefined || override.severity === "off") return [d]
|
|
2760
|
+
return [{ ...d, severity: override.severity === "warn" ? "warning" : override.severity }]
|
|
2761
|
+
})
|
|
2762
|
+
const finalStructured = structuredDiagnostics.flatMap<Diagnostic>((d) => {
|
|
2763
|
+
const override = overrideFor(d.code, codeToLegacy.get(d.code))
|
|
2764
|
+
if (override === undefined) return [d]
|
|
2765
|
+
if (dropped(override, d.file)) return []
|
|
2766
|
+
if (override.severity === undefined || override.severity === "off") return [d]
|
|
2767
|
+
return [Object.freeze({ ...d, severity: override.severity })]
|
|
2768
|
+
})
|
|
2769
|
+
|
|
2111
2770
|
// Cap the diagnostics when asked (the MCP path), so a project with thousands of findings can't return a
|
|
2112
2771
|
// message that breaks the stdio transport. `ok` reflects the FULL set - truncation never flips it.
|
|
2113
|
-
const total =
|
|
2772
|
+
const total = finalDiagnostics.length
|
|
2114
2773
|
const max = opts.maxDiagnostics
|
|
2115
|
-
const shown = max !== undefined && total > max ?
|
|
2116
|
-
|
|
2117
|
-
ok: !
|
|
2774
|
+
const shown = max !== undefined && total > max ? finalDiagnostics.slice(0, max) : finalDiagnostics
|
|
2775
|
+
const result: CheckResult = {
|
|
2776
|
+
ok: !finalDiagnostics.some((diagnostic) => diagnostic.severity === "error"),
|
|
2118
2777
|
typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
|
|
2778
|
+
...(!tc.ran && tc.note !== undefined ? { typecheckNote: tc.note } : {}),
|
|
2119
2779
|
diagnostics: shown,
|
|
2780
|
+
...(dr.pipeline !== undefined ? { pipeline: dr.pipeline } : {}),
|
|
2120
2781
|
...(checkConfig.externalMounts.length > 0
|
|
2121
2782
|
? { externalMounts: checkConfig.externalMounts }
|
|
2122
2783
|
: {}),
|
|
2784
|
+
...(Object.keys(checkConfig.rules).length > 0 ? { ruleOverrides: checkConfig.rules } : {}),
|
|
2123
2785
|
...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
|
|
2124
2786
|
}
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2787
|
+
Object.defineProperty(result, "structuredDiagnostics", {
|
|
2788
|
+
value: finalStructured.slice(0, shown.length + structuredExtras.length),
|
|
2789
|
+
enumerable: false,
|
|
2790
|
+
})
|
|
2791
|
+
return result
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
/** The named rule sections of the human report, in print order. A rule absent from this list is NOT
|
|
2795
|
+
* dropped: {@link renderCheckReport} renders every remaining diagnostic in a generic section keyed by
|
|
2796
|
+
* its rule code, so a finding that can flip the exit code is never invisible in the default output. */
|
|
2797
|
+
const REPORT_SECTIONS = [
|
|
2798
|
+
["typecheck", "typecheck"],
|
|
2799
|
+
["typed-client", "hand-rolled fetch() to your own API"],
|
|
2800
|
+
["untyped-client", 'client("…") missing its <typeof app> type argument'],
|
|
2801
|
+
["server-only-import", "server-only import in a route module"],
|
|
2802
|
+
["interpolated-sql", "SQL built by interpolating a value into the statement"],
|
|
2803
|
+
["response-route", "route returns a raw Response (typed client → data: never)"],
|
|
2804
|
+
["undeclared-dependency", "undeclared dependency in package.json"],
|
|
2805
|
+
["duplicate-install", "duplicate identity-sensitive dependency install"],
|
|
2806
|
+
["stale-workspace-dist", "workspace-linked dist older than its source"],
|
|
2807
|
+
["pipeline", "bundler pipeline (Vite/Bun) config"],
|
|
2808
|
+
["server-manifest-drift", "server-manifest.ts drifted from routes/"],
|
|
2809
|
+
["manifest-drift", "versioned trust manifest drift"],
|
|
2810
|
+
["capability-assurance", "effect/capability assurance"],
|
|
2811
|
+
["capability-config", "capability assurance config"],
|
|
2812
|
+
["check-config", "nifra.check.json"],
|
|
2813
|
+
] as const
|
|
2140
2814
|
|
|
2141
|
-
|
|
2142
|
-
|
|
2815
|
+
/**
|
|
2816
|
+
* Render the human-readable check report as lines. Pure (no I/O, no cwd) so tests can assert
|
|
2817
|
+
* report/exit-code parity: every diagnostic in `result.diagnostics` appears in the output, and the
|
|
2818
|
+
* trailer states the error/advisory counts that produced `ok`.
|
|
2819
|
+
*/
|
|
2820
|
+
export function renderCheckReport(result: CheckResult): string[] {
|
|
2821
|
+
const lines: string[] = []
|
|
2822
|
+
lines.push("nifra check", "")
|
|
2823
|
+
lines.push(
|
|
2143
2824
|
result.typecheck === "pass"
|
|
2144
2825
|
? "✓ typecheck passed"
|
|
2145
2826
|
: result.typecheck === "fail"
|
|
2146
2827
|
? "✗ typecheck failed - the frontend/backend contract is broken"
|
|
2147
|
-
:
|
|
2828
|
+
: `⚠ typecheck SKIPPED - ${result.typecheckNote ?? "no tsconfig / typescript not installed"} (the contract gate did not run)`,
|
|
2148
2829
|
)
|
|
2830
|
+
// Stated on every run, passing or not. "Which bundler is this app on" decides which plugin slot is
|
|
2831
|
+
// live and which toolchain compiles a component, so it belongs in the report rather than only in the
|
|
2832
|
+
// dev server's banner - where CI never sees it.
|
|
2833
|
+
if (result.pipeline !== undefined) {
|
|
2834
|
+
lines.push(
|
|
2835
|
+
result.pipeline.pipeline === "unknown"
|
|
2836
|
+
? `• bundler: not readable from ${result.pipeline.configFile} - ${result.pipeline.reason}`
|
|
2837
|
+
: `• bundler: ${result.pipeline.pipeline} (${result.pipeline.reason})`,
|
|
2838
|
+
)
|
|
2839
|
+
}
|
|
2149
2840
|
if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
|
|
2150
|
-
|
|
2841
|
+
lines.push(
|
|
2151
2842
|
`• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`,
|
|
2152
2843
|
)
|
|
2153
2844
|
}
|
|
2154
|
-
|
|
2155
|
-
result.
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2845
|
+
if (result.ruleOverrides !== undefined) {
|
|
2846
|
+
const active = Object.entries(result.ruleOverrides).map(([rule, override]) => {
|
|
2847
|
+
const parts: string[] = []
|
|
2848
|
+
if (override.severity !== undefined) parts.push(`severity=${override.severity}`)
|
|
2849
|
+
if (override.ignore !== undefined) parts.push(`ignore=${override.ignore.join(",")}`)
|
|
2850
|
+
return `${rule} (${parts.join(", ")})`
|
|
2851
|
+
})
|
|
2852
|
+
lines.push(`• rule overrides from nifra.check.json: ${active.join("; ")}`)
|
|
2853
|
+
}
|
|
2854
|
+
const renderSection = (rule: string, label: string, ds: readonly CheckDiagnostic[]): void => {
|
|
2855
|
+
if (rule !== "typecheck") {
|
|
2856
|
+
// Marked by SEVERITY, not by rule name. `response-route` and `stale-workspace-dist` are advisory
|
|
2857
|
+
// in whole; `pipeline` is the first rule that is advisory in part (a misplaced plugin fails, a
|
|
2858
|
+
// resolve condition the Bun dev bundler can't take does not), so the counts are split rather than
|
|
2859
|
+
// rounded up to the worse of the two.
|
|
2860
|
+
const errors = ds.filter((d) => d.severity === "error").length
|
|
2861
|
+
const advisory = ds.length - errors
|
|
2862
|
+
lines.push(
|
|
2863
|
+
ds.length === 0
|
|
2864
|
+
? `✓ ${label}: none`
|
|
2865
|
+
: errors === 0
|
|
2866
|
+
? `⚠ ${label}: ${advisory} (advisory)`
|
|
2867
|
+
: `✗ ${label}: ${errors}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`,
|
|
2868
|
+
)
|
|
2178
2869
|
}
|
|
2179
2870
|
for (const d of ds) {
|
|
2180
|
-
|
|
2871
|
+
lines.push(` ${d.file ?? ""}${d.line ? `:${d.line}` : ""} ${d.message}`)
|
|
2181
2872
|
if (d.suggestion !== undefined) {
|
|
2182
|
-
|
|
2873
|
+
lines.push(` fix: ${d.suggestion.title}`)
|
|
2183
2874
|
if (d.suggestion.command !== undefined) {
|
|
2184
|
-
|
|
2875
|
+
lines.push(` command: ${d.suggestion.command.join(" ")}`)
|
|
2185
2876
|
}
|
|
2186
2877
|
if (d.suggestion.diff !== undefined) {
|
|
2187
|
-
|
|
2188
|
-
d.suggestion.diff
|
|
2189
|
-
.split("\n")
|
|
2190
|
-
.map((line) => ` ${line}`)
|
|
2191
|
-
.join("\n"),
|
|
2192
|
-
)
|
|
2878
|
+
for (const line of d.suggestion.diff.split("\n")) lines.push(` ${line}`)
|
|
2193
2879
|
}
|
|
2194
|
-
for (const step of d.suggestion.steps ?? [])
|
|
2880
|
+
for (const step of d.suggestion.steps ?? []) lines.push(` - ${step}`)
|
|
2195
2881
|
}
|
|
2196
2882
|
}
|
|
2197
2883
|
}
|
|
2198
|
-
|
|
2884
|
+
for (const [rule, label] of REPORT_SECTIONS) {
|
|
2885
|
+
renderSection(
|
|
2886
|
+
rule,
|
|
2887
|
+
label,
|
|
2888
|
+
result.diagnostics.filter((d) => d.rule === rule),
|
|
2889
|
+
)
|
|
2890
|
+
}
|
|
2891
|
+
// Generic section: diagnostics whose rule has no named section above (registry rules publishing
|
|
2892
|
+
// under their NF- code, application rule packs). These count toward `ok` exactly like the named
|
|
2893
|
+
// ones, so they get the same severity-marked rendering - only "✓ …: none" is skipped, because
|
|
2894
|
+
// the set of possible unlisted rules is open-ended.
|
|
2895
|
+
const named = new Set<string>(REPORT_SECTIONS.map(([rule]) => rule))
|
|
2896
|
+
const extraRules: string[] = []
|
|
2897
|
+
for (const d of result.diagnostics) {
|
|
2898
|
+
if (!named.has(d.rule) && !extraRules.includes(d.rule)) extraRules.push(d.rule)
|
|
2899
|
+
}
|
|
2900
|
+
for (const rule of extraRules) {
|
|
2901
|
+
const title = (RULE_CODES as Record<string, string | undefined>)[rule]
|
|
2902
|
+
renderSection(
|
|
2903
|
+
rule,
|
|
2904
|
+
title !== undefined ? `${title} (${rule})` : rule,
|
|
2905
|
+
result.diagnostics.filter((d) => d.rule === rule),
|
|
2906
|
+
)
|
|
2907
|
+
}
|
|
2908
|
+
if (result.truncated !== undefined) {
|
|
2909
|
+
lines.push(
|
|
2910
|
+
`• showing ${result.truncated.shown} of ${result.truncated.total} diagnostics (truncated)`,
|
|
2911
|
+
)
|
|
2912
|
+
}
|
|
2913
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error").length
|
|
2914
|
+
const advisory = result.diagnostics.length - errors
|
|
2915
|
+
lines.push(
|
|
2916
|
+
"",
|
|
2917
|
+
result.ok
|
|
2918
|
+
? advisory > 0
|
|
2919
|
+
? `✓ check passed (${advisory} advisory)`
|
|
2920
|
+
: "✓ check passed"
|
|
2921
|
+
: `✗ check failed: ${errors} error${errors === 1 ? "" : "s"}${advisory > 0 ? ` (+${advisory} advisory)` : ""}`,
|
|
2922
|
+
)
|
|
2923
|
+
return lines
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/** Run the full check; print a report (`--json` for machine output) and return whether it passed. */
|
|
2927
|
+
export async function runCheck(
|
|
2928
|
+
cwd: string,
|
|
2929
|
+
opts: {
|
|
2930
|
+
readonly json?: boolean
|
|
2931
|
+
readonly lintsOnly?: boolean
|
|
2932
|
+
readonly structured?: boolean
|
|
2933
|
+
} = {},
|
|
2934
|
+
): Promise<boolean> {
|
|
2935
|
+
// The check view over the one project verification: the same collector `assure` and `levels` read.
|
|
2936
|
+
const { collectProjectVerification } = await import("./verification.ts")
|
|
2937
|
+
const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false })
|
|
2938
|
+
const result = await verification.check()
|
|
2939
|
+
if (opts.json) {
|
|
2940
|
+
console.log(
|
|
2941
|
+
JSON.stringify(
|
|
2942
|
+
opts.structured === true && result.structuredDiagnostics !== undefined
|
|
2943
|
+
? { ...result, diagnostics: result.structuredDiagnostics }
|
|
2944
|
+
: result,
|
|
2945
|
+
null,
|
|
2946
|
+
2,
|
|
2947
|
+
),
|
|
2948
|
+
)
|
|
2949
|
+
return result.ok
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
console.log(renderCheckReport(result).join("\n"))
|
|
2199
2953
|
// Discoverability nudge: a project with no `.mcp.json` hasn't wired its nifra MCP for coding agents.
|
|
2200
2954
|
// `nifra init-agents` writes it (+ .cursor/mcp.json + a CLAUDE.md preamble), no-clobber. A non-fatal
|
|
2201
2955
|
// one-line tip in the human report only (the `--json` path returns above, unaffected).
|