@backbencher/brain 0.0.0-stage → 0.1.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 (319) hide show
  1. package/CHANGELOG.md +298 -0
  2. package/EMBEDDINGS.md +355 -0
  3. package/LICENSE +21 -0
  4. package/README.md +415 -2
  5. package/bin/bb +13 -0
  6. package/dist/analysis-exclusions-7ZJDXOVZ.js +35 -0
  7. package/dist/brain-index-health-KY7K552E.js +17 -0
  8. package/dist/brain-relationship-enrichment-QH7OOHUF.js +558 -0
  9. package/dist/brain.sql-EIM4WS4D.js +29 -0
  10. package/dist/chunk-2BSKD7O3.js +40 -0
  11. package/dist/chunk-2DE27DVC.js +1314 -0
  12. package/dist/chunk-2RRBMNBQ.js +322 -0
  13. package/dist/chunk-2RTN2DFU.js +711 -0
  14. package/dist/chunk-2WXYNLBA.js +29 -0
  15. package/dist/chunk-3CTFIJJB.js +17 -0
  16. package/dist/chunk-3LFTSMIV.js +174 -0
  17. package/dist/chunk-47HKX4AJ.js +930 -0
  18. package/dist/chunk-4H22TLOT.js +543 -0
  19. package/dist/chunk-5SD45FLC.js +818 -0
  20. package/dist/chunk-66A5ZFYA.js +1758 -0
  21. package/dist/chunk-6AIRWAO2.js +247 -0
  22. package/dist/chunk-6WNGD3NN.js +196 -0
  23. package/dist/chunk-7AN7U5IZ.js +371 -0
  24. package/dist/chunk-BHB7BYYI.js +626 -0
  25. package/dist/chunk-BQ3OVQ3P.js +125 -0
  26. package/dist/chunk-CW4XXSNN.js +1479 -0
  27. package/dist/chunk-D2Q4Y47A.js +53 -0
  28. package/dist/chunk-DIYAGQJL.js +34 -0
  29. package/dist/chunk-DYM4FQEE.js +90 -0
  30. package/dist/chunk-EWMWK7WN.js +75 -0
  31. package/dist/chunk-FVVQTSIM.js +3427 -0
  32. package/dist/chunk-FWSL4VG6.js +1290 -0
  33. package/dist/chunk-GBI7V7KY.js +69 -0
  34. package/dist/chunk-GEFFO6O2.js +152 -0
  35. package/dist/chunk-HOWAQXJO.js +236 -0
  36. package/dist/chunk-HQSPJDHI.js +613 -0
  37. package/dist/chunk-IFZNR5UJ.js +2863 -0
  38. package/dist/chunk-J4PMBXQQ.js +336 -0
  39. package/dist/chunk-KAET47ZL.js +125 -0
  40. package/dist/chunk-KCDECRJY.js +440 -0
  41. package/dist/chunk-KLK3PBTO.js +1119 -0
  42. package/dist/chunk-L5TDYKYK.js +76 -0
  43. package/dist/chunk-MLKGABMK.js +9 -0
  44. package/dist/chunk-OWISBAOF.js +61 -0
  45. package/dist/chunk-PJTNGD3Z.js +6924 -0
  46. package/dist/chunk-QLK6ED4Q.js +235 -0
  47. package/dist/chunk-R2GW4F6R.js +19 -0
  48. package/dist/chunk-RCMTFSEF.js +87 -0
  49. package/dist/chunk-RH7KC5HU.js +12050 -0
  50. package/dist/chunk-RM3PY6TG.js +376 -0
  51. package/dist/chunk-ROMM6KMC.js +433 -0
  52. package/dist/chunk-SK6YVHFZ.js +211 -0
  53. package/dist/chunk-SM7RFQRO.js +214 -0
  54. package/dist/chunk-UFE77L67.js +782 -0
  55. package/dist/chunk-UGR4QIGS.js +67 -0
  56. package/dist/chunk-UUNHHX4M.js +13 -0
  57. package/dist/chunk-V44PGPA6.js +2104 -0
  58. package/dist/chunk-VL52PL2T.js +71 -0
  59. package/dist/chunk-VURYFIHW.js +201 -0
  60. package/dist/chunk-XJKJYIPP.js +7436 -0
  61. package/dist/chunk-YI6FF2ZR.js +340 -0
  62. package/dist/cli/index.js +37 -0
  63. package/dist/config-HZAAYDP3.js +10 -0
  64. package/dist/content-search-642F7E5V.js +593 -0
  65. package/dist/database-TCTZDLE5.js +9 -0
  66. package/dist/doctor-GXCKSJOX.js +395 -0
  67. package/dist/edit-engine-KM7MPFEC.js +51 -0
  68. package/dist/engine-42TZFNIZ.js +11 -0
  69. package/dist/engine-LO6L4JLQ.js +18 -0
  70. package/dist/fingerprint-XDXFRWO6.js +33 -0
  71. package/dist/framework-intelligence-DWFCKV44.js +860 -0
  72. package/dist/freshness-3K6VU5OT.js +174 -0
  73. package/dist/graph-engine-PL5YA7BA.js +21 -0
  74. package/dist/graph_request-MM4REP6H.js +350 -0
  75. package/dist/impact_request-WDE47IZ5.js +68 -0
  76. package/dist/index-space-IUUN5WYV.js +15 -0
  77. package/dist/index-status-6BD7UBHR.js +34 -0
  78. package/dist/index.js +159 -0
  79. package/dist/instance-FO6QZRLH.js +19 -0
  80. package/dist/language-detection-DFCAU5DZ.js +11 -0
  81. package/dist/lsp/server.js +4301 -0
  82. package/dist/mcp/server.js +132 -0
  83. package/dist/onboard-DIQZV2NW.js +161 -0
  84. package/dist/override-companion-pass-XR7MHTP7.js +117 -0
  85. package/dist/parallel-indexer-4PW3AFXP.js +88 -0
  86. package/dist/pool-worker.js +1153 -0
  87. package/dist/project-health-ZS5TS2Z6.js +94 -0
  88. package/dist/project-index-5SDPHITE.js +57 -0
  89. package/dist/project-registry-DGZBIZS7.js +355 -0
  90. package/dist/real-bash-analyzer-SXSV67E2.js +2201 -0
  91. package/dist/real-c-analyzer-SY7FTZ6W.js +5942 -0
  92. package/dist/real-clean-analyzer-IGDHHLZK.js +3470 -0
  93. package/dist/real-clojure-analyzer-4OH7IWLB.js +2818 -0
  94. package/dist/real-cpp-analyzer-7LTDRJRJ.js +4101 -0
  95. package/dist/real-csharp-analyzer-KICU3TLH.js +3298 -0
  96. package/dist/real-css-analyzer-HPU277Q6.js +15 -0
  97. package/dist/real-dart-analyzer-7LZ5LCMW.js +3944 -0
  98. package/dist/real-elixir-analyzer-BE3MEVZO.js +2192 -0
  99. package/dist/real-erlang-analyzer-BZMAKYBK.js +1731 -0
  100. package/dist/real-fsharp-analyzer-5QLTOV3E.js +2390 -0
  101. package/dist/real-go-analyzer-POD7MZQR.js +4126 -0
  102. package/dist/real-graphql-analyzer-MAQRZ6QW.js +1442 -0
  103. package/dist/real-groovy-analyzer-MEWSUPMJ.js +2285 -0
  104. package/dist/real-haskell-analyzer-I5L2GR3Y.js +2533 -0
  105. package/dist/real-hcl-analyzer-DBOPXSWJ.js +725 -0
  106. package/dist/real-html-analyzer-O63XZLZD.js +1759 -0
  107. package/dist/real-java-analyzer-CHCW3EKA.js +4677 -0
  108. package/dist/real-javascript-analyzer-QAAJQYZE.js +14 -0
  109. package/dist/real-json-analyzer-UGBR7YQN.js +1640 -0
  110. package/dist/real-julia-analyzer-35Q4GHOZ.js +2085 -0
  111. package/dist/real-kotlin-analyzer-GRAYCWW2.js +3956 -0
  112. package/dist/real-lua-analyzer-5BAPDZDF.js +1957 -0
  113. package/dist/real-makefile-analyzer-YJKAHNAS.js +920 -0
  114. package/dist/real-markdown-analyzer-UKUQZO4F.js +1215 -0
  115. package/dist/real-matlab-analyzer-FVIKQZKG.js +480 -0
  116. package/dist/real-nim-analyzer-M7FAIABG.js +3603 -0
  117. package/dist/real-objective-c-analyzer-XMYQIGQI.js +1518 -0
  118. package/dist/real-perl-analyzer-ZLY6M2DZ.js +1865 -0
  119. package/dist/real-php-analyzer-KI2ZAM7X.js +9368 -0
  120. package/dist/real-powershell-analyzer-B3T5ZP6U.js +2915 -0
  121. package/dist/real-protobuf-analyzer-23OZ75XV.js +867 -0
  122. package/dist/real-python-analyzer-I4TIUB6D.js +4880 -0
  123. package/dist/real-r-analyzer-FJZJXXS6.js +2596 -0
  124. package/dist/real-ruby-analyzer-M3UFIVTQ.js +2429 -0
  125. package/dist/real-rust-analyzer-LVSONPAN.js +3359 -0
  126. package/dist/real-scala-analyzer-CWPI33Q2.js +3293 -0
  127. package/dist/real-scss-analyzer-TGRJQ7TH.js +1515 -0
  128. package/dist/real-solidity-analyzer-4P2VBVWA.js +2608 -0
  129. package/dist/real-sql-analyzer-J7DYZOZR.js +3840 -0
  130. package/dist/real-svelte-analyzer-NHEN2SX5.js +2532 -0
  131. package/dist/real-swift-analyzer-2NSYULWI.js +2311 -0
  132. package/dist/real-toml-analyzer-L3C66L7Q.js +929 -0
  133. package/dist/real-typescript-analyzer-FTC6ETRQ.js +14 -0
  134. package/dist/real-vb-analyzer-ORY7LAQS.js +3145 -0
  135. package/dist/real-vba-analyzer-JH2TTUFI.js +466 -0
  136. package/dist/real-vue-analyzer-OO4FUGBV.js +2680 -0
  137. package/dist/real-xml-analyzer-6CQVR477.js +2405 -0
  138. package/dist/real-yaml-analyzer-J6MQG3YX.js +3528 -0
  139. package/dist/real-zig-analyzer-EFKCPJNU.js +2430 -0
  140. package/dist/regex-search-OPTLKUN5.js +239 -0
  141. package/dist/rules-I453JTRC.js +152 -0
  142. package/dist/scoped-path-MJYWJZQX.js +11 -0
  143. package/dist/setup-show-Y5GB5KXA.js +15 -0
  144. package/dist/setup-wizard-G5YYR5T6.js +24 -0
  145. package/dist/symbol-search-KB4MU4SI.js +16 -0
  146. package/dist/taint-YOTN5X55.js +202 -0
  147. package/dist/taint-analyzer-N7YLZU3N.js +163 -0
  148. package/dist/tool-handlers-G4NAGJIH.js +72 -0
  149. package/dist/tool-registry-BPEJNSZG.js +17 -0
  150. package/dist/tool-validation-RF675A3C.js +32 -0
  151. package/dist/type-inference-4Y7WTMFE.js +13 -0
  152. package/dist/vector-binary-LMY4QGAN.js +19 -0
  153. package/dist/version-LXGNFO37.js +9 -0
  154. package/dist/wasm-NMWO3WXY.js +16 -0
  155. package/docs/EMBEDDINGS.md +132 -0
  156. package/docs/README.md +178 -0
  157. package/docs/data/m10-baseline.json +602 -0
  158. package/docs/data/m10-benchmark-baseline.json +57 -0
  159. package/docs/data/m10-benchmark-last-run.json +127 -0
  160. package/docs/data/m10-final-audit.json +215 -0
  161. package/docs/data/m12-final-audit.json +138 -0
  162. package/docs/data/m12-surface-matrix.json +50 -0
  163. package/docs/data/public-tools.json +1902 -0
  164. package/docs/indexing/DEAD-ASSETS.md +88 -0
  165. package/docs/indexing/INDEXER-REVIEW.md +634 -0
  166. package/docs/indexing/PERFORMANCE-FINDINGS.md +106 -0
  167. package/docs/indexing/README.md +427 -0
  168. package/docs/indexing/discovery-matrix.md +41 -0
  169. package/docs/indexing/embeddings.md +325 -0
  170. package/docs/indexing/environment-variables.md +77 -0
  171. package/docs/indexing/performance.md +323 -0
  172. package/docs/indexing/resume.md +345 -0
  173. package/docs/indexing/troubleshooting.md +391 -0
  174. package/docs/man/bb.1 +190 -0
  175. package/docs/tools/bb_analyze.md +147 -0
  176. package/docs/tools/bb_annotate.md +110 -0
  177. package/docs/tools/bb_annotations.md +106 -0
  178. package/docs/tools/bb_ask.md +101 -0
  179. package/docs/tools/bb_cross_language.md +103 -0
  180. package/docs/tools/bb_doc.md +169 -0
  181. package/docs/tools/bb_edit.md +185 -0
  182. package/docs/tools/bb_graph.md +169 -0
  183. package/docs/tools/bb_health.md +156 -0
  184. package/docs/tools/bb_impact.md +117 -0
  185. package/docs/tools/bb_index.md +108 -0
  186. package/docs/tools/bb_migrate.md +135 -0
  187. package/docs/tools/bb_patterns.md +140 -0
  188. package/docs/tools/bb_search.md +150 -0
  189. package/docs/tools/bb_security.md +100 -0
  190. package/docs/tools/bb_visualize.md +184 -0
  191. package/grammars/README.md +57 -0
  192. package/grammars/THIRD-PARTY-NOTICES.md +50 -0
  193. package/grammars/tree-sitter-c/LICENSE +21 -0
  194. package/grammars/tree-sitter-clojure/LICENSE.md +21 -0
  195. package/grammars/tree-sitter-cpp/LICENSE +21 -0
  196. package/grammars/tree-sitter-csharp/LICENSE +21 -0
  197. package/grammars/tree-sitter-css/LICENSE +21 -0
  198. package/grammars/tree-sitter-css/README.md +21 -0
  199. package/grammars/tree-sitter-dart/LICENSE +20 -0
  200. package/grammars/tree-sitter-dart-upstream/LICENSE +20 -0
  201. package/grammars/tree-sitter-dart-upstream/README.md +18 -0
  202. package/grammars/tree-sitter-elixir/LICENSE +176 -0
  203. package/grammars/tree-sitter-elixir/README.md +11 -0
  204. package/grammars/tree-sitter-fsharp/LICENSE +21 -0
  205. package/grammars/tree-sitter-fsharp/README.md +45 -0
  206. package/grammars/tree-sitter-graphql/LICENSE +21 -0
  207. package/grammars/tree-sitter-graphql/README.md +106 -0
  208. package/grammars/tree-sitter-groovy/LICENSE +21 -0
  209. package/grammars/tree-sitter-groovy/README.md +4 -0
  210. package/grammars/tree-sitter-haskell/LICENSE +21 -0
  211. package/grammars/tree-sitter-haskell/README.md +394 -0
  212. package/grammars/tree-sitter-hcl/CHANGELOG.md +102 -0
  213. package/grammars/tree-sitter-hcl/LICENSE +201 -0
  214. package/grammars/tree-sitter-hcl/README.md +39 -0
  215. package/grammars/tree-sitter-html/LICENSE +21 -0
  216. package/grammars/tree-sitter-html/README.md +21 -0
  217. package/grammars/tree-sitter-julia/LICENSE +21 -0
  218. package/grammars/tree-sitter-julia/README.md +24 -0
  219. package/grammars/tree-sitter-kotlin/LICENSE +9 -0
  220. package/grammars/tree-sitter-lua/LICENSE.md +21 -0
  221. package/grammars/tree-sitter-lua/README.md +29 -0
  222. package/grammars/tree-sitter-make/LICENSE +21 -0
  223. package/grammars/tree-sitter-matlab/LICENSE +19 -0
  224. package/grammars/tree-sitter-matlab/README.md +99 -0
  225. package/grammars/tree-sitter-nim/LICENSE +373 -0
  226. package/grammars/tree-sitter-nim/README.md +46 -0
  227. package/grammars/tree-sitter-objc/LICENSE +21 -0
  228. package/grammars/tree-sitter-objc/README.md +20 -0
  229. package/grammars/tree-sitter-perl/LICENSE +7 -0
  230. package/grammars/tree-sitter-perl/README.md +100 -0
  231. package/grammars/tree-sitter-powershell/LICENSE +21 -0
  232. package/grammars/tree-sitter-powershell/README.md +7 -0
  233. package/grammars/tree-sitter-proto/LICENSE +21 -0
  234. package/grammars/tree-sitter-proto/README.md +105 -0
  235. package/grammars/tree-sitter-r/LICENSE +9 -0
  236. package/grammars/tree-sitter-r/README.md +66 -0
  237. package/grammars/tree-sitter-ruby/LICENSE +22 -0
  238. package/grammars/tree-sitter-scss/LICENSE +21 -0
  239. package/grammars/tree-sitter-scss/README.md +17 -0
  240. package/grammars/tree-sitter-solidity/LICENSE +7 -0
  241. package/grammars/tree-sitter-sql/LICENSE +21 -0
  242. package/grammars/tree-sitter-typescript/LICENSE +21 -0
  243. package/grammars/tree-sitter-vba/LICENSE +21 -0
  244. package/grammars/tree-sitter-vba/README.md +738 -0
  245. package/grammars/tree-sitter-vbnet/LICENSE +21 -0
  246. package/grammars/tree-sitter-vbnet/README.md +322 -0
  247. package/grammars/tree-sitter-xml/LICENSE +19 -0
  248. package/grammars/tree-sitter-yaml/LICENSE +20 -0
  249. package/grammars/tree-sitter-yaml/README.md +21 -0
  250. package/package.json +184 -4
  251. package/wasm/dart.wasm +0 -0
  252. package/wasm/tree-sitter-bash.wasm +0 -0
  253. package/wasm/tree-sitter-c.wasm +0 -0
  254. package/wasm/tree-sitter-c_sharp.wasm +0 -0
  255. package/wasm/tree-sitter-clean.wasm +0 -0
  256. package/wasm/tree-sitter-clojure.wasm +0 -0
  257. package/wasm/tree-sitter-core_schema.wasm +0 -0
  258. package/wasm/tree-sitter-cpp.wasm +0 -0
  259. package/wasm/tree-sitter-css.wasm +0 -0
  260. package/wasm/tree-sitter-dart.wasm +0 -0
  261. package/wasm/tree-sitter-elisp.wasm +0 -0
  262. package/wasm/tree-sitter-elixir.wasm +0 -0
  263. package/wasm/tree-sitter-elm.wasm +0 -0
  264. package/wasm/tree-sitter-embedded_template.wasm +0 -0
  265. package/wasm/tree-sitter-erlang.wasm +0 -0
  266. package/wasm/tree-sitter-fsharp.wasm +0 -0
  267. package/wasm/tree-sitter-fsharp_signature.wasm +0 -0
  268. package/wasm/tree-sitter-go.wasm +0 -0
  269. package/wasm/tree-sitter-graphql.wasm +0 -0
  270. package/wasm/tree-sitter-groovy.wasm +0 -0
  271. package/wasm/tree-sitter-haskell.wasm +0 -0
  272. package/wasm/tree-sitter-hcl.wasm +0 -0
  273. package/wasm/tree-sitter-html.wasm +0 -0
  274. package/wasm/tree-sitter-java.wasm +0 -0
  275. package/wasm/tree-sitter-javascript.wasm +0 -0
  276. package/wasm/tree-sitter-json.wasm +0 -0
  277. package/wasm/tree-sitter-json_schema.wasm +0 -0
  278. package/wasm/tree-sitter-julia.wasm +0 -0
  279. package/wasm/tree-sitter-kotlin.wasm +0 -0
  280. package/wasm/tree-sitter-lua.wasm +0 -0
  281. package/wasm/tree-sitter-make.wasm +0 -0
  282. package/wasm/tree-sitter-markdown.wasm +0 -0
  283. package/wasm/tree-sitter-markdown_inline.wasm +0 -0
  284. package/wasm/tree-sitter-matlab.wasm +0 -0
  285. package/wasm/tree-sitter-nim.wasm +0 -0
  286. package/wasm/tree-sitter-objc.wasm +0 -0
  287. package/wasm/tree-sitter-ocaml.wasm +0 -0
  288. package/wasm/tree-sitter-perl.wasm +0 -0
  289. package/wasm/tree-sitter-php.wasm +0 -0
  290. package/wasm/tree-sitter-php_only.wasm +0 -0
  291. package/wasm/tree-sitter-powershell.wasm +0 -0
  292. package/wasm/tree-sitter-protobuf.wasm +0 -0
  293. package/wasm/tree-sitter-python.wasm +0 -0
  294. package/wasm/tree-sitter-ql.wasm +0 -0
  295. package/wasm/tree-sitter-r.wasm +0 -0
  296. package/wasm/tree-sitter-rescript.wasm +0 -0
  297. package/wasm/tree-sitter-ruby.wasm +0 -0
  298. package/wasm/tree-sitter-rust.wasm +0 -0
  299. package/wasm/tree-sitter-scala.wasm +0 -0
  300. package/wasm/tree-sitter-scss.wasm +0 -0
  301. package/wasm/tree-sitter-solidity.wasm +0 -0
  302. package/wasm/tree-sitter-sql.wasm +0 -0
  303. package/wasm/tree-sitter-svelte.wasm +0 -0
  304. package/wasm/tree-sitter-swift.wasm +0 -0
  305. package/wasm/tree-sitter-systemrdl.wasm +0 -0
  306. package/wasm/tree-sitter-terraform.wasm +0 -0
  307. package/wasm/tree-sitter-tlaplus.wasm +0 -0
  308. package/wasm/tree-sitter-toml.wasm +0 -0
  309. package/wasm/tree-sitter-tsx.wasm +0 -0
  310. package/wasm/tree-sitter-typescript.wasm +0 -0
  311. package/wasm/tree-sitter-vba.wasm +0 -0
  312. package/wasm/tree-sitter-vbnet.wasm +0 -0
  313. package/wasm/tree-sitter-vue.wasm +0 -0
  314. package/wasm/tree-sitter-xml.wasm +0 -0
  315. package/wasm/tree-sitter-yaml.wasm +0 -0
  316. package/wasm/tree-sitter-zig.wasm +0 -0
  317. package/wasm/tree-sitter.wasm +0 -0
  318. package/wasm/tsx.wasm +0 -0
  319. package/wasm/typescript.wasm +0 -0
