@nifrajs/cli 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +10 -0
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +6 -1
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts.map +1 -1
  6. package/dist/assure.js +3 -2
  7. package/dist/assure.js.map +1 -1
  8. package/dist/check-diagnostics.d.ts +165 -0
  9. package/dist/check-diagnostics.d.ts.map +1 -0
  10. package/dist/check-diagnostics.js +1009 -0
  11. package/dist/check-diagnostics.js.map +1 -0
  12. package/dist/check-scan.d.ts +245 -0
  13. package/dist/check-scan.d.ts.map +1 -0
  14. package/dist/check-scan.js +1599 -0
  15. package/dist/check-scan.js.map +1 -0
  16. package/dist/check.d.ts +4 -324
  17. package/dist/check.d.ts.map +1 -1
  18. package/dist/check.js +8 -2366
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +247 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +21 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +122 -0
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/env-file.js +5 -5
  28. package/dist/env-file.js.map +1 -1
  29. package/dist/frontend-guidance.d.ts +57 -0
  30. package/dist/frontend-guidance.d.ts.map +1 -0
  31. package/dist/frontend-guidance.js +195 -0
  32. package/dist/frontend-guidance.js.map +1 -0
  33. package/dist/levels-tool.d.ts.map +1 -1
  34. package/dist/levels-tool.js +6 -0
  35. package/dist/levels-tool.js.map +1 -1
  36. package/dist/load.d.ts.map +1 -1
  37. package/dist/load.js +4 -1
  38. package/dist/load.js.map +1 -1
  39. package/dist/mcp-context.d.ts +48 -0
  40. package/dist/mcp-context.d.ts.map +1 -0
  41. package/dist/mcp-context.js +215 -0
  42. package/dist/mcp-context.js.map +1 -0
  43. package/dist/mcp-docs-tools.d.ts.map +1 -1
  44. package/dist/mcp-docs-tools.js +31 -0
  45. package/dist/mcp-docs-tools.js.map +1 -1
  46. package/dist/mcp-exec.d.ts +74 -0
  47. package/dist/mcp-exec.d.ts.map +1 -0
  48. package/dist/mcp-exec.js +844 -0
  49. package/dist/mcp-exec.js.map +1 -0
  50. package/dist/mcp-http.js +1 -1
  51. package/dist/mcp-io.d.ts +26 -0
  52. package/dist/mcp-io.d.ts.map +1 -0
  53. package/dist/mcp-io.js +100 -0
  54. package/dist/mcp-io.js.map +1 -0
  55. package/dist/mcp-reflect.d.ts +16 -0
  56. package/dist/mcp-reflect.d.ts.map +1 -0
  57. package/dist/mcp-reflect.js +101 -0
  58. package/dist/mcp-reflect.js.map +1 -0
  59. package/dist/mcp-root.d.ts +7 -0
  60. package/dist/mcp-root.d.ts.map +1 -1
  61. package/dist/mcp-root.js +51 -3
  62. package/dist/mcp-root.js.map +1 -1
  63. package/dist/mcp.d.ts +10 -92
  64. package/dist/mcp.d.ts.map +1 -1
  65. package/dist/mcp.js +13 -1232
  66. package/dist/mcp.js.map +1 -1
  67. package/dist/openapi-tool.d.ts +5 -10
  68. package/dist/openapi-tool.d.ts.map +1 -1
  69. package/dist/openapi-tool.js +21 -2
  70. package/dist/openapi-tool.js.map +1 -1
  71. package/dist/openapi-types.d.ts +21 -0
  72. package/dist/openapi-types.d.ts.map +1 -0
  73. package/dist/openapi-types.js +214 -0
  74. package/dist/openapi-types.js.map +1 -0
  75. package/dist/port.d.ts.map +1 -1
  76. package/dist/port.js +2 -1
  77. package/dist/port.js.map +1 -1
  78. package/dist/project-facts.d.ts +2 -1
  79. package/dist/project-facts.d.ts.map +1 -1
  80. package/dist/project-facts.js.map +1 -1
  81. package/dist/release-verification.d.ts +4 -1
  82. package/dist/release-verification.d.ts.map +1 -1
  83. package/dist/release-verification.js +6 -138
  84. package/dist/release-verification.js.map +1 -1
  85. package/dist/rules/codes.d.ts +4 -0
  86. package/dist/rules/codes.d.ts.map +1 -1
  87. package/dist/rules/codes.js +4 -0
  88. package/dist/rules/codes.js.map +1 -1
  89. package/dist/rules/islands.d.ts +4 -0
  90. package/dist/rules/islands.d.ts.map +1 -0
  91. package/dist/rules/islands.js +155 -0
  92. package/dist/rules/islands.js.map +1 -0
  93. package/dist/rules/nano.d.ts +6 -0
  94. package/dist/rules/nano.d.ts.map +1 -0
  95. package/dist/rules/nano.js +264 -0
  96. package/dist/rules/nano.js.map +1 -0
  97. package/dist/rules/security.d.ts.map +1 -1
  98. package/dist/rules/security.js +38 -4
  99. package/dist/rules/security.js.map +1 -1
  100. package/dist/scaffold.d.ts +12 -6
  101. package/dist/scaffold.d.ts.map +1 -1
  102. package/dist/scaffold.js +149 -10
  103. package/dist/scaffold.js.map +1 -1
  104. package/dist/search-terms.d.ts.map +1 -1
  105. package/dist/search-terms.js +5 -3
  106. package/dist/search-terms.js.map +1 -1
  107. package/dist/stylex-migrate.d.ts +38 -0
  108. package/dist/stylex-migrate.d.ts.map +1 -0
  109. package/dist/stylex-migrate.js +1052 -0
  110. package/dist/stylex-migrate.js.map +1 -0
  111. package/dist/sync-manifest.d.ts.map +1 -1
  112. package/dist/sync-manifest.js +2 -1
  113. package/dist/sync-manifest.js.map +1 -1
  114. package/dist/verification-plan.d.ts +24 -0
  115. package/dist/verification-plan.d.ts.map +1 -0
  116. package/dist/verification-plan.js +116 -0
  117. package/dist/verification-plan.js.map +1 -0
  118. package/dist/work-graph.d.ts.map +1 -1
  119. package/dist/work-graph.js +6 -3
  120. package/dist/work-graph.js.map +1 -1
  121. package/docs/examples.json +46 -4
  122. package/docs/llms-full.txt +1346 -28
  123. package/docs/types.json +4185 -87
  124. package/package.json +9 -9
  125. package/src/assure-hydration.ts +6 -1
  126. package/src/assure.ts +4 -2
  127. package/src/check-diagnostics.ts +1219 -0
  128. package/src/check-scan.ts +1825 -0
  129. package/src/check.ts +47 -2752
  130. package/src/cli.ts +252 -10
  131. package/src/command-catalog.ts +155 -0
  132. package/src/env-file.ts +5 -5
  133. package/src/frontend-guidance.ts +259 -0
  134. package/src/levels-tool.ts +6 -0
  135. package/src/load.ts +4 -1
  136. package/src/mcp-context.ts +266 -0
  137. package/src/mcp-docs-tools.ts +37 -0
  138. package/src/mcp-exec.ts +1040 -0
  139. package/src/mcp-http.ts +1 -1
  140. package/src/mcp-io.ts +118 -0
  141. package/src/mcp-reflect.ts +140 -0
  142. package/src/mcp-root.ts +62 -4
  143. package/src/mcp.ts +61 -1511
  144. package/src/openapi-tool.ts +32 -2
  145. package/src/openapi-types.ts +261 -0
  146. package/src/port.ts +2 -1
  147. package/src/project-facts.ts +2 -4
  148. package/src/release-verification.ts +22 -164
  149. package/src/rules/codes.ts +4 -0
  150. package/src/rules/islands.ts +171 -0
  151. package/src/rules/nano.ts +295 -0
  152. package/src/rules/security.ts +40 -4
  153. package/src/scaffold.ts +169 -9
  154. package/src/search-terms.ts +7 -3
  155. package/src/stylex-migrate.ts +1139 -0
  156. package/src/sync-manifest.ts +2 -1
  157. package/src/verification-plan.ts +242 -0
  158. package/src/work-graph.ts +6 -3
@@ -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])
@@ -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 = nameOf(ts, node.left)
127
- const right = nameOf(ts, node.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
- if (name !== undefined && /^(?:require|assert|can|authorize)/i.test(name)) {
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
- if (!/\b(?:throw|return\s+(?:false|new\s+Response|deny))/s.test(text)) {
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(