@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,3528 @@
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-yaml-analyzer.ts
12
+ import { readFileSync, readdirSync, statSync } from "fs";
13
+ import { join } from "path";
14
+ var stripDotSlash = (s) => s.startsWith("./") ? s.slice(2) : s;
15
+ var stripTrailingSlashes = (s) => {
16
+ let e = s.length;
17
+ while (e > 0 && s.charCodeAt(e - 1) === 47) e--;
18
+ return e === s.length ? s : s.slice(0, e);
19
+ };
20
+ var isYamlSpelling = (s) => {
21
+ const t = s.toLowerCase();
22
+ return t.endsWith(".yaml") || t.endsWith(".yml");
23
+ };
24
+ var hasTemplateOrGlobChar = (s) => s.includes("{") || s.includes("}") || s.includes("$") || s.includes("*") || s.includes("?");
25
+ var hasJsWhitespace = (s) => {
26
+ for (let i = 0; i < s.length; i++) {
27
+ const c = s.charCodeAt(i);
28
+ if (c === 9 || c === 10 || c === 11 || c === 12 || c === 13 || c === 32 || c === 160 || c === 5760 || c >= 8192 && c <= 8202 || c === 8232 || c === 8233 || c === 8239 || c === 8287 || c === 12288 || c === 65279) return true;
29
+ }
30
+ return false;
31
+ };
32
+ var hasUriSchemePrefix = (s) => {
33
+ const isAlpha = (c) => c >= 65 && c <= 90 || c >= 97 && c <= 122;
34
+ if (s.length === 0 || !isAlpha(s.charCodeAt(0))) return false;
35
+ for (let i = 1; i < s.length; i++) {
36
+ const c = s.charCodeAt(i);
37
+ if (c === 58) return true;
38
+ if (!isAlpha(c) && !(c >= 48 && c <= 57) && c !== 43 && c !== 46 && c !== 45) return false;
39
+ }
40
+ return false;
41
+ };
42
+ var hasParamKeyPrefix = (s) => {
43
+ const isStart = (c) => c >= 65 && c <= 90 || c >= 97 && c <= 122 || c === 95;
44
+ if (s.length === 0 || !isStart(s.charCodeAt(0))) return false;
45
+ for (let i = 1; i < s.length; i++) {
46
+ const c = s.charCodeAt(i);
47
+ if (c === 61) return true;
48
+ if (!isStart(c) && !(c >= 48 && c <= 57)) return false;
49
+ }
50
+ return false;
51
+ };
52
+ var isMatrixPathKey = (s) => s === "matrix" || s.endsWith(".matrix");
53
+ var isCheckoutAction = (s) => s.startsWith("actions/checkout") && (s.length === 16 || s.charCodeAt(16) === 64);
54
+ var parseStepsPath = (s, suffix) => {
55
+ if (!s.endsWith(suffix)) return null;
56
+ const body = s.slice(0, s.length - suffix.length);
57
+ if (!body.endsWith("]")) return null;
58
+ const open = body.lastIndexOf(".steps[");
59
+ if (open < 0) return null;
60
+ const digits = body.slice(open + 7, body.length - 1);
61
+ if (digits.length === 0) return null;
62
+ for (let i = 0; i < digits.length; i++) {
63
+ const c = digits.charCodeAt(i);
64
+ if (c < 48 || c > 57) return null;
65
+ }
66
+ return [body.slice(0, open), Number(digits)];
67
+ };
68
+ var isAnsibleParamName = (s) => {
69
+ if (s.length === 0) return false;
70
+ const first = s.charCodeAt(0);
71
+ if (first < 97 || first > 122) return false;
72
+ for (let i = 1; i < s.length; i++) {
73
+ const c = s.charCodeAt(i);
74
+ if (c >= 97 && c <= 122 || c >= 48 && c <= 57 || c === 95) continue;
75
+ return false;
76
+ }
77
+ return true;
78
+ };
79
+ var isBareRefName = (s) => {
80
+ if (s.length === 0) return false;
81
+ for (let i = 0; i < s.length; i++) {
82
+ const c = s.charCodeAt(i);
83
+ if (c >= 65 && c <= 90 || c >= 97 && c <= 122 || c >= 48 && c <= 57) continue;
84
+ if (c === 95 || c === 45 || c === 36) continue;
85
+ return false;
86
+ }
87
+ return true;
88
+ };
89
+ var MAPPING_TYPES = /* @__PURE__ */ new Set(["block_mapping", "flow_mapping"]);
90
+ var MAPPING_PAIR_TYPES = /* @__PURE__ */ new Set(["block_mapping_pair", "flow_pair"]);
91
+ var SEQUENCE_TYPES = /* @__PURE__ */ new Set(["block_sequence", "flow_sequence"]);
92
+ var SEQUENCE_ITEM_TYPES = /* @__PURE__ */ new Set(["block_sequence_item"]);
93
+ var WRAPPER_TYPES = /* @__PURE__ */ new Set(["block_node", "flow_node"]);
94
+ var DIRECTIVE_TYPES = /* @__PURE__ */ new Set(["yaml_directive", "tag_directive", "reserved_directive"]);
95
+ var SCALAR_TYPES = /* @__PURE__ */ new Set([
96
+ "string_scalar",
97
+ "plain_scalar",
98
+ "single_quote_scalar",
99
+ "double_quote_scalar",
100
+ "integer_scalar",
101
+ "float_scalar",
102
+ "boolean_scalar",
103
+ "null_scalar",
104
+ "block_scalar",
105
+ "timestamp_scalar"
106
+ ]);
107
+ var RESOLVED_VALUE_VOCABULARY = /* @__PURE__ */ new Set([
108
+ "object",
109
+ "array",
110
+ "string",
111
+ "integer",
112
+ "float",
113
+ "boolean",
114
+ "null",
115
+ "timestamp",
116
+ "alias",
117
+ "anchor",
118
+ "untyped"
119
+ ]);
120
+ var SCALAR_SHAPES = /* @__PURE__ */ new Set(["string", "integer", "float", "boolean", "null", "timestamp"]);
121
+ var TAG_TYPE_OVERRIDES = {
122
+ str: "string",
123
+ int: "integer",
124
+ float: "float",
125
+ bool: "boolean",
126
+ null: "null",
127
+ map: "object",
128
+ seq: "array",
129
+ omap: "array",
130
+ pairs: "array",
131
+ set: "array",
132
+ binary: "string",
133
+ merge: "object",
134
+ timestamp: "timestamp"
135
+ };
136
+ var YAML_CORE_PREFIX = "tag:yaml.org,2002:";
137
+ var INCLUDE_TAGS = /* @__PURE__ */ new Set([
138
+ "include",
139
+ "inc",
140
+ "include_tasks",
141
+ "include_role",
142
+ "include_vars",
143
+ "include_file",
144
+ "import",
145
+ "import_tasks",
146
+ "import_playbook",
147
+ "import_role",
148
+ "import_file"
149
+ ]);
150
+ function looksLikePath(text, quoted = false) {
151
+ if (!text || text.trim() !== text) return false;
152
+ if (!quoted && text.includes(" ")) return false;
153
+ return text.includes(".") || text.includes("/");
154
+ }
155
+ var QUOTED_SCALAR_TYPES = /* @__PURE__ */ new Set(["single_quote_scalar", "double_quote_scalar"]);
156
+ var SECRET_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["data", "stringData", "binaryData"]);
157
+ var SENSITIVE_KEY_NAMES = /* @__PURE__ */ new Set([
158
+ "password",
159
+ "passwd",
160
+ "pass",
161
+ "secret",
162
+ "secrets",
163
+ "token",
164
+ "tokens",
165
+ "apikey",
166
+ "api_key",
167
+ "api_secret",
168
+ "access_key",
169
+ "accesskey",
170
+ "secret_key",
171
+ "secretkey",
172
+ "private_key",
173
+ "privatekey",
174
+ "client_secret",
175
+ "clientsecret",
176
+ "credential",
177
+ "credentials",
178
+ "auth",
179
+ "authorization",
180
+ "session_key",
181
+ "signing_key",
182
+ "encryption_key",
183
+ "connection_string",
184
+ "dsn",
185
+ "bearer_token",
186
+ "refresh_token",
187
+ "id_rsa"
188
+ ]);
189
+ var DEPENDS_ON_KEYS = /* @__PURE__ */ new Set(["depends_on", "dependsOn", "needs", "requires"]);
190
+ var RESOURCE_REF_WRAPPERS = /* @__PURE__ */ new Set(["configMapRef", "secretRef", "configMapKeyRef", "secretKeyRef"]);
191
+ var RESOURCE_REF_SOURCES = /* @__PURE__ */ new Map([
192
+ ["configMap", "name"],
193
+ // A SecretVolumeSource names the secret `secretName` (upstream
194
+ // `SecretVolumeSource.secretName`); `name` is the EnvVarSource spelling, which is
195
+ // the WRAPPER above and is read there. Probed both: a `volumes[].secret: { name: x }`
196
+ // payload is not the API's shape and is left unreferenced rather than guessed at.
197
+ ["secret", "secretName"],
198
+ ["persistentVolumeClaim", "claimName"]
199
+ ]);
200
+ var RESOURCE_REF_SCALARS = /* @__PURE__ */ new Set(["serviceAccountName"]);
201
+ var FILE_IMPORT_KEYS = /* @__PURE__ */ new Set([
202
+ "include_tasks",
203
+ "import_tasks",
204
+ "import_playbook",
205
+ "import_role",
206
+ "include_role",
207
+ "import_values",
208
+ "import",
209
+ "include",
210
+ // `include_vars` — R3 (2026-10-21, the yaml lane's whole-population open sweep).
211
+ //
212
+ // THE ASYMMETRY THIS FIXES IS THE EVIDENCE. `INCLUDE_TAGS` (the line above) has carried
213
+ // `include_vars` since the include family was written, because the TAG spelling `!include_vars`
214
+ // names a vars file. The KEY spelling `include_vars:` names the SAME file and was not in this
215
+ // set, so the same Ansible construct was covered in one spelling and missed in the other.
216
+ //
217
+ // MEASURED, not assumed. The open sweep (the census's `yaml.include_family_key` tally, taken
218
+ // over the WHOLE admitted population from the TREE — never from the analyzer's own decisions)
219
+ // found the include family and its per-spelling counts; the VALUE SHAPES decided which
220
+ // spellings are file inclusions and which are not:
221
+ //
222
+ // `include_vars: <file>` a vars FILE -> an import
223
+ // `include_vars: {file: <file>}` a vars FILE -> an import (the `file:` key)
224
+ // `include_vars: {dir: …, with_first_found: …}` a DIRECTORY of vars -> not a file
225
+ // `include_vars: "{{ lookup(…) }}"` an expression -> a name read, not a known file
226
+ //
227
+ // The other out-of-set spellings the sweep found are NOT file inclusions, and the sweep is
228
+ // what says so rather than the absence of a number: `include-hidden-files: true`,
229
+ // `include_aggregate_mounts: true`, `include_in_metadata: true` and `include_tags: true` are
230
+ // BOOLEAN FLAGS; `INCLUDE_DIR: ${{ github.workspace }}/…` is an ENVIRONMENT VARIABLE holding a
231
+ // C++ include path; and Helm's `import-values:` names VALUE PATHS (`child:`/`parent:`) between
232
+ // charts, not a file at all. So `import-values` stays out of this set, and `include_vars` is
233
+ // the one spelling the sweep proved is a file inclusion the analyzer did not attempt.
234
+ //
235
+ // WHAT THE EXISTING EXTRACTION ALREADY GETS RIGHT, so the fix adds no new machinery: a value
236
+ // that is a MAPPING without a `file:` key contributes nothing (`fileImportTargets` refuses a
237
+ // mapping), a SEQUENCE contributes only its scalar items, and a scalar contributes one
238
+ // candidate. The file CLAIM is still made by `keywordIncludeClaim`, which states a project-file
239
+ // claim only when the project actually carries the file and falls back to `isExternal`
240
+ // otherwise — so this fix can only ADD checkable edges, never weaken one.
241
+ "include_vars"
242
+ ]);
243
+ var ROLE_IMPORT_KEYS = /* @__PURE__ */ new Set(["include_role", "import_role"]);
244
+ var INCLUDE_SELECTOR_SEGMENTS = /* @__PURE__ */ new Set([
245
+ "branches",
246
+ "paths",
247
+ "sources",
248
+ "generate_for",
249
+ "outputs",
250
+ "exposure",
251
+ "objc-interfaces",
252
+ "objc-protocols",
253
+ "enums"
254
+ ]);
255
+ var ROLE_SUBDIR_NAMES = /* @__PURE__ */ new Set([
256
+ "tasks",
257
+ "handlers",
258
+ "vars",
259
+ "defaults",
260
+ "meta",
261
+ "templates",
262
+ "files",
263
+ "library"
264
+ ]);
265
+ var OPENAPI_COMPONENT_CONTAINERS = /* @__PURE__ */ new Set([
266
+ "schemas",
267
+ "responses",
268
+ "parameters",
269
+ "examples",
270
+ "requestBodies",
271
+ "headers",
272
+ "securitySchemes",
273
+ "links",
274
+ "callbacks",
275
+ "pathItems"
276
+ ]);
277
+ function schemaPublicationRole(parentPath, document) {
278
+ const prefix = `${document}.`;
279
+ if (!parentPath.startsWith(prefix)) return null;
280
+ const parts = parentPath.slice(prefix.length).split(".");
281
+ if (parts.length === 1 && (parts[0] === "definitions" || parts[0] === "$defs")) return parts[0];
282
+ if (parts.length === 2 && parts[0] === "components" && OPENAPI_COMPONENT_CONTAINERS.has(parts[1])) {
283
+ return `components.${parts[1]}`;
284
+ }
285
+ return null;
286
+ }
287
+ function isJobLevelPath(parentPath, document) {
288
+ const prefix = `${document}.jobs.`;
289
+ if (!parentPath.startsWith(prefix)) return false;
290
+ const jobId = parentPath.slice(prefix.length);
291
+ return jobId.length > 0 && !jobId.includes(".");
292
+ }
293
+ var OPENAPI_METHODS = /* @__PURE__ */ new Set([
294
+ "get",
295
+ "put",
296
+ "post",
297
+ "delete",
298
+ "options",
299
+ "head",
300
+ "patch",
301
+ "trace"
302
+ ]);
303
+ var FRAMEWORK_SIGNATURES = [
304
+ {
305
+ name: "GitHub Actions",
306
+ maturity: 25,
307
+ insight: "GitHub Actions CI/CD detected",
308
+ all: [{ path: "jobs", shape: "object" }],
309
+ any: [
310
+ { path: "on", shape: "any", pathHint: ".github/workflows" },
311
+ { path: "on", shape: "object" },
312
+ { path: "on", shape: "array" },
313
+ { path: "on", shape: "string" }
314
+ ]
315
+ },
316
+ {
317
+ name: "Docker Compose",
318
+ maturity: 20,
319
+ insight: "Containerized architecture detected",
320
+ all: [{ path: "services", shape: "object" }],
321
+ any: [
322
+ { path: "version", shape: "any" },
323
+ { path: "networks", shape: "any" },
324
+ { path: "volumes", shape: "any" },
325
+ { path: "configs", shape: "any" }
326
+ ]
327
+ },
328
+ {
329
+ name: "Kubernetes",
330
+ maturity: 30,
331
+ insight: "Kubernetes orchestration detected",
332
+ all: [
333
+ { path: "apiVersion", shape: "string" },
334
+ { path: "kind", shape: "string" }
335
+ ]
336
+ },
337
+ {
338
+ name: "GitLab CI",
339
+ maturity: 25,
340
+ insight: "GitLab CI/CD detected",
341
+ all: [{ path: "stages", shape: "array" }],
342
+ any: [
343
+ { path: "variables", shape: "any" },
344
+ { path: "default", shape: "any" },
345
+ { path: "include", shape: "any" }
346
+ ]
347
+ },
348
+ {
349
+ name: "Ansible",
350
+ maturity: 20,
351
+ insight: "Ansible provisioning detected",
352
+ all: [{ path: "hosts", shape: "any" }],
353
+ any: [
354
+ { path: "tasks", shape: "any" },
355
+ { path: "roles", shape: "any" },
356
+ { path: "play", shape: "any" }
357
+ ]
358
+ },
359
+ {
360
+ name: "Spring Boot",
361
+ maturity: 10,
362
+ insight: "Spring Boot configuration detected",
363
+ all: [
364
+ { path: "spring", shape: "object" },
365
+ { path: "server", shape: "any" }
366
+ ]
367
+ },
368
+ {
369
+ name: "CircleCI",
370
+ maturity: 25,
371
+ insight: "CircleCI CI/CD detected",
372
+ all: [
373
+ { path: "version", shape: "string" },
374
+ { path: "jobs", shape: "object" }
375
+ ],
376
+ any: [{ path: "workflows", shape: "any" }, { path: "executors", shape: "any" }]
377
+ },
378
+ {
379
+ name: "Azure Pipelines",
380
+ maturity: 25,
381
+ insight: "Azure Pipelines CI/CD detected",
382
+ all: [
383
+ { path: "trigger", shape: "any" },
384
+ { path: "pool", shape: "any" },
385
+ { path: "steps", shape: "any" }
386
+ ]
387
+ },
388
+ {
389
+ name: "Serverless Framework",
390
+ maturity: 20,
391
+ insight: "Serverless service definition detected",
392
+ all: [
393
+ { path: "service", shape: "string" },
394
+ { path: "provider", shape: "any" },
395
+ { path: "functions", shape: "object" }
396
+ ]
397
+ },
398
+ {
399
+ name: "Prometheus",
400
+ maturity: 15,
401
+ insight: "Prometheus scrape configuration detected",
402
+ all: [{ path: "scrape_configs", shape: "array" }],
403
+ any: [{ path: "global", shape: "any" }]
404
+ },
405
+ {
406
+ name: "CloudFormation",
407
+ maturity: 20,
408
+ insight: "AWS CloudFormation template detected",
409
+ all: [{ path: "AWSTemplateFormatVersion", shape: "any" }]
410
+ },
411
+ {
412
+ name: "OpenAPI",
413
+ maturity: 20,
414
+ insight: "OpenAPI/Swagger interface contract detected",
415
+ all: [{ path: "paths", shape: "object" }],
416
+ any: [
417
+ { path: "openapi", shape: "string" },
418
+ { path: "swagger", shape: "string" }
419
+ ]
420
+ },
421
+ {
422
+ name: "Helm chart",
423
+ maturity: 20,
424
+ insight: "Helm chart metadata detected",
425
+ all: [
426
+ { path: "kind", shape: "string" },
427
+ { path: "name", shape: "string" },
428
+ { path: "version", shape: "string" }
429
+ ],
430
+ any: [{ path: "apiVersion", shape: "string" }]
431
+ }
432
+ ];
433
+ var RealYamlAnalyzer = class _RealYamlAnalyzer {
434
+ analyze(rootNode, content, filePath, context) {
435
+ const ctx = {
436
+ filePath,
437
+ projectRoot: typeof context?.projectRoot === "string" ? context.projectRoot : void 0,
438
+ content,
439
+ symbols: [],
440
+ relationships: [],
441
+ imports: [],
442
+ exports: [],
443
+ metadata: {
444
+ detectedFrameworks: [],
445
+ frameworkPatterns: [],
446
+ // Declared here because M60 R8's census reads the metadata INITIALISER, and
447
+ // the real value is only known after shape detection has run. Before that
448
+ // point this key genuinely has no value, so `undefined` is the honest
449
+ // initializer and not a placeholder.
450
+ framework: void 0,
451
+ hasFrameworkIntelligence: true,
452
+ patterns: [],
453
+ architecturalInsights: [],
454
+ devOpsMaturity: 0,
455
+ securityScore: 100,
456
+ configurationComplexity: 0,
457
+ yamlVersion: null,
458
+ tagHandles: [],
459
+ reservedDirectives: [],
460
+ sensitiveValueCount: 0
461
+ },
462
+ anchors: /* @__PURE__ */ new Map(),
463
+ aliasRefs: [],
464
+ relationshipKeys: /* @__PURE__ */ new Set(),
465
+ symbolIds: /* @__PURE__ */ new Set(),
466
+ visitedNodes: 0,
467
+ documentCount: 0,
468
+ maxDepth: 0,
469
+ pointerIndex: /* @__PURE__ */ new Map(),
470
+ shapeIndex: /* @__PURE__ */ new Map(),
471
+ documentShapes: /* @__PURE__ */ new Map(),
472
+ serviceIndex: /* @__PURE__ */ new Map(),
473
+ serviceKeyIndex: /* @__PURE__ */ new Map(),
474
+ topLevelObjectIndex: /* @__PURE__ */ new Map(),
475
+ commentIndex: /* @__PURE__ */ new Map(),
476
+ trailingIndex: /* @__PURE__ */ new Map(),
477
+ sensitiveDocuments: /* @__PURE__ */ new Set(),
478
+ declaredNames: /* @__PURE__ */ new Map(),
479
+ documentKinds: /* @__PURE__ */ new Map(),
480
+ tagHandles: /* @__PURE__ */ new Map(),
481
+ duplicateCounters: /* @__PURE__ */ new Map(),
482
+ pendingActionUses: [],
483
+ stepWithFacts: /* @__PURE__ */ new Map(),
484
+ pendingSchemaPublications: [],
485
+ pendingReferences: [],
486
+ frameworks: [],
487
+ frameworkPatterns: []
488
+ };
489
+ ctx.parseHadError = rootNode.hasError === true;
490
+ this.collectComments(rootNode, ctx);
491
+ this.visit(rootNode, {
492
+ parent: null,
493
+ parentPath: "",
494
+ pointerPath: "",
495
+ document: this.documentName(filePath, 1, ctx.projectRoot),
496
+ depth: 0,
497
+ siblingKeys: /* @__PURE__ */ new Set()
498
+ }, ctx);
499
+ this.detectFrameworks(ctx);
500
+ this.resolveActionUses(ctx);
501
+ this.resolveSchemaExports(ctx);
502
+ this.resolveAliases(ctx);
503
+ this.resolvePointerReferences(ctx);
504
+ this.emitSchemaComposition(ctx);
505
+ this.resolveSiblingReferences(ctx);
506
+ this.attachQualifiedCalls(ctx);
507
+ this.applySecurityScore(ctx);
508
+ this.classifyEvidenceTier(ctx);
509
+ ctx.metadata.configurationComplexity = ctx.visitedNodes;
510
+ ctx.metadata.maxDepth = ctx.maxDepth;
511
+ ctx.metadata.documentCount = ctx.documentCount;
512
+ ctx.metadata.symbolCount = ctx.symbols.length;
513
+ ctx.metadata.relationshipCount = ctx.relationships.length;
514
+ ctx.metadata.pointerReferenceCount = ctx.pointerIndex.size;
515
+ ctx.metadata.framework = ctx.frameworks[0] ?? null;
516
+ ctx.metadata.detectedFrameworks = ctx.frameworks;
517
+ ctx.metadata.frameworkPatterns = ctx.frameworkPatterns;
518
+ const declaredOwn = /* @__PURE__ */ new Set();
519
+ for (const s of ctx.symbols) {
520
+ if (typeof s.name === "string" && s.name.length > 0) declaredOwn.add(s.name);
521
+ const q = typeof s.qualifiedName === "string" ? s.qualifiedName : "";
522
+ if (q.length > 0) declaredOwn.add(q);
523
+ }
524
+ let strippedOwnContradicted = 0;
525
+ let strippedNoExternalShape = 0;
526
+ const hasExternalShape = (tgt) => {
527
+ if (tgt.includes("/") || tgt.includes(":") || tgt.includes("@")) return true;
528
+ if (tgt.includes(".")) return true;
529
+ return false;
530
+ };
531
+ for (const r of ctx.relationships) {
532
+ const md = r?.metadata;
533
+ if (!md || typeof md !== "object" || md.isExternal !== true) continue;
534
+ const tgt = String(r.target ?? "");
535
+ if (tgt.length === 0) continue;
536
+ if (declaredOwn.has(tgt)) {
537
+ delete md.isExternal;
538
+ strippedOwnContradicted++;
539
+ continue;
540
+ }
541
+ const tagUri = typeof md.tagUri === "string" ? md.tagUri : "";
542
+ if (!hasExternalShape(tgt) && tagUri.length === 0) {
543
+ delete md.isExternal;
544
+ strippedNoExternalShape++;
545
+ }
546
+ }
547
+ if (strippedOwnContradicted > 0) {
548
+ ;
549
+ ctx.metadata.isExternalStrippedOwnFileContradicted = strippedOwnContradicted;
550
+ }
551
+ if (strippedNoExternalShape > 0) {
552
+ ;
553
+ ctx.metadata.isExternalStrippedNoExternalShape = strippedNoExternalShape;
554
+ }
555
+ return {
556
+ symbols: ctx.symbols,
557
+ relationships: ctx.relationships,
558
+ imports: ctx.imports,
559
+ exports: ctx.exports,
560
+ metadata: ctx.metadata
561
+ };
562
+ }
563
+ // ---------------------------------------------------------------- comments
564
+ /**
565
+ * Pass A. Comments are children of `stream`, siblings of `document`, so the main
566
+ * walk reaches them BEFORE the key they document and a pending-comment buffer
567
+ * could never see them. They are therefore indexed by the line they precede, with
568
+ * their column, and attached during the walk by position. `comment.text` is the
569
+ * comment's own text; the `#` run is removed by slicing, not by a pattern.
570
+ */
571
+ collectComments(rootNode, ctx) {
572
+ const stack = [rootNode];
573
+ while (stack.length > 0) {
574
+ const node = stack.pop();
575
+ if (node.type === "comment") {
576
+ const text = this.commentText(node);
577
+ if (text) {
578
+ const column = node.startPosition.column + 1;
579
+ const nextLine = node.endPosition.row + 2;
580
+ const leading = ctx.commentIndex.get(nextLine);
581
+ if (leading) leading.push({ text, column });
582
+ else ctx.commentIndex.set(nextLine, [{ text, column }]);
583
+ const ownLine = node.startPosition.row + 1;
584
+ const trailing = ctx.trailingIndex.get(ownLine);
585
+ if (trailing) trailing.push({ text, column });
586
+ else ctx.trailingIndex.set(ownLine, [{ text, column }]);
587
+ }
588
+ continue;
589
+ }
590
+ for (let i = node.childCount - 1; i >= 0; i--) {
591
+ const child = node.child(i);
592
+ if (child) stack.push(child);
593
+ }
594
+ }
595
+ }
596
+ commentText(node) {
597
+ const raw = node.text;
598
+ let i = 0;
599
+ while (i < raw.length && (raw[i] === "#" || raw[i] === " ")) i++;
600
+ return raw.slice(i).trimEnd();
601
+ }
602
+ /**
603
+ * The docstring a symbol earns: leading comments on the line above at the same or
604
+ * shallower column, plus a trailing comment on its own row to the right. Purely
605
+ * positional — no comment text is matched against anything, so a comment can only
606
+ * ever document a neighbour, never be guessed onto an unrelated key.
607
+ */
608
+ docstringFor(line, column, ctx) {
609
+ const parts = [];
610
+ const above = ctx.commentIndex.get(line);
611
+ if (above) {
612
+ for (const c of above) if (c.column <= column) parts.push(c.text);
613
+ }
614
+ const trailing = ctx.trailingIndex.get(line);
615
+ if (trailing) {
616
+ for (const c of trailing) if (c.column > column) parts.push(c.text);
617
+ }
618
+ return parts.length > 0 ? parts.join("\n") : void 0;
619
+ }
620
+ // ------------------------------------------------------------------- walk
621
+ visit(node, visitContext, ctx) {
622
+ const stack = [{ node, context: visitContext }];
623
+ while (stack.length > 0) {
624
+ const item = stack.pop();
625
+ const curr = item.node;
626
+ const current = item.context;
627
+ if (curr.isMissing) continue;
628
+ ctx.visitedNodes++;
629
+ if (current.depth > ctx.maxDepth) ctx.maxDepth = current.depth;
630
+ if (curr.type === "stream") {
631
+ this.pushChildren(stack, curr, current);
632
+ continue;
633
+ }
634
+ if (curr.type === "comment") continue;
635
+ if (curr.type === "document") {
636
+ this.visitDocument(curr, current, ctx, stack);
637
+ continue;
638
+ }
639
+ if (DIRECTIVE_TYPES.has(curr.type)) {
640
+ this.visitDirective(curr, ctx);
641
+ this.pushChildren(stack, curr, current);
642
+ continue;
643
+ }
644
+ if (curr.type === "ERROR" || curr.type === "MISSING") {
645
+ this.descendThroughDamage(curr, current, ctx, stack);
646
+ continue;
647
+ }
648
+ if (SEQUENCE_ITEM_TYPES.has(curr.type) && !current.sequenceItem) {
649
+ const child = this.firstNamedChild(curr);
650
+ if (child) this.visitSequenceItem(child, curr, current, ctx, stack, this.itemIndexAmongSiblings(curr));
651
+ continue;
652
+ }
653
+ if (current.sequenceItem) {
654
+ this.visitSequenceItem(curr, curr, current, ctx, stack, current.sequenceIndex ?? 0);
655
+ continue;
656
+ }
657
+ if (WRAPPER_TYPES.has(curr.type)) {
658
+ this.pushChildren(stack, curr, current);
659
+ continue;
660
+ }
661
+ if (MAPPING_TYPES.has(curr.type)) {
662
+ this.pushMappingChildren(stack, curr, current, ctx);
663
+ continue;
664
+ }
665
+ if (MAPPING_PAIR_TYPES.has(curr.type)) {
666
+ this.visitMappingPair(curr, current, ctx, stack);
667
+ continue;
668
+ }
669
+ if (SEQUENCE_TYPES.has(curr.type)) {
670
+ this.visitSequence(curr, current, ctx, stack);
671
+ continue;
672
+ }
673
+ if (curr.type === "anchor") {
674
+ this.visitAnchor(curr, current, ctx, stack);
675
+ continue;
676
+ }
677
+ if (curr.type === "alias") {
678
+ this.visitAlias(curr, current, ctx);
679
+ continue;
680
+ }
681
+ this.pushChildren(stack, curr, current);
682
+ }
683
+ }
684
+ visitDocument(node, current, ctx, stack) {
685
+ const documentName = this.documentName(ctx.filePath, ctx.documentCount + 1, ctx.projectRoot);
686
+ ctx.documentCount++;
687
+ if (ctx.documentCount > 1) this.addPattern(ctx, "Multi-document YAML");
688
+ if (!ctx.anchors.has(documentName)) ctx.anchors.set(documentName, /* @__PURE__ */ new Map());
689
+ const document = this.createSymbol(
690
+ documentName,
691
+ ASTEngine.SymbolKind.Module,
692
+ node,
693
+ current.parent,
694
+ current.parentPath,
695
+ ctx,
696
+ { yamlSpecific: true, nodeType: "document", documentIndex: ctx.documentCount, fullPath: documentName },
697
+ documentName
698
+ );
699
+ const kind = this.topLevelScalar(node, "kind");
700
+ const isSecretDocument = kind === "Secret" || kind === "SealedSecret";
701
+ if (isSecretDocument) {
702
+ ctx.sensitiveDocuments.add(documentName);
703
+ this.addPattern(ctx, "Secret Manifest");
704
+ }
705
+ const declaredName = this.topLevelScalar(node, "metadata.name") ?? this.topLevelScalarFromMapping(node, "metadata", "name");
706
+ if (declaredName) {
707
+ ctx.declaredNames.set(declaredName, documentName);
708
+ if (kind) ctx.documentKinds.set(declaredName, kind);
709
+ }
710
+ this.pushChildren(stack, node, {
711
+ parent: document,
712
+ parentPath: documentName,
713
+ pointerPath: "",
714
+ document: documentName,
715
+ depth: current.depth + 1,
716
+ siblingKeys: /* @__PURE__ */ new Set(),
717
+ inSecretDocument: isSecretDocument || current.inSecretDocument
718
+ });
719
+ void document;
720
+ }
721
+ visitDirective(node, ctx) {
722
+ if (node.type === "yaml_directive") {
723
+ const version = this.findNamedChild(node, "yaml_version");
724
+ ctx.metadata.yamlVersion = version ? this.scalarText(version) : this.scalarText(node);
725
+ this.addPattern(ctx, "YAML Version Directive");
726
+ return;
727
+ }
728
+ if (node.type === "tag_directive") {
729
+ const handle = this.findNamedChild(node, "tag_handle");
730
+ const prefix = this.findNamedChild(node, "tag_prefix");
731
+ if (handle && prefix) {
732
+ const h = this.scalarText(handle);
733
+ const p = this.scalarText(prefix);
734
+ ctx.tagHandles.set(h, p);
735
+ const handles = ctx.metadata.tagHandles ?? [];
736
+ handles.push(`${h} -> ${p}`);
737
+ ctx.metadata.tagHandles = handles;
738
+ }
739
+ this.addPattern(ctx, "YAML Tag Directive");
740
+ return;
741
+ }
742
+ const name = this.findNamedChild(node, "directive_name");
743
+ const parameter = this.findNamedChild(node, "directive_parameter");
744
+ const reserved = ctx.metadata.reservedDirectives ?? [];
745
+ reserved.push(name ? this.scalarText(name) : this.scalarText(node));
746
+ if (parameter) reserved.push(this.scalarText(parameter));
747
+ ctx.metadata.reservedDirectives = reserved;
748
+ this.addPattern(ctx, "Reserved Directive");
749
+ }
750
+ /**
751
+ * Descend through a grammar ERROR while KEEPING the enclosing identity.
752
+ *
753
+ * WHY NOT A NEW SCOPE. The first version answered damage with damage: a damaged node
754
+ * became a synthetic `#damaged@<line>` scope and every symbol beneath it was renamed
755
+ * under that prefix. It stopped ids colliding — which was the actual defect — but it
756
+ * renamed the ENTIRE key tree of the file, and the F5 differential then reported
757
+ * 45,990 "unexplained" disagreements that were this one decision, not a disagreement
758
+ * between the two parsers. Nothing is lost by keeping identity, so nothing should be
759
+ * renamed.
760
+ *
761
+ * SO: the pairs keep the document's identity, the symbols carry `recovered: true` plus
762
+ * the line and width of the damage that was passed through, and the file keeps the
763
+ * pattern. The damage is still declared — on the symbols and in the pattern list —
764
+ * but it is declared as METADATA rather than baked into every name.
765
+ *
766
+ * A root-level ERROR has no `document` node, so the file would otherwise have no
767
+ * identity at all; that case synthesises the document symbol from the stem, marked
768
+ * recovered, which is what keeps `v1.27.swagger` instead of `[13].swagger`.
769
+ *
770
+ * WHAT THE DAMAGE ACTUALLY IS — corrected 2026-09-28 by script/m60/yaml-bucket-audit.ts,
771
+ * which overturned the description this comment previously gave. It used to say "a
772
+ * single 58-byte multi-line double-quoted scalar … at the document root", and the
773
+ * 58-byte figure is real but it is a DIFFERENT node: in v1.28 it is a nested ERROR at
774
+ * bytes 79,218-79,276, line 2453, depth 4. The root node ITSELF is the ERROR, and it
775
+ * is enormous. Measured over `docker/api/docs/v1.25.yaml`..`v1.38.yaml`:
776
+ *
777
+ * file fileBytes treeEnd coverage rootType
778
+ * v1.25 260,742 72,809 27.9% ERROR
779
+ * v1.28 278,336 79,276 28.5% ERROR
780
+ * v1.38 354,991 110,341 31.1% ERROR
781
+ *
782
+ * So the parser does not lose containment to one bad token — it STOPS. Between 69%
783
+ * and 72% of each file is never parsed at all, which is why the analyzer publishes
784
+ * 1,991-2,637 key-tree paths where an independent reader finds 7,249-9,001. The
785
+ * distinction is not cosmetic: "one 58-byte token" is a narrow upstream bug report,
786
+ * "the root node is an ERROR and 70% of the file is unparsed" says the parser gives
787
+ * up, and only the second is true. The trigger is still the multi-line double-quoted
788
+ * scalar — the 58-byte node is where it starts.
789
+
790
+ */
791
+ descendThroughDamage(node, current, ctx, stack) {
792
+ const site = { line: node.startPosition.row + 1, width: node.endIndex - node.startIndex };
793
+ this.addPattern(ctx, "Grammar Error Recovery");
794
+ if (!current.parent) {
795
+ const documentName = this.documentName(ctx.filePath, ctx.documentCount + 1, ctx.projectRoot);
796
+ this.pushChildren(stack, node, {
797
+ parent: null,
798
+ parentPath: documentName,
799
+ pointerPath: "",
800
+ document: documentName,
801
+ depth: current.depth + 1,
802
+ siblingKeys: /* @__PURE__ */ new Set(),
803
+ inSensitiveSubtree: current.inSensitiveSubtree,
804
+ recoveredThrough: site
805
+ });
806
+ return;
807
+ }
808
+ this.pushChildren(stack, node, { ...current, depth: current.depth + 1, recoveredThrough: site });
809
+ }
810
+ /**
811
+ * The index a sequence item carries when it is reached WITHOUT an enclosing
812
+ * sequence — which is exactly the damaged case above. Counted by node TYPE and
813
+ * byte position, never by wrapper identity: web-tree-sitter returns a FRESH
814
+ * wrapper per call, so `sibling === itemNode` can never hold and an identity scan
815
+ * is dead code that silently returns a file-wide count instead.
816
+ */
817
+ itemIndexAmongSiblings(itemNode) {
818
+ const parent = itemNode.parent;
819
+ if (!parent) return 0;
820
+ let index = 0;
821
+ for (let i = 0; i < parent.namedChildCount; i++) {
822
+ const sibling = parent.namedChild(i);
823
+ if (!sibling) continue;
824
+ if (sibling.startIndex === itemNode.startIndex) return index;
825
+ if (SEQUENCE_ITEM_TYPES.has(sibling.type)) index++;
826
+ }
827
+ return index;
828
+ }
829
+ visitMappingPair(node, current, ctx, stack) {
830
+ const keyNode = this.mappingKeyNode(node);
831
+ if (!keyNode) return;
832
+ const key = this.scalarText(keyNode);
833
+ if (!key) return;
834
+ const valueNode = node.childForFieldName("value") || this.mappingValueNode(node);
835
+ const tag = this.tagOf(valueNode, ctx);
836
+ const rawType = this.valueType(valueNode);
837
+ const valueType = tag?.typeOverride ?? rawType;
838
+ const isMerge = key === "<<";
839
+ const isDuplicate = current.siblingKeys.has(key);
840
+ if (isDuplicate) current.siblingKeys.add(`${key}\0dup`);
841
+ else current.siblingKeys.add(key);
842
+ const keyLine = keyNode.startPosition.row + 1;
843
+ const path = this.uniquePath(current.parentPath, current.document, key, isDuplicate, ctx);
844
+ if (isDuplicate) {
845
+ const base = current.parentPath ? `${current.parentPath}.${key}` : key;
846
+ const ordinal = ctx.duplicateCounters.get(`${current.document}\0${base}`) ?? 1;
847
+ const overridden = ordinal === 1 ? base : `${base}#${ordinal}`;
848
+ this.addRelationship(
849
+ ctx,
850
+ path,
851
+ overridden,
852
+ ASTEngine.RelationshipType.Overrides,
853
+ {
854
+ relationshipType: "yaml_duplicate_key",
855
+ duplicateKey: key,
856
+ occurrence: ordinal + 1,
857
+ overriddenPath: overridden,
858
+ isDuplicateKey: true,
859
+ // Both endpoints are symbols of THIS file, so this resolves; the uncertainty
860
+ // is in the semantics, which is why `resolved` and `confidence` disagree here.
861
+ resolved: true,
862
+ yamlSpecStatus: "duplicate mapping keys are undefined in YAML 1.2; last-wins is conventional loader behaviour"
863
+ },
864
+ keyLine,
865
+ 0.8,
866
+ "duplicate-mapping-key-last-wins"
867
+ );
868
+ }
869
+ const pointerPath = `${current.pointerPath}/${this.escapePointer(key)}`;
870
+ const symbolKind = valueType === "object" ? ASTEngine.SymbolKind.Class : valueType === "array" ? ASTEngine.SymbolKind.Variable : ASTEngine.SymbolKind.Property;
871
+ const sensitive = SENSITIVE_KEY_NAMES.has(key.toLowerCase()) || current.inSensitiveSubtree === true || current.inSecretDocument === true && SECRET_PAYLOAD_KEYS.has(key);
872
+ const line = keyNode.startPosition.row + 1;
873
+ const symbol = this.createSymbol(
874
+ key,
875
+ symbolKind,
876
+ keyNode,
877
+ current.parent,
878
+ current.parentPath,
879
+ ctx,
880
+ {
881
+ yamlSpecific: true,
882
+ nodeType: node.type,
883
+ valueType,
884
+ rawValueType: tag?.typeOverride ? rawType : void 0,
885
+ valueTypeSource: tag?.typeOverride ? "tag" : rawType === "untyped" ? "default" : "grammar",
886
+ fullPath: path,
887
+ pointerPath,
888
+ isObject: valueType === "object",
889
+ isArray: valueType === "array",
890
+ isMergeKey: isMerge,
891
+ isAlias: valueType === "alias",
892
+ isDuplicateKey: isDuplicate || void 0,
893
+ isSensitive: sensitive || void 0,
894
+ sensitivityReason: sensitive ? SENSITIVE_KEY_NAMES.has(key.toLowerCase()) ? `key-name:${key.toLowerCase()}` : current.inSecretDocument === true && SECRET_PAYLOAD_KEYS.has(key) ? "secret-document-payload" : "sensitive-subtree" : void 0,
895
+ recovered: current.recoveredThrough !== void 0 || current.parent?.metadata?.recovered === true || void 0,
896
+ recoveredThrough: current.recoveredThrough,
897
+ declaredType: this.tagTypeName(tag),
898
+ tagUri: tag?.uri,
899
+ tagLine: tag?.line,
900
+ tagIncludePath: tag?.includePath,
901
+ tagIncludeQuoted: tag?.includeQuoted,
902
+ scalarType: SCALAR_SHAPES.has(valueType) ? valueType : void 0,
903
+ line
904
+ },
905
+ path
906
+ );
907
+ symbol.complexity = this.nodeComplexity(valueNode, valueType);
908
+ const docstring = this.docstringFor(line, keyNode.startPosition.column + 1, ctx);
909
+ if (docstring) symbol.docstring = docstring;
910
+ this.assignApiContractPath(symbol);
911
+ ctx.shapeIndex.set(path, valueType);
912
+ this.indexShape(current.document, current.parentPath, path, valueType, ctx, key);
913
+ if (valueType === "object" || valueType === "array") ctx.pointerIndex.set(pointerPath, path);
914
+ this.emitKeySemantics(key, symbol, valueNode, valueType, keyNode, line, current, ctx);
915
+ if (valueNode) {
916
+ stack.push({
917
+ node: valueNode,
918
+ context: {
919
+ parent: symbol,
920
+ parentPath: path,
921
+ pointerPath,
922
+ document: current.document,
923
+ depth: current.depth + 1,
924
+ siblingKeys: /* @__PURE__ */ new Set(),
925
+ inSensitiveSubtree: sensitive || current.inSensitiveSubtree
926
+ }
927
+ });
928
+ }
929
+ }
930
+ /**
931
+ * The semantics a mapping KEY carries. Every decision here comes from the key
932
+ * node's text, the value node's type, or the value node's position — never from
933
+ * scanning the value's characters.
934
+ */
935
+ emitKeySemantics(key, symbol, valueNode, valueType, keyNode, line, current, ctx) {
936
+ const path = symbol.qualifiedName || symbol.id;
937
+ this.recordStepWithFacts(key, valueNode, current, ctx, line);
938
+ if (key === "extends") this.emitConfigExtends(valueNode, line, current, ctx);
939
+ if (valueType === "string" && valueNode) {
940
+ const pointer = this.scalarText(valueNode);
941
+ if (key === "$ref" || this.isJsonPointer(pointer)) {
942
+ this.addRelationship(ctx, path, pointer, ASTEngine.RelationshipType.References, {
943
+ relationshipType: "yaml_json_pointer",
944
+ ...this.pointerClaim(pointer, ctx),
945
+ pointer: pointer.startsWith("#/") ? pointer.slice(1) : pointer,
946
+ referencedName: pointer
947
+ }, line);
948
+ }
949
+ }
950
+ if (DEPENDS_ON_KEYS.has(key) && this.isDependencyPosition(key, current)) {
951
+ for (const name of this.dependencyTargets(valueNode)) {
952
+ if (_RealYamlAnalyzer.markerNamespaceTarget(name)) continue;
953
+ ctx.pendingReferences.push({ source: path, target: name, kind: "yaml_depends_on", line, document: current.document });
954
+ }
955
+ }
956
+ if (RESOURCE_REF_WRAPPERS.has(key)) {
957
+ for (const name of this.wrapperRefNames(valueNode)) {
958
+ ctx.pendingReferences.push({ source: path, target: name, kind: "yaml_resource_ref", line, resourceKind: _RealYamlAnalyzer.KIND_FOR_RESOURCE_KEY[key] });
959
+ }
960
+ } else if (RESOURCE_REF_SOURCES.has(key)) {
961
+ const nameKey = RESOURCE_REF_SOURCES.get(key);
962
+ const direct = valueNode ? this.scalarNode(valueNode) : null;
963
+ if (direct && SCALAR_TYPES.has(direct.type)) {
964
+ const name = this.scalarText(direct);
965
+ if (name) ctx.pendingReferences.push({ source: path, target: name, kind: "yaml_resource_ref", line, resourceKind: _RealYamlAnalyzer.KIND_FOR_RESOURCE_KEY[key] });
966
+ } else {
967
+ const named = this.mappingEntryScalar(valueNode, nameKey);
968
+ if (named) ctx.pendingReferences.push({ source: path, target: named, kind: "yaml_resource_ref", line });
969
+ }
970
+ } else if (RESOURCE_REF_SCALARS.has(key)) {
971
+ const direct = valueNode ? this.scalarNode(valueNode) : null;
972
+ if (direct && SCALAR_TYPES.has(direct.type)) {
973
+ const name = this.scalarText(direct);
974
+ if (name) ctx.pendingReferences.push({ source: path, target: name, kind: "yaml_resource_ref", line, resourceKind: _RealYamlAnalyzer.KIND_FOR_RESOURCE_KEY[key] });
975
+ }
976
+ }
977
+ if (FILE_IMPORT_KEYS.has(key) && !this.isIncludeSelectorKey(key, current, ctx)) {
978
+ for (const name of this.fileImportTargets(key, valueNode, current.parentPath, current.document)) {
979
+ const roleEntry = ROLE_IMPORT_KEYS.has(key) ? this.roleEntryTarget(name, ctx, this.roleEntryFileHint(valueNode)) : null;
980
+ const inlineFallback = ROLE_IMPORT_KEYS.has(key) && roleEntry === null && this.inlineRoleParams(valueNode) !== null ? this.scalarValues(valueNode).slice(0, 1)[0] : void 0;
981
+ const target = roleEntry ?? inlineFallback ?? name;
982
+ const claim = roleEntry !== null ? { importProjectFile: [roleEntry] } : this.keywordIncludeClaim(name, ctx, key);
983
+ this.addImport(ctx, target, "yaml_keyword_include", keyNode, true);
984
+ this.addRelationship(ctx, path, target, ASTEngine.RelationshipType.Imports, {
985
+ relationshipType: "yaml_keyword_include",
986
+ ...roleEntry !== null ? { roleName: name } : {},
987
+ // THE CLAIM IS DECIDED, NOT ASSUMED. This site used to hard-code `isExternal: true`,
988
+ // which the arbiter reads as "look this target's OWNER up" — the wrong question for a
989
+ // path, and the source of the 107 refuted external claims the whole-population run
990
+ // found. `keywordIncludeClaim` states the file claim when the target is a file this
991
+ // project's admitted list can answer for, and nothing weaker than before when it is
992
+ // not. See the helper for the measurement and the two refusals.
993
+ ...claim
994
+ }, line);
995
+ }
996
+ }
997
+ if (key === "uses" && valueType === "string" && valueNode) {
998
+ const role = isJobLevelPath(current.parentPath, current.document) ? "workflow-call" : "other";
999
+ const written = this.scalarText(valueNode);
1000
+ ctx.pendingActionUses.push({
1001
+ source: path,
1002
+ action: _RealYamlAnalyzer.rootAnchoredActionPath(written) ?? written,
1003
+ written,
1004
+ line,
1005
+ role
1006
+ });
1007
+ }
1008
+ const publication = schemaPublicationRole(current.parentPath, current.document);
1009
+ if (publication) {
1010
+ ctx.pendingSchemaPublications.push({ document: current.document, container: publication, name: key, path, line });
1011
+ }
1012
+ const declaredType = symbol.metadata?.declaredType;
1013
+ if (declaredType) {
1014
+ this.addRelationship(ctx, path, declaredType, ASTEngine.RelationshipType.References, {
1015
+ relationshipType: "declared_type",
1016
+ tagUri: symbol.metadata?.tagUri ?? declaredType,
1017
+ isExternal: true
1018
+ }, symbol.metadata?.tagLine ?? line);
1019
+ }
1020
+ const includePath = symbol.metadata?.tagIncludePath;
1021
+ if (includePath) {
1022
+ this.addImport(ctx, includePath, "yaml_tag_include", keyNode, true);
1023
+ this.addRelationship(ctx, path, includePath, ASTEngine.RelationshipType.Imports, {
1024
+ relationshipType: "yaml_tag_include",
1025
+ ...this.tagIncludeClaim(includePath, ctx, symbol.metadata?.tagIncludeQuoted === true)
1026
+ }, symbol.metadata?.tagLine ?? line);
1027
+ }
1028
+ }
1029
+ visitSequence(node, current, ctx, stack) {
1030
+ const total = node.namedChildCount;
1031
+ let assigned = 0;
1032
+ for (let i = total - 1; i >= 0; i--) {
1033
+ const child = node.namedChild(i);
1034
+ if (child) {
1035
+ stack.push({
1036
+ node: child,
1037
+ context: { ...current, depth: current.depth + 1, sequenceItem: true, sequenceIndex: i }
1038
+ });
1039
+ assigned++;
1040
+ }
1041
+ }
1042
+ if (current.parent) {
1043
+ current.parent.metadata = { ...current.parent.metadata || {}, itemCount: assigned };
1044
+ if (assigned > 0 && !current.parent.complexity) current.parent.complexity = assigned;
1045
+ }
1046
+ }
1047
+ visitSequenceItem(node, itemNode, current, ctx, stack, index) {
1048
+ const valueNode = SEQUENCE_ITEM_TYPES.has(node.type) ? this.firstNamedChild(node) : node;
1049
+ if (!valueNode) return;
1050
+ const itemPath = `${current.parentPath}[${index}]`;
1051
+ const pointerPath = `${current.pointerPath}/${index}`;
1052
+ const tag = this.tagOf(valueNode, ctx);
1053
+ const rawType = this.valueType(valueNode);
1054
+ const valueType = tag?.typeOverride ?? rawType;
1055
+ const line = itemNode.startPosition.row + 1;
1056
+ if (valueType === "object" || valueType === "array") {
1057
+ const item = this.createSymbol(
1058
+ `item_${index}`,
1059
+ valueType === "object" ? ASTEngine.SymbolKind.Class : ASTEngine.SymbolKind.Variable,
1060
+ itemNode,
1061
+ current.parent,
1062
+ current.parentPath,
1063
+ ctx,
1064
+ {
1065
+ yamlSpecific: true,
1066
+ nodeType: itemNode.type,
1067
+ valueType,
1068
+ valueTypeSource: tag?.typeOverride ? "tag" : rawType === "untyped" ? "default" : "grammar",
1069
+ fullPath: itemPath,
1070
+ pointerPath,
1071
+ isArrayItem: true,
1072
+ arrayIndex: index,
1073
+ recovered: current.recoveredThrough !== void 0 || current.parent?.metadata?.recovered === true || void 0,
1074
+ recoveredThrough: current.recoveredThrough,
1075
+ declaredType: this.tagTypeName(tag),
1076
+ tagUri: tag?.uri,
1077
+ tagLine: tag?.line,
1078
+ tagIncludePath: tag?.includePath,
1079
+ tagIncludeQuoted: tag?.includeQuoted,
1080
+ isSensitive: current.inSensitiveSubtree === true || void 0,
1081
+ sensitivityReason: current.inSensitiveSubtree === true ? "sensitive-subtree" : void 0,
1082
+ line
1083
+ },
1084
+ itemPath
1085
+ );
1086
+ item.complexity = this.nodeComplexity(valueNode, valueType);
1087
+ const docstring = this.docstringFor(line, itemNode.startPosition.column + 1, ctx);
1088
+ if (docstring) item.docstring = docstring;
1089
+ this.assignApiContractPath(item);
1090
+ ctx.shapeIndex.set(itemPath, valueType);
1091
+ ctx.pointerIndex.set(pointerPath, itemPath);
1092
+ this.emitTagSemantics(itemPath, tag, itemNode, line, ctx);
1093
+ stack.push({
1094
+ node: valueNode,
1095
+ context: {
1096
+ parent: item,
1097
+ parentPath: itemPath,
1098
+ pointerPath,
1099
+ document: current.document,
1100
+ depth: current.depth + 1,
1101
+ siblingKeys: /* @__PURE__ */ new Set(),
1102
+ inSensitiveSubtree: current.inSensitiveSubtree
1103
+ }
1104
+ });
1105
+ return;
1106
+ }
1107
+ ctx.pointerIndex.set(pointerPath, itemPath);
1108
+ if (tag?.includePath) {
1109
+ this.addImport(ctx, tag.includePath, "yaml_tag_include", itemNode, true);
1110
+ this.addRelationship(ctx, current.parentPath, tag.includePath, ASTEngine.RelationshipType.Imports, {
1111
+ relationshipType: "yaml_tag_include",
1112
+ ...this.tagIncludeClaim(tag.includePath, ctx, tag.includeQuoted === true)
1113
+ }, tag.line);
1114
+ } else if (this.tagTypeName(tag)) {
1115
+ this.addRelationship(ctx, current.parentPath, tag.shortName, ASTEngine.RelationshipType.References, {
1116
+ relationshipType: "declared_type",
1117
+ tagUri: tag.uri,
1118
+ isExternal: true
1119
+ }, tag.line);
1120
+ }
1121
+ stack.push({ node: valueNode, context: { ...current, depth: current.depth + 1, sequenceItem: false } });
1122
+ }
1123
+ /** The declared-type and include-import edges a tag implies, for a sequence item. */
1124
+ emitTagSemantics(source, tag, node, line, ctx) {
1125
+ if (!tag) return;
1126
+ if (tag.includePath) {
1127
+ this.addImport(ctx, tag.includePath, "yaml_tag_include", node, true);
1128
+ this.addRelationship(ctx, source, tag.includePath, ASTEngine.RelationshipType.Imports, {
1129
+ relationshipType: "yaml_tag_include",
1130
+ ...this.tagIncludeClaim(tag.includePath, ctx, tag.includeQuoted === true)
1131
+ }, tag.line);
1132
+ return;
1133
+ }
1134
+ if (this.tagTypeName(tag)) {
1135
+ this.addRelationship(ctx, source, tag.shortName, ASTEngine.RelationshipType.References, {
1136
+ relationshipType: "declared_type",
1137
+ tagUri: tag.uri,
1138
+ isExternal: true
1139
+ }, tag.line);
1140
+ }
1141
+ void line;
1142
+ }
1143
+ visitAnchor(node, current, ctx, stack) {
1144
+ const nameNode = this.findNamedChild(node, "anchor_name");
1145
+ const name = nameNode ? this.scalarText(nameNode) : "";
1146
+ if (name) {
1147
+ const anchorScope = current.parent?.qualifiedName || current.parent?.id || current.parent?.name || current.document;
1148
+ const key = `${anchorScope}#${name}`;
1149
+ if (!ctx.symbolIds.has(key)) {
1150
+ ctx.symbolIds.add(key);
1151
+ const anchor = this.createSymbol(
1152
+ name,
1153
+ ASTEngine.SymbolKind.Constant,
1154
+ nameNode || node,
1155
+ current.parent,
1156
+ current.parentPath,
1157
+ ctx,
1158
+ {
1159
+ yamlSpecific: true,
1160
+ nodeType: "anchor",
1161
+ isAnchor: true,
1162
+ anchorName: name,
1163
+ anchorDocument: current.document,
1164
+ anchorScope,
1165
+ fullPath: key,
1166
+ line: node.startPosition.row + 1
1167
+ },
1168
+ key
1169
+ );
1170
+ const documentAnchors = ctx.anchors.get(current.document) ?? /* @__PURE__ */ new Map();
1171
+ documentAnchors.set(name, anchor);
1172
+ ctx.anchors.set(current.document, documentAnchors);
1173
+ this.addPattern(ctx, "Anchors and Aliases");
1174
+ }
1175
+ }
1176
+ for (let i = node.namedChildCount - 1; i >= 0; i--) {
1177
+ const child = node.namedChild(i);
1178
+ if (child && child.type !== "anchor_name") stack.push({ node: child, context: current });
1179
+ }
1180
+ }
1181
+ visitAlias(node, current, ctx) {
1182
+ const nameNode = this.findNamedChild(node, "alias_name");
1183
+ const target = nameNode ? this.scalarText(nameNode) : "";
1184
+ if (!target) return;
1185
+ this.addPattern(ctx, "Anchors and Aliases");
1186
+ const merge = current.parent?.metadata?.isMergeKey === true;
1187
+ const source = merge ? current.parent?.ownerName || current.parent?.qualifiedName || current.parent?.id || current.parent?.name || "" : current.parent?.qualifiedName || current.parent?.id || current.parent?.name || "";
1188
+ if (!source) return;
1189
+ ctx.aliasRefs.push({ source, target, merge, document: current.document, line: node.startPosition.row + 1 });
1190
+ }
1191
+ resolveAliases(ctx) {
1192
+ for (const alias of ctx.aliasRefs) {
1193
+ const anchor = ctx.anchors.get(alias.document)?.get(alias.target);
1194
+ const target = anchor?.qualifiedName || alias.target;
1195
+ const type = alias.merge ? ASTEngine.RelationshipType.Extends : ASTEngine.RelationshipType.Uses;
1196
+ const crossDocument = !anchor && this.anchorExistsElsewhere(alias.target, alias.document, ctx);
1197
+ this.addRelationship(ctx, alias.source, target, type, {
1198
+ relationshipType: alias.merge ? "yaml_merge" : "yaml_alias",
1199
+ aliasName: alias.target,
1200
+ aliasDocument: alias.document,
1201
+ resolved: !!anchor,
1202
+ unresolvedReason: anchor ? void 0 : crossDocument ? "anchor-defined-in-another-document" : "anchor-not-declared",
1203
+ isExternal: false
1204
+ }, alias.line, anchor ? 1 : crossDocument ? 0.5 : 0.8, crossDocument ? "cross-document-alias" : "unresolved-alias");
1205
+ if (alias.merge) this.addPattern(ctx, "Configuration Inheritance");
1206
+ }
1207
+ }
1208
+ anchorExistsElsewhere(name, document, ctx) {
1209
+ for (const [doc, anchors] of ctx.anchors) {
1210
+ if (doc !== document && anchors.has(name)) return true;
1211
+ }
1212
+ return false;
1213
+ }
1214
+ /**
1215
+ * ONE STEP'S SHAPE, READ ONCE — `uses:`, and the `with:` keys that decide what a LATER local
1216
+ * `uses:` names.
1217
+ *
1218
+ * WHY IT IS COLLECTED AND NOT LOOKED UP LATER. The question `materializedActionSource` answers is
1219
+ * about the steps BEFORE this one IN THE SAME JOB, and a step's own keys do not come in a fixed
1220
+ * order: measured over the whole admitted corpus, `with:` follows `uses:` in the HashiCorp-style
1221
+ * files and PRECEDES it in others (control arm 7). So both are recorded into one record per step
1222
+ * as the walk passes them, and the join happens after the walk, where no key order can matter.
1223
+ * Only four key names are read, and only when they sit at a position that makes them a step's:
1224
+ * `uses:` at `…steps[i]`, and `path:`/`repository:`/`ref:` at `…steps[i].with`.
1225
+ */
1226
+ recordStepWithFacts(key, valueNode, current, ctx, line) {
1227
+ const suffix = key === "uses" ? "" : key === "path" || key === "repository" || key === "ref" ? ".with" : null;
1228
+ if (suffix === null) return;
1229
+ if (valueNode === null) return;
1230
+ const node = this.scalarNode(valueNode);
1231
+ if (!SCALAR_TYPES.has(node.type)) return;
1232
+ const value = this.scalarText(node);
1233
+ if (value.length === 0) return;
1234
+ const m = parseStepsPath(current.parentPath, suffix);
1235
+ if (m === null) return;
1236
+ const container = m[0], index = m[1];
1237
+ const byIndex = ctx.stepWithFacts.get(container) ?? /* @__PURE__ */ new Map();
1238
+ const entry = byIndex.get(index) ?? { usesLine: line };
1239
+ if (key === "uses") {
1240
+ entry.uses = value;
1241
+ entry.usesLine = line;
1242
+ } else entry[key] = value;
1243
+ byIndex.set(index, entry);
1244
+ ctx.stepWithFacts.set(container, byIndex);
1245
+ }
1246
+ /**
1247
+ * WHAT A LOCAL `uses:` TARGET NAMES, once the RUNNER'S OWN ORDER AND SCOPE are read.
1248
+ *
1249
+ * ## The defect this fixes
1250
+ *
1251
+ * `uses: ./security-scanner` in a job whose earlier step ran `actions/checkout@<sha>` with
1252
+ * `with: {repository: hashicorp/security-scanner, path: security-scanner, ref: main}` names a
1253
+ * directory that does not exist in the repository — because it is not supposed to: the checkout
1254
+ * PUTS that repository's content there before the step runs. The lane emitted `isExternal: true`
1255
+ * on the path, which the arbiter can only answer `externalUnverified` about, so 10 edges of the
1256
+ * EXT-Q class were claims no check could ever reach. A site making an unsupportable claim is a
1257
+ * FIX, not a ruling — and the claim IS supportable: the referent is a NAMED REPOSITORY at a NAMED
1258
+ * REF, which `verifyRemoteRefClaim` verifies.
1259
+ *
1260
+ * MEASURED over the whole admitted population before this was written, from the text and
1261
+ * independently of this analyzer (`validation/m14/fixtures/yaml/materialized-action-price.ts`):
1262
+ * 523 local `uses: ./…` sites, 10 matched to a prior same-job checkout, 9 convertible and 1
1263
+ * honestly left unverifiable:
1264
+ *
1265
+ * ./security-scanner ×7 boundary, consul ×4, nomad, vault -> repo@main
1266
+ * ./ci-actions/.github/actions/setup ×2 nimony -> the project's own file
1267
+ * ./integration-release-action ×1 waypoint -> REFUSED: no `ref:` in the
1268
+ * checkout, and the shared rule refuses a ref-less
1269
+ * remoteRef, so the edge stays `isExternal`
1270
+ *
1271
+ * ## The two families, and why both are the same question
1272
+ *
1273
+ * A checkout WITH `repository: R` puts R's tree at `path:`; a checkout WITHOUT `repository:` puts
1274
+ * THE CALLING REPOSITORY's tree there (nimony's own comment says the checkout exists so the local
1275
+ * `uses:` has its file on disk). So the second family's `./ci-actions/.github/actions/setup` IS
1276
+ * the project's `.github/actions/setup`, and the claim is the file claim the project's admitted
1277
+ * list answers — the stripped path is handed to `actionUseClaim` as an extra candidate.
1278
+ *
1279
+ * ## What it refuses, because these are the shapes a wrong answer wears
1280
+ *
1281
+ * ONLY EARLIER STEPS COUNT: the runner materializes in step order, so a checkout AFTER the use
1282
+ * cannot be its referent (control arm 4). ONLY THE SAME JOB: another job runs on another machine
1283
+ * and materializes nothing for this one (arm 3). ONLY `./…`: a `../` target leaves the workspace,
1284
+ * which a `path:` never does. ONLY AN `actions/checkout@…` STEP — an unrelated action carrying the
1285
+ * same `with: path:` materializes nothing (arm 6). Key order inside the step is NOT evidence
1286
+ * (arm 7). Where an in-repo `action.yml` also exists the MATERIALIZED repository wins, because a
1287
+ * checkout REPLACES that directory at run time (arm 10, whose falsifier is arm 10b).
1288
+ *
1289
+ * NOT CLAIMED, and stated as refusals rather than implemented unmeasured (0 corpus sites each): a
1290
+ * `../` target, a SUBPATH of a named repository, and an exact match against a checkout of the
1291
+ * calling repository (which would name the project root as an action).
1292
+ */
1293
+ materializedActionSource(use, ctx) {
1294
+ if (!use.action.startsWith("./")) return null;
1295
+ const m = parseStepsPath(use.source, ".uses");
1296
+ if (m === null) return null;
1297
+ const byIndex = ctx.stepWithFacts.get(m[0]);
1298
+ if (byIndex === void 0) return null;
1299
+ const useIndex = m[1];
1300
+ const dir = stripTrailingSlashes(use.action.slice(2));
1301
+ if (dir.length === 0) return null;
1302
+ let best = null;
1303
+ for (const [index, facts] of byIndex) {
1304
+ if (index >= useIndex) continue;
1305
+ if (facts.uses === void 0 || !isCheckoutAction(facts.uses)) continue;
1306
+ if (facts.path === void 0) continue;
1307
+ const checkoutPath = stripTrailingSlashes(stripDotSlash(facts.path));
1308
+ if (checkoutPath.length === 0) continue;
1309
+ const rest = dir === checkoutPath ? "" : dir.startsWith(checkoutPath + "/") ? dir.slice(checkoutPath.length + 1) : null;
1310
+ if (rest === null) continue;
1311
+ if (best === null || index > best.index) best = { index, facts, rest };
1312
+ }
1313
+ if (best === null) return null;
1314
+ const repository = best.facts.repository !== void 0 && best.facts.repository.length > 0 ? best.facts.repository : null;
1315
+ const ref = best.facts.ref !== void 0 && best.facts.ref.length > 0 ? best.facts.ref : null;
1316
+ if (repository !== null && best.rest.length > 0) return null;
1317
+ if (repository === null && best.rest.length === 0) return null;
1318
+ return { repository, ref: repository === null ? null : ref, stripped: repository === null ? best.rest : null, line: best.facts.usesLine };
1319
+ }
1320
+ /** A job-level `uses:` is a CALL of a reusable workflow, and never an import. */
1321
+ resolveActionUses(ctx) {
1322
+ if (ctx.pendingActionUses.length === 0) return;
1323
+ const isWorkflow = ctx.frameworks.includes("GitHub Actions");
1324
+ for (const use of ctx.pendingActionUses) {
1325
+ const spelling = use.written !== use.action ? { actionSpelling: use.written } : {};
1326
+ if (use.role === "workflow-call") {
1327
+ if (!isWorkflow) {
1328
+ this.addRelationship(ctx, use.source, use.action, ASTEngine.RelationshipType.References, {
1329
+ relationshipType: "yaml_workflow_call_unverified",
1330
+ isExternal: true,
1331
+ resolved: false,
1332
+ unresolvedReason: "file-shape-is-not-a-github-workflow"
1333
+ }, use.line, 0.7, "shape-not-workflow");
1334
+ continue;
1335
+ }
1336
+ const local = use.action.startsWith("./") || use.action.startsWith("../");
1337
+ this.addRelationship(ctx, use.source, use.action, ASTEngine.RelationshipType.Calls, {
1338
+ relationshipType: "yaml_workflow_call",
1339
+ evidenceClass: "structural",
1340
+ ...spelling,
1341
+ // A REUSABLE-WORKFLOW CALL NAMES A FILE, not an action directory — see
1342
+ // `workflowCallClaim`. Routed through `actionUseClaim` it looked for
1343
+ // `<file>/action.yml`, found nothing, and claimed `isExternal` about a workflow
1344
+ // file this project has (208 edges over the whole admitted population).
1345
+ ...this.workflowCallClaim(use.action, ctx),
1346
+ resolved: false,
1347
+ unresolvedReason: local ? "workflow-in-same-repository-not-indexed" : "workflow-in-another-repository"
1348
+ }, use.line);
1349
+ continue;
1350
+ }
1351
+ const materialized = this.materializedActionSource(use, ctx);
1352
+ const claim = materialized === null ? this.actionUseClaim(use.action, ctx) : {
1353
+ // The claim IS the checked-out repository at its ref, and the `./D` spelling it
1354
+ // replaced travels in metadata so a reader can audit the re-route from the edge alone.
1355
+ ...materialized.repository !== null ? { remoteRef: true } : this.actionUseClaim(use.action, ctx, materialized.stripped !== null ? [materialized.stripped] : []),
1356
+ materializedPath: use.action,
1357
+ materializedAtLine: materialized.line
1358
+ };
1359
+ const target = materialized !== null && materialized.repository !== null ? materialized.ref !== null ? `${materialized.repository}@${materialized.ref}` : materialized.repository : use.action;
1360
+ if (!isWorkflow) {
1361
+ this.addRelationship(ctx, use.source, target, ASTEngine.RelationshipType.References, {
1362
+ relationshipType: "yaml_action_use_unverified",
1363
+ ...claim,
1364
+ ...spelling,
1365
+ resolved: false,
1366
+ unresolvedReason: "file-shape-is-not-a-github-workflow"
1367
+ }, use.line, 0.7, "shape-not-workflow");
1368
+ continue;
1369
+ }
1370
+ this.addImport(ctx, target, "yaml_action_use", void 0, materialized !== null || !use.action.startsWith("./") && !use.action.startsWith("docker://"), use.line);
1371
+ this.addRelationship(ctx, use.source, target, ASTEngine.RelationshipType.Imports, {
1372
+ relationshipType: "yaml_action_use",
1373
+ ...claim,
1374
+ ...spelling
1375
+ }, use.line);
1376
+ }
1377
+ }
1378
+ /**
1379
+ * `exports`: the named definitions a document publishes for other documents to
1380
+ * `$ref`. The document is the exporting unit and the definition is the exported
1381
+ * name, and BOTH are symbols of this file — so unlike every external edge in this
1382
+ * analyzer, this one resolves at both ends.
1383
+ *
1384
+ * Gated on the document declaring `openapi:` or `swagger:` at its top level, read
1385
+ * from the shape index rather than from the text: a `components.schemas.X` path
1386
+ * inside a file that never claims to be an API description is a coincidence of
1387
+ * naming, and a publication edge on a coincidence is a fabricated edge.
1388
+ */
1389
+ resolveSchemaExports(ctx) {
1390
+ if (ctx.pendingSchemaPublications.length === 0) return;
1391
+ let emitted = 0;
1392
+ for (const pub of ctx.pendingSchemaPublications) {
1393
+ const shapes = ctx.documentShapes.get(pub.document);
1394
+ if (!shapes || !(shapes.has("openapi") || shapes.has("swagger"))) continue;
1395
+ this.addRelationship(ctx, pub.document, pub.path, ASTEngine.RelationshipType.Exports, {
1396
+ relationshipType: "yaml_schema_publication",
1397
+ evidenceClass: "structural",
1398
+ container: pub.container,
1399
+ definitionName: pub.name,
1400
+ isExternal: false,
1401
+ resolved: true
1402
+ }, pub.line);
1403
+ emitted++;
1404
+ }
1405
+ if (emitted > 0) this.addPattern(ctx, "Published Schema Components");
1406
+ }
1407
+ /**
1408
+ * A `$ref` WHOSE VALUE IS A BARE NAME, RESOLVED AGAINST THE `test-data:` BLOCK IT LIVES IN.
1409
+ *
1410
+ * The convention is documented by the file's own consumer (gql-sangria's `CatsSupport.resolveRef`,
1411
+ * cited at the call site): inside a `test-data:` block, `{$ref: name}` means the entry `name` of
1412
+ * THAT block, not a JSON pointer. It is a REFERENCE convention rather than a path, so the target
1413
+ * must be the published declaration path, which is what makes the edge resolve instead of
1414
+ * appearing as an ambiguous bare leaf.
1415
+ *
1416
+ * Refuses more than it accepts:
1417
+ * * the value must be a bare name — a '.', '/', ':' or '#' anywhere means a path, URI or JSON
1418
+ * pointer and this rule does not apply (`isBareRefName`);
1419
+ * * the read site must be INSIDE a `test-data:` block (the nearest ancestor, found from the
1420
+ * shape index), so a `$ref` elsewhere in a file that happens to have one is untouched;
1421
+ * * the target path must EXIST in the document's shape index — a name the block does not
1422
+ * declare returns null and the pre-existing `external-file` reading is kept.
1423
+ *
1424
+ * MEASURED corpus-wide: the only `test-data`-nested bare `$ref` values are gql-sangria's four
1425
+ * (`data` ×3, `deepData` ×1); the other bare spellings corpus-wide are 26 `$ref: null` in the
1426
+ * Grace OpenAPI documents, which have no `test-data` block and therefore never reach this rule.
1427
+ *
1428
+ * Both endpoints are therefore existing declarations of this one file, and the target is a
1429
+ * qualified path — so this can move an edge out of the ambiguous/unresolved columns and cannot
1430
+ * put one into a collision.
1431
+ */
1432
+ resolveTestDataReference(source, pointer, ctx) {
1433
+ if (!isBareRefName(pointer)) return null;
1434
+ let block = null;
1435
+ for (const [path, valueType] of ctx.shapeIndex) {
1436
+ if (valueType !== "object") continue;
1437
+ if (!path.endsWith(".test-data")) continue;
1438
+ if (!source.startsWith(`${path}.`)) continue;
1439
+ if (block === null || path.length > block.length) block = path;
1440
+ }
1441
+ if (block === null) return null;
1442
+ const target = `${block}.${pointer}`;
1443
+ return ctx.shapeIndex.has(target) ? target : null;
1444
+ }
1445
+ /** `$ref` targets that point outside this file, or at a pointer nothing declares. */
1446
+ resolvePointerReferences(ctx) {
1447
+ for (const r of ctx.relationships) {
1448
+ if (r.metadata?.relationshipType !== "yaml_json_pointer") continue;
1449
+ const pointer = r.metadata.pointer;
1450
+ if (typeof pointer !== "string") continue;
1451
+ if (!pointer.startsWith("/")) {
1452
+ const testDataTarget = this.resolveTestDataReference(String(r.source ?? ""), pointer, ctx);
1453
+ if (testDataTarget !== null) {
1454
+ r.target = testDataTarget;
1455
+ r.metadata.resolved = true;
1456
+ r.metadata.resolvedIn = "file";
1457
+ r.metadata.resolvedVia = "test-data-reference";
1458
+ r.confidence = 1;
1459
+ delete r.metadata.isExternal;
1460
+ delete r.metadata.unresolvedReason;
1461
+ continue;
1462
+ }
1463
+ r.metadata.resolved = false;
1464
+ r.metadata.unresolvedReason = "external-file";
1465
+ continue;
1466
+ }
1467
+ const hit = ctx.pointerIndex.get(pointer);
1468
+ if (hit) {
1469
+ r.target = hit;
1470
+ r.metadata.resolved = true;
1471
+ r.metadata.resolvedIn = "file";
1472
+ r.confidence = 1;
1473
+ } else {
1474
+ const recovered = this.resolvePointerOnRecoveredTree(pointer, String(r.source ?? ""), ctx);
1475
+ if (recovered !== null) {
1476
+ r.target = recovered;
1477
+ r.metadata.resolved = true;
1478
+ r.metadata.resolvedIn = "file";
1479
+ r.metadata.resolvedVia = "document-shape-suffix";
1480
+ r.confidence = 0.95;
1481
+ continue;
1482
+ }
1483
+ r.metadata.resolved = false;
1484
+ r.metadata.unresolvedReason = ctx.parseHadError ? "parse-error-in-document" : "pointer-not-declared-in-file";
1485
+ r.confidence = 0.9;
1486
+ r.metadata.confidenceReason = r.metadata.unresolvedReason;
1487
+ }
1488
+ }
1489
+ }
1490
+ /**
1491
+ * WHAT A `$ref` MEANT WHEN THE GRAMMAR FLATTENED THE TREE IT POINTS INTO.
1492
+ *
1493
+ * Consulted only for a pointer the exact index missed, and ONLY on a tree the grammar errored
1494
+ * on: on a clean parse the pointer key is the answer and this never runs, so no existing
1495
+ * resolution can move. The evidence is the document's OWN declared paths (`documentShapes`,
1496
+ * keyed by the symbol path the walk built) — the analyzer's published name for the same
1497
+ * declaration, which is what the edge must target to be resolved at all by production.
1498
+ *
1499
+ * The rule, and it refuses more than it accepts:
1500
+ *
1501
+ * * decode the pointer's segments (`~1` -> `/`, `~0` -> `~`) and join them with `.`;
1502
+ * * a declared path matches when it IS that string, or when the string ENDS WITH `.` + it —
1503
+ * i.e. the declaration sits at or below the path the pointer names, which is the shape a
1504
+ * hoisted level produces (`definitions.MountType` ends with `.MountType`);
1505
+ * * take the LONGEST match, so the most specific declaration wins;
1506
+ * * if two declared paths tie at that length, or the winner is not a published symbol path,
1507
+ * return null and let the edge stay unresolved. A recovered binding that is not unique is
1508
+ * a fabrication, and an ambiguous `$ref` is not a resolved one.
1509
+ */
1510
+ resolvePointerOnRecoveredTree(pointer, source, ctx) {
1511
+ if (!ctx.parseHadError) return null;
1512
+ const document = this.documentOfPath(source, ctx);
1513
+ if (document === null) return null;
1514
+ const shapes = ctx.documentShapes.get(document);
1515
+ if (!shapes) return null;
1516
+ const decoded = pointer.slice(1).split("/").map((seg) => seg.split("~1").join("/").split("~0").join("~")).join(".");
1517
+ if (decoded.length === 0) return null;
1518
+ let longest = -1;
1519
+ let winner = null;
1520
+ let ties = 0;
1521
+ for (const key of shapes.keys()) {
1522
+ if (key.length === 0) continue;
1523
+ if (key !== decoded && !decoded.endsWith(`.${key}`)) continue;
1524
+ if (key.length > longest) {
1525
+ longest = key.length;
1526
+ winner = key;
1527
+ ties = 1;
1528
+ continue;
1529
+ }
1530
+ if (key.length === longest) ties++;
1531
+ }
1532
+ if (winner === null || ties !== 1) return null;
1533
+ const target = `${document}.${winner}`;
1534
+ return ctx.shapeIndex.has(target) ? target : null;
1535
+ }
1536
+ /** The document a symbol path belongs to: the longest document name that prefixes it. */
1537
+ documentOfPath(source, ctx) {
1538
+ let best = null;
1539
+ for (const document of ctx.documentShapes.keys()) {
1540
+ if (source !== document && !source.startsWith(`${document}.`)) continue;
1541
+ if (best === null || document.length > best.length) best = document;
1542
+ }
1543
+ return best;
1544
+ }
1545
+ /**
1546
+ * Sibling references: a key that names another thing declared in the same file.
1547
+ * The target set is what the walk actually saw — document `metadata.name` values and
1548
+ * top-level `services` keys — so a resolution claimed here is a resolution that was
1549
+ * measured, and an unresolved one says so with a reason and a sub-1.0 confidence.
1550
+ */
1551
+ /**
1552
+ * A `needs:` TARGET NAMES A JOB OF ITS OWN DOCUMENT, and the document-scoped identity is what
1553
+ * makes it resolvable.
1554
+ *
1555
+ * `needs: [test]` inside `.github/workflows/build.yml` means `jobs.test` of THAT FILE, and
1556
+ * every workflow in a project may declare a job named `test` — so the bare name carries N
1557
+ * declarations in the project and production's resolver, which fills a bare name only when
1558
+ * `COUNT(*) = 1` within the project, can never bind it. The lane already publishes each job as
1559
+ * `<document>.jobs.<id>`; targeting that is unique, and it is what the edge MEANT.
1560
+ *
1561
+ * Measured before the change, whole corpus: 802 of the lane's 879 ambiguous `references` are
1562
+ * this shape. The lookup is gated on the document's OWN shape index, so a Compose `depends_on`
1563
+ * (which resolves through `serviceIndex` instead) and a name that merely looks like a job are
1564
+ * both untouched — the check is "does this document declare `jobs.<target>`", not "does the
1565
+ * target look like a job".
1566
+ */
1567
+ resolveJobInDocument(ref, ctx) {
1568
+ if (typeof ref.document !== "string" || ref.document.length === 0) return null;
1569
+ const shapes = ctx.documentShapes.get(ref.document);
1570
+ if (!shapes || !shapes.has(`jobs.${ref.target}`)) return null;
1571
+ return `${ref.document}.jobs.${ref.target}`;
1572
+ }
1573
+ /**
1574
+ * A KUBERNETES OBJECT REFERENCED BY NAME, DECLARED IN A SIBLING FILE OF THE SAME DIRECTORY.
1575
+ *
1576
+ * ## The defect this fixes, and it was found by this lane's own falsifier
1577
+ *
1578
+ * `serviceAccountName: apiserver` in
1579
+ * `staging/src/k8s.io/sample-apiserver/artifacts/example/deployment.yaml` was UNRESOLVED, while
1580
+ * `.../artifacts/example/sa.yaml` declares `kind: ServiceAccount, metadata.name: apiserver` — the
1581
+ * same directory, one manifest set, and `kubectl apply -f <dir>` reads both. `name-collision-
1582
+ * falsifier.ts` proved it with a non-zero exit before this existed.
1583
+ *
1584
+ * ## The rule, PRICED before it was applied (the falsifier counts instead of asserting)
1585
+ *
1586
+ * CEILING 1 ambiguous edge a same-directory, same-kind resolution can reach
1587
+ * CONTROL 0 of them sit in a directory declaring that kind+name TWICE (ambiguous even scoped)
1588
+ * CONTROL 117 MORE would be bound by a PROJECT-WIDE version of the rule, and every one of them
1589
+ * is refused: another manifest set is another cluster. The directory scope is the whole
1590
+ * claim, and that is what makes 117 a control rather than a ceiling.
1591
+ *
1592
+ * ## What it reads, and why that is not a contract violation
1593
+ *
1594
+ * `analyze()` is SYNCHRONOUS and the only parser accessor (`engine.getParser`) is async, so a
1595
+ * sibling cannot be parsed here. The two candidate spellings of a sibling's declaration
1596
+ * (`kind:` at column 0, `name:` inside a top-level `metadata:` block) are therefore read from the
1597
+ * sibling's TEXT — and the important part is what the read is FOR: it is a CANDIDATE FILTER, never
1598
+ * an extraction. The value it produces is a DOCUMENT IDENTITY, and the ARBITER then has to
1599
+ * resolve that name against the project's own key space. A wrong sibling makes the edge
1600
+ * unresolved again, not wrong; the only way text can turn into a fact here is through a lookup
1601
+ * this analyzer does not perform. The current file's OWN structure still comes from the tree.
1602
+ *
1603
+ * It is bounded and refuses more than it accepts: only a `yaml_resource_ref` whose referring key
1604
+ * maps to a KIND, only with a project root, only `.yaml`/`.yml` siblings, and it returns null the
1605
+ * moment a SECOND sibling matches — two same-kind, same-name objects in one directory is exactly
1606
+ * the case where the rule would be a guess.
1607
+ */
1608
+ resolveSameDirectoryResource(ref, ctx) {
1609
+ if (ref.kind !== "yaml_resource_ref") return null;
1610
+ const key = ref.source.slice(ref.source.lastIndexOf(".") + 1);
1611
+ const kind = _RealYamlAnalyzer.KIND_FOR_RESOURCE_KEY[key];
1612
+ if (kind === void 0) return null;
1613
+ const root = ctx.projectRoot;
1614
+ if (typeof root !== "string" || root.length === 0) return null;
1615
+ const rel = _RealYamlAnalyzer.projectRelativePath(ctx.filePath, ctx.projectRoot);
1616
+ if (rel === null) return null;
1617
+ const cut = rel.lastIndexOf("/");
1618
+ const dirRel = cut >= 0 ? rel.slice(0, cut) : "";
1619
+ const dirAbs = dirRel.length > 0 ? join(root, ...dirRel.split("/")) : root;
1620
+ let entries;
1621
+ try {
1622
+ entries = readdirSync(dirAbs);
1623
+ } catch {
1624
+ return null;
1625
+ }
1626
+ const hits = [];
1627
+ for (const entry of entries.sort()) {
1628
+ if (!isYamlSpelling(entry)) continue;
1629
+ const siblingRel = dirRel.length > 0 ? `${dirRel}/${entry}` : entry;
1630
+ if (siblingRel === rel) continue;
1631
+ let text;
1632
+ try {
1633
+ text = readFileSync(join(dirAbs, entry), "utf8");
1634
+ } catch {
1635
+ continue;
1636
+ }
1637
+ const parser = ASTEngine.getCachedParser("yaml");
1638
+ if (!parser) return null;
1639
+ let siblingTree = null;
1640
+ try {
1641
+ siblingTree = parser.parse(text);
1642
+ } catch {
1643
+ continue;
1644
+ }
1645
+ let declares = false;
1646
+ try {
1647
+ declares = siblingTree?.rootNode ? this.declaresResourceInTree(siblingTree.rootNode, kind, ref.target) : false;
1648
+ } finally {
1649
+ try {
1650
+ siblingTree?.delete?.();
1651
+ } catch {
1652
+ }
1653
+ }
1654
+ if (!declares) continue;
1655
+ if (hits.length > 0) return null;
1656
+ hits.push(this.documentName(join(dirAbs, entry), 1, ctx.projectRoot));
1657
+ }
1658
+ return hits.length === 1 ? hits[0] : null;
1659
+ }
1660
+ /**
1661
+ * DOES THIS SIBLING DECLARE `kind`/`name`? ASKED OF ITS TREE, NOT OF ITS TEXT.
1662
+ *
1663
+ * THE DEFECT THIS REPLACES WAS MEASURED BY THE FLEET'S OWN RULE, not by taste: the old version
1664
+ * matched `/^kind:[ \t]*["']?([A-Za-z][\w.-]*)["']?[ \t]*$/m` and
1665
+ * `/^[ \t]+name:[ \t]*["']?([^"'\n#]+?)["']?[ \t]*$/m` over the sibling's SOURCE TEXT. Regex over
1666
+ * source text MATCHES COMMENTS AND STRING LITERALS, MISSES NESTED SYNTAX, AND BREAKS SILENTLY ON
1667
+ * THE NEXT GRAMMAR VERSION — so a commented-out `# kind: Deployment` matched, and so did a
1668
+ * `description: "kind: Pod"` value, which is a string ABOUT a kind rather than a kind.
1669
+ *
1670
+ * THE TREE CANNOT SEE EITHER. The pair's `key` field gives `kind` and `metadata` exactly, the
1671
+ * `value` field gives the scalar with its QUOTING ALREADY RESOLVED BY THE GRAMMAR (so `kind:
1672
+ * "Deployment"` and `kind: Deployment` read the same, which the regex only approximated), and a
1673
+ * comment is not a node. The rules are unchanged and are the reason this is anchored at the
1674
+ * document's top level: only the document's OWN `kind`, and only the `name` INSIDE ITS OWN
1675
+ * `metadata` block — a nested pod-template `metadata.name` is not the document's own.
1676
+ *
1677
+ * IT DESCENDS THE STREAM THE GRAMMAR ACTUALLY PRODUCES: the root is a `stream` of `document`
1678
+ * nodes, not a mapping, so a reader that assumed a mapping would silently find nothing.
1679
+ */
1680
+ declaresResourceInTree(root, kind, name) {
1681
+ const document = this.firstChildOfType(root, "document");
1682
+ if (!document) return false;
1683
+ const mapping = this.firstMappingUnder(document);
1684
+ if (!mapping) return false;
1685
+ if (this.mappingEntryScalar(mapping, "kind") !== kind) return false;
1686
+ const metadata = this.mappingValueForKey(mapping, "metadata");
1687
+ if (!metadata) return false;
1688
+ const inner = this.firstMappingUnder(this.scalarNode(metadata));
1689
+ if (!inner) return false;
1690
+ return this.mappingEntryScalar(inner, "name") === name;
1691
+ }
1692
+ /** The first descendant of a given type, in tree order. */
1693
+ firstChildOfType(node, type) {
1694
+ for (let i = 0; i < node.namedChildCount; i++) {
1695
+ const child = node.namedChild(i);
1696
+ if (!child) continue;
1697
+ if (child.type === type) return child;
1698
+ const deeper = this.firstChildOfType(child, type);
1699
+ if (deeper) return deeper;
1700
+ }
1701
+ return null;
1702
+ }
1703
+ /** The mapping a document (or a wrapper) holds: the first mapping-shaped named child. */
1704
+ firstMappingUnder(node) {
1705
+ const unwrapped = this.scalarNode(node);
1706
+ if (MAPPING_TYPES.has(unwrapped.type)) return unwrapped;
1707
+ for (let i = 0; i < unwrapped.namedChildCount; i++) {
1708
+ const child = unwrapped.namedChild(i);
1709
+ if (!child) continue;
1710
+ const inner = this.firstMappingUnder(child);
1711
+ if (inner) return inner;
1712
+ }
1713
+ return null;
1714
+ }
1715
+ /** The VALUE NODE of a mapping's entry for `keyName` — the counterpart of `mappingEntryScalar`. */
1716
+ mappingValueForKey(node, keyName) {
1717
+ if (!node) return null;
1718
+ const mapping = this.scalarNode(node);
1719
+ if (!MAPPING_TYPES.has(mapping.type)) return null;
1720
+ for (let i = 0; i < mapping.namedChildCount; i++) {
1721
+ const pair = mapping.namedChild(i);
1722
+ if (!pair || !MAPPING_PAIR_TYPES.has(pair.type)) continue;
1723
+ const keyNode = pair.childForFieldName("key") || this.firstNonDecorationChild(pair);
1724
+ if (!keyNode || this.scalarText(keyNode) !== keyName) continue;
1725
+ const value = pair.childForFieldName("value") || this.mappingValueNode(pair);
1726
+ if (value) return value;
1727
+ }
1728
+ return null;
1729
+ }
1730
+ /** The referring KEY -> the object KIND it names. One source of truth: the falsifier imports it,
1731
+ * so the instrument that PRICES this rule and the analyzer that APPLIES it cannot drift. */
1732
+ static KIND_FOR_RESOURCE_KEY = {
1733
+ secretName: "Secret",
1734
+ secretRef: "Secret",
1735
+ secretKeyRef: "Secret",
1736
+ secret: "Secret",
1737
+ claimName: "PersistentVolumeClaim",
1738
+ configMap: "ConfigMap",
1739
+ configMapRef: "ConfigMap",
1740
+ configMapKeyRef: "ConfigMap",
1741
+ serviceAccountName: "ServiceAccount"
1742
+ };
1743
+ /**
1744
+ * JSON SCHEMA COMPOSITION, as `extends`.
1745
+ *
1746
+ * `allOf` means "must satisfy ALL of these" — a superset-of relationship, and the only schema
1747
+ * keyword that means one. `anyOf` and `oneOf` are ALTERNATIVES, which have no ontology type, so
1748
+ * their `$ref`s correctly contribute only the `references` they already produce.
1749
+ *
1750
+ * THIS IS THE JSON LANE'S OWN SETTLED RULING, applied to the same construct in the YAML
1751
+ * spelling: `script/m14/spec/json-completeness.json` records `BD-allof-extends` — "`allOf` is the
1752
+ * only JSON Schema keyword that means 'must satisfy all of these' … a superset relation, which is
1753
+ * why it is `extends` and not `implements`" — and the JSON analyzer emits it over 1,432 edges.
1754
+ * The YAML corpus carries the same construct in the same documents, because a JSON Schema or an
1755
+ * OpenAPI document is routinely written as YAML, and this lane already reads those documents
1756
+ * (it publishes their schema definitions as `exports` and their `$ref`s as `references`).
1757
+ *
1758
+ * MEASURED over the admitted population (the count is the TEXT sweep's, and the tree census's
1759
+ * number is the one the spec cites): `allOf` appears **974** times, against `anyOf` **446** and
1760
+ * `oneOf` **154** — the shape the reading predicts, composition common and alternatives rare.
1761
+ *
1762
+ * THE SOURCE IS THE SCHEMA THAT COMPOSES, not the `allOf` array that records the composition.
1763
+ * `allOf` is a keyword shared by every schema in the file, so an edge whose source were the
1764
+ * array would bind a consumer resolving by name to an arbitrary one of them. The thing that
1765
+ * inherits is `Request`, not `Request.allOf`.
1766
+ *
1767
+ * THE RULE IS THE PATH'S TAIL, and it is exact on purpose: the `$ref` must be a DIRECT member
1768
+ * of an `allOf` item. A `$ref` DEEPER inside an item — `…allOf[1].properties.OnTimeout.$ref` —
1769
+ * is a property reference, not a composition, and the corpus has both: measured on
1770
+ * `Grace/src/OpenAPI/Approval.Components.OpenAPI.yaml`, `CreateApprovalPolicyParameters.allOf[1]`
1771
+ * holds a `properties` mapping whose members carry their own `$ref`s. A rule that matched
1772
+ * "inside an allOf" would have claimed those as compositions; a rule that matched "the parent
1773
+ * ends with `.allOf`" would have missed the object form (`allOf: {$ref: X}`), which is legal
1774
+ * JSON Schema and which the corpus also carries.
1775
+ *
1776
+ * THE CLAIM IS THE POINTER EDGE'S OWN, because it is the same statement: a local `#/…` pointer
1777
+ * resolves inside the file and the target is the resolved symbol path; a SIBLING-FILE pointer
1778
+ * (`Shared.Components.OpenAPI.yaml#/components/schemas/CommonParameters`) names a file this
1779
+ * document composes with, and the file claim is the one the project can check.
1780
+ */
1781
+ /**
1782
+ * THE COMPOSER OF A SCHEMA PATH, or null when the path is not a DIRECT member of an `allOf`.
1783
+ *
1784
+ * `…allOf[N].$ref` and `…allOf.$ref` are compositions; `…allOf[1].properties.X.$ref` is a property
1785
+ * reference and is NOT. THE RULE IS THE TAIL, and the walk says so in one pass: find the LAST
1786
+ * `.allOf`, then require the rest of the string to be exactly `[N]?.$ref`. A path with a deeper
1787
+ * `$ref` fails that requirement at the first character after `.allOf`.
1788
+ *
1789
+ * A STRING WALK AND NOT A REGEX — the token is already in hand, and the fleet's rule is that
1790
+ * pattern-matching a value you already have is a second implementation of a question already
1791
+ * answered. Returns the composer (everything before `.allOf`), or null.
1792
+ */
1793
+ allOfComposerOf(source) {
1794
+ const marker = ".allOf";
1795
+ const at = source.lastIndexOf(marker);
1796
+ if (at <= 0) return null;
1797
+ let i = at + marker.length;
1798
+ if (source[i] === "[") {
1799
+ const close = source.indexOf("]", i);
1800
+ if (close < 0) return null;
1801
+ const digits = source.slice(i + 1, close);
1802
+ if (digits.length === 0) return null;
1803
+ for (let d = 0; d < digits.length; d++) {
1804
+ const c = digits[d];
1805
+ if (c < "0" || c > "9") return null;
1806
+ }
1807
+ i = close + 1;
1808
+ }
1809
+ if (source[i] === ".") i++;
1810
+ if (source.slice(i) !== "$ref") return null;
1811
+ return source.slice(0, at);
1812
+ }
1813
+ emitSchemaComposition(ctx) {
1814
+ for (const r of ctx.relationships) {
1815
+ if (r?.metadata?.relationshipType !== "yaml_json_pointer") continue;
1816
+ const source = String(r.source ?? "");
1817
+ const composer = this.allOfComposerOf(source);
1818
+ if (composer === null) continue;
1819
+ const target = String(r.target ?? "");
1820
+ if (!composer || !target) continue;
1821
+ const md = r.metadata ?? {};
1822
+ const resolved = md.resolved === true;
1823
+ this.addRelationship(ctx, composer, target, ASTEngine.RelationshipType.Extends, {
1824
+ relationshipType: "yaml_schema_composition",
1825
+ compositionOperator: "allOf",
1826
+ evidenceClass: "structural",
1827
+ resolved,
1828
+ ...md.pointer !== void 0 ? { pointer: md.pointer } : {},
1829
+ ...md.importProjectFile !== void 0 ? { importProjectFile: md.importProjectFile } : {},
1830
+ ...md.isExternal !== void 0 ? { isExternal: md.isExternal } : {},
1831
+ ...resolved ? {} : { unresolvedReason: md.unresolvedReason ?? "external-file" }
1832
+ }, r.line_number ?? 0, resolved ? 1 : 0.9, resolved ? void 0 : "composition-target-not-resolved");
1833
+ }
1834
+ }
1835
+ /**
1836
+ * CONFIGURATION INHERITANCE — the `extends:` key — as `extends`.
1837
+ *
1838
+ * `extends` on a YAML mapping is the same relation the merge key carries, written the other way
1839
+ * round: "this thing inherits that thing's configuration". A merge key says it with a `<<` and
1840
+ * an anchor; these dialects say it with a KEY, and the key's VALUE SHAPE says what the base is.
1841
+ *
1842
+ * MEASURED over the admitted population (18,997 YAML files read; the count is the census's, the
1843
+ * shape split is the sweep's), the `extends:` key occurs **80** times in five shapes:
1844
+ *
1845
+ * `extends: .hidden-job` 33 GitLab CI — a SIBLING KEY in the same file
1846
+ * `extends: {file: F, service: S}` 17 Docker Compose — a service in ANOTHER file
1847
+ * `extends: {template: T}` 15 Azure Pipelines — a TEMPLATE FILE
1848
+ * `extends: name` 8 yamllint — a config of another tool
1849
+ * `extends: {service: S}` 6 Docker Compose — a SIBLING service in the same file
1850
+ * `extends: {…}` 1 a mapping this lane does not recognise
1851
+ *
1852
+ * AND THE CONTRAST THAT KEEPS THE RULING HONEST: the same sweep found the OTHER supertype-
1853
+ * candidate keys in the corpus — `implements` 59, `inherits` 38, `base` 30, `superclass` 2 —
1854
+ * and each of them names something that is NOT a YAML supertype clause: the `implements:` keys
1855
+ * are Dart package plugin lists in Flutter `pubspec.yaml` files, the `inherits:` keys are
1856
+ * `pnpm-lock.yaml` entries, and the `base:` keys are Ansible filter-plugin bases. The `extends:`
1857
+ * key is the only one of the five whose value names another thing whose CONFIGURATION is
1858
+ * inherited, and the four shapes above are read as that.
1859
+ *
1860
+ * THE SOURCE IS THE THING THAT INHERITS, not the `extends` key: the owner of the key. The
1861
+ * `extends` key is shared by every job in the file, so an edge whose source were the key would
1862
+ * bind a consumer resolving by name to an arbitrary one of them.
1863
+ *
1864
+ * THE TARGET IS THE BASE, and the base is named the way the dialect names it — which is why
1865
+ * the shape decides the claim and not the spelling:
1866
+ *
1867
+ * - A SIBLING in this file (`.hidden-job`, `{service: S}` with no `file:`) resolves to that
1868
+ * sibling's SYMBOL, because the analyzer can see it. `resolved: true`.
1869
+ * - A FILE (`{file: F}`, `{template: T}`) is the base, and the file is what the project can
1870
+ * check: the claim is the same file claim every other file claim in this lane makes
1871
+ * (`relativePathCandidates` + `projectCarriesFile`), so a file the project carries is
1872
+ * `importProjectFile` and one it does not is `isExternal`. The base's NAME inside that file
1873
+ * (`service: S`, the part after `@` in an Azure template ref) rides in the metadata, because
1874
+ * the analyzer analyses ONE file at a time and cannot name another file's symbols.
1875
+ * - A CONFIG NAME (`extends: default`) is a name the document does not declare, so the edge
1876
+ * says so: `isExternal: true`, `resolved: false`, `unresolvedReason: "not-declared-in-file"`
1877
+ * — the same shape the lane's unresolved sibling names already use.
1878
+ *
1879
+ * WHAT WOULD OVERTURN THIS: a corpus site where `extends:` names something that is NOT a
1880
+ * configuration base — a version, a label, a metric. The measured population has five shapes
1881
+ * and every one of them names a base, so the falsifier is a sixth shape with a different
1882
+ * meaning.
1883
+ */
1884
+ emitConfigExtends(valueNode, line, current, ctx) {
1885
+ if (!valueNode) return;
1886
+ const composer = current.parentPath;
1887
+ if (!composer) return;
1888
+ const node = this.scalarNode(valueNode);
1889
+ if (MAPPING_TYPES.has(node.type)) {
1890
+ const service = this.mappingEntryScalar(node, "service");
1891
+ const file = this.mappingEntryScalar(node, "file");
1892
+ const template = this.mappingEntryScalar(node, "template");
1893
+ if (template !== null) {
1894
+ const at = template.indexOf("@");
1895
+ const templateFile = at >= 0 ? template.slice(0, at) : template;
1896
+ const templateName = at >= 0 ? template.slice(at + 1) : null;
1897
+ this.emitExtendsToFile(composer, templateFile, line, ctx, "template", templateName);
1898
+ return;
1899
+ }
1900
+ if (file !== null) {
1901
+ this.emitExtendsToFile(composer, file, line, ctx, "service", service);
1902
+ return;
1903
+ }
1904
+ if (service !== null) {
1905
+ const sibling = ctx.serviceKeyIndex.get(service) ?? ctx.serviceIndex.get(service);
1906
+ if (!sibling) return;
1907
+ this.addRelationship(ctx, composer, sibling, ASTEngine.RelationshipType.Extends, {
1908
+ relationshipType: "yaml_config_extends",
1909
+ evidenceClass: "structural",
1910
+ resolved: true,
1911
+ isExternal: false,
1912
+ baseName: service,
1913
+ baseKind: "service"
1914
+ }, line);
1915
+ return;
1916
+ }
1917
+ return;
1918
+ }
1919
+ if (!SCALAR_TYPES.has(node.type)) return;
1920
+ const written = this.scalarValueText(node);
1921
+ if (!written) return;
1922
+ if (written.includes("\n")) return;
1923
+ if (written.startsWith(".")) {
1924
+ const sibling = ctx.topLevelObjectIndex.get(written);
1925
+ if (!sibling) return;
1926
+ this.addRelationship(ctx, composer, sibling, ASTEngine.RelationshipType.Extends, {
1927
+ relationshipType: "yaml_config_extends",
1928
+ evidenceClass: "structural",
1929
+ resolved: true,
1930
+ isExternal: false,
1931
+ baseName: written,
1932
+ baseKind: "hidden-job"
1933
+ }, line);
1934
+ return;
1935
+ }
1936
+ this.addRelationship(ctx, composer, written, ASTEngine.RelationshipType.Extends, {
1937
+ relationshipType: "yaml_config_extends",
1938
+ evidenceClass: "structural",
1939
+ resolved: false,
1940
+ isExternal: true,
1941
+ unresolvedReason: "not-declared-in-file",
1942
+ baseName: written,
1943
+ baseKind: "config"
1944
+ }, line, 0.8, "unresolved-sibling-name");
1945
+ }
1946
+ /**
1947
+ * The `extends:` edge whose base is a FILE — Docker Compose's `{file: F, service: S}` and Azure
1948
+ * Pipelines' `{template: T}`.
1949
+ *
1950
+ * The FILE is the base the project can check, and the file claim is the one every other file
1951
+ * claim in this lane makes, so the edge can carry `importProjectFile` when the project carries
1952
+ * the file and `isExternal` when it does not. The base's NAME inside that file rides in the
1953
+ * metadata, because the analyzer analyses one file at a time and cannot name another file's
1954
+ * symbols — a claim it could make would be a fabrication.
1955
+ *
1956
+ * THE FILE IS ALSO READ, and that is an `imports` edge: Azure's template is included, and
1957
+ * Compose's `file:` is read. So the same site publishes BOTH, which is what the JSON lane does
1958
+ * for the sibling-file form of a composition ("the file form is still composition: 'inherits
1959
+ * from the schema in that file'"), and the two edges are distinguishable by their marker.
1960
+ */
1961
+ emitExtendsToFile(composer, file, line, ctx, baseKind, baseName) {
1962
+ if (!file) return;
1963
+ const claim = this.relativePathCandidates(file, ctx);
1964
+ const carries = claim !== null && this.projectCarriesFile(ctx, claim);
1965
+ this.addRelationship(ctx, composer, file, ASTEngine.RelationshipType.Extends, {
1966
+ relationshipType: "yaml_config_extends",
1967
+ evidenceClass: "structural",
1968
+ resolved: false,
1969
+ baseKind,
1970
+ ...baseName ? { baseName } : {},
1971
+ ...carries ? { importProjectFile: claim } : { isExternal: true },
1972
+ unresolvedReason: carries ? "base-symbol-in-another-file" : "external-file"
1973
+ }, line, 0.9, "base-symbol-in-another-file");
1974
+ this.addRelationship(ctx, composer, file, ASTEngine.RelationshipType.Imports, {
1975
+ relationshipType: "yaml_extends_file",
1976
+ evidenceClass: "import_based",
1977
+ ...carries ? { importProjectFile: claim, isExternal: false } : { isExternal: true },
1978
+ ...baseName ? { baseName } : {}
1979
+ }, line);
1980
+ }
1981
+ resolveSiblingReferences(ctx) {
1982
+ for (const ref of ctx.pendingReferences) {
1983
+ const resolved = ref.kind === "yaml_resource_ref" ? this.resolveDeclaredName(ref.target, ctx, ref.resourceKind) ?? this.resolveSameDirectoryResource(ref, ctx) : this.resolveDeclaredName(ref.target, ctx) ?? this.resolveJobInDocument(ref, ctx) ?? this.resolveSameDirectoryResource(ref, ctx);
1984
+ if (resolved) {
1985
+ this.addRelationship(ctx, ref.source, resolved, ASTEngine.RelationshipType.References, {
1986
+ relationshipType: ref.kind,
1987
+ isExternal: false,
1988
+ resolved: true,
1989
+ referencedName: ref.target
1990
+ }, ref.line);
1991
+ } else {
1992
+ this.addRelationship(ctx, ref.source, ref.target, ASTEngine.RelationshipType.References, {
1993
+ relationshipType: ref.kind,
1994
+ isExternal: true,
1995
+ resolved: false,
1996
+ unresolvedReason: "not-declared-in-file",
1997
+ referencedName: ref.target
1998
+ }, ref.line, 0.8, "unresolved-sibling-name");
1999
+ }
2000
+ }
2001
+ }
2002
+ resolveDeclaredName(name, ctx, wantKind) {
2003
+ if (ctx.declaredNames.has(name)) {
2004
+ if (wantKind === void 0) return ctx.declaredNames.get(name);
2005
+ return ctx.documentKinds.get(name) === wantKind ? ctx.declaredNames.get(name) : null;
2006
+ }
2007
+ if (wantKind !== void 0) return null;
2008
+ return ctx.serviceIndex.get(name) ?? null;
2009
+ }
2010
+ /**
2011
+ * Record one symbol's shape in the two indexes the post-passes read. Called once per
2012
+ * emitted key, so both indexes are O(1) to build and O(1) to query.
2013
+ */
2014
+ indexShape(document, parentPath, path, valueType, ctx, key) {
2015
+ let shapes = ctx.documentShapes.get(document);
2016
+ if (!shapes) {
2017
+ shapes = /* @__PURE__ */ new Map();
2018
+ ctx.documentShapes.set(document, shapes);
2019
+ }
2020
+ const relative = path.startsWith(`${document}.`) ? path.slice(document.length + 1) : path;
2021
+ shapes.set(relative, valueType);
2022
+ if (valueType === "object" && parentPath.endsWith("services")) {
2023
+ ctx.serviceIndex.set(path.slice(path.lastIndexOf(".") + 1), path);
2024
+ if (key !== void 0) ctx.serviceKeyIndex.set(key, path);
2025
+ }
2026
+ if (valueType === "object" && parentPath === document && key !== void 0) {
2027
+ ctx.topLevelObjectIndex.set(key, path);
2028
+ }
2029
+ }
2030
+ /**
2031
+ * Matrix 1b `qualified_calls`, filled honestly for a data language: the reference,
2032
+ * alias and import targets a declaration carries. Containment is excluded — a child
2033
+ * is not something a key "calls".
2034
+ */
2035
+ attachQualifiedCalls(ctx) {
2036
+ const byId = /* @__PURE__ */ new Map();
2037
+ for (const s of ctx.symbols) byId.set(String(s.qualifiedName ?? s.id), s);
2038
+ for (const r of ctx.relationships) {
2039
+ if (r.type === ASTEngine.RelationshipType.Contains || r.type === ASTEngine.RelationshipType.Exports) continue;
2040
+ const symbol = byId.get(String(r.source));
2041
+ if (!symbol) continue;
2042
+ const target = String(r.target);
2043
+ if (!target) continue;
2044
+ const list = symbol.qualified_calls ?? [];
2045
+ if (!list.includes(target)) list.push(target);
2046
+ symbol.qualified_calls = list;
2047
+ }
2048
+ }
2049
+ // ------------------------------------------------------------- frameworks
2050
+ /**
2051
+ * Framework detection is a SET OF EXACT PATHS WITH SHAPES, not a set of names
2052
+ * anywhere in the file. The previous implementation tested a whole-file name set,
2053
+ * so a seven-line file carrying `version`, `services`, `jobs`, `on` and `stages`
2054
+ * claimed three frameworks. The shape index this reads is built from the walk, so
2055
+ * `nested.version` is not `version` and cannot match.
2056
+ */
2057
+ detectFrameworks(ctx) {
2058
+ const path = ctx.filePath.toLowerCase();
2059
+ for (const signature of FRAMEWORK_SIGNATURES) {
2060
+ const patterns = [];
2061
+ let matched = true;
2062
+ for (const requirement of signature.all) {
2063
+ const hit = this.matchRequirement(requirement, path, ctx);
2064
+ if (!hit) {
2065
+ matched = false;
2066
+ break;
2067
+ }
2068
+ patterns.push(hit);
2069
+ }
2070
+ if (matched && signature.any) {
2071
+ const hits = signature.any.map((requirement) => this.matchRequirement(requirement, path, ctx)).filter((hit) => hit !== null);
2072
+ if (hits.length === 0) matched = false;
2073
+ else patterns.push(hits[0]);
2074
+ }
2075
+ if (!matched) continue;
2076
+ if (!ctx.frameworks.includes(signature.name)) {
2077
+ ctx.frameworks.push(signature.name);
2078
+ for (const p of patterns) if (!ctx.frameworkPatterns.includes(p)) ctx.frameworkPatterns.push(p);
2079
+ ctx.metadata.devOpsMaturity = (ctx.metadata.devOpsMaturity || 0) + signature.maturity;
2080
+ const insights = ctx.metadata.architecturalInsights;
2081
+ if (!insights.includes(signature.insight)) insights.push(signature.insight);
2082
+ }
2083
+ }
2084
+ if (ctx.pendingReferences.length > 0 || ctx.shapeIndex.has("services")) this.addPattern(ctx, "Service Dependencies");
2085
+ }
2086
+ /**
2087
+ * Match one signature requirement against one document. The lookup is on the
2088
+ * DOCUMENT-RELATIVE path in `documentShapes`, which is what makes two things true at
2089
+ * once: it is O(1) per requirement rather than a scan of the file's shapes, and
2090
+ * `version` can only match the key `version` at the document's top level — never
2091
+ * `nested.version`. A suffix comparison alone is exactly what let the previous
2092
+ * detector fire on a key at any depth, so the exactness is the point, not a detail.
2093
+ */
2094
+ matchRequirement(requirement, filePath, ctx) {
2095
+ if (requirement.pathHint && !filePath.includes(requirement.pathHint)) return null;
2096
+ for (const [document, shapes] of ctx.documentShapes) {
2097
+ const shape = shapes.get(requirement.path);
2098
+ if (shape === void 0) continue;
2099
+ if (requirement.shape === "any" || shape === requirement.shape) return `${document}.${requirement.path}`;
2100
+ }
2101
+ return null;
2102
+ }
2103
+ // --------------------------------------------------------------- security
2104
+ /**
2105
+ * Sensitivity is structural, from two signals only: the key node's own text is a
2106
+ * known sensitive spelling, or the document's `kind` is `Secret`. No value text is
2107
+ * read, so `"^\d+$"` cannot be mistaken for a secret and `password: hunter2`
2108
+ * cannot be missed.
2109
+ */
2110
+ applySecurityScore(ctx) {
2111
+ let score = 100;
2112
+ let sensitiveCount = 0;
2113
+ for (const symbol of ctx.symbols) if (symbol.metadata?.isSensitive === true) sensitiveCount++;
2114
+ if (sensitiveCount > 0) {
2115
+ score -= Math.min(40, sensitiveCount * 4);
2116
+ this.addPattern(ctx, "Sensitive Values");
2117
+ }
2118
+ if (ctx.sensitiveDocuments.size > 0) score -= Math.min(25, ctx.sensitiveDocuments.size * 10);
2119
+ ctx.metadata.securityScore = Math.max(0, score);
2120
+ ctx.metadata.sensitiveValueCount = sensitiveCount;
2121
+ }
2122
+ // -------------------------------------------------------------- utilities
2123
+ /**
2124
+ * Matrix 1b `apiContractPath`, only for the two shapes that genuinely ARE a
2125
+ * contract: an OpenAPI operation, and a GitHub Actions job step. A YAML key is not
2126
+ * an API surface, so the field stays absent everywhere else rather than carrying a
2127
+ * constant.
2128
+ */
2129
+ assignApiContractPath(symbol) {
2130
+ const path = String(symbol.qualifiedName ?? symbol.id);
2131
+ const segments = path.split(".");
2132
+ const last = segments[segments.length - 1] ?? "";
2133
+ const pathsAt = segments.lastIndexOf("paths");
2134
+ if (pathsAt >= 0 && OPENAPI_METHODS.has(last.toLowerCase()) && segments.length === pathsAt + 3) {
2135
+ symbol.apiContractPath = `${last.toUpperCase()} ${segments[pathsAt + 1]}`;
2136
+ return;
2137
+ }
2138
+ if (symbol.metadata?.isArrayItem === true && last.startsWith("steps[") && segments.length >= 3) {
2139
+ symbol.apiContractPath = `github-actions:${segments[segments.length - 2]}#${String(symbol.metadata.arrayIndex)}`;
2140
+ }
2141
+ }
2142
+ createSymbol(name, kind, node, parent, parentPath, ctx, metadata, qualifiedName) {
2143
+ const qualified = qualifiedName || (parentPath ? `${parentPath}.${name}` : name);
2144
+ const symbol = {
2145
+ id: qualified,
2146
+ name,
2147
+ kind,
2148
+ qualifiedName: qualified,
2149
+ ownerName: parent ? parent.qualifiedName || parent.id || parent.name : void 0,
2150
+ ownerKind: parent?.kind,
2151
+ scope: parent ? parent.qualifiedName || parent.id || parent.name : void 0,
2152
+ parentId: parent?.id,
2153
+ visibility: "public",
2154
+ range: this.nodeToRange(node),
2155
+ metadata
2156
+ };
2157
+ symbol.complexity = 1;
2158
+ ctx.symbols.push(symbol);
2159
+ if (parent) {
2160
+ const role = metadata.isAnchor ? "yaml_anchor" : metadata.isArrayItem ? "yaml_sequence_item" : metadata.recovered ? "yaml_recovered_member" : "yaml_mapping_key";
2161
+ this.addRelationship(ctx, parent.qualifiedName || parent.id || parent.name, qualified, ASTEngine.RelationshipType.Contains, {
2162
+ role,
2163
+ fullPath: qualified
2164
+ }, node.startPosition.row + 1);
2165
+ }
2166
+ return symbol;
2167
+ }
2168
+ /**
2169
+ * Every relationship is emitted here, so two things are guaranteed for all of them
2170
+ * without an emission site being able to forget either:
2171
+ *
2172
+ * - `line_number` is a TOP-LEVEL field, 1-based, on the name-bearing node. It is
2173
+ * deliberately not inside `metadata`: the store binds the `line_number` COLUMN
2174
+ * from `rel.line_number` (src/ast/parallel-indexer.ts:2870), so a value parked in
2175
+ * metadata would reach the index as NULL and the whole navigation payload would
2176
+ * die silently at the seam. That is the M60 P1 rot-check's exact failure mode.
2177
+ * - `metadata.evidenceClass` is set from the relationship type.
2178
+ *
2179
+ * A sub-1.0 confidence always carries a machine-readable reason (M14 §4).
2180
+ */
2181
+ addRelationship(ctx, source, target, type, metadata, line, confidence, reasonCode) {
2182
+ if (!source || !target) return;
2183
+ const key = `${source}\0${type}\0${target}`;
2184
+ if (ctx.relationshipKeys.has(key)) return;
2185
+ ctx.relationshipKeys.add(key);
2186
+ const conf = confidence ?? 1;
2187
+ const md = { ...metadata };
2188
+ if (conf < 1) md.confidenceReason = reasonCode ?? metadata.unresolvedReason ?? "heuristic-edge";
2189
+ const relationship = { source, target, type, confidence: conf, metadata: md };
2190
+ if (typeof line === "number" && line >= 1) relationship.line_number = line;
2191
+ ctx.relationships.push(relationship);
2192
+ }
2193
+ /**
2194
+ * WHICH SCALARS AN INCLUDE KEY ACTUALLY NAMES — the fix for the over-emission.
2195
+ *
2196
+ * `scalarValues` recurses into a mapping and returns every nested VALUE scalar, which is right
2197
+ * for a bag of names and wrong for these keys: one
2198
+ * `- import_role: {name: role1, tasks_from: x.yml, allow_duplicates: true}` published the role
2199
+ * name, its sub-file and a BOOLEAN as three imports of this file. MEASURED over the whole
2200
+ * admitted ansible population by attributing every include edge to the key on its own
2201
+ * `line_number`: of 891 `imports` edges carrying `yaml_keyword_include`, only **567** came from
2202
+ * a key that names a file at all — 149 from a nested `name:`, 45 from `tasks_from:`, 18 from
2203
+ * `role_name:`, and 51 from keys a role import happens to contain (`hosts:`, `public:`,
2204
+ * `gather_facts:`, `command:` …). The over-emission is not cosmetic: it is what put bare
2205
+ * identifiers such as `a` (29 declarations), `c`, `foo` and `include_role` (123 declarations)
2206
+ * into `imports`, where the arbiter can only read them as ambiguous.
2207
+ *
2208
+ * The rule now follows the key's meaning, which is the same thing the claim already follows:
2209
+ *
2210
+ * a FILE key `include_tasks`, `import_tasks`, `import_playbook`, `import_values`,
2211
+ * `include`, `import` — the file is the scalar value, or the `file:` sub-key
2212
+ * when the value is a mapping (ansible's documented long form).
2213
+ * a ROLE key `include_role`, `import_role` — the value names a ROLE: its `name:`, or the
2214
+ * scalar shorthand. `tasks_from`/`vars_from`/`defaults_from`/`handlers_from`
2215
+ * name files inside a role DIRECTORY this analyzer does not resolve, so they are
2216
+ * not this file's imports, and a boolean is not a target at all.
2217
+ */
2218
+ fileImportTargets(key, valueNode, parentPath, document) {
2219
+ if (ROLE_IMPORT_KEYS.has(key)) {
2220
+ const named = this.mappingEntryScalar(valueNode, "name");
2221
+ if (named !== null) return [named];
2222
+ const inline = this.inlineRoleParams(valueNode);
2223
+ if (inline !== null) return [inline.get("name")];
2224
+ return this.scalarValues(valueNode).slice(0, 1);
2225
+ }
2226
+ if (key === "include" && parentPath !== document && isMatrixPathKey(parentPath)) return [];
2227
+ const file = this.mappingEntryScalar(valueNode, "file");
2228
+ if (file !== null) return [file];
2229
+ const node = valueNode ? this.scalarNode(valueNode) : null;
2230
+ if (node && MAPPING_TYPES.has(node.type)) return [];
2231
+ if (node && SEQUENCE_TYPES.has(node.type)) {
2232
+ const out = [];
2233
+ for (let i = 0; i < node.namedChildCount; i++) {
2234
+ const item = node.namedChild(i);
2235
+ if (!item) continue;
2236
+ const unwrapped = item.type === "block_sequence_item" ? this.firstNonDecorationChild(item) : item;
2237
+ const scalar = unwrapped ? this.scalarNode(unwrapped) : null;
2238
+ if (!scalar || !SCALAR_TYPES.has(scalar.type)) continue;
2239
+ const text = this.scalarText(scalar);
2240
+ if (text) out.push(text);
2241
+ }
2242
+ return out;
2243
+ }
2244
+ return this.scalarValues(valueNode);
2245
+ }
2246
+ addImport(ctx, target, importType, node, isExternal, lineOverride) {
2247
+ if (!target) return;
2248
+ if (ctx.imports.some((i) => i.module === target && i.importType === importType)) return;
2249
+ ctx.imports.push({
2250
+ module: target,
2251
+ path: target,
2252
+ importType,
2253
+ isExternal,
2254
+ isNamespace: false,
2255
+ // `lineOverride` exists because one emission site has no node to read a position
2256
+ // from — an action `uses:` is recorded in a pending list during the walk and
2257
+ // resolved afterwards — and it was passing `undefined`, so 329 of this lane's 540
2258
+ // corpus imports (every GitHub Actions `uses:`) reached the store with no line at
2259
+ // all. The row is otherwise complete, which is the worst shape for a data loss:
2260
+ // a count of imports looked perfectly healthy.
2261
+ line_number: lineOverride ?? (node ? node.startPosition.row + 1 : void 0),
2262
+ metadata: { yamlSpecific: true, evidenceClass: "import_based" }
2263
+ });
2264
+ }
2265
+ /**
2266
+ * M14 Matrix 1d: every edge states its epistemic status. Set centrally so an
2267
+ * emission site cannot forget it, and it runs after every relationship exists.
2268
+ */
2269
+ classifyEvidenceTier(ctx) {
2270
+ for (const r of ctx.relationships) {
2271
+ const md = r.metadata ?? (r.metadata = {});
2272
+ if (md.evidenceClass) continue;
2273
+ const t = String(r.type ?? "");
2274
+ if (t === "contains" || t === "exports") {
2275
+ md.evidenceClass = "structural";
2276
+ continue;
2277
+ }
2278
+ if (t === "imports") {
2279
+ md.evidenceClass = "import_based";
2280
+ continue;
2281
+ }
2282
+ if (t === "references") {
2283
+ if (md.relationshipType === "declared_type" || md.relationshipType === "yaml_json_pointer") {
2284
+ md.evidenceClass = "reference_qualified";
2285
+ continue;
2286
+ }
2287
+ md.evidenceClass = md.resolved === false ? "inferred" : "usage_contextual";
2288
+ continue;
2289
+ }
2290
+ if (t === "extends" || t === "implements" || t === "overrides") {
2291
+ const conf = typeof r.confidence === "number" ? r.confidence : 1;
2292
+ md.evidenceClass = conf >= 1 ? "structural" : conf >= 0.85 ? "usage_contextual" : "inferred";
2293
+ continue;
2294
+ }
2295
+ if (t === "instantiates") {
2296
+ md.evidenceClass = "structural";
2297
+ continue;
2298
+ }
2299
+ if (t === "calls") {
2300
+ md.evidenceClass = md.relationshipType === "yaml_workflow_call" ? "structural" : "inferred";
2301
+ continue;
2302
+ }
2303
+ md.evidenceClass = "usage_contextual";
2304
+ }
2305
+ }
2306
+ addPattern(ctx, pattern) {
2307
+ const patterns = ctx.metadata.patterns;
2308
+ if (!patterns.includes(pattern)) patterns.push(pattern);
2309
+ }
2310
+ // ------------------------------------------------------------ value typing
2311
+ /**
2312
+ * The resolved shape of a value, over a CLOSED vocabulary. A node the analyzer does
2313
+ * not classify is `untyped` — YAML's own name for a plain scalar with no tag — and
2314
+ * never a placeholder string. The previous implementation returned the literal
2315
+ * `"unknown"`, which the M60 R7 sentinel census records as a violation.
2316
+ */
2317
+ valueType(node) {
2318
+ if (!node) return "null";
2319
+ if (MAPPING_TYPES.has(node.type)) return "object";
2320
+ if (SEQUENCE_TYPES.has(node.type)) return "array";
2321
+ if (node.type === "alias") return "alias";
2322
+ if (node.type === "anchor") return "anchor";
2323
+ if (SEQUENCE_ITEM_TYPES.has(node.type)) {
2324
+ const first = this.firstNamedChild(node);
2325
+ return first ? this.valueType(first) : "untyped";
2326
+ }
2327
+ if (WRAPPER_TYPES.has(node.type)) {
2328
+ for (let i = 0; i < node.namedChildCount; i++) {
2329
+ const nested = this.valueType(node.namedChild(i));
2330
+ if (nested === "object" || nested === "array") return nested;
2331
+ }
2332
+ for (let i = 0; i < node.namedChildCount; i++) {
2333
+ const child = node.namedChild(i);
2334
+ if (!child || child.type === "tag" || child.type === "anchor") continue;
2335
+ const nested = this.valueType(child);
2336
+ if (nested !== "untyped") return nested;
2337
+ }
2338
+ return "untyped";
2339
+ }
2340
+ switch (node.type) {
2341
+ case "integer_scalar":
2342
+ return "integer";
2343
+ case "float_scalar":
2344
+ return "float";
2345
+ case "boolean_scalar":
2346
+ return "boolean";
2347
+ case "null_scalar":
2348
+ return "null";
2349
+ // The core schema resolves a plain scalar by which typed child it carries.
2350
+ case "timestamp_scalar":
2351
+ return "timestamp";
2352
+ case "plain_scalar":
2353
+ for (let i = 0; i < node.namedChildCount; i++) {
2354
+ const nested = this.valueType(node.namedChild(i));
2355
+ if (nested !== "untyped") return nested;
2356
+ }
2357
+ return "untyped";
2358
+ case "string_scalar":
2359
+ case "single_quote_scalar":
2360
+ case "double_quote_scalar":
2361
+ case "block_scalar":
2362
+ case "string":
2363
+ return "string";
2364
+ default:
2365
+ for (let i = 0; i < node.namedChildCount; i++) {
2366
+ const nested = this.valueType(node.namedChild(i));
2367
+ if (nested !== "untyped") return nested;
2368
+ }
2369
+ return RESOLVED_VALUE_VOCABULARY.has(node.type) ? node.type : "untyped";
2370
+ }
2371
+ }
2372
+ /**
2373
+ * A tag on a value, parsed into what a consumer needs: the short name (what a
2374
+ * `declared_type` edge targets), the resolved URI (what a `%TAG` handle expands to),
2375
+ * the type the tag forces, and the file an include-family tag names. The tag node
2376
+ * carries its own text and range, so nothing here scans the value.
2377
+ */
2378
+ tagOf(node, ctx) {
2379
+ if (!node) return null;
2380
+ let tagNode = null;
2381
+ if (node.type === "tag") tagNode = node;
2382
+ else {
2383
+ for (let i = 0; i < node.namedChildCount; i++) {
2384
+ const child = node.namedChild(i);
2385
+ if (child?.type === "tag") {
2386
+ tagNode = child;
2387
+ break;
2388
+ }
2389
+ }
2390
+ }
2391
+ if (!tagNode) return null;
2392
+ const parsed = this.parseTag(tagNode.text, ctx);
2393
+ if (!parsed) return null;
2394
+ const shortName = parsed.shortName;
2395
+ let includePath;
2396
+ let includeQuoted = false;
2397
+ if (INCLUDE_TAGS.has(shortName)) {
2398
+ const sibling = this.firstNonDecorationChild(node);
2399
+ if (sibling) {
2400
+ const text = this.scalarText(sibling);
2401
+ if (looksLikePath(text, QUOTED_SCALAR_TYPES.has(sibling.type))) {
2402
+ includePath = text;
2403
+ includeQuoted = QUOTED_SCALAR_TYPES.has(sibling.type);
2404
+ }
2405
+ }
2406
+ }
2407
+ return { shortName, uri: parsed.uri, typeOverride: TAG_TYPE_OVERRIDES[shortName], includePath, includeQuoted, line: tagNode.startPosition.row + 1 };
2408
+ }
2409
+ /**
2410
+ * THE TYPE NAME A TAG DECLARES, or undefined when the tag declares no type at all.
2411
+ *
2412
+ * An include-family tag names a FILE — `functions: !include "s1/functions/functions.yaml"` is an
2413
+ * import and nothing else. Reading its short name as the name of a TYPE (which is what a
2414
+ * `declared_type` edge says) was a category error the arbiter could see: the edges targeted the
2415
+ * literal string `include`, which collides with ordinary keys spelled `include`, so ten of them
2416
+ * were reported AMBIGUOUS against three unrelated declarations. The `includePath` marker is the
2417
+ * same fact, but it is only set when the value was readable as a path, and a tag whose path the
2418
+ * lane refuses still does not name a type — so the test is the tag's own membership in the closed
2419
+ * include set, not the presence of a readable path.
2420
+ *
2421
+ * One definition, read at every `declared_type` site, so a new site cannot reintroduce the error.
2422
+ */
2423
+ tagTypeName(tag) {
2424
+ if (!tag || INCLUDE_TAGS.has(tag.shortName)) return void 0;
2425
+ return tag.shortName;
2426
+ }
2427
+ /**
2428
+ * Tag text to (short name, URI), by index arithmetic alone. Four shapes:
2429
+ * `!<tag:example.com,2000:str>` verbatim -> uri is the body, short is after the last ':'
2430
+ * `!!str` local -> tag:yaml.org,2002:str, short `str`
2431
+ * `!e!thing` named -> handle `!e!` expands through the %TAG map
2432
+ * `!Foo` custom -> uri is the name itself
2433
+ */
2434
+ parseTag(text, ctx) {
2435
+ if (text.length < 2 || text[0] !== "!") return null;
2436
+ if (text.startsWith("!<") && text.endsWith(">")) {
2437
+ const uri = text.slice(2, -1);
2438
+ const colon = uri.lastIndexOf(":");
2439
+ return { uri, shortName: colon >= 0 ? uri.slice(colon + 1) : uri };
2440
+ }
2441
+ const body = text.slice(1);
2442
+ const bang = body.indexOf("!");
2443
+ if (bang >= 0) {
2444
+ const handle = `!${body.slice(0, bang + 1)}`;
2445
+ const suffix = body.slice(bang + 1);
2446
+ const prefix = handle === "!!" ? YAML_CORE_PREFIX : ctx.tagHandles.get(handle);
2447
+ return { uri: prefix ? `${prefix}${suffix}` : `${handle}${suffix}`, shortName: suffix };
2448
+ }
2449
+ if (body.startsWith("!")) {
2450
+ const short = body.slice(1);
2451
+ return { uri: `${YAML_CORE_PREFIX}${short}`, shortName: short };
2452
+ }
2453
+ return { uri: body, shortName: body };
2454
+ }
2455
+ // ------------------------------------------------------------- node access
2456
+ mappingKeyNode(node) {
2457
+ const field = node.childForFieldName("key");
2458
+ if (field) return this.scalarNode(field);
2459
+ for (let i = 0; i < node.namedChildCount; i++) {
2460
+ const child = node.namedChild(i);
2461
+ if (child && this.isScalarLike(child)) return this.scalarNode(child);
2462
+ }
2463
+ return null;
2464
+ }
2465
+ mappingValueNode(node) {
2466
+ const field = node.childForFieldName("value");
2467
+ if (field) return field;
2468
+ if (node.namedChildCount > 1) return node.namedChild(node.namedChildCount - 1);
2469
+ return null;
2470
+ }
2471
+ scalarNode(node) {
2472
+ let current = node;
2473
+ while (WRAPPER_TYPES.has(current.type) && current.namedChildCount > 0) {
2474
+ const next = this.firstNonDecorationChild(current);
2475
+ if (!next) break;
2476
+ current = next;
2477
+ }
2478
+ return current;
2479
+ }
2480
+ firstNonDecorationChild(node) {
2481
+ for (let i = 0; i < node.namedChildCount; i++) {
2482
+ const child = node.namedChild(i);
2483
+ if (child && child.type !== "tag" && child.type !== "anchor" && child.type !== "anchor_name") return child;
2484
+ }
2485
+ return null;
2486
+ }
2487
+ /**
2488
+ * THE TEXT OF A SCALAR VALUE, with a BLOCK SCALAR's indicator stripped.
2489
+ *
2490
+ * `scalarText` reads a node's own text, and a `block_scalar`'s own text starts with the block
2491
+ * INDICATOR — `>` for a folded scalar, `|` for a literal one — with the content after it. The
2492
+ * indicator is not part of the value, so a scalar read as raw text carries `>` into the name.
2493
+ *
2494
+ * THIS IS THE SAME DEFECT THE `include_vars` FIX HAD, IN A SECOND SITE: the include-family path
2495
+ * was corrected and the `extends:` key family was left reading raw text, so `extends: >` would
2496
+ * have published the target ">\n default" — a name that is a folded YAML scalar, not a config
2497
+ * name. ONE reader for both sites, so the second one cannot drift.
2498
+ */
2499
+ scalarValueText(node) {
2500
+ return this.blockScalarValue(node) ?? this.scalarText(node);
2501
+ }
2502
+ /**
2503
+ * THE SHAPE-GREP, APPLIED TO THIS FILE — AND IT FOUND TWO MORE SITES.
2504
+ *
2505
+ * The fleet's rule is that a defect found once in a SHAPE is a defect in every site of that shape,
2506
+ * and that the fix is to GREP THE SHAPE, not the site. The block-scalar shape was found in the
2507
+ * include-family path and then in the `extends:` family — two sites, two fixes — and the grep of
2508
+ * the shape (`SCALAR_TYPES` CONTAINS `block_scalar`, so any site that reads a scalar's own text
2509
+ * can receive a block scalar) found two MORE: the RESOURCE-REFERENCE path
2510
+ * (`RESOURCE_REF_SCALARS` ⇒ `yaml_resource_ref`) and the reference-WRAPPER path
2511
+ * (`wrapperRefNames`). Both read `scalarText` on a node that `SCALAR_TYPES` admits, and both
2512
+ * would have published a name carrying the indicator.
2513
+ *
2514
+ * ⇒ ⇒ ⇒ THE FIX IS AT THE READER, NOT AT THE SITES: `scalarText` itself now reads a block
2515
+ * scalar's CONTENT, so every one of the eighteen call sites is fixed by construction and a
2516
+ * future site cannot drift. THE SHAPE WAS ONE LINE LONG; IT WAS IN FOUR PLACES.
2517
+ */
2518
+ scalarText(node) {
2519
+ const inner = this.scalarNode(node);
2520
+ const value = (this.blockScalarValue(inner) ?? inner.text).trim();
2521
+ if (value.length >= 2) {
2522
+ const first = value[0];
2523
+ const last = value[value.length - 1];
2524
+ if ((first === '"' || first === "'") && first === last) return value.slice(1, -1);
2525
+ }
2526
+ return value;
2527
+ }
2528
+ /**
2529
+ * THE VALUE OF A BLOCK SCALAR, or null when the node is not one.
2530
+ *
2531
+ * A `block_scalar` node's own text starts with the block INDICATOR on its first line — `>` for
2532
+ * a folded scalar, `|` for a literal one, with any chomping and indentation modifiers attached
2533
+ * — and the CONTENT follows. The indicator is not part of the value, so a scalar read as raw
2534
+ * text carries `>` into the name.
2535
+ *
2536
+ * MEASURED over the whole corpus: the only include-family value in the admitted population
2537
+ * written this way is `include_vars: >` in
2538
+ * `ansible/test/integration/targets/include_vars/tasks/main.yml:192`, whose value names the
2539
+ * vars file `services/service_vars.yml`. Read as raw text the import target was the string
2540
+ * `>\n services/service_vars.yml` — a name no project has, so the edge could carry no file
2541
+ * claim and a reader could not tell what was imported.
2542
+ *
2543
+ * THE FOLDING IS DONE AS YAML DEFINES IT, AND THE LIMIT IS STATED: `>` folds the content's
2544
+ * line breaks into spaces and `|` keeps them, and the block's indentation (the DEEPEST
2545
+ * indentation in the block) is stripped from every line. A chomping modifier is not applied,
2546
+ * because a chomped block's trailing newlines are not part of a file name in any spelling this
2547
+ * lane reads, and the caller trims what it is given.
2548
+ */
2549
+ blockScalarValue(node) {
2550
+ if (node.type !== "block_scalar") return null;
2551
+ const raw = node.text;
2552
+ const nl = raw.indexOf("\n");
2553
+ if (nl < 0) return null;
2554
+ const indicator = raw.slice(0, nl).trim();
2555
+ const folded = indicator.startsWith(">");
2556
+ const body = raw.slice(nl + 1);
2557
+ const lines = body.split("\n");
2558
+ let indent = Number.POSITIVE_INFINITY;
2559
+ for (const l of lines) {
2560
+ if (l.trim() === "") continue;
2561
+ indent = Math.min(indent, l.length - l.trimStart().length);
2562
+ }
2563
+ if (!Number.isFinite(indent)) return "";
2564
+ const content = lines.map((l) => l.slice(indent)).filter((l) => l.trim() !== "");
2565
+ return (folded ? content.join(" ") : content.join("\n")).trim();
2566
+ }
2567
+ /**
2568
+ * The names a reference WRAPPER holds: the scalar shorthand (`configMapRef: cm`) or
2569
+ * the `name` field of its payload. Never a walk — see `RESOURCE_REF_SOURCES` for the
2570
+ * shape this deliberately does not serve.
2571
+ */
2572
+ wrapperRefNames(node) {
2573
+ if (!node) return [];
2574
+ const direct = this.scalarNode(node);
2575
+ if (direct && SCALAR_TYPES.has(direct.type)) {
2576
+ const text = this.scalarText(direct);
2577
+ return text ? [text] : [];
2578
+ }
2579
+ const named = this.mappingEntryScalar(node, "name");
2580
+ return named ? [named] : [];
2581
+ }
2582
+ /**
2583
+ * The scalar VALUE of one named entry of a mapping payload, or null when the node is
2584
+ * not a mapping, the entry is absent, or its value is itself a mapping or sequence.
2585
+ * Keys are read through the grammar's own `key` field, so a quoted key
2586
+ * (`"name": cm`) and a plain one match identically.
2587
+ */
2588
+ mappingEntryScalar(node, keyName) {
2589
+ if (!node) return null;
2590
+ let mapping = this.scalarNode(node);
2591
+ if (!MAPPING_TYPES.has(mapping.type)) {
2592
+ mapping = this.firstNonDecorationChild(node);
2593
+ if (!mapping || !MAPPING_TYPES.has(mapping.type)) return null;
2594
+ }
2595
+ for (let i = 0; i < mapping.namedChildCount; i++) {
2596
+ const pair = mapping.namedChild(i);
2597
+ if (!pair || !MAPPING_PAIR_TYPES.has(pair.type)) continue;
2598
+ const keyNode = pair.childForFieldName("key") || this.firstNonDecorationChild(pair);
2599
+ if (!keyNode || this.scalarText(keyNode) !== keyName) continue;
2600
+ const value = this.mappingValueNode(pair);
2601
+ if (!value) return null;
2602
+ const scalar = this.scalarNode(value);
2603
+ if (!SCALAR_TYPES.has(scalar.type)) return null;
2604
+ const text = this.scalarValueText(scalar);
2605
+ return text || null;
2606
+ }
2607
+ return null;
2608
+ }
2609
+ /**
2610
+ * Every scalar VALUE under a node, in order — used for the lists `depends_on` and
2611
+ * the `*Ref` objects hold. Mapping KEYS are skipped via the grammar's own `key`
2612
+ * field, because `configMapRef: { name: cm }` must yield `cm` and not `name`: a
2613
+ * descendant walk without that skip returns both, and the first one emitted is the
2614
+ * key.
2615
+ */
2616
+ scalarValues(node) {
2617
+ if (!node) return [];
2618
+ const out = [];
2619
+ const collect = (n, isKey) => {
2620
+ if (n.type === "tag") return;
2621
+ if (MAPPING_PAIR_TYPES.has(n.type)) {
2622
+ const value = n.childForFieldName("value") || this.mappingValueNode(n);
2623
+ if (value) collect(this.scalarNode(value), false);
2624
+ return;
2625
+ }
2626
+ if (SCALAR_TYPES.has(n.type)) {
2627
+ if (isKey) return;
2628
+ const text = n.type === "block_scalar" ? this.blockScalarValue(n) ?? "" : this.scalarText(n);
2629
+ if (text) out.push(text);
2630
+ return;
2631
+ }
2632
+ for (let i = 0; i < n.namedChildCount; i++) {
2633
+ const child = n.namedChild(i);
2634
+ if (child) collect(child, false);
2635
+ }
2636
+ };
2637
+ collect(this.scalarNode(node), false);
2638
+ return out;
2639
+ }
2640
+ /** A top-level scalar of a document, by dotted key path (`kind`, `metadata.name`). */
2641
+ topLevelScalar(document, keyPath) {
2642
+ const parts = keyPath.split(".");
2643
+ let mapping = null;
2644
+ for (let i = 0; i < document.namedChildCount; i++) {
2645
+ const child = document.namedChild(i);
2646
+ if (!child) continue;
2647
+ if (MAPPING_TYPES.has(child.type)) {
2648
+ mapping = child;
2649
+ break;
2650
+ }
2651
+ if (child.type === "block_node") {
2652
+ for (let j = 0; j < child.namedChildCount; j++) {
2653
+ const inner = child.namedChild(j);
2654
+ if (inner && MAPPING_TYPES.has(inner.type)) {
2655
+ mapping = inner;
2656
+ break;
2657
+ }
2658
+ }
2659
+ if (mapping) break;
2660
+ }
2661
+ }
2662
+ if (!mapping) return null;
2663
+ return this.scalarAtPath(mapping, parts);
2664
+ }
2665
+ topLevelScalarFromMapping(document, outer, inner) {
2666
+ return this.topLevelScalar(document, `${outer}.${inner}`);
2667
+ }
2668
+ scalarAtPath(mapping, parts) {
2669
+ let current = mapping;
2670
+ for (let i = 0; i < parts.length; i++) {
2671
+ const part = parts[i];
2672
+ const isLast = i === parts.length - 1;
2673
+ let next = null;
2674
+ for (let j = 0; j < current.namedChildCount; j++) {
2675
+ const child = current.namedChild(j);
2676
+ if (!child || !MAPPING_PAIR_TYPES.has(child.type)) continue;
2677
+ const keyNode = this.mappingKeyNode(child);
2678
+ if (!keyNode || this.scalarText(keyNode) !== part) continue;
2679
+ next = this.mappingValueNode(child);
2680
+ break;
2681
+ }
2682
+ if (!next) return null;
2683
+ current = WRAPPER_TYPES.has(next.type) ? this.scalarNode(next) : next;
2684
+ if (isLast) {
2685
+ if (MAPPING_TYPES.has(current.type) || SEQUENCE_TYPES.has(current.type)) return null;
2686
+ const text = this.scalarText(current);
2687
+ return text || null;
2688
+ }
2689
+ if (!MAPPING_TYPES.has(current.type)) return null;
2690
+ }
2691
+ return null;
2692
+ }
2693
+ isScalarLike(node) {
2694
+ return SCALAR_TYPES.has(node.type) || WRAPPER_TYPES.has(node.type);
2695
+ }
2696
+ isJsonPointer(value) {
2697
+ return value.length > 2 && value.startsWith("#/") && !value.includes(" ");
2698
+ }
2699
+ firstNamedChild(node) {
2700
+ return node.namedChildCount > 0 ? node.namedChild(0) : null;
2701
+ }
2702
+ findNamedChild(node, type) {
2703
+ for (let i = 0; i < node.namedChildCount; i++) {
2704
+ const child = node.namedChild(i);
2705
+ if (child?.type === type) return child;
2706
+ }
2707
+ return null;
2708
+ }
2709
+ pushChildren(stack, node, context) {
2710
+ for (let i = node.namedChildCount - 1; i >= 0; i--) {
2711
+ const child = node.namedChild(i);
2712
+ if (child) stack.push({ node: child, context: { ...context, depth: context.depth + 1 } });
2713
+ }
2714
+ }
2715
+ pushMappingChildren(stack, node, context, ctx) {
2716
+ for (let i = node.namedChildCount - 1; i >= 0; i--) {
2717
+ const child = node.namedChild(i);
2718
+ if (!child) continue;
2719
+ if (MAPPING_PAIR_TYPES.has(child.type)) {
2720
+ stack.push({ node: child, context: { ...context, depth: context.depth + 1 } });
2721
+ continue;
2722
+ }
2723
+ if (child.type === "comment") continue;
2724
+ if (this.tagIn(child, ctx)) {
2725
+ const tag = this.tagOf(child, ctx);
2726
+ const owner = context.parent?.qualifiedName || context.parent?.id || context.document;
2727
+ this.emitTagSemantics(owner, tag, child, child.startPosition.row + 1, ctx);
2728
+ if (tag.typeOverride && context.parent && context.parent.metadata) {
2729
+ context.parent.metadata = {
2730
+ ...context.parent.metadata,
2731
+ declaredType: this.tagTypeName(tag),
2732
+ tagUri: tag.uri,
2733
+ tagLine: tag.line
2734
+ };
2735
+ }
2736
+ }
2737
+ }
2738
+ }
2739
+ /** Whether a node carries a tag, looking through one level of wrapper. */
2740
+ tagIn(node, ctx) {
2741
+ void ctx;
2742
+ if (node.type === "tag") return true;
2743
+ if (!WRAPPER_TYPES.has(node.type)) return false;
2744
+ for (let i = 0; i < node.namedChildCount; i++) {
2745
+ if (node.namedChild(i)?.type === "tag") return true;
2746
+ }
2747
+ return false;
2748
+ }
2749
+ /**
2750
+ * A duplicate mapping key is invalid YAML that tree-sitter recovers. Two symbols
2751
+ * sharing one id makes every relationship endpoint ambiguous, because the graph
2752
+ * resolves endpoints by qualified name while the store's own primary key includes
2753
+ * the start line. Later occurrences are therefore disambiguated and the collision
2754
+ * is recorded rather than tolerated.
2755
+ */
2756
+ uniquePath(parentPath, document, key, isDuplicate, ctx) {
2757
+ const base = parentPath ? `${parentPath}.${key}` : key;
2758
+ if (!isDuplicate) return base;
2759
+ const counterKey = `${document}\0${base}`;
2760
+ const n = (ctx.duplicateCounters.get(counterKey) ?? 0) + 1;
2761
+ ctx.duplicateCounters.set(counterKey, n);
2762
+ return `${base}#${n + 1}`;
2763
+ }
2764
+ escapePointer(segment) {
2765
+ return segment.split("~").join("~0").split("/").join("~1");
2766
+ }
2767
+ /**
2768
+ * A symbol's own review cost: how many keys or items it directly holds. O(children)
2769
+ * once per symbol, therefore O(n) across the file. The previous file-level
2770
+ * `configurationComplexity = visitedNodes` said nothing about any individual node,
2771
+ * and no symbol carried a complexity at all.
2772
+ */
2773
+ nodeComplexity(valueNode, valueType) {
2774
+ if (!valueNode) return 1;
2775
+ const node = this.scalarNode(valueNode);
2776
+ if (valueType === "object") {
2777
+ let count = 0;
2778
+ for (let i = 0; i < node.namedChildCount; i++) {
2779
+ const child = node.namedChild(i);
2780
+ if (child && MAPPING_PAIR_TYPES.has(child.type)) count++;
2781
+ }
2782
+ return Math.max(1, count);
2783
+ }
2784
+ if (valueType === "array") {
2785
+ let count = 0;
2786
+ for (let i = 0; i < node.namedChildCount; i++) {
2787
+ const child = node.namedChild(i);
2788
+ if (child && (SEQUENCE_ITEM_TYPES.has(child.type) || child.type === "flow_node")) count++;
2789
+ }
2790
+ return Math.max(1, count);
2791
+ }
2792
+ return 1;
2793
+ }
2794
+ /**
2795
+ * A document's PROJECT-RELATIVE directory chain, dot-joined, or "" when it cannot be known.
2796
+ *
2797
+ * Dot-joined rather than slash-joined on purpose: everything downstream treats a dotted
2798
+ * string as a path (`Schema.X.properties`, `spec.containers[0]`), so a slash here would
2799
+ * introduce a second, undeclared convention into the same key space.
2800
+ *
2801
+ * Empty when the root is unknown OR the file does not sit under it (a relative filePath
2802
+ * against an absolute root, which is how several callers run). Refusing to guess is the
2803
+ * point: a WRONG scope is worse than no scope, because the scope's whole job is to be the
2804
+ * part of the identity that cannot collide.
2805
+ */
2806
+ static documentDirScope(filePath, projectRoot) {
2807
+ const rel = _RealYamlAnalyzer.projectRelativePath(filePath, projectRoot);
2808
+ if (rel === null) return "";
2809
+ const cut = rel.lastIndexOf("/");
2810
+ if (cut < 0) return "";
2811
+ return rel.slice(0, cut).split("/").filter((s) => s.length > 0).join(".");
2812
+ }
2813
+ /**
2814
+ * A filesystem path in the POSIX convention this analyzer's key space uses: every `\` becomes
2815
+ * `/`, and TRAILING separators are dropped.
2816
+ *
2817
+ * WHY THIS IS A CHARACTER WALK AND NOT TWO `replace` CALLS. The two substitutions it replaces
2818
+ * were `replace(/\\/g, "/")` followed by `replace(/\/+$/, "")` — two regex literals on each of
2819
+ * two lines, i.e. four of the twelve regex usages the fleet's no-regex gate counted on this
2820
+ * lane. The rule is mechanically enforced on ANALYZER source, so the walk is the form that keeps
2821
+ * the gate's statement TRUE rather than waived. This is a predicate about a string the SOURCE
2822
+ * wrote, not a node type the grammar never emits — the distinction the fsharp lane's fix
2823
+ * (b3623467b) draws.
2824
+ *
2825
+ * THE ORDER IS LOAD-BEARING and is preserved exactly. Backslashes fold to `/` FIRST, so a path
2826
+ * ending in `\` is still stripped by the trailing-separator pass; stripping first would leave the
2827
+ * backslash and produce a key with a trailing `\` in it. Semantics are otherwise identical,
2828
+ * including the degenerate cases: `""` stays `""`, and a string of nothing but separators
2829
+ * (`"/"`, `"///"`) becomes `""` because the second pass removes every one of them.
2830
+ */
2831
+ static toPosixPath(p) {
2832
+ let out = "";
2833
+ for (let i = 0; i < p.length; i++) {
2834
+ const ch = p[i];
2835
+ out += ch === "\\" ? "/" : ch;
2836
+ }
2837
+ let end = out.length;
2838
+ while (end > 0 && out[end - 1] === "/") end--;
2839
+ return out.slice(0, end);
2840
+ }
2841
+ /** The file's own path relative to its project root, POSIX-normalised, or null when unknown. */
2842
+ static projectRelativePath(filePath, projectRoot) {
2843
+ if (!projectRoot) return null;
2844
+ const root = _RealYamlAnalyzer.toPosixPath(projectRoot);
2845
+ const file = _RealYamlAnalyzer.toPosixPath(filePath);
2846
+ if (root.length === 0 || file === root || !file.startsWith(root + "/")) return null;
2847
+ return file.slice(root.length + 1);
2848
+ }
2849
+ /** Collapse `.`, `..` and duplicate separators; null when the path escapes its root. */
2850
+ static normalizePosix(p) {
2851
+ const parts = [];
2852
+ for (const seg of p.split("/")) {
2853
+ if (seg.length === 0 || seg === ".") continue;
2854
+ if (seg === "..") {
2855
+ if (parts.length === 0) return null;
2856
+ parts.pop();
2857
+ continue;
2858
+ }
2859
+ parts.push(seg);
2860
+ }
2861
+ return parts.length > 0 ? parts.join("/") : null;
2862
+ }
2863
+ /**
2864
+ * THE CANDIDATE SPELLINGS a tag include could name INSIDE this project.
2865
+ *
2866
+ * A `!include` path is resolved by the LOADER, and the corpus runs two live conventions — Hasura
2867
+ * resolves it against the including FILE's directory (`metadata/databases/databases.yaml`
2868
+ * includes `s1/functions/functions.yaml`, and that file exists beside it), while pyyaml-include's
2869
+ * test data resolves it against the REPO ROOT (`tests/data/0.yaml` includes
2870
+ * `tests/data/include.d/1.yaml`). Both were read off the corpus, not guessed, and an analyzer
2871
+ * cannot know which loader will read the file. So it states both candidate spellings and the
2872
+ * ARBITER decides: the gate's file claim verifies when any candidate is an admitted file of the
2873
+ * project (script/m14/incoming-rule.ts, `FILE_CLAIM_KEY`), and is REFUTED when none is.
2874
+ *
2875
+ * Null means "no claim can be made", and it is returned rather than an empty list so the caller
2876
+ * keeps the weaker `isExternal` marker it used before: a URL include, or a file whose project
2877
+ * root this run does not know (every fixture that passes no root), is exactly the case where the
2878
+ * candidate set would be a fabrication.
2879
+ *
2880
+ * AND A SPELLING THAT IS NOT A PATH AT ALL GETS NO CANDIDATE EITHER, which is the guard
2881
+ * `isLiteralPathSpelling` adds. Measured on the ansible population before it existed: a
2882
+ * templated include (`'{{ service_type }}_cleanup.yml'`, 6 sites) and an ansible free-form
2883
+ * `include_tasks: file=x.yml` (5 sites) both used to walk straight into a candidate set, and a
2884
+ * candidate containing `{{ }}` can never be an admitted file — so the arbiter would have
2885
+ * REFUTED the lane on 11 edges whose target was never a literal path. A claim has to be spelled
2886
+ * as the thing it claims before a lookup can say anything about it, which is also why the
2887
+ * gate's own `verifyRemoteRefClaim` refuses shapes it cannot read.
2888
+ */
2889
+ relativePathCandidates(written, ctx, quoted = false) {
2890
+ if (!_RealYamlAnalyzer.isLiteralPathSpelling(written, quoted)) return null;
2891
+ const rel = _RealYamlAnalyzer.projectRelativePath(ctx.filePath, ctx.projectRoot);
2892
+ if (rel === null) return null;
2893
+ const cut = rel.lastIndexOf("/");
2894
+ const dir = cut >= 0 ? rel.slice(0, cut) : "";
2895
+ const trimmed = written.startsWith("./") ? written.slice(2) : written;
2896
+ const rooted = trimmed.startsWith("/");
2897
+ const out = [];
2898
+ const add = (p) => {
2899
+ const n = _RealYamlAnalyzer.normalizePosix(p);
2900
+ if (n !== null && !out.includes(n)) out.push(n);
2901
+ };
2902
+ add(rooted ? trimmed.slice(1) : dir.length > 0 ? `${dir}/${trimmed}` : trimmed);
2903
+ if (!rooted) add(trimmed);
2904
+ const base = rooted ? null : _RealYamlAnalyzer.roleSubdirBase(rel);
2905
+ if (base !== null && base.length > 0) {
2906
+ add(`${base}/tasks/${trimmed}`);
2907
+ if (ROLE_SUBDIR_NAMES.has(trimmed.split("/")[0])) add(`${base}/${trimmed}`);
2908
+ }
2909
+ return out.length > 0 ? out : null;
2910
+ }
2911
+ /**
2912
+ * THE ROLE ROOT of the file that is including something, or null when it is not inside a role.
2913
+ *
2914
+ * A role is a directory carrying `tasks/`, `handlers/`, `vars/`, `defaults/`, `meta/` — so the
2915
+ * role root is the path BEFORE the LAST such segment. `roles/role1/handlers/main.yml` ->
2916
+ * `roles/role1`; `roles/role1/tasks/sub/x.yml` -> `roles/role1`, which is what makes the
2917
+ * fallback work for a nested task file too.
2918
+ */
2919
+ static roleSubdirBase(rel) {
2920
+ const segs = rel.split("/");
2921
+ segs.pop();
2922
+ for (let i = segs.length - 1; i >= 0; i--) {
2923
+ if (!ROLE_SUBDIR_NAMES.has(segs[i])) continue;
2924
+ return segs.slice(0, i).join("/");
2925
+ }
2926
+ return null;
2927
+ }
2928
+ /**
2929
+ * The claim a tag-include import carries. The file claim when the candidates are known (the
2930
+ * project's own file list can confirm it), and the older `isExternal` marker otherwise — never
2931
+ * both, because the rule routes the file claim first and an edge carrying both would be excused
2932
+ * by the weaker one (`incoming-rule.ts`'s control plants exactly that pairing).
2933
+ */
2934
+ tagIncludeClaim(includePath, ctx, quoted = false) {
2935
+ const candidates = this.relativePathCandidates(includePath, ctx, quoted);
2936
+ return candidates !== null && this.projectCarriesFile(ctx, candidates) ? { importProjectFile: candidates } : { isExternal: true };
2937
+ }
2938
+ /**
2939
+ * DOES THE PROJECT ACTUALLY CARRY ONE OF THESE CANDIDATES? A STAT, not a read.
2940
+ *
2941
+ * THE RULE, and it is now the same rule in json, toml and yaml: **a file claim is made only
2942
+ * where the analyzer has evidence for it.** `verifyFileClaim` returning `externalRefuted` on a
2943
+ * miss is pinned by the gate's own positive control (arms 4 and 5), and that is right — a file
2944
+ * claim is a checkable statement and the check is its whole purpose. What was wrong was WHICH
2945
+ * edges asserted it: 18 of them named paths this project does not have (a directory
2946
+ * materialised at run time by a checkout step, a CodeQL query directory holding no yaml, files
2947
+ * ansible's integration tests deliberately omit), and each one made a claim the analyzer had
2948
+ * no evidence for. They now carry `isExternal` instead, which on a path-shaped target is
2949
+ * `externalUnverified` — the honest outcome for a path the corpus does not contain — and the
2950
+ * gate's rule is not weakened by one arm.
2951
+ *
2952
+ * Mirrors `real-json-analyzer.ts`'s `projectCarriesFile` exactly, including its two
2953
+ * non-obvious decisions: this is a STAT and never a read (the lane's contract is that
2954
+ * decisions come from the tree, and asking the filesystem whether a path exists asserts
2955
+ * nothing about that file's contents), and an error that is NOT `ENOENT`/`ENOTDIR` counts as
2956
+ * PRESENT, because "I could not look" must not be recorded as "it is not there".
2957
+ */
2958
+ projectCarriesFile(ctx, candidates) {
2959
+ const root = ctx.projectRoot;
2960
+ if (typeof root !== "string" || root.length === 0) return false;
2961
+ for (const candidate of candidates) {
2962
+ try {
2963
+ if (statSync(join(root, candidate)).isFile()) return true;
2964
+ } catch (e) {
2965
+ if (!(e?.code === "ENOENT" || e?.code === "ENOTDIR")) return true;
2966
+ }
2967
+ }
2968
+ return false;
2969
+ }
2970
+ /**
2971
+ * THE CLAIM A `$ref` CARRIES — and for a cross-FILE pointer, `isExternal: true` was FALSE.
2972
+ *
2973
+ * `$ref` is spelled two ways and the difference is not cosmetic: `#/x` names a pointer into
2974
+ * THIS document (nothing external about it), while `<file>#/x` names a pointer into ANOTHER
2975
+ * DOCUMENT. The emit site stated `isExternal: !pointer.startsWith("#/")`, so the second shape
2976
+ * claimed to be outside the declared population — and over the whole admitted population
2977
+ * (18,364 `.yaml`/`.yml`, 189 repos) that claim is false **1,356 times**, every one of them in
2978
+ * one repository's OpenAPI set:
2979
+ *
2980
+ * references "./Responses.OpenAPI.yaml#/400" 681 edges (relative path)
2981
+ * references "Shared.Components.OpenAPI.yaml#/components/…" 675 edges (bare sibling)
2982
+ *
2983
+ * `Grace/src/OpenAPI/Responses.OpenAPI.yaml` is an ADMITTED FILE of that project, and the
2984
+ * arbiter said so: `pathLikeTarget` refuses to believe an "external" claim about a path, so all
2985
+ * 1,356 read `externalUnverified` — the outcome that blocks a PASS. They were 71% of this lane's
2986
+ * entire unverifiable population (`externalUnverified` 1,903).
2987
+ *
2988
+ * So the claim is the one the project can CHECK: the file part of the pointer, run through the
2989
+ * same candidate builder and the same `statSync` evidence rule the `!include` sites use. The
2990
+ * fragment is dropped — it addresses a position INSIDE the file, and a claim has to be spelled
2991
+ * as the thing it claims before a lookup can say anything about it (the same reason
2992
+ * `isLiteralPathSpelling` refuses a template). A pointer whose file this project does not carry
2993
+ * (a URL, a runtime-materialised path, a `../` past the root, or a file that is genuinely
2994
+ * absent) keeps the weaker `isExternal` marker, which on a path-shaped target is
2995
+ * `externalUnverified` — the honest outcome, and the direction this fix cannot reverse.
2996
+ *
2997
+ * NOT CLAIMED: that the pointer RESOLVES. The target document is not indexed by this analyzer,
2998
+ * so the edge still reports `unresolvedReason: "external-file"` (see
2999
+ * `resolvePointerReferences`); what changed is that the file dependency it names is now a
3000
+ * statement the project confirms instead of a false statement that it is outside the population.
3001
+ */
3002
+ pointerClaim(pointer, ctx) {
3003
+ if (pointer.startsWith("#/")) return { isExternal: false };
3004
+ const hash = pointer.indexOf("#");
3005
+ const file = hash >= 0 ? pointer.slice(0, hash) : pointer;
3006
+ if (file.length === 0) return { isExternal: false };
3007
+ const candidates = this.relativePathCandidates(file, ctx);
3008
+ return candidates !== null && this.projectCarriesFile(ctx, candidates) ? { importProjectFile: candidates } : { isExternal: true };
3009
+ }
3010
+ /**
3011
+ * THE CLAIM A REUSABLE-WORKFLOW CALL CARRIES — a FILE, not an action DIRECTORY.
3012
+ *
3013
+ * `jobs.<id>.uses: ./.github/workflows/build.yml` calls a workflow declared in another FILE of
3014
+ * this repository, and it was routed through `actionUseClaim`, which for a `./` target resolves
3015
+ * the ACTION's manifest (`action.yml`/`action.yaml`) inside it. That rule is right for
3016
+ * `steps[].uses: ./dir` and wrong here in the one way that matters: the target IS the file, so
3017
+ * the manifest lookup asks for `./.github/workflows/build.yml/action.yml`, finds nothing, and
3018
+ * the edge falls back to `isExternal: true` — a false statement about a file the project has.
3019
+ * MEASURED over the whole admitted population: **208** `calls` edges, this lane's entire `calls`
3020
+ * unverifiable population, every one of them an admitted `.yml`:
3021
+ *
3022
+ * "./.github/workflows/reusable-get-go-version.yml" 11 edges (consul)
3023
+ * "./.github/workflows/build.yml" 10 (consul, cython, …)
3024
+ * "./.github/workflows/ci-job.yml" 10 (cython)
3025
+ *
3026
+ * The file claim is the statement the project can check, and the candidates are built the same
3027
+ * way every other file claim in this lane is (`relativePathCandidates` + `projectCarriesFile`).
3028
+ * A remote call keeps the reference claim, and a local one the project does not carry keeps
3029
+ * `isExternal` — the same two directions as `pointerClaim` above.
3030
+ */
3031
+ workflowCallClaim(action, ctx) {
3032
+ if (!action.startsWith("./") && !action.startsWith("../")) return this.actionUseClaim(action, ctx);
3033
+ const candidates = this.relativePathCandidates(action, ctx);
3034
+ if (candidates === null) return this.actionUseClaim(action, ctx);
3035
+ return this.projectCarriesFile(ctx, candidates) ? { importProjectFile: candidates } : { isExternal: true };
3036
+ }
3037
+ /**
3038
+ * IS THIS KEY, AT THIS POSITION, A DEPENDENCY? A KEY NAME IS NOT A POSITION.
3039
+ *
3040
+ * `DEPENDS_ON_KEYS` fires on `depends_on|dependsOn|needs|requires` at ANY depth, and the corpus
3041
+ * contains the counter-example the sibling `uses:` site already guards against with
3042
+ * `isJobLevelPath`: GitHub Actions admits an action INPUT named `needs`,
3043
+ *
3044
+ * - uses: r-lib/actions/setup-r-dependencies@v2
3045
+ * with:
3046
+ * needs: check <- an input, NOT a job dependency
3047
+ *
3048
+ * so the analyzer emitted a `yaml_depends_on` reference for a key that names no dependency.
3049
+ * MEASURED over the whole admitted population — every `yaml_depends_on` edge grouped by the
3050
+ * position it was emitted from (1,938 edges, 413 candidate files, `yaml-depends-position`):
3051
+ *
3052
+ * jobs.<id>.needs 1,655 TRUE (a job dependency)
3053
+ * services.<svc>.depends_on 74 mixed — see `dependencyTargets`
3054
+ * steps[].with.needs 48 FALSE (an action input: check 14, website 12, …)
3055
+ * Azure stages[].dependsOn 22 TRUE
3056
+ * CircleCI jobs[].requires 38 TRUE
3057
+ *
3058
+ * So `needs` is gated to `jobs.<id>` and nothing else is: `depends_on`/`dependsOn`/`requires` are
3059
+ * the spellings of OTHER loaders (Compose, Azure Pipelines, CircleCI) whose containers this
3060
+ * analyzer has no shape index for, and every measured position for them is a true dependency.
3061
+ * The gate is the same predicate the `uses:` site uses, so one notion of "job level" governs both.
3062
+ *
3063
+ * The 46 of the 48 that read `externalVerified` are why this is a FIX and not a ruling: a false
3064
+ * edge that RESOLVES is worse than one that does not, and these were sitting in the VERIFIED
3065
+ * column as claims about `check`, `website`, `pr-document` and `coverage`.
3066
+ */
3067
+ /**
3068
+ * Is this an `include:` that selects a SET rather than naming a file? See
3069
+ * `INCLUDE_SELECTOR_PARENT_KEYS` for the measurement behind each parent.
3070
+ *
3071
+ * For `branches` the document's own top-level claim (`trigger:`/`pr:`) is required as well, and it
3072
+ * is read from the shape index rather than from the text: the ancestor is always visited before
3073
+ * its descendant, so the shape is already indexed when the `include` key is read — no second pass,
3074
+ * no text scan.
3075
+ */
3076
+ isIncludeSelectorKey(key, current, ctx) {
3077
+ if (key !== "include") return false;
3078
+ const parentPath = current.parentPath ?? "";
3079
+ const document = current.document;
3080
+ let rest = parentPath;
3081
+ if (rest === document) rest = "";
3082
+ else if (rest.startsWith(`${document}.`)) rest = rest.slice(document.length + 1);
3083
+ if (rest.length === 0) return false;
3084
+ let selector = false;
3085
+ let branches = false;
3086
+ for (const seg of rest.split(".")) {
3087
+ if (!INCLUDE_SELECTOR_SEGMENTS.has(seg)) continue;
3088
+ selector = true;
3089
+ if (seg === "branches") branches = true;
3090
+ }
3091
+ if (!selector) return false;
3092
+ if (!branches) return true;
3093
+ const shapes = ctx.documentShapes.get(document);
3094
+ return shapes ? shapes.has("trigger") || shapes.has("pr") : false;
3095
+ }
3096
+ isDependencyPosition(key, current) {
3097
+ if (key === "needs") return isJobLevelPath(current.parentPath, current.document);
3098
+ return true;
3099
+ }
3100
+ /**
3101
+ * WHICH NAMES A DEPENDENCY KEY NAMES, decided by the VALUE'S SHAPE.
3102
+ *
3103
+ * Compose's `depends_on` has two spellings, and they put the dependency name in different places:
3104
+ *
3105
+ * depends_on: [db, cache] the names are the SEQUENCE ITEMS
3106
+ * depends_on: the names are the MAPPING KEYS
3107
+ * db:
3108
+ * condition: service_healthy <- CONFIGURATION, not a name
3109
+ *
3110
+ * `scalarValues` walks a mapping's VALUES (it is built for payloads), so on the second form it
3111
+ * returned the dependency's own settings — `service_healthy`, `service_started`,
3112
+ * `service_completed_successfully` — and DROPPED the dependency. MEASURED over the whole admitted
3113
+ * population: **31** such edges, all in `depends_on`, every one of them a false dependency on a
3114
+ * condition string (22 `service_healthy`, 7 `service_started`, 2
3115
+ * `service_completed_successfully`). The long form's keys are real services, so the fix ADDS the
3116
+ * true edges while removing the false ones — and the delta is reported in BOTH directions.
3117
+ *
3118
+ * A sequence, a scalar, or a mapping with no pair keys falls through to `scalarValues` unchanged,
3119
+ * so every shape that worked before still works.
3120
+ */
3121
+ /**
3122
+ * A DEPENDENCY VALUE THAT NAMES A MARKER, not a member of any document.
3123
+ *
3124
+ * `DEPENDS_ON_KEYS` fires on four loader spellings (Compose `depends_on`, GitHub `needs`,
3125
+ * Azure `dependsOn`, CircleCI `requires`), and in every one of them the value names a
3126
+ * DECLARATION the same document carries — a service, a job, a stage. One corpus position
3127
+ * breaks that premise: kube-api-linter's config spells a rule-to-marker requirement
3128
+ *
3129
+ * dependenttags:
3130
+ * rules:
3131
+ * - identifier: "k8s:unionMember"
3132
+ * dependsOn: ["k8s:optional"] <- a kubebuilder MARKER, not a key
3133
+ *
3134
+ * where the value is a Go-comment annotation (`+k8s:optional` in Go struct docs) — not a
3135
+ * key any analyzer publishes in any lane, so the `references` edge it produced (target
3136
+ * `k8s:optional`, `isExternal: true`) was structurally unverifiable: no symbol named
3137
+ * `k8s:optional` can exist in the key space, and no instrument could ever confirm it.
3138
+ * Same class as `steps[].with.needs` (an action INPUT read as a job dependency) and the
3139
+ * 31 `service_healthy` condition strings: the key is spelled `dependsOn`, but the value
3140
+ * at this position names no dependency the graph indexes.
3141
+ *
3142
+ * MEASURED, whole admitted population, every `yaml_depends_on` target counted UNTRUNCATED
3143
+ * (1,895 edges over 413 candidate files, `.tmp-probe/yaml-depends-position-r16.txt`):
3144
+ * **1,892 carry no colon; the only colon-bearing, template-free targets are the 3
3145
+ * `k8s:optional` sites above** (kubernetes' `hack/golangci{,-hints}.yaml` and
3146
+ * `hack/kube-api-linter/kube-api-linter.yaml`, every one under `dependenttags.rules[]`),
3147
+ * so the rule fires on exactly the measured class with ZERO collateral. The shape is
3148
+ * loader-independent and vocabulary-free — no list of marker namespaces, which is what
3149
+ * made the previously-named candidate guard ("an ancestor segment names a marker-rule
3150
+ * container") unpriceable at 3 edges:
3151
+ *
3152
+ * * a colon is legal in no member name any of the four loaders admits (GitHub job ids,
3153
+ * Compose service names, Azure stage names, CircleCI job names are colon-free by
3154
+ * their formats' own rules), and
3155
+ * * every template spelling that could carry a colon (`${{ … }}`) contains `{{` and is
3156
+ * excluded — a template edge stays a RULED external claim, unchanged.
3157
+ *
3158
+ * A lookup-first variant (refuse only when the name is ALSO undeclared in the file) was
3159
+ * considered and measured equivalent on this population: `k8s:optional` occurs as a KEY
3160
+ * declaration in 0 of the three emitting files (grep, all sites are VALUES beside
3161
+ * `identifier:`/`conflicts:`), so shape-first and lookup-first refuse the same 3 edges.
3162
+ * Shape-first is chosen because it fires at capture with no dependence on walk order.
3163
+ */
3164
+ static markerNamespaceTarget(name) {
3165
+ return name.includes(":") && !name.includes("{{");
3166
+ }
3167
+ dependencyTargets(valueNode) {
3168
+ if (!valueNode) return [];
3169
+ const node = this.scalarNode(valueNode);
3170
+ const pairs = [];
3171
+ for (let i = 0; i < node.namedChildCount; i++) {
3172
+ const child = node.namedChild(i);
3173
+ if (child && MAPPING_PAIR_TYPES.has(child.type)) pairs.push(child);
3174
+ }
3175
+ if (pairs.length > 0) {
3176
+ const names = [];
3177
+ for (const pair of pairs) {
3178
+ const keyNode = pair.childForFieldName("key") || this.firstNonDecorationChild(pair);
3179
+ const text = keyNode ? this.scalarText(keyNode) : "";
3180
+ if (text) names.push(text);
3181
+ }
3182
+ if (names.length > 0) return names;
3183
+ }
3184
+ return this.scalarValues(valueNode);
3185
+ }
3186
+ /**
3187
+ * THE MANIFEST A LOCAL ACTION DIRECTORY IS FOR, confirmed to exist.
3188
+ *
3189
+ * `uses: ./dir` names a DIRECTORY, and the gate admits a directory claim by PREFIX over its
3190
+ * admitted files — which can only ever be right for this lane when the directory holds a yaml
3191
+ * file. Claiming the directory itself was measured wrong twice over: HashiCorp's
3192
+ * `./security-scanner` and nimony's `./ci-actions/.github/actions/setup` are checked out AT RUN
3193
+ * TIME and are not in the repository at all (7 + 2 edges), and gh-cli's CodeQL query directory
3194
+ * exists but holds `.ql`/`.md` only (1 edge) — all of them REFUTED claims about directories the
3195
+ * project cannot be asked about. The dependency a `uses: ./dir` really has is the action's
3196
+ * DEFINITION FILE, which is `action.yml`/`action.yaml` by GitHub's own rule, so that is the
3197
+ * candidate now — the same shape toml-lane landed on (claim the manifest FILE, not the
3198
+ * directory). Null when no candidate carries one, and the caller then states `isExternal`.
3199
+ */
3200
+ localActionManifest(ctx, candidates) {
3201
+ const root = ctx.projectRoot;
3202
+ if (typeof root !== "string" || root.length === 0) return null;
3203
+ for (const dir of candidates) {
3204
+ for (const manifest of ["action.yml", "action.yaml"]) {
3205
+ const candidate = `${dir}/${manifest}`;
3206
+ try {
3207
+ if (statSync(join(root, candidate)).isFile()) return [candidate];
3208
+ } catch (e) {
3209
+ if (!(e?.code === "ENOENT" || e?.code === "ENOTDIR")) return [candidate];
3210
+ }
3211
+ }
3212
+ }
3213
+ return null;
3214
+ }
3215
+ /**
3216
+ * IS THIS SPELLING A LITERAL PATH, before anything is claimed about where it points?
3217
+ *
3218
+ * The arbiter checks a file claim by looking the candidate up in the project's ADMITTED file
3219
+ * list. That lookup can only be evidence about a string that is a path: run it on a Jinja
3220
+ * template and the miss is a fact about the template, not about the project.
3221
+ *
3222
+ * `https://x/y.yaml` a URL, refused by `://` (already refused before this guard)
3223
+ * `{{ item }}` a template — the `{`, `}`, `$` set covers `{{ }}` and `${ }`
3224
+ * `name=a` ansible's free-form `key=value`, which is a parameter list
3225
+ * `file=x.yml` the same, and MEASURED: 5 sites in the ansible population
3226
+ * `'{{ svc }}_cleanup.yml'` the same, and MEASURED: 6 sites
3227
+ * `package:lints/core.yaml` A URI SCHEME, not a path. MEASURED over the whole corpus:
3228
+ * **85 refuted edges** — `package:lints/recommended.yaml` 56,
3229
+ * `package:dart_flutter_team_lints/analysis_options.yaml` 18,
3230
+ * `package:lints/core.yaml` 5, `package:flutter_lints/flutter.yaml`
3231
+ * 4, `package:foo/foo_package_options.yaml` 1 — every one an
3232
+ * `include:` in a Dart `analysis_options.yaml`, whose target is a
3233
+ * PUB package resolved by pub, never a path in this project.
3234
+ * `.vsts-ci/windows/*.yml` A GLOB. MEASURED: 1 refuted edge (powershell's azure-pipelines
3235
+ * path filter). A glob names a SET, so no single candidate can be
3236
+ * the thing it names.
3237
+ *
3238
+ * Everything else is admissible, including a bare `apt.yml` and a `subdir/x.yaml`.
3239
+ */
3240
+ static isLiteralPathSpelling(written, quoted = false) {
3241
+ if (written.length === 0) return false;
3242
+ if (written.includes("://")) return false;
3243
+ if (hasTemplateOrGlobChar(written)) return false;
3244
+ if (!quoted && hasJsWhitespace(written)) return false;
3245
+ if (hasUriSchemePrefix(written)) return false;
3246
+ if (hasParamKeyPrefix(written)) return false;
3247
+ return true;
3248
+ }
3249
+ /**
3250
+ * THE CLAIM A KEY-SPELLING INCLUDE CARRIES — and this is the fix for the 107 refuted external
3251
+ * claims the whole-population run found.
3252
+ *
3253
+ * `- import_tasks: apt.yml` names a FILE in this project. Until this helper existed the edge
3254
+ * carried `isExternal: true` and nothing else, so the arbiter asked its OWNER question —
3255
+ * `targetOwner("apt.yml")` is `apt` — and ansible publishes a module named `apt`, so the claim
3256
+ * "this is outside the corpus" was judged FALSE and the edge was counted against the lane. The
3257
+ * refutation was an artefact of asking an owner question about a path.
3258
+ *
3259
+ * MEASURED over the whole admitted ansible population (2,082 files, 891 include edges, the
3260
+ * key attributed from each edge's own `line_number`): 96 of the 107 refuted edges come from
3261
+ * `include_tasks`/`import_tasks` and name a file — 95 of them a file the project HAS. This
3262
+ * helper makes the claim the evidence can actually answer, and the arbiter verifies those 95
3263
+ * against the project's own file list instead of refuting them against a module's name.
3264
+ *
3265
+ * TWO REFUSALS, each measured rather than assumed:
3266
+ *
3267
+ * 1. A ROLE KEY gets no file claim (`ROLE_IMPORT_KEYS`). `tasks_from: canary2.yml` is
3268
+ * relative to a role directory this analyzer does not resolve, so a claim that THIS
3269
+ * file's directory holds it would be refuted — 10 edges measured.
3270
+ * 2. A TARGET THAT IS NOT SPELLED AS AN ADMITTED SPELLING gets no file claim. The arbiter's
3271
+ * file list is the lane's admitted population, `.yaml`/`.yml` only, so a claim about any
3272
+ * other suffix could never be confirmed and would be refuted by the claim rather than by
3273
+ * the project. `isLiteralPathSpelling` also refuses templates and free-form spellings.
3274
+ *
3275
+ * When no candidate can be stated the edge keeps the weaker `isExternal` marker it had — the
3276
+ * pre-existing behaviour, unchanged, so this fix can only ADD verifiable claims, never remove
3277
+ * an edge or silently re-label one.
3278
+ */
3279
+ /**
3280
+ * THE ENTRY FILE A ROLE IMPORT RESOLVES TO — and the reason the lane can stop emitting a bare
3281
+ * role name, which is never a symbol.
3282
+ *
3283
+ * Ansible resolves `include_role`/`import_role` `name: X` against a SEARCH PATH: for each
3284
+ * directory from the importing file's own directory up to the project root, `roles/X`. The entry
3285
+ * file of the resolved role is its `tasks/main.yml`. The lane's `imports` relation is file to
3286
+ * file, so that file — not the role's NAME — is what an edge can point at, and pointing at it is
3287
+ * what makes the edge checkable: a bare `X` collides with every mapping key spelled `X` (measured:
3288
+ * `a` 29 declarations, `include_role` 123, `no_log` 42), so the arbiter had no disposition for it
3289
+ * other than AMBIGUOUS.
3290
+ *
3291
+ * RETURNS NULL UNLESS THE FILE IS ACTUALLY THERE, checked with the same `statSync` evidence rule
3292
+ * every other file claim in this lane uses. That ordering is the whole safety property: a role
3293
+ * whose entry file cannot be found keeps the pre-existing bare-name marker, so the change can
3294
+ * only ADD a verifiable claim and can never manufacture a refutation.
3295
+ *
3296
+ * The candidate is normalised segment by segment so that a role spelled with `..` cannot escape
3297
+ * the project (`include_role: name=../x` occurs in ansible), and a candidate that escapes returns
3298
+ * null rather than a path the arbiter's file list could never hold.
3299
+ */
3300
+ roleEntryTarget(name, ctx, tasksFrom = null) {
3301
+ if (typeof name !== "string" || name.length === 0) return null;
3302
+ const rel = _RealYamlAnalyzer.projectRelativePath(ctx.filePath, ctx.projectRoot);
3303
+ if (rel === null) return null;
3304
+ const parts = rel.split("/");
3305
+ parts.pop();
3306
+ const spellings = [`roles/${name}/tasks/main.yml`];
3307
+ if (typeof tasksFrom === "string" && tasksFrom.length > 0) {
3308
+ spellings.push(`roles/${name}/tasks/${tasksFrom}`);
3309
+ }
3310
+ for (let depth = parts.length; depth >= 0; depth--) {
3311
+ const base = parts.slice(0, depth).join("/");
3312
+ for (const spelling of spellings) {
3313
+ const raw = (base === "" ? "" : base + "/") + spelling;
3314
+ const segs = [];
3315
+ let escaped = false;
3316
+ for (const seg of raw.split("/")) {
3317
+ if (seg === "" || seg === ".") continue;
3318
+ if (seg === "..") {
3319
+ if (segs.length === 0) {
3320
+ escaped = true;
3321
+ break;
3322
+ }
3323
+ segs.pop();
3324
+ continue;
3325
+ }
3326
+ segs.push(seg);
3327
+ }
3328
+ if (escaped) continue;
3329
+ const cand = segs.join("/");
3330
+ if (cand.length === 0) continue;
3331
+ if (this.projectCarriesFile(ctx, [cand])) return cand;
3332
+ }
3333
+ }
3334
+ return null;
3335
+ }
3336
+ /**
3337
+ * THE PARAMETERS OF AN INLINE ROLE IMPORT — `import_role: name=a public=no`.
3338
+ *
3339
+ * Ansible accepts the parameters of `include_role`/`import_role` as an inline `k=v` list as well
3340
+ * as a mapping, and the lane read the whole list as ONE role name. This parses the list and
3341
+ * returns it, or null unless EVERY whitespace-separated token is a `key=value` pair with a
3342
+ * lowercase parameter name AND the list carries `name`:
3343
+ *
3344
+ * * a plain scalar role name (`- include_role: some_role`) has no '=' → null, pre-existing path;
3345
+ * * a templated value (`name={{ role }}`) keeps its '{{' spelling as the VALUE of `name` and is
3346
+ * returned as the name — it will not resolve to a directory, and the caller keeps the
3347
+ * pre-existing target in that case (the fallback in `emitKeySemantics`);
3348
+ * * only the tokens are scanned; there is no regex and no guessing at quoting.
3349
+ *
3350
+ * The two parameters that name an entry FILE are read by `roleEntryFileHint`.
3351
+ */
3352
+ inlineRoleParams(node) {
3353
+ const scalar = node ? this.scalarNode(node) : null;
3354
+ if (!scalar || !SCALAR_TYPES.has(scalar.type)) return null;
3355
+ const text = this.scalarText(scalar);
3356
+ if (text.length === 0) return null;
3357
+ const params = /* @__PURE__ */ new Map();
3358
+ let i = 0;
3359
+ while (i < text.length) {
3360
+ while (i < text.length && hasJsWhitespace(text[i])) i++;
3361
+ const start = i;
3362
+ while (i < text.length && !hasJsWhitespace(text[i])) i++;
3363
+ if (i === start) continue;
3364
+ const token = text.slice(start, i);
3365
+ const eq = token.indexOf("=");
3366
+ if (eq <= 0 || eq === token.length - 1) return null;
3367
+ const key = token.slice(0, eq);
3368
+ if (!isAnsibleParamName(key)) return null;
3369
+ params.set(key, token.slice(eq + 1));
3370
+ }
3371
+ return params.size > 0 && params.has("name") ? params : null;
3372
+ }
3373
+ /** The `tasks_from:` file a role import names, in EITHER spelling ansible accepts: the mapping
3374
+ * form (`import_role: {name: X, tasks_from: y.yml}`) or the inline form
3375
+ * (`import_role: name=X tasks_from=y.yml`). Null when no entry file is named, which is the
3376
+ * common case and keeps the pre-existing `tasks/main.yml` target. */
3377
+ roleEntryFileHint(valueNode) {
3378
+ const named = this.mappingEntryScalar(valueNode, "tasks_from");
3379
+ if (named !== null) return named;
3380
+ return this.inlineRoleParams(valueNode)?.get("tasks_from") ?? null;
3381
+ }
3382
+ keywordIncludeClaim(written, ctx, key) {
3383
+ if (ROLE_IMPORT_KEYS.has(key)) return { isExternal: true };
3384
+ if (!isYamlSpelling(written)) return { isExternal: true };
3385
+ const candidates = this.relativePathCandidates(written, ctx);
3386
+ return candidates !== null && this.projectCarriesFile(ctx, candidates) ? { importProjectFile: candidates } : { isExternal: true };
3387
+ }
3388
+ /**
3389
+ * THE CLAIM A `uses:` TARGET CARRIES, decided by what the target actually names.
3390
+ *
3391
+ * Three shapes, measured over this corpus's workflow files rather than assumed: **7,265**
3392
+ * remote actions (`actions/checkout@v6`, `r-lib/actions/setup-r@v2`), **501** local ones
3393
+ * (`./ci-actions/.github/actions/setup` — a DIRECTORY), and **13** container images
3394
+ * (`docker://hashicorp/terraform:1.9`).
3395
+ *
3396
+ * docker://… a container image -> the REFERENCE claim (spelling)
3397
+ * ./… or ../… a path in THIS project -> the FILE claim (the project's list)
3398
+ * anything else a pinned remote action reference -> the REFERENCE claim (spelling)
3399
+ *
3400
+ * The alternative for the first and third is what the lane did until now — `isExternal: true` on
3401
+ * a target with no identifier-shaped owner — which the gate reads as UNVERIFIABLE, blocks a PASS
3402
+ * on, and can say nothing else about. The alternative for the second is worse: it was marked NOT
3403
+ * external and resolved to nothing, so a local action read as a broken edge. All three now carry
3404
+ * a claim the arbiter can check, and a claim it can refute.
3405
+ *
3406
+ * When no project root is known the local shape keeps `isExternal: false` — the pre-existing
3407
+ * behaviour — because a candidate set without a root would be a fabrication, and the file claim
3408
+ * with no evidence is exactly what the rule's control refuses.
3409
+ *
3410
+ * AND THE LOCAL SHAPE CLAIMS THE MANIFEST, NOT THE DIRECTORY, confirmed to exist. Measured over
3411
+ * the whole corpus: 11 residual refutations were local directories this project cannot be asked
3412
+ * about — two of them checked out at run time by an earlier step (`path: security-scanner`,
3413
+ * `path: ci-actions`), one holding `.ql`/`.md` and no yaml at all. A directory claim can only be
3414
+ * admitted by prefix over the admitted YAML population, so it was a claim with no possible
3415
+ * evidence. A `uses: ./dir` depends on the action's definition file (`action.yml`/`action.yaml`
3416
+ * by GitHub's rule), which is a statement the arbiter's file list can actually answer. Where
3417
+ * that manifest is absent the edge states `isExternal: true` — on a path-shaped target that is
3418
+ * `externalUnverified`, not a refutation.
3419
+ */
3420
+ /**
3421
+ * `$/…` IS A ROOT-ANCHORED PATH, the spelling one repository in the corpus uses where every
3422
+ * other repository writes `./…`.
3423
+ *
3424
+ * WHAT WAS WRONG. A `uses:` value that is not `docker://` and does not begin `./`/`../` fell
3425
+ * through to `actionUseClaim`'s last line, `return { remoteRef: true }` — a claim that `$/` is a
3426
+ * GitHub OWNER and `.github` the repository under it. `$` is not an owner, and the target is not
3427
+ * an action reference at all: it is a path in the checked-out tree. So the edge asserted an
3428
+ * external remote action that does not exist, was read as `externalUnverified`, and the lane's
3429
+ * `ext?` population carried it as unnamed. MEASURED over the whole corpus, this was **18 edges**, all
3430
+ * of them terragrunt's (`uses: $/.github/actions/go-cache-restore` 15, `…/go-cache-save` 3).
3431
+ *
3432
+ * WHY THE REFERENT IS SUPPORTABLE, and the two places it could have been a ruling instead:
3433
+ * `terragrunt/.github/actions/go-cache-restore/action.yml` and `…/go-cache-save/action.yml` are
3434
+ * FILES THE PROJECT CARRIES, each a composite action whose name is exactly the last segment of
3435
+ * the `uses:` value, and no `$/.github` directory exists anywhere in the tree. The rewrite is
3436
+ * unconditional, so it manufactures nothing: a `$/…` action with no manifest in the project
3437
+ * still lands in `actionUseClaim`'s local branch and falls back to `isExternal: true`, which is
3438
+ * the weaker claim it carried before — the same refusal the `./…` spelling already has.
3439
+ *
3440
+ * WHAT IT DOES NOT DO. It does not read `$` as a GitHub expression, does not touch a target that
3441
+ * merely CONTAINS `$/`, and does not apply to any other key: over the whole admitted population a
3442
+ * `$/` spelling occurs only in a step-level `uses:`. Null means "not a root-anchored spelling", so
3443
+ * a caller keeps the written text.
3444
+ */
3445
+ static rootAnchoredActionPath(action) {
3446
+ return action.startsWith("$/") ? `./${action.slice(2)}` : null;
3447
+ }
3448
+ actionUseClaim(action, ctx, extraProjectPaths = []) {
3449
+ if (action.startsWith("docker://")) return { remoteRef: true };
3450
+ if (action.startsWith("./") || action.startsWith("../")) {
3451
+ const candidates = this.relativePathCandidates(action, ctx);
3452
+ if (candidates === null) return { isExternal: false };
3453
+ const all = extraProjectPaths.length > 0 ? [...extraProjectPaths, ...candidates] : candidates;
3454
+ const manifest = this.localActionManifest(ctx, all);
3455
+ if (manifest !== null) return { importProjectFile: manifest };
3456
+ if (isYamlSpelling(action) && this.projectCarriesFile(ctx, all)) {
3457
+ return { importProjectFile: all };
3458
+ }
3459
+ return { isExternal: true };
3460
+ }
3461
+ return { remoteRef: true };
3462
+ }
3463
+ /**
3464
+ * A document's IDENTITY, and it is scoped to the file.
3465
+ *
3466
+ * ## The defect this fixes
3467
+ *
3468
+ * The identity was the filesystem BASENAME, and a basename is not unique in a project.
3469
+ * Measured on a 60-file stride of the yaml corpus: `hostports0.yaml` occurs **156 times**
3470
+ * in one repository (Kubernetes' pod-security-admission testdata, once per API version
3471
+ * and once per pass directory) and `Chart.yaml` **243 times** across the helm charts.
3472
+ * Every member of every one of those documents was published under the same name —
3473
+ * `hostports0.spec.containers[0]` had 156 declarations — so the `contains` edge asserting
3474
+ * that containment could not be walked in either direction: the shared resolver resolves a
3475
+ * bare name only under `HAVING COUNT(*) = 1`, and an exact-key match has to be unique too.
3476
+ * The incoming gate read **78 ambiguous edges of 1,512** on that stride.
3477
+ *
3478
+ * ## The rule, and why it is the only sound one
3479
+ *
3480
+ * A file-scoped symbol's identity must contain the file. The basename is the ONE part of
3481
+ * the path that is not unique; the project-relative directory chain is the part that is,
3482
+ * and it is stable across machines in a way the absolute path is not. The BASENAME STAYS
3483
+ * THE LAST SEGMENT, which is what keeps the leaf of every member path (`...v1.37.fail` +
3484
+ * `hostports0` -> `hostports0`) unchanged for the store's leaf matching.
3485
+ *
3486
+ * ## Price, measured before the code was written
3487
+ *
3488
+ * `script/m14/incoming-gate.ts yaml yaml 60` with the identity scoped: **ambiguous 78 -> 0,
3489
+ * unresolved 0 -> 0, resolved 1,432 -> 1,510** (+78, i.e. every previously-ambiguous edge
3490
+ * now resolves and NO edge that resolved before stopped resolving). That last clause is the
3491
+ * one the fleet worklist asked to be checked rather than assumed: it is the evidence that
3492
+ * nothing in this corpus spells a document by its basename, so scoping the identity cannot
3493
+ * break an incoming reference.
3494
+ *
3495
+ * ## What it does NOT do
3496
+ *
3497
+ * It does not make the BASENAME key unique — `hostports0` is still published by 156 files,
3498
+ * because the basename is the readable name a human uses. It makes the QUALIFIED PATH
3499
+ * unique, and that path is what `contains` emits and what strategy 1 matches exactly.
3500
+ */
3501
+ documentName(filePath, documentIndex, projectRoot) {
3502
+ const slash = filePath.lastIndexOf("/");
3503
+ const base = slash >= 0 ? filePath.slice(slash + 1) : filePath;
3504
+ let stem = base;
3505
+ const lower = base.toLowerCase();
3506
+ if (lower.endsWith(".yaml.template")) stem = base.slice(0, -13);
3507
+ else if (lower.endsWith(".yml.template")) stem = base.slice(0, -12);
3508
+ else if (lower.endsWith(".yaml")) stem = base.slice(0, -5);
3509
+ else if (lower.endsWith(".yml")) stem = base.slice(0, -4);
3510
+ stem = stem.trim() || "document";
3511
+ const scope = _RealYamlAnalyzer.documentDirScope(filePath, projectRoot);
3512
+ const scoped = scope.length > 0 ? `${scope}.${stem}` : stem;
3513
+ return documentIndex === 1 ? scoped : `${scoped}#${documentIndex}`;
3514
+ }
3515
+ nodeToRange(node) {
3516
+ return {
3517
+ startLine: node.startPosition.row + 1,
3518
+ startColumn: node.startPosition.column + 1,
3519
+ endLine: node.endPosition.row + 1,
3520
+ endColumn: node.endPosition.column + 1,
3521
+ startIndex: node.startIndex,
3522
+ endIndex: node.endIndex
3523
+ };
3524
+ }
3525
+ };
3526
+ export {
3527
+ RealYamlAnalyzer
3528
+ };