@@ -0,0 +1,2201 @@
1
+ import {
2
+ ASTEngine
3
+ } from "./chunk-V44PGPA6.js";
4
+ import "./chunk-BQ3OVQ3P.js";
5
+ import "./chunk-SK6YVHFZ.js";
6
+ import "./chunk-2BSKD7O3.js";
7
+ import "./chunk-KAET47ZL.js";
8
+ import "./chunk-KCDECRJY.js";
9
+ import "./chunk-MLKGABMK.js";
10
+
11
+ // src/ast/real-bash-analyzer.ts
12
+ import { existsSync } from "fs";
13
+ import * as path from "path";
14
+ var BASH_REPO_ROOT = /* @__PURE__ */ new Map();
15
+ function bashRepoRootOf(filePath) {
16
+ const abs = path.resolve(filePath);
17
+ const dir = path.dirname(abs);
18
+ const cached = BASH_REPO_ROOT.get(dir);
19
+ if (cached !== void 0) return cached;
20
+ let probe = dir;
21
+ for (let i = 0; i < 64; i++) {
22
+ if (existsSync(path.join(probe, ".git"))) {
23
+ BASH_REPO_ROOT.set(dir, probe);
24
+ return probe;
25
+ }
26
+ const parent = path.dirname(probe);
27
+ if (parent === probe) break;
28
+ probe = parent;
29
+ }
30
+ BASH_REPO_ROOT.set(dir, "");
31
+ return "";
32
+ }
33
+ function bashFileScopeOf(filePath) {
34
+ const root = bashRepoRootOf(filePath);
35
+ if (root === "") return "";
36
+ const rel = path.relative(root, path.resolve(filePath));
37
+ if (rel.startsWith("..") || rel === "") return "";
38
+ return rel.split(path.sep).join("/").split(".").join("_").split(":").join("_");
39
+ }
40
+ var SHELL_BUILTINS = /* @__PURE__ */ new Set([
41
+ "echo",
42
+ "printf",
43
+ "cd",
44
+ "pwd",
45
+ "exit",
46
+ "return",
47
+ "break",
48
+ "continue",
49
+ "shift",
50
+ "source",
51
+ ".",
52
+ "export",
53
+ "local",
54
+ "declare",
55
+ "typeset",
56
+ "readonly",
57
+ "set",
58
+ "unset",
59
+ "alias",
60
+ "unalias",
61
+ "trap",
62
+ "eval",
63
+ "exec",
64
+ "true",
65
+ "false",
66
+ "test",
67
+ "[",
68
+ "command",
69
+ "builtin",
70
+ "type",
71
+ "let",
72
+ "pushd",
73
+ "popd",
74
+ "dirs",
75
+ "wait",
76
+ "jobs",
77
+ "fg",
78
+ "bg",
79
+ "kill",
80
+ "read",
81
+ "readarray",
82
+ "mapfile",
83
+ "getopts",
84
+ "ulimit",
85
+ "umask",
86
+ "times",
87
+ "enable",
88
+ "help",
89
+ "hash",
90
+ "bind",
91
+ "compgen",
92
+ "complete",
93
+ "compopt",
94
+ "logout",
95
+ "suspend",
96
+ "caller",
97
+ "disown",
98
+ "shopt",
99
+ "history",
100
+ "fc",
101
+ "getopts",
102
+ "coproc",
103
+ // `:` is the POSIX no-op utility — a builtin, and also the shape an
104
+ // ERROR-recovered `command` can carry when the grammar lost the real command
105
+ // word (see isRecoveredCommandWord), so naming it here keeps a recovery
106
+ // artifact off the call graph as well.
107
+ ":"
108
+ // `sudo` is DELIBERATELY absent. It is an external binary that happens to be
109
+ // installed everywhere, not a shell builtin, and the pre-wave list filed it
110
+ // under builtins — which withheld the edge for `sudo docker build …` and left
111
+ // the privilege-escalation pattern as the only trace of the call. The pattern
112
+ // is still raised (see the classification block); the edge is emitted too.
113
+ ]);
114
+ var DECLARATION_KEYWORDS = /* @__PURE__ */ new Set(["export", "local", "declare", "typeset", "readonly"]);
115
+ var SHELL_RESERVED_WORDS = /* @__PURE__ */ new Set([
116
+ "if",
117
+ "then",
118
+ "else",
119
+ "elif",
120
+ "fi",
121
+ "case",
122
+ "esac",
123
+ "for",
124
+ "select",
125
+ "while",
126
+ "until",
127
+ "do",
128
+ "done",
129
+ "in",
130
+ "function",
131
+ "time",
132
+ "{",
133
+ "}",
134
+ "!",
135
+ "[[",
136
+ "]]",
137
+ "((",
138
+ "))",
139
+ "coproc"
140
+ ]);
141
+ var SPECIAL_VAR_PUNCTUATION = /* @__PURE__ */ new Set(["@", "*", "?", "$", "#", "!", "_"]);
142
+ var COMPUTED_EXPORT_NAME_PATTERN = "Computed Export Name";
143
+ var SHELL_DEFINED_VARS = /* @__PURE__ */ new Set([
144
+ "BASH",
145
+ "BASHOPTS",
146
+ "BASHPID",
147
+ "BASH_ALIASES",
148
+ "BASH_ARGC",
149
+ "BASH_ARGV",
150
+ "BASH_ARGV0",
151
+ "BASH_CMDS",
152
+ "BASH_COMMAND",
153
+ "BASH_EXECUTION_STRING",
154
+ "BASH_LINENO",
155
+ "BASH_LOADABLES_PATH",
156
+ "BASH_REMATCH",
157
+ "BASH_SOURCE",
158
+ "BASH_SUBSHELL",
159
+ "BASH_VERSINFO",
160
+ "BASH_VERSION",
161
+ "COMP_CWORD",
162
+ "COMP_LINE",
163
+ "COMP_POINT",
164
+ "COMP_TYPE",
165
+ "COMP_WORDBREAKS",
166
+ "COMP_WORDS",
167
+ "COMP_KEY",
168
+ "COMP_WORDBREAKS",
169
+ "DIRSTACK",
170
+ "EPOCHREALTIME",
171
+ "EPOCHSECONDS",
172
+ "EUID",
173
+ "FUNCNAME",
174
+ "GROUPS",
175
+ "HISTCMD",
176
+ "HOSTNAME",
177
+ "HOSTTYPE",
178
+ "LINENO",
179
+ "LINES",
180
+ "MACHTYPE",
181
+ "MAIL",
182
+ "MAILCHECK",
183
+ "OLDPWD",
184
+ "OPTARG",
185
+ "OPTERR",
186
+ "OPTIND",
187
+ "OSTYPE",
188
+ "PIPESTATUS",
189
+ "PPID",
190
+ "PROMPT_COMMAND",
191
+ "PS0",
192
+ "PS1",
193
+ "PS2",
194
+ "PS3",
195
+ "PS4",
196
+ "PWD",
197
+ "RANDOM",
198
+ "SECONDS",
199
+ "SHELL",
200
+ "SHELLOPTS",
201
+ "SRANDOM",
202
+ "UID",
203
+ "OTEST"
204
+ ]);
205
+ var READ_TARGET_COMMANDS = /* @__PURE__ */ new Set(["read", "mapfile", "readarray"]);
206
+ var READ_VALUE_OPT_LETTERS = /* @__PURE__ */ new Set(["d", "e", "i", "n", "N", "p", "s", "t", "u", "O"]);
207
+ var READ_OPTION_LETTERS = /* @__PURE__ */ new Set(["r", "a", "d", "e", "i", "n", "N", "p", "s", "t", "u", "O", "C", "c"]);
208
+ var isShellIdentifierName = (text) => {
209
+ if (text.length === 0) return false;
210
+ const first = text.charCodeAt(0);
211
+ const firstOk = first >= 97 && first <= 122 || first >= 65 && first <= 90 || first === 95;
212
+ if (!firstOk) return false;
213
+ for (let i = 1; i < text.length; i++) {
214
+ const c = text.charCodeAt(i);
215
+ if (c >= 97 && c <= 122 || c >= 65 && c <= 90 || c >= 48 && c <= 57 || c === 95) continue;
216
+ return false;
217
+ }
218
+ return true;
219
+ };
220
+ var opensWithDoubleParen = (text) => text.length >= 2 && text.charCodeAt(0) === 40 && text.charCodeAt(1) === 40;
221
+ var opensWithArithmeticExpansion = (text) => text.length >= 3 && text.charCodeAt(0) === 36 && opensWithDoubleParen(text.slice(1));
222
+ var endsWithDoubleParen = (text) => {
223
+ const t = text.trim();
224
+ return t.length >= 2 && t.charCodeAt(t.length - 2) === 41 && t.charCodeAt(t.length - 1) === 41;
225
+ };
226
+ var isArithmeticCompoundText = (text) => opensWithDoubleParen(text) && endsWithDoubleParen(text);
227
+ var isCommandNameText = (text) => {
228
+ if (text.length === 0) return false;
229
+ if (SHELL_RESERVED_WORDS.has(text)) return false;
230
+ for (let i = 0; i < text.length; i++) {
231
+ const c = text.charCodeAt(i);
232
+ if (c >= 48 && c <= 57) {
233
+ let allDigits = true;
234
+ for (let j = 0; j < text.length; j++) {
235
+ const d = text.charCodeAt(j);
236
+ if (d < 48 || d > 57) {
237
+ allDigits = false;
238
+ break;
239
+ }
240
+ }
241
+ if (allDigits) return false;
242
+ break;
243
+ }
244
+ }
245
+ for (let i = 0; i < text.length; i++) {
246
+ const c = text.charCodeAt(i);
247
+ if (c === 36 || c === 96 || c === 34 || c === 39) return false;
248
+ if (c === 32 || c === 9 || c === 10 || c === 13) return false;
249
+ if (c === 59 || c === 124 || c === 38) return false;
250
+ if (c === 40 || c === 41 || c === 60 || c === 62) return false;
251
+ }
252
+ return true;
253
+ };
254
+ var RealBashAnalyzer = class {
255
+ analyze(rootNode, content, filePath) {
256
+ const symbols = [];
257
+ const relationships = [];
258
+ const imports = [];
259
+ const patterns = /* @__PURE__ */ new Set();
260
+ const scriptName = this.scriptNameOf(filePath);
261
+ const scriptRange = this.nodeToRange(rootNode);
262
+ symbols.push({
263
+ name: scriptName,
264
+ kind: ASTEngine.SymbolKind.Module,
265
+ range: scriptRange,
266
+ visibility: "public",
267
+ metadata: { bashSpecific: true, isScript: true, scriptPath: filePath }
268
+ });
269
+ const declaredSymbols = /* @__PURE__ */ new Set();
270
+ const declSites = /* @__PURE__ */ new Map();
271
+ const localFunctions = this.collectFunctionNames(rootNode);
272
+ const scopeOwners = localFunctions;
273
+ const functionStack = [];
274
+ const functionPriorDeclaration = /* @__PURE__ */ new Map();
275
+ const functionParams = /* @__PURE__ */ new Map();
276
+ const relKeys = /* @__PURE__ */ new Set();
277
+ let cyclomaticComplexity = 1;
278
+ const scopeComplexity = /* @__PURE__ */ new Map();
279
+ let hasStrictError = false;
280
+ let usesEval = false;
281
+ let usesSudo = false;
282
+ let hasCurlPipeBash = false;
283
+ let hasChmod777 = false;
284
+ let hasRmRoot = false;
285
+ const enclosingFunction = () => functionStack[functionStack.length - 1] ?? "";
286
+ const relSource = () => enclosingFunction() || scriptName;
287
+ const evidenceClassFor = (type) => {
288
+ if (type === ASTEngine.RelationshipType.Imports || type === ASTEngine.RelationshipType.References) return "import_based";
289
+ if (type === ASTEngine.RelationshipType.Uses) return "usage_contextual";
290
+ return "structural";
291
+ };
292
+ const addRel = (source, target, type, confidence, line, metadata = {}) => {
293
+ if (!Number.isInteger(line) || line < 1) return;
294
+ const key = `${source}\0${type}\0${target}\0${line}`;
295
+ if (relKeys.has(key)) return;
296
+ relKeys.add(key);
297
+ relationships.push({
298
+ source,
299
+ target,
300
+ type,
301
+ confidence,
302
+ metadata: { evidenceClass: evidenceClassFor(type), ...metadata },
303
+ line_number: line
304
+ });
305
+ };
306
+ const stack = [{ node: rootNode, exit: false }];
307
+ while (stack.length > 0) {
308
+ const { node, exit } = stack.pop();
309
+ if (!node) continue;
310
+ if (exit) {
311
+ if (node.type === "function_definition") functionStack.pop();
312
+ continue;
313
+ }
314
+ stack.push({ node, exit: true });
315
+ if (node.type === "if_statement" || node.type === "for_statement" || node.type === "while_statement" || node.type === "case_item" || node.type === "elif_clause" || node.type === "test_command" || node.type === "binary_expression" || node.type === "c_style_for_statement") {
316
+ cyclomaticComplexity++;
317
+ const scope = enclosingFunction() || scriptName;
318
+ scopeComplexity.set(scope, (scopeComplexity.get(scope) ?? 0) + 1);
319
+ }
320
+ if (node.type === "function_definition") {
321
+ const parentFn = enclosingFunction();
322
+ this.extractFunction(node, symbols, parentFn || scriptName);
323
+ const name = this.functionName(node);
324
+ if (name) {
325
+ functionStack.push(name);
326
+ if (!functionParams.has(name)) functionParams.set(name, /* @__PURE__ */ new Set());
327
+ const prior = functionPriorDeclaration.get(name);
328
+ if (prior !== void 0) {
329
+ addRel(
330
+ parentFn || scriptName,
331
+ name,
332
+ ASTEngine.RelationshipType.Overrides,
333
+ 1,
334
+ node.startPosition.row + 1,
335
+ {
336
+ bashSpecific: true,
337
+ overridesLine: prior,
338
+ redefinition: true,
339
+ evidenceClass: "structural"
340
+ }
341
+ );
342
+ }
343
+ functionPriorDeclaration.set(name, node.startPosition.row + 1);
344
+ }
345
+ patterns.add("Function Definition");
346
+ } else if (node.type === "command_substitution") {
347
+ patterns.add("Command Substitution");
348
+ } else if (node.type === "subshell") {
349
+ patterns.add("Subshell");
350
+ } else if (node.type === "pipeline") {
351
+ const names = [];
352
+ const collect = (n) => {
353
+ for (let i = 0; i < n.namedChildCount; i++) {
354
+ const child = n.namedChild(i);
355
+ if (!child) continue;
356
+ if (child.type === "command") {
357
+ const name = this.commandNameOf(child);
358
+ if (name) names.push(name);
359
+ } else if (child.type !== "pipeline") {
360
+ collect(child);
361
+ }
362
+ }
363
+ };
364
+ collect(node);
365
+ const hasDownloader = names.some((n) => n === "curl" || n === "wget");
366
+ const hasShell = names.some((n) => n === "bash" || n === "sh" || n === "zsh" || n === "dash");
367
+ if (hasDownloader && hasShell) {
368
+ hasCurlPipeBash = true;
369
+ patterns.add("Insecure Pipe Execution");
370
+ }
371
+ } else if (node.type === "simple_expansion" || node.type === "expansion" || // BD-gap closure: a variable read inside an arithmetic context is a
372
+ // read. `$((i + 1))`, `((count++))` and `for ((i=0; i<n; i++))` all
373
+ // name real project variables and the analyzer reached none of them:
374
+ // `expansionVariableName` was only consulted for the two string
375
+ // expansion forms, so every arithmetic operand was invisible. The
376
+ // census then named `arithmetic_expression` as a fourth form to add —
377
+ // a literal the pinned grammar does not have, which the rewritten
378
+ // F8 gate reported as a dead branch on the same day it was written.
379
+ // That is the gate working: a plausible guess, refused by evidence.
380
+ node.type === "arithmetic_expansion" || node.type === "postfix_expression" || node.type === "c_style_for_statement" || // `((i < 5))` — an arithmetic COMMAND, whose operands are real reads. The
381
+ // five node types above cover `$((…))`, `for ((…))` and `((i++))`, and none
382
+ // of them covers the plain form, because the pinned grammar holds a bare
383
+ // `(( … ))` in a `compound_statement` wrapping a `binary_expression` /
384
+ // `unary_expression` / plain `variable_name` — none of which is dispatched.
385
+ // The asymmetry was a grammar-shape accident rather than a fact about bash:
386
+ // `((i++))` produced a `uses` edge only because its operand happened to
387
+ // arrive as a `postfix_expression`, and `((i < 5))` on the same line
388
+ // produced none. Recorded as limitation 7 and measured before it was
389
+ // changed: `probe-bash-arithmetic-operands.mts` derives the delta from the
390
+ // tree BEFORE the edit and predicts +70 `uses` edges on the 400-file
391
+ // hash-order sample — 89 arithmetic compound_statements in 32 files, 126
392
+ // operand name nodes, 115 distinct (target, line) pairs, 45 of them
393
+ // already published through the five dispatched types and 70 not, 54 of
394
+ // those 70 naming a variable the same file declares. The census then
395
+ // measured uses 7,786 -> 7,856 and total relationships 16,984 -> 17,054,
396
+ // which is the 70. NO other type moves: this adds reads, no symbols and no
397
+ // edges of any other type.
398
+ //
399
+ // The first run of that probe predicted 72 and the census said 70, and the
400
+ // probe was wrong: it deduplicated operands per STATEMENT while `addRel`'s
401
+ // key carries no statement identity, so `(( i++ )); (( i-- ))` on one line
402
+ // counted twice. The instrument now dedupes per file.
403
+ //
404
+ // The predicate is the node's own text, because `compound_statement` is also
405
+ // a block: `then <block> fi` and `do <body> done` reach the same node type,
406
+ // and their reads are already dispatched — `simple_expansion`, `expansion`,
407
+ // `arithmetic_expansion`, `postfix_expression` and `c_style_for_statement`
408
+ // all sit under them — so a block contributes no edge a block-read would
409
+ // duplicate. The guard keeps the claim true rather than preventing a
410
+ // duplicate, and its own population is measured: 792 compound_statements
411
+ // are excluded by it on the 400-file sample, of which 0 open `((`.
412
+ //
413
+ // That 0 is also why no mutant covers the CLOSING half of the test. A
414
+ // malformed `(( a < 2` does not arrive as a compound_statement at all — the
415
+ // grammar produces an ERROR node for it (verified on the tree) — so
416
+ // `opensWithDoubleParen` alone has an empty reachable population on this
417
+ // corpus and a mutant dropping `endsWithDoubleParen` would survive. The
418
+ // condition is kept because `( (date) )` is a subshell and `((x))` is
419
+ // arithmetic, and it is declared rather than covered by a mutant that
420
+ // cannot fail.
421
+ node.type === "compound_statement" && isArithmeticCompoundText(node.text)) {
422
+ this.emitVariableUses(node, symbols, relSource, addRel, declaredSymbols, functionStack, functionParams, declSites, scriptName);
423
+ } else if (node.type === "for_statement") {
424
+ this.extractImplicitBinding(node, symbols, enclosingFunction() || scriptName, declaredSymbols, scopeOwners, declSites);
425
+ } else if (node.type === "variable_assignment") {
426
+ if (node.parent?.type !== "declaration_command") {
427
+ this.extractAssignment(node, symbols, enclosingFunction() || scriptName, declaredSymbols, scopeOwners, declSites);
428
+ }
429
+ } else if (node.type === "declaration_command") {
430
+ this.extractDeclaration(node, symbols, enclosingFunction() || scriptName, functionParams, declaredSymbols, scopeOwners, addRel, declSites, patterns);
431
+ } else if (node.type === "command") {
432
+ this.extractImplicitBinding(node, symbols, enclosingFunction() || scriptName, declaredSymbols, scopeOwners, declSites);
433
+ this.extractCommand(node, symbols, imports, patterns, {
434
+ addRel,
435
+ relSource,
436
+ scriptName,
437
+ localFunctions,
438
+ scopeOwners,
439
+ flags: {
440
+ usesEval: () => usesEval,
441
+ usesSudo: () => usesSudo,
442
+ hasChmod777: () => hasChmod777,
443
+ hasRmRoot: () => hasRmRoot,
444
+ hasStrictError: () => hasStrictError
445
+ },
446
+ setFlag: {
447
+ eval: (v) => usesEval = v,
448
+ sudo: (v) => usesSudo = v,
449
+ chmod777: (v) => hasChmod777 = v,
450
+ rmRoot: (v) => hasRmRoot = v,
451
+ strictError: (v) => hasStrictError = v
452
+ }
453
+ });
454
+ }
455
+ for (let i = node.childCount - 1; i >= 0; i--) {
456
+ const child = node.child(i);
457
+ if (child) stack.push({ node: child, exit: false });
458
+ }
459
+ }
460
+ for (const sym of symbols) {
461
+ if (sym.kind !== ASTEngine.SymbolKind.Module && sym.kind !== ASTEngine.SymbolKind.Function) continue;
462
+ const points = scopeComplexity.get(sym.name);
463
+ if (points === void 0) continue;
464
+ sym.complexity = 1 + points;
465
+ }
466
+ for (let i = 0; i < symbols.length; i++) {
467
+ const sym = symbols[i];
468
+ if (sym.kind === ASTEngine.SymbolKind.Function) {
469
+ const params = functionParams.get(sym.name);
470
+ if (params && params.size > 0) {
471
+ const paramObjects = [];
472
+ for (const p of params) paramObjects.push({ name: p, type: "any" });
473
+ sym.parameters = paramObjects;
474
+ }
475
+ }
476
+ if (sym.ownerName) {
477
+ addRel(sym.ownerName, sym.name, ASTEngine.RelationshipType.Contains, 1, this.nameLineOf(sym), {
478
+ bashSpecific: true
479
+ });
480
+ }
481
+ }
482
+ const functionDeclLines = /* @__PURE__ */ new Map();
483
+ const namesWithNonFunctionDecl = /* @__PURE__ */ new Set();
484
+ for (const sym of symbols) {
485
+ const name = sym.name;
486
+ if (sym.kind === ASTEngine.SymbolKind.Function) {
487
+ const line = this.nameLineOf(sym);
488
+ const list = functionDeclLines.get(name);
489
+ if (list) {
490
+ if (!list.some((e) => e.line === line)) list.push({ line, kind: String(sym.kind) });
491
+ } else functionDeclLines.set(name, [{ line, kind: String(sym.kind) }]);
492
+ } else if (name.length > 0 && !namesWithNonFunctionDecl.has(name)) {
493
+ namesWithNonFunctionDecl.add(name);
494
+ }
495
+ }
496
+ const assignedNamesInFile = /* @__PURE__ */ new Set();
497
+ for (const k of declaredSymbols) {
498
+ const i = k.indexOf("\0");
499
+ if (i >= 0) assignedNamesInFile.add(k.slice(i + 1));
500
+ }
501
+ for (const rel of relationships) {
502
+ if (rel.type !== ASTEngine.RelationshipType.Uses) continue;
503
+ const meta = rel.metadata;
504
+ if (meta.isAssignedInFile !== void 0) continue;
505
+ if (assignedNamesInFile.has(String(rel.target ?? ""))) meta.isAssignedInFile = true;
506
+ }
507
+ for (const rel of relationships) {
508
+ if (rel.type !== ASTEngine.RelationshipType.Uses) continue;
509
+ const meta = rel.metadata;
510
+ if (meta.targetResolution !== void 0) continue;
511
+ const resolved = this.resolveDeclaration(
512
+ declSites,
513
+ String(rel.target ?? ""),
514
+ String(rel.source ?? ""),
515
+ rel.line_number,
516
+ scriptName
517
+ );
518
+ if (resolved) Object.assign(meta, resolved);
519
+ }
520
+ for (const rel of relationships) {
521
+ if (rel.type !== ASTEngine.RelationshipType.Uses) continue;
522
+ const meta = rel.metadata;
523
+ if (meta.targetResolution !== void 0) continue;
524
+ const name = String(rel.target ?? "");
525
+ if (!functionDeclLines.has(name)) continue;
526
+ const resolved = this.resolveReadAgainstFunctionSymbol(
527
+ declSites,
528
+ name,
529
+ String(rel.source ?? ""),
530
+ rel.line_number,
531
+ scriptName
532
+ );
533
+ if (resolved) Object.assign(meta, resolved);
534
+ }
535
+ for (const rel of relationships) {
536
+ if (rel.type !== ASTEngine.RelationshipType.Calls) continue;
537
+ const meta = rel.metadata;
538
+ if (meta.targetResolution !== void 0) continue;
539
+ if (meta.isFunctionCall !== true) {
540
+ if (meta.commandInErrorRecovery === true) {
541
+ if ((declSites.get(String(rel.target ?? ""))?.length ?? 0) >= 2) meta.targetResolution = "ambiguousInFile";
542
+ continue;
543
+ }
544
+ const name = String(rel.target ?? "");
545
+ if (!namesWithNonFunctionDecl.has(name)) continue;
546
+ meta.targetResolution = "commandNameIsExternal";
547
+ meta.collidesWithLocalDeclaration = true;
548
+ continue;
549
+ }
550
+ const resolved = this.resolveFunctionTarget(
551
+ functionDeclLines,
552
+ String(rel.target ?? ""),
553
+ rel.line_number,
554
+ namesWithNonFunctionDecl
555
+ );
556
+ if (resolved) Object.assign(meta, resolved);
557
+ }
558
+ for (const rel of relationships) {
559
+ if (rel.type !== ASTEngine.RelationshipType.Exports) continue;
560
+ const meta = rel.metadata;
561
+ if (meta.targetResolution !== void 0) continue;
562
+ const name = String(rel.target ?? "");
563
+ if (functionDeclLines.has(name)) continue;
564
+ const resolved = this.resolveDeclaration(
565
+ declSites,
566
+ name,
567
+ String(rel.source ?? ""),
568
+ rel.line_number,
569
+ scriptName
570
+ );
571
+ if (resolved) Object.assign(meta, resolved);
572
+ }
573
+ const fileScope = bashFileScopeOf(filePath);
574
+ if (fileScope !== "") {
575
+ const occurrences = /* @__PURE__ */ new Map();
576
+ for (const sym of symbols) {
577
+ const n = String(sym.name ?? "");
578
+ if (n.length > 0) occurrences.set(n, (occurrences.get(n) ?? 0) + 1);
579
+ }
580
+ const resplellable = {
581
+ // command position: a function declared in this file answers it (bash resolves functions
582
+ // before PATH); a variable does NOT, which is what `commandNameIsExternal` records.
583
+ calls: (k) => k === "function",
584
+ uses: (k) => k === "variable" || k === "constant",
585
+ exports: (k) => k === "variable" || k === "constant" || k === "function",
586
+ overrides: (k) => k === "function"
587
+ };
588
+ const published = /* @__PURE__ */ new Set();
589
+ for (const sym of symbols) {
590
+ const n = String(sym.name ?? "");
591
+ if (n.length === 0) continue;
592
+ published.add(`${n}|${Number(sym.range?.startLine ?? 0)}|${String(sym.kind ?? "")}`);
593
+ }
594
+ for (const rel of relationships) {
595
+ const kindRule = resplellable[String(rel.type ?? "")];
596
+ if (kindRule === void 0) continue;
597
+ const target = String(rel.target ?? "");
598
+ if (target.length === 0) continue;
599
+ const r = this.bindingInEffect(declSites, target, String(rel.source ?? ""), rel.line_number, scriptName);
600
+ if (r === null || r === "ambiguous") continue;
601
+ if (!kindRule(r.kind)) continue;
602
+ const symbolKind = r.kind === "function" ? ASTEngine.SymbolKind.Function : r.kind === "constant" ? ASTEngine.SymbolKind.Constant : ASTEngine.SymbolKind.Variable;
603
+ if (!published.has(`${target}|${r.line}|${symbolKind}`)) continue;
604
+ const owner = r.owner.length > 0 ? r.owner : scriptName;
605
+ rel.target = `${owner}.${target}`;
606
+ }
607
+ }
608
+ let security = 50;
609
+ let maintainability = 50;
610
+ if (hasStrictError) {
611
+ security += 20;
612
+ maintainability += 10;
613
+ }
614
+ if (usesEval) security -= 30;
615
+ if (usesSudo) security -= 10;
616
+ if (hasCurlPipeBash) security -= 25;
617
+ if (hasChmod777) security -= 20;
618
+ if (hasRmRoot) security -= 40;
619
+ let hasFunction = false;
620
+ for (let i = 0; i < symbols.length; i++) {
621
+ if (symbols[i].kind === ASTEngine.SymbolKind.Function) {
622
+ hasFunction = true;
623
+ break;
624
+ }
625
+ }
626
+ if (hasFunction) maintainability += 20;
627
+ if (cyclomaticComplexity > 10) maintainability -= 15;
628
+ const score = Math.round((security + maintainability) / 2);
629
+ const patternsArray = [...patterns];
630
+ const frameworks = this.detectFrameworks(patternsArray);
631
+ return {
632
+ language: "bash",
633
+ symbols,
634
+ relationships,
635
+ imports,
636
+ exports: symbols.filter((s) => s.metadata?.isExported).map((s) => ({
637
+ name: s.name,
638
+ type: s.kind,
639
+ range: s.range,
640
+ isType: false
641
+ })),
642
+ // Syntax errors are engine-owned (engine.ts merges treeErrors) — this
643
+ // analyzer never duplicates them.
644
+ metadata: {
645
+ complexity: symbols.length + relationships.length,
646
+ linesOfCode: content.split("\n").length,
647
+ cyclomaticComplexity,
648
+ framework: frameworks.length > 1 ? frameworks[1] : "Bash Script",
649
+ patterns: patternsArray,
650
+ detectedFrameworks: frameworks,
651
+ frameworkPatterns: [{ framework: "Bash", patterns: patternsArray }],
652
+ architecturalInsights: this.generateInsights(patternsArray, hasStrictError),
653
+ architecturalScore: score,
654
+ codeQuality: score >= 80 ? "Excellent" : score >= 60 ? "Good" : "Fair",
655
+ hasTypeInference: false,
656
+ // A framework claim must be earned. This used to be an unconditional
657
+ // `true` on every bash file, which asserted framework intelligence for a
658
+ // file that had none; it is now true only when a framework beyond the
659
+ // language itself was actually detected from the file's own commands.
660
+ hasFrameworkIntelligence: frameworks.length > 1,
661
+ securityScore: Math.min(100, Math.max(0, security)),
662
+ maintainabilityScore: Math.min(100, Math.max(0, maintainability)),
663
+ analysisVersion: "5.0-bash-module-owner-per-site-lines"
664
+ }
665
+ };
666
+ }
667
+ // -------------------------------------------------------------- functions
668
+ /** BD2: the file's own basename, extension included. */
669
+ scriptNameOf(filePath) {
670
+ const normalized = filePath.split("\\").join("/");
671
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
672
+ return base.length > 0 ? base : "script.sh";
673
+ }
674
+ /** The 1-based line a symbol's NAME is written on (M60 #921). */
675
+ nameLineOf(sym) {
676
+ const line = sym.range?.startLine;
677
+ return typeof line === "number" && line >= 1 ? line : 1;
678
+ }
679
+ /** Record WHERE a name was declared. One entry per (owner, name), added at the
680
+ * same place `declaredSymbols` is, so the two indexes cannot disagree. */
681
+ noteDeclaration(declSites, name, owner, line, kind) {
682
+ if (!Number.isInteger(line) || line < 1) return;
683
+ const list = declSites.get(name);
684
+ if (list) list.push({ owner, line, kind });
685
+ else declSites.set(name, [{ owner, line, kind }]);
686
+ }
687
+ /**
688
+ * Which declaration does an edge naming `name` at `useLine` in `useOwner` mean?
689
+ *
690
+ * The rule is a lexical-scope chain walked OUTWARD, and the order of the two
691
+ * halves is load-bearing:
692
+ *
693
+ * 1. Walk the scope chain from the use's own function out to the file. In the
694
+ * first scope that has a declaration of `name` AT OR BEFORE the use site,
695
+ * the nearest such line is the answer — and the walk STOPS there. A use is
696
+ * not answered by a declaration in a scope it does not belong to.
697
+ * 2. If no scope in the chain has one, the analyzer publishes nothing and
698
+ * says `targetResolution: "ambiguousInFile"`.
699
+ *
700
+ * Both halves were arrived at by being wrong first, and the fixture
701
+ * `ambiguous-target-resolution.sh` is what caught each:
702
+ *
703
+ * The first version had no scope chain at all — it took every declaration of
704
+ * the name in the file and picked the nearest preceding one. So `echo "$X"` at
705
+ * line 45 inside `read_early`, whose own assignment to X is at line 46,
706
+ * resolved to line 37 — a declaration inside a DIFFERENT function. That is
707
+ * wrong twice over: bash does not let one function's assignment to a local
708
+ * shadow another function's, and the fixture's own comment says the analyzer
709
+ * must not point at a declaration that does not exist yet at the use site.
710
+ *
711
+ * The second version had the chain but walked it in the wrong direction for
712
+ * the "no declaration yet" case, and would fall through to a nearer
713
+ * declaration in an unrelated scope instead of the file-scope one that really
714
+ * is the value in effect.
715
+ *
716
+ * `declSites` keys only the first declaration of each (owner, name), because
717
+ * that is exactly what the collectors publish — a second `X=1` in the same
718
+ * function is a reassignment, not a declaration. So "nearest preceding" within
719
+ * a scope always has one candidate, and the ties the corpus measurement found
720
+ * (49 of 1,879 edges) can only arise between scopes.
721
+ *
722
+ * The three outcomes are DISTINCT on purpose, because "the analyzer could not
723
+ * say" and "the analyzer saw several and declined to pick" are different facts:
724
+ *
725
+ * resolved one candidate — `targetLine` + `targetKind` + the rule named, so
726
+ * the fact can be checked rather than trusted.
727
+ * ambiguous two or more candidates and the rule cannot separate them — no
728
+ * line is published, because publishing one of several would be
729
+ * exactly the invented attribution this exists to prevent.
730
+ * undefined fewer than two declarations in the file, so the name is not
731
+ * ambiguous and the edge needs nothing added to it.
732
+ *
733
+ * The `undefined` case is why this adds metadata to 15.2% of `uses` edges
734
+ * rather than all of them: an edge whose target is unique in its file carries
735
+ * no new information, and 80,851 `uses` edges on the whole corpus would each
736
+ * grow three keys to say so.
737
+ */
738
+ resolveDeclaration(declSites, name, useOwner, useLine, fileOwner) {
739
+ const cands = declSites.get(name);
740
+ if (!cands || cands.length < 2) return void 0;
741
+ const r = this.bindingInEffect(declSites, name, useOwner, useLine, fileOwner);
742
+ if (r === null || r === "ambiguous") return { targetResolution: "ambiguousInFile" };
743
+ return {
744
+ targetLine: r.line,
745
+ targetKind: r.kind,
746
+ targetResolution: r.innermost ? "innermostScope" : "outerScope"
747
+ };
748
+ }
749
+ /**
750
+ * WHICH DECLARATION IS IN EFFECT WHERE THE EDGE POINTS — the lane's scope-chain rule, in ONE
751
+ * place, because two consumers now need it and two copies of a resolution rule are two rules.
752
+ *
753
+ * `null` the file declares this name NOWHERE — there is nothing to name
754
+ * `"ambiguous"` the name is declared, but not exactly one of them is in effect here
755
+ * the binding the nearest declaration at-or-before the use, in the innermost scope that
756
+ * has one, with `innermost` saying which scope answered
757
+ *
758
+ * Bash's own rule drives the walk: a function or variable defined LATER in the file does not
759
+ * answer a use that precedes it, and a use inside a function is answered by that function's own
760
+ * scope before the script's. That is why the respell pass below can reach a declaration WITHOUT
761
+ * guessing — and why it must not respell an edge whose binding is `"ambiguous"` or `null`.
762
+ */
763
+ bindingInEffect(declSites, name, useOwner, useLine, fileOwner) {
764
+ const cands = declSites.get(name);
765
+ if (!cands || cands.length === 0) return null;
766
+ const scopes = useOwner === fileOwner ? [fileOwner] : [useOwner, fileOwner];
767
+ for (const scope of scopes) {
768
+ const atOrBefore = cands.filter((c) => c.owner === scope && c.line <= useLine);
769
+ if (atOrBefore.length === 0) continue;
770
+ const nearest = Math.max(...atOrBefore.map((c) => c.line));
771
+ const winners = atOrBefore.filter((c) => c.line === nearest);
772
+ if (winners.length !== 1) return "ambiguous";
773
+ return { line: winners[0].line, kind: winners[0].kind, owner: scope, innermost: scope === useOwner };
774
+ }
775
+ return "ambiguous";
776
+ }
777
+ /**
778
+ * Publish, on a `uses` edge, that this file binds `name` as a VARIABLE nowhere.
779
+ *
780
+ * ## Why this is not the same as "external"
781
+ *
782
+ * The obvious field name here is `isExternal`, and it would be a false claim on a
783
+ * measured population. Measured on the pinned corpus — 600 `*.sh` files in path order,
784
+ * production `parseFile`, analyzer digest `6d5542aa6d6640e2` — 490 `uses` edges go to a
785
+ * name no file in the window declares, and a whole-corpus pass over all 3,748 tracked
786
+ * bash files found a real declaration for 101 of them:
787
+ *
788
+ * consul/test/integration/connect/envoy/defaults.sh DEFAULT_REQUIRED_SERVICES 32 edges
789
+ * fsharp/eng/common/tools.sh HOME 48 edges
790
+ * consul/test/integration/connect/envoy/run-tests.sh CASE_DIR, CLUSTER, XDS_TARGET
791
+ *
792
+ * Those 101 reads are not external. They read a variable that a `source`d file assigns,
793
+ * and a single-file analyzer cannot see it. Calling them external would tell a consumer
794
+ * to stop looking for a declaration that exists.
795
+ *
796
+ * So the published fact is the one that is true and checkable: **no variable, alias or
797
+ * local in THIS FILE binds this name.**
798
+ *
799
+ * ## Why the field says "variable"
800
+ *
801
+ * `declSites` holds variables, aliases and locals and never functions — the same
802
+ * limitation `resolveReadAgainstFunctionSymbol` is built around (a `$name` read cannot
803
+ * denote a function). So for a name declared only as a function, "no declaration in this
804
+ * file" is FALSE while "no VARIABLE binding in this file" is TRUE. The field is named for
805
+ * the index it is derived from so the row cannot be over-read, and the distinction is the
806
+ * one a consumer needs: a read that no variable binds is unresolved for a different
807
+ * reason than one that binds ambiguously, and `targetResolution` already reports the
808
+ * latter.
809
+ *
810
+ * Published on both states, as `resolvedWithinFile` is, so a consumer reads one field
811
+ * rather than inferring the negative from an absent one.
812
+ */
813
+ declareAbsenceOfVariableBinding(declSites, name) {
814
+ const cands = declSites.get(name);
815
+ return { variableDeclaredInFile: cands !== void 0 && cands.length > 0 };
816
+ }
817
+ /**
818
+ * THE SHELL'S OWN VARIABLE NAMES, ON THE KEY THE INCOMING GATE READS — the one part of this
819
+ * lane's residual an analyzer can answer.
820
+ *
821
+ * ## The defect this closes is the fleet's finding (a), and this lane had it
822
+ *
823
+ * The fact was already computed: `isShellDefined` (`SHELL_DEFINED_VARS`, the interpreter's own
824
+ * name space) is set on every read of a name the shell binds. But `script/m14/incoming-rule.ts`
825
+ * reads external claims from a CLOSED list of keys (`EXTERNAL_KEYS`, `:56-66`) and
826
+ * `isShellDefined` is not one of them — so on the gate's own 400-file stride **125 unresolved
827
+ * `uses` edges carried the reason and `extOK` read 0**, the spelling problem the fleet brief
828
+ * records for html's `isExternalResource`. The reason is republished here as a CLAIM
829
+ * (`isExternal`, the key BD12 already uses for path-invoked commands) with the evidence naming
830
+ * which list spoke.
831
+ *
832
+ * ## Why this is sound where the CALL marker was withdrawn (and stays withdrawn)
833
+ *
834
+ * `SHELL_DEFINED_VARS` is the shell's name space, not a guess about the corpus: `BASH_SOURCE`,
835
+ * `PIPESTATUS`, `UID`, `SECONDS` … are bound by the interpreter running the script, so no file
836
+ * of any project declares them and the claim cannot be refuted by a sourced library the way a
837
+ * bare command name can (a file may define `grep` as a function — measured 82 of 435 refuted,
838
+ * which is why `externalCommandEvidence` below stayed path-invoked only and
839
+ * `bash/incoming-external-marker-bd12` forbids the wider marker).
840
+ * MEASURED on this population with `probe-bash-residual-causes.mts --repo-wide` (all 3,178
841
+ * admitted files of the 118 sampled repositories parsed into a declaration index): **123 of the
842
+ * 125 marked edges name nothing the project declares; 2 do** (`HOSTNAME` read in
843
+ * `kubernetes/cluster/addons/addon-manager/kube-addons.sh` while another file of that project
844
+ * binds it) — the false-in-fact class, reported rather than hidden, and the same shape the `c`
845
+ * lane published (55 of 2,686, its `zig`/musl counter-example).
846
+ *
847
+ * ## The guard, and why it is not optional
848
+ *
849
+ * NOT EMITTED WHEN THIS FILE ITSELF BINDS THE NAME. `HOSTNAME=$(hostname)` makes a later
850
+ * `$HOSTNAME` a read of a PROJECT variable, and the claim would then be false about the file in
851
+ * hand. The guard asks the question `variableDeclaredInFile` already answers, from the same
852
+ * `declSites` index — one rule, not two.
853
+ *
854
+ * ## What the claim can and cannot do to the table
855
+ *
856
+ * `classifyEdge` reaches the verifier only where the key space declares NOTHING by that name
857
+ * (`incoming-rule.ts:1147`), and a bare identifier is looked up directly (`:815`) — so the
858
+ * outcome is `externalVerified` (the project confirms it) or, if a declaration exists,
859
+ * `externalRefuted`, which counts AGAINST the lane. It can never raise `resolved`, never turn an
860
+ * ambiguous edge into anything, and never touch `collRes` or the binding-intent split.
861
+ */
862
+ externalShellVariableEvidence(name, declSites) {
863
+ if (!SHELL_DEFINED_VARS.has(name)) return {};
864
+ const cands = declSites.get(name);
865
+ if (cands !== void 0 && cands.length > 0) return {};
866
+ return { isExternal: true, externalEvidence: "shellDefinedVariable" };
867
+ }
868
+ /**
869
+ * A `$name` read in a file that also declares `name` as a FUNCTION.
870
+ *
871
+ * `resolveDeclaration` declines here, and correctly so: it is asked "is this name
872
+ * ambiguous in this file?" and `declSites` — variables, aliases and locals, never
873
+ * functions — says no. The ambiguity a consumer actually faces is against the
874
+ * PUBLISHED symbol table, which holds both.
875
+ *
876
+ * The rule is the language's, not a preference: `$name` is a parameter expansion,
877
+ * it reads a variable, and a function is not reachable through `$`. So the
878
+ * function symbol is excluded as a candidate and the SAME outward scope walk
879
+ * `resolveDeclaration` performs runs over what remains. One walk, called twice
880
+ * over two different candidate sets — not a second implementation of the walk,
881
+ * which is how two rules drift apart.
882
+ *
883
+ * Declines, publishing no line, when the walk finds nothing at or before the use
884
+ * site or finds a tie: the same two refusals `resolveDeclaration` makes, for the
885
+ * same reason. In that case the edge carries `ambiguousInFile`, which is the
886
+ * honest row — the name is genuinely claimed by two published declarations and
887
+ * this edge declines to say which.
888
+ */
889
+ resolveReadAgainstFunctionSymbol(declSites, name, useOwner, useLine, fileOwner) {
890
+ const cands = declSites.get(name);
891
+ if (!cands || cands.length === 0) return void 0;
892
+ const scopes = useOwner === fileOwner ? [fileOwner] : [useOwner, fileOwner];
893
+ for (const scope of scopes) {
894
+ const atOrBefore = cands.filter((c) => c.owner === scope && c.line <= useLine);
895
+ if (atOrBefore.length === 0) continue;
896
+ const nearest = Math.max(...atOrBefore.map((c) => c.line));
897
+ const winners = atOrBefore.filter((c) => c.line === nearest);
898
+ if (winners.length !== 1) return { targetResolution: "ambiguousInFile" };
899
+ return {
900
+ targetLine: winners[0].line,
901
+ targetKind: winners[0].kind,
902
+ targetResolution: "readCannotBeAFunction"
903
+ };
904
+ }
905
+ return { targetResolution: "ambiguousInFile" };
906
+ }
907
+ /**
908
+ * Which FUNCTION does a `calls` edge at `useLine` naming `name` reach?
909
+ *
910
+ * The sibling of `resolveDeclaration`, over functions rather than variables, and
911
+ * deliberately not a shared code path: `declSites` keys variables, aliases and
912
+ * locals and never functions, so the two indexes have different contents and a
913
+ * shared body would be a claim about one of them dressed as a claim about both.
914
+ *
915
+ * bash has no nested function scope, so the scope chain `resolveDeclaration`
916
+ * walks does not exist here and is not faked. The language rule is one step:
917
+ * a name resolves to the most recent definition executed before the call, which
918
+ * for a script read top to bottom is the latest definition AT OR BEFORE the call
919
+ * site. `overrides` already publishes that a redefinition happened and both
920
+ * lines; this publishes which one a given call reaches.
921
+ *
922
+ * The three outcomes are the same three `resolveDeclaration` publishes, and they
923
+ * are distinct on purpose — "resolved", "there is more than one at the same
924
+ * line" and "every definition is after the call" are different facts, and a
925
+ * consumer cannot tell them apart if they collapse into one row:
926
+ *
927
+ * latestDefinitionBeforeUse one candidate — `targetLine` + `targetKind`, so
928
+ * the fact can be checked against the symbol table
929
+ * instead of trusted.
930
+ * commandNameIsAFunction exactly ONE function declaration, and the name is
931
+ * also declared as something that is not a function.
932
+ * Position is irrelevant here and deliberately so: a
933
+ * COMMAND position cannot denote a variable at all,
934
+ * so unlike a `uses` edge there is no "declared
935
+ * after the use site" case to decline. Measured at
936
+ * 105 whole-corpus edges (swift-nio/scripts/
937
+ * nio-diagnose:398 `nio_pids` against variable@136
938
+ * and function@151; mvdan-sh/syntax/typedjson/
939
+ * testdata/roundtrip/file.sh `foo` against
940
+ * function@19 and variable@20).
941
+ * commandNameIsExternal NOT this function. Published by the CALLER, not
942
+ * here: `isFunctionCall` is false and the name is
943
+ * declared in this file as something that is not a
944
+ * function, so a name-matching consumer has
945
+ * candidates that are not this edge's target. The
946
+ * sibling of `commandNameIsAFunction` and the same
947
+ * fact seen from the other side — there the
948
+ * function is the answer, here nothing local is.
949
+ * 2 whole-corpus edges (`xargs` in
950
+ * ohmyzsh/plugins/pip/pip.plugin.zsh at 107/115).
951
+ * ambiguousInFile two declarations on the same line, which the
952
+ * published line cannot separate. No line: picking
953
+ * one would be the invented attribution.
954
+ * declaredAfterUse every definition of the name is below the call
955
+ * site, so none of them is in effect there. bash
956
+ * permits the forward reference — the callee may be
957
+ * defined before the caller's body runs — so this
958
+ * says what was observed and publishes no line
959
+ * rather than pretending the text order settles it.
960
+ *
961
+ * The name is only ambiguous at all if there are two or more FUNCTION
962
+ * declarations of it, or one function plus a declaration of another kind. Fewer
963
+ * than that and nothing is added: 55,241 `calls` edges on the whole corpus and
964
+ * only the 236 whose target the published symbol table answers to twice grow a
965
+ * key.
966
+ *
967
+ * `targetKind` is read off the PUBLISHED symbol rather than written as a literal.
968
+ * The first version wrote the literal `"Function"` and the published kind is
969
+ * `"function"` (`SymbolKind.Function`, src/ast/types.ts:11), so every edge it
970
+ * touched carried a `targetKind` that matched nothing: the attribution pointed at
971
+ * a real line and a kind no symbol carries, and a consumer filtering on kind
972
+ * discarded the answer. `probe-bash-published-resolvability.mts` caught it as 6
973
+ * edges where applying the published fields leaves ZERO candidates — which is the
974
+ * worse outcome, because it reads as a dangling claim rather than a wrong kind.
975
+ */
976
+ resolveFunctionTarget(functionDeclLines, name, useLine, namesWithNonFunctionDecl) {
977
+ const cands = functionDeclLines.get(name);
978
+ const collidesWithOtherKind = namesWithNonFunctionDecl.has(name);
979
+ if (!cands) return void 0;
980
+ if (cands.length < 2) {
981
+ if (!collidesWithOtherKind) return void 0;
982
+ const only = cands[0];
983
+ return { targetLine: only.line, targetKind: only.kind, targetResolution: "commandNameIsAFunction" };
984
+ }
985
+ const atOrBefore = cands.filter((c) => c.line <= useLine);
986
+ if (atOrBefore.length === 0) return { targetResolution: "declaredAfterUse" };
987
+ const nearest = Math.max(...atOrBefore.map((c) => c.line));
988
+ const winners = atOrBefore.filter((c) => c.line === nearest);
989
+ if (winners.length !== 1) return { targetResolution: "ambiguousInFile" };
990
+ return {
991
+ targetLine: nearest,
992
+ targetKind: winners[0].kind,
993
+ targetResolution: "latestDefinitionBeforeUse"
994
+ };
995
+ }
996
+ /** Remove quote characters from a path expression. Index arithmetic over the
997
+ * two quote code points; a path cannot contain either. */
998
+ dequotePath(text) {
999
+ let out = "";
1000
+ for (let i = 0; i < text.length; i++) {
1001
+ const c = text.charCodeAt(i);
1002
+ if (c === 34 || c === 39) continue;
1003
+ out += text[i];
1004
+ }
1005
+ return out;
1006
+ }
1007
+ stripQuotes(text) {
1008
+ if (text.length >= 2) {
1009
+ const first = text.charCodeAt(0);
1010
+ const last = text.charCodeAt(text.length - 1);
1011
+ if ((first === 34 || first === 39) && last === first) return text.slice(1, -1);
1012
+ }
1013
+ return text;
1014
+ }
1015
+ commandNameNode(node) {
1016
+ for (let i = 0; i < node.namedChildCount; i++) {
1017
+ const child = node.namedChild(i);
1018
+ if (child && child.type === "command_name") return child;
1019
+ }
1020
+ return null;
1021
+ }
1022
+ commandNameOf(node) {
1023
+ const cn = this.commandNameNode(node);
1024
+ if (!cn) return "";
1025
+ for (let j = 0; j < cn.namedChildCount; j++) {
1026
+ const word = cn.namedChild(j);
1027
+ if (word && word.type === "word") return word.text;
1028
+ }
1029
+ return cn.text;
1030
+ }
1031
+ /**
1032
+ * BD4 (structural): does this node's subtree contain an unexpanded value?
1033
+ * `command_name` holding `$cmd`, `${cmd}` or `$(cmd)` names a command only at
1034
+ * run time, so there is no callee to point at. The test is on the TREE, not
1035
+ * on the text: a character-class test cannot tell `apt-get` from `$pkg_mgr`
1036
+ * without a rule per metacharacter, and the first cut of this guard was
1037
+ * exactly that and swallowed every hyphenated command in the corpus.
1038
+ */
1039
+ containsExpansion(node) {
1040
+ const stack = [node];
1041
+ while (stack.length > 0) {
1042
+ const n = stack.pop();
1043
+ if (n.type === "simple_expansion" || n.type === "expansion" || n.type === "command_substitution" || n.type === "arithmetic_expansion" || n.type === "process_substitution") {
1044
+ return true;
1045
+ }
1046
+ for (let i = 0; i < n.childCount; i++) {
1047
+ const c = n.child(i);
1048
+ if (c) stack.push(c);
1049
+ }
1050
+ }
1051
+ return false;
1052
+ }
1053
+ /**
1054
+ * Is this `command` node an ARITHMETIC OPERAND that the grammar handed back in
1055
+ * command position?
1056
+ *
1057
+ * bash writes an arithmetic expression `$(( ... ))`. The pinned grammar models
1058
+ * it correctly in the ordinary position — `z=$((a + 1))` yields
1059
+ * `arithmetic_expansion` → `binary_expression` → `variable_name`, and there
1060
+ * is no `command` node to misread. It misparses in exactly two shapes, and in
1061
+ * both the operand arrives as a `command` whose `command_name` is a VARIABLE,
1062
+ * so the analyzer was emitting a call to something bash never runs:
1063
+ *
1064
+ * 1. as the default operand of a parameter expansion inside a string —
1065
+ * `last_run="${last_run:-$((attempt + 1))}"` parses as
1066
+ * `expansion` → `command_substitution` → `subshell` → `command` →
1067
+ * `command_name: attempt`, i.e. `$(` plus a subshell where `$((` was meant.
1068
+ * 2. under `!` — `! ((force_known_update))` parses as `negated_command` →
1069
+ * `subshell` → `subshell` → `command` → `command_name`.
1070
+ *
1071
+ * The test is the enclosing node's own text, never the source text: a
1072
+ * `command_substitution` whose text opens `$((` is an arithmetic expansion,
1073
+ * and a `subshell` whose text opens `((` is one too. A real command
1074
+ * substitution is untouched by this — `$(wc -l < f)` inside `$(( ))` opens
1075
+ * `$(w`, so `calls: -> wc` still fires, which is correct, because `wc` really
1076
+ * is run there.
1077
+ *
1078
+ * Measured on the 400-file census sample: 2 occurrences of shape 1 and 1 of
1079
+ * shape 2. Both edges carried `confidence: 1` and
1080
+ * `evidenceClass: structural`, i.e. they were presented as parse facts rather
1081
+ * than guesses, which is what made them worth removing rather than labelling.
1082
+ */
1083
+ isArithmeticOperandCommand(node) {
1084
+ let cur = node.parent;
1085
+ while (cur) {
1086
+ if (cur.type === "command_substitution") return opensWithArithmeticExpansion(cur.text);
1087
+ if (cur.type === "subshell" && isArithmeticCompoundText(cur.text)) return true;
1088
+ cur = cur.parent;
1089
+ }
1090
+ return false;
1091
+ }
1092
+ /**
1093
+ * Is this node inside a CHILD-PROCESS boundary — a subshell, a command
1094
+ * substitution, or a process substitution?
1095
+ *
1096
+ * BD16: `part_of` asserts that the included text shares THIS file's single
1097
+ * shell scope. `( source lib.sh )` runs the inclusion in a forked shell, so
1098
+ * the definitions live and die in that child and the outer file's scope never
1099
+ * sees them; the claim would be false. The boundary is read from the tree
1100
+ * (ancestor node types), never from the text, so `source lib.sh` inside a
1101
+ * function — which DOES run in this shell, because a function is not a
1102
+ * process — is unaffected.
1103
+ *
1104
+ * `bash x.sh` / `sh x.sh` never reach this helper: they are not the
1105
+ * `source`/`.` command at all, they EXECUTE a file and the analyzer already
1106
+ * publishes a `calls` edge to the interpreter.
1107
+ */
1108
+ isInsideChildProcess(node) {
1109
+ let cur = node.parent;
1110
+ while (cur) {
1111
+ if (cur.type === "subshell" || cur.type === "command_substitution" || cur.type === "process_substitution") {
1112
+ return true;
1113
+ }
1114
+ if (cur.type === "program") return false;
1115
+ cur = cur.parent;
1116
+ }
1117
+ return false;
1118
+ }
1119
+ /**
1120
+ * THE PART'S MODULE SYMBOL — the only `part_of` target the arbiter can bind.
1121
+ *
1122
+ * The argument of a `source` is a PATH, and a path is not in the arbiter's
1123
+ * target space: it resolves target STRINGS against SYMBOL NAMES, so a path
1124
+ * target can only ever be unresolved (`partPath` keeps the path as evidence).
1125
+ * The symbol this lane publishes for a file is BD2's module symbol — its
1126
+ * basename WITH its extension — so `source ./lib/helpers.sh` names the part
1127
+ * `helpers.sh`. The stem (`helpers`) is deliberately NOT used: it is the
1128
+ * spelling another lane's module symbol may have, and here it names no symbol
1129
+ * this analyzer publishes.
1130
+ *
1131
+ * Returns "" when the last path segment is an EXPRESSION rather than a name
1132
+ * (`source "$lib"`, `source "$(pick)"`, `source lib/*.sh`). A name that a
1133
+ * symbol can carry is: non-empty, and free of the characters that mark an
1134
+ * unexpanded value or shell syntax. Index arithmetic over code points, which
1135
+ * is the same non-syntactic shape the lane's other text tests use.
1136
+ */
1137
+ partSymbolOfPath(pathText) {
1138
+ const slash = pathText.lastIndexOf("/");
1139
+ const base = slash >= 0 ? pathText.slice(slash + 1) : pathText;
1140
+ if (base.length === 0) return "";
1141
+ for (let i = 0; i < base.length; i++) {
1142
+ const c = base.charCodeAt(i);
1143
+ if (c <= 32 || c === 34 || c === 39 || c === 96 || c === 36 || c === 40 || c === 41 || c === 123 || c === 125 || c === 91 || c === 93 || c === 42 || c === 63 || c === 59 || c === 124 || c === 38 || c === 60 || c === 62 || c === 92) {
1144
+ return "";
1145
+ }
1146
+ }
1147
+ return base;
1148
+ }
1149
+ /**
1150
+ * Is this `command` a `case` arm's PATTERN that the grammar could not close?
1151
+ *
1152
+ * `if ! case "$t" in binary) … ;; source) … ;; esac; then` is a construct the
1153
+ * pinned wasm cannot close, so an arm arrives as
1154
+ *
1155
+ * command @936 "binary) _try_existing binary"
1156
+ * command_name → word "binary" ← the arm's PATTERN, not a command
1157
+ * ERROR → ")" ← the arm terminator the grammar kept
1158
+ * word "_try_existing"
1159
+ *
1160
+ * The structural fact is a POSIX one, not a heuristic: **a `)` can never
1161
+ * immediately follow a command NAME in valid bash.** `(cmd)` puts the `)` on
1162
+ * the subshell, a sibling of the whole command; `echo )` puts it after an
1163
+ * argument or on its own as a `word`. The only way `)` lands directly between
1164
+ * the name and the first argument is a terminator the grammar could not place.
1165
+ *
1166
+ * This is the narrow half that the rejected damage guard (see
1167
+ * `guard-ab-bash-calls.ts`, guard E) lacked. Guard E refuses a command with ANY
1168
+ * direct `ERROR`/missing child and costs 15 real `kube::log::status` calls in
1169
+ * kubernetes/test/e2e_node/gubernator.sh, because `V=2 kube::log::status "…"`
1170
+ * puts a `variable_assignment` in the command's own slot and the grammar puts an
1171
+ * `ERROR` beside it. Keying on the POSITION — immediately after `command_name` —
1172
+ * and on the CHARACTER `)` costs 0 real calls on 400 hash-ordered corpus files
1173
+ * while removing 5 fabrications.
1174
+ *
1175
+ * The wider predicate was measured too and REJECTED, rather than assumed:
1176
+ * "the command has a direct `ERROR`/missing child whose text is `)`" fires on
1177
+ * `echo one two ) three`, where `)` is an ordinary word argument, so it would
1178
+ * delete a real `calls: -> echo`. `guard-f-soundness.mts` asks both predicates
1179
+ * about 39 node shapes; the positional one fires on 0 valid-parse commands, the
1180
+ * wider one on 1.
1181
+ *
1182
+ * **Why this is worth an edge the `calls` guards were declined.** The `binary`,
1183
+ * `git` and `auto` arms produce `calls` edges naming nothing runnable — the
1184
+ * residuals pass 2 named and declined. The `source` arm's pattern is the word
1185
+ * `source`, so that arm's `command` reaches the `source`/`.` branch, which emits
1186
+ * `imports` AND `references` to its first argument:
1187
+ *
1188
+ * gimme -[imports]-> _try_existing
1189
+ * gimme -[references]-> _try_existing
1190
+ *
1191
+ * `_try_existing` is a shell FUNCTION defined at gimme:414, not a file. That is
1192
+ * a fabricated module dependency — the kind `imports` feeds `bb_migrate` and
1193
+ * `bb_impact` — and no prior guard was aimed at it. The shared differential
1194
+ * cannot see it either: `deriveBashEdges` pushes `imports`+`references` for the
1195
+ * same construct, so two walkers over the same tree agree that a function name
1196
+ * is a dependency.
1197
+ */
1198
+ isCaseArmPatternCommand(node) {
1199
+ const name = this.commandNameNode(node);
1200
+ if (!name) return false;
1201
+ const after = name.nextSibling;
1202
+ if (!after) return false;
1203
+ if (after.type !== "ERROR" && !after.isMissing) return false;
1204
+ return after.text.charCodeAt(0) === 41;
1205
+ }
1206
+ /**
1207
+ * A command's name is a WORD, and a plain `word` name is trusted outright.
1208
+ * The two real-world shapes that put something else in `command_name` are
1209
+ * both fabrications: a zsh `case` arm's alternation, and a quoted string or
1210
+ * number the grammar could not classify. Measured on ohmyzsh's
1211
+ * `tools/changelog.sh` the analyzer emitted `calls: -> ]+` and
1212
+ * `calls: -> \e[2m\1\e[0m`; measured on the 385 extensionless files the
1213
+ * shebang rule had just opened (nvm, rbenv, ohmyzsh, otp) it emitted
1214
+ * `calls: -> 129`, `-> 130`, `-> 141`, `-> 143` and `-> 6` — numeric literals
1215
+ * promoted to callees, 18 rows the independent oracle refused.
1216
+ */
1217
+ isRecoveredCommandWord(nameNode) {
1218
+ if (!nameNode) return true;
1219
+ if (nameNode.isMissing) return true;
1220
+ const name = nameNode;
1221
+ const nameIsPlainWord = name.namedChildCount === 0 || name.namedChildCount === 1 && name.namedChild(0)?.type === "word";
1222
+ if (!nameIsPlainWord) {
1223
+ return true;
1224
+ }
1225
+ if (name.text.charCodeAt(0) === 92) return true;
1226
+ return false;
1227
+ }
1228
+ functionName(node) {
1229
+ const named = node.childForFieldName?.("name");
1230
+ if (named && named.text) return named.text;
1231
+ let prev = null;
1232
+ for (let i = 0; i < node.namedChildCount; i++) {
1233
+ const child = node.namedChild(i);
1234
+ if (!child) continue;
1235
+ if (child.type === "compound_statement" && prev && prev.type === "word") {
1236
+ return prev.text;
1237
+ }
1238
+ prev = child;
1239
+ }
1240
+ for (let i = 0; i < node.namedChildCount; i++) {
1241
+ const child = node.namedChild(i);
1242
+ if (child && child.type === "word" && child.text !== "function") return child.text;
1243
+ }
1244
+ return "";
1245
+ }
1246
+ /**
1247
+ * Every function name the file declares, in one linear pass over the
1248
+ * function_definition nodes. Order-independent by construction: a call to a
1249
+ * function defined later in the file resolves the same as one defined above
1250
+ * it, which is the property the pre-order walk cannot provide on its own.
1251
+ */
1252
+ collectFunctionNames(root) {
1253
+ const names = /* @__PURE__ */ new Set();
1254
+ const stack = [root];
1255
+ while (stack.length > 0) {
1256
+ const node = stack.pop();
1257
+ if (node.type === "function_definition") {
1258
+ const name = this.functionName(node);
1259
+ if (name) names.add(name);
1260
+ }
1261
+ for (let i = node.childCount - 1; i >= 0; i--) {
1262
+ const child = node.child(i);
1263
+ if (child) stack.push(child);
1264
+ }
1265
+ }
1266
+ return names;
1267
+ }
1268
+ extractFunction(node, symbols, owner) {
1269
+ const name = this.functionName(node);
1270
+ if (!name) return;
1271
+ symbols.push({
1272
+ name,
1273
+ kind: ASTEngine.SymbolKind.Function,
1274
+ range: this.nodeToRange(node),
1275
+ scope: owner,
1276
+ visibility: "public",
1277
+ ownerName: owner,
1278
+ parameters: [],
1279
+ metadata: { bashSpecific: true, isScriptFunction: true }
1280
+ });
1281
+ }
1282
+ /**
1283
+ * Every variable READ the analyzer can see, at any expansion or arithmetic
1284
+ * site. Positional parameters and shell bookkeeping never become Uses edges.
1285
+ */
1286
+ emitVariableUses(node, symbols, relSource, addRel, declaredSymbols, functionStack, functionParams, declSites, fileOwner) {
1287
+ const names = [];
1288
+ this.collectReadNames(node, names);
1289
+ for (const { rawName, line } of names) {
1290
+ const name = rawName.trim();
1291
+ if (name.length === 0) continue;
1292
+ if (isAllDigits(name)) {
1293
+ if (name === "0") continue;
1294
+ const fn = functionStack[functionStack.length - 1];
1295
+ if (fn) functionParams.get(fn)?.add(name);
1296
+ continue;
1297
+ }
1298
+ if (SPECIAL_VAR_PUNCTUATION.has(name)) continue;
1299
+ addRel(
1300
+ relSource(),
1301
+ name,
1302
+ ASTEngine.RelationshipType.Uses,
1303
+ 0.8,
1304
+ line,
1305
+ {
1306
+ isVariableReference: true,
1307
+ bashSpecific: true,
1308
+ // A read of a name the shell itself defines is recorded as such, so
1309
+ // a consumer can tell `${BASH_SOURCE[0]}` from a project variable
1310
+ // instead of seeing 23 undiagnosable edges to one shell name.
1311
+ ...SHELL_DEFINED_VARS.has(name) ? { isShellDefined: true } : {},
1312
+ // THE SAME FACT ON THE KEY THE INCOMING GATE READS -- see
1313
+ // `externalShellVariableEvidence`. A read of a name the interpreter itself binds is
1314
+ // not a read of a project declaration, and until this the lane published that only
1315
+ // as `isShellDefined`, which `incoming-rule.ts` does not read as a claim.
1316
+ ...this.externalShellVariableEvidence(name, declSites),
1317
+ // Why a `uses` edge does not name a line, when the reason is that this file
1318
+ // binds the name as a variable nowhere. `resolveDeclaration` publishes nothing
1319
+ // in that case and the edge looks identical to one that resolved, so the
1320
+ // population is unreadable from the row alone.
1321
+ ...this.declareAbsenceOfVariableBinding(declSites, name),
1322
+ // WHICH declaration, when this file has more than one. A `uses` edge
1323
+ // puts the READ's line on the row, which is a different line from the
1324
+ // declaration, so without this the declaration is unrecoverable from
1325
+ // the edge and any consumer matching on target alone is choosing
1326
+ // between N declarations by a rule it did not write. Emitted ONLY when
1327
+ // the name is ambiguous in this file — see `resolveDeclaration` for the
1328
+ // three outcomes and why "declined to pick" is published separately
1329
+ // from "picked one".
1330
+ ...this.resolveDeclaration(
1331
+ declSites,
1332
+ name,
1333
+ functionStack[functionStack.length - 1] ?? "",
1334
+ line,
1335
+ fileOwner
1336
+ ) ?? {}
1337
+ }
1338
+ );
1339
+ }
1340
+ void symbols;
1341
+ }
1342
+ /** Variable names read anywhere under `node`, each with the line it is on. */
1343
+ collectReadNames(node, out) {
1344
+ const visit = (n) => {
1345
+ if (n.type === "variable_name" || n.type === "special_variable_name") {
1346
+ const text = n.text;
1347
+ if (text.length > 0) out.push({ rawName: text, line: n.startPosition.row + 1 });
1348
+ return;
1349
+ }
1350
+ if (n.type === "subscript") {
1351
+ for (let j = 0; j < n.namedChildCount; j++) {
1352
+ const c = n.namedChild(j);
1353
+ if (c) visit(c);
1354
+ }
1355
+ return;
1356
+ }
1357
+ for (let i = 0; i < n.namedChildCount; i++) {
1358
+ const c = n.namedChild(i);
1359
+ if (c) visit(c);
1360
+ }
1361
+ };
1362
+ visit(node);
1363
+ }
1364
+ extractAssignment(node, symbols, owner, declaredSymbols, scopeOwners, declSites) {
1365
+ for (let i = 0; i < node.namedChildCount; i++) {
1366
+ const child = node.namedChild(i);
1367
+ let nameNode = null;
1368
+ let elementAssignment = false;
1369
+ if (child && child.type === "variable_name") {
1370
+ nameNode = child;
1371
+ } else if (child && child.type === "subscript") {
1372
+ const base = child.namedChild(0);
1373
+ if (base && base.type === "variable_name") {
1374
+ nameNode = base;
1375
+ elementAssignment = true;
1376
+ }
1377
+ }
1378
+ if (nameNode && nameNode.text.trim() !== "") {
1379
+ const key = `${owner}\0${nameNode.text}`;
1380
+ if (declaredSymbols.has(key)) return;
1381
+ declaredSymbols.add(key);
1382
+ this.noteDeclaration(declSites, nameNode.text, owner, node.startPosition.row + 1, "variable");
1383
+ const isArray = elementAssignment || this.valueIsArray(node);
1384
+ symbols.push({
1385
+ name: nameNode.text,
1386
+ kind: ASTEngine.SymbolKind.Variable,
1387
+ range: this.nodeToRange(node),
1388
+ scope: owner,
1389
+ visibility: this.visibilityForOwner(owner, scopeOwners),
1390
+ ownerName: owner || void 0,
1391
+ metadata: { bashSpecific: true, isArray, isTopLevel: owner === "" }
1392
+ });
1393
+ return;
1394
+ }
1395
+ }
1396
+ }
1397
+ /**
1398
+ * A binding the language creates without an assignment node.
1399
+ *
1400
+ * `extractAssignment` and `extractDeclaration` cover the two node types that
1401
+ * carry an explicit `NAME=`. These four constructs bind a name and carry
1402
+ * neither, so before this existed they produced a `uses` edge to a target the
1403
+ * analyzer had not published — the edge asserted a read of a declaration that
1404
+ * did not exist in the graph. Measured on a 400-file hash-ordered sample:
1405
+ * 234 binding sites across 96 distinct files, and 357 `uses` edges naming a
1406
+ * target one of them declared. Adding this closed all 357 — the unresolvable
1407
+ * `uses` count fell 1,920 -> 1,563 and the `uses` total stayed at 7,802.
1408
+ *
1409
+ * The rule throughout is DECLINE, NEVER GUESS. A target counts only when it is
1410
+ * a `word` sibling that is a shell identifier, because the pinned grammar
1411
+ * represents a computed name as a different node and a literal name as a
1412
+ * `word` — so the type test is the whole filter. Every one of these was
1413
+ * verified on the tree rather than assumed, in
1414
+ * `validation/m14/fixtures/bash/probe-bash-binding-shapes.mts`:
1415
+ *
1416
+ * read "$target" -> `string` declined
1417
+ * read pre${n}fix -> `concatenation` declined
1418
+ * printf -v "$2" -> `string` declined
1419
+ * mapfile -t "$name" -> `string` declined
1420
+ * read *.txt -> `word` declined, not an identifier
1421
+ * read -> (no target) declined; bash assigns REPLY,
1422
+ * which is in SHELL_DEFINED_VARS
1423
+ * # read x -> `comment` declined
1424
+ * echo "read x" -> `string_content` declined
1425
+ * cat <<'EOF' … read x … -> `heredoc_body` declined
1426
+ *
1427
+ * The last three are why this reads the tree: a text matcher would have
1428
+ * declared a variable from a comment, a string and a heredoc.
1429
+ */
1430
+ extractImplicitBinding(node, symbols, owner, declaredSymbols, scopeOwners, declSites) {
1431
+ const publish = (name, at, site, isArray) => {
1432
+ if (!isShellIdentifierName(name)) return;
1433
+ const key = `${owner}\0${name}`;
1434
+ if (declaredSymbols.has(key)) return;
1435
+ declaredSymbols.add(key);
1436
+ this.noteDeclaration(declSites, name, owner, at.startPosition.row + 1, "variable");
1437
+ symbols.push({
1438
+ name,
1439
+ kind: ASTEngine.SymbolKind.Variable,
1440
+ range: this.nodeToRange(at),
1441
+ scope: owner,
1442
+ visibility: this.visibilityForOwner(owner, scopeOwners),
1443
+ ownerName: owner || void 0,
1444
+ metadata: { bashSpecific: true, isImplicitBinding: true, bindingSite: site, isArray }
1445
+ });
1446
+ };
1447
+ if (node.type === "for_statement") {
1448
+ for (let i = 0; i < node.namedChildCount; i++) {
1449
+ const child = node.namedChild(i);
1450
+ if (child && child.type === "variable_name") publish(child.text, child, "for", false);
1451
+ }
1452
+ return;
1453
+ }
1454
+ const cmd = this.commandNameOf(node);
1455
+ if (cmd.length === 0) return;
1456
+ if (READ_TARGET_COMMANDS.has(cmd)) {
1457
+ let skipNext = false;
1458
+ for (let i = 0; i < node.namedChildCount; i++) {
1459
+ const child = node.namedChild(i);
1460
+ if (!child || child.type === "command_name") continue;
1461
+ if (child.type === "variable_assignment") continue;
1462
+ const isOption = child.type === "word" && child.text.length > 1 && child.text.charCodeAt(0) === 45 && child.text.charCodeAt(1) !== 45;
1463
+ if (isOption) {
1464
+ const lastIndex = child.text.length - 1;
1465
+ let consumesNext = false;
1466
+ for (let j = 1; j < child.text.length; j++) {
1467
+ const letter = child.text[j];
1468
+ if (!READ_OPTION_LETTERS.has(letter)) continue;
1469
+ if (READ_VALUE_OPT_LETTERS.has(letter)) {
1470
+ consumesNext = j === lastIndex;
1471
+ break;
1472
+ }
1473
+ }
1474
+ skipNext = consumesNext;
1475
+ continue;
1476
+ }
1477
+ if (skipNext) {
1478
+ skipNext = false;
1479
+ continue;
1480
+ }
1481
+ publish(child.text, child, cmd, cmd !== "read");
1482
+ }
1483
+ return;
1484
+ }
1485
+ if (cmd === "printf") {
1486
+ for (let i = 0; i < node.namedChildCount - 1; i++) {
1487
+ const child = node.namedChild(i);
1488
+ if (!child || child.type !== "word" || child.text !== "-v") continue;
1489
+ const target = node.namedChild(i + 1);
1490
+ if (target && target.type === "word") publish(target.text, target, "printf -v", false);
1491
+ return;
1492
+ }
1493
+ }
1494
+ }
1495
+ valueIsArray(node) {
1496
+ for (let i = 0; i < node.namedChildCount; i++) {
1497
+ const child = node.namedChild(i);
1498
+ if (!child) continue;
1499
+ if (child.type === "array") return true;
1500
+ if (child.type === "variable_name") continue;
1501
+ if (child.type === "word" || child.type === "string" || child.type === "concatenation") continue;
1502
+ return false;
1503
+ }
1504
+ return false;
1505
+ }
1506
+ /**
1507
+ * A declaration inside a function is private to it; one at the script's top
1508
+ * level is part of the script's surface.
1509
+ *
1510
+ * The lookup is a Set membership test, not a scan of the symbol list. The
1511
+ * scan was the last O(n^2) on the hot path: this is called for EVERY
1512
+ * assignment and EVERY `local`/`declare`, so a file with D declarations and S
1513
+ * symbols spent O(D x S) deciding a field that answers to one question — "is
1514
+ * this owner a function?". The answer is already known: the caller collected
1515
+ * the file's function names in one linear pre-pass (BD10) precisely so that
1516
+ * this kind of question is O(1). `alias` names join the same set, because an
1517
+ * alias is callable and a `local` inside one is equally private.
1518
+ */
1519
+ visibilityForOwner(owner, scopeOwners) {
1520
+ return scopeOwners.has(owner) ? "private" : "public";
1521
+ }
1522
+ extractDeclaration(node, symbols, owner, functionParams, declaredSymbols, scopeOwners, addRel, declSites, patterns) {
1523
+ const keyword = node.child(0)?.text ?? "";
1524
+ if (!DECLARATION_KEYWORDS.has(keyword)) return;
1525
+ const hasFunctionFlag = (() => {
1526
+ for (let i = 0; i < node.namedChildCount; i++) {
1527
+ const child = node.namedChild(i);
1528
+ if (child && child.type === "word" && child.text === "-f") return true;
1529
+ }
1530
+ return false;
1531
+ })();
1532
+ const isArrayDecl = keyword === "declare" || keyword === "typeset";
1533
+ let sawArrayFlag = false;
1534
+ let sawExportFlag = false;
1535
+ if (isArrayDecl) {
1536
+ for (let i = 0; i < node.namedChildCount; i++) {
1537
+ const child = node.namedChild(i);
1538
+ if (!child || child.type !== "word") continue;
1539
+ if (child.text === "-a") sawArrayFlag = true;
1540
+ if (child.text === "-x") sawExportFlag = true;
1541
+ }
1542
+ }
1543
+ const isExport = keyword === "export" || sawExportFlag;
1544
+ const emit = (name, range, line) => {
1545
+ if (!name || name.trim() === "") return;
1546
+ if (!isShellIdentifierName(name)) return;
1547
+ if (isExport) {
1548
+ addRel(owner, name, ASTEngine.RelationshipType.Exports, 1, line, {
1549
+ bashSpecific: true,
1550
+ exportForm: "variable"
1551
+ });
1552
+ }
1553
+ const key = `${owner}\0${name}`;
1554
+ if (declaredSymbols.has(key)) return;
1555
+ declaredSymbols.add(key);
1556
+ this.noteDeclaration(declSites, name, owner, line, isExport || keyword === "readonly" ? "constant" : "variable");
1557
+ if (isExport || keyword === "readonly") {
1558
+ symbols.push({
1559
+ name,
1560
+ kind: ASTEngine.SymbolKind.Constant,
1561
+ range,
1562
+ scope: owner,
1563
+ visibility: "public",
1564
+ ownerName: owner || void 0,
1565
+ metadata: {
1566
+ bashSpecific: true,
1567
+ isExported: isExport,
1568
+ isReadonly: keyword === "readonly"
1569
+ }
1570
+ });
1571
+ } else {
1572
+ symbols.push({
1573
+ name,
1574
+ kind: ASTEngine.SymbolKind.Variable,
1575
+ range,
1576
+ scope: owner,
1577
+ visibility: this.visibilityForOwner(owner, scopeOwners),
1578
+ ownerName: owner || void 0,
1579
+ metadata: {
1580
+ bashSpecific: true,
1581
+ isLocal: keyword === "local",
1582
+ isDeclared: keyword === "declare" || keyword === "typeset",
1583
+ isArray: sawArrayFlag
1584
+ }
1585
+ });
1586
+ }
1587
+ };
1588
+ for (let i = 0; i < node.namedChildCount; i++) {
1589
+ const child = node.namedChild(i);
1590
+ if (!child) continue;
1591
+ const childLine = child.startPosition.row + 1;
1592
+ if (child.type === "variable_name") {
1593
+ if (hasFunctionFlag) {
1594
+ this.markFunctionExported(symbols, child.text);
1595
+ addRel(owner, child.text, ASTEngine.RelationshipType.Exports, 1, childLine, {
1596
+ bashSpecific: true,
1597
+ exportForm: "function"
1598
+ });
1599
+ } else {
1600
+ emit(child.text, this.nodeToRange(child), childLine);
1601
+ }
1602
+ } else if (child.type === "variable_assignment") {
1603
+ if (hasFunctionFlag) {
1604
+ for (let j = 0; j < child.namedChildCount; j++) {
1605
+ const vn = child.namedChild(j);
1606
+ if (vn && vn.type === "variable_name") {
1607
+ this.markFunctionExported(symbols, vn.text);
1608
+ addRel(owner, vn.text, ASTEngine.RelationshipType.Exports, 1, childLine, {
1609
+ bashSpecific: true,
1610
+ exportForm: "function"
1611
+ });
1612
+ }
1613
+ }
1614
+ continue;
1615
+ }
1616
+ for (let j = 0; j < child.namedChildCount; j++) {
1617
+ const vn = child.namedChild(j);
1618
+ if (vn && vn.type === "variable_name") {
1619
+ emit(vn.text, this.nodeToRange(child), childLine);
1620
+ if (keyword === "local" && owner) {
1621
+ const valueNode = child.namedChild(1);
1622
+ this.collectPositionalFromValue(valueNode, functionParams, owner);
1623
+ }
1624
+ }
1625
+ }
1626
+ } else if (child.type === "word" && !child.text.startsWith("-") && child.text !== keyword) {
1627
+ if (hasFunctionFlag) {
1628
+ this.markFunctionExported(symbols, child.text);
1629
+ addRel(owner, child.text, ASTEngine.RelationshipType.Exports, 1, childLine, {
1630
+ bashSpecific: true,
1631
+ exportForm: "function"
1632
+ });
1633
+ } else {
1634
+ emit(child.text, this.nodeToRange(child), childLine);
1635
+ }
1636
+ } else if (child.type === "string" || child.type === "concatenation") {
1637
+ const name = this.staticNameOfDeclarationString(child);
1638
+ if (name) {
1639
+ if (hasFunctionFlag) {
1640
+ this.markFunctionExported(symbols, name);
1641
+ addRel(owner, name, ASTEngine.RelationshipType.Exports, 1, childLine, {
1642
+ bashSpecific: true,
1643
+ exportForm: "function"
1644
+ });
1645
+ } else {
1646
+ emit(name, this.nodeToRange(child), childLine);
1647
+ }
1648
+ } else if (isExport) {
1649
+ patterns.add(COMPUTED_EXPORT_NAME_PATTERN);
1650
+ }
1651
+ }
1652
+ }
1653
+ }
1654
+ /**
1655
+ * BD11 -- the statically-known name inside a declaration whose whole argument is ONE string
1656
+ * node (`export "NAME=value"`, `export "NAME"`, `export 'NAME=value'`).
1657
+ *
1658
+ * The name is read from the node's LITERAL run: the characters up to the first `=` that are
1659
+ * produced by `string_content`/`word`/`raw_string`, with the quotes removed. The moment an
1660
+ * expansion appears before the `=`, the name is decided at RUN TIME and `null` is returned --
1661
+ * there is no name to publish, and publishing the template would be a target no symbol can
1662
+ * ever have.
1663
+ *
1664
+ * FAIL-CLOSED: an unrecognised child shape ends the literal run rather than contributing to
1665
+ * it, so a grammar change makes this quieter instead of wrong. `isShellIdentifierName` is the
1666
+ * same identifier test the rest of the declaration path uses, so a value that is not a shell
1667
+ * name (`export "1=2"`, `export "a b=c"`) is refused here too rather than published.
1668
+ */
1669
+ staticNameOfDeclarationString(node) {
1670
+ if (node.namedChildCount === 0) return this.nameFromLiteralRun(this.stripQuotes(node.text));
1671
+ let literal = "";
1672
+ let computed = false;
1673
+ const scan = (n) => {
1674
+ for (let i = 0; i < n.namedChildCount && !computed; i++) {
1675
+ const c = n.namedChild(i);
1676
+ if (!c) continue;
1677
+ switch (c.type) {
1678
+ case "simple_expansion":
1679
+ case "expansion":
1680
+ case "command_substitution":
1681
+ case "arithmetic_expansion":
1682
+ computed = true;
1683
+ break;
1684
+ case "concatenation":
1685
+ case "string":
1686
+ scan(c);
1687
+ break;
1688
+ default:
1689
+ literal += this.stripQuotes(c.text);
1690
+ break;
1691
+ }
1692
+ if (literal.includes("=")) return;
1693
+ }
1694
+ };
1695
+ scan(node);
1696
+ if (computed) return null;
1697
+ return this.nameFromLiteralRun(literal);
1698
+ }
1699
+ /** The identifier before the first `=` in a literal run, or the whole run when it holds no
1700
+ * `=` (a quoted BARE name: `export "NAME"`). Anything that is not a shell identifier is
1701
+ * refused -- including the empty string. */
1702
+ nameFromLiteralRun(literal) {
1703
+ const eq = literal.indexOf("=");
1704
+ const name = eq >= 0 ? literal.slice(0, eq) : literal;
1705
+ return isShellIdentifierName(name) ? name : null;
1706
+ }
1707
+ collectPositionalFromValue(node, functionParams, fnName) {
1708
+ if (!node) return;
1709
+ const scan = (n) => {
1710
+ if (n.type === "simple_expansion") {
1711
+ for (let i = 0; i < n.namedChildCount; i++) {
1712
+ const c = n.namedChild(i);
1713
+ if (c && c.type === "variable_name" && c.text.length > 0 && c.text.charCodeAt(0) >= 48 && c.text.charCodeAt(0) <= 57) {
1714
+ functionParams.get(fnName)?.add(c.text);
1715
+ }
1716
+ }
1717
+ }
1718
+ for (let i = 0; i < n.namedChildCount; i++) {
1719
+ const child = n.namedChild(i);
1720
+ if (child) scan(child);
1721
+ }
1722
+ };
1723
+ scan(node);
1724
+ }
1725
+ markFunctionExported(symbols, name) {
1726
+ for (let i = 0; i < symbols.length; i++) {
1727
+ const s = symbols[i];
1728
+ if (s.kind === ASTEngine.SymbolKind.Function && s.name === name) {
1729
+ s.metadata = { ...s.metadata, isExported: true };
1730
+ s.visibility = "public";
1731
+ return;
1732
+ }
1733
+ }
1734
+ }
1735
+ extractCommand(node, symbols, imports, patterns, ctx) {
1736
+ const nameNode = this.commandNameNode(node);
1737
+ if (!nameNode) return;
1738
+ const cmdName = this.commandNameOf(node);
1739
+ if (!cmdName) return;
1740
+ const nameLine = nameNode.startPosition.row + 1;
1741
+ if (this.isRecoveredCommandWord(nameNode)) {
1742
+ patterns.add("Recovered Parse Region");
1743
+ return;
1744
+ }
1745
+ if (this.containsExpansion(nameNode) || !isCommandNameText(cmdName)) {
1746
+ patterns.add(SHELL_RESERVED_WORDS.has(cmdName) ? "Recovered Parse Region" : "Dynamic Command Name");
1747
+ return;
1748
+ }
1749
+ if (this.isArithmeticOperandCommand(node)) {
1750
+ return;
1751
+ }
1752
+ if (this.isCaseArmPatternCommand(node)) {
1753
+ patterns.add("Recovered Parse Region");
1754
+ return;
1755
+ }
1756
+ if (cmdName === "docker" || cmdName === "docker-compose" || cmdName === "kubectl" || cmdName === "helm") {
1757
+ patterns.add("Container/Orchestration Script");
1758
+ patterns.add("Deployment Script");
1759
+ } else if (cmdName === "aws" || cmdName === "gcloud" || cmdName === "az" || cmdName === "terraform") {
1760
+ patterns.add("Cloud Infrastructure Script");
1761
+ } else if (cmdName === "npm" || cmdName === "yarn" || cmdName === "pnpm" || cmdName === "cargo" || cmdName === "go") {
1762
+ patterns.add("Build/Compilation Script");
1763
+ } else if (cmdName === "curl" || cmdName === "wget") {
1764
+ patterns.add("Network Request Script");
1765
+ } else if (cmdName === "eval") {
1766
+ ctx.setFlag.eval(true);
1767
+ patterns.add("Dynamic Code Execution");
1768
+ } else if (cmdName === "sudo") {
1769
+ ctx.setFlag.sudo(true);
1770
+ patterns.add("Privilege Escalation");
1771
+ }
1772
+ if (cmdName === "source" || cmdName === ".") {
1773
+ const arg = this.firstArgumentWord(node);
1774
+ if (arg) {
1775
+ const line = this.firstArgumentLine(node) ?? nameLine;
1776
+ const argNode = this.firstArgumentNode(node);
1777
+ const dynamic = argNode ? this.containsExpansion(argNode) : false;
1778
+ const normalized = this.dequotePath(arg);
1779
+ imports.push({
1780
+ module: normalized,
1781
+ items: ["*"],
1782
+ isDefaultImport: true,
1783
+ isNamespaceImport: false,
1784
+ range: this.nodeToRange(node),
1785
+ metadata: {
1786
+ isSourceImport: true,
1787
+ resolution: dynamic ? "dynamic" : "literal"
1788
+ }
1789
+ });
1790
+ const inChildProcess = this.isInsideChildProcess(node);
1791
+ const partSymbol = inChildProcess ? "" : this.partSymbolOfPath(normalized);
1792
+ if (partSymbol.length > 0) {
1793
+ ctx.addRel(ctx.scriptName, partSymbol, ASTEngine.RelationshipType.PartOf, 1, line, {
1794
+ // MANDATORY on every `part_of` row: the direction is the
1795
+ // directive's own, and this file carries it and names the part.
1796
+ partRole: "library",
1797
+ partPath: normalized,
1798
+ partSymbol,
1799
+ resolution: dynamic ? "dynamic" : "literal",
1800
+ isSourcePart: true,
1801
+ isSourceInclusion: true,
1802
+ bashSpecific: true,
1803
+ evidenceClass: "import_based"
1804
+ });
1805
+ } else {
1806
+ patterns.add(inChildProcess ? "Child-Process Inclusion" : "Dynamic Source Path");
1807
+ }
1808
+ ctx.addRel(ctx.relSource(), normalized, ASTEngine.RelationshipType.References, 1, line, {
1809
+ isSourceDependency: true,
1810
+ resolution: dynamic ? "dynamic" : "literal",
1811
+ bashSpecific: true
1812
+ });
1813
+ }
1814
+ return;
1815
+ }
1816
+ if (cmdName === "export") {
1817
+ this.handleExportCommand(node, symbols, ctx);
1818
+ return;
1819
+ }
1820
+ if (cmdName === "alias") {
1821
+ const aliasName = this.aliasNameOf(node);
1822
+ if (aliasName) {
1823
+ symbols.push({
1824
+ name: aliasName,
1825
+ kind: ASTEngine.SymbolKind.Function,
1826
+ range: this.nodeToRange(node),
1827
+ scope: ctx.scriptName,
1828
+ visibility: "public",
1829
+ ownerName: ctx.scriptName,
1830
+ metadata: { isAlias: true, bashSpecific: true }
1831
+ });
1832
+ ctx.localFunctions.add(aliasName);
1833
+ ctx.scopeOwners.add(aliasName);
1834
+ }
1835
+ return;
1836
+ }
1837
+ if (cmdName === "trap") {
1838
+ this.handleTrap(node, ctx, patterns, nameLine);
1839
+ return;
1840
+ }
1841
+ if (cmdName === "set") {
1842
+ if (this.hasErrexitFlags(node)) {
1843
+ ctx.setFlag.strictError(true);
1844
+ patterns.add("Strict Error Handling");
1845
+ }
1846
+ return;
1847
+ }
1848
+ if (cmdName === "chmod" && this.hasArgument(node, "777")) {
1849
+ ctx.setFlag.chmod777(true);
1850
+ patterns.add("Insecure Permissions");
1851
+ }
1852
+ if (cmdName === "rm" && this.isRmRoot(node)) {
1853
+ ctx.setFlag.rmRoot(true);
1854
+ patterns.add("Destructive File Operation");
1855
+ }
1856
+ if (SHELL_BUILTINS.has(cmdName)) return;
1857
+ const isLocal = ctx.localFunctions.has(cmdName);
1858
+ ctx.addRel(ctx.relSource(), cmdName, ASTEngine.RelationshipType.Calls, 1, nameLine, {
1859
+ // The previous emitter set BOTH flags to `true` on every call, so a call
1860
+ // to a function defined three lines above claimed to be an external
1861
+ // command. The two are now mutually exclusive facts.
1862
+ isFunctionCall: isLocal,
1863
+ isExternalCommand: !isLocal,
1864
+ resolvedWithinFile: isLocal,
1865
+ // Where this command node sits, so the attribution pass can tell a name
1866
+ // collision from wreckage. Published on true only, and it is a statement
1867
+ // about the node's POSITION rather than a verdict on the edge — round 13
1868
+ // measured 427 function names whose `name:` field sits inside an ERROR span
1869
+ // and every one is a real declaration, the span reaching back over intact
1870
+ // code. So this flag never removes an edge. It exists because the
1871
+ // attribution pass runs AFTER the walk and would otherwise have to re-derive
1872
+ // the shape from a node it no longer holds.
1873
+ commandInErrorRecovery: this.isInsideErrorNode(node) || void 0,
1874
+ ...this.externalCommandEvidence(cmdName, isLocal),
1875
+ bashSpecific: true
1876
+ });
1877
+ }
1878
+ /**
1879
+ * Is this node a DESCENDANT of a tree-sitter `ERROR` node?
1880
+ *
1881
+ * The narrow half of what round 3's guard B would have tested, and the half that
1882
+ * survives its own measurement. Guard B was "any damaged region — subtree or
1883
+ * ancestor chain" and it removed 96 of 2,945 `calls` edges on a 200-file sample,
1884
+ * 15 of them real `kube::log::status` calls, because a MISSING token anywhere in
1885
+ * a file condemns every command under it.
1886
+ *
1887
+ * Re-measured at whole corpus with the analyzer's OWN selector for the command
1888
+ * name (`commandNameNode`, which scans all named children — the first version of
1889
+ * the probe read `namedChild(0)` and so missed the very shape round 3 named):
1890
+ *
1891
+ * 2,891 `calls` edges come from a command inside an ERROR subtree, and the
1892
+ * removal set is `setopt`, `print`, `echoti`, `autoload`, `emulate`, `title`,
1893
+ * `terminal-notifier`, `notify-send` — real work, overwhelmingly zsh.
1894
+ *
1895
+ * So the flag is published and NO guard is built on it. The three whole-corpus
1896
+ * edges it would have caught are named in the lane doc as residuals, because the
1897
+ * alternative is deleting 2,891 correct edges to remove 3.
1898
+ */
1899
+ isInsideErrorNode(node) {
1900
+ for (let cur = node.parent; cur; cur = cur.parent) {
1901
+ if (cur.type === "ERROR") return true;
1902
+ }
1903
+ return false;
1904
+ }
1905
+ handleExportCommand(node, symbols, ctx) {
1906
+ for (let i = 0; i < node.namedChildCount; i++) {
1907
+ const child = node.namedChild(i);
1908
+ if (!child || child.type !== "word" || child.text !== "-f") continue;
1909
+ for (let j = 0; j < node.namedChildCount; j++) {
1910
+ const word = node.namedChild(j);
1911
+ if (word && word.type === "word" && !word.text.startsWith("-") && word.text !== "export") {
1912
+ this.markFunctionExported(symbols, word.text);
1913
+ ctx.addRel(ctx.relSource(), word.text, ASTEngine.RelationshipType.Exports, 1, child.startPosition.row + 1, {
1914
+ bashSpecific: true,
1915
+ exportForm: "function"
1916
+ });
1917
+ }
1918
+ }
1919
+ return;
1920
+ }
1921
+ for (let i = 0; i < node.namedChildCount; i++) {
1922
+ const child = node.namedChild(i);
1923
+ if (child && child.type === "variable_assignment") {
1924
+ for (let j = 0; j < child.namedChildCount; j++) {
1925
+ const vn = child.namedChild(j);
1926
+ if (vn && vn.type === "variable_name") {
1927
+ symbols.push({
1928
+ name: vn.text,
1929
+ kind: ASTEngine.SymbolKind.Constant,
1930
+ range: this.nodeToRange(child),
1931
+ scope: ctx.relSource(),
1932
+ visibility: "public",
1933
+ ownerName: ctx.relSource(),
1934
+ metadata: { isExported: true, bashSpecific: true }
1935
+ });
1936
+ ctx.addRel(ctx.relSource(), vn.text, ASTEngine.RelationshipType.Exports, 1, child.startPosition.row + 1, {
1937
+ bashSpecific: true,
1938
+ exportForm: "variable"
1939
+ });
1940
+ }
1941
+ }
1942
+ }
1943
+ }
1944
+ }
1945
+ aliasNameOf(node) {
1946
+ for (let i = 0; i < node.namedChildCount; i++) {
1947
+ const child = node.namedChild(i);
1948
+ if (!child) continue;
1949
+ const text = child.type === "concatenation" ? child.text : child.type === "word" ? child.text : "";
1950
+ if (text) {
1951
+ const eq = text.indexOf("=");
1952
+ if (eq > 0) {
1953
+ const name = text.slice(0, eq);
1954
+ if (name.trim() !== "" && !name.startsWith("-")) return name;
1955
+ }
1956
+ }
1957
+ }
1958
+ return "";
1959
+ }
1960
+ /**
1961
+ * BD3: `trap 'handler' SIGNAL`. A signal is not code and was used as the
1962
+ * edge SOURCE, which produced `calls: EXIT -> cleanup` — a caller that does
1963
+ * not exist. The handler is a command string; the edge belongs to the scope
1964
+ * that installs the trap, the target is the handler's command NAME, and the
1965
+ * signal rides along in metadata.
1966
+ */
1967
+ /**
1968
+ * THE INCOMING MARKER -- `isExternal`, and the evidence that is allowed to carry it.
1969
+ *
1970
+ * `script/m14/incoming-rule.ts` treats an external target as a DECLARED EXCEPTION rather than
1971
+ * a defect, but only when the lane MARKS it, and the marker is a claim the corpus checks: an
1972
+ * unmarked external target and a broken edge are otherwise indistinguishable. MEASURED before
1973
+ * this method existed (`incoming-gate.ts bash sh 60`): 855 unresolved of 2,590 edges, of which
1974
+ * 631 are `calls`, and ZERO edges carried any external marker -- the lane was asserting
1975
+ * nothing about the half of its graph that leaves the repository.
1976
+ *
1977
+ * A FIRST, WIDER VERSION OF THIS RULE WAS MEASURED AND WITHDRAWN, and the measurement is the
1978
+ * reason this one is narrow. It marked every call in a file that contains no `source`/`.` and
1979
+ * no `eval`, on the argument that bash can then only resolve the name through PATH. On the
1980
+ * gate's own population that marked 435 edges and an audit of the marked set
1981
+ * (`probe-bash-incoming-split.mts`, whole-project declaration index) found **82 of them name
1982
+ * a `function_definition` the same repository declares** -- 62 of them
1983
+ * `kube::test::get_object_assert` in kubernetes. The argument is false for a LIBRARY file: a
1984
+ * file that sources nothing may itself be sourced by another file that has already defined the
1985
+ * name, so "this file cannot define it" does not mean "the project cannot supply it". A marker
1986
+ * that is wrong 18.9% of the time is worse than no marker, because the number it removes stops
1987
+ * meaning anything.
1988
+ *
1989
+ * So one shape remains, and it is a fact about the NAME rather than about the file:
1990
+ *
1991
+ * PATH-INVOKED (`/bin/expr`, `./otp_build`) the command word contains `/`, and a shell
1992
+ * function name cannot contain `/`. The shell is being handed a FILE to execute; the
1993
+ * target can never be a project declaration, whatever else the file does.
1994
+ *
1995
+ * WHAT IS NOT MARKED, and it is the honest half: an unqualified call to a name this file does
1996
+ * not define. Whether the project declares it elsewhere is a fact about the CORPUS, which a
1997
+ * single-file analyzer does not have, and the wider rule's 82 refuted claims are the price of
1998
+ * pretending otherwise. Those edges stay in `unresolved` and are reported there -- the bash
1999
+ * lane's residual is a measured number, not a laundered one.
2000
+ */
2001
+ externalCommandEvidence(cmdName, isLocal) {
2002
+ if (isLocal) return {};
2003
+ if (!cmdName.includes("/")) return {};
2004
+ if (cmdName.startsWith("/")) return { isExternal: true, externalEvidence: "pathInvokedCommand" };
2005
+ if (cmdName.length >= 3 && (cmdName.charCodeAt(0) >= 65 && cmdName.charCodeAt(0) <= 90 || cmdName.charCodeAt(0) >= 97 && cmdName.charCodeAt(0) <= 122) && cmdName.charCodeAt(1) === 58 && (cmdName.charCodeAt(2) === 92 || cmdName.charCodeAt(2) === 47)) return { isExternal: true, externalEvidence: "pathInvokedCommand" };
2006
+ return {};
2007
+ return { isExternal: true, externalEvidence: "pathInvokedCommand" };
2008
+ }
2009
+ handleTrap(node, ctx, patterns, nameLine) {
2010
+ let handlerText = "";
2011
+ const signals = [];
2012
+ for (let i = 0; i < node.namedChildCount; i++) {
2013
+ const child = node.namedChild(i);
2014
+ if (!child) continue;
2015
+ const type = child.type;
2016
+ if (type === "raw_string" || type === "string") {
2017
+ const inner = this.stripQuotes(child.text);
2018
+ if (!handlerText) handlerText = inner;
2019
+ } else if (type === "word") {
2020
+ if (child.text === "trap") continue;
2021
+ if (child.text.startsWith("-")) continue;
2022
+ if (!handlerText) handlerText = child.text;
2023
+ else signals.push(child.text);
2024
+ }
2025
+ }
2026
+ if (!handlerText) return;
2027
+ const parts = handlerText.split(";");
2028
+ let emittedAny = false;
2029
+ for (const rawPart of parts) {
2030
+ const part = rawPart.trim();
2031
+ if (part.length === 0) continue;
2032
+ const firstToken = this.firstTokenOf(part);
2033
+ if (!firstToken || !isCommandNameText(firstToken)) continue;
2034
+ if (SHELL_BUILTINS.has(firstToken)) continue;
2035
+ const isLocal = ctx.localFunctions.has(firstToken);
2036
+ ctx.addRel(ctx.relSource(), firstToken, ASTEngine.RelationshipType.Calls, 0.7, nameLine, {
2037
+ isTrapHandler: true,
2038
+ viaTrap: true,
2039
+ isFunctionCall: isLocal,
2040
+ isExternalCommand: !isLocal,
2041
+ resolvedWithinFile: isLocal,
2042
+ signals: signals.length > 0 ? signals : ["EXIT"],
2043
+ ...this.externalCommandEvidence(firstToken, isLocal),
2044
+ bashSpecific: true
2045
+ });
2046
+ emittedAny = true;
2047
+ }
2048
+ if (!emittedAny) {
2049
+ patterns.add("Signal Handler");
2050
+ }
2051
+ }
2052
+ /** The first shell token of a handler string: leading `name`, `name=value` and
2053
+ * `name(args)` shapes all yield the command name. */
2054
+ firstTokenOf(part) {
2055
+ let i = 0;
2056
+ while (i < part.length) {
2057
+ const start = i;
2058
+ while (i < part.length && part.charCodeAt(i) !== 32 && part.charCodeAt(i) !== 61) i++;
2059
+ const token = part.slice(start, i);
2060
+ if (i < part.length && part.charCodeAt(i) === 61) {
2061
+ i++;
2062
+ while (i < part.length && part.charCodeAt(i) === 32) i++;
2063
+ continue;
2064
+ }
2065
+ if (i < part.length && part.charCodeAt(i) === 40) break;
2066
+ return token;
2067
+ }
2068
+ return "";
2069
+ }
2070
+ hasErrexitFlags(node) {
2071
+ let sawO = false;
2072
+ for (let i = 0; i < node.namedChildCount; i++) {
2073
+ const child = node.namedChild(i);
2074
+ if (!child || child.type !== "word") continue;
2075
+ const text = child.text;
2076
+ if (text === "-o") {
2077
+ sawO = true;
2078
+ continue;
2079
+ }
2080
+ if (sawO && text === "errexit") return true;
2081
+ sawO = false;
2082
+ if (text.startsWith("-") && text.length > 1) {
2083
+ for (let j = 1; j < text.length; j++) {
2084
+ if (text.charCodeAt(j) === 101) return true;
2085
+ }
2086
+ }
2087
+ }
2088
+ return false;
2089
+ }
2090
+ hasArgument(node, value) {
2091
+ for (let i = 0; i < node.namedChildCount; i++) {
2092
+ const child = node.namedChild(i);
2093
+ if (child && child.type === "word" && child.text === value) return true;
2094
+ }
2095
+ return false;
2096
+ }
2097
+ isRmRoot(node) {
2098
+ let hasRecursive = false;
2099
+ let hasForce = false;
2100
+ let hasRoot = false;
2101
+ for (let i = 0; i < node.namedChildCount; i++) {
2102
+ const child = node.namedChild(i);
2103
+ if (!child || child.type !== "word") continue;
2104
+ const text = child.text;
2105
+ if (text === "/") hasRoot = true;
2106
+ else if (text.startsWith("-")) {
2107
+ for (let j = 1; j < text.length; j++) {
2108
+ const code = text.charCodeAt(j);
2109
+ if (code === 114) hasRecursive = true;
2110
+ if (code === 102) hasForce = true;
2111
+ }
2112
+ }
2113
+ }
2114
+ return hasRecursive && hasForce && hasRoot;
2115
+ }
2116
+ firstArgumentWord(node) {
2117
+ for (let i = 0; i < node.namedChildCount; i++) {
2118
+ const child = node.namedChild(i);
2119
+ if (!child || child.type === "command_name") continue;
2120
+ if (child.type === "word" || child.type === "raw_string" || child.type === "string" || child.type === "concatenation") {
2121
+ return this.stripQuotes(child.text);
2122
+ }
2123
+ }
2124
+ return "";
2125
+ }
2126
+ firstArgumentNode(node) {
2127
+ for (let i = 0; i < node.namedChildCount; i++) {
2128
+ const child = node.namedChild(i);
2129
+ if (!child || child.type === "command_name") continue;
2130
+ if (child.type === "word" || child.type === "raw_string" || child.type === "string" || child.type === "concatenation") {
2131
+ return child;
2132
+ }
2133
+ }
2134
+ return null;
2135
+ }
2136
+ firstArgumentLine(node) {
2137
+ for (let i = 0; i < node.namedChildCount; i++) {
2138
+ const child = node.namedChild(i);
2139
+ if (!child || child.type === "command_name") continue;
2140
+ if (child.type === "word" || child.type === "raw_string" || child.type === "string" || child.type === "concatenation") {
2141
+ return child.startPosition.row + 1;
2142
+ }
2143
+ }
2144
+ return null;
2145
+ }
2146
+ // `normalizeRelative` was DELETED in M60, not merely unused. It resolved a
2147
+ // relative `source` against the sourcing file's own directory, which BD9
2148
+ // reversed as wrong: bash resolves a relative `source` against `$PWD` at run
2149
+ // time, so the rewrite turned `source ./eng/common/tools.sh` inside
2150
+ // `eng/common/` into the nonexistent `eng/common/eng/common/tools.sh`. The
2151
+ // function had no callers, and its doc comment still asserted the reversed
2152
+ // behaviour, so the file described two different contracts for one construct.
2153
+ detectFrameworks(patterns) {
2154
+ const frameworks = [];
2155
+ for (let i = 0; i < patterns.length; i++) {
2156
+ if (patterns[i] === "Container/Orchestration Script" && !frameworks.includes("Docker/Kubernetes")) frameworks.push("Docker/Kubernetes");
2157
+ if (patterns[i] === "Cloud Infrastructure Script" && !frameworks.includes("Cloud Native")) frameworks.push("Cloud Native");
2158
+ }
2159
+ return frameworks;
2160
+ }
2161
+ generateInsights(patterns, hasStrictError) {
2162
+ const insights = [];
2163
+ for (let i = 0; i < patterns.length; i++) {
2164
+ if (patterns[i] === "Deployment Script") {
2165
+ insights.push("This script contains deployment logic.");
2166
+ break;
2167
+ }
2168
+ }
2169
+ let hasContainerPattern = false;
2170
+ let hasEnvPattern = false;
2171
+ for (let i = 0; i < patterns.length; i++) {
2172
+ if (patterns[i] === "Container/Orchestration Script") hasContainerPattern = true;
2173
+ if (patterns[i] === "Environment Variable Tracking") hasEnvPattern = true;
2174
+ }
2175
+ if (hasContainerPattern) insights.push("Acts as a container orchestration script");
2176
+ if (hasEnvPattern) insights.push("Manages environment states for downstream processes");
2177
+ if (!hasStrictError) insights.push("Lacks 'set -e' for strict error catching");
2178
+ return insights;
2179
+ }
2180
+ nodeToRange(node) {
2181
+ return {
2182
+ startLine: node.startPosition.row + 1,
2183
+ startColumn: node.startPosition.column + 1,
2184
+ endLine: node.endPosition.row + 1,
2185
+ endColumn: node.endPosition.column + 1,
2186
+ startIndex: node.startIndex,
2187
+ endIndex: node.endIndex
2188
+ };
2189
+ }
2190
+ };
2191
+ function isAllDigits(text) {
2192
+ if (text.length === 0) return false;
2193
+ for (let i = 0; i < text.length; i++) {
2194
+ const code = text.charCodeAt(i);
2195
+ if (code < 48 || code > 57) return false;
2196
+ }
2197
+ return true;
2198
+ }
2199
+ export {
2200
+ RealBashAnalyzer
2201
+ };