@sikkuumiii/defuse 0.0.0-stage → 0.5.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 (236) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +561 -2
  3. package/dist/src/cli.d.ts +20 -0
  4. package/dist/src/cli.js +619 -0
  5. package/dist/src/cli.js.map +1 -0
  6. package/dist/src/core/analyze.d.ts +178 -0
  7. package/dist/src/core/analyze.js +834 -0
  8. package/dist/src/core/analyze.js.map +1 -0
  9. package/dist/src/core/coverage.d.ts +87 -0
  10. package/dist/src/core/coverage.js +181 -0
  11. package/dist/src/core/coverage.js.map +1 -0
  12. package/dist/src/core/finding.d.ts +196 -0
  13. package/dist/src/core/finding.js +178 -0
  14. package/dist/src/core/finding.js.map +1 -0
  15. package/dist/src/core/licence.d.ts +50 -0
  16. package/dist/src/core/licence.js +56 -0
  17. package/dist/src/core/licence.js.map +1 -0
  18. package/dist/src/core/paths.d.ts +21 -0
  19. package/dist/src/core/paths.js +58 -0
  20. package/dist/src/core/paths.js.map +1 -0
  21. package/dist/src/core/score.d.ts +137 -0
  22. package/dist/src/core/score.js +232 -0
  23. package/dist/src/core/score.js.map +1 -0
  24. package/dist/src/dast/targets.d.ts +30 -0
  25. package/dist/src/dast/targets.js +79 -0
  26. package/dist/src/dast/targets.js.map +1 -0
  27. package/dist/src/dast/zap.d.ts +42 -0
  28. package/dist/src/dast/zap.js +184 -0
  29. package/dist/src/dast/zap.js.map +1 -0
  30. package/dist/src/engine/scan.d.ts +80 -0
  31. package/dist/src/engine/scan.js +129 -0
  32. package/dist/src/engine/scan.js.map +1 -0
  33. package/dist/src/engine/shapes.d.ts +47 -0
  34. package/dist/src/engine/shapes.js +328 -0
  35. package/dist/src/engine/shapes.js.map +1 -0
  36. package/dist/src/engine/walk.d.ts +60 -0
  37. package/dist/src/engine/walk.js +156 -0
  38. package/dist/src/engine/walk.js.map +1 -0
  39. package/dist/src/parse/astDump.d.ts +31 -0
  40. package/dist/src/parse/astDump.js +66 -0
  41. package/dist/src/parse/astDump.js.map +1 -0
  42. package/dist/src/parse/languages.d.ts +70 -0
  43. package/dist/src/parse/languages.js +174 -0
  44. package/dist/src/parse/languages.js.map +1 -0
  45. package/dist/src/parse/location.d.ts +17 -0
  46. package/dist/src/parse/location.js +38 -0
  47. package/dist/src/parse/location.js.map +1 -0
  48. package/dist/src/parse/node-grammars.d.ts +12 -0
  49. package/dist/src/parse/node-grammars.js +35 -0
  50. package/dist/src/parse/node-grammars.js.map +1 -0
  51. package/dist/src/parse/parser.d.ts +126 -0
  52. package/dist/src/parse/parser.js +202 -0
  53. package/dist/src/parse/parser.js.map +1 -0
  54. package/dist/src/parse/tree-store.d.ts +139 -0
  55. package/dist/src/parse/tree-store.js +236 -0
  56. package/dist/src/parse/tree-store.js.map +1 -0
  57. package/dist/src/platform/dependency-rule.d.ts +27 -0
  58. package/dist/src/platform/dependency-rule.js +21 -0
  59. package/dist/src/platform/dependency-rule.js.map +1 -0
  60. package/dist/src/platform/deps.d.ts +86 -0
  61. package/dist/src/platform/deps.js +304 -0
  62. package/dist/src/platform/deps.js.map +1 -0
  63. package/dist/src/platform/php-facts.d.ts +49 -0
  64. package/dist/src/platform/php-facts.js +67 -0
  65. package/dist/src/platform/php-facts.js.map +1 -0
  66. package/dist/src/platform/runtime-version.d.ts +78 -0
  67. package/dist/src/platform/runtime-version.js +573 -0
  68. package/dist/src/platform/runtime-version.js.map +1 -0
  69. package/dist/src/report/colors.d.ts +50 -0
  70. package/dist/src/report/colors.js +97 -0
  71. package/dist/src/report/colors.js.map +1 -0
  72. package/dist/src/report/coverage-table.d.ts +90 -0
  73. package/dist/src/report/coverage-table.js +291 -0
  74. package/dist/src/report/coverage-table.js.map +1 -0
  75. package/dist/src/report/html-report.d.ts +66 -0
  76. package/dist/src/report/html-report.js +489 -0
  77. package/dist/src/report/html-report.js.map +1 -0
  78. package/dist/src/report/human.d.ts +44 -0
  79. package/dist/src/report/human.js +575 -0
  80. package/dist/src/report/human.js.map +1 -0
  81. package/dist/src/report/json.d.ts +24 -0
  82. package/dist/src/report/json.js +178 -0
  83. package/dist/src/report/json.js.map +1 -0
  84. package/dist/src/report/mark.d.ts +17 -0
  85. package/dist/src/report/mark.js +39 -0
  86. package/dist/src/report/mark.js.map +1 -0
  87. package/dist/src/report/pricing.d.ts +33 -0
  88. package/dist/src/report/pricing.js +343 -0
  89. package/dist/src/report/pricing.js.map +1 -0
  90. package/dist/src/report/sarif.d.ts +39 -0
  91. package/dist/src/report/sarif.js +219 -0
  92. package/dist/src/report/sarif.js.map +1 -0
  93. package/dist/src/report/site.d.ts +87 -0
  94. package/dist/src/report/site.js +742 -0
  95. package/dist/src/report/site.js.map +1 -0
  96. package/dist/src/rules/code-injection.d.ts +47 -0
  97. package/dist/src/rules/code-injection.js +326 -0
  98. package/dist/src/rules/code-injection.js.map +1 -0
  99. package/dist/src/rules/command-injection.d.ts +30 -0
  100. package/dist/src/rules/command-injection.js +286 -0
  101. package/dist/src/rules/command-injection.js.map +1 -0
  102. package/dist/src/rules/contract.d.ts +198 -0
  103. package/dist/src/rules/contract.js +39 -0
  104. package/dist/src/rules/contract.js.map +1 -0
  105. package/dist/src/rules/format-string.d.ts +46 -0
  106. package/dist/src/rules/format-string.js +146 -0
  107. package/dist/src/rules/format-string.js.map +1 -0
  108. package/dist/src/rules/hardcoded-secret.d.ts +38 -0
  109. package/dist/src/rules/hardcoded-secret.js +440 -0
  110. package/dist/src/rules/hardcoded-secret.js.map +1 -0
  111. package/dist/src/rules/lib/guards.d.ts +168 -0
  112. package/dist/src/rules/lib/guards.js +1179 -0
  113. package/dist/src/rules/lib/guards.js.map +1 -0
  114. package/dist/src/rules/lib/html-safety.d.ts +75 -0
  115. package/dist/src/rules/lib/html-safety.js +755 -0
  116. package/dist/src/rules/lib/html-safety.js.map +1 -0
  117. package/dist/src/rules/lib/local-build.d.ts +59 -0
  118. package/dist/src/rules/lib/local-build.js +320 -0
  119. package/dist/src/rules/lib/local-build.js.map +1 -0
  120. package/dist/src/rules/lib/strings.d.ts +208 -0
  121. package/dist/src/rules/lib/strings.js +1970 -0
  122. package/dist/src/rules/lib/strings.js.map +1 -0
  123. package/dist/src/rules/path-traversal.d.ts +33 -0
  124. package/dist/src/rules/path-traversal.js +96 -0
  125. package/dist/src/rules/path-traversal.js.map +1 -0
  126. package/dist/src/rules/registry.d.ts +19 -0
  127. package/dist/src/rules/registry.js +77 -0
  128. package/dist/src/rules/registry.js.map +1 -0
  129. package/dist/src/rules/sql-injection.d.ts +36 -0
  130. package/dist/src/rules/sql-injection.js +299 -0
  131. package/dist/src/rules/sql-injection.js.map +1 -0
  132. package/dist/src/rules/ssrf.d.ts +39 -0
  133. package/dist/src/rules/ssrf.js +111 -0
  134. package/dist/src/rules/ssrf.js.map +1 -0
  135. package/dist/src/rules/unbounded-copy.d.ts +37 -0
  136. package/dist/src/rules/unbounded-copy.js +147 -0
  137. package/dist/src/rules/unbounded-copy.js.map +1 -0
  138. package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
  139. package/dist/src/rules/unsafe-deserialization.js +276 -0
  140. package/dist/src/rules/unsafe-deserialization.js.map +1 -0
  141. package/dist/src/rules/weak-hash.d.ts +37 -0
  142. package/dist/src/rules/weak-hash.js +197 -0
  143. package/dist/src/rules/weak-hash.js.map +1 -0
  144. package/dist/src/rules/xss.d.ts +38 -0
  145. package/dist/src/rules/xss.js +527 -0
  146. package/dist/src/rules/xss.js.map +1 -0
  147. package/dist/src/taint/content-type.d.ts +19 -0
  148. package/dist/src/taint/content-type.js +20 -0
  149. package/dist/src/taint/content-type.js.map +1 -0
  150. package/dist/src/taint/dictionaries.d.ts +23 -0
  151. package/dist/src/taint/dictionaries.js +1778 -0
  152. package/dist/src/taint/dictionaries.js.map +1 -0
  153. package/dist/src/taint/fold.d.ts +59 -0
  154. package/dist/src/taint/fold.js +1177 -0
  155. package/dist/src/taint/fold.js.map +1 -0
  156. package/dist/src/taint/java-scope.d.ts +61 -0
  157. package/dist/src/taint/java-scope.js +212 -0
  158. package/dist/src/taint/java-scope.js.map +1 -0
  159. package/dist/src/taint/local-map.d.ts +79 -0
  160. package/dist/src/taint/local-map.js +603 -0
  161. package/dist/src/taint/local-map.js.map +1 -0
  162. package/dist/src/taint/project.d.ts +172 -0
  163. package/dist/src/taint/project.js +617 -0
  164. package/dist/src/taint/project.js.map +1 -0
  165. package/dist/src/taint/tracer.d.ts +265 -0
  166. package/dist/src/taint/tracer.js +3817 -0
  167. package/dist/src/taint/tracer.js.map +1 -0
  168. package/dist/src/taint/types.d.ts +484 -0
  169. package/dist/src/taint/types.js +47 -0
  170. package/dist/src/taint/types.js.map +1 -0
  171. package/dist/src/taint/visibility.d.ts +25 -0
  172. package/dist/src/taint/visibility.js +96 -0
  173. package/dist/src/taint/visibility.js.map +1 -0
  174. package/docs/examples/defuse.json +20 -0
  175. package/docs/examples/dvwa-urls.json +14 -0
  176. package/docs/examples/github-code-scanning.yml +54 -0
  177. package/docs/examples/tiny.js +4 -0
  178. package/package.json +73 -4
  179. package/src/cli.ts +747 -0
  180. package/src/core/analyze.ts +1072 -0
  181. package/src/core/coverage.ts +269 -0
  182. package/src/core/finding.ts +311 -0
  183. package/src/core/licence.ts +61 -0
  184. package/src/core/paths.ts +59 -0
  185. package/src/core/score.ts +353 -0
  186. package/src/dast/targets.ts +121 -0
  187. package/src/dast/zap.ts +246 -0
  188. package/src/engine/scan.ts +200 -0
  189. package/src/engine/shapes.ts +348 -0
  190. package/src/engine/walk.ts +196 -0
  191. package/src/parse/astDump.ts +85 -0
  192. package/src/parse/languages.ts +232 -0
  193. package/src/parse/location.ts +44 -0
  194. package/src/parse/node-grammars.ts +41 -0
  195. package/src/parse/parser.ts +262 -0
  196. package/src/parse/tree-store.ts +301 -0
  197. package/src/platform/dependency-rule.ts +33 -0
  198. package/src/platform/deps.ts +374 -0
  199. package/src/platform/php-facts.ts +85 -0
  200. package/src/platform/runtime-version.ts +710 -0
  201. package/src/report/colors.ts +107 -0
  202. package/src/report/coverage-table.ts +359 -0
  203. package/src/report/html-report.ts +601 -0
  204. package/src/report/human.ts +811 -0
  205. package/src/report/json.ts +189 -0
  206. package/src/report/mark.ts +47 -0
  207. package/src/report/pricing.ts +374 -0
  208. package/src/report/sarif.ts +235 -0
  209. package/src/report/site.ts +760 -0
  210. package/src/rules/code-injection.ts +346 -0
  211. package/src/rules/command-injection.ts +296 -0
  212. package/src/rules/contract.ts +220 -0
  213. package/src/rules/format-string.ts +158 -0
  214. package/src/rules/hardcoded-secret.ts +485 -0
  215. package/src/rules/lib/guards.ts +1235 -0
  216. package/src/rules/lib/html-safety.ts +793 -0
  217. package/src/rules/lib/local-build.ts +329 -0
  218. package/src/rules/lib/strings.ts +2034 -0
  219. package/src/rules/path-traversal.ts +101 -0
  220. package/src/rules/registry.ts +90 -0
  221. package/src/rules/sql-injection.ts +311 -0
  222. package/src/rules/ssrf.ts +118 -0
  223. package/src/rules/unbounded-copy.ts +162 -0
  224. package/src/rules/unsafe-deserialization.ts +291 -0
  225. package/src/rules/weak-hash.ts +226 -0
  226. package/src/rules/xss.ts +572 -0
  227. package/src/taint/content-type.ts +20 -0
  228. package/src/taint/dictionaries.ts +1855 -0
  229. package/src/taint/fold.ts +1206 -0
  230. package/src/taint/java-scope.ts +200 -0
  231. package/src/taint/local-map.ts +594 -0
  232. package/src/taint/project.ts +774 -0
  233. package/src/taint/tracer.ts +4298 -0
  234. package/src/taint/types.ts +511 -0
  235. package/src/taint/visibility.ts +100 -0
  236. package/tsconfig.json +61 -0
@@ -0,0 +1,1206 @@
1
+ /**
2
+ * THE CONSTANT FOLDER, SHARED.
3
+ *
4
+ * This lived inside traceFile() for as long as only the tracer needed it. Then
5
+ * the signature rules needed it too: 65 of BenchmarkJava's remaining false
6
+ * positives are SQL strings built from a `bar` that a decidable condition has
7
+ * already set to a literal, and the SQL rule could not see that, because the
8
+ * only thing that knew how to decide a condition was sealed inside the tracer.
9
+ *
10
+ * The alternative was a second, smaller folder in rules/lib. This project has
11
+ * paid for "the same fact written twice" more than once - ASSIGNMENT_NODES below
12
+ * carries a scar from exactly that - so the folder moved out instead, and both
13
+ * callers now use the one copy. Moving it was checked with `npm run identity`,
14
+ * which hashes every finding and every proof step on twelve fixed targets: the
15
+ * move was meant to change nothing, and it had to be shown to change nothing.
16
+ *
17
+ * createFolder() returns a fresh folder per language, per file, so its caches
18
+ * never leak an answer from one file into another.
19
+ */
20
+
21
+ import type { Node } from 'web-tree-sitter';
22
+ import type { LanguageId } from '../parse/languages.js';
23
+
24
+ /** Whitespace-normalised node text. The tracer has its own copy of this one-liner. */
25
+ function text(node: Node | null | undefined): string {
26
+ return (node?.text ?? '').replace(/\s+/g, ' ').trim();
27
+ }
28
+
29
+ export const assignmentParts = (node: Node): { target: Node; value: Node } | null => {
30
+ const pairs: Array<[string, string]> = [
31
+ ['left', 'right'],
32
+ ['name', 'value'],
33
+ ['key', 'value'],
34
+ // C and C++: `char *user = getenv("X")` is an init_declarator, whose
35
+ // fields are `declarator` and `value` rather than any of the above.
36
+ ['declarator', 'value'],
37
+ ];
38
+ for (const [targetField, valueField] of pairs) {
39
+ const target = node.childForFieldName(targetField);
40
+ const value = node.childForFieldName(valueField);
41
+ if (target && value) return { target, value };
42
+ }
43
+ return null;
44
+ };
45
+
46
+ export const ASSIGNMENT_NODES = new Set([
47
+ 'assignment_expression', // JS/TS, Java
48
+ 'augmented_assignment_expression',
49
+ 'variable_declarator', // JS/TS, Java
50
+ 'assignment', // Python
51
+ 'augmented_assignment',
52
+ 'pair',
53
+ 'public_field_definition',
54
+ 'short_var_declaration', // Go x := ...
55
+ 'assignment_statement', // Go x = ...
56
+ 'var_spec', // Go var x = ...
57
+ 'const_spec', // Go const x = ...
58
+ 'property_element', // PHP class property with a default
59
+ /*
60
+ * C/C++ declaration-with-initialiser: `char *user = getenv("X")`.
61
+ *
62
+ * THIS LIST AND THE SHAPE QUERIES IN engine/shapes.ts ARE THE SAME FACT
63
+ * WRITTEN TWICE, and this entry is what that costs. The query knew about
64
+ * init_declarator from the first commit; the tracer's own walk did not, so
65
+ * every C value bound at its declaration read as clean while direct
66
+ * arguments traced perfectly - `printf(getenv("X"))` was flow-verified and
67
+ * `char *u = getenv("X"); printf(u);` was not, which is most of real C.
68
+ *
69
+ * It failed quietly because the shapes that DO work look like success: a
70
+ * plain `p = argv[1]` is an assignment_expression and was always covered.
71
+ */
72
+ 'init_declarator',
73
+ ]);
74
+
75
+ export type Folded = number | boolean | undefined;
76
+
77
+ export interface Folder {
78
+ /** Fold an expression to a number or boolean, or undefined when it cannot be decided. */
79
+ readonly foldExpression: (node: Node | null, body: Node, depth: number) => Folded;
80
+ /** True or false when a condition is decidable, undefined when it is not. */
81
+ readonly conditionTruth: (node: Node | null, body: Node) => boolean | undefined;
82
+ /** The three parts of a ternary, whatever the grammar calls them. */
83
+ readonly ternaryParts: (node: Node) => { condition: Node | null; consequence: Node | null; alternative: Node | null };
84
+ /** The arm of an if-statement that certainly does not run, if any. */
85
+ readonly deadBranchOf: (node: Node, body: Node) => Node | null;
86
+ /** Which cases of a switch can run, when the selector and every label fold. */
87
+ readonly switchDecision: (node: Node, body: Node) => SwitchDecision | null;
88
+ /** Whether a name receives any write that is not a plain `name = value`. */
89
+ readonly writtenOtherwise: (name: string, body: Node) => boolean;
90
+ /** How many plain writes a name receives in a function, nested functions included. */
91
+ readonly plainWriteCount: (name: string, body: Node) => number;
92
+ }
93
+
94
+ export function createFolder(language: LanguageId): Folder {
95
+ /*
96
+ * CONSTANT CONDITIONS, FOLDED - DELIBERATELY BADLY.
97
+ *
98
+ * The engine reads statements in order and does not evaluate conditions, so
99
+ * a value on the losing side of a decision already made at compile time gets
100
+ * carried anyway. That single blind spot was 128 of the 131 false positives
101
+ * still wearing the proven label.
102
+ *
103
+ * int num = 106;
104
+ * bar = (7 * 18) + num > 200 ? "constant" : param;
105
+ *
106
+ * 126 + 106 = 232. The tainted branch cannot run.
107
+ *
108
+ * THIS FOLDER IS SMALL ON PURPOSE. It understands integer and boolean
109
+ * literals, parentheses, negation, arithmetic, comparison and the logical
110
+ * connectives, plus a local variable bound exactly once to something it can
111
+ * already fold. Everything else returns undefined, which means "cannot tell",
112
+ * which means the branch is assumed live and nothing changes.
113
+ *
114
+ * The asymmetry matters. A folder that is too weak costs a false positive
115
+ * that was already there. A folder that is too clever - one that decides
116
+ * `"abc".length() > 2` by modelling String, or evaluates a call - eventually
117
+ * gets a condition wrong in the other direction and DELETES A REAL FINDING.
118
+ * A miss is the failure mode this tool exists to prevent, so every doubt
119
+ * resolves toward keeping the flow.
120
+ *
121
+ * Grammar-agnostic by construction: it reads the `operator` field and the
122
+ * node's own text rather than switching on per-language node names, because
123
+ * eight grammars spell a literal eight ways and a name this list forgot
124
+ * would fail silently toward undefined.
125
+ */
126
+
127
+ const foldLiteral = (raw: string): Folded => {
128
+ const t = raw.trim();
129
+ if (t === 'true' || t === 'True') return true;
130
+ if (t === 'false' || t === 'False') return false;
131
+ /*
132
+ * A leading zero means OCTAL in Java, C, C++, Go, PHP and sloppy JavaScript:
133
+ * `010` is 8, and reading it as ten decided `n < 9` the wrong way round. It
134
+ * is not worth modelling - a leading-zero literal is "cannot tell".
135
+ */
136
+ if (/^-?0\d/.test(t)) return undefined;
137
+ if (/^-?\d+$/.test(t)) return Number(t);
138
+ if (/^-?\d+[lL]$/.test(t)) return Number(t.slice(0, -1));
139
+ return undefined;
140
+ };
141
+
142
+ /**
143
+ * The bare name out of whatever the grammar wrapped it in.
144
+ *
145
+ * `String bar` is Java declaring and naming in one node, so everything up to
146
+ * the last space goes. `$num` is PHP, and the folder reaches the inner `name`
147
+ * node - text `num` - while the assignment target is the outer `variable_name`
148
+ * - text `$num`. Comparing those two raw is a mismatch that looks exactly
149
+ * like "this variable is never assigned", which is a silent undefined.
150
+ */
151
+ const bareName = (raw: string): string => raw.trim().replace(/^.*\s/, '').replace(/^\$/, '');
152
+
153
+ /**
154
+ * A local name bound exactly once, to something foldable. Two assignments and
155
+ * we stop: the value at this point would depend on order and reachability,
156
+ * which is the very thing we are not modelling.
157
+ */
158
+ /*
159
+ * One walk per name per function, not one per mention.
160
+ *
161
+ * Keyed on the body's SOURCE SPAN rather than on the node, because the
162
+ * tree-sitter binding hands back a fresh wrapper object every time a child is
163
+ * asked for - a Map keyed by Node would miss on every lookup and quietly
164
+ * degrade into no cache at all, which is the same mistake that once made the
165
+ * dead-branch skip compare two identical nodes and decide they differed.
166
+ */
167
+ const constantCache = new Map<string, Map<string, Folded>>();
168
+
169
+ /*
170
+ * THE CYCLE GUARD, AND THE DEPTH THAT USED TO RESET TO ZERO.
171
+ *
172
+ * Resolving a name folds whatever it was assigned, and folding that can hit
173
+ * another name. `x = y + 1; y = x + 1` is a loop, and until now the recursion
174
+ * had nothing to stop it: foldExpression carries a depth cap of 12, but the
175
+ * call BACK into it from here passed 0, so every hop through a name reset the
176
+ * budget and the cap could never be reached.
177
+ *
178
+ * That was survivable while the folder barely reached names at all. Reading
179
+ * operators off the shape made it reach them constantly, and DVWA's packed
180
+ * sha256 - one 10,417-character line, hundreds of one-letter names assigned
181
+ * from each other - turned it into an out-of-memory on a 10KB file.
182
+ *
183
+ * Two changes, and each would have been enough on its own: the depth now
184
+ * threads through instead of resetting, and a name currently being resolved
185
+ * answers "cannot tell" if it is asked about again. Two, because the depth is
186
+ * the principle and the cycle guard is the proof - a budget that merely runs
187
+ * out still does the work first, and a loop should cost nothing at all.
188
+ */
189
+ const resolving = new Set<string>();
190
+
191
+ /*
192
+ * "BOUND EXACTLY ONCE" WAS NOT ENOUGH, AND IT WAS DELETING REAL FINDINGS.
193
+ *
194
+ * The rule used to be: find every `=` to this name in the function, and if
195
+ * there is exactly one, the name IS that value. Probed before the switch
196
+ * folding was built on top of it, that rule was wrong in every language here,
197
+ * in ordinary code, and wrong in the dangerous direction:
198
+ *
199
+ * void f(..., int num) { if (x) num = 500; bar = num > 200 ? "safe" : param; }
200
+ * for (int i = 0; i < 10; i++) { bar = i > 5 ? param : "safe"; }
201
+ * n := 0; fmt.Sscan(s, &n); if n > 5 { bar = param }
202
+ *
203
+ * One `=` each - so `num` was 500, `i` was 0 and `n` was 0, a branch that
204
+ * really runs was declared dead, a real SQL injection went unreported, and
205
+ * the SQL rule printed a receipt calling the line PROVABLY CONSTANT. A
206
+ * parameter already holds the caller's value before any `=`; `i++` and `&n`
207
+ * write without an `=`; a field is not this function's to fix.
208
+ *
209
+ * So the single write now has to be one that CANNOT be bypassed:
210
+ *
211
+ * - a DECLARATION with its value (Java, C, C++, Go, JS let/const), and the
212
+ * use inside that declaration's block, after it. Block scoping is what
213
+ * makes it sound: nothing outside the block can name it, and nothing
214
+ * inside can read it before the declaration runs.
215
+ * - in Python, PHP and a JavaScript `var` there are no block-scoped
216
+ * declarations, so the write must sit at the TOP LEVEL of the function
217
+ * body - not inside an if, a loop, a try - and before the read. A write
218
+ * there cannot be skipped, so it replaces a parameter's value outright.
219
+ * - and nothing else may write to it: no `++`/`--`, no address taken, no
220
+ * loop or `with`/`as` binding, no `global`, and - in PHP and C++, where a
221
+ * call site does not show that an argument is passed by reference - not
222
+ * handed to any call at all.
223
+ *
224
+ * Anything else is "cannot tell". SwitchOnConstant.java and folder-soundness.*
225
+ * are the fence; every case in them was a missed vulnerability.
226
+ */
227
+ const wordIn = (haystack: string, name: string): boolean =>
228
+ new RegExp(`(^|[^\\w$])\\$?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`).test(haystack);
229
+
230
+ /** Node types that write to a name without an `=`, and the field that names it. */
231
+ const OTHER_BINDERS: ReadonlyArray<[string, string | null]> = [
232
+ ['for_statement', 'left'], // Python for i in ...
233
+ ['for_in_statement', 'left'], // JS for (x of xs)
234
+ ['enhanced_for_statement', 'name'], // Java for (String s : xs)
235
+ ['range_clause', 'left'], // Go for i := range xs
236
+ ['for_range_loop', 'declarator'], // C++ for (auto x : xs)
237
+ ['named_expression', 'name'], // Python (x := ...)
238
+ ['as_pattern_target', null], // Python with ... as x, except E as x
239
+ ['catch_formal_parameter', 'name'], // Java
240
+ ['catch_clause', 'parameter'], // JS
241
+ ['global_statement', null], // Python
242
+ ['nonlocal_statement', null], // Python
243
+ ['global_declaration', null], // PHP
244
+ ['function_static_declaration', null], // PHP
245
+ ['static_variable_declaration', null], // PHP
246
+ ['foreach_statement', null], // PHP checked below: the part after `as`
247
+ ['case_pattern', null], // Python match x: case n: - a bare name CAPTURES
248
+ ];
249
+ const INCREMENT_NODES = new Set(['update_expression', 'inc_statement', 'dec_statement']);
250
+
251
+ const BINDER_FIELD = new Map(OTHER_BINDERS);
252
+ /** Where an address is taken, in the grammars that can take one. */
253
+ const ADDRESS_OF = new Set([
254
+ 'pointer_expression', // C, C++ &n
255
+ 'unary_expression', // Go &n
256
+ 'by_ref', // PHP foreach ($a as &$v), function f(&$x)
257
+ 'reference_assignment_expression', // PHP $y = &$n
258
+ ]);
259
+
260
+ /*
261
+ * Checked on every node of the function for every name the folder asks
262
+ * about, so it must decide by node TYPE before it reads any text: reading
263
+ * the text of a large node copies it out of the parser, and doing that for
264
+ * each node made a BenchmarkJava scan measurably slower.
265
+ */
266
+ const writesWithoutAssignment = (node: Node, name: string): boolean => {
267
+ const type = node.type;
268
+ if (INCREMENT_NODES.has(type)) return wordIn(node.text, name);
269
+ // `&n` - C, C++, Go - and PHP's `&$n`: whoever holds the address can write.
270
+ if (ADDRESS_OF.has(type)) {
271
+ // Comments and parentheses are dropped first: `&(n)` and `&/*out*/n` are
272
+ // both the address of n.
273
+ const t = node.text;
274
+ if (t.length > 200) return false;
275
+ const flat = t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/[()\s]/g, '');
276
+ return new RegExp(`&\\$?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`).test(flat);
277
+ }
278
+ // Go: `n.Set(s)` takes &n implicitly when Set has a pointer receiver.
279
+ if (language === 'go' && type === 'call_expression') {
280
+ const fn = node.childForFieldName('function');
281
+ if (fn?.type === 'selector_expression' && bareName(fn.childForFieldName('operand')?.text ?? '') === name) return true;
282
+ }
283
+ if (language === 'c' || language === 'cpp') {
284
+ // A macro can assign to its argument - `SET_INT(n, s)` - and nothing at the
285
+ // call says so. Upper-case callees are read as macros, by convention.
286
+ if (type === 'call_expression' && /^[A-Z][A-Z0-9_]*$/.test(node.childForFieldName('function')?.text ?? '')) {
287
+ const args = node.childForFieldName('arguments');
288
+ if (args?.namedChildren.some((c) => !!c && bareName(c.text.replace(/[()\s]/g, '')) === name)) return true;
289
+ }
290
+ // `#define n ...` makes every later `n` something else entirely.
291
+ if ((type === 'preproc_def' || type === 'preproc_function_def') && node.childForFieldName('name')?.text === name) {
292
+ return true;
293
+ }
294
+ }
295
+ const field = BINDER_FIELD.get(type);
296
+ if (field !== undefined) {
297
+ if (type === 'foreach_statement') {
298
+ const afterAs = node.text.split(/\bas\b/)[1]?.split(')')[0] ?? '';
299
+ return wordIn(afterAs, name);
300
+ }
301
+ const binder = field ? node.childForFieldName(field) : node;
302
+ return !!binder && wordIn(binder.text, name);
303
+ }
304
+ // PHP and C++ pass by reference invisibly: `preg_match($re, $s, $n)` and
305
+ // `void f(int &x); f(n);` both write to n with nothing at the call site.
306
+ if (language === 'php' && type === 'argument') return bareName(node.text) === name;
307
+ if (language === 'cpp') {
308
+ if (type === 'argument_list') return node.namedChildren.some((c) => !!c && bareName(c.text) === name);
309
+ // `std::cin >> n`, and `int &r = n` - a second name for the same storage.
310
+ if (type === 'binary_expression' && node.childForFieldName('operator')?.text === '>>' &&
311
+ bareName(node.childForFieldName('right')?.text ?? '') === name) return true;
312
+ if (type === 'init_declarator' && node.childForFieldName('declarator')?.type === 'reference_declarator' &&
313
+ bareName(node.childForFieldName('value')?.text ?? '') === name) return true;
314
+ }
315
+ return false;
316
+ };
317
+
318
+ /**
319
+ * The region a single write governs, or null when that write can be bypassed.
320
+ * See the note above: a block-scoped declaration governs its block; a plain
321
+ * write governs the function only when it sits at the top of the body.
322
+ */
323
+ /*
324
+ * WHAT RANGE A DECLARED TYPE CAN HOLD, because arithmetic does not wrap here
325
+ * and does in the program. `int t = 30 * 24 * 60 * 60 * 1000` is NEGATIVE in
326
+ * Java; `unsigned n = 0 - 1` is huge in C; `signed char c = 200` is -56. A
327
+ * value outside what its type holds is "cannot tell". Null means the type is
328
+ * not one whose numbers are modelled at all - unsigned, floating point, a
329
+ * typedef, a class - and then no number bound to it is trusted.
330
+ */
331
+ const INT32: readonly [number, number] = [-2147483648, 2147483647];
332
+ const INT16: readonly [number, number] = [-32768, 32767];
333
+ const SAFE: readonly [number, number] = [-(2 ** 53), 2 ** 53];
334
+ const rangeOfType = (typeText: string | undefined): readonly [number, number] | null => {
335
+ const t = (typeText ?? '').replace(/\b(?:const|constexpr|static|final|volatile|register|signed)\b/g, ' ').replace(/\s+/g, ' ').trim();
336
+ if (language === 'java') {
337
+ if (t === 'int' || t === 'long' || t === 'var') return INT32;
338
+ if (t === 'short') return INT16;
339
+ if (t === 'byte') return [-128, 127];
340
+ if (t === 'char') return [0, 65535];
341
+ return null;
342
+ }
343
+ if (language === 'c' || language === 'cpp') {
344
+ if (/^(?:int|long|long int|long long|long long int|short int|)$/.test(t)) return t === '' ? INT32 : INT32;
345
+ if (t === 'short') return INT16;
346
+ // Plain `char` may be signed or unsigned; 0-127 is the same either way.
347
+ if (t === 'char') return [0, 127];
348
+ return null;
349
+ }
350
+ if (language === 'go') {
351
+ if (t === '' || t === 'int' || t === 'int32' || t === 'int64' || t === 'rune') return INT32;
352
+ if (t === 'int16') return INT16;
353
+ if (t === 'int8') return [-128, 127];
354
+ if (t === 'byte' || t === 'uint8') return [0, 255];
355
+ return null;
356
+ }
357
+ return SAFE;
358
+ };
359
+
360
+ const regionOfWrite = (write: Node, body: Node): { region: Node; range: readonly [number, number] | null } | null => {
361
+ const found = regionNodeOfWrite(write, body);
362
+ if (!found) return null;
363
+ const parent = write.parent;
364
+ let typeText: string | undefined;
365
+ if (language === 'java' || language === 'c' || language === 'cpp') {
366
+ typeText = parent?.childForFieldName('type')?.text;
367
+ // C++ references and aliasing mean a mutable local cannot be read from
368
+ // the text alone - `int &r{n}` writes n with no `n` in sight. Only a
369
+ // `const` or `constexpr` local is a constant in C++.
370
+ if (language === 'cpp' && !parent?.namedChildren.some((c) => c?.type === 'type_qualifier' && /^(?:const|constexpr)$/.test(c.text))) {
371
+ return null;
372
+ }
373
+ // An unsigned or sized declaration keeps its qualifiers in the type node.
374
+ if (language !== 'java') {
375
+ const qualifiers = (parent?.namedChildren ?? []).filter((c) => c?.type === 'type_qualifier' || c?.type === 'storage_class_specifier').map((c) => c?.text ?? '');
376
+ typeText = `${qualifiers.join(' ')} ${typeText ?? ''}`;
377
+ }
378
+ } else if (language === 'go' && write.type === 'var_spec') {
379
+ typeText = write.childForFieldName('type')?.text;
380
+ }
381
+ return { region: found, range: rangeOfType(typeText) };
382
+ };
383
+
384
+ const regionNodeOfWrite = (write: Node, body: Node): Node | null => {
385
+ const parent = write.parent;
386
+ if (!parent) return null;
387
+ const same = (a: Node | null, b: Node): boolean =>
388
+ !!a && a.startIndex === b.startIndex && a.endIndex === b.endIndex;
389
+ // The tracer hands over a function's BODY; the constant proof hands over the
390
+ // FUNCTION. Both mean the same scope, and the top-level tests below need the
391
+ // block either way.
392
+ const block = body.childForFieldName('body') ?? body;
393
+
394
+ if (language === 'java' && write.type === 'variable_declarator' && parent.type === 'local_variable_declaration') {
395
+ return parent.parent;
396
+ }
397
+ if ((language === 'c' || language === 'cpp') && write.type === 'init_declarator' && parent.type === 'declaration') {
398
+ return parent.parent;
399
+ }
400
+ if (language === 'go' && (write.type === 'short_var_declaration' || write.type === 'var_spec' || write.type === 'const_spec')) {
401
+ const holder = write.type === 'short_var_declaration' ? parent : parent.parent;
402
+ return holder ?? null;
403
+ }
404
+ if ((language === 'javascript' || language === 'typescript') && write.type === 'variable_declarator') {
405
+ if (parent.type === 'lexical_declaration') return parent.parent;
406
+ // `var` is function-scoped and hoisted: only the top of the body is safe.
407
+ if (parent.type === 'variable_declaration' && same(parent.parent, block)) return block;
408
+ return null;
409
+ }
410
+ /*
411
+ * No parameter check is needed here, and that is not an oversight: a write
412
+ * at the top of the body runs before any read after it, so it replaces the
413
+ * caller's value rather than competing with it. What a parameter breaks is
414
+ * a write that might NOT run - and that is exactly what "top level" rules out.
415
+ */
416
+ if ((language === 'python' || language === 'php') && parent.type === 'expression_statement' && same(parent.parent, block)) {
417
+ return block;
418
+ }
419
+ return null;
420
+ };
421
+
422
+ interface WriteFacts {
423
+ readonly write: Node;
424
+ readonly value: Node;
425
+ readonly region: Node;
426
+ readonly range: readonly [number, number] | null;
427
+ }
428
+ interface WriteSurvey {
429
+ /** Plain writes whose target IS the name. */
430
+ readonly writes: ReadonlyArray<{ write: Node; value: Node }>;
431
+ /** Any write the name receives some other way - or a function where that cannot be ruled out. */
432
+ readonly otherWrite: boolean;
433
+ }
434
+ const surveyCache = new Map<string, WriteSurvey>();
435
+
436
+ /*
437
+ * NAMES THAT CANNOT BE READ FROM THE TEXT AT ALL. `with` and `eval` in
438
+ * JavaScript, `$$name`, `${...}`, extract() and eval() in PHP, exec() and
439
+ * locals() in Python: any of them in a function can write to any local in
440
+ * it, under a name that appears nowhere. One of them present, and no name in
441
+ * that function is a constant.
442
+ */
443
+ const dynamicScopeCache = new Map<string, boolean>();
444
+ const DYNAMIC_CALLS = new Set(['eval', 'exec', 'extract', 'parse_str', 'locals', 'globals', 'vars']);
445
+ const hasDynamicScope = (body: Node): boolean => {
446
+ const key = `${body.startIndex}:${body.endIndex}`;
447
+ const cached = dynamicScopeCache.get(key);
448
+ if (cached !== undefined) return cached;
449
+ /*
450
+ * A function the grammar could not fully parse may hide a write inside the
451
+ * part it gave up on - Java's grammar reads `(n) = x;` as an error, so that
452
+ * write was invisible and the declaration looked like the only one.
453
+ */
454
+ let found = body.hasError;
455
+ let walked = 0;
456
+ /*
457
+ * PYTHON FUNCTIONS ARE THE EXCEPTION. Since Python 3, exec/eval/locals()
458
+ * inside a function work on a COPY of its locals - they cannot rebind one
459
+ * (PEP 667 made that the language's word in 3.13; earlier releases behave
460
+ * the same without tracing hooks). So inside a function they only count
461
+ * when a `global`/`nonlocal` line lets a name escape to a scope exec CAN
462
+ * write. Module and class bodies are real dicts, and keep the old answer.
463
+ * Refusing here had stopped BenchmarkPython's `num = 86; if 7*42 - num > 200`
464
+ * from ever being decided in a function that went on to call exec().
465
+ */
466
+ const pyFunction = language === 'python' && (body.type === 'function_definition' || body.parent?.type === 'function_definition');
467
+ let pyDynamicCall = false;
468
+ let pyEscape = false;
469
+ const visit = (n: Node): void => {
470
+ if (found || ++walked > 20000) {
471
+ found = true;
472
+ return;
473
+ }
474
+ const type = n.type;
475
+ if (type === 'with_statement' && (language === 'javascript' || language === 'typescript')) found = true;
476
+ else if (type === 'dynamic_variable_name') found = true;
477
+ // C lets `goto` jump past `int n = 0;` into its block, leaving n unset.
478
+ // (C++ refuses to compile that, so only C gives up here.)
479
+ else if (type === 'goto_statement' && language === 'c') found = true;
480
+ else if (type === 'identifier' && n.text === 'arguments' && (language === 'javascript' || language === 'typescript')) found = true;
481
+ else if (pyFunction && (type === 'global_statement' || type === 'nonlocal_statement')) pyEscape = true;
482
+ else if ((type === 'call_expression' || type === 'call' || type === 'function_call_expression') &&
483
+ DYNAMIC_CALLS.has((n.childForFieldName('function')?.text ?? '').replace(/^\\/, ''))) {
484
+ if (pyFunction) pyDynamicCall = true;
485
+ else found = true;
486
+ }
487
+ if (!found) for (const c of n.namedChildren) if (c) visit(c);
488
+ };
489
+ visit(body);
490
+ if (pyDynamicCall && pyEscape) found = true;
491
+ dynamicScopeCache.set(key, found);
492
+ return found;
493
+ };
494
+
495
+ /** Every write to `name` in `body`, and whether any came some other way. */
496
+ const survey = (name: string, body: Node): WriteSurvey => {
497
+ const key = `${body.startIndex}:${body.endIndex}#${name}`;
498
+ const cached = surveyCache.get(key);
499
+ if (cached) return cached;
500
+
501
+ const writes: Array<{ write: Node; value: Node }> = [];
502
+ let otherWrite = hasDynamicScope(body);
503
+ let walked = 0;
504
+ const visit = (node: Node, walkDepth: number): void => {
505
+ if (otherWrite) return;
506
+ // Out of budget is "cannot tell", never "nothing more to see": a second
507
+ // write in the 20th arm of an else-if chain is still a second write.
508
+ if (walkDepth > 400 || ++walked > 20000) {
509
+ otherWrite = true;
510
+ return;
511
+ }
512
+ /*
513
+ * ASSIGNMENT_NODES first, and this is not belt-and-braces.
514
+ *
515
+ * assignmentParts() looks for a `left`/`right` field pair, and a
516
+ * comparison has exactly that - so `num > 200` was read as an assignment
517
+ * of 200 to num. The folder then saw two assignments to `num` and gave
518
+ * up, which is why every condition with a variable in it silently failed
519
+ * to fold. The real tracer never hit this because it only ever calls
520
+ * assignmentParts on nodes it has already checked.
521
+ */
522
+ const parts = ASSIGNMENT_NODES.has(node.type) ? assignmentParts(node) : null;
523
+ if (parts) {
524
+ const target = text(parts.target);
525
+ if (bareName(target) === name && !/[()[\]{},]/.test(target)) {
526
+ writes.push({ write: node, value: parts.value });
527
+ // `n += 1` and Python's `n += 1` are writes the constant proof must see.
528
+ if (/augmented/.test(node.type) || (node.childForFieldName('operator')?.text ?? '=') !== '=') otherWrite = true;
529
+ } else if (wordIn(target, name)) {
530
+ /*
531
+ * The name is PART of what is written: `(n) = x`, `n, err := f()`,
532
+ * `[n] = [x]`, `list($n) = ...`, `{ limit } = req.query`. None of those
533
+ * was counted before, so the one plain `n := 0` looked like the only
534
+ * write there was.
535
+ */
536
+ otherWrite = true;
537
+ return;
538
+ }
539
+ } else if (writesWithoutAssignment(node, name)) {
540
+ otherWrite = true;
541
+ return;
542
+ }
543
+ for (const child of node.namedChildren) if (child) visit(child, walkDepth + 1);
544
+ };
545
+ visit(body, 0);
546
+
547
+ const result = { writes, otherWrite };
548
+ surveyCache.set(key, result);
549
+ return result;
550
+ };
551
+
552
+ const writeCache = new Map<string, WriteFacts | null>();
553
+
554
+ /** The one write that decides `name` in `body`, if there is exactly one sound one. */
555
+ const soleWrite = (name: string, body: Node): WriteFacts | null => {
556
+ const key = `${body.startIndex}:${body.endIndex}#${name}`;
557
+ if (writeCache.has(key)) return writeCache.get(key) ?? null;
558
+ const { writes, otherWrite } = survey(name, body);
559
+ const only = writes.length === 1 && !otherWrite ? writes[0] : undefined;
560
+ const found = only ? regionOfWrite(only.write, body) : null;
561
+ const facts = only && found ? { write: only.write, value: only.value, region: found.region, range: found.range } : null;
562
+ writeCache.set(key, facts);
563
+ return facts;
564
+ };
565
+
566
+ /** For the constant proof: does `name` receive any write that is not a plain `name = value`? */
567
+ const writtenOtherwise = (name: string, body: Node): boolean => survey(bareName(name), body).otherWrite;
568
+
569
+ /** Every plain write to `name` in `body`, nested functions included. */
570
+ const plainWriteCount = (name: string, body: Node): number => survey(bareName(name), body).writes.length;
571
+
572
+ /** Does the sole write certainly run before `use`, and govern it? */
573
+ const writeReaches = (facts: WriteFacts, use: Node | null): boolean =>
574
+ !!use &&
575
+ facts.write.endIndex <= use.startIndex &&
576
+ use.startIndex >= facts.region.startIndex &&
577
+ use.endIndex <= facts.region.endIndex;
578
+
579
+ const constantOfName = (rawName: string, body: Node, depth: number, use: Node | null): Folded => {
580
+ const name = bareName(rawName);
581
+ const facts = soleWrite(name, body);
582
+ if (!facts || !writeReaches(facts, use)) return undefined;
583
+
584
+ const scopeKey = `${body.startIndex}:${body.endIndex}`;
585
+ let perScope = constantCache.get(scopeKey);
586
+ if (!perScope) {
587
+ perScope = new Map<string, Folded>();
588
+ constantCache.set(scopeKey, perScope);
589
+ }
590
+ if (perScope.has(name)) return perScope.get(name);
591
+
592
+ const cycleKey = `${scopeKey}#${name}`;
593
+ if (resolving.has(cycleKey)) return undefined;
594
+ resolving.add(cycleKey);
595
+ // `depth` is how many names deep the resolution itself is.
596
+ let answer = foldExpression(facts.value, body, depth + 1);
597
+ resolving.delete(cycleKey);
598
+ if (typeof answer === 'number' && (!facts.range || answer < facts.range[0] || answer > facts.range[1])) {
599
+ answer = undefined;
600
+ }
601
+
602
+ /*
603
+ * Cached even when the answer was cut short by the depth budget. A budget
604
+ * that ran out always answers undefined, which means "cannot tell", which
605
+ * keeps the flow - so the worst a stale one can do is leave a false
606
+ * positive that was already there. Caching a NARROWER answer would be the
607
+ * dangerous direction, and undefined is never narrower.
608
+ */
609
+ perScope.set(name, answer);
610
+ return answer;
611
+ };
612
+
613
+ /*
614
+ * `s.charAt(i)` - THE ONE STRING METHOD, AND WHY ONLY THIS ONE.
615
+ *
616
+ * The note at the top says a folder that models String eventually deletes a
617
+ * real finding, and that is still the policy. This is the exception, taken
618
+ * because 299 of BenchmarkJava's cases decide a switch with it and because it
619
+ * is as exact as arithmetic: a Java String is immutable, so a string literal,
620
+ * or a local declared once with one, has one character at each index forever.
621
+ * An index out of range throws at runtime, and folds to "cannot tell".
622
+ *
623
+ * Nothing else about strings is folded - not length(), not equals(), not
624
+ * substring() - and a string that is a parameter, a field, or written twice
625
+ * is not a string this knows.
626
+ */
627
+ const CHARACTER_LITERALS = new Set(['character_literal', 'char_literal', 'rune_literal']);
628
+
629
+ const characterCode = (raw: string): Folded => {
630
+ // Printable ASCII only, and no escapes: '\n' and 'é' are not worth being wrong about.
631
+ const match = /^'([\x20-\x26\x28-\x5b\x5d-\x7e])'$/.exec(raw.trim());
632
+ return match?.[1] !== undefined ? match[1].charCodeAt(0) : undefined;
633
+ };
634
+
635
+ const stringConstantOf = (node: Node, body: Node, depth: number): string | undefined => {
636
+ if (depth > 12) return undefined;
637
+ let n: Node = node;
638
+ while (n.type === 'parenthesized_expression' && n.namedChildren.length === 1) n = n.namedChildren[0] ?? n;
639
+ if (n.type === 'string_literal') {
640
+ const match = /^"([^"\\]*)"$/.exec(n.text);
641
+ return match?.[1];
642
+ }
643
+ if (n.type !== 'identifier') return undefined;
644
+ const facts = soleWrite(n.text, body);
645
+ if (!facts || !writeReaches(facts, n)) return undefined;
646
+ const cycleKey = `${body.startIndex}:${body.endIndex}#string#${n.text}`;
647
+ if (resolving.has(cycleKey)) return undefined;
648
+ resolving.add(cycleKey);
649
+ const value = stringConstantOf(facts.value, body, depth + 1);
650
+ resolving.delete(cycleKey);
651
+ return value;
652
+ };
653
+
654
+ const charAtOf = (call: Node, body: Node, depth: number): Folded => {
655
+ if (call.childForFieldName('name')?.text !== 'charAt') return undefined;
656
+ const args = (call.childForFieldName('arguments')?.namedChildren ?? []).filter((c): c is Node => c !== null);
657
+ const receiver = call.childForFieldName('object');
658
+ if (args.length !== 1 || !receiver) return undefined;
659
+ const s = stringConstantOf(receiver, body, depth + 1);
660
+ const index = foldExpression(args[0] ?? null, body, depth + 1);
661
+ if (s === undefined || typeof index !== 'number' || index < 0 || index >= s.length) return undefined;
662
+ return s.charCodeAt(index);
663
+ };
664
+
665
+ /*
666
+ * THE OPERATOR IS NOT ALWAYS A FIELD, AND PYTHON IS WHY.
667
+ *
668
+ * Java, JavaScript, TypeScript, Go, PHP and the C family all name the two
669
+ * sides of `a > b` left and right, and the `>` operator. Python names none of
670
+ * the three, because Python allows `1 < x < 5` - a comparison there is a LIST
671
+ * of operands with a LIST of operators, so there is no pair to name.
672
+ *
673
+ * The consequence was total rather than partial: no Python comparison folded
674
+ * anywhere, so every constant-branch decoy in Python stayed a false positive
675
+ * and nothing said so.
676
+ *
677
+ * The fallback reads the shape instead of the field names: exactly three
678
+ * children, the outer two named, the middle one an unnamed token. That token
679
+ * is the operator, whatever the grammar calls it. A chained comparison has
680
+ * five children and returns null, which means "cannot tell", which is the
681
+ * safe direction. `a.b` and `a instanceof B` match the shape and hand over
682
+ * `.` and `instanceof`, neither of which the switch below knows, so they fold
683
+ * to undefined exactly as they did before.
684
+ */
685
+ const binaryParts = (node: Node): { left: Node; right: Node; operator: string } | null => {
686
+ const left = node.childForFieldName('left');
687
+ const right = node.childForFieldName('right');
688
+ const named = node.childForFieldName('operator')?.text ?? '';
689
+ if (left && right && named) return { left, right, operator: named };
690
+
691
+ const children = node.children.filter((c): c is Node => c !== null);
692
+ if (children.length !== 3) return null;
693
+ const [a, op, b] = children;
694
+ if (!a || !op || !b) return null;
695
+ if (!a.isNamed || !b.isNamed || op.isNamed) return null;
696
+ return { left: a, right: b, operator: op.text };
697
+ };
698
+
699
+ /*
700
+ * The same problem one operand down, and this one was not failing safe.
701
+ *
702
+ * Python's not_operator has no operator field either - `not` is an unnamed
703
+ * child, like the `>` above. The unary branch needs an operator field so it
704
+ * skipped, and the single-child unwrap below picked up a node with exactly
705
+ * one named child and returned it. `not True` folded to TRUE.
706
+ *
707
+ * Everything else in this folder fails toward "cannot tell", where the worst
708
+ * outcome is a false positive that was already there. This one failed toward
709
+ * a CONFIDENT WRONG ANSWER: the engine believed a branch ran when it did not,
710
+ * skipped the arm that really ran, and stopped reporting a real flow. That is
711
+ * the failure this tool exists to prevent, and it was sitting inside the
712
+ * feature that was supposed to be about precision.
713
+ *
714
+ * Restricted to the three operators the switch can actually resolve. An
715
+ * unknown leading token is not treated as unary at all, and falls through to
716
+ * the unwrap guard, which now refuses it.
717
+ */
718
+ const UNARY_TOKENS = new Set(['!', 'not', '-']);
719
+
720
+ const unaryParts = (node: Node): { operand: Node; operator: string } | null => {
721
+ const operand = node.childForFieldName('argument') ?? node.childForFieldName('operand');
722
+ const operator = node.childForFieldName('operator')?.text ?? '';
723
+ if (operand && operator) return { operand, operator };
724
+
725
+ const children = node.children.filter((c): c is Node => c !== null);
726
+ if (children.length !== 2) return null;
727
+ const [op, value] = children;
728
+ if (!op || !value || op.isNamed || !value.isNamed) return null;
729
+ if (!UNARY_TOKENS.has(op.text)) return null;
730
+ return { operand: value, operator: op.text };
731
+ };
732
+
733
+ /*
734
+ * Unwrapping a single-child node is only safe when nothing was thrown away.
735
+ *
736
+ * `(x)` discards two brackets and means x. `not x` discards the word `not`
737
+ * and does NOT mean x. Both have one named child, and the old test could not
738
+ * tell them apart - which is how `not True` became true.
739
+ *
740
+ * So the test is on what gets discarded rather than on the node's name: every
741
+ * unnamed child must be pure punctuation. A discarded WORD, or any symbol not
742
+ * on this list, means the node transforms its operand in a way this folder
743
+ * has not been taught, and the answer is "cannot tell".
744
+ *
745
+ * `$` is here for PHP, whose variable_name wraps a bare name in a `$` token.
746
+ */
747
+ const WRAPPER_PUNCTUATION = new Set(['(', ')', '[', ']', '{', '}', ',', ';', '$']);
748
+
749
+ /*
750
+ * CHECK THE OPERATOR BEFORE FOLDING THE OPERANDS, AND THIS IS NOT A TIDY-UP.
751
+ *
752
+ * Reading the operator off the shape instead of a field made binaryParts
753
+ * match things it was never meant to: `a.b` is three children with the outer
754
+ * two named, so is `a instanceof B`, so is every member access in every
755
+ * minified file on earth. The switch below has no case for `.`, so all of
756
+ * them still folded to undefined and the ANSWER was never wrong.
757
+ *
758
+ * The cost was. Folding an operand that turns out to be a bare name calls
759
+ * constantOfName, and constantOfName walks the entire enclosing function
760
+ * looking for assignments. DVWA ships an inlined sha256 - one 19KB function,
761
+ * thousands of member accesses - and the scan went from finishing to running
762
+ * out of memory on 1.1MB of source with 7GB free.
763
+ *
764
+ * It did not show up in the suite, the benchmark, or the metamorphic run.
765
+ * Fixture files are small and tidy, and BenchmarkJava's generated cases are
766
+ * about forty lines each; this needed one real file written by somebody
767
+ * optimising for size. The corpus check is in the repo precisely so that
768
+ * "all tests pass" is not the last word, and this is the first time it has
769
+ * caught something the tests could not.
770
+ *
771
+ * So the operator is checked first. An operator with no case cannot change
772
+ * any answer, and now it does not cost a tree walk to find that out.
773
+ */
774
+ const FOLDABLE_OPERATORS = new Set([
775
+ '+', '-', '*', '/', '%',
776
+ '>', '<', '>=', '<=', '==', '!=',
777
+ '&&', 'and', '||', 'or',
778
+ ]);
779
+
780
+ const INTEGER_DIVISION = language === 'java' || language === 'c' || language === 'cpp' || language === 'go';
781
+ /*
782
+ * Where arithmetic stops being exact. Java, C, C++ and Go wrap at their type's
783
+ * width - checked here at 32 bits, the narrowest `int` any of them uses - and
784
+ * JavaScript, Python and PHP lose integer precision past 2^53. Outside that,
785
+ * "cannot tell".
786
+ */
787
+ const inRange = (value: number): Folded =>
788
+ INTEGER_DIVISION
789
+ ? value >= -2147483648 && value <= 2147483647 ? value : undefined
790
+ : Math.abs(value) <= 2 ** 53 ? value : undefined;
791
+
792
+ const foldExpression = (node: Node | null, body: Node, depth: number): Folded => {
793
+ if (!node || depth > 12) return undefined;
794
+
795
+ // 'B' is 66 in Java, C, C++ and Go, where a character IS an integer. Only
796
+ // the grammars that have a character literal get this; a quoted 'B' in
797
+ // JavaScript, Python or PHP is a string, and strings are not folded.
798
+ if (CHARACTER_LITERALS.has(node.type)) return characterCode(node.text);
799
+ if (language === 'java' && node.type === 'method_invocation') return charAtOf(node, body, depth);
800
+
801
+ const binary = binaryParts(node);
802
+
803
+ if (binary) {
804
+ const { left, right, operator } = binary;
805
+ if (!FOLDABLE_OPERATORS.has(operator)) return undefined;
806
+ const a = foldExpression(left, body, depth + 1);
807
+ const b = foldExpression(right, body, depth + 1);
808
+ if (a === undefined || b === undefined) return undefined;
809
+ if (typeof a === 'number' && typeof b === 'number') {
810
+ switch (operator) {
811
+ case '+': return inRange(a + b);
812
+ case '-': return inRange(a - b);
813
+ case '*': return inRange(a * b);
814
+ // Integer division truncates in Java, C, C++ and Go. In JavaScript,
815
+ // Python and PHP `7 / 2` is 3.5, and in Python `-7 % 3` is 2.
816
+ case '/': return b === 0 || !INTEGER_DIVISION ? undefined : inRange(Math.trunc(a / b));
817
+ case '%': return b === 0 || (language === 'python' && (a < 0 || b < 0)) ? undefined : inRange(a % b);
818
+ case '>': return a > b;
819
+ case '<': return a < b;
820
+ case '>=': return a >= b;
821
+ case '<=': return a <= b;
822
+ case '==': return a === b;
823
+ case '!=': return a !== b;
824
+ default: return undefined;
825
+ }
826
+ }
827
+ if (typeof a === 'boolean' && typeof b === 'boolean') {
828
+ switch (operator) {
829
+ case '&&': case 'and': return a && b;
830
+ case '||': case 'or': return a || b;
831
+ case '==': return a === b;
832
+ case '!=': return a !== b;
833
+ default: return undefined;
834
+ }
835
+ }
836
+ return undefined;
837
+ }
838
+
839
+ // `!x`, Python's `not x`, and unary minus.
840
+ const unary = unaryParts(node);
841
+ if (unary) {
842
+ const value = foldExpression(unary.operand, body, depth + 1);
843
+ if ((unary.operator === '!' || unary.operator === 'not') && typeof value === 'boolean') {
844
+ return !value;
845
+ }
846
+ if (unary.operator === '-' && typeof value === 'number') return inRange(-value);
847
+ return undefined;
848
+ }
849
+
850
+ // Parentheses and other single-child wrappers - see WRAPPER_PUNCTUATION.
851
+ const named = node.namedChildren.filter((c): c is Node => c !== null);
852
+ if (named.length === 1) {
853
+ const discardsOnlyPunctuation = node.children.every(
854
+ (c) => c === null || c.isNamed || WRAPPER_PUNCTUATION.has(c.text),
855
+ );
856
+ if (discardsOnlyPunctuation) return foldExpression(named[0] ?? null, body, depth + 1);
857
+ return undefined;
858
+ }
859
+
860
+ if (named.length === 0) {
861
+ const literal = foldLiteral(text(node));
862
+ if (literal !== undefined) return literal;
863
+ /*
864
+ * A bare word that is not a literal is a name, and asking whether it is
865
+ * bound to a constant is the whole point of constantOfName.
866
+ *
867
+ * This used to test `/identifier/` against the NODE TYPE, which is true
868
+ * of Java's `identifier` and JavaScript's `identifier` and false of PHP -
869
+ * a PHP variable is a `variable_name` wrapping a `name`, and neither
870
+ * spelling contains the word. So no PHP variable folded, and every PHP
871
+ * condition mentioning one was undecidable.
872
+ *
873
+ * Testing the TEXT covers all eight without a list of node names to keep
874
+ * up to date. A keyword like `null` or `nil` reaches constantOfName,
875
+ * finds no assignment, and returns undefined - the same answer it gave
876
+ * before, by a shorter route.
877
+ */
878
+ if (/^[A-Za-z_][\w]*$/.test(text(node).trim())) {
879
+ return constantOfName(text(node), body, depth, node);
880
+ }
881
+ return undefined;
882
+ }
883
+
884
+ return undefined;
885
+ };
886
+
887
+ /*
888
+ * THE THREE PARTS OF A TERNARY, WHICH NOT EVERY GRAMMAR AGREES TO NAME.
889
+ *
890
+ * The first version of the folder read `condition`, `consequence` and
891
+ * `alternative` as fields, which is right for Java, JavaScript, TypeScript,
892
+ * Go and the C family - and quietly wrong for two of the eight:
893
+ *
894
+ * python no fields at all, children in WRITTEN order: A if C else B,
895
+ * so the middle child is the condition and the first is the value
896
+ * php names `condition` and `alternative`, but not `consequence`
897
+ *
898
+ * Measured, not assumed. The folder was written against Java, verified
899
+ * against Java, and would have shipped carrying a language-shaped hole -
900
+ * Python would have kept tracing into dead branches while every other
901
+ * language stopped, and nothing would have failed to say so.
902
+ *
903
+ * Python is matched on the language id rather than on a shape heuristic like
904
+ * "no condition field and three children". A heuristic would be one grammar
905
+ * away from silently reading a condition as a value, and that error deletes
906
+ * findings rather than adding them.
907
+ */
908
+ const ternaryParts = (
909
+ node: Node,
910
+ ): { condition: Node | null; consequence: Node | null; alternative: Node | null } => {
911
+ const named = node.namedChildren.filter((c): c is Node => c !== null);
912
+
913
+ if (language === 'python' && named.length === 3) {
914
+ return {
915
+ consequence: named[0] ?? null,
916
+ condition: named[1] ?? null,
917
+ alternative: named[2] ?? null,
918
+ };
919
+ }
920
+
921
+ const condition = node.childForFieldName('condition');
922
+ const alternative = node.childForFieldName('alternative');
923
+ let consequence = node.childForFieldName('consequence');
924
+
925
+ // PHP leaves the middle one unnamed; it is whichever child is left over.
926
+ if (!consequence && condition) {
927
+ const same = (a: Node, b: Node | null): boolean =>
928
+ b !== null && a.startIndex === b.startIndex && a.endIndex === b.endIndex;
929
+ consequence = named.find((c) => !same(c, condition) && !same(c, alternative)) ?? null;
930
+ /*
931
+ * `$a ?: $b` has two operands, not three, so there IS no left-over child
932
+ * and the search above finds nothing. In that form the value when the
933
+ * condition holds is the condition itself.
934
+ *
935
+ * Falling back to the condition rather than to null matters because null
936
+ * would make a decidable elvis select NOTHING, and selecting nothing is
937
+ * how a folder deletes a flow instead of merely failing to narrow one.
938
+ */
939
+ consequence ??= condition;
940
+ }
941
+
942
+ return { condition, consequence, alternative };
943
+ };
944
+
945
+ /** True or false when the condition is decidable, undefined when it is not. */
946
+ const conditionTruth = (node: Node | null, body: Node): boolean | undefined => {
947
+ const value = foldExpression(node, body, 0);
948
+ return typeof value === 'boolean' ? value : undefined;
949
+ };
950
+
951
+ /**
952
+ * The branch of an if-statement that certainly does not run, if any.
953
+ *
954
+ * `consequence ?? body` is PHP, and it is the reason this was HALF broken
955
+ * rather than broken - which is worse, because half a feature looks like a
956
+ * working one. PHP names the else `alternative` like everyone else, so a
957
+ * condition that folded TRUE correctly dropped the dead else. It calls the
958
+ * then-block `body`, not `consequence`, so a condition that folded FALSE
959
+ * asked for a field that does not exist, got null, and kept the dead arm.
960
+ *
961
+ * Same statement, same file, same folder: one direction fixed, one direction
962
+ * silently untouched. Nothing failed, because "no dead branch here" is a
963
+ * perfectly ordinary answer.
964
+ */
965
+ const deadBranchOf = (node: Node, body: Node): Node | null => {
966
+ const truth = conditionTruth(node.childForFieldName('condition'), body);
967
+ if (truth === undefined) return null;
968
+ return truth
969
+ ? node.childForFieldName('alternative')
970
+ : (node.childForFieldName('consequence') ?? node.childForFieldName('body'));
971
+ };
972
+
973
+ /*
974
+ * WHICH CASES OF A SWITCH CAN RUN.
975
+ *
976
+ * Decided only when the selector AND every case label fold to a number or a
977
+ * boolean of the same kind. One label the folder cannot read - an enum
978
+ * constant, a string, a pattern - and the whole switch is undecided, because
979
+ * that label might be the one that matches.
980
+ *
981
+ * From the matching case (or `default` when none matches, or nothing at all
982
+ * when there is no default), execution runs on into the next case until a
983
+ * `break`, `return`, `throw` or `continue` sits DIRECTLY in a case - one
984
+ * nested inside an `if` might not run, so it does not stop the fall. Go and
985
+ * Java's arrow cases do not fall through; Go's explicit `fallthrough` does.
986
+ * Every case outside that run is dead.
987
+ *
988
+ * C has one more way in: `goto`. A label inside the switch can be jumped to
989
+ * whatever the selector says, and so can a case label buried inside a nested
990
+ * block (Duff's device). Either one, and the switch is left undecided.
991
+ *
992
+ * `certain` answers a narrower question for the tracer: does this statement
993
+ * run EVERY time the switch does? Only if it is in the run and no jump of any
994
+ * kind - nested or not - comes before it in the run. A clean write that is
995
+ * certain may clear a value; one that is merely possible may not.
996
+ */
997
+ const switchDecision = (node: Node, body: Node): SwitchDecision | null => {
998
+ const shape = switchShape(node);
999
+ if (!shape || !shape.selector) return null;
1000
+ const selector = foldExpression(shape.selector, body, 0);
1001
+ if (selector === undefined) return null;
1002
+
1003
+ let matched = -1;
1004
+ let defaultIndex = -1;
1005
+ for (let i = 0; i < shape.groups.length; i++) {
1006
+ const group = shape.groups[i];
1007
+ if (!group) return null;
1008
+ if (group.labels === 'default') {
1009
+ if (defaultIndex === -1) defaultIndex = i;
1010
+ continue;
1011
+ }
1012
+ for (const label of group.labels) {
1013
+ const value = foldExpression(label, body, 0);
1014
+ if (value === undefined || typeof value !== typeof selector) return null;
1015
+ if (matched === -1 && value === selector) matched = i;
1016
+ }
1017
+ }
1018
+ const start = matched !== -1 ? matched : defaultIndex;
1019
+
1020
+ const live: number[] = [];
1021
+ if (start !== -1) {
1022
+ for (let i = start; i < shape.groups.length; i++) {
1023
+ const group = shape.groups[i];
1024
+ if (!group) break;
1025
+ live.push(i);
1026
+ if (shape.fallthrough === 'never') break;
1027
+ if (shape.fallthrough === 'explicit') {
1028
+ if (group.statements[group.statements.length - 1]?.type !== 'fallthrough_statement') break;
1029
+ continue;
1030
+ }
1031
+ if (group.statements.some((s) => TERMINATORS.has(s.type))) break;
1032
+ }
1033
+ }
1034
+ const liveSet = new Set(live);
1035
+ const dead = shape.groups.filter((_, i) => !liveSet.has(i)).map((g) => g.node);
1036
+ const inside = (outer: Node, inner: Node): boolean =>
1037
+ inner.startIndex >= outer.startIndex && inner.endIndex <= outer.endIndex;
1038
+
1039
+ const certain = (n: Node): boolean => {
1040
+ for (const i of live) {
1041
+ const group = shape.groups[i];
1042
+ if (!group) return false;
1043
+ const jumpBefore = containsJumpBefore(group.node, n.startIndex);
1044
+ if (inside(group.node, n)) return !jumpBefore;
1045
+ if (jumpBefore) return false;
1046
+ }
1047
+ return false;
1048
+ };
1049
+
1050
+ const matchedGroup = matched !== -1 ? shape.groups[matched] : undefined;
1051
+ const line = node.startPosition.row + 1;
1052
+ const note =
1053
+ matchedGroup && matchedGroup.labels !== 'default'
1054
+ ? `the switch on line ${line} always takes \`case ${text(matchedGroup.labels.find((l) => foldExpression(l, body, 0) === selector) ?? null)}\``
1055
+ : start !== -1
1056
+ ? `the switch on line ${line} matches no case, so only \`default\` runs`
1057
+ : `the switch on line ${line} matches no case and has no default, so none of it runs`;
1058
+
1059
+ return { dead, certain, note };
1060
+ };
1061
+
1062
+ return { foldExpression, conditionTruth, ternaryParts, deadBranchOf, switchDecision, writtenOtherwise, plainWriteCount };
1063
+ }
1064
+
1065
+ /** What a decided switch tells the tracer and the constant proof. */
1066
+ export interface SwitchDecision {
1067
+ /** Case groups that cannot run. */
1068
+ readonly dead: readonly Node[];
1069
+ /** Whether a node inside the switch runs every time the switch does. */
1070
+ readonly certain: (node: Node) => boolean;
1071
+ /** A sentence for a receipt. */
1072
+ readonly note: string;
1073
+ }
1074
+
1075
+ /** Every switch node type, in every grammar here - decided or not. */
1076
+ export const SWITCH_NODES = new Set([
1077
+ 'switch_statement', // JS, TS, C, C++, PHP
1078
+ 'switch_expression', // Java - the statement form too
1079
+ 'expression_switch_statement', // Go
1080
+ ]);
1081
+
1082
+ /** A statement that leaves a switch when it sits directly in a case. */
1083
+ const TERMINATORS = new Set([
1084
+ 'break_statement',
1085
+ 'continue_statement',
1086
+ 'return_statement',
1087
+ 'throw_statement',
1088
+ 'yield_statement',
1089
+ 'goto_statement',
1090
+ ]);
1091
+
1092
+ /** Any statement that can leave early, wherever it is nested. */
1093
+ const JUMPS = new Set([...TERMINATORS, 'labeled_statement']);
1094
+
1095
+ function containsJumpBefore(node: Node, before: number): boolean {
1096
+ if (node.startIndex >= before) return false;
1097
+ if (JUMPS.has(node.type)) return true;
1098
+ for (const child of node.namedChildren) {
1099
+ if (child && child.startIndex < before && containsJumpBefore(child, before)) return true;
1100
+ }
1101
+ return false;
1102
+ }
1103
+
1104
+ interface SwitchGroup {
1105
+ readonly node: Node;
1106
+ readonly labels: readonly Node[] | 'default';
1107
+ readonly statements: readonly Node[];
1108
+ }
1109
+
1110
+ interface SwitchShape {
1111
+ readonly selector: Node | null;
1112
+ readonly groups: readonly SwitchGroup[];
1113
+ readonly fallthrough: 'c-style' | 'never' | 'explicit';
1114
+ }
1115
+
1116
+ const named = (n: Node | null | undefined): Node[] =>
1117
+ (n?.namedChildren ?? []).filter((c): c is Node => c !== null);
1118
+
1119
+ /**
1120
+ * Read a switch into one shape, whatever the grammar calls its parts. Five
1121
+ * grammars, five vocabularies - measured from each grammar's own trees, not
1122
+ * assumed from one. Null means "not a shape this knows", and an unknown shape
1123
+ * is an undecided switch.
1124
+ */
1125
+ function switchShape(node: Node): SwitchShape | null {
1126
+ const defaultOf = (n: Node): boolean => /^default\b/.test(n.text.trim());
1127
+
1128
+ // Java: switch_block of statement groups (falls through) or arrow rules (never).
1129
+ if (node.type === 'switch_expression') {
1130
+ const block = node.childForFieldName('body');
1131
+ const kids = named(block);
1132
+ const isGroup = (k: Node): boolean => k.type === 'switch_block_statement_group';
1133
+ const isRule = (k: Node): boolean => k.type === 'switch_rule';
1134
+ if (kids.length === 0 || !(kids.every(isGroup) || kids.every(isRule))) return null;
1135
+ const groups: SwitchGroup[] = [];
1136
+ for (const k of kids) {
1137
+ const labels = named(k).filter((c) => c.type === 'switch_label');
1138
+ const statements = named(k).filter((c) => c.type !== 'switch_label');
1139
+ if (labels.length === 0) return null;
1140
+ if (labels.some(defaultOf)) {
1141
+ if (labels.length > 1) return null; // `case 1: default:` - keep it simple
1142
+ groups.push({ node: k, labels: 'default', statements });
1143
+ } else {
1144
+ groups.push({ node: k, labels: labels.flatMap((l) => named(l)), statements });
1145
+ }
1146
+ }
1147
+ return {
1148
+ selector: node.childForFieldName('condition'),
1149
+ groups,
1150
+ fallthrough: kids.every(isRule) ? 'never' : 'c-style',
1151
+ };
1152
+ }
1153
+
1154
+ // Go: cases directly under the switch; `fallthrough` must be asked for.
1155
+ if (node.type === 'expression_switch_statement') {
1156
+ const groups: SwitchGroup[] = [];
1157
+ for (const k of named(node)) {
1158
+ if (k.type === 'expression_case') {
1159
+ const value = k.childForFieldName('value');
1160
+ const labels = value?.type === 'expression_list' ? named(value) : value ? [value] : [];
1161
+ if (labels.length === 0) return null;
1162
+ groups.push({ node: k, labels, statements: named(k).filter((c) => !value || c.startIndex !== value.startIndex) });
1163
+ } else if (k.type === 'default_case') {
1164
+ groups.push({ node: k, labels: 'default', statements: named(k) });
1165
+ }
1166
+ }
1167
+ return { selector: node.childForFieldName('value'), groups, fallthrough: 'explicit' };
1168
+ }
1169
+
1170
+ if (node.type !== 'switch_statement') return null;
1171
+ const block = node.childForFieldName('body');
1172
+ const selector = node.childForFieldName('condition') ?? node.childForFieldName('value');
1173
+ const kids = named(block);
1174
+ const groups: SwitchGroup[] = [];
1175
+
1176
+ // A label inside the switch, or a case label that is not directly in its
1177
+ // body, is a way in that the selector does not control. Decline.
1178
+ const hasHiddenEntry = (n: Node, depthInBody: number, inNestedSwitch: boolean): boolean => {
1179
+ for (const c of named(n)) {
1180
+ if (c.type === 'labeled_statement') return true;
1181
+ if (!inNestedSwitch && depthInBody > 0 && (c.type === 'case_statement' || c.type === 'switch_case')) return true;
1182
+ // A nested switch's cases are its own; a label inside it is still a way in.
1183
+ if (hasHiddenEntry(c, depthInBody + 1, inNestedSwitch || SWITCH_NODES.has(c.type))) return true;
1184
+ }
1185
+ return false;
1186
+ };
1187
+ if (!block || hasHiddenEntry(block, 0, false)) return null;
1188
+
1189
+ for (const k of kids) {
1190
+ if (k.type === 'switch_case' || k.type === 'case_statement' || k.type === 'switch_default' || k.type === 'default_statement') {
1191
+ const value = k.childForFieldName('value');
1192
+ // GNU's `case 1 ... 5:` is a RANGE, and the grammar reads it as `case 1`
1193
+ // followed by an error. A label that did not parse cleanly is not read.
1194
+ if (value && (value.hasError || /\.\.\./.test(k.text.split(':')[0] ?? ''))) return null;
1195
+ if (k.hasError) return null;
1196
+ const isDefault = k.type === 'switch_default' || k.type === 'default_statement' || (!value && defaultOf(k));
1197
+ const statements = named(k).filter((c) => !value || c.startIndex !== value.startIndex);
1198
+ if (isDefault) groups.push({ node: k, labels: 'default', statements });
1199
+ else if (value) groups.push({ node: k, labels: [value], statements });
1200
+ else return null;
1201
+ } else if (k.type !== 'comment') {
1202
+ return null; // a statement before the first case, or a shape we do not know
1203
+ }
1204
+ }
1205
+ return { selector, groups, fallthrough: 'c-style' };
1206
+ }