@nifrajs/cli 2.14.1 → 3.2.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 +10 -0
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +6 -1
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +3 -2
- package/dist/assure.js.map +1 -1
- package/dist/check-diagnostics.d.ts +165 -0
- package/dist/check-diagnostics.d.ts.map +1 -0
- package/dist/check-diagnostics.js +1009 -0
- package/dist/check-diagnostics.js.map +1 -0
- package/dist/check-scan.d.ts +245 -0
- package/dist/check-scan.d.ts.map +1 -0
- package/dist/check-scan.js +1599 -0
- package/dist/check-scan.js.map +1 -0
- package/dist/check.d.ts +4 -324
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +8 -2346
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +285 -10
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +27 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +142 -2
- package/dist/command-catalog.js.map +1 -1
- package/dist/doctor.d.ts +42 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +64 -7
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.d.ts +57 -0
- package/dist/frontend-guidance.d.ts.map +1 -0
- package/dist/frontend-guidance.js +195 -0
- package/dist/frontend-guidance.js.map +1 -0
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +6 -0
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +4 -1
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +48 -0
- package/dist/mcp-context.d.ts.map +1 -0
- package/dist/mcp-context.js +215 -0
- package/dist/mcp-context.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +31 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +74 -0
- package/dist/mcp-exec.d.ts.map +1 -0
- package/dist/mcp-exec.js +844 -0
- package/dist/mcp-exec.js.map +1 -0
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +26 -0
- package/dist/mcp-io.d.ts.map +1 -0
- package/dist/mcp-io.js +100 -0
- package/dist/mcp-io.js.map +1 -0
- package/dist/mcp-reflect.d.ts +16 -0
- package/dist/mcp-reflect.d.ts.map +1 -0
- package/dist/mcp-reflect.js +101 -0
- package/dist/mcp-reflect.js.map +1 -0
- package/dist/mcp-root.d.ts +7 -0
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +51 -3
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp.d.ts +10 -84
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +13 -1170
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +5 -10
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js +21 -2
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +21 -0
- package/dist/openapi-types.d.ts.map +1 -0
- package/dist/openapi-types.js +214 -0
- package/dist/openapi-types.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +2 -1
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +2 -1
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +4 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +6 -123
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/codes.d.ts +4 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +4 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/islands.d.ts +4 -0
- package/dist/rules/islands.d.ts.map +1 -0
- package/dist/rules/islands.js +155 -0
- package/dist/rules/islands.js.map +1 -0
- package/dist/rules/nano.d.ts +6 -0
- package/dist/rules/nano.d.ts.map +1 -0
- package/dist/rules/nano.js +264 -0
- package/dist/rules/nano.js.map +1 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +17 -30
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +38 -4
- package/dist/rules/security.js.map +1 -1
- package/dist/scaffold.d.ts +12 -6
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +149 -10
- package/dist/scaffold.js.map +1 -1
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -3
- package/dist/search-terms.js.map +1 -1
- package/dist/stylex-migrate.d.ts +38 -0
- package/dist/stylex-migrate.d.ts.map +1 -0
- package/dist/stylex-migrate.js +1052 -0
- package/dist/stylex-migrate.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/verification-plan.d.ts +24 -0
- package/dist/verification-plan.d.ts.map +1 -0
- package/dist/verification-plan.js +116 -0
- package/dist/verification-plan.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +6 -3
- package/dist/work-graph.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +37 -1
- package/docs/llms-full.txt +1598 -44
- package/docs/types.json +5157 -172
- package/package.json +9 -9
- package/src/assure-hydration.ts +6 -1
- package/src/assure.ts +4 -2
- package/src/check-diagnostics.ts +1219 -0
- package/src/check-scan.ts +1825 -0
- package/src/check.ts +47 -2731
- package/src/cli.ts +291 -10
- package/src/command-catalog.ts +177 -2
- package/src/doctor.ts +99 -8
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/frontend-guidance.ts +259 -0
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/levels-tool.ts +6 -0
- package/src/load.ts +4 -1
- package/src/mcp-context.ts +266 -0
- package/src/mcp-docs-tools.ts +37 -0
- package/src/mcp-exec.ts +1040 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +118 -0
- package/src/mcp-reflect.ts +140 -0
- package/src/mcp-root.ts +62 -4
- package/src/mcp.ts +61 -1436
- package/src/openapi-tool.ts +32 -2
- package/src/openapi-types.ts +261 -0
- package/src/port.ts +2 -1
- package/src/project-facts.ts +2 -4
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +22 -147
- package/src/rules/codes.ts +4 -0
- package/src/rules/islands.ts +171 -0
- package/src/rules/nano.ts +295 -0
- package/src/rules/routes.ts +17 -30
- package/src/rules/security.ts +40 -4
- package/src/scaffold.ts +169 -9
- package/src/search-terms.ts +7 -3
- package/src/stylex-migrate.ts +1139 -0
- package/src/sync-manifest.ts +2 -1
- package/src/verification-plan.ts +242 -0
- package/src/work-graph.ts +6 -3
- package/src/workspace-link.ts +80 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type * as TSApi from "typescript"
|
|
2
|
+
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
3
|
+
import { importProjectTypeScript } from "../internal/typescript-import.ts"
|
|
4
|
+
import type { CheckRule, SourceIndex } from "./index.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* NF-C020 - the island cleanup lint. An island enhancer (`@nifrajs/web/islands`) runs once and must
|
|
8
|
+
* hand back a teardown for anything it wires: `mountIslands`'s disposer calls it on soft-nav, and
|
|
9
|
+
* without it every navigation leaks the listener. This is the single mistake an agent writing
|
|
10
|
+
* imperative island code makes most - it adds `el.addEventListener(...)` and forgets the `return`.
|
|
11
|
+
* The lint watches the two authoring shapes and warns (never blocks) when a block-body enhancer adds
|
|
12
|
+
* a listener but returns no cleanup:
|
|
13
|
+
*
|
|
14
|
+
* defineIsland((el) => { el.addEventListener("click", f) }) // ← flagged: no cleanup
|
|
15
|
+
* defineIsland((el) => { el.addEventListener("click", f); return () => el.removeEventListener("click", f) }) // ok
|
|
16
|
+
* mountIslands({ x: (el) => { el.addEventListener("click", f) } }) // ← flagged
|
|
17
|
+
*
|
|
18
|
+
* Deliberately conservative to stay false-positive-free: a concise-body arrow (`(el) => bus.on(...)`)
|
|
19
|
+
* already IS a returned value, so it is never flagged; a block that returns anything is trusted as
|
|
20
|
+
* cleanup; only an inline function literal is inspected (a `defineIsland(...)` result referenced by
|
|
21
|
+
* name is caught at its definition site, never twice).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const parseCache = new WeakMap<
|
|
25
|
+
SourceIndex,
|
|
26
|
+
Map<string, { readonly tree: TSApi.SourceFile } | null>
|
|
27
|
+
>()
|
|
28
|
+
|
|
29
|
+
function parsedTree(
|
|
30
|
+
ts: typeof TSApi,
|
|
31
|
+
sources: SourceIndex,
|
|
32
|
+
file: string,
|
|
33
|
+
): TSApi.SourceFile | undefined {
|
|
34
|
+
let files = parseCache.get(sources)
|
|
35
|
+
if (files === undefined) {
|
|
36
|
+
files = new Map()
|
|
37
|
+
parseCache.set(sources, files)
|
|
38
|
+
}
|
|
39
|
+
const cached = files.get(file)
|
|
40
|
+
if (cached !== undefined) return cached?.tree
|
|
41
|
+
const source = sources.read(file)
|
|
42
|
+
if (source === undefined) {
|
|
43
|
+
files.set(file, null)
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
const kind = /\.tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
|
|
47
|
+
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
|
|
48
|
+
files.set(file, { tree })
|
|
49
|
+
return tree
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type EnhancerFn = TSApi.ArrowFunction | TSApi.FunctionExpression
|
|
53
|
+
|
|
54
|
+
function isEnhancerFn(ts: typeof TSApi, node: TSApi.Node): node is EnhancerFn {
|
|
55
|
+
return ts.isArrowFunction(node) || ts.isFunctionExpression(node)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Any `<expr>.addEventListener(...)` reachable inside the enhancer (nested handlers included - the
|
|
59
|
+
* presence of the wiring is what matters, not its depth). */
|
|
60
|
+
function addsListener(ts: typeof TSApi, fn: EnhancerFn): boolean {
|
|
61
|
+
let found = false
|
|
62
|
+
const walk = (node: TSApi.Node): void => {
|
|
63
|
+
if (found) return
|
|
64
|
+
if (
|
|
65
|
+
ts.isCallExpression(node) &&
|
|
66
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
67
|
+
node.expression.name.text === "addEventListener"
|
|
68
|
+
) {
|
|
69
|
+
found = true
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
ts.forEachChild(node, walk)
|
|
73
|
+
}
|
|
74
|
+
ts.forEachChild(fn, walk)
|
|
75
|
+
return found
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Does the enhancer hand back a value? A concise-body arrow always does. A block body counts only a
|
|
79
|
+
* `return <expr>` that is NOT inside a nested function (a nested handler's return is not the
|
|
80
|
+
* enhancer's cleanup). */
|
|
81
|
+
function returnsCleanup(ts: typeof TSApi, fn: EnhancerFn): boolean {
|
|
82
|
+
if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) return true // concise body IS the return value
|
|
83
|
+
let found = false
|
|
84
|
+
const walk = (node: TSApi.Node): void => {
|
|
85
|
+
if (found) return
|
|
86
|
+
if (isEnhancerFn(ts, node) || ts.isFunctionDeclaration(node)) return // don't descend into nested fns
|
|
87
|
+
if (ts.isReturnStatement(node) && node.expression !== undefined) {
|
|
88
|
+
found = true
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
ts.forEachChild(node, walk)
|
|
92
|
+
}
|
|
93
|
+
ts.forEachChild(fn, walk)
|
|
94
|
+
return found
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The inline enhancer functions authored in `file`: the argument to `defineIsland(...)` and every
|
|
98
|
+
* function-typed value of a `mountIslands({ ... })` object literal. */
|
|
99
|
+
function enhancerFns(ts: typeof TSApi, tree: TSApi.SourceFile): EnhancerFn[] {
|
|
100
|
+
const out: EnhancerFn[] = []
|
|
101
|
+
const visit = (node: TSApi.Node): void => {
|
|
102
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
103
|
+
const callee = node.expression.text
|
|
104
|
+
if (callee === "defineIsland") {
|
|
105
|
+
const arg = node.arguments[0]
|
|
106
|
+
if (arg !== undefined && isEnhancerFn(ts, arg)) out.push(arg)
|
|
107
|
+
} else if (callee === "mountIslands") {
|
|
108
|
+
const arg = node.arguments[0]
|
|
109
|
+
if (arg !== undefined && ts.isObjectLiteralExpression(arg)) {
|
|
110
|
+
for (const prop of arg.properties) {
|
|
111
|
+
// Only inline function literals `{ x: (el) => {…} }` are inspected. A shorthand method
|
|
112
|
+
// `{ x(el) {…} }` or a named reference `{ x: counter }` is left to its definition site.
|
|
113
|
+
if (ts.isPropertyAssignment(prop) && isEnhancerFn(ts, prop.initializer))
|
|
114
|
+
out.push(prop.initializer)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
ts.forEachChild(node, visit)
|
|
120
|
+
}
|
|
121
|
+
visit(tree)
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const islandCleanupRule: CheckRule = {
|
|
126
|
+
code: "NF-C020",
|
|
127
|
+
title: "Island enhancer cleanup check",
|
|
128
|
+
async scan(ctx) {
|
|
129
|
+
const ts = await importProjectTypeScript(ctx.root)
|
|
130
|
+
if (ts === undefined) {
|
|
131
|
+
return [
|
|
132
|
+
diagnostic({
|
|
133
|
+
code: "NF-C020",
|
|
134
|
+
severity: "warn",
|
|
135
|
+
message:
|
|
136
|
+
"Island enhancer cleanup check (NF-C020) did NOT run - TypeScript is not installed, so this report says nothing about it",
|
|
137
|
+
fix: { recipe: "toolchain.install-typescript", command: "bun add -d typescript" },
|
|
138
|
+
verify: "nifra check --lints-only",
|
|
139
|
+
}),
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
const findings: Diagnostic[] = []
|
|
143
|
+
for (const file of ctx.project.source.files) {
|
|
144
|
+
const tree = parsedTree(ts, ctx.project.source, file)
|
|
145
|
+
if (tree === undefined) continue
|
|
146
|
+
for (const fn of enhancerFns(ts, tree)) {
|
|
147
|
+
if (!addsListener(ts, fn) || returnsCleanup(ts, fn)) continue
|
|
148
|
+
const line = tree.getLineAndCharacterOfPosition(fn.getStart(tree)).line + 1
|
|
149
|
+
findings.push(
|
|
150
|
+
diagnostic({
|
|
151
|
+
code: "NF-C020",
|
|
152
|
+
severity: "warn",
|
|
153
|
+
file,
|
|
154
|
+
line,
|
|
155
|
+
message:
|
|
156
|
+
"island enhancer adds an event listener but returns no cleanup - it leaks on soft-nav teardown",
|
|
157
|
+
evidence: ["addEventListener without a returned cleanup"],
|
|
158
|
+
fix: {
|
|
159
|
+
recipe: "islands.return-cleanup",
|
|
160
|
+
command: "nifra_docs islands",
|
|
161
|
+
},
|
|
162
|
+
verify: "nifra check --lints-only",
|
|
163
|
+
}),
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return findings
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const islandRules = Object.freeze([islandCleanupRule])
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type * as TSApi from "typescript"
|
|
2
|
+
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
3
|
+
import { importProjectTypeScript } from "../internal/typescript-import.ts"
|
|
4
|
+
import type { CheckRule, SourceIndex } from "./index.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The nano lane lints (`@nifrajs/web/nano`). nano is deliberately explicit - every reactive edge is a
|
|
8
|
+
* `bind`/`bindList` call or a `computed(fn, [deps])` - and that is exactly what makes its three
|
|
9
|
+
* mistakes STATICALLY catchable, the property a framework's auto-tracked reactivity cannot offer:
|
|
10
|
+
*
|
|
11
|
+
* NF-C021 a `bind`/`bindList`/`bindResource` whose disposer is discarded (leaks on teardown)
|
|
12
|
+
* NF-C022 a `bindList` keyed by the array index (breaks add/remove/reorder)
|
|
13
|
+
* NF-C023 a `computed`/`resource` `(fn, [deps])` reading a signal its `deps` omit (won't recompute/refetch)
|
|
14
|
+
*
|
|
15
|
+
* All three warn (never block): a false positive must never fail a build, and each maps to a fix
|
|
16
|
+
* recipe (`nano.*`). Deliberately conservative - only the unambiguous authoring shapes are inspected.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const parseCache = new WeakMap<
|
|
20
|
+
SourceIndex,
|
|
21
|
+
Map<string, { readonly tree: TSApi.SourceFile } | null>
|
|
22
|
+
>()
|
|
23
|
+
|
|
24
|
+
function parsedTree(
|
|
25
|
+
ts: typeof TSApi,
|
|
26
|
+
sources: SourceIndex,
|
|
27
|
+
file: string,
|
|
28
|
+
): TSApi.SourceFile | undefined {
|
|
29
|
+
let files = parseCache.get(sources)
|
|
30
|
+
if (files === undefined) {
|
|
31
|
+
files = new Map()
|
|
32
|
+
parseCache.set(sources, files)
|
|
33
|
+
}
|
|
34
|
+
const cached = files.get(file)
|
|
35
|
+
if (cached !== undefined) return cached?.tree
|
|
36
|
+
const source = sources.read(file)
|
|
37
|
+
if (source === undefined) {
|
|
38
|
+
files.set(file, null)
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
const kind = /\.tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
|
|
42
|
+
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
|
|
43
|
+
files.set(file, { tree })
|
|
44
|
+
return tree
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Fn = TSApi.ArrowFunction | TSApi.FunctionExpression
|
|
48
|
+
const isFn = (ts: typeof TSApi, n: TSApi.Node): n is Fn =>
|
|
49
|
+
ts.isArrowFunction(n) || ts.isFunctionExpression(n)
|
|
50
|
+
|
|
51
|
+
/** Names bound to a `signal(...)` or `computed(...)` result anywhere in the file - the reactive cells
|
|
52
|
+
* a `computed` body may read and must therefore declare. */
|
|
53
|
+
function reactiveBindings(ts: typeof TSApi, tree: TSApi.SourceFile): Set<string> {
|
|
54
|
+
const names = new Set<string>()
|
|
55
|
+
const visit = (n: TSApi.Node): void => {
|
|
56
|
+
if (
|
|
57
|
+
ts.isVariableDeclaration(n) &&
|
|
58
|
+
ts.isIdentifier(n.name) &&
|
|
59
|
+
n.initializer !== undefined &&
|
|
60
|
+
ts.isCallExpression(n.initializer) &&
|
|
61
|
+
ts.isIdentifier(n.initializer.expression) &&
|
|
62
|
+
(n.initializer.expression.text === "signal" ||
|
|
63
|
+
n.initializer.expression.text === "computed" ||
|
|
64
|
+
n.initializer.expression.text === "resource")
|
|
65
|
+
) {
|
|
66
|
+
names.add(n.name.text)
|
|
67
|
+
}
|
|
68
|
+
ts.forEachChild(n, visit)
|
|
69
|
+
}
|
|
70
|
+
visit(tree)
|
|
71
|
+
return names
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reactive identifiers read as `x.get()` inside `fn`. */
|
|
75
|
+
function getsInside(ts: typeof TSApi, fn: TSApi.Node, reactive: ReadonlySet<string>): Set<string> {
|
|
76
|
+
const reads = new Set<string>()
|
|
77
|
+
const visit = (n: TSApi.Node): void => {
|
|
78
|
+
if (
|
|
79
|
+
ts.isCallExpression(n) &&
|
|
80
|
+
ts.isPropertyAccessExpression(n.expression) &&
|
|
81
|
+
n.expression.name.text === "get" &&
|
|
82
|
+
ts.isIdentifier(n.expression.expression) &&
|
|
83
|
+
reactive.has(n.expression.expression.text)
|
|
84
|
+
) {
|
|
85
|
+
reads.add(n.expression.expression.text)
|
|
86
|
+
}
|
|
87
|
+
ts.forEachChild(n, visit)
|
|
88
|
+
}
|
|
89
|
+
visit(fn)
|
|
90
|
+
return reads
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The identifiers listed in a `computed`'s deps array literal (non-identifier entries are ignored -
|
|
94
|
+
* an unusual deps shape simply isn't linted rather than mis-flagged). */
|
|
95
|
+
function declaredDeps(ts: typeof TSApi, depsArg: TSApi.Expression): Set<string> {
|
|
96
|
+
const deps = new Set<string>()
|
|
97
|
+
if (ts.isArrayLiteralExpression(depsArg)) {
|
|
98
|
+
for (const el of depsArg.elements) if (ts.isIdentifier(el)) deps.add(el.text)
|
|
99
|
+
}
|
|
100
|
+
return deps
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A `bind`/`bindList` call whose return value is discarded: its immediate parent is the expression
|
|
104
|
+
* statement itself (not an assignment, array element, `return`, `await`, or argument). */
|
|
105
|
+
function isDiscarded(ts: typeof TSApi, call: TSApi.CallExpression): boolean {
|
|
106
|
+
const parent = call.parent
|
|
107
|
+
return parent !== undefined && ts.isExpressionStatement(parent)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** For a `bindList(src, container, { key, ... })`, the `key` property's function if it is an inline
|
|
111
|
+
* arrow/function literal. */
|
|
112
|
+
function keyFn(ts: typeof TSApi, call: TSApi.CallExpression): Fn | undefined {
|
|
113
|
+
const opts = call.arguments[2]
|
|
114
|
+
if (opts === undefined || !ts.isObjectLiteralExpression(opts)) return undefined
|
|
115
|
+
for (const prop of opts.properties) {
|
|
116
|
+
if (
|
|
117
|
+
ts.isPropertyAssignment(prop) &&
|
|
118
|
+
ts.isIdentifier(prop.name) &&
|
|
119
|
+
prop.name.text === "key" &&
|
|
120
|
+
isFn(ts, prop.initializer)
|
|
121
|
+
) {
|
|
122
|
+
return prop.initializer
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Does `fn`'s body return its own second parameter (the index) directly? `key: (_, i) => i`, the
|
|
129
|
+
* classic reorder-breaking key. Conservative: only a bare `return <secondParam>` / concise `=> i`. */
|
|
130
|
+
function returnsSecondParam(ts: typeof TSApi, fn: Fn): boolean {
|
|
131
|
+
if (fn.parameters.length < 2) return false
|
|
132
|
+
const second = fn.parameters[1]
|
|
133
|
+
if (second === undefined || !ts.isIdentifier(second.name)) return false
|
|
134
|
+
const idx = second.name.text
|
|
135
|
+
const isIdxRef = (e: TSApi.Expression): boolean => ts.isIdentifier(e) && e.text === idx
|
|
136
|
+
if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) return isIdxRef(fn.body)
|
|
137
|
+
let found = false
|
|
138
|
+
const visit = (n: TSApi.Node): void => {
|
|
139
|
+
if (found) return
|
|
140
|
+
if (isFn(ts, n) || ts.isFunctionDeclaration(n)) return // not a nested fn's return
|
|
141
|
+
if (ts.isReturnStatement(n) && n.expression !== undefined && isIdxRef(n.expression)) {
|
|
142
|
+
found = true
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
ts.forEachChild(n, visit)
|
|
146
|
+
}
|
|
147
|
+
ts.forEachChild(fn, visit)
|
|
148
|
+
return found
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const lineOf = (tree: TSApi.SourceFile, n: TSApi.Node): number =>
|
|
152
|
+
tree.getLineAndCharacterOfPosition(n.getStart(tree)).line + 1
|
|
153
|
+
|
|
154
|
+
function toolchainMissing(code: string): Diagnostic {
|
|
155
|
+
return diagnostic({
|
|
156
|
+
code,
|
|
157
|
+
severity: "warn",
|
|
158
|
+
message: `nano lint (${code}) did NOT run - TypeScript is not installed, so this report says nothing about it`,
|
|
159
|
+
fix: { recipe: "toolchain.install-typescript", command: "bun add -d typescript" },
|
|
160
|
+
verify: "nifra check --lints-only",
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const nanoBindCleanupRule: CheckRule = {
|
|
165
|
+
code: "NF-C021",
|
|
166
|
+
title: "nano binding cleanup check",
|
|
167
|
+
async scan(ctx) {
|
|
168
|
+
const ts = await importProjectTypeScript(ctx.root)
|
|
169
|
+
if (ts === undefined) return [toolchainMissing("NF-C021")]
|
|
170
|
+
const findings: Diagnostic[] = []
|
|
171
|
+
for (const file of ctx.project.source.files) {
|
|
172
|
+
const tree = parsedTree(ts, ctx.project.source, file)
|
|
173
|
+
if (tree === undefined) continue
|
|
174
|
+
const visit = (n: TSApi.Node): void => {
|
|
175
|
+
if (
|
|
176
|
+
ts.isCallExpression(n) &&
|
|
177
|
+
ts.isIdentifier(n.expression) &&
|
|
178
|
+
(n.expression.text === "bind" ||
|
|
179
|
+
n.expression.text === "bindList" ||
|
|
180
|
+
n.expression.text === "bindResource") &&
|
|
181
|
+
isDiscarded(ts, n)
|
|
182
|
+
) {
|
|
183
|
+
findings.push(
|
|
184
|
+
diagnostic({
|
|
185
|
+
code: "NF-C021",
|
|
186
|
+
severity: "warn",
|
|
187
|
+
file,
|
|
188
|
+
line: lineOf(tree, n),
|
|
189
|
+
message: `${n.expression.text}(...) discards its cleanup - collect the returned disposer and call it on teardown, or it leaks on soft-nav`,
|
|
190
|
+
evidence: [`${n.expression.text}(...) as a bare statement`],
|
|
191
|
+
fix: { recipe: "nano.collect-cleanup", command: "nifra_docs nano" },
|
|
192
|
+
verify: "nifra check --lints-only",
|
|
193
|
+
}),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
ts.forEachChild(n, visit)
|
|
197
|
+
}
|
|
198
|
+
visit(tree)
|
|
199
|
+
}
|
|
200
|
+
return findings
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const nanoListKeyRule: CheckRule = {
|
|
205
|
+
code: "NF-C022",
|
|
206
|
+
title: "nano bindList key check",
|
|
207
|
+
async scan(ctx) {
|
|
208
|
+
const ts = await importProjectTypeScript(ctx.root)
|
|
209
|
+
if (ts === undefined) return [toolchainMissing("NF-C022")]
|
|
210
|
+
const findings: Diagnostic[] = []
|
|
211
|
+
for (const file of ctx.project.source.files) {
|
|
212
|
+
const tree = parsedTree(ts, ctx.project.source, file)
|
|
213
|
+
if (tree === undefined) continue
|
|
214
|
+
const visit = (n: TSApi.Node): void => {
|
|
215
|
+
if (
|
|
216
|
+
ts.isCallExpression(n) &&
|
|
217
|
+
ts.isIdentifier(n.expression) &&
|
|
218
|
+
n.expression.text === "bindList"
|
|
219
|
+
) {
|
|
220
|
+
const key = keyFn(ts, n)
|
|
221
|
+
if (key !== undefined && returnsSecondParam(ts, key)) {
|
|
222
|
+
findings.push(
|
|
223
|
+
diagnostic({
|
|
224
|
+
code: "NF-C022",
|
|
225
|
+
severity: "warn",
|
|
226
|
+
file,
|
|
227
|
+
line: lineOf(tree, key),
|
|
228
|
+
message:
|
|
229
|
+
"bindList key is the array index - add/remove/reorder will reuse the wrong node. Key by a stable id on the item instead",
|
|
230
|
+
evidence: ["key: (_, i) => i"],
|
|
231
|
+
fix: { recipe: "nano.stable-key", command: "nifra_docs nano" },
|
|
232
|
+
verify: "nifra check --lints-only",
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
ts.forEachChild(n, visit)
|
|
238
|
+
}
|
|
239
|
+
visit(tree)
|
|
240
|
+
}
|
|
241
|
+
return findings
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const nanoComputedDepsRule: CheckRule = {
|
|
246
|
+
code: "NF-C023",
|
|
247
|
+
title: "nano computed deps check",
|
|
248
|
+
async scan(ctx) {
|
|
249
|
+
const ts = await importProjectTypeScript(ctx.root)
|
|
250
|
+
if (ts === undefined) return [toolchainMissing("NF-C023")]
|
|
251
|
+
const findings: Diagnostic[] = []
|
|
252
|
+
for (const file of ctx.project.source.files) {
|
|
253
|
+
const tree = parsedTree(ts, ctx.project.source, file)
|
|
254
|
+
if (tree === undefined) continue
|
|
255
|
+
const reactive = reactiveBindings(ts, tree)
|
|
256
|
+
if (reactive.size === 0) continue
|
|
257
|
+
const visit = (n: TSApi.Node): void => {
|
|
258
|
+
const fnArg = ts.isCallExpression(n) ? n.arguments[0] : undefined
|
|
259
|
+
const depsArg = ts.isCallExpression(n) ? n.arguments[1] : undefined
|
|
260
|
+
const callName =
|
|
261
|
+
ts.isCallExpression(n) && ts.isIdentifier(n.expression) ? n.expression.text : undefined
|
|
262
|
+
if (
|
|
263
|
+
(callName === "computed" || callName === "resource") &&
|
|
264
|
+
fnArg !== undefined &&
|
|
265
|
+
depsArg !== undefined &&
|
|
266
|
+
isFn(ts, fnArg)
|
|
267
|
+
) {
|
|
268
|
+
const reads = getsInside(ts, fnArg, reactive)
|
|
269
|
+
const deps = declaredDeps(ts, depsArg)
|
|
270
|
+
const missing = [...reads].filter((r) => !deps.has(r))
|
|
271
|
+
if (missing.length > 0) {
|
|
272
|
+
const verb = callName === "resource" ? "refetch" : "recompute"
|
|
273
|
+
findings.push(
|
|
274
|
+
diagnostic({
|
|
275
|
+
code: "NF-C023",
|
|
276
|
+
severity: "warn",
|
|
277
|
+
file,
|
|
278
|
+
line: lineOf(tree, n),
|
|
279
|
+
message: `${callName} reads ${JSON.stringify([...reads])} but its deps declare ${JSON.stringify([...deps])} - it will not ${verb} when ${missing.join(", ")} changes. Add ${missing.join(", ")} to the deps array`,
|
|
280
|
+
evidence: [`missing dep: ${missing.join(", ")}`],
|
|
281
|
+
fix: { recipe: "nano.declare-deps", command: "nifra_docs nano" },
|
|
282
|
+
verify: "nifra check --lints-only",
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
ts.forEachChild(n, visit)
|
|
288
|
+
}
|
|
289
|
+
visit(tree)
|
|
290
|
+
}
|
|
291
|
+
return findings
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export const nanoRules = Object.freeze([nanoBindCleanupRule, nanoListKeyRule, nanoComputedDepsRule])
|
package/src/rules/routes.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RESERVED_KEY_READOUT, reservedKeyFor } from "@nifrajs/client"
|
|
1
2
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
2
3
|
import { commentBlockHasMarker } from "./comment-markers.ts"
|
|
3
4
|
import type { CheckRule, RuleContext } from "./index.ts"
|
|
@@ -7,27 +8,18 @@ import type { CheckRule, RuleContext } from "./index.ts"
|
|
|
7
8
|
* once before the registry runs - no rule re-walks source).
|
|
8
9
|
*
|
|
9
10
|
* 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)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* path resolution (`resolveSegment` + the thenable guard in @nifrajs/client). A route whose path
|
|
12
|
+
* contains a static segment spelling one of these cannot be reached by DOT ACCESS -
|
|
13
|
+
* `api.delete.post` resolves the DELETE verb, not the path. The typed spelling is a call on the
|
|
14
|
+
* parent node (`api.api("delete").post()`, treaty.ts `SegmentCall`), and the client type rejects
|
|
15
|
+
* the dot access with the same guidance - so the collision is a warning that teaches the call
|
|
16
|
+
* spelling, not a blocking error: the route IS reachable, just not by the spelling its name
|
|
17
|
+
* suggests.
|
|
18
|
+
*
|
|
19
|
+
* The set itself is NOT copied here. It comes from `@nifrajs/client`'s `reserved.ts`, which is the
|
|
20
|
+
* one place it is written down and the one place the freeze policy lives - a lint that carried its
|
|
21
|
+
* own copy is a lint that can disagree with the compiler it is explaining.
|
|
17
22
|
*/
|
|
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
23
|
|
|
32
24
|
/** Opt-out pragma for a route deliberately served only to NON-typed-client consumers. */
|
|
33
25
|
const RESERVED_SEGMENT_PRAGMA = "nifra-expect reserved-segment"
|
|
@@ -69,14 +61,6 @@ function routeEscapeHint(path: string, colliding: string): string {
|
|
|
69
61
|
return `${["api", ...before].join(".")}("${colliding}")`
|
|
70
62
|
}
|
|
71
63
|
|
|
72
|
-
/** The reserved key a static path segment collides with, or undefined. Params/wildcards never collide. */
|
|
73
|
-
function reservedCollision(segment: string): string | undefined {
|
|
74
|
-
if (segment.startsWith(":") || segment.startsWith("*")) return undefined
|
|
75
|
-
if (RESERVED_EXACT_SEGMENTS.has(segment)) return segment
|
|
76
|
-
const lower = segment.toLowerCase()
|
|
77
|
-
return RESERVED_VERB_SEGMENTS.has(lower) ? lower : undefined
|
|
78
|
-
}
|
|
79
|
-
|
|
80
64
|
export const reservedSegmentRule: CheckRule = {
|
|
81
65
|
code: "NF-C018",
|
|
82
66
|
title: "Route segment collides with a reserved client proxy key",
|
|
@@ -86,7 +70,7 @@ export const reservedSegmentRule: CheckRule = {
|
|
|
86
70
|
for (const route of routeFacts(ctx)) {
|
|
87
71
|
for (const segment of route.path.split("/")) {
|
|
88
72
|
if (segment === "") continue
|
|
89
|
-
const collision =
|
|
73
|
+
const collision = reservedKeyFor(segment)
|
|
90
74
|
if (collision === undefined) continue
|
|
91
75
|
let lines = linesByFile.get(route.file)
|
|
92
76
|
if (lines === undefined) {
|
|
@@ -100,8 +84,11 @@ export const reservedSegmentRule: CheckRule = {
|
|
|
100
84
|
severity: "warn",
|
|
101
85
|
file: route.file,
|
|
102
86
|
line: route.line,
|
|
103
|
-
message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${
|
|
87
|
+
message: `${route.method} ${route.path} - segment '${segment}' collides with the reserved client proxy key '${collision}' (reserved: ${RESERVED_KEY_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. \`nifra fix --code NF-C018\` rewrites the broken call sites for you`,
|
|
104
88
|
evidence: [`${route.method} ${route.path}`, `segment: ${segment}`],
|
|
89
|
+
// The route is fine; what needs editing is every typed-client call site the collision
|
|
90
|
+
// broke, which the recipe finds from the compiler rather than from this one finding.
|
|
91
|
+
fix: { recipe: "client.reserved-segment", command: "nifra fix --code NF-C018" },
|
|
105
92
|
verify: "nifra check --lints-only",
|
|
106
93
|
}),
|
|
107
94
|
)
|
package/src/rules/security.ts
CHANGED
|
@@ -61,9 +61,29 @@ function nameOf(ts: typeof TSApi, node: TSApi.Node): string | undefined {
|
|
|
61
61
|
return undefined
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The operand name to match secret-like patterns against for NF-S002, or undefined when the operand
|
|
66
|
+
* cannot hold runtime secret material. A property access to an uppercase-initial member
|
|
67
|
+
* (`ts.SyntaxKind.PlusToken`, `MediaKind.Audio`) reads an enum/type-constant discriminant, never a
|
|
68
|
+
* secret string or byte buffer, so comparing against it is a kind check a timing oracle does not
|
|
69
|
+
* apply to. Runtime secret fields follow the camelCase convention (`apiToken`, `signature`), so an
|
|
70
|
+
* uppercase-initial member is a safe exclusion. Identifiers stay matched in every case, so an
|
|
71
|
+
* `UPPER_SNAKE` secret constant still surfaces.
|
|
72
|
+
*/
|
|
73
|
+
function secretName(ts: typeof TSApi, node: TSApi.Node): string | undefined {
|
|
74
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
75
|
+
const member = node.name.text
|
|
76
|
+
return /^[A-Z]/.test(member) ? undefined : member
|
|
77
|
+
}
|
|
78
|
+
return nameOf(ts, node)
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* Presence checks (`token === undefined`, `secret == null`, `apiKey !== ""`) and `typeof` guards are
|
|
66
83
|
* not equality over secret material - rewriting them to a timing-safe comparison would break the code.
|
|
84
|
+
* A comparison against a numeric literal (`signature !== 1`, `signatureLength !== 64`) is a
|
|
85
|
+
* length/version/discriminant check, never a secret: secrets are strings or bytes, so a number on
|
|
86
|
+
* either side proves this is not the compare a timing oracle applies to.
|
|
67
87
|
*/
|
|
68
88
|
function isPresenceComparison(ts: typeof TSApi, node: TSApi.BinaryExpression): boolean {
|
|
69
89
|
for (const side of [node.left, node.right]) {
|
|
@@ -72,6 +92,8 @@ function isPresenceComparison(ts: typeof TSApi, node: TSApi.BinaryExpression): b
|
|
|
72
92
|
if (ts.isStringLiteralLike(side) && side.text === "") return true
|
|
73
93
|
if (ts.isTypeOfExpression(side)) return true
|
|
74
94
|
if (ts.isVoidExpression(side)) return true
|
|
95
|
+
if (ts.isNumericLiteral(side)) return true
|
|
96
|
+
if (ts.isPrefixUnaryExpression(side) && ts.isNumericLiteral(side.operand)) return true
|
|
75
97
|
}
|
|
76
98
|
return false
|
|
77
99
|
}
|
|
@@ -123,8 +145,8 @@ export const secretComparisonRule: CheckRule = {
|
|
|
123
145
|
].includes(node.operatorToken.kind) &&
|
|
124
146
|
!isPresenceComparison(ts, node)
|
|
125
147
|
) {
|
|
126
|
-
const left =
|
|
127
|
-
const right =
|
|
148
|
+
const left = secretName(ts, node.left)
|
|
149
|
+
const right = secretName(ts, node.right)
|
|
128
150
|
if (
|
|
129
151
|
(left !== undefined && SECRET.test(left)) ||
|
|
130
152
|
(right !== undefined && SECRET.test(right))
|
|
@@ -229,9 +251,23 @@ export const failOpenGateRule: CheckRule = {
|
|
|
229
251
|
ts.isFunctionLike(parent) && parent.name && ts.isIdentifier(parent.name)
|
|
230
252
|
? parent.name.text
|
|
231
253
|
: undefined
|
|
232
|
-
|
|
254
|
+
// `can` only names a gate in camelCase (`canEdit`, `canAccess`) - matching it
|
|
255
|
+
// case-insensitively swept in `canonicalize…`, `cancel…`, `candidate…`, none of them
|
|
256
|
+
// authorization decisions. The other roots stay case-insensitive.
|
|
257
|
+
const gateName =
|
|
258
|
+
name !== undefined &&
|
|
259
|
+
(/^(?:require|assert|authorize)/i.test(name) || /^can[A-Z]/.test(name))
|
|
260
|
+
if (name !== undefined && gateName) {
|
|
233
261
|
const text = node.block.getText(tree)
|
|
234
|
-
|
|
262
|
+
// A catch fails closed when it rethrows, returns an explicit denial, or hands the failure
|
|
263
|
+
// to a helper whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). The last
|
|
264
|
+
// form is why conformance/invariant asserts that funnel every error through a
|
|
265
|
+
// `never`-returning `fail()` are not fail-open - the scanner reads no types, so it trusts
|
|
266
|
+
// the helper by name, the same trade the SQL and gate-name heuristics already make.
|
|
267
|
+
if (
|
|
268
|
+
!/\b(?:throw|return\s+(?:false|new\s+Response|deny))/s.test(text) &&
|
|
269
|
+
!/(?:^|[^.\w])(?:fail|deny|reject|forbid|unauthorized|abort)\s*\(/s.test(text)
|
|
270
|
+
) {
|
|
235
271
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
236
272
|
const reviewed = hasReview(lines, line)
|
|
237
273
|
findings.push(
|