@nifrajs/cli 2.11.0 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +125 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +706 -145
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +194 -10
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +28 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +173 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +53 -4
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +358 -25
- package/dist/mcp.js.map +1 -1
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +906 -15
- package/docs/types.json +1682 -130
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +873 -168
- package/src/cli.ts +198 -11
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +259 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +75 -6
- package/src/load.ts +6 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +438 -32
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- package/src/work-graph.ts +613 -0
package/src/replay.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolve, sep } from "node:path"
|
|
2
|
+
import { type CompatibleReplayFile, parseCompatibleReplayFile } from "@nifrajs/core/replay"
|
|
3
|
+
import { runHydrationAssurance } from "./assure-hydration.ts"
|
|
4
|
+
import { checkContractsLock } from "./contracts.ts"
|
|
5
|
+
|
|
6
|
+
export interface ReplayResult {
|
|
7
|
+
readonly ok: boolean
|
|
8
|
+
readonly replay: CompatibleReplayFile
|
|
9
|
+
readonly compatible?: boolean
|
|
10
|
+
readonly error?: string
|
|
11
|
+
readonly diagnostics?: readonly unknown[]
|
|
12
|
+
readonly replays?: readonly string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Validate and dispatch one token-only replay while keeping the file inside the project root. */
|
|
16
|
+
export async function runReplay(cwd: string, file: string): Promise<ReplayResult> {
|
|
17
|
+
const path = resolve(cwd, file)
|
|
18
|
+
if (path !== cwd && !path.startsWith(cwd + sep)) {
|
|
19
|
+
throw new Error("replay file must stay inside the project root")
|
|
20
|
+
}
|
|
21
|
+
const replay = parseCompatibleReplayFile(JSON.parse(await Bun.file(path).text()))
|
|
22
|
+
if ("gate" in replay && replay.gate === "hydration") {
|
|
23
|
+
const hydration = await runHydrationAssurance(cwd, {
|
|
24
|
+
seed: replay.seed,
|
|
25
|
+
...(replay.case.startsWith("/") ? { routes: [replay.case] } : {}),
|
|
26
|
+
})
|
|
27
|
+
return {
|
|
28
|
+
ok: hydration.diagnostics.length === 0,
|
|
29
|
+
replay,
|
|
30
|
+
diagnostics: hydration.diagnostics,
|
|
31
|
+
...(hydration.replays === undefined ? {} : { replays: hydration.replays }),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if ("gate" in replay && replay.gate === "contracts") {
|
|
35
|
+
const contracts = await checkContractsLock(cwd)
|
|
36
|
+
return {
|
|
37
|
+
ok: contracts.present && contracts.diagnostics.length === 0,
|
|
38
|
+
replay,
|
|
39
|
+
diagnostics: contracts.diagnostics,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!("gate" in replay)) return { ok: true, replay, compatible: true }
|
|
43
|
+
return { ok: false, replay, error: `no replay dispatcher for gate ${replay.gate}` }
|
|
44
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Built-in diagnostic code registry. Codes are append-only and reserved by the NF- prefix. */
|
|
2
|
+
|
|
3
|
+
export const RULE_CODES = Object.freeze({
|
|
4
|
+
"NF-C001": "TypeScript contract check",
|
|
5
|
+
"NF-C002": "Typed client check",
|
|
6
|
+
"NF-C003": "Untyped client check",
|
|
7
|
+
"NF-C004": "Server-only import check",
|
|
8
|
+
"NF-C005": "Removed import check",
|
|
9
|
+
"NF-C006": "Interpolated SQL check",
|
|
10
|
+
"NF-C007": "Raw response route advisory",
|
|
11
|
+
"NF-C008": "Undeclared dependency check",
|
|
12
|
+
"NF-C009": "Duplicate install check",
|
|
13
|
+
"NF-C010": "Stale workspace build check",
|
|
14
|
+
"NF-C011": "Build pipeline check",
|
|
15
|
+
"NF-C012": "Server manifest drift check",
|
|
16
|
+
"NF-C013": "Trust manifest drift check",
|
|
17
|
+
"NF-C014": "Capability assurance check",
|
|
18
|
+
"NF-C015": "Assurance configuration check",
|
|
19
|
+
"NF-C016": "Check configuration check",
|
|
20
|
+
"NF-C017": "Rule pack validation",
|
|
21
|
+
"NF-C018": "Reserved client segment check",
|
|
22
|
+
"NF-C019": "Duplicate route registration check",
|
|
23
|
+
"NF-D001": "Doctor check",
|
|
24
|
+
"NF-A001": "Assurance bundle check",
|
|
25
|
+
"NF-H001": "Hydration mismatch",
|
|
26
|
+
"NF-H002": "Duplicate framework runtime",
|
|
27
|
+
"NF-H003": "Stale client build",
|
|
28
|
+
"NF-H004": "Hydration interaction",
|
|
29
|
+
"NF-K001": "Contract snapshot drift",
|
|
30
|
+
"NF-S001": "Fail-open gate",
|
|
31
|
+
"NF-S002": "Non-constant-time secret comparison",
|
|
32
|
+
"NF-S003": "Sensitive value in log call",
|
|
33
|
+
"NF-S004": "CORS origin predicate ignores the origin",
|
|
34
|
+
"NF-S005": "External redirect opt-out",
|
|
35
|
+
"NF-S006": "Security escape hatch enabled",
|
|
36
|
+
"NF-S007": "Secure cookie without a __Host-/__Secure- prefix",
|
|
37
|
+
} as const)
|
|
38
|
+
|
|
39
|
+
/** Codes that no longer fire but remain reserved forever. */
|
|
40
|
+
export const RETIRED_CODES: ReadonlySet<string> = new Set()
|
|
41
|
+
|
|
42
|
+
export type BuiltInCode = keyof typeof RULE_CODES
|
|
43
|
+
|
|
44
|
+
export function isBuiltInCode(code: string): code is BuiltInCode {
|
|
45
|
+
return code in RULE_CODES || RETIRED_CODES.has(code)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assertUniqueRuleCodes(): void {
|
|
49
|
+
const codes = Object.keys(RULE_CODES)
|
|
50
|
+
if (new Set(codes).size !== codes.length)
|
|
51
|
+
throw new Error("diagnostic code registry contains duplicates")
|
|
52
|
+
for (const code of codes) {
|
|
53
|
+
if (!/^NF-[A-Z]\d{3}$/.test(code)) throw new Error(`invalid built-in diagnostic code: ${code}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
assertUniqueRuleCodes()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker recognition for in-source escape hatches (`@nifra-gate-reviewed`, `nifra-expect …`).
|
|
3
|
+
*
|
|
4
|
+
* A marker counts when it appears on the flagged line itself (trailing comment) or ANYWHERE in the
|
|
5
|
+
* contiguous comment block directly above it - which is where a human naturally writes the
|
|
6
|
+
* multi-line justification the hatch asks for. A blank line or a code line ends the block, so a
|
|
7
|
+
* marker in an unrelated comment further up never leaks onto a finding below it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Lines that continue a comment block when walking upward: line comments, block-comment
|
|
11
|
+
// open/body/close lines, and the JSX comment form.
|
|
12
|
+
const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*\/|\*|\{\/\*)/
|
|
13
|
+
|
|
14
|
+
export function commentBlockHasMarker(
|
|
15
|
+
lines: readonly string[],
|
|
16
|
+
line: number,
|
|
17
|
+
marker: string,
|
|
18
|
+
): boolean {
|
|
19
|
+
if ((lines[line - 1] ?? "").includes(marker)) return true
|
|
20
|
+
for (let i = line - 2; i >= 0; i--) {
|
|
21
|
+
const text = lines[i] ?? ""
|
|
22
|
+
if (!COMMENT_LINE.test(text)) return false
|
|
23
|
+
if (text.includes(marker)) return true
|
|
24
|
+
}
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Diagnostic } from "../diagnostics.ts"
|
|
2
|
+
import { isBuiltInCode } from "./codes.ts"
|
|
3
|
+
|
|
4
|
+
export interface SourceFile {
|
|
5
|
+
readonly file: string
|
|
6
|
+
readonly content: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SourceIndex {
|
|
10
|
+
readonly files: readonly string[]
|
|
11
|
+
read(file: string): string | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProjectFacts {
|
|
15
|
+
readonly [key: string]: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RuleContext {
|
|
19
|
+
readonly root: string
|
|
20
|
+
readonly sources: SourceIndex
|
|
21
|
+
readonly project: ProjectFacts
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CheckRule {
|
|
25
|
+
readonly code: string
|
|
26
|
+
readonly title: string
|
|
27
|
+
scan(ctx: RuleContext): Promise<readonly Diagnostic[]>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RulePack {
|
|
31
|
+
readonly name: string
|
|
32
|
+
readonly rules: readonly CheckRule[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateRulePacks(packs: readonly RulePack[] | undefined): readonly RulePack[] {
|
|
36
|
+
if (packs === undefined) return []
|
|
37
|
+
const names = new Set<string>()
|
|
38
|
+
const codes = new Set<string>()
|
|
39
|
+
const normalized: RulePack[] = []
|
|
40
|
+
for (const pack of packs) {
|
|
41
|
+
if (typeof pack?.name !== "string" || pack.name.trim() === "")
|
|
42
|
+
throw new Error("rule pack name must be a non-empty string")
|
|
43
|
+
if (names.has(pack.name)) throw new Error(`duplicate rule pack: ${pack.name}`)
|
|
44
|
+
names.add(pack.name)
|
|
45
|
+
if (!Array.isArray(pack.rules)) throw new Error(`rule pack ${pack.name} must contain rules`)
|
|
46
|
+
const rules: CheckRule[] = []
|
|
47
|
+
for (const rule of pack.rules) {
|
|
48
|
+
if (
|
|
49
|
+
typeof rule?.code !== "string" ||
|
|
50
|
+
typeof rule.title !== "string" ||
|
|
51
|
+
typeof rule.scan !== "function"
|
|
52
|
+
) {
|
|
53
|
+
throw new Error(`rule pack ${pack.name} contains an invalid rule`)
|
|
54
|
+
}
|
|
55
|
+
if (rule.code.startsWith("NF-"))
|
|
56
|
+
throw new Error(`application rule pack code ${rule.code} uses the reserved NF- prefix`)
|
|
57
|
+
if (codes.has(rule.code)) throw new Error(`duplicate application rule code: ${rule.code}`)
|
|
58
|
+
codes.add(rule.code)
|
|
59
|
+
rules.push(rule)
|
|
60
|
+
}
|
|
61
|
+
normalized.push(Object.freeze({ name: pack.name, rules: Object.freeze(rules) }))
|
|
62
|
+
}
|
|
63
|
+
return Object.freeze(normalized)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseRulePacks(value: unknown): readonly RulePack[] {
|
|
67
|
+
if (value === undefined) return []
|
|
68
|
+
if (!Array.isArray(value)) throw new TypeError("rulePacks must be an array")
|
|
69
|
+
const packs: RulePack[] = []
|
|
70
|
+
for (const rawPack of value) {
|
|
71
|
+
if (typeof rawPack !== "object" || rawPack === null || Array.isArray(rawPack))
|
|
72
|
+
throw new TypeError("rule pack must be an object")
|
|
73
|
+
const packRecord = Object.fromEntries(Object.entries(rawPack))
|
|
74
|
+
const name = packRecord.name
|
|
75
|
+
const rawRules = packRecord.rules
|
|
76
|
+
if (typeof name !== "string" || !Array.isArray(rawRules))
|
|
77
|
+
throw new TypeError("rule pack requires a name and rules array")
|
|
78
|
+
const rules: CheckRule[] = []
|
|
79
|
+
for (const rawRule of rawRules) {
|
|
80
|
+
if (typeof rawRule !== "object" || rawRule === null || Array.isArray(rawRule))
|
|
81
|
+
throw new TypeError(`rule pack ${name} contains an invalid rule`)
|
|
82
|
+
const ruleRecord = Object.fromEntries(Object.entries(rawRule))
|
|
83
|
+
if (
|
|
84
|
+
typeof ruleRecord.code !== "string" ||
|
|
85
|
+
typeof ruleRecord.title !== "string" ||
|
|
86
|
+
typeof ruleRecord.scan !== "function"
|
|
87
|
+
) {
|
|
88
|
+
throw new TypeError(`rule pack ${name} contains an invalid rule`)
|
|
89
|
+
}
|
|
90
|
+
const scan = ruleRecord.scan
|
|
91
|
+
rules.push({
|
|
92
|
+
code: ruleRecord.code,
|
|
93
|
+
title: ruleRecord.title,
|
|
94
|
+
scan: async (ctx) => {
|
|
95
|
+
const result: unknown = await Reflect.apply(scan, undefined, [ctx])
|
|
96
|
+
if (!Array.isArray(result))
|
|
97
|
+
throw new TypeError(`rule ${ruleRecord.code} did not return diagnostics`)
|
|
98
|
+
const findings: Diagnostic[] = []
|
|
99
|
+
for (const item of result) {
|
|
100
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
101
|
+
throw new TypeError(`rule ${ruleRecord.code} returned an invalid diagnostic`)
|
|
102
|
+
const finding = Object.fromEntries(Object.entries(item))
|
|
103
|
+
if (
|
|
104
|
+
typeof finding.code !== "string" ||
|
|
105
|
+
(finding.severity !== "error" &&
|
|
106
|
+
finding.severity !== "warn" &&
|
|
107
|
+
finding.severity !== "info") ||
|
|
108
|
+
typeof finding.message !== "string"
|
|
109
|
+
) {
|
|
110
|
+
throw new TypeError(`rule ${ruleRecord.code} returned an invalid diagnostic`)
|
|
111
|
+
}
|
|
112
|
+
const code = finding.code
|
|
113
|
+
const severity = finding.severity
|
|
114
|
+
const message = finding.message
|
|
115
|
+
const rawFix =
|
|
116
|
+
typeof finding.fix === "object" && finding.fix !== null && !Array.isArray(finding.fix)
|
|
117
|
+
? Object.fromEntries(Object.entries(finding.fix))
|
|
118
|
+
: undefined
|
|
119
|
+
findings.push({
|
|
120
|
+
code,
|
|
121
|
+
severity,
|
|
122
|
+
message,
|
|
123
|
+
...(typeof finding.file === "string" ? { file: finding.file } : {}),
|
|
124
|
+
...(typeof finding.line === "number" ? { line: finding.line } : {}),
|
|
125
|
+
...(Array.isArray(finding.evidence)
|
|
126
|
+
? {
|
|
127
|
+
evidence: finding.evidence.filter(
|
|
128
|
+
(item): item is string => typeof item === "string",
|
|
129
|
+
),
|
|
130
|
+
}
|
|
131
|
+
: {}),
|
|
132
|
+
...(typeof rawFix?.recipe === "string"
|
|
133
|
+
? {
|
|
134
|
+
fix: {
|
|
135
|
+
recipe: rawFix.recipe,
|
|
136
|
+
...(typeof rawFix.command === "string" ? { command: rawFix.command } : {}),
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
...(typeof finding.verify === "string" ? { verify: finding.verify } : {}),
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
return findings
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
packs.push({ name, rules })
|
|
148
|
+
}
|
|
149
|
+
return validateRulePacks(packs)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function runRuleRegistry(
|
|
153
|
+
ctx: RuleContext,
|
|
154
|
+
builtIns: readonly CheckRule[],
|
|
155
|
+
packs: readonly RulePack[] = [],
|
|
156
|
+
): Promise<Diagnostic[]> {
|
|
157
|
+
const rules = [...builtIns, ...validateRulePacks(packs).flatMap((pack) => pack.rules)]
|
|
158
|
+
const out: Diagnostic[] = []
|
|
159
|
+
for (const rule of rules) {
|
|
160
|
+
if (isBuiltInCode(rule.code) === false && rule.code.startsWith("NF-")) {
|
|
161
|
+
throw new Error(`application rule pack code ${rule.code} uses the reserved NF- prefix`)
|
|
162
|
+
}
|
|
163
|
+
const findings = await rule.scan(ctx)
|
|
164
|
+
out.push(...findings)
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function sourceIndex(files: readonly SourceFile[]): SourceIndex {
|
|
170
|
+
const map = new Map(files.map((file) => [file.file, file.content]))
|
|
171
|
+
return Object.freeze({
|
|
172
|
+
files: Object.freeze(files.map((file) => file.file)),
|
|
173
|
+
read: (file: string) => map.get(file),
|
|
174
|
+
})
|
|
175
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { CheckDiagnostic } from "../check.ts"
|
|
2
|
+
import type { Diagnostic, Severity } from "../diagnostics.ts"
|
|
3
|
+
import type { CheckRule, RuleContext } from "./index.ts"
|
|
4
|
+
|
|
5
|
+
/** Legacy rule name → stable NF- code. Exported so `nifra.check.json` rule overrides can be keyed
|
|
6
|
+
* by either name and still hit both diagnostic views. */
|
|
7
|
+
export const LEGACY_RULE_CODES: Readonly<Record<string, string>> = Object.freeze({
|
|
8
|
+
typecheck: "NF-C001",
|
|
9
|
+
"typed-client": "NF-C002",
|
|
10
|
+
"untyped-client": "NF-C003",
|
|
11
|
+
"server-only-import": "NF-C004",
|
|
12
|
+
"removed-import": "NF-C005",
|
|
13
|
+
"interpolated-sql": "NF-C006",
|
|
14
|
+
"response-route": "NF-C007",
|
|
15
|
+
"undeclared-dependency": "NF-C008",
|
|
16
|
+
"duplicate-install": "NF-C009",
|
|
17
|
+
"stale-workspace-dist": "NF-C010",
|
|
18
|
+
pipeline: "NF-C011",
|
|
19
|
+
"server-manifest-drift": "NF-C012",
|
|
20
|
+
"manifest-drift": "NF-C013",
|
|
21
|
+
"capability-assurance": "NF-C014",
|
|
22
|
+
"capability-config": "NF-C015",
|
|
23
|
+
"check-config": "NF-C016",
|
|
24
|
+
"contract-drift": "NF-K001",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const TITLES: Readonly<Record<string, string>> = Object.freeze({
|
|
28
|
+
typecheck: "TypeScript contract check",
|
|
29
|
+
"typed-client": "Typed client check",
|
|
30
|
+
"untyped-client": "Untyped client check",
|
|
31
|
+
"server-only-import": "Server-only import check",
|
|
32
|
+
"removed-import": "Removed import check",
|
|
33
|
+
"interpolated-sql": "Interpolated SQL check",
|
|
34
|
+
"response-route": "Raw response route advisory",
|
|
35
|
+
"undeclared-dependency": "Undeclared dependency check",
|
|
36
|
+
"duplicate-install": "Duplicate install check",
|
|
37
|
+
"stale-workspace-dist": "Stale workspace build check",
|
|
38
|
+
pipeline: "Build pipeline check",
|
|
39
|
+
"server-manifest-drift": "Server manifest drift check",
|
|
40
|
+
"manifest-drift": "Trust manifest drift check",
|
|
41
|
+
"capability-assurance": "Capability assurance check",
|
|
42
|
+
"capability-config": "Assurance configuration check",
|
|
43
|
+
"check-config": "Check configuration check",
|
|
44
|
+
"contract-drift": "Contract snapshot drift",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function recipeFor(finding: CheckDiagnostic): Diagnostic["fix"] {
|
|
48
|
+
if (finding.rule === "stale-workspace-dist")
|
|
49
|
+
return { recipe: "workspace-dist.rebuild", command: "nifra fix --code NF-C010" }
|
|
50
|
+
if (finding.rule === "server-manifest-drift")
|
|
51
|
+
return { recipe: "manifest.sync", command: "nifra sync-manifest" }
|
|
52
|
+
if (finding.rule === "contract-drift")
|
|
53
|
+
return { recipe: "contracts.snapshot", command: "nifra contracts snapshot" }
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toDiagnostic(finding: CheckDiagnostic, code: string): Diagnostic {
|
|
58
|
+
const severity: Severity = finding.severity === "warning" ? "warn" : finding.severity
|
|
59
|
+
const evidence = finding.evidence ?? finding.chain
|
|
60
|
+
const fix = recipeFor(finding)
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
code,
|
|
63
|
+
severity,
|
|
64
|
+
message: finding.message,
|
|
65
|
+
...(finding.file === undefined ? {} : { file: finding.file }),
|
|
66
|
+
...(finding.line === undefined ? {} : { line: finding.line }),
|
|
67
|
+
...(evidence === undefined ? {} : { evidence }),
|
|
68
|
+
...(fix === undefined ? {} : { fix }),
|
|
69
|
+
...(finding.verify === undefined ? {} : { verify: finding.verify }),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findingsFor(ctx: RuleContext, rule: string): Diagnostic[] {
|
|
74
|
+
const findings = ctx.project.legacyDiagnostics
|
|
75
|
+
if (!Array.isArray(findings)) return []
|
|
76
|
+
const code = LEGACY_RULE_CODES[rule]
|
|
77
|
+
if (code === undefined) return []
|
|
78
|
+
return findings
|
|
79
|
+
.filter((finding): finding is CheckDiagnostic => finding?.rule === rule)
|
|
80
|
+
.map((finding) => toDiagnostic(finding, code))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function makeRule(rule: string): CheckRule {
|
|
84
|
+
const code = LEGACY_RULE_CODES[rule]
|
|
85
|
+
const title = TITLES[rule]
|
|
86
|
+
if (code === undefined || title === undefined) throw new Error(`unknown legacy rule ${rule}`)
|
|
87
|
+
return Object.freeze({ code, title, scan: async (ctx: RuleContext) => findingsFor(ctx, rule) })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Stable registry adapters for the existing source scanners. */
|
|
91
|
+
export const legacyRules: readonly CheckRule[] = Object.freeze(
|
|
92
|
+
Object.keys(LEGACY_RULE_CODES).map((name) => makeRule(name)),
|
|
93
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
2
|
+
import { commentBlockHasMarker } from "./comment-markers.ts"
|
|
3
|
+
import type { CheckRule, RuleContext } from "./index.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Route-table lints over the statically collected route registrations (`scanStaticRouteText` in
|
|
7
|
+
* ../check.ts fills `ctx.project.staticRoutes` during the source walk - no second scan here).
|
|
8
|
+
*
|
|
9
|
+
* NF-C018 exists because the typed client's proxy intercepts a fixed set of property names before
|
|
10
|
+
* path resolution (`resolveSegment` + the thenable guard in @nifrajs/client): the seven HTTP verbs
|
|
11
|
+
* (case-insensitively), `subscribe`, `ws`, `index`, and `then`. A route whose path contains a
|
|
12
|
+
* static segment spelling one of these cannot be reached by DOT ACCESS - `api.delete.post` resolves
|
|
13
|
+
* the DELETE verb, not the path. The typed spelling is a call on the parent node
|
|
14
|
+
* (`api.api("delete").post()`, treaty.ts `CollisionEscape`), and the client type rejects the dot
|
|
15
|
+
* access with the same guidance - so the collision is a warning that teaches the escape spelling,
|
|
16
|
+
* not a blocking error: the route IS reachable, just not by the spelling its name suggests.
|
|
17
|
+
*/
|
|
18
|
+
const RESERVED_VERB_SEGMENTS: ReadonlySet<string> = new Set([
|
|
19
|
+
"get",
|
|
20
|
+
"post",
|
|
21
|
+
"put",
|
|
22
|
+
"patch",
|
|
23
|
+
"delete",
|
|
24
|
+
"head",
|
|
25
|
+
"options",
|
|
26
|
+
])
|
|
27
|
+
// Intercepted by exact match only (`key === "subscribe"` etc.), unlike the lowercased verbs.
|
|
28
|
+
const RESERVED_EXACT_SEGMENTS: ReadonlySet<string> = new Set(["subscribe", "ws", "index", "then"])
|
|
29
|
+
const RESERVED_READOUT =
|
|
30
|
+
"get/post/put/patch/delete/head/options (any casing), subscribe, ws, index, then"
|
|
31
|
+
|
|
32
|
+
/** Opt-out pragma for a route deliberately served only to NON-typed-client consumers. */
|
|
33
|
+
const RESERVED_SEGMENT_PRAGMA = "nifra-expect reserved-segment"
|
|
34
|
+
|
|
35
|
+
interface StaticRouteFact {
|
|
36
|
+
readonly file: string
|
|
37
|
+
readonly line: number
|
|
38
|
+
readonly method: string
|
|
39
|
+
readonly path: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse-don't-cast over the project fact: a malformed entry is dropped, never trusted. */
|
|
43
|
+
function routeFacts(ctx: RuleContext): StaticRouteFact[] {
|
|
44
|
+
const raw = ctx.project.staticRoutes
|
|
45
|
+
if (!Array.isArray(raw)) return []
|
|
46
|
+
const out: StaticRouteFact[] = []
|
|
47
|
+
for (const item of raw) {
|
|
48
|
+
if (typeof item !== "object" || item === null) continue
|
|
49
|
+
const record = item as Record<string, unknown>
|
|
50
|
+
if (
|
|
51
|
+
typeof record.file === "string" &&
|
|
52
|
+
typeof record.line === "number" &&
|
|
53
|
+
typeof record.method === "string" &&
|
|
54
|
+
typeof record.path === "string"
|
|
55
|
+
) {
|
|
56
|
+
out.push({ file: record.file, line: record.line, method: record.method, path: record.path })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The typed escape spelling for a colliding segment, e.g. `/api/delete` + `delete` →
|
|
64
|
+
* `api("delete").post()` shown as the chain up to the collision. Best-effort readability: earlier
|
|
65
|
+
* segments render as dot access, the colliding one as the parent-node call.
|
|
66
|
+
*/
|
|
67
|
+
function routeEscapeHint(path: string, colliding: string): string {
|
|
68
|
+
const segments = path.split("/").filter((segment) => segment !== "")
|
|
69
|
+
const before = segments.slice(0, Math.max(segments.indexOf(colliding), 0))
|
|
70
|
+
return `${["api", ...before].join(".")}("${colliding}")`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The reserved key a static path segment collides with, or undefined. Params/wildcards never collide. */
|
|
74
|
+
function reservedCollision(segment: string): string | undefined {
|
|
75
|
+
if (segment.startsWith(":") || segment.startsWith("*")) return undefined
|
|
76
|
+
if (RESERVED_EXACT_SEGMENTS.has(segment)) return segment
|
|
77
|
+
const lower = segment.toLowerCase()
|
|
78
|
+
return RESERVED_VERB_SEGMENTS.has(lower) ? lower : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const reservedSegmentRule: CheckRule = {
|
|
82
|
+
code: "NF-C018",
|
|
83
|
+
title: "Route segment collides with a reserved client proxy key",
|
|
84
|
+
async scan(ctx) {
|
|
85
|
+
const findings: Diagnostic[] = []
|
|
86
|
+
const linesByFile = new Map<string, readonly string[]>()
|
|
87
|
+
for (const route of routeFacts(ctx)) {
|
|
88
|
+
for (const segment of route.path.split("/")) {
|
|
89
|
+
if (segment === "") continue
|
|
90
|
+
const collision = reservedCollision(segment)
|
|
91
|
+
if (collision === undefined) continue
|
|
92
|
+
let lines = linesByFile.get(route.file)
|
|
93
|
+
if (lines === undefined) {
|
|
94
|
+
lines = (ctx.sources.read(route.file) ?? "").split("\n")
|
|
95
|
+
linesByFile.set(route.file, lines)
|
|
96
|
+
}
|
|
97
|
+
if (commentBlockHasMarker(lines, route.line, RESERVED_SEGMENT_PRAGMA)) continue
|
|
98
|
+
findings.push(
|
|
99
|
+
diagnostic({
|
|
100
|
+
code: "NF-C018",
|
|
101
|
+
severity: "warn",
|
|
102
|
+
file: route.file,
|
|
103
|
+
line: route.line,
|
|
104
|
+
message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${RESERVED_READOUT}); dot access cannot reach it - call the parent node with the segment instead (\`${routeEscapeHint(route.path, segment)}\`), rename the segment, or mark an intentionally non-typed-client route with \`// ${RESERVED_SEGMENT_PRAGMA}\` above the registration`,
|
|
105
|
+
evidence: [`${route.method} ${route.path}`, `segment: ${segment}`],
|
|
106
|
+
verify: "nifra check --lints-only",
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
break // one finding per route, even if several segments collide
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return findings
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* NF-C019: the same method+path registered twice in one file - the later registration is dead or
|
|
118
|
+
* shadowing, and which one serves is a router implementation detail nobody should depend on.
|
|
119
|
+
* Scoped to a single file on purpose: two different apps in one repo (a monorepo with several
|
|
120
|
+
* backends) legitimately both register `GET /health`, and this scanner cannot tell app instances
|
|
121
|
+
* apart across files - so cross-file duplicates are NOT flagged rather than guessed at.
|
|
122
|
+
*/
|
|
123
|
+
export const duplicateRouteRule: CheckRule = {
|
|
124
|
+
code: "NF-C019",
|
|
125
|
+
title: "Duplicate route registration",
|
|
126
|
+
async scan(ctx) {
|
|
127
|
+
const findings: Diagnostic[] = []
|
|
128
|
+
const byFile = new Map<string, Map<string, StaticRouteFact>>()
|
|
129
|
+
for (const route of routeFacts(ctx)) {
|
|
130
|
+
let seen = byFile.get(route.file)
|
|
131
|
+
if (seen === undefined) {
|
|
132
|
+
seen = new Map()
|
|
133
|
+
byFile.set(route.file, seen)
|
|
134
|
+
}
|
|
135
|
+
const key = `${route.method} ${route.path}`
|
|
136
|
+
const first = seen.get(key)
|
|
137
|
+
if (first === undefined) {
|
|
138
|
+
seen.set(key, route)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
findings.push(
|
|
142
|
+
diagnostic({
|
|
143
|
+
code: "NF-C019",
|
|
144
|
+
severity: "error",
|
|
145
|
+
file: route.file,
|
|
146
|
+
line: route.line,
|
|
147
|
+
message: `${key} is registered twice in this file (first at line ${first.line}) - remove or rename one; which registration serves is undefined`,
|
|
148
|
+
evidence: [key, `first registration: line ${first.line}`],
|
|
149
|
+
verify: "nifra check --lints-only",
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return findings
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const routeRules = Object.freeze([reservedSegmentRule, duplicateRouteRule])
|