@backbencher/brain 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +298 -0
- package/EMBEDDINGS.md +355 -0
- package/LICENSE +21 -0
- package/README.md +415 -2
- package/bin/bb +13 -0
- package/dist/analysis-exclusions-7ZJDXOVZ.js +35 -0
- package/dist/brain-index-health-Q5APTJQL.js +17 -0
- package/dist/brain-relationship-enrichment-MYR2AM4F.js +558 -0
- package/dist/brain.sql-EIM4WS4D.js +29 -0
- package/dist/chunk-22V37YTK.js +1758 -0
- package/dist/chunk-2BSKD7O3.js +40 -0
- package/dist/chunk-2DE27DVC.js +1314 -0
- package/dist/chunk-2RRBMNBQ.js +322 -0
- package/dist/chunk-2WXYNLBA.js +29 -0
- package/dist/chunk-3CTFIJJB.js +17 -0
- package/dist/chunk-3ZTOHHSW.js +2104 -0
- package/dist/chunk-4H22TLOT.js +543 -0
- package/dist/chunk-5BVAH26D.js +340 -0
- package/dist/chunk-62DHLYW6.js +3427 -0
- package/dist/chunk-7AN7U5IZ.js +371 -0
- package/dist/chunk-7DRPNVIN.js +782 -0
- package/dist/chunk-7MBYT777.js +174 -0
- package/dist/chunk-AHDWGM7Q.js +236 -0
- package/dist/chunk-B5GEAAYF.js +818 -0
- package/dist/chunk-BEIZZ73B.js +1479 -0
- package/dist/chunk-BQ3OVQ3P.js +125 -0
- package/dist/chunk-D2Q4Y47A.js +53 -0
- package/dist/chunk-DIYAGQJL.js +34 -0
- package/dist/chunk-DWKXZ6IF.js +336 -0
- package/dist/chunk-DYM4FQEE.js +90 -0
- package/dist/chunk-ENW4BTBA.js +930 -0
- package/dist/chunk-EWMWK7WN.js +75 -0
- package/dist/chunk-EY5ZZUUA.js +626 -0
- package/dist/chunk-GBI7V7KY.js +69 -0
- package/dist/chunk-GEFFO6O2.js +152 -0
- package/dist/chunk-HJEWO5EV.js +247 -0
- package/dist/chunk-HQSPJDHI.js +613 -0
- package/dist/chunk-KAET47ZL.js +125 -0
- package/dist/chunk-KCDECRJY.js +440 -0
- package/dist/chunk-L5TDYKYK.js +76 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-NO3U3FJQ.js +201 -0
- package/dist/chunk-OWISBAOF.js +61 -0
- package/dist/chunk-QKTRECYS.js +1119 -0
- package/dist/chunk-QLK6ED4Q.js +235 -0
- package/dist/chunk-R2GW4F6R.js +19 -0
- package/dist/chunk-RCMTFSEF.js +87 -0
- package/dist/chunk-RM3PY6TG.js +376 -0
- package/dist/chunk-ROMM6KMC.js +433 -0
- package/dist/chunk-SGGCSHAX.js +12050 -0
- package/dist/chunk-SK6YVHFZ.js +211 -0
- package/dist/chunk-SM7RFQRO.js +214 -0
- package/dist/chunk-UGR4QIGS.js +67 -0
- package/dist/chunk-UQNQZXHY.js +711 -0
- package/dist/chunk-UUNHHX4M.js +13 -0
- package/dist/chunk-VL52PL2T.js +71 -0
- package/dist/chunk-WIG36E67.js +196 -0
- package/dist/chunk-XHK3WWGB.js +7436 -0
- package/dist/chunk-XTQM2IP3.js +2863 -0
- package/dist/chunk-ZE2LG76D.js +1296 -0
- package/dist/chunk-ZETTGPAW.js +6950 -0
- package/dist/cli/index.js +37 -0
- package/dist/config-HZAAYDP3.js +10 -0
- package/dist/content-search-7XIP555Q.js +593 -0
- package/dist/database-76RP44JG.js +9 -0
- package/dist/doctor-ANGETYMN.js +395 -0
- package/dist/edit-engine-CG72LDZD.js +51 -0
- package/dist/engine-BKEOGN7N.js +11 -0
- package/dist/engine-VIW6NSKO.js +18 -0
- package/dist/fingerprint-OBLIMZLP.js +33 -0
- package/dist/framework-intelligence-3LORTPOX.js +860 -0
- package/dist/freshness-3K6VU5OT.js +174 -0
- package/dist/graph-engine-HBSQNPTB.js +21 -0
- package/dist/graph_request-Q6NKB6N2.js +350 -0
- package/dist/impact_request-LYP672WE.js +68 -0
- package/dist/index-space-IUUN5WYV.js +15 -0
- package/dist/index-status-7OVGYSGW.js +34 -0
- package/dist/index.js +159 -0
- package/dist/instance-FO6QZRLH.js +19 -0
- package/dist/language-detection-DFCAU5DZ.js +11 -0
- package/dist/lsp/server.js +4301 -0
- package/dist/mcp/server.js +132 -0
- package/dist/onboard-DIQZV2NW.js +161 -0
- package/dist/override-companion-pass-XR7MHTP7.js +117 -0
- package/dist/parallel-indexer-FK2XKZXY.js +88 -0
- package/dist/pool-worker.js +1153 -0
- package/dist/project-health-6XPENVAA.js +94 -0
- package/dist/project-index-5SDPHITE.js +57 -0
- package/dist/project-registry-ZJ3DVUGO.js +355 -0
- package/dist/real-bash-analyzer-EJAU5LW5.js +2201 -0
- package/dist/real-c-analyzer-HBD3FNNH.js +5942 -0
- package/dist/real-clean-analyzer-4EEZLPV5.js +3470 -0
- package/dist/real-clojure-analyzer-Q55EBOO2.js +2818 -0
- package/dist/real-cpp-analyzer-IVVOD25R.js +4101 -0
- package/dist/real-csharp-analyzer-ZBNLNJZJ.js +3298 -0
- package/dist/real-css-analyzer-6XRQLUN7.js +15 -0
- package/dist/real-dart-analyzer-OLR72EFW.js +3944 -0
- package/dist/real-elixir-analyzer-MQWGAH2I.js +2192 -0
- package/dist/real-erlang-analyzer-7BVPQEHO.js +1731 -0
- package/dist/real-fsharp-analyzer-C2UPFNOA.js +2390 -0
- package/dist/real-go-analyzer-UKGI43YS.js +4126 -0
- package/dist/real-graphql-analyzer-VE2EEFII.js +1442 -0
- package/dist/real-groovy-analyzer-W26VACFW.js +2285 -0
- package/dist/real-haskell-analyzer-5CVVAJRX.js +2533 -0
- package/dist/real-hcl-analyzer-5ZT4U43N.js +725 -0
- package/dist/real-html-analyzer-JWNX3C6I.js +1759 -0
- package/dist/real-java-analyzer-GU42VBGQ.js +4677 -0
- package/dist/real-javascript-analyzer-QA4QOJ6W.js +14 -0
- package/dist/real-json-analyzer-MPA6PRXL.js +1640 -0
- package/dist/real-julia-analyzer-22CWYQMN.js +2085 -0
- package/dist/real-kotlin-analyzer-QDZJLXXU.js +3956 -0
- package/dist/real-lua-analyzer-G7NHLYKS.js +1957 -0
- package/dist/real-makefile-analyzer-OCY3GT5N.js +920 -0
- package/dist/real-markdown-analyzer-TEJK2JXJ.js +1215 -0
- package/dist/real-matlab-analyzer-Q5SWLN7Z.js +480 -0
- package/dist/real-nim-analyzer-YLXSK563.js +3603 -0
- package/dist/real-objective-c-analyzer-D23ACC2R.js +1518 -0
- package/dist/real-perl-analyzer-WWTRHYDT.js +1865 -0
- package/dist/real-php-analyzer-JNKJHTCL.js +9368 -0
- package/dist/real-powershell-analyzer-XRSNQERV.js +2915 -0
- package/dist/real-protobuf-analyzer-3OKUCZ7T.js +867 -0
- package/dist/real-python-analyzer-VXAFWI72.js +4880 -0
- package/dist/real-r-analyzer-ZGAIDVI4.js +2596 -0
- package/dist/real-ruby-analyzer-X2KTLVBH.js +2429 -0
- package/dist/real-rust-analyzer-OFFHPL2A.js +3359 -0
- package/dist/real-scala-analyzer-TSTW27J6.js +3293 -0
- package/dist/real-scss-analyzer-AVWYPSOQ.js +1515 -0
- package/dist/real-solidity-analyzer-UCI2KWUB.js +2608 -0
- package/dist/real-sql-analyzer-F77ZMQXR.js +3840 -0
- package/dist/real-svelte-analyzer-SF7NRUO5.js +2532 -0
- package/dist/real-swift-analyzer-MN5QT3QN.js +2311 -0
- package/dist/real-toml-analyzer-66L7BDCQ.js +929 -0
- package/dist/real-typescript-analyzer-6KEMVDWQ.js +14 -0
- package/dist/real-vb-analyzer-VICVPOP7.js +3145 -0
- package/dist/real-vba-analyzer-HXLTTHKM.js +466 -0
- package/dist/real-vue-analyzer-QXR56CFV.js +2680 -0
- package/dist/real-xml-analyzer-YL465ZJW.js +2405 -0
- package/dist/real-yaml-analyzer-SUBCBNSI.js +3528 -0
- package/dist/real-zig-analyzer-HIQLZMGY.js +2430 -0
- package/dist/regex-search-NJKHMZNO.js +239 -0
- package/dist/rules-ACHPB7QU.js +152 -0
- package/dist/scoped-path-MJYWJZQX.js +11 -0
- package/dist/setup-show-NT5ZHYNE.js +15 -0
- package/dist/setup-wizard-G5YYR5T6.js +24 -0
- package/dist/symbol-search-GZJIM2J2.js +16 -0
- package/dist/taint-KXUHHPIE.js +202 -0
- package/dist/taint-analyzer-N7YLZU3N.js +163 -0
- package/dist/tool-handlers-OJDC35DI.js +72 -0
- package/dist/tool-registry-BPEJNSZG.js +17 -0
- package/dist/tool-validation-Q57YMMBX.js +32 -0
- package/dist/type-inference-QMT2RMLV.js +13 -0
- package/dist/vector-binary-LMY4QGAN.js +19 -0
- package/dist/version-ABP2TUL2.js +9 -0
- package/dist/wasm-NMWO3WXY.js +16 -0
- package/docs/EMBEDDINGS.md +132 -0
- package/docs/README.md +178 -0
- package/docs/data/m10-baseline.json +602 -0
- package/docs/data/m10-benchmark-baseline.json +57 -0
- package/docs/data/m10-benchmark-last-run.json +127 -0
- package/docs/data/m10-final-audit.json +215 -0
- package/docs/data/m12-final-audit.json +138 -0
- package/docs/data/m12-surface-matrix.json +50 -0
- package/docs/data/public-tools.json +1902 -0
- package/docs/indexing/DEAD-ASSETS.md +88 -0
- package/docs/indexing/INDEXER-REVIEW.md +634 -0
- package/docs/indexing/PERFORMANCE-FINDINGS.md +106 -0
- package/docs/indexing/README.md +427 -0
- package/docs/indexing/discovery-matrix.md +41 -0
- package/docs/indexing/embeddings.md +325 -0
- package/docs/indexing/environment-variables.md +77 -0
- package/docs/indexing/performance.md +323 -0
- package/docs/indexing/resume.md +345 -0
- package/docs/indexing/troubleshooting.md +391 -0
- package/docs/man/bb.1 +190 -0
- package/docs/tools/bb_analyze.md +147 -0
- package/docs/tools/bb_annotate.md +110 -0
- package/docs/tools/bb_annotations.md +106 -0
- package/docs/tools/bb_ask.md +101 -0
- package/docs/tools/bb_cross_language.md +103 -0
- package/docs/tools/bb_doc.md +169 -0
- package/docs/tools/bb_edit.md +185 -0
- package/docs/tools/bb_graph.md +169 -0
- package/docs/tools/bb_health.md +156 -0
- package/docs/tools/bb_impact.md +117 -0
- package/docs/tools/bb_index.md +108 -0
- package/docs/tools/bb_migrate.md +135 -0
- package/docs/tools/bb_patterns.md +140 -0
- package/docs/tools/bb_search.md +150 -0
- package/docs/tools/bb_security.md +100 -0
- package/docs/tools/bb_visualize.md +184 -0
- package/grammars/README.md +57 -0
- package/grammars/THIRD-PARTY-NOTICES.md +50 -0
- package/grammars/tree-sitter-c/LICENSE +21 -0
- package/grammars/tree-sitter-clojure/LICENSE.md +21 -0
- package/grammars/tree-sitter-cpp/LICENSE +21 -0
- package/grammars/tree-sitter-csharp/LICENSE +21 -0
- package/grammars/tree-sitter-css/LICENSE +21 -0
- package/grammars/tree-sitter-css/README.md +21 -0
- package/grammars/tree-sitter-dart/LICENSE +20 -0
- package/grammars/tree-sitter-dart-upstream/LICENSE +20 -0
- package/grammars/tree-sitter-dart-upstream/README.md +18 -0
- package/grammars/tree-sitter-elixir/LICENSE +176 -0
- package/grammars/tree-sitter-elixir/README.md +11 -0
- package/grammars/tree-sitter-fsharp/LICENSE +21 -0
- package/grammars/tree-sitter-fsharp/README.md +45 -0
- package/grammars/tree-sitter-graphql/LICENSE +21 -0
- package/grammars/tree-sitter-graphql/README.md +106 -0
- package/grammars/tree-sitter-groovy/LICENSE +21 -0
- package/grammars/tree-sitter-groovy/README.md +4 -0
- package/grammars/tree-sitter-haskell/LICENSE +21 -0
- package/grammars/tree-sitter-haskell/README.md +394 -0
- package/grammars/tree-sitter-hcl/CHANGELOG.md +102 -0
- package/grammars/tree-sitter-hcl/LICENSE +201 -0
- package/grammars/tree-sitter-hcl/README.md +39 -0
- package/grammars/tree-sitter-html/LICENSE +21 -0
- package/grammars/tree-sitter-html/README.md +21 -0
- package/grammars/tree-sitter-julia/LICENSE +21 -0
- package/grammars/tree-sitter-julia/README.md +24 -0
- package/grammars/tree-sitter-kotlin/LICENSE +9 -0
- package/grammars/tree-sitter-lua/LICENSE.md +21 -0
- package/grammars/tree-sitter-lua/README.md +29 -0
- package/grammars/tree-sitter-make/LICENSE +21 -0
- package/grammars/tree-sitter-matlab/LICENSE +19 -0
- package/grammars/tree-sitter-matlab/README.md +99 -0
- package/grammars/tree-sitter-nim/LICENSE +373 -0
- package/grammars/tree-sitter-nim/README.md +46 -0
- package/grammars/tree-sitter-objc/LICENSE +21 -0
- package/grammars/tree-sitter-objc/README.md +20 -0
- package/grammars/tree-sitter-perl/LICENSE +7 -0
- package/grammars/tree-sitter-perl/README.md +100 -0
- package/grammars/tree-sitter-powershell/LICENSE +21 -0
- package/grammars/tree-sitter-powershell/README.md +7 -0
- package/grammars/tree-sitter-proto/LICENSE +21 -0
- package/grammars/tree-sitter-proto/README.md +105 -0
- package/grammars/tree-sitter-r/LICENSE +9 -0
- package/grammars/tree-sitter-r/README.md +66 -0
- package/grammars/tree-sitter-ruby/LICENSE +22 -0
- package/grammars/tree-sitter-scss/LICENSE +21 -0
- package/grammars/tree-sitter-scss/README.md +17 -0
- package/grammars/tree-sitter-solidity/LICENSE +7 -0
- package/grammars/tree-sitter-sql/LICENSE +21 -0
- package/grammars/tree-sitter-typescript/LICENSE +21 -0
- package/grammars/tree-sitter-vba/LICENSE +21 -0
- package/grammars/tree-sitter-vba/README.md +738 -0
- package/grammars/tree-sitter-vbnet/LICENSE +21 -0
- package/grammars/tree-sitter-vbnet/README.md +322 -0
- package/grammars/tree-sitter-xml/LICENSE +19 -0
- package/grammars/tree-sitter-yaml/LICENSE +20 -0
- package/grammars/tree-sitter-yaml/README.md +21 -0
- package/package.json +184 -4
- package/wasm/dart.wasm +0 -0
- package/wasm/tree-sitter-bash.wasm +0 -0
- package/wasm/tree-sitter-c.wasm +0 -0
- package/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/wasm/tree-sitter-clean.wasm +0 -0
- package/wasm/tree-sitter-clojure.wasm +0 -0
- package/wasm/tree-sitter-core_schema.wasm +0 -0
- package/wasm/tree-sitter-cpp.wasm +0 -0
- package/wasm/tree-sitter-css.wasm +0 -0
- package/wasm/tree-sitter-dart.wasm +0 -0
- package/wasm/tree-sitter-elisp.wasm +0 -0
- package/wasm/tree-sitter-elixir.wasm +0 -0
- package/wasm/tree-sitter-elm.wasm +0 -0
- package/wasm/tree-sitter-embedded_template.wasm +0 -0
- package/wasm/tree-sitter-erlang.wasm +0 -0
- package/wasm/tree-sitter-fsharp.wasm +0 -0
- package/wasm/tree-sitter-fsharp_signature.wasm +0 -0
- package/wasm/tree-sitter-go.wasm +0 -0
- package/wasm/tree-sitter-graphql.wasm +0 -0
- package/wasm/tree-sitter-groovy.wasm +0 -0
- package/wasm/tree-sitter-haskell.wasm +0 -0
- package/wasm/tree-sitter-hcl.wasm +0 -0
- package/wasm/tree-sitter-html.wasm +0 -0
- package/wasm/tree-sitter-java.wasm +0 -0
- package/wasm/tree-sitter-javascript.wasm +0 -0
- package/wasm/tree-sitter-json.wasm +0 -0
- package/wasm/tree-sitter-json_schema.wasm +0 -0
- package/wasm/tree-sitter-julia.wasm +0 -0
- package/wasm/tree-sitter-kotlin.wasm +0 -0
- package/wasm/tree-sitter-lua.wasm +0 -0
- package/wasm/tree-sitter-make.wasm +0 -0
- package/wasm/tree-sitter-markdown.wasm +0 -0
- package/wasm/tree-sitter-markdown_inline.wasm +0 -0
- package/wasm/tree-sitter-matlab.wasm +0 -0
- package/wasm/tree-sitter-nim.wasm +0 -0
- package/wasm/tree-sitter-objc.wasm +0 -0
- package/wasm/tree-sitter-ocaml.wasm +0 -0
- package/wasm/tree-sitter-perl.wasm +0 -0
- package/wasm/tree-sitter-php.wasm +0 -0
- package/wasm/tree-sitter-php_only.wasm +0 -0
- package/wasm/tree-sitter-powershell.wasm +0 -0
- package/wasm/tree-sitter-protobuf.wasm +0 -0
- package/wasm/tree-sitter-python.wasm +0 -0
- package/wasm/tree-sitter-ql.wasm +0 -0
- package/wasm/tree-sitter-r.wasm +0 -0
- package/wasm/tree-sitter-rescript.wasm +0 -0
- package/wasm/tree-sitter-ruby.wasm +0 -0
- package/wasm/tree-sitter-rust.wasm +0 -0
- package/wasm/tree-sitter-scala.wasm +0 -0
- package/wasm/tree-sitter-scss.wasm +0 -0
- package/wasm/tree-sitter-solidity.wasm +0 -0
- package/wasm/tree-sitter-sql.wasm +0 -0
- package/wasm/tree-sitter-svelte.wasm +0 -0
- package/wasm/tree-sitter-swift.wasm +0 -0
- package/wasm/tree-sitter-systemrdl.wasm +0 -0
- package/wasm/tree-sitter-terraform.wasm +0 -0
- package/wasm/tree-sitter-tlaplus.wasm +0 -0
- package/wasm/tree-sitter-toml.wasm +0 -0
- package/wasm/tree-sitter-tsx.wasm +0 -0
- package/wasm/tree-sitter-typescript.wasm +0 -0
- package/wasm/tree-sitter-vba.wasm +0 -0
- package/wasm/tree-sitter-vbnet.wasm +0 -0
- package/wasm/tree-sitter-vue.wasm +0 -0
- package/wasm/tree-sitter-xml.wasm +0 -0
- package/wasm/tree-sitter-yaml.wasm +0 -0
- package/wasm/tree-sitter-zig.wasm +0 -0
- package/wasm/tree-sitter.wasm +0 -0
- package/wasm/tsx.wasm +0 -0
- package/wasm/typescript.wasm +0 -0
|
@@ -0,0 +1,3528 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ASTEngine
|
|
3
|
+
} from "./chunk-3ZTOHHSW.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
|
+
};
|