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