diff --git a/.gitignore b/.gitignore index 0273508b5..1f4a89cac 100644 --- a/.gitignore +++ b/.gitignore @@ -85,7 +85,7 @@ call-chain-*.csv # tables, and the raw per-language relations it was built from. graph.sqlite graph.sqlite-journal -graph/ +csv/ raw/ client-ir/ library-ir/ @@ -227,7 +227,7 @@ $RECYCLE.BIN/ # checks the property beats a pattern list that tries to anticipate it. # ── Java engine test suite: per-run work dirs and the generated JDK index ───── -test/java/.work/ +graph/test/java/.work/ # Python bytecode from the test tools __pycache__/ @@ -236,5 +236,8 @@ __pycache__/ # fixture harness work directories (generated per run). The plain `.work/` is the one # run-tests.sh itself uses; the `.work-*` variants are the fixtures'. Only the latter was # listed, so every run of the TypeScript suite left an untracked directory behind. -test/typescript/.work/ -test/typescript/.work-*/ +graph/test/typescript/.work/ +graph/test/typescript/.work-*/ + +# the one-command pipeline keeps its IR and scratch here +.intermediate/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..208a83b48 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, Apache 2.0 Future License + +## Abbreviation + +FSL-1.1-Apache-2.0 + +## Notice + +Copyright 2026, AxiomCode Inc. + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/README.md b/README.md index 2a2e904c4..a9c1b476b 100644 --- a/README.md +++ b/README.md @@ -127,26 +127,36 @@ receiver is a lambda parameter. Restricted to files of 1,000 lines or more, d1 r ## Run ```bash -npm install && npm run build - -# 1. extract a relational IR from source (separate parser package) -node /dist/index.js false - -# 2. solve -bash src/pipeline/run-souffle.sh \ - --language java \ # java | typescript | python - --client-ir \ - --library [,...] \ # platform library + the project's real dependencies - --intermediate --output +npm install && npm run build # builds the parser and the engine (Node ≥ 22.5) + +bin/axiomcode all --src --out # source → //graph.sqlite, per language found +# --language java | typescript | python restrict to one (the parser emits every language it finds) +# --version V stamp the IR (default: the source's git commit, else v1.0.0) +# --exclude-tests leave test code out (default: included) +# --library [,...] the platform library and real dependencies, when you have their IR +# --debug also write csv/*.csv and keep raw/ + +bin/axiomcode parser # the two stages separately: +bin/axiomcode engine --language java --client-ir /java --out # IR is written per language, // +bin/axiomcode test [java|typescript|python|parser|all] ``` -**Outputs — the same in every language** ([`src/bundle/SCHEMA.md`](src/bundle/SCHEMA.md)) +`bin/axiomcode` is the whole pipeline as subcommands: the parser (`parser/`) extracts a relational IR +from the source — every language it finds, in one pass — the engine (`graph/`) solves each language +separately, and `//graph.sqlite` is the result (graphs are per language; a Java→TypeScript +call is not an edge in either). **No +Soufflé and no C++ compiler**: the rules compile to one self-contained executable, CI builds it for +Linux (x86_64, arm64), macOS (arm64) and Windows on every merge to `main` and commits it under +`binaries///`, so a checkout carries the engine for every platform. With `souffle` +installed the engine compiles locally instead. + +**Outputs — the same in every language** ([`graph/bundle/SCHEMA.md`](graph/bundle/SCHEMA.md)) ``` / graph.sqlite the contract: core tables, the language's ext_* relations, and the schema as tables and only with --debug: - graph/.csv the core tables as headered, tab-delimited text + csv/
.csv the core tables as headered, tab-delimited text raw/ the per-language Soufflé relations, verbatim — engine-internal, not a contract ``` @@ -191,16 +201,37 @@ the omission is reported. ## Layout ``` -src/engine/projections/ IR → typed relations -src/engine/containment/ ownership, type nesting -src/engine/resolution/ type resolution, hierarchy, generics, virtual dispatch -src/engine/expression-resolution/ call sites, callee resolution, overloads, lambdas -src/engine/call-edge-generation/ call classes, chain edges, lambda dispatch -src/souffle/ relation declarations + export manifest -src/pipeline/run-souffle.sh fact staging, compile cache, stage↔solve loop, then the bundle stage -src/bundle/ the output contract: schema as data, per-language adapters, CSV + SQLite writers +bin/axiomcode parser | engine | all | test — the pipeline as subcommands +parser/ the IR extractor (its own package; merged in with history) +graph/ the engine + /engine/projections/ IR → typed relations + /engine/containment/ ownership, type nesting + /engine/resolution/ type resolution, hierarchy, generics, virtual dispatch + /engine/expression-resolution/ call sites, callee resolution, overloads, lambdas + /engine/call-edge-generation/ call classes, chain edges, lambda dispatch + /souffle/ relation declarations + export manifest + /templates/ staging maps + pipeline/run-souffle.sh fact staging, engine resolution (committed / compiled / fetched), stage↔solve loop, then the bundle stage + bundle/ the output contract: schema as data (SCHEMA.md), per-language adapters, writers + test// the engine's regression suites, torture harnesses, oracles (graph/test/tools: platform preflights) +binaries/// CI-built engines, committed on merge (ENGINE_ID = the rules they were built from) +``` + +## Tests + +```bash +bin/axiomcode test java # the engine's Java suite; --oracle also scores against javac/javap ground truth +bin/axiomcode test typescript # --oracle scores against the TypeScript compiler +bin/axiomcode test python # --oracle scores against CPython bytecode and tracing +bin/axiomcode test parser # the parser's own suites (bin/axiomcode test = everything) ``` +Each suite parses its fixture cases with the parser in this repository (`AXIOM_PARSER` overrides), +solves them, guards that no call site was dropped, and diffs the normalised edges against a +golden. `--keep` retains the per-case work directories (`graph/test//.work//out/graph.sqlite` +is a real bundle to poke at); `--bless` regenerates goldens — review the diff. The torture +harnesses (`graph/test//torture/`) and the TypeScript corpus runner score real projects. + ## Known limits Stated because a graph you can't trust the boundaries of isn't useful: @@ -214,3 +245,7 @@ Stated because a graph you can't trust the boundaries of isn't useful: * **Function values in parameters or collections** are not tracked (fields and locals are). * **Reflection** is out of scope by construction, and is reported as `ambiguous_unknown` rather than silently omitted. + +## License + +[Functional Source License 1.1, Apache 2.0 Future License](LICENSE.md) (FSL-1.1-Apache-2.0) — Copyright 2026, AxiomCode Inc. diff --git a/bin/axiomcode b/bin/axiomcode new file mode 100755 index 000000000..622f28eb7 --- /dev/null +++ b/bin/axiomcode @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# axiomcode — one command for any repository: source tree in, graph.sqlite out. +# +# bin/axiomcode [--library [,…]] [--exclude-tests] [--version V] +# [--language L] [--debug] [engine options] +# +# It parses every language it finds (Java, TypeScript, Python — JavaScript and C# as they +# land), solves each with its engine, and leaves //graph.sqlite per language. +# Nothing about the repository has to be known in advance. Graphs are per language: a +# Java→TypeScript call is not an edge in either. +# +# --library the platform library and real dependencies, as SOURCE TREES or as IR +# (an IR root, e.g. a JDK IR; a source tree is parsed for you, once, under +# /.intermediate/lib/). Libraries are the type oracle: without them, +# calls into dependencies are declared unknown rather than guessed. +# --version V the version the IR is stamped with. Default: the source's git commit; v1.0.0 +# when it is not a checkout. Recorded in graph.sqlite (run.source_version). +# --exclude-tests leave test code out (default: included). +# --language L restrict to one language. +# --debug also write csv/*.csv and keep raw/ next to each graph.sqlite. +# Engine options: --dispatch-cap N|off --lib-depth N --jdk-depth N --taint on|off +# +# The pieces, when you want them separately: +# bin/axiomcode parser [--version V] [--exclude-tests] → // +# bin/axiomcode engine --language L --client-ir / --out [options] +# bin/axiomcode test [java|typescript|python|parser|all] [suite options] +# +# Requires Node ≥ 22.5; no Soufflé or compiler (binaries/, or a local souffle if present). +# ───────────────────────────────────────────────────────────────────────────── +set -eu +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" +usage(){ sed -n '3,30p' "$0" | sed 's/^# \{0,1\}//'; } +die(){ echo "axiomcode: $*" >&2; exit 2; } +need_parser(){ [ -f "$PARSER" ] || { echo "axiomcode: parser not built at $PARSER — run: npm install && npm run build" >&2; exit 1; }; } + +cmd="${1:-}" +case "$cmd" in parser|engine|all|test|-h|--help|help|"") [ $# -gt 0 ] && shift;; *) cmd=all;; esac +case "$cmd" in + parser) + need_parser + src="${1:-}"; ir="${2:-}"; [ -n "$src" ] && [ -n "$ir" ] || die "usage: axiomcode parser [--version V] [--exclude-tests]" + shift 2; version=""; exclude=false + while [ $# -gt 0 ]; do case "$1" in + --version) version="$2"; shift 2;; --exclude-tests) exclude=true; shift;; + --slug) version="$2"; shift 2;; # the old name for the same thing + *) die "parser: unknown option $1";; esac; done + [ -d "$src" ] || die "not a directory: $src" + # The version stamps every IR row (serviceVersionLink): the commit of the source when it is a + # git checkout, so a graph can always be traced to what it was built from; v1.0.0 otherwise. + [ -n "$version" ] || version="$(git -C "$src" rev-parse HEAD 2>/dev/null || echo v1.0.0)" + mkdir -p "$ir" + echo "▶ parser: version=$version exclude-tests=$exclude" + # --per-language: the parser writes // itself, one folder per language that had + # a project (the config tables go with java/, whose rules are their only reader). + node "$PARSER" "$src" "$version" "$exclude" "$ir" --per-language + found=(); for d in "$ir"/*/; do [ -d "$d" ] && found+=("$(basename "$d")"); done + [ ${#found[@]} -gt 0 ] || { echo "❌ the parser found no Java, TypeScript or Python source under $src" >&2; exit 1; } + echo "▶ IR: ${found[*]} → $ir//" + ;; + engine) + # every option is the engine's own; only --out is spelled for symmetry with `all` + args=() + while [ $# -gt 0 ]; do case "$1" in --out) args+=(--output "$2"); shift 2;; *) args+=("$1"); shift;; esac; done + has(){ for a in ${args[@]+"${args[@]}"}; do [ "$a" = "$1" ] && return 0; done; return 1; } + has --language && has --client-ir && has --output || die "usage: axiomcode engine --language L --client-ir --out [options]" + has --intermediate || { out=""; for i in "${!args[@]}"; do [ "${args[$i]}" = --output ] && out="${args[$((i+1))]}"; done; args+=(--intermediate "$out/.intermediate"); } + has --library || args+=(--library "") + bash "$ROOT/graph/pipeline/run-souffle.sh" "${args[@]}" + ;; + all) + need_parser + lang=""; src=""; out=""; version=""; libs=""; popts=(); rest=(); pos=() + while [ $# -gt 0 ]; do case "$1" in + --language) lang="$2"; shift 2;; --src) src="$2"; shift 2;; --out) out="$2"; shift 2;; + --library) libs="$2"; shift 2;; + --version|--slug) version="$2"; shift 2;; --exclude-tests) popts+=(--exclude-tests); shift;; + --intermediate|--dispatch-cap|--lib-depth|--jdk-depth|--taint) rest+=("$1" "$2"); shift 2;; + --debug) rest+=(--debug); shift;; + --*) die "unknown option $1 (try --help)";; + *) pos+=("$1"); shift;; esac; done + [ -n "$src" ] || src="${pos[0]:-}"; [ -n "$out" ] || out="${pos[1]:-}" + [ -n "$src" ] && [ -n "$out" ] || die "usage: axiomcode [--library [,…]] [options] (try --help)" + [ -d "$src" ] || die "not a directory: $src" + [ -z "$lang" ] || [ -d "$ROOT/graph/$lang" ] || die "no engine for --language=$lang (have: $(ls -d "$ROOT"/graph/*/engine 2>/dev/null | sed 's#.*/graph/##; s#/engine##' | tr '\n' ' '))" + [ -n "$version" ] || version="$(git -C "$src" rev-parse HEAD 2>/dev/null || echo v1.0.0)" + popts+=(--version "$version") + mkdir -p "$out"; out="$(cd "$out" && pwd)"; int="$out/.intermediate"; ir="$int/ir" + rm -rf "$ir"; mkdir -p "$ir" + # Libraries: each --library entry is either an IR root already (it holds a language folder + # or the flat tables themselves, or module sub-folders that do) or a source tree, which is + # parsed into /.intermediate/lib// so it can be passed as an IR root. + # An IR root holds a marker table directly (flat IR), or one level down (per-language IR + # from `parser`, or a JDK-style root of module folders). Folder NAMES prove nothing — a + # source tree may well have a java/ directory. + is_ir(){ local d="$1" m sub + for m in all-types.csv all-typescript-modules.csv all-python-modules.csv all-javascript-modules.csv; do + [ -f "$d/$m" ] && return 0 + for sub in "$d"/*/; do [ -f "$sub$m" ] && return 0; done + done; return 1; } + libroots="" + IFS=',' read -ra libentries <<< "$libs" + for L in ${libentries[@]+"${libentries[@]}"}; do + [ -n "$L" ] || continue; [ -d "$L" ] || die "--library: not a directory: $L" + if is_ir "$L"; then libroots="$libroots,$L" + else + n="$(basename "$(cd "$L" && pwd)")"; lir="$int/lib/$n" + echo "▶ parsing library $L → $lir" + "$0" parser "$L" "$lir" --version "$(git -C "$L" rev-parse HEAD 2>/dev/null || echo v1.0.0)" > "$int/parse-lib-$n.log" 2>&1 \ + || { echo "❌ parsing library $L failed — see $int/parse-lib-$n.log" >&2; exit 1; } + libroots="$libroots,$lir" + fi + done + libroots="${libroots#,}" + echo "▶ parsing $src → IR" + "$0" parser "$src" "$ir" ${popts[@]+"${popts[@]}"} > "$int/parse.log" 2>&1 || { echo "❌ parser failed — see $int/parse.log" >&2; tail -5 "$int/parse.log" >&2; exit 1; } + head -1 "$int/parse.log" + # which languages the IR holds — one folder each — and which of them have an engine + found=(); for d in "$ir"/*/; do l="$(basename "$d")"; [ -d "$ROOT/graph/$l/engine" ] && found+=("$l"); done + [ ${#found[@]} -gt 0 ] || { echo "❌ no language with an engine in the IR (folders: $(ls "$ir" | tr '\n' ' '))" >&2; exit 1; } + langs=("${found[@]}") + if [ -n "$lang" ]; then + case " ${found[*]} " in *" $lang "*) langs=("$lang");; *) echo "❌ --language=$lang, but the IR holds only: ${found[*]}" >&2; exit 1;; esac + fi + echo "▶ languages: ${langs[*]}" + for l in "${langs[@]}"; do + echo "▶ solving $l → $out/$l/graph.sqlite" + "$0" engine --language "$l" --client-ir "$ir/$l" --out "$out/$l" --intermediate "$int" --library "$libroots" \ + --meta "source_version=$version" --meta "source_dir=$(cd "$src" && pwd)" ${rest[@]+"${rest[@]}"} + done + for l in "${langs[@]}"; do echo "$out/$l/graph.sqlite"; done + ;; + test) + which="${1:-all}"; [ $# -gt 0 ] && shift + rc=0 + run_suite(){ local s="$1"; shift; echo "══ $s"; bash "$ROOT/graph/test/$s/run-tests.sh" "$@" || rc=1; } + case "$which" in + java|typescript|python) run_suite "$which" "$@";; + parser) for t in java-extractor-tests typescript-tests python-tests javascript-tests gradle-tests services-tests; do + [ -f "$ROOT/parser/src/test/$t.ts" ] || continue; echo "══ parser: $t"; (cd "$ROOT/parser" && npx tsx "src/test/$t.ts") || rc=1; done;; + all) for l in java typescript python; do run_suite "$l" "$@"; done; "$0" test parser || rc=1;; + *) die "test: unknown suite $which (java | typescript | python | parser | all)";; + esac + exit $rc + ;; + -h|--help|help|"") usage; [ -n "$cmd" ] || exit 2;; + *) die "unknown command '$cmd' (parser | engine | all | test; --help)";; +esac diff --git a/src/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md similarity index 97% rename from src/bundle/SCHEMA.md rename to graph/bundle/SCHEMA.md index 7a1ab5880..557f0caba 100644 --- a/src/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -1,6 +1,6 @@ # The output bundle — schema -_Generated from `src/bundle/schema.ts` (schema version 1). Do not edit; run `npm run schema-doc`._ +_Generated from `graph/bundle/schema.ts` (schema version 1). Do not edit; run `npm run schema-doc`._ Every run, in every language, writes the same thing: @@ -8,7 +8,7 @@ Every run, in every language, writes the same thing: / graph.sqlite the contract — the tables below, the ext_* tables, and this document as tables and only with --debug: - graph/
.csv the core tables as headered, tab-delimited text (RFC 4180 quoting) + csv/
.csv the core tables as headered, tab-delimited text (RFC 4180 quoting) raw/ the per-language Soufflé relations, verbatim. Engine-internal; not a contract. ``` @@ -176,6 +176,8 @@ What produced this bundle: one key/value row per fact about the run (language, e | `solve_seconds` | all | Wall-clock seconds of staging + solving, before the bundle stage. | | `created_at` | all | ISO-8601 timestamp of the bundle. | | `raw_dir` | all | Where the per-language Soufflé relations were read from (`raw/` next to the bundle). | +| `source_version` | all | The version the IR was stamped with (bin/axiomcode): the git commit of the analysed source, or v1.0.0 when it was not a checkout. Present when the run went through bin/axiomcode all. | +| `source_dir` | all | The source directory that was parsed. Present when the run went through bin/axiomcode all. | ### `methods` @@ -531,7 +533,7 @@ Types this run creates an instance of — the rapid-type-analysis set that bound ## Extended tables — `ext_` -Every relation in the language's `src//souffle/export_manifest.tsv`, loaded as `ext_` with positional columns `c0…cN` (the raw relation is declared positionally; nothing here invents a name). `schema_tables` lists each one with its arity and the comment lifted from the rule that derives it — read that before querying. They are language-specific by construction: a bundle holds only the ext tables of its own language. +Every relation in the language's `graph//souffle/export_manifest.tsv`, loaded as `ext_` with positional columns `c0…cN` (the raw relation is declared positionally; nothing here invents a name). `schema_tables` lists each one with its arity and the comment lifted from the rule that derives it — read that before querying. They are language-specific by construction: a bundle holds only the ext tables of its own language. ## Catalog tables diff --git a/src/bundle/build.ts b/graph/bundle/build.ts similarity index 100% rename from src/bundle/build.ts rename to graph/bundle/build.ts diff --git a/src/bundle/catalog.ts b/graph/bundle/catalog.ts similarity index 100% rename from src/bundle/catalog.ts rename to graph/bundle/catalog.ts diff --git a/src/bundle/cli.ts b/graph/bundle/cli.ts similarity index 94% rename from src/bundle/cli.ts rename to graph/bundle/cli.ts index 291cda369..adb2061c3 100644 --- a/src/bundle/cli.ts +++ b/graph/bundle/cli.ts @@ -5,7 +5,7 @@ * [--library ROOT,ROOT…] [--lib-facts DIR] [--meta key=value]… * * Reads the per-language Soufflé dump in --raw and the parser IR, and writes the - * language-neutral bundle: /graph/*.csv and /graph.sqlite. `--print-schema` + * language-neutral bundle: /graph.sqlite (and /csv/*.csv with --debug). `--print-schema` * renders SCHEMA.md to stdout instead. */ import * as fs from 'fs'; @@ -83,7 +83,7 @@ export async function main(argv = process.argv.slice(2)): Promise { // The one case where they are written anyway is the one where they are not redundant: // an older Node has no `node:sqlite`, so without them the run would produce no // consumer-facing output at all. - const graphDir = path.join(a.out!, 'graph'); + const graphDir = path.join(a.out!, 'csv'); const haveSqlite = sqliteAvailable(); const wantCsv = a.debug || !haveSqlite; fs.rmSync(graphDir, { recursive: true, force: true }); // owned: no table from an earlier run survives @@ -92,10 +92,10 @@ export async function main(argv = process.argv.slice(2)): Promise { const dbPath = path.join(a.out!, 'graph.sqlite'); if (haveSqlite) { await writeSqlite({ dbPath, language: adapter.language, core, ext: catalogExt(langDir), rawDir: a.raw!, log }); - log(`▶ wrote ${dbPath}${a.debug ? ' (+ graph/*.csv, --debug)' : ''}`); + log(`▶ wrote ${dbPath}${a.debug ? ' (+ csv/*.csv, --debug)' : ''}`); } else { fs.rmSync(dbPath, { force: true }); - console.error(` ! node ${process.versions.node} has no node:sqlite (needs ≥ 22.5) — graph.sqlite NOT written; graph/*.csv is complete`); + console.error(` ! node ${process.versions.node} has no node:sqlite (needs ≥ 22.5) — graph.sqlite NOT written; csv/*.csv is complete`); } log(`▶ bundle complete in ${((Date.now() - t0) / 1000).toFixed(1)}s`); } diff --git a/src/bundle/csv.ts b/graph/bundle/csv.ts similarity index 100% rename from src/bundle/csv.ts rename to graph/bundle/csv.ts diff --git a/src/bundle/languages.ts b/graph/bundle/languages.ts similarity index 100% rename from src/bundle/languages.ts rename to graph/bundle/languages.ts diff --git a/src/bundle/node-sqlite.d.ts b/graph/bundle/node-sqlite.d.ts similarity index 100% rename from src/bundle/node-sqlite.d.ts rename to graph/bundle/node-sqlite.d.ts diff --git a/src/bundle/schema.ts b/graph/bundle/schema.ts similarity index 98% rename from src/bundle/schema.ts rename to graph/bundle/schema.ts index 2507b4e74..130a0a2ec 100644 --- a/src/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -8,7 +8,7 @@ * time, from the database it is querying) and rendered to SCHEMA.md (so a human can read * it without a database). Neither can drift from the other because neither is authored. * - * Values here are the ones the RULES emit (`grep`-able as string literals in src// + * Values here are the ones the RULES emit (`grep`-able as string literals in graph// * engine) or the parser's own enums (its fact-schema documents). Do not add a value you * cannot point at. */ @@ -266,6 +266,8 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'run', column: 'key', value: 'solve_seconds', languages: 'all', meaning: 'Wall-clock seconds of staging + solving, before the bundle stage.' }, { table: 'run', column: 'key', value: 'created_at', languages: 'all', meaning: 'ISO-8601 timestamp of the bundle.' }, { table: 'run', column: 'key', value: 'raw_dir', languages: 'all', meaning: 'Where the per-language Soufflé relations were read from (`raw/` next to the bundle).' }, + { table: 'run', column: 'key', value: 'source_version', languages: 'all', meaning: 'The version the IR was stamped with (bin/axiomcode): the git commit of the analysed source, or v1.0.0 when it was not a checkout. Present when the run went through bin/axiomcode all.' }, + { table: 'run', column: 'key', value: 'source_dir', languages: 'all', meaning: 'The source directory that was parsed. Present when the run went through bin/axiomcode all.' }, // provenance (methods, types) { table: 'methods', column: 'provenance', value: 'client', languages: 'all', meaning: 'Declared in the analysed project.' }, @@ -590,7 +592,7 @@ export function renderSchemaMarkdown(): string { const out: string[] = []; out.push('# The output bundle — schema'); out.push(''); - out.push(`_Generated from \`src/bundle/schema.ts\` (schema version ${SCHEMA_VERSION}). Do not edit; run \`npm run schema-doc\`._`); + out.push(`_Generated from \`graph/bundle/schema.ts\` (schema version ${SCHEMA_VERSION}). Do not edit; run \`npm run schema-doc\`._`); out.push(''); out.push('Every run, in every language, writes the same thing:'); out.push(''); @@ -598,7 +600,7 @@ export function renderSchemaMarkdown(): string { out.push('/'); out.push(' graph.sqlite the contract — the tables below, the ext_* tables, and this document as tables'); out.push('and only with --debug:'); - out.push(' graph/
.csv the core tables as headered, tab-delimited text (RFC 4180 quoting)'); + out.push(' csv/
.csv the core tables as headered, tab-delimited text (RFC 4180 quoting)'); out.push(' raw/ the per-language Soufflé relations, verbatim. Engine-internal; not a contract.'); out.push('```'); out.push(''); @@ -663,7 +665,7 @@ export function renderSchemaMarkdown(): string { } out.push('## Extended tables — `ext_`'); out.push(''); - out.push('Every relation in the language\'s `src//souffle/export_manifest.tsv`, loaded as `ext_` with positional columns `c0…cN` (the raw relation is declared positionally; nothing here invents a name). `schema_tables` lists each one with its arity and the comment lifted from the rule that derives it — read that before querying. They are language-specific by construction: a bundle holds only the ext tables of its own language.'); + out.push('Every relation in the language\'s `graph//souffle/export_manifest.tsv`, loaded as `ext_` with positional columns `c0…cN` (the raw relation is declared positionally; nothing here invents a name). `schema_tables` lists each one with its arity and the comment lifted from the rule that derives it — read that before querying. They are language-specific by construction: a bundle holds only the ext tables of its own language.'); out.push(''); out.push('## Catalog tables'); out.push(''); diff --git a/src/bundle/write.ts b/graph/bundle/write.ts similarity index 98% rename from src/bundle/write.ts rename to graph/bundle/write.ts index a4b3e80d1..3faa6fc6c 100644 --- a/src/bundle/write.ts +++ b/graph/bundle/write.ts @@ -16,7 +16,7 @@ export function writeCoreCsv(graphDir: string, core: CoreTables, log: (s: string for (const t of CORE_TABLES) { const rows = (core as unknown as Record)[t.name] ?? []; const n = writeTable(path.join(graphDir, `${t.name}.csv`), t.columns.map((c) => c.name), rows); - log(` graph/${t.name}.csv: ${n} rows`); + log(` csv/${t.name}.csv: ${n} rows`); } } @@ -103,7 +103,7 @@ export async function writeSqlite(inp: SqliteInputs): Promise { const k = `${table}\t${column}\t${inp.language}\t${val}`; if (authored.has(k)) continue; if (prefixes.some((p) => p.key === `${table}\t${column}` && val.startsWith(p.prefix))) continue; - voc.run(table, column, inp.language, val, 'undocumented — observed in this run; not in the authored vocabulary (src/bundle/schema.ts)'); + voc.run(table, column, inp.language, val, 'undocumented — observed in this run; not in the authored vocabulary (graph/bundle/schema.ts)'); inp.log(` ! undocumented ${table}.${column} value: ${val}`); } }; diff --git a/src/constants/paths.ts b/graph/constants/paths.ts similarity index 75% rename from src/constants/paths.ts rename to graph/constants/paths.ts index 923f3c466..6651d5021 100644 --- a/src/constants/paths.ts +++ b/graph/constants/paths.ts @@ -1,15 +1,15 @@ import * as path from 'path'; -// src/constants/ → src/ → package root. At runtime we run from dist/, so resolve -// the package root and read the (always-present) src//templates from there. +// graph/constants/ → graph/ → package root. At runtime we run from dist/, so resolve +// the package root and read the (always-present) graph//templates from there. export const PACKAGE_ROOT = path.resolve(__dirname, '..', '..'); /** * Import-rule templates — the relation→CSV import map, parsed by run-souffle.sh. - * Per-language: the rule sets live under src//, so the maps do too. + * Per-language: the rule sets live under graph//, so the maps do too. */ export const templatesDir = (language = 'java'): string => - path.join(PACKAGE_ROOT, 'src', language, 'templates'); + path.join(PACKAGE_ROOT, 'graph', language, 'templates'); /** Java's import maps — the default rule set. */ export const TEMPLATES_DIR = templatesDir('java'); @@ -20,7 +20,7 @@ export const TEMPLATES_DIR = templatesDir('java'); * to a native binary (cached by checksum), and solves. Self-contained — the sole * reasoning entry point. */ -export const RUN_SOUFFLE_SH = path.join(PACKAGE_ROOT, 'src', 'pipeline', 'run-souffle.sh'); +export const RUN_SOUFFLE_SH = path.join(PACKAGE_ROOT, 'graph', 'pipeline', 'run-souffle.sh'); /** Marker that identifies a library IR module folder (and a client IR dir). */ export const IR_MARKER = 'all-types.csv'; diff --git a/src/constants/schema.ts b/graph/constants/schema.ts similarity index 98% rename from src/constants/schema.ts rename to graph/constants/schema.ts index 9e4497f87..dae17bb5a 100644 --- a/src/constants/schema.ts +++ b/graph/constants/schema.ts @@ -6,7 +6,7 @@ * * Counts mirror the parser's output, and are PER-LANGUAGE: each language's parser * emits its own entity set with its own arities (Java's `all-*.csv`, Python's - * `all-python-*.csv`), matching the per-language rule sets under `src//`. + * `all-python-*.csv`), matching the per-language rule sets under `graph//`. */ export const JAVA_ENTITY_COLUMNS: Record = { 'all-types.csv': 14, @@ -121,7 +121,7 @@ export const PYTHON_CLIENT_REQUIRED_ENTITIES = [ /** * TypeScript's minimum, by the same presence rule: every file the parser accepts yields a * module row and that module's MODULE_INITIALIZER method, so these two blocks exist for any - * valid project. Mirrors IR_MARKER in src/typescript/templates/staging.conf. + * valid project. Mirrors IR_MARKER in graph/typescript/templates/staging.conf. */ export const TYPESCRIPT_CLIENT_REQUIRED_ENTITIES = [ 'all-typescript-modules.csv', diff --git a/src/index.ts b/graph/index.ts similarity index 88% rename from src/index.ts rename to graph/index.ts index ac0b38c63..9d5739981 100644 --- a/src/index.ts +++ b/graph/index.ts @@ -4,8 +4,8 @@ * * node dist/index.js --language=L --client-ir=DIR --library=DIR --intermediate=DIR --output=DIR [--debug] * - * --language selects the rule set under src// (java, typescript, python; default java). - * The output layout is the same for every language — see src/bundle/SCHEMA.md. + * --language selects the rule set under graph// (java, typescript, python; default java). + * The output layout is the same for every language — see graph/bundle/SCHEMA.md. * * Throws (non-zero exit) on any failure so the agent sees it. */ diff --git a/src/java/engine/call-edge-generation/annotation_flow.dl b/graph/java/engine/call-edge-generation/annotation_flow.dl similarity index 100% rename from src/java/engine/call-edge-generation/annotation_flow.dl rename to graph/java/engine/call-edge-generation/annotation_flow.dl diff --git a/src/java/engine/call-edge-generation/call_chain.dl b/graph/java/engine/call-edge-generation/call_chain.dl similarity index 100% rename from src/java/engine/call-edge-generation/call_chain.dl rename to graph/java/engine/call-edge-generation/call_chain.dl diff --git a/src/java/engine/call-edge-generation/calls.dl b/graph/java/engine/call-edge-generation/calls.dl similarity index 100% rename from src/java/engine/call-edge-generation/calls.dl rename to graph/java/engine/call-edge-generation/calls.dl diff --git a/src/java/engine/call-edge-generation/lambda_dispatch.dl b/graph/java/engine/call-edge-generation/lambda_dispatch.dl similarity index 100% rename from src/java/engine/call-edge-generation/lambda_dispatch.dl rename to graph/java/engine/call-edge-generation/lambda_dispatch.dl diff --git a/src/java/engine/config-resolution/annotation-args.dl b/graph/java/engine/config-resolution/annotation-args.dl similarity index 100% rename from src/java/engine/config-resolution/annotation-args.dl rename to graph/java/engine/config-resolution/annotation-args.dl diff --git a/src/java/engine/config-resolution/config-keys.dl b/graph/java/engine/config-resolution/config-keys.dl similarity index 100% rename from src/java/engine/config-resolution/config-keys.dl rename to graph/java/engine/config-resolution/config-keys.dl diff --git a/src/java/engine/config-resolution/di.dl b/graph/java/engine/config-resolution/di.dl similarity index 100% rename from src/java/engine/config-resolution/di.dl rename to graph/java/engine/config-resolution/di.dl diff --git a/src/java/engine/config-resolution/entry-points.dl b/graph/java/engine/config-resolution/entry-points.dl similarity index 100% rename from src/java/engine/config-resolution/entry-points.dl rename to graph/java/engine/config-resolution/entry-points.dl diff --git a/src/java/engine/config-resolution/impact.dl b/graph/java/engine/config-resolution/impact.dl similarity index 100% rename from src/java/engine/config-resolution/impact.dl rename to graph/java/engine/config-resolution/impact.dl diff --git a/src/java/engine/config-resolution/knobs.dl b/graph/java/engine/config-resolution/knobs.dl similarity index 100% rename from src/java/engine/config-resolution/knobs.dl rename to graph/java/engine/config-resolution/knobs.dl diff --git a/src/java/engine/config-resolution/services.dl b/graph/java/engine/config-resolution/services.dl similarity index 100% rename from src/java/engine/config-resolution/services.dl rename to graph/java/engine/config-resolution/services.dl diff --git a/src/java/engine/config-resolution/value-interpretation.dl b/graph/java/engine/config-resolution/value-interpretation.dl similarity index 100% rename from src/java/engine/config-resolution/value-interpretation.dl rename to graph/java/engine/config-resolution/value-interpretation.dl diff --git a/src/java/engine/config-resolution/xml-wiring.dl b/graph/java/engine/config-resolution/xml-wiring.dl similarity index 100% rename from src/java/engine/config-resolution/xml-wiring.dl rename to graph/java/engine/config-resolution/xml-wiring.dl diff --git a/src/java/engine/containment/ownership.dl b/graph/java/engine/containment/ownership.dl similarity index 100% rename from src/java/engine/containment/ownership.dl rename to graph/java/engine/containment/ownership.dl diff --git a/src/java/engine/containment/type-nesting.dl b/graph/java/engine/containment/type-nesting.dl similarity index 100% rename from src/java/engine/containment/type-nesting.dl rename to graph/java/engine/containment/type-nesting.dl diff --git a/src/java/engine/export/call-edges.dl b/graph/java/engine/export/call-edges.dl similarity index 100% rename from src/java/engine/export/call-edges.dl rename to graph/java/engine/export/call-edges.dl diff --git a/src/java/engine/export/containment.dl b/graph/java/engine/export/containment.dl similarity index 100% rename from src/java/engine/export/containment.dl rename to graph/java/engine/export/containment.dl diff --git a/src/java/engine/export/expression-resolution.dl b/graph/java/engine/export/expression-resolution.dl similarity index 100% rename from src/java/engine/export/expression-resolution.dl rename to graph/java/engine/export/expression-resolution.dl diff --git a/src/java/engine/export/resolution.dl b/graph/java/engine/export/resolution.dl similarity index 100% rename from src/java/engine/export/resolution.dl rename to graph/java/engine/export/resolution.dl diff --git a/src/java/engine/expression-resolution/call-site.dl b/graph/java/engine/expression-resolution/call-site.dl similarity index 100% rename from src/java/engine/expression-resolution/call-site.dl rename to graph/java/engine/expression-resolution/call-site.dl diff --git a/src/java/engine/expression-resolution/callee-resolution.dl b/graph/java/engine/expression-resolution/callee-resolution.dl similarity index 100% rename from src/java/engine/expression-resolution/callee-resolution.dl rename to graph/java/engine/expression-resolution/callee-resolution.dl diff --git a/src/java/engine/expression-resolution/expr-type.dl b/graph/java/engine/expression-resolution/expr-type.dl similarity index 100% rename from src/java/engine/expression-resolution/expr-type.dl rename to graph/java/engine/expression-resolution/expr-type.dl diff --git a/src/java/engine/expression-resolution/lambda.dl b/graph/java/engine/expression-resolution/lambda.dl similarity index 100% rename from src/java/engine/expression-resolution/lambda.dl rename to graph/java/engine/expression-resolution/lambda.dl diff --git a/src/java/engine/expression-resolution/overload.dl b/graph/java/engine/expression-resolution/overload.dl similarity index 100% rename from src/java/engine/expression-resolution/overload.dl rename to graph/java/engine/expression-resolution/overload.dl diff --git a/src/java/engine/projections/annotations.dl b/graph/java/engine/projections/annotations.dl similarity index 100% rename from src/java/engine/projections/annotations.dl rename to graph/java/engine/projections/annotations.dl diff --git a/src/java/engine/projections/blocks.dl b/graph/java/engine/projections/blocks.dl similarity index 100% rename from src/java/engine/projections/blocks.dl rename to graph/java/engine/projections/blocks.dl diff --git a/src/java/engine/projections/config-properties.dl b/graph/java/engine/projections/config-properties.dl similarity index 100% rename from src/java/engine/projections/config-properties.dl rename to graph/java/engine/projections/config-properties.dl diff --git a/src/java/engine/projections/config-services.dl b/graph/java/engine/projections/config-services.dl similarity index 100% rename from src/java/engine/projections/config-services.dl rename to graph/java/engine/projections/config-services.dl diff --git a/src/java/engine/projections/config-xml.dl b/graph/java/engine/projections/config-xml.dl similarity index 100% rename from src/java/engine/projections/config-xml.dl rename to graph/java/engine/projections/config-xml.dl diff --git a/src/java/engine/projections/config-yaml.dl b/graph/java/engine/projections/config-yaml.dl similarity index 100% rename from src/java/engine/projections/config-yaml.dl rename to graph/java/engine/projections/config-yaml.dl diff --git a/src/java/engine/projections/enums.dl b/graph/java/engine/projections/enums.dl similarity index 100% rename from src/java/engine/projections/enums.dl rename to graph/java/engine/projections/enums.dl diff --git a/src/java/engine/projections/expressions.dl b/graph/java/engine/projections/expressions.dl similarity index 100% rename from src/java/engine/projections/expressions.dl rename to graph/java/engine/projections/expressions.dl diff --git a/src/java/engine/projections/fields.dl b/graph/java/engine/projections/fields.dl similarity index 100% rename from src/java/engine/projections/fields.dl rename to graph/java/engine/projections/fields.dl diff --git a/src/java/engine/projections/imports.dl b/graph/java/engine/projections/imports.dl similarity index 100% rename from src/java/engine/projections/imports.dl rename to graph/java/engine/projections/imports.dl diff --git a/src/java/engine/projections/locals.dl b/graph/java/engine/projections/locals.dl similarity index 100% rename from src/java/engine/projections/locals.dl rename to graph/java/engine/projections/locals.dl diff --git a/src/java/engine/projections/methods.dl b/graph/java/engine/projections/methods.dl similarity index 100% rename from src/java/engine/projections/methods.dl rename to graph/java/engine/projections/methods.dl diff --git a/src/java/engine/projections/types.dl b/graph/java/engine/projections/types.dl similarity index 100% rename from src/java/engine/projections/types.dl rename to graph/java/engine/projections/types.dl diff --git a/src/java/engine/resolution/cast-pattern-flow.dl b/graph/java/engine/resolution/cast-pattern-flow.dl similarity index 100% rename from src/java/engine/resolution/cast-pattern-flow.dl rename to graph/java/engine/resolution/cast-pattern-flow.dl diff --git a/src/java/engine/resolution/dispatch-cap.dl b/graph/java/engine/resolution/dispatch-cap.dl similarity index 100% rename from src/java/engine/resolution/dispatch-cap.dl rename to graph/java/engine/resolution/dispatch-cap.dl diff --git a/src/java/engine/resolution/generic-chain.dl b/graph/java/engine/resolution/generic-chain.dl similarity index 100% rename from src/java/engine/resolution/generic-chain.dl rename to graph/java/engine/resolution/generic-chain.dl diff --git a/src/java/engine/resolution/instantiation.dl b/graph/java/engine/resolution/instantiation.dl similarity index 100% rename from src/java/engine/resolution/instantiation.dl rename to graph/java/engine/resolution/instantiation.dl diff --git a/src/java/engine/resolution/lib-hierarchy.dl b/graph/java/engine/resolution/lib-hierarchy.dl similarity index 100% rename from src/java/engine/resolution/lib-hierarchy.dl rename to graph/java/engine/resolution/lib-hierarchy.dl diff --git a/src/java/engine/resolution/local-flow.dl b/graph/java/engine/resolution/local-flow.dl similarity index 100% rename from src/java/engine/resolution/local-flow.dl rename to graph/java/engine/resolution/local-flow.dl diff --git a/src/java/engine/resolution/method-lookup.dl b/graph/java/engine/resolution/method-lookup.dl similarity index 100% rename from src/java/engine/resolution/method-lookup.dl rename to graph/java/engine/resolution/method-lookup.dl diff --git a/src/java/engine/resolution/param-flow.dl b/graph/java/engine/resolution/param-flow.dl similarity index 100% rename from src/java/engine/resolution/param-flow.dl rename to graph/java/engine/resolution/param-flow.dl diff --git a/src/java/engine/resolution/reference-types.dl b/graph/java/engine/resolution/reference-types.dl similarity index 100% rename from src/java/engine/resolution/reference-types.dl rename to graph/java/engine/resolution/reference-types.dl diff --git a/src/java/engine/resolution/return-flow.dl b/graph/java/engine/resolution/return-flow.dl similarity index 100% rename from src/java/engine/resolution/return-flow.dl rename to graph/java/engine/resolution/return-flow.dl diff --git a/src/java/engine/resolution/type-arg-binding.dl b/graph/java/engine/resolution/type-arg-binding.dl similarity index 100% rename from src/java/engine/resolution/type-arg-binding.dl rename to graph/java/engine/resolution/type-arg-binding.dl diff --git a/src/java/engine/resolution/type-flow.dl b/graph/java/engine/resolution/type-flow.dl similarity index 100% rename from src/java/engine/resolution/type-flow.dl rename to graph/java/engine/resolution/type-flow.dl diff --git a/src/java/engine/resolution/type-hierarchy.dl b/graph/java/engine/resolution/type-hierarchy.dl similarity index 100% rename from src/java/engine/resolution/type-hierarchy.dl rename to graph/java/engine/resolution/type-hierarchy.dl diff --git a/src/java/engine/resolution/type-resolution.dl b/graph/java/engine/resolution/type-resolution.dl similarity index 100% rename from src/java/engine/resolution/type-resolution.dl rename to graph/java/engine/resolution/type-resolution.dl diff --git a/src/java/engine/resolution/type-var-bound.dl b/graph/java/engine/resolution/type-var-bound.dl similarity index 100% rename from src/java/engine/resolution/type-var-bound.dl rename to graph/java/engine/resolution/type-var-bound.dl diff --git a/src/java/engine/resolution/virtual-dispatch.dl b/graph/java/engine/resolution/virtual-dispatch.dl similarity index 100% rename from src/java/engine/resolution/virtual-dispatch.dl rename to graph/java/engine/resolution/virtual-dispatch.dl diff --git a/src/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl similarity index 100% rename from src/java/souffle/decls_all.dl rename to graph/java/souffle/decls_all.dl diff --git a/src/java/souffle/decls_base.dl b/graph/java/souffle/decls_base.dl similarity index 100% rename from src/java/souffle/decls_base.dl rename to graph/java/souffle/decls_base.dl diff --git a/src/java/souffle/export_manifest.tsv b/graph/java/souffle/export_manifest.tsv similarity index 100% rename from src/java/souffle/export_manifest.tsv rename to graph/java/souffle/export_manifest.tsv diff --git a/src/java/templates/client-ir.map b/graph/java/templates/client-ir.map similarity index 100% rename from src/java/templates/client-ir.map rename to graph/java/templates/client-ir.map diff --git a/src/java/templates/lib.map b/graph/java/templates/lib.map similarity index 100% rename from src/java/templates/lib.map rename to graph/java/templates/lib.map diff --git a/src/java/templates/staging.conf b/graph/java/templates/staging.conf similarity index 100% rename from src/java/templates/staging.conf rename to graph/java/templates/staging.conf diff --git a/src/pipeline/portable-stat.sh b/graph/pipeline/portable-stat.sh similarity index 94% rename from src/pipeline/portable-stat.sh rename to graph/pipeline/portable-stat.sh index 363e4df88..3be93fdf0 100644 --- a/src/pipeline/portable-stat.sh +++ b/graph/pipeline/portable-stat.sh @@ -18,8 +18,8 @@ # Decide ONCE instead, by asking stat which spelling it understands, and never look at the exit # status of a stat that may already have written to the stream being captured. # -# See src/pipeline/run-souffle.sh (lib_cache_key) and test/java/tools/build-{jdk,lib}-ir.sh. -# test/tools/portable-stat-test.sh asserts the behaviour these callers depend on. +# See graph/pipeline/run-souffle.sh (lib_cache_key) and graph/test/java/tools/build-{jdk,lib}-ir.sh. +# graph/test/tools/portable-stat-test.sh asserts the behaviour these callers depend on. # ───────────────────────────────────────────────────────────────────────────── if stat -c %Y . >/dev/null 2>&1; then diff --git a/src/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh similarity index 96% rename from src/pipeline/run-souffle.sh rename to graph/pipeline/run-souffle.sh index ba6327e4b..cf04f48d1 100755 --- a/src/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -4,17 +4,18 @@ # to only the signature relations the rules reference (never loads GB-scale bodies). # Usage: run-souffle.sh --client-ir DIR --library DIR --intermediate DIR --output DIR [--language L] [--debug] # -# OUTPUT LAYOUT — the same in every language (src/bundle/SCHEMA.md): +# OUTPUT LAYOUT — the same in every language (graph/bundle/SCHEMA.md): # $OUT/graph.sqlite the contract: core tables + ext_* tables + the schema catalog -# $OUT/graph/*.csv the same core tables as headered text — ONLY with --debug +# $OUT/csv/*.csv the same core tables as headered text — ONLY with --debug # (or when node has no node:sqlite, so a run always emits something) # $OUT/raw/ the per-language Soufflé relations, verbatim — engine-internal; # kept ONLY with --debug (the regression suites score it) -# Soufflé solves into raw/; the bundle stage (src/bundle/cli.ts) then joins the raw +# Soufflé solves into raw/; the bundle stage (graph/bundle/cli.ts) then joins the raw # relations to the parser IR and writes the database. Without --debug the raw relations are # deleted once the database is written, so a consumer sees one file: graph.sqlite. set -e DEBUG_BUNDLE="${AXIOM_DEBUG:-0}" +EXTRA_META=() JDK_DEPTH=1 # max JDK-hop depth engine-ii expands. FORCED (always applied). Default 1: sinks are # known JDK methods (the cwe catalog), so external code reaches a file-op sink at JDK # hop 1; deeper JDK expansion only traces internal plumbing (the explosion source). @@ -29,7 +30,7 @@ DISPATCH_CAP="${DISPATCH_CAP:-20}" # fan-width cap on virtual dispatch. DEFAUL # TURN IT OFF (--dispatch-cap off) for UNBOUNDED reachability — sink/taint traversal — # where a sink behind a wide dispatch would be dropped: entry_reachable falls 21.5% # (32,229 -> 25,288) under the cap. Bounded-depth impact queries are unaffected. -LANG_ARG="" # which rule set under src// to run. Default java. +LANG_ARG="" # which rule set under graph// to run. Default java. TAINT="" # --taint on → gate lib→lib GROW on client-seeded data flow (dataflow/taint.dl). Also # settable via env AXIOM_TAINT_GATING=on. Empty = ungated (default behavior). while [ $# -gt 0 ]; do case "$1" in @@ -40,17 +41,18 @@ while [ $# -gt 0 ]; do case "$1" in --lib-depth) LIB_DEPTH="$2"; shift 2;; --taint) TAINT="$2"; shift 2;; --language) LANG_ARG="$2"; shift 2;; - # graph.sqlite is the deliverable; graph/*.csv is a debugging view of the same core + # graph.sqlite is the deliverable; csv/*.csv is a debugging view of the same core # tables. --debug asks for both. (An older Node with no node:sqlite writes the CSVs # regardless, because otherwise the run would produce no consumer-facing output.) --debug) DEBUG_BUNDLE=1; shift;; + --meta) EXTRA_META+=(--meta "$2"); shift 2;; # key=value recorded in graph.sqlite's run table # (AXIOM_DEBUG=1 in the environment is the same as --debug — for harnesses that cannot # change the invocation.) *) shift;; esac; done SRC="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=portable-stat.sh . "$SRC/pipeline/portable-stat.sh" -# Rules are PER-LANGUAGE and live under src//; the executor itself is shared. +# Rules are PER-LANGUAGE and live under graph//; the executor itself is shared. LANG_ARG="${LANG_ARG:-java}" ENG="$SRC/$LANG_ARG/engine"; ENG2="$SRC/$LANG_ARG/engine-ii"; DL="$SRC/$LANG_ARG/souffle"; TPL="$SRC/$LANG_ARG/templates" [ -d "$ENG" ] || { echo "no rule set for --language=$LANG_ARG (looked in $ENG)" >&2; exit 1; } @@ -148,7 +150,7 @@ lib_cache_key(){ # served the other's staged facts. Reproduced on macOS, so it is not the `stat` portability # problem: `find dir_symlink -name '*.csv'` simply prints nothing. # size+mtime of each module's CSVs — cheap, and changes whenever the IR does. - # file_ident, NOT `stat -f ... || stat -c ...`: see src/pipeline/portable-stat.sh. On GNU + # file_ident, NOT `stat -f ... || stat -c ...`: see graph/pipeline/portable-stat.sh. On GNU # coreutils `-f` is --file-system, so the BSD form printed a FILESYSTEM report — free-block # and inode counters — for each file, and the fallback was unreachable here anyway because # find exits 0 whether or not the command it exec'd failed. The key was therefore computed @@ -406,7 +408,7 @@ rm -rf "$FACTS" "$INT/souffle-program.cpp" SOLVE_EPOCH=$(date +%s) echo "Elapsed (solve): $((SOLVE_EPOCH-START_EPOCH))s" -# --- BUNDLE: raw/ + the parser IR -> graph.sqlite + graph/*.csv (src/bundle/) --- +# --- BUNDLE: raw/ + the parser IR -> graph.sqlite + csv/*.csv (graph/bundle/) --- # The stage is TypeScript. In a development checkout it runs from SOURCE through tsx, so the # bundle can never be built from a stale dist/ (the failure mode a compiled step invites); # an installed package has no devDependencies and runs the compiled dist/bundle/cli.js that @@ -427,13 +429,14 @@ BUNDLE_FLAGS=(); [ "$DEBUG_BUNDLE" = "1" ] && BUNDLE_FLAGS+=(--debug) --library "$LIB" --lib-facts "$LIBDIR" "${BUNDLE_FLAGS[@]}" \ --meta "engine_commit=$ENGINE_COMMIT" \ --meta "dispatch_cap=${CAP_EFF:-off}" --meta "jdk_depth=$JDK_DEPTH" --meta "lib_depth=${LIB_DEPTH:-uncapped}" \ - --meta "engine_ii=$ENGINE_II_MODE" --meta "solve_iterations=$iter" --meta "solve_seconds=$((SOLVE_EPOCH-START_EPOCH))" + --meta "engine_ii=$ENGINE_II_MODE" --meta "solve_iterations=$iter" --meta "solve_seconds=$((SOLVE_EPOCH-START_EPOCH))" \ + ${EXTRA_META[@]+"${EXTRA_META[@]}"} END_EPOCH=$(date +%s); END_TS=$(date '+%Y-%m-%d %H:%M:%S') echo "Elapsed: $((END_EPOCH-START_EPOCH))s" if [ "$DEBUG_BUNDLE" = "1" ]; then - echo "✅ reasoning complete: $OUT/graph.sqlite · debug: $OUT/graph/ and raw relations in $RAW" + echo "✅ reasoning complete: $OUT/graph.sqlite · debug: $OUT/csv/ and raw relations in $RAW" else rm -rf "$RAW" - echo "✅ reasoning complete: $OUT/graph.sqlite (--debug keeps raw/ and writes graph/*.csv)" + echo "✅ reasoning complete: $OUT/graph.sqlite (--debug keeps raw/ and writes csv/*.csv)" fi diff --git a/src/pipeline/souffle-include.sh b/graph/pipeline/souffle-include.sh similarity index 97% rename from src/pipeline/souffle-include.sh rename to graph/pipeline/souffle-include.sh index 4fdacf44c..cb2d25d10 100644 --- a/src/pipeline/souffle-include.sh +++ b/graph/pipeline/souffle-include.sh @@ -3,7 +3,7 @@ # # Separate from run-souffle.sh so it can be tested: this resolution silently produced a path that # only compiled on the machine it was written on, and nothing could assert otherwise while it was -# inlined in a script that runs the whole pipeline. See test/tools/souffle-include-test.sh. +# inlined in a script that runs the whole pipeline. See graph/test/tools/souffle-include-test.sh. # ───────────────────────────────────────────────────────────────────────────── # Soufflé's C++ headers. DERIVED, never hardcoded — the path is version- and diff --git a/src/python/PARSER-DEFECTS.md b/graph/python/PARSER-DEFECTS.md similarity index 100% rename from src/python/PARSER-DEFECTS.md rename to graph/python/PARSER-DEFECTS.md diff --git a/src/python/README.md b/graph/python/README.md similarity index 99% rename from src/python/README.md rename to graph/python/README.md index 7ef25b245..bd227f97c 100644 --- a/src/python/README.md +++ b/graph/python/README.md @@ -3,7 +3,7 @@ Run it: ```bash -bash src/pipeline/run-souffle.sh --language python \ +bash graph/pipeline/run-souffle.sh --language python \ --client-ir --library ~/Documents/AxiomCode/python/v3.10.4 \ --intermediate --output ``` diff --git a/src/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl similarity index 100% rename from src/python/engine/call-edge-generation/call_chain.dl rename to graph/python/engine/call-edge-generation/call_chain.dl diff --git a/src/python/engine/call-edge-generation/calls.dl b/graph/python/engine/call-edge-generation/calls.dl similarity index 100% rename from src/python/engine/call-edge-generation/calls.dl rename to graph/python/engine/call-edge-generation/calls.dl diff --git a/src/python/engine/containment/ownership.dl b/graph/python/engine/containment/ownership.dl similarity index 100% rename from src/python/engine/containment/ownership.dl rename to graph/python/engine/containment/ownership.dl diff --git a/src/python/engine/containment/type-nesting.dl b/graph/python/engine/containment/type-nesting.dl similarity index 100% rename from src/python/engine/containment/type-nesting.dl rename to graph/python/engine/containment/type-nesting.dl diff --git a/src/python/engine/export/call-edges.dl b/graph/python/engine/export/call-edges.dl similarity index 100% rename from src/python/engine/export/call-edges.dl rename to graph/python/engine/export/call-edges.dl diff --git a/src/python/engine/expression-resolution/call-site.dl b/graph/python/engine/expression-resolution/call-site.dl similarity index 100% rename from src/python/engine/expression-resolution/call-site.dl rename to graph/python/engine/expression-resolution/call-site.dl diff --git a/src/python/engine/expression-resolution/callee-resolution.dl b/graph/python/engine/expression-resolution/callee-resolution.dl similarity index 100% rename from src/python/engine/expression-resolution/callee-resolution.dl rename to graph/python/engine/expression-resolution/callee-resolution.dl diff --git a/src/python/engine/expression-resolution/expr-type.dl b/graph/python/engine/expression-resolution/expr-type.dl similarity index 100% rename from src/python/engine/expression-resolution/expr-type.dl rename to graph/python/engine/expression-resolution/expr-type.dl diff --git a/src/python/engine/projections/bindings.dl b/graph/python/engine/projections/bindings.dl similarity index 100% rename from src/python/engine/projections/bindings.dl rename to graph/python/engine/projections/bindings.dl diff --git a/src/python/engine/projections/blocks.dl b/graph/python/engine/projections/blocks.dl similarity index 100% rename from src/python/engine/projections/blocks.dl rename to graph/python/engine/projections/blocks.dl diff --git a/src/python/engine/projections/call-sites.dl b/graph/python/engine/projections/call-sites.dl similarity index 100% rename from src/python/engine/projections/call-sites.dl rename to graph/python/engine/projections/call-sites.dl diff --git a/src/python/engine/projections/decorators.dl b/graph/python/engine/projections/decorators.dl similarity index 100% rename from src/python/engine/projections/decorators.dl rename to graph/python/engine/projections/decorators.dl diff --git a/src/python/engine/projections/expressions.dl b/graph/python/engine/projections/expressions.dl similarity index 100% rename from src/python/engine/projections/expressions.dl rename to graph/python/engine/projections/expressions.dl diff --git a/src/python/engine/projections/fields.dl b/graph/python/engine/projections/fields.dl similarity index 100% rename from src/python/engine/projections/fields.dl rename to graph/python/engine/projections/fields.dl diff --git a/src/python/engine/projections/imports.dl b/graph/python/engine/projections/imports.dl similarity index 100% rename from src/python/engine/projections/imports.dl rename to graph/python/engine/projections/imports.dl diff --git a/src/python/engine/projections/methods.dl b/graph/python/engine/projections/methods.dl similarity index 100% rename from src/python/engine/projections/methods.dl rename to graph/python/engine/projections/methods.dl diff --git a/src/python/engine/projections/modules.dl b/graph/python/engine/projections/modules.dl similarity index 100% rename from src/python/engine/projections/modules.dl rename to graph/python/engine/projections/modules.dl diff --git a/src/python/engine/projections/parse-gaps.dl b/graph/python/engine/projections/parse-gaps.dl similarity index 100% rename from src/python/engine/projections/parse-gaps.dl rename to graph/python/engine/projections/parse-gaps.dl diff --git a/src/python/engine/projections/scopes.dl b/graph/python/engine/projections/scopes.dl similarity index 100% rename from src/python/engine/projections/scopes.dl rename to graph/python/engine/projections/scopes.dl diff --git a/src/python/engine/projections/type-references.dl b/graph/python/engine/projections/type-references.dl similarity index 100% rename from src/python/engine/projections/type-references.dl rename to graph/python/engine/projections/type-references.dl diff --git a/src/python/engine/projections/types.dl b/graph/python/engine/projections/types.dl similarity index 100% rename from src/python/engine/projections/types.dl rename to graph/python/engine/projections/types.dl diff --git a/src/python/engine/resolution/annotations.dl b/graph/python/engine/resolution/annotations.dl similarity index 100% rename from src/python/engine/resolution/annotations.dl rename to graph/python/engine/resolution/annotations.dl diff --git a/src/python/engine/resolution/attribute-lookup.dl b/graph/python/engine/resolution/attribute-lookup.dl similarity index 100% rename from src/python/engine/resolution/attribute-lookup.dl rename to graph/python/engine/resolution/attribute-lookup.dl diff --git a/src/python/engine/resolution/builtin-types.dl b/graph/python/engine/resolution/builtin-types.dl similarity index 100% rename from src/python/engine/resolution/builtin-types.dl rename to graph/python/engine/resolution/builtin-types.dl diff --git a/src/python/engine/resolution/builtins.dl b/graph/python/engine/resolution/builtins.dl similarity index 100% rename from src/python/engine/resolution/builtins.dl rename to graph/python/engine/resolution/builtins.dl diff --git a/src/python/engine/resolution/collection-flow.dl b/graph/python/engine/resolution/collection-flow.dl similarity index 100% rename from src/python/engine/resolution/collection-flow.dl rename to graph/python/engine/resolution/collection-flow.dl diff --git a/src/python/engine/resolution/decorators.dl b/graph/python/engine/resolution/decorators.dl similarity index 100% rename from src/python/engine/resolution/decorators.dl rename to graph/python/engine/resolution/decorators.dl diff --git a/src/python/engine/resolution/dispatch.dl b/graph/python/engine/resolution/dispatch.dl similarity index 100% rename from src/python/engine/resolution/dispatch.dl rename to graph/python/engine/resolution/dispatch.dl diff --git a/src/python/engine/resolution/field-flow.dl b/graph/python/engine/resolution/field-flow.dl similarity index 100% rename from src/python/engine/resolution/field-flow.dl rename to graph/python/engine/resolution/field-flow.dl diff --git a/src/python/engine/resolution/generics.dl b/graph/python/engine/resolution/generics.dl similarity index 100% rename from src/python/engine/resolution/generics.dl rename to graph/python/engine/resolution/generics.dl diff --git a/src/python/engine/resolution/iteration.dl b/graph/python/engine/resolution/iteration.dl similarity index 100% rename from src/python/engine/resolution/iteration.dl rename to graph/python/engine/resolution/iteration.dl diff --git a/src/python/engine/resolution/lib-linking.dl b/graph/python/engine/resolution/lib-linking.dl similarity index 100% rename from src/python/engine/resolution/lib-linking.dl rename to graph/python/engine/resolution/lib-linking.dl diff --git a/src/python/engine/resolution/mro.dl b/graph/python/engine/resolution/mro.dl similarity index 100% rename from src/python/engine/resolution/mro.dl rename to graph/python/engine/resolution/mro.dl diff --git a/src/python/engine/resolution/name-resolution.dl b/graph/python/engine/resolution/name-resolution.dl similarity index 100% rename from src/python/engine/resolution/name-resolution.dl rename to graph/python/engine/resolution/name-resolution.dl diff --git a/src/python/engine/resolution/type-hierarchy.dl b/graph/python/engine/resolution/type-hierarchy.dl similarity index 100% rename from src/python/engine/resolution/type-hierarchy.dl rename to graph/python/engine/resolution/type-hierarchy.dl diff --git a/src/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl similarity index 100% rename from src/python/engine/resolution/value-flow.dl rename to graph/python/engine/resolution/value-flow.dl diff --git a/src/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl similarity index 100% rename from src/python/souffle/decls_all.dl rename to graph/python/souffle/decls_all.dl diff --git a/src/python/souffle/decls_base.dl b/graph/python/souffle/decls_base.dl similarity index 100% rename from src/python/souffle/decls_base.dl rename to graph/python/souffle/decls_base.dl diff --git a/src/python/souffle/export_manifest.tsv b/graph/python/souffle/export_manifest.tsv similarity index 100% rename from src/python/souffle/export_manifest.tsv rename to graph/python/souffle/export_manifest.tsv diff --git a/src/python/templates/client-ir.map b/graph/python/templates/client-ir.map similarity index 100% rename from src/python/templates/client-ir.map rename to graph/python/templates/client-ir.map diff --git a/src/python/templates/lib.map b/graph/python/templates/lib.map similarity index 100% rename from src/python/templates/lib.map rename to graph/python/templates/lib.map diff --git a/src/python/templates/staging.conf b/graph/python/templates/staging.conf similarity index 100% rename from src/python/templates/staging.conf rename to graph/python/templates/staging.conf diff --git a/src/reason.ts b/graph/reason.ts similarity index 92% rename from src/reason.ts rename to graph/reason.ts index fee44b7a3..3dbcea716 100644 --- a/src/reason.ts +++ b/graph/reason.ts @@ -12,11 +12,11 @@ export interface ReasoningOptions { libraryDir?: string; /** Intermediate scratch: staged facts + the compiled-engine cache. */ intermediateDir?: string; - /** Final output: graph.sqlite + graph/*.csv + raw/ (see src/bundle/SCHEMA.md). */ + /** Final output: graph.sqlite + graph/*.csv + raw/ (see graph/bundle/SCHEMA.md). */ outputDir?: string; /** Rule set to run — java (default), typescript, python. */ language?: string; - /** Keep raw/ and also write graph/*.csv next to graph.sqlite. */ + /** Keep raw/ and also write csv/*.csv next to graph.sqlite. */ debug?: boolean; } @@ -27,10 +27,10 @@ export interface ReasoningOptions { * (parsing the .map import map), compiles the .dl program to a native binary * (cached by program checksum under `/souffle`, so only the first run — * or a rule change — pays the compile cost), solves into `/raw`, and then runs the - * bundle stage that writes `/graph.sqlite` and `/graph/*.csv` — the same - * schema in every language (src/bundle/SCHEMA.md). + * bundle stage that writes `/graph.sqlite` (and `/csv/*.csv` with --debug) — the same + * schema in every language (graph/bundle/SCHEMA.md). * - * Cleanups only ever touch the OWNED paths inside the given directories (`raw/`, `graph/`, + * Cleanups only ever touch the OWNED paths inside the given directories (`raw/`, `csv/`, * `graph.sqlite`, the souffle scratch), never the caller's raw IR or anything else they keep * next to the output. Throws on any problem so the agent can catch it. */ @@ -74,7 +74,7 @@ export function runReasoning(opts: ReasoningOptions = {}): void { fs.mkdirSync(souffleScratch, { recursive: true }); // The stage rewrites these itself; wiping first means a failed run cannot leave the previous // run's bundle in place looking like this one's. - for (const owned of ['raw', 'graph', 'graph.sqlite']) { + for (const owned of ['raw', 'csv', 'graph.sqlite']) { fs.rmSync(path.join(outputDir, owned), { recursive: true, force: true }); } fs.mkdirSync(outputDir, { recursive: true }); diff --git a/test/java/cases/01-inheritance-override/src/InheritanceOverride.java b/graph/test/java/cases/01-inheritance-override/src/InheritanceOverride.java similarity index 100% rename from test/java/cases/01-inheritance-override/src/InheritanceOverride.java rename to graph/test/java/cases/01-inheritance-override/src/InheritanceOverride.java diff --git a/test/java/cases/02-anonymous-sam/src/AnonymousClasses.java b/graph/test/java/cases/02-anonymous-sam/src/AnonymousClasses.java similarity index 100% rename from test/java/cases/02-anonymous-sam/src/AnonymousClasses.java rename to graph/test/java/cases/02-anonymous-sam/src/AnonymousClasses.java diff --git a/test/java/cases/03-overloads-and-receivers/src/testcases/engine/ComplexDispatch.java b/graph/test/java/cases/03-overloads-and-receivers/src/testcases/engine/ComplexDispatch.java similarity index 100% rename from test/java/cases/03-overloads-and-receivers/src/testcases/engine/ComplexDispatch.java rename to graph/test/java/cases/03-overloads-and-receivers/src/testcases/engine/ComplexDispatch.java diff --git a/test/java/cases/04-unqualified-calls/src/Probe.java b/graph/test/java/cases/04-unqualified-calls/src/Probe.java similarity index 100% rename from test/java/cases/04-unqualified-calls/src/Probe.java rename to graph/test/java/cases/04-unqualified-calls/src/Probe.java diff --git a/test/java/cases/05-lambda-and-method-refs/src/Lam.java b/graph/test/java/cases/05-lambda-and-method-refs/src/Lam.java similarity index 100% rename from test/java/cases/05-lambda-and-method-refs/src/Lam.java rename to graph/test/java/cases/05-lambda-and-method-refs/src/Lam.java diff --git a/test/java/cases/06-function-value-in-field/src/Asset.java b/graph/test/java/cases/06-function-value-in-field/src/Asset.java similarity index 100% rename from test/java/cases/06-function-value-in-field/src/Asset.java rename to graph/test/java/cases/06-function-value-in-field/src/Asset.java diff --git a/test/java/cases/07-constant-looking-type/src/Caps.java b/graph/test/java/cases/07-constant-looking-type/src/Caps.java similarity index 100% rename from test/java/cases/07-constant-looking-type/src/Caps.java rename to graph/test/java/cases/07-constant-looking-type/src/Caps.java diff --git a/test/java/cases/08-cha-interface-fanout/src/testcases/engine/EdgeCHA.java b/graph/test/java/cases/08-cha-interface-fanout/src/testcases/engine/EdgeCHA.java similarity index 100% rename from test/java/cases/08-cha-interface-fanout/src/testcases/engine/EdgeCHA.java rename to graph/test/java/cases/08-cha-interface-fanout/src/testcases/engine/EdgeCHA.java diff --git a/test/java/cases/09-cha-interface-injection/src/testcases/engine/InterfaceDI.java b/graph/test/java/cases/09-cha-interface-injection/src/testcases/engine/InterfaceDI.java similarity index 100% rename from test/java/cases/09-cha-interface-injection/src/testcases/engine/InterfaceDI.java rename to graph/test/java/cases/09-cha-interface-injection/src/testcases/engine/InterfaceDI.java diff --git a/test/java/cases/10-super-invocations/src/testcases/engine/SuperInvocations.java b/graph/test/java/cases/10-super-invocations/src/testcases/engine/SuperInvocations.java similarity index 100% rename from test/java/cases/10-super-invocations/src/testcases/engine/SuperInvocations.java rename to graph/test/java/cases/10-super-invocations/src/testcases/engine/SuperInvocations.java diff --git a/test/java/cases/11-cha-inherited-into-implementor/src/InheritedSatisfaction.java b/graph/test/java/cases/11-cha-inherited-into-implementor/src/InheritedSatisfaction.java similarity index 100% rename from test/java/cases/11-cha-inherited-into-implementor/src/InheritedSatisfaction.java rename to graph/test/java/cases/11-cha-inherited-into-implementor/src/InheritedSatisfaction.java diff --git a/test/java/cases/12-library-interface-override/src/LibIfaceOverride.java b/graph/test/java/cases/12-library-interface-override/src/LibIfaceOverride.java similarity index 100% rename from test/java/cases/12-library-interface-override/src/LibIfaceOverride.java rename to graph/test/java/cases/12-library-interface-override/src/LibIfaceOverride.java diff --git a/test/java/cases/13-type-qualified-statics/src/TypeQualifiedStatics.java b/graph/test/java/cases/13-type-qualified-statics/src/TypeQualifiedStatics.java similarity index 100% rename from test/java/cases/13-type-qualified-statics/src/TypeQualifiedStatics.java rename to graph/test/java/cases/13-type-qualified-statics/src/TypeQualifiedStatics.java diff --git a/test/java/cases/14-lambda-param-callback/src/LambdaParamCallback.java b/graph/test/java/cases/14-lambda-param-callback/src/LambdaParamCallback.java similarity index 100% rename from test/java/cases/14-lambda-param-callback/src/LambdaParamCallback.java rename to graph/test/java/cases/14-lambda-param-callback/src/LambdaParamCallback.java diff --git a/test/java/cases/15-cross-file-same-package/src/shop/Discount.java b/graph/test/java/cases/15-cross-file-same-package/src/shop/Discount.java similarity index 100% rename from test/java/cases/15-cross-file-same-package/src/shop/Discount.java rename to graph/test/java/cases/15-cross-file-same-package/src/shop/Discount.java diff --git a/test/java/cases/15-cross-file-same-package/src/shop/OrderService.java b/graph/test/java/cases/15-cross-file-same-package/src/shop/OrderService.java similarity index 100% rename from test/java/cases/15-cross-file-same-package/src/shop/OrderService.java rename to graph/test/java/cases/15-cross-file-same-package/src/shop/OrderService.java diff --git a/test/java/cases/15-cross-file-same-package/src/shop/PercentDiscount.java b/graph/test/java/cases/15-cross-file-same-package/src/shop/PercentDiscount.java similarity index 100% rename from test/java/cases/15-cross-file-same-package/src/shop/PercentDiscount.java rename to graph/test/java/cases/15-cross-file-same-package/src/shop/PercentDiscount.java diff --git a/test/java/cases/15-cross-file-same-package/src/shop/PriceCalculator.java b/graph/test/java/cases/15-cross-file-same-package/src/shop/PriceCalculator.java similarity index 100% rename from test/java/cases/15-cross-file-same-package/src/shop/PriceCalculator.java rename to graph/test/java/cases/15-cross-file-same-package/src/shop/PriceCalculator.java diff --git a/test/java/cases/16-cross-package-imports/src/app/Main.java b/graph/test/java/cases/16-cross-package-imports/src/app/Main.java similarity index 100% rename from test/java/cases/16-cross-package-imports/src/app/Main.java rename to graph/test/java/cases/16-cross-package-imports/src/app/Main.java diff --git a/test/java/cases/16-cross-package-imports/src/core/Config.java b/graph/test/java/cases/16-cross-package-imports/src/core/Config.java similarity index 100% rename from test/java/cases/16-cross-package-imports/src/core/Config.java rename to graph/test/java/cases/16-cross-package-imports/src/core/Config.java diff --git a/test/java/cases/16-cross-package-imports/src/core/Engine.java b/graph/test/java/cases/16-cross-package-imports/src/core/Engine.java similarity index 100% rename from test/java/cases/16-cross-package-imports/src/core/Engine.java rename to graph/test/java/cases/16-cross-package-imports/src/core/Engine.java diff --git a/test/java/cases/16-cross-package-imports/src/core/FastHandler.java b/graph/test/java/cases/16-cross-package-imports/src/core/FastHandler.java similarity index 100% rename from test/java/cases/16-cross-package-imports/src/core/FastHandler.java rename to graph/test/java/cases/16-cross-package-imports/src/core/FastHandler.java diff --git a/test/java/cases/16-cross-package-imports/src/core/Handler.java b/graph/test/java/cases/16-cross-package-imports/src/core/Handler.java similarity index 100% rename from test/java/cases/16-cross-package-imports/src/core/Handler.java rename to graph/test/java/cases/16-cross-package-imports/src/core/Handler.java diff --git a/test/java/cases/16-cross-package-imports/src/util/Config.java b/graph/test/java/cases/16-cross-package-imports/src/util/Config.java similarity index 100% rename from test/java/cases/16-cross-package-imports/src/util/Config.java rename to graph/test/java/cases/16-cross-package-imports/src/util/Config.java diff --git a/test/java/cases/16-cross-package-imports/src/util/Strings.java b/graph/test/java/cases/16-cross-package-imports/src/util/Strings.java similarity index 100% rename from test/java/cases/16-cross-package-imports/src/util/Strings.java rename to graph/test/java/cases/16-cross-package-imports/src/util/Strings.java diff --git a/test/java/cases/17-nested-outer-access/src/OuterAccess.java b/graph/test/java/cases/17-nested-outer-access/src/OuterAccess.java similarity index 100% rename from test/java/cases/17-nested-outer-access/src/OuterAccess.java rename to graph/test/java/cases/17-nested-outer-access/src/OuterAccess.java diff --git a/test/java/cases/18-enum-record-sealed/src/LangFeatures.java b/graph/test/java/cases/18-enum-record-sealed/src/LangFeatures.java similarity index 100% rename from test/java/cases/18-enum-record-sealed/src/LangFeatures.java rename to graph/test/java/cases/18-enum-record-sealed/src/LangFeatures.java diff --git a/test/java/cases/19-receiver-forms/src/ReceiverForms.java b/graph/test/java/cases/19-receiver-forms/src/ReceiverForms.java similarity index 100% rename from test/java/cases/19-receiver-forms/src/ReceiverForms.java rename to graph/test/java/cases/19-receiver-forms/src/ReceiverForms.java diff --git a/test/java/cases/20-reflection-blind-spot/src/ReflectionSites.java b/graph/test/java/cases/20-reflection-blind-spot/src/ReflectionSites.java similarity index 100% rename from test/java/cases/20-reflection-blind-spot/src/ReflectionSites.java rename to graph/test/java/cases/20-reflection-blind-spot/src/ReflectionSites.java diff --git a/test/java/cases/21-function-value-blind-spots/src/FunctionValues.java b/graph/test/java/cases/21-function-value-blind-spots/src/FunctionValues.java similarity index 100% rename from test/java/cases/21-function-value-blind-spots/src/FunctionValues.java rename to graph/test/java/cases/21-function-value-blind-spots/src/FunctionValues.java diff --git a/test/java/cases/22-config-annotation-args/src/application.properties b/graph/test/java/cases/22-config-annotation-args/src/application.properties similarity index 100% rename from test/java/cases/22-config-annotation-args/src/application.properties rename to graph/test/java/cases/22-config-annotation-args/src/application.properties diff --git a/test/java/cases/22-config-annotation-args/src/testcases/config/AnnotationArgs.java b/graph/test/java/cases/22-config-annotation-args/src/testcases/config/AnnotationArgs.java similarity index 100% rename from test/java/cases/22-config-annotation-args/src/testcases/config/AnnotationArgs.java rename to graph/test/java/cases/22-config-annotation-args/src/testcases/config/AnnotationArgs.java diff --git a/test/java/cases/23-config-xml-wiring/src/application.properties b/graph/test/java/cases/23-config-xml-wiring/src/application.properties similarity index 100% rename from test/java/cases/23-config-xml-wiring/src/application.properties rename to graph/test/java/cases/23-config-xml-wiring/src/application.properties diff --git a/test/java/cases/23-config-xml-wiring/src/beans.xml b/graph/test/java/cases/23-config-xml-wiring/src/beans.xml similarity index 100% rename from test/java/cases/23-config-xml-wiring/src/beans.xml rename to graph/test/java/cases/23-config-xml-wiring/src/beans.xml diff --git a/test/java/cases/23-config-xml-wiring/src/testcases/config/XmlWired.java b/graph/test/java/cases/23-config-xml-wiring/src/testcases/config/XmlWired.java similarity index 100% rename from test/java/cases/23-config-xml-wiring/src/testcases/config/XmlWired.java rename to graph/test/java/cases/23-config-xml-wiring/src/testcases/config/XmlWired.java diff --git a/test/java/cases/23-config-xml-wiring/src/web.xml b/graph/test/java/cases/23-config-xml-wiring/src/web.xml similarity index 100% rename from test/java/cases/23-config-xml-wiring/src/web.xml rename to graph/test/java/cases/23-config-xml-wiring/src/web.xml diff --git a/test/java/cases/24-config-properties-yaml/src/application.properties b/graph/test/java/cases/24-config-properties-yaml/src/application.properties similarity index 100% rename from test/java/cases/24-config-properties-yaml/src/application.properties rename to graph/test/java/cases/24-config-properties-yaml/src/application.properties diff --git a/test/java/cases/24-config-properties-yaml/src/application.yml b/graph/test/java/cases/24-config-properties-yaml/src/application.yml similarity index 100% rename from test/java/cases/24-config-properties-yaml/src/application.yml rename to graph/test/java/cases/24-config-properties-yaml/src/application.yml diff --git a/test/java/cases/24-config-properties-yaml/src/testcases/config/KeySpace.java b/graph/test/java/cases/24-config-properties-yaml/src/testcases/config/KeySpace.java similarity index 100% rename from test/java/cases/24-config-properties-yaml/src/testcases/config/KeySpace.java rename to graph/test/java/cases/24-config-properties-yaml/src/testcases/config/KeySpace.java diff --git a/test/java/cases/25-di-narrowing/src/testcases/config/Wiring.java b/graph/test/java/cases/25-di-narrowing/src/testcases/config/Wiring.java similarity index 100% rename from test/java/cases/25-di-narrowing/src/testcases/config/Wiring.java rename to graph/test/java/cases/25-di-narrowing/src/testcases/config/Wiring.java diff --git a/test/java/cases/26-spring-oracle/spring-oracle.conf b/graph/test/java/cases/26-spring-oracle/spring-oracle.conf similarity index 100% rename from test/java/cases/26-spring-oracle/spring-oracle.conf rename to graph/test/java/cases/26-spring-oracle/spring-oracle.conf diff --git a/test/java/cases/26-spring-oracle/src/testcases/springoracle/Config.java b/graph/test/java/cases/26-spring-oracle/src/testcases/springoracle/Config.java similarity index 100% rename from test/java/cases/26-spring-oracle/src/testcases/springoracle/Config.java rename to graph/test/java/cases/26-spring-oracle/src/testcases/springoracle/Config.java diff --git a/test/java/cases/26-spring-oracle/src/testcases/springoracle/Domain.java b/graph/test/java/cases/26-spring-oracle/src/testcases/springoracle/Domain.java similarity index 100% rename from test/java/cases/26-spring-oracle/src/testcases/springoracle/Domain.java rename to graph/test/java/cases/26-spring-oracle/src/testcases/springoracle/Domain.java diff --git a/test/java/cases/27-messaging-and-grpc/src/application.yml b/graph/test/java/cases/27-messaging-and-grpc/src/application.yml similarity index 100% rename from test/java/cases/27-messaging-and-grpc/src/application.yml rename to graph/test/java/cases/27-messaging-and-grpc/src/application.yml diff --git a/test/java/cases/27-messaging-and-grpc/src/testcases/config/Messaging.java b/graph/test/java/cases/27-messaging-and-grpc/src/testcases/config/Messaging.java similarity index 100% rename from test/java/cases/27-messaging-and-grpc/src/testcases/config/Messaging.java rename to graph/test/java/cases/27-messaging-and-grpc/src/testcases/config/Messaging.java diff --git a/test/java/cases/28-array-constructor-ref/src/probe/ArrayCtorRef.java b/graph/test/java/cases/28-array-constructor-ref/src/probe/ArrayCtorRef.java similarity index 100% rename from test/java/cases/28-array-constructor-ref/src/probe/ArrayCtorRef.java rename to graph/test/java/cases/28-array-constructor-ref/src/probe/ArrayCtorRef.java diff --git a/test/java/cases/29-qualified-this/src/probe/QualifiedThis.java b/graph/test/java/cases/29-qualified-this/src/probe/QualifiedThis.java similarity index 100% rename from test/java/cases/29-qualified-this/src/probe/QualifiedThis.java rename to graph/test/java/cases/29-qualified-this/src/probe/QualifiedThis.java diff --git a/test/java/cases/30-dispatch-param-types/src/probe/DispatchParams.java b/graph/test/java/cases/30-dispatch-param-types/src/probe/DispatchParams.java similarity index 100% rename from test/java/cases/30-dispatch-param-types/src/probe/DispatchParams.java rename to graph/test/java/cases/30-dispatch-param-types/src/probe/DispatchParams.java diff --git a/test/java/cases/31-service-loader/src/META-INF/services/probe.Codec b/graph/test/java/cases/31-service-loader/src/META-INF/services/probe.Codec similarity index 100% rename from test/java/cases/31-service-loader/src/META-INF/services/probe.Codec rename to graph/test/java/cases/31-service-loader/src/META-INF/services/probe.Codec diff --git a/test/java/cases/31-service-loader/src/probe/Plugins.java b/graph/test/java/cases/31-service-loader/src/probe/Plugins.java similarity index 100% rename from test/java/cases/31-service-loader/src/probe/Plugins.java rename to graph/test/java/cases/31-service-loader/src/probe/Plugins.java diff --git a/test/java/cases/32-library-handoff-depth/lib-src/dep/BufferedSink.java b/graph/test/java/cases/32-library-handoff-depth/lib-src/dep/BufferedSink.java similarity index 100% rename from test/java/cases/32-library-handoff-depth/lib-src/dep/BufferedSink.java rename to graph/test/java/cases/32-library-handoff-depth/lib-src/dep/BufferedSink.java diff --git a/test/java/cases/32-library-handoff-depth/lib-src/dep/FileSink.java b/graph/test/java/cases/32-library-handoff-depth/lib-src/dep/FileSink.java similarity index 100% rename from test/java/cases/32-library-handoff-depth/lib-src/dep/FileSink.java rename to graph/test/java/cases/32-library-handoff-depth/lib-src/dep/FileSink.java diff --git a/test/java/cases/32-library-handoff-depth/lib-src/dep/Sink.java b/graph/test/java/cases/32-library-handoff-depth/lib-src/dep/Sink.java similarity index 100% rename from test/java/cases/32-library-handoff-depth/lib-src/dep/Sink.java rename to graph/test/java/cases/32-library-handoff-depth/lib-src/dep/Sink.java diff --git a/test/java/cases/32-library-handoff-depth/src/probe/Handoff.java b/graph/test/java/cases/32-library-handoff-depth/src/probe/Handoff.java similarity index 100% rename from test/java/cases/32-library-handoff-depth/src/probe/Handoff.java rename to graph/test/java/cases/32-library-handoff-depth/src/probe/Handoff.java diff --git a/test/java/cases/33-compiler-lowering/src/lowering/Lowering.java b/graph/test/java/cases/33-compiler-lowering/src/lowering/Lowering.java similarity index 100% rename from test/java/cases/33-compiler-lowering/src/lowering/Lowering.java rename to graph/test/java/cases/33-compiler-lowering/src/lowering/Lowering.java diff --git a/test/java/cases/34-object-members/lib-src/java/lang/Class.java b/graph/test/java/cases/34-object-members/lib-src/java/lang/Class.java similarity index 100% rename from test/java/cases/34-object-members/lib-src/java/lang/Class.java rename to graph/test/java/cases/34-object-members/lib-src/java/lang/Class.java diff --git a/test/java/cases/34-object-members/lib-src/java/lang/Cloneable.java b/graph/test/java/cases/34-object-members/lib-src/java/lang/Cloneable.java similarity index 100% rename from test/java/cases/34-object-members/lib-src/java/lang/Cloneable.java rename to graph/test/java/cases/34-object-members/lib-src/java/lang/Cloneable.java diff --git a/test/java/cases/34-object-members/lib-src/java/lang/Object.java b/graph/test/java/cases/34-object-members/lib-src/java/lang/Object.java similarity index 100% rename from test/java/cases/34-object-members/lib-src/java/lang/Object.java rename to graph/test/java/cases/34-object-members/lib-src/java/lang/Object.java diff --git a/test/java/cases/34-object-members/lib-src/java/lang/String.java b/graph/test/java/cases/34-object-members/lib-src/java/lang/String.java similarity index 100% rename from test/java/cases/34-object-members/lib-src/java/lang/String.java rename to graph/test/java/cases/34-object-members/lib-src/java/lang/String.java diff --git a/test/java/cases/34-object-members/src/probe/ObjectMembers.java b/graph/test/java/cases/34-object-members/src/probe/ObjectMembers.java similarity index 100% rename from test/java/cases/34-object-members/src/probe/ObjectMembers.java rename to graph/test/java/cases/34-object-members/src/probe/ObjectMembers.java diff --git a/test/java/cases/35-overload-phases/lib-src/java/lang/Integer.java b/graph/test/java/cases/35-overload-phases/lib-src/java/lang/Integer.java similarity index 100% rename from test/java/cases/35-overload-phases/lib-src/java/lang/Integer.java rename to graph/test/java/cases/35-overload-phases/lib-src/java/lang/Integer.java diff --git a/test/java/cases/35-overload-phases/lib-src/java/lang/Object.java b/graph/test/java/cases/35-overload-phases/lib-src/java/lang/Object.java similarity index 100% rename from test/java/cases/35-overload-phases/lib-src/java/lang/Object.java rename to graph/test/java/cases/35-overload-phases/lib-src/java/lang/Object.java diff --git a/test/java/cases/35-overload-phases/lib-src/java/lang/String.java b/graph/test/java/cases/35-overload-phases/lib-src/java/lang/String.java similarity index 100% rename from test/java/cases/35-overload-phases/lib-src/java/lang/String.java rename to graph/test/java/cases/35-overload-phases/lib-src/java/lang/String.java diff --git a/test/java/cases/35-overload-phases/src/probe/Phases.java b/graph/test/java/cases/35-overload-phases/src/probe/Phases.java similarity index 100% rename from test/java/cases/35-overload-phases/src/probe/Phases.java rename to graph/test/java/cases/35-overload-phases/src/probe/Phases.java diff --git a/test/java/cases/36-generic-two-hop/lib-src/dep/Box.java b/graph/test/java/cases/36-generic-two-hop/lib-src/dep/Box.java similarity index 100% rename from test/java/cases/36-generic-two-hop/lib-src/dep/Box.java rename to graph/test/java/cases/36-generic-two-hop/lib-src/dep/Box.java diff --git a/test/java/cases/36-generic-two-hop/lib-src/dep/Iter.java b/graph/test/java/cases/36-generic-two-hop/lib-src/dep/Iter.java similarity index 100% rename from test/java/cases/36-generic-two-hop/lib-src/dep/Iter.java rename to graph/test/java/cases/36-generic-two-hop/lib-src/dep/Iter.java diff --git a/test/java/cases/36-generic-two-hop/lib-src/dep/Lookup.java b/graph/test/java/cases/36-generic-two-hop/lib-src/dep/Lookup.java similarity index 100% rename from test/java/cases/36-generic-two-hop/lib-src/dep/Lookup.java rename to graph/test/java/cases/36-generic-two-hop/lib-src/dep/Lookup.java diff --git a/test/java/cases/36-generic-two-hop/lib-src/dep/Pair.java b/graph/test/java/cases/36-generic-two-hop/lib-src/dep/Pair.java similarity index 100% rename from test/java/cases/36-generic-two-hop/lib-src/dep/Pair.java rename to graph/test/java/cases/36-generic-two-hop/lib-src/dep/Pair.java diff --git a/test/java/cases/36-generic-two-hop/src/probe/TwoHop.java b/graph/test/java/cases/36-generic-two-hop/src/probe/TwoHop.java similarity index 100% rename from test/java/cases/36-generic-two-hop/src/probe/TwoHop.java rename to graph/test/java/cases/36-generic-two-hop/src/probe/TwoHop.java diff --git a/test/java/cases/37-method-ref-arity/lib-src/fn/BiFn.java b/graph/test/java/cases/37-method-ref-arity/lib-src/fn/BiFn.java similarity index 100% rename from test/java/cases/37-method-ref-arity/lib-src/fn/BiFn.java rename to graph/test/java/cases/37-method-ref-arity/lib-src/fn/BiFn.java diff --git a/test/java/cases/37-method-ref-arity/lib-src/fn/Fn.java b/graph/test/java/cases/37-method-ref-arity/lib-src/fn/Fn.java similarity index 100% rename from test/java/cases/37-method-ref-arity/lib-src/fn/Fn.java rename to graph/test/java/cases/37-method-ref-arity/lib-src/fn/Fn.java diff --git a/test/java/cases/37-method-ref-arity/lib-src/fn/Sink.java b/graph/test/java/cases/37-method-ref-arity/lib-src/fn/Sink.java similarity index 100% rename from test/java/cases/37-method-ref-arity/lib-src/fn/Sink.java rename to graph/test/java/cases/37-method-ref-arity/lib-src/fn/Sink.java diff --git a/test/java/cases/37-method-ref-arity/lib-src/fn/Sup.java b/graph/test/java/cases/37-method-ref-arity/lib-src/fn/Sup.java similarity index 100% rename from test/java/cases/37-method-ref-arity/lib-src/fn/Sup.java rename to graph/test/java/cases/37-method-ref-arity/lib-src/fn/Sup.java diff --git a/test/java/cases/37-method-ref-arity/src/probe/MrefArity.java b/graph/test/java/cases/37-method-ref-arity/src/probe/MrefArity.java similarity index 100% rename from test/java/cases/37-method-ref-arity/src/probe/MrefArity.java rename to graph/test/java/cases/37-method-ref-arity/src/probe/MrefArity.java diff --git a/test/java/cases/37-method-ref-arity/src/probe/Own.java b/graph/test/java/cases/37-method-ref-arity/src/probe/Own.java similarity index 100% rename from test/java/cases/37-method-ref-arity/src/probe/Own.java rename to graph/test/java/cases/37-method-ref-arity/src/probe/Own.java diff --git a/test/java/cases/38-record-patterns/src/probe/Patterns.java b/graph/test/java/cases/38-record-patterns/src/probe/Patterns.java similarity index 100% rename from test/java/cases/38-record-patterns/src/probe/Patterns.java rename to graph/test/java/cases/38-record-patterns/src/probe/Patterns.java diff --git a/test/java/cases/39-library-supertype-dispatch/lib-src/dep/Strategy.java b/graph/test/java/cases/39-library-supertype-dispatch/lib-src/dep/Strategy.java similarity index 100% rename from test/java/cases/39-library-supertype-dispatch/lib-src/dep/Strategy.java rename to graph/test/java/cases/39-library-supertype-dispatch/lib-src/dep/Strategy.java diff --git a/test/java/cases/39-library-supertype-dispatch/lib-src/dep/Task.java b/graph/test/java/cases/39-library-supertype-dispatch/lib-src/dep/Task.java similarity index 100% rename from test/java/cases/39-library-supertype-dispatch/lib-src/dep/Task.java rename to graph/test/java/cases/39-library-supertype-dispatch/lib-src/dep/Task.java diff --git a/test/java/cases/39-library-supertype-dispatch/src/app/Dispatch.java b/graph/test/java/cases/39-library-supertype-dispatch/src/app/Dispatch.java similarity index 100% rename from test/java/cases/39-library-supertype-dispatch/src/app/Dispatch.java rename to graph/test/java/cases/39-library-supertype-dispatch/src/app/Dispatch.java diff --git a/test/java/cases/39-library-supertype-dispatch/src/app/Local.java b/graph/test/java/cases/39-library-supertype-dispatch/src/app/Local.java similarity index 100% rename from test/java/cases/39-library-supertype-dispatch/src/app/Local.java rename to graph/test/java/cases/39-library-supertype-dispatch/src/app/Local.java diff --git a/test/java/cases/39-library-supertype-dispatch/src/app/Strategies.java b/graph/test/java/cases/39-library-supertype-dispatch/src/app/Strategies.java similarity index 100% rename from test/java/cases/39-library-supertype-dispatch/src/app/Strategies.java rename to graph/test/java/cases/39-library-supertype-dispatch/src/app/Strategies.java diff --git a/test/java/expected/01-inheritance-override.edges b/graph/test/java/expected/01-inheritance-override.edges similarity index 100% rename from test/java/expected/01-inheritance-override.edges rename to graph/test/java/expected/01-inheritance-override.edges diff --git a/test/java/expected/01-inheritance-override.oracle b/graph/test/java/expected/01-inheritance-override.oracle similarity index 100% rename from test/java/expected/01-inheritance-override.oracle rename to graph/test/java/expected/01-inheritance-override.oracle diff --git a/test/java/expected/02-anonymous-sam.edges b/graph/test/java/expected/02-anonymous-sam.edges similarity index 100% rename from test/java/expected/02-anonymous-sam.edges rename to graph/test/java/expected/02-anonymous-sam.edges diff --git a/test/java/expected/02-anonymous-sam.known-missing b/graph/test/java/expected/02-anonymous-sam.known-missing similarity index 100% rename from test/java/expected/02-anonymous-sam.known-missing rename to graph/test/java/expected/02-anonymous-sam.known-missing diff --git a/test/java/expected/02-anonymous-sam.oracle b/graph/test/java/expected/02-anonymous-sam.oracle similarity index 100% rename from test/java/expected/02-anonymous-sam.oracle rename to graph/test/java/expected/02-anonymous-sam.oracle diff --git a/test/java/expected/03-overloads-and-receivers.edges b/graph/test/java/expected/03-overloads-and-receivers.edges similarity index 100% rename from test/java/expected/03-overloads-and-receivers.edges rename to graph/test/java/expected/03-overloads-and-receivers.edges diff --git a/test/java/expected/03-overloads-and-receivers.envelope b/graph/test/java/expected/03-overloads-and-receivers.envelope similarity index 100% rename from test/java/expected/03-overloads-and-receivers.envelope rename to graph/test/java/expected/03-overloads-and-receivers.envelope diff --git a/test/java/expected/03-overloads-and-receivers.known-missing b/graph/test/java/expected/03-overloads-and-receivers.known-missing similarity index 100% rename from test/java/expected/03-overloads-and-receivers.known-missing rename to graph/test/java/expected/03-overloads-and-receivers.known-missing diff --git a/test/java/expected/03-overloads-and-receivers.oracle b/graph/test/java/expected/03-overloads-and-receivers.oracle similarity index 100% rename from test/java/expected/03-overloads-and-receivers.oracle rename to graph/test/java/expected/03-overloads-and-receivers.oracle diff --git a/test/java/expected/04-unqualified-calls.edges b/graph/test/java/expected/04-unqualified-calls.edges similarity index 100% rename from test/java/expected/04-unqualified-calls.edges rename to graph/test/java/expected/04-unqualified-calls.edges diff --git a/test/java/expected/04-unqualified-calls.oracle b/graph/test/java/expected/04-unqualified-calls.oracle similarity index 100% rename from test/java/expected/04-unqualified-calls.oracle rename to graph/test/java/expected/04-unqualified-calls.oracle diff --git a/test/java/expected/05-lambda-and-method-refs.edges b/graph/test/java/expected/05-lambda-and-method-refs.edges similarity index 100% rename from test/java/expected/05-lambda-and-method-refs.edges rename to graph/test/java/expected/05-lambda-and-method-refs.edges diff --git a/test/java/expected/05-lambda-and-method-refs.known-missing b/graph/test/java/expected/05-lambda-and-method-refs.known-missing similarity index 100% rename from test/java/expected/05-lambda-and-method-refs.known-missing rename to graph/test/java/expected/05-lambda-and-method-refs.known-missing diff --git a/test/java/expected/05-lambda-and-method-refs.oracle b/graph/test/java/expected/05-lambda-and-method-refs.oracle similarity index 100% rename from test/java/expected/05-lambda-and-method-refs.oracle rename to graph/test/java/expected/05-lambda-and-method-refs.oracle diff --git a/test/java/expected/06-function-value-in-field.edges b/graph/test/java/expected/06-function-value-in-field.edges similarity index 100% rename from test/java/expected/06-function-value-in-field.edges rename to graph/test/java/expected/06-function-value-in-field.edges diff --git a/test/java/expected/06-function-value-in-field.known-missing b/graph/test/java/expected/06-function-value-in-field.known-missing similarity index 100% rename from test/java/expected/06-function-value-in-field.known-missing rename to graph/test/java/expected/06-function-value-in-field.known-missing diff --git a/test/java/expected/06-function-value-in-field.oracle b/graph/test/java/expected/06-function-value-in-field.oracle similarity index 100% rename from test/java/expected/06-function-value-in-field.oracle rename to graph/test/java/expected/06-function-value-in-field.oracle diff --git a/test/java/expected/07-constant-looking-type.edges b/graph/test/java/expected/07-constant-looking-type.edges similarity index 100% rename from test/java/expected/07-constant-looking-type.edges rename to graph/test/java/expected/07-constant-looking-type.edges diff --git a/test/java/expected/07-constant-looking-type.oracle b/graph/test/java/expected/07-constant-looking-type.oracle similarity index 100% rename from test/java/expected/07-constant-looking-type.oracle rename to graph/test/java/expected/07-constant-looking-type.oracle diff --git a/test/java/expected/08-cha-interface-fanout.edges b/graph/test/java/expected/08-cha-interface-fanout.edges similarity index 100% rename from test/java/expected/08-cha-interface-fanout.edges rename to graph/test/java/expected/08-cha-interface-fanout.edges diff --git a/test/java/expected/08-cha-interface-fanout.envelope b/graph/test/java/expected/08-cha-interface-fanout.envelope similarity index 100% rename from test/java/expected/08-cha-interface-fanout.envelope rename to graph/test/java/expected/08-cha-interface-fanout.envelope diff --git a/test/java/expected/08-cha-interface-fanout.known-missing b/graph/test/java/expected/08-cha-interface-fanout.known-missing similarity index 100% rename from test/java/expected/08-cha-interface-fanout.known-missing rename to graph/test/java/expected/08-cha-interface-fanout.known-missing diff --git a/test/java/expected/08-cha-interface-fanout.oracle b/graph/test/java/expected/08-cha-interface-fanout.oracle similarity index 100% rename from test/java/expected/08-cha-interface-fanout.oracle rename to graph/test/java/expected/08-cha-interface-fanout.oracle diff --git a/test/java/expected/09-cha-interface-injection.edges b/graph/test/java/expected/09-cha-interface-injection.edges similarity index 100% rename from test/java/expected/09-cha-interface-injection.edges rename to graph/test/java/expected/09-cha-interface-injection.edges diff --git a/test/java/expected/09-cha-interface-injection.envelope b/graph/test/java/expected/09-cha-interface-injection.envelope similarity index 100% rename from test/java/expected/09-cha-interface-injection.envelope rename to graph/test/java/expected/09-cha-interface-injection.envelope diff --git a/test/java/expected/09-cha-interface-injection.oracle b/graph/test/java/expected/09-cha-interface-injection.oracle similarity index 100% rename from test/java/expected/09-cha-interface-injection.oracle rename to graph/test/java/expected/09-cha-interface-injection.oracle diff --git a/test/java/expected/10-super-invocations.edges b/graph/test/java/expected/10-super-invocations.edges similarity index 100% rename from test/java/expected/10-super-invocations.edges rename to graph/test/java/expected/10-super-invocations.edges diff --git a/test/java/expected/10-super-invocations.envelope b/graph/test/java/expected/10-super-invocations.envelope similarity index 100% rename from test/java/expected/10-super-invocations.envelope rename to graph/test/java/expected/10-super-invocations.envelope diff --git a/test/java/expected/10-super-invocations.oracle b/graph/test/java/expected/10-super-invocations.oracle similarity index 100% rename from test/java/expected/10-super-invocations.oracle rename to graph/test/java/expected/10-super-invocations.oracle diff --git a/test/java/expected/11-cha-inherited-into-implementor.edges b/graph/test/java/expected/11-cha-inherited-into-implementor.edges similarity index 100% rename from test/java/expected/11-cha-inherited-into-implementor.edges rename to graph/test/java/expected/11-cha-inherited-into-implementor.edges diff --git a/test/java/expected/11-cha-inherited-into-implementor.envelope b/graph/test/java/expected/11-cha-inherited-into-implementor.envelope similarity index 100% rename from test/java/expected/11-cha-inherited-into-implementor.envelope rename to graph/test/java/expected/11-cha-inherited-into-implementor.envelope diff --git a/test/java/expected/11-cha-inherited-into-implementor.oracle b/graph/test/java/expected/11-cha-inherited-into-implementor.oracle similarity index 100% rename from test/java/expected/11-cha-inherited-into-implementor.oracle rename to graph/test/java/expected/11-cha-inherited-into-implementor.oracle diff --git a/test/java/expected/12-library-interface-override.edges b/graph/test/java/expected/12-library-interface-override.edges similarity index 100% rename from test/java/expected/12-library-interface-override.edges rename to graph/test/java/expected/12-library-interface-override.edges diff --git a/test/java/expected/12-library-interface-override.oracle b/graph/test/java/expected/12-library-interface-override.oracle similarity index 100% rename from test/java/expected/12-library-interface-override.oracle rename to graph/test/java/expected/12-library-interface-override.oracle diff --git a/test/java/expected/13-type-qualified-statics.edges b/graph/test/java/expected/13-type-qualified-statics.edges similarity index 100% rename from test/java/expected/13-type-qualified-statics.edges rename to graph/test/java/expected/13-type-qualified-statics.edges diff --git a/test/java/expected/13-type-qualified-statics.oracle b/graph/test/java/expected/13-type-qualified-statics.oracle similarity index 100% rename from test/java/expected/13-type-qualified-statics.oracle rename to graph/test/java/expected/13-type-qualified-statics.oracle diff --git a/test/java/expected/14-lambda-param-callback.edges b/graph/test/java/expected/14-lambda-param-callback.edges similarity index 100% rename from test/java/expected/14-lambda-param-callback.edges rename to graph/test/java/expected/14-lambda-param-callback.edges diff --git a/test/java/expected/14-lambda-param-callback.known-missing b/graph/test/java/expected/14-lambda-param-callback.known-missing similarity index 100% rename from test/java/expected/14-lambda-param-callback.known-missing rename to graph/test/java/expected/14-lambda-param-callback.known-missing diff --git a/test/java/expected/14-lambda-param-callback.oracle b/graph/test/java/expected/14-lambda-param-callback.oracle similarity index 100% rename from test/java/expected/14-lambda-param-callback.oracle rename to graph/test/java/expected/14-lambda-param-callback.oracle diff --git a/test/java/expected/15-cross-file-same-package.edges b/graph/test/java/expected/15-cross-file-same-package.edges similarity index 100% rename from test/java/expected/15-cross-file-same-package.edges rename to graph/test/java/expected/15-cross-file-same-package.edges diff --git a/test/java/expected/15-cross-file-same-package.envelope b/graph/test/java/expected/15-cross-file-same-package.envelope similarity index 100% rename from test/java/expected/15-cross-file-same-package.envelope rename to graph/test/java/expected/15-cross-file-same-package.envelope diff --git a/test/java/expected/15-cross-file-same-package.oracle b/graph/test/java/expected/15-cross-file-same-package.oracle similarity index 100% rename from test/java/expected/15-cross-file-same-package.oracle rename to graph/test/java/expected/15-cross-file-same-package.oracle diff --git a/test/java/expected/16-cross-package-imports.edges b/graph/test/java/expected/16-cross-package-imports.edges similarity index 100% rename from test/java/expected/16-cross-package-imports.edges rename to graph/test/java/expected/16-cross-package-imports.edges diff --git a/test/java/expected/16-cross-package-imports.envelope b/graph/test/java/expected/16-cross-package-imports.envelope similarity index 100% rename from test/java/expected/16-cross-package-imports.envelope rename to graph/test/java/expected/16-cross-package-imports.envelope diff --git a/test/java/expected/16-cross-package-imports.oracle b/graph/test/java/expected/16-cross-package-imports.oracle similarity index 100% rename from test/java/expected/16-cross-package-imports.oracle rename to graph/test/java/expected/16-cross-package-imports.oracle diff --git a/test/java/expected/17-nested-outer-access.edges b/graph/test/java/expected/17-nested-outer-access.edges similarity index 100% rename from test/java/expected/17-nested-outer-access.edges rename to graph/test/java/expected/17-nested-outer-access.edges diff --git a/test/java/expected/17-nested-outer-access.known-missing b/graph/test/java/expected/17-nested-outer-access.known-missing similarity index 100% rename from test/java/expected/17-nested-outer-access.known-missing rename to graph/test/java/expected/17-nested-outer-access.known-missing diff --git a/test/java/expected/17-nested-outer-access.oracle b/graph/test/java/expected/17-nested-outer-access.oracle similarity index 100% rename from test/java/expected/17-nested-outer-access.oracle rename to graph/test/java/expected/17-nested-outer-access.oracle diff --git a/test/java/expected/18-enum-record-sealed.edges b/graph/test/java/expected/18-enum-record-sealed.edges similarity index 100% rename from test/java/expected/18-enum-record-sealed.edges rename to graph/test/java/expected/18-enum-record-sealed.edges diff --git a/test/java/expected/18-enum-record-sealed.envelope b/graph/test/java/expected/18-enum-record-sealed.envelope similarity index 100% rename from test/java/expected/18-enum-record-sealed.envelope rename to graph/test/java/expected/18-enum-record-sealed.envelope diff --git a/test/java/expected/18-enum-record-sealed.known-missing b/graph/test/java/expected/18-enum-record-sealed.known-missing similarity index 100% rename from test/java/expected/18-enum-record-sealed.known-missing rename to graph/test/java/expected/18-enum-record-sealed.known-missing diff --git a/test/java/expected/18-enum-record-sealed.oracle b/graph/test/java/expected/18-enum-record-sealed.oracle similarity index 100% rename from test/java/expected/18-enum-record-sealed.oracle rename to graph/test/java/expected/18-enum-record-sealed.oracle diff --git a/test/java/expected/19-receiver-forms.edges b/graph/test/java/expected/19-receiver-forms.edges similarity index 100% rename from test/java/expected/19-receiver-forms.edges rename to graph/test/java/expected/19-receiver-forms.edges diff --git a/test/java/expected/19-receiver-forms.envelope b/graph/test/java/expected/19-receiver-forms.envelope similarity index 100% rename from test/java/expected/19-receiver-forms.envelope rename to graph/test/java/expected/19-receiver-forms.envelope diff --git a/test/java/expected/19-receiver-forms.oracle b/graph/test/java/expected/19-receiver-forms.oracle similarity index 100% rename from test/java/expected/19-receiver-forms.oracle rename to graph/test/java/expected/19-receiver-forms.oracle diff --git a/test/java/expected/20-reflection-blind-spot.edges b/graph/test/java/expected/20-reflection-blind-spot.edges similarity index 100% rename from test/java/expected/20-reflection-blind-spot.edges rename to graph/test/java/expected/20-reflection-blind-spot.edges diff --git a/test/java/expected/20-reflection-blind-spot.oracle b/graph/test/java/expected/20-reflection-blind-spot.oracle similarity index 100% rename from test/java/expected/20-reflection-blind-spot.oracle rename to graph/test/java/expected/20-reflection-blind-spot.oracle diff --git a/test/java/expected/21-function-value-blind-spots.edges b/graph/test/java/expected/21-function-value-blind-spots.edges similarity index 100% rename from test/java/expected/21-function-value-blind-spots.edges rename to graph/test/java/expected/21-function-value-blind-spots.edges diff --git a/test/java/expected/21-function-value-blind-spots.oracle b/graph/test/java/expected/21-function-value-blind-spots.oracle similarity index 100% rename from test/java/expected/21-function-value-blind-spots.oracle rename to graph/test/java/expected/21-function-value-blind-spots.oracle diff --git a/test/java/expected/22-config-annotation-args.config b/graph/test/java/expected/22-config-annotation-args.config similarity index 100% rename from test/java/expected/22-config-annotation-args.config rename to graph/test/java/expected/22-config-annotation-args.config diff --git a/test/java/expected/22-config-annotation-args.edges b/graph/test/java/expected/22-config-annotation-args.edges similarity index 100% rename from test/java/expected/22-config-annotation-args.edges rename to graph/test/java/expected/22-config-annotation-args.edges diff --git a/test/java/expected/22-config-annotation-args.oracle b/graph/test/java/expected/22-config-annotation-args.oracle similarity index 100% rename from test/java/expected/22-config-annotation-args.oracle rename to graph/test/java/expected/22-config-annotation-args.oracle diff --git a/test/java/expected/23-config-xml-wiring.config b/graph/test/java/expected/23-config-xml-wiring.config similarity index 100% rename from test/java/expected/23-config-xml-wiring.config rename to graph/test/java/expected/23-config-xml-wiring.config diff --git a/test/java/expected/23-config-xml-wiring.edges b/graph/test/java/expected/23-config-xml-wiring.edges similarity index 100% rename from test/java/expected/23-config-xml-wiring.edges rename to graph/test/java/expected/23-config-xml-wiring.edges diff --git a/test/java/expected/23-config-xml-wiring.oracle b/graph/test/java/expected/23-config-xml-wiring.oracle similarity index 100% rename from test/java/expected/23-config-xml-wiring.oracle rename to graph/test/java/expected/23-config-xml-wiring.oracle diff --git a/test/java/expected/24-config-properties-yaml.config b/graph/test/java/expected/24-config-properties-yaml.config similarity index 100% rename from test/java/expected/24-config-properties-yaml.config rename to graph/test/java/expected/24-config-properties-yaml.config diff --git a/test/java/expected/24-config-properties-yaml.edges b/graph/test/java/expected/24-config-properties-yaml.edges similarity index 100% rename from test/java/expected/24-config-properties-yaml.edges rename to graph/test/java/expected/24-config-properties-yaml.edges diff --git a/test/java/expected/24-config-properties-yaml.oracle b/graph/test/java/expected/24-config-properties-yaml.oracle similarity index 100% rename from test/java/expected/24-config-properties-yaml.oracle rename to graph/test/java/expected/24-config-properties-yaml.oracle diff --git a/test/java/expected/25-di-narrowing.config b/graph/test/java/expected/25-di-narrowing.config similarity index 100% rename from test/java/expected/25-di-narrowing.config rename to graph/test/java/expected/25-di-narrowing.config diff --git a/test/java/expected/25-di-narrowing.edges b/graph/test/java/expected/25-di-narrowing.edges similarity index 100% rename from test/java/expected/25-di-narrowing.edges rename to graph/test/java/expected/25-di-narrowing.edges diff --git a/test/java/expected/25-di-narrowing.envelope b/graph/test/java/expected/25-di-narrowing.envelope similarity index 100% rename from test/java/expected/25-di-narrowing.envelope rename to graph/test/java/expected/25-di-narrowing.envelope diff --git a/test/java/expected/25-di-narrowing.known-missing b/graph/test/java/expected/25-di-narrowing.known-missing similarity index 100% rename from test/java/expected/25-di-narrowing.known-missing rename to graph/test/java/expected/25-di-narrowing.known-missing diff --git a/test/java/expected/25-di-narrowing.oracle b/graph/test/java/expected/25-di-narrowing.oracle similarity index 100% rename from test/java/expected/25-di-narrowing.oracle rename to graph/test/java/expected/25-di-narrowing.oracle diff --git a/test/java/expected/26-spring-oracle.config b/graph/test/java/expected/26-spring-oracle.config similarity index 100% rename from test/java/expected/26-spring-oracle.config rename to graph/test/java/expected/26-spring-oracle.config diff --git a/test/java/expected/26-spring-oracle.edges b/graph/test/java/expected/26-spring-oracle.edges similarity index 100% rename from test/java/expected/26-spring-oracle.edges rename to graph/test/java/expected/26-spring-oracle.edges diff --git a/test/java/expected/26-spring-oracle.envelope b/graph/test/java/expected/26-spring-oracle.envelope similarity index 100% rename from test/java/expected/26-spring-oracle.envelope rename to graph/test/java/expected/26-spring-oracle.envelope diff --git a/test/java/expected/26-spring-oracle.spring-oracle b/graph/test/java/expected/26-spring-oracle.spring-oracle similarity index 100% rename from test/java/expected/26-spring-oracle.spring-oracle rename to graph/test/java/expected/26-spring-oracle.spring-oracle diff --git a/test/java/expected/27-messaging-and-grpc.config b/graph/test/java/expected/27-messaging-and-grpc.config similarity index 100% rename from test/java/expected/27-messaging-and-grpc.config rename to graph/test/java/expected/27-messaging-and-grpc.config diff --git a/test/java/expected/27-messaging-and-grpc.edges b/graph/test/java/expected/27-messaging-and-grpc.edges similarity index 100% rename from test/java/expected/27-messaging-and-grpc.edges rename to graph/test/java/expected/27-messaging-and-grpc.edges diff --git a/test/java/expected/27-messaging-and-grpc.envelope b/graph/test/java/expected/27-messaging-and-grpc.envelope similarity index 100% rename from test/java/expected/27-messaging-and-grpc.envelope rename to graph/test/java/expected/27-messaging-and-grpc.envelope diff --git a/test/java/expected/27-messaging-and-grpc.oracle b/graph/test/java/expected/27-messaging-and-grpc.oracle similarity index 100% rename from test/java/expected/27-messaging-and-grpc.oracle rename to graph/test/java/expected/27-messaging-and-grpc.oracle diff --git a/test/java/expected/28-array-constructor-ref.edges b/graph/test/java/expected/28-array-constructor-ref.edges similarity index 100% rename from test/java/expected/28-array-constructor-ref.edges rename to graph/test/java/expected/28-array-constructor-ref.edges diff --git a/test/java/expected/28-array-constructor-ref.oracle b/graph/test/java/expected/28-array-constructor-ref.oracle similarity index 100% rename from test/java/expected/28-array-constructor-ref.oracle rename to graph/test/java/expected/28-array-constructor-ref.oracle diff --git a/test/java/expected/29-qualified-this.edges b/graph/test/java/expected/29-qualified-this.edges similarity index 100% rename from test/java/expected/29-qualified-this.edges rename to graph/test/java/expected/29-qualified-this.edges diff --git a/test/java/expected/29-qualified-this.known-missing b/graph/test/java/expected/29-qualified-this.known-missing similarity index 100% rename from test/java/expected/29-qualified-this.known-missing rename to graph/test/java/expected/29-qualified-this.known-missing diff --git a/test/java/expected/29-qualified-this.oracle b/graph/test/java/expected/29-qualified-this.oracle similarity index 100% rename from test/java/expected/29-qualified-this.oracle rename to graph/test/java/expected/29-qualified-this.oracle diff --git a/test/java/expected/30-dispatch-param-types.edges b/graph/test/java/expected/30-dispatch-param-types.edges similarity index 100% rename from test/java/expected/30-dispatch-param-types.edges rename to graph/test/java/expected/30-dispatch-param-types.edges diff --git a/test/java/expected/30-dispatch-param-types.envelope b/graph/test/java/expected/30-dispatch-param-types.envelope similarity index 100% rename from test/java/expected/30-dispatch-param-types.envelope rename to graph/test/java/expected/30-dispatch-param-types.envelope diff --git a/test/java/expected/30-dispatch-param-types.oracle b/graph/test/java/expected/30-dispatch-param-types.oracle similarity index 100% rename from test/java/expected/30-dispatch-param-types.oracle rename to graph/test/java/expected/30-dispatch-param-types.oracle diff --git a/test/java/expected/31-service-loader.config b/graph/test/java/expected/31-service-loader.config similarity index 100% rename from test/java/expected/31-service-loader.config rename to graph/test/java/expected/31-service-loader.config diff --git a/test/java/expected/31-service-loader.edges b/graph/test/java/expected/31-service-loader.edges similarity index 100% rename from test/java/expected/31-service-loader.edges rename to graph/test/java/expected/31-service-loader.edges diff --git a/test/java/expected/31-service-loader.envelope b/graph/test/java/expected/31-service-loader.envelope similarity index 100% rename from test/java/expected/31-service-loader.envelope rename to graph/test/java/expected/31-service-loader.envelope diff --git a/test/java/expected/31-service-loader.oracle b/graph/test/java/expected/31-service-loader.oracle similarity index 100% rename from test/java/expected/31-service-loader.oracle rename to graph/test/java/expected/31-service-loader.oracle diff --git a/test/java/expected/32-library-handoff-depth.boundary b/graph/test/java/expected/32-library-handoff-depth.boundary similarity index 100% rename from test/java/expected/32-library-handoff-depth.boundary rename to graph/test/java/expected/32-library-handoff-depth.boundary diff --git a/test/java/expected/32-library-handoff-depth.edges b/graph/test/java/expected/32-library-handoff-depth.edges similarity index 100% rename from test/java/expected/32-library-handoff-depth.edges rename to graph/test/java/expected/32-library-handoff-depth.edges diff --git a/test/java/expected/32-library-handoff-depth.oracle b/graph/test/java/expected/32-library-handoff-depth.oracle similarity index 100% rename from test/java/expected/32-library-handoff-depth.oracle rename to graph/test/java/expected/32-library-handoff-depth.oracle diff --git a/test/java/expected/33-compiler-lowering.edges b/graph/test/java/expected/33-compiler-lowering.edges similarity index 100% rename from test/java/expected/33-compiler-lowering.edges rename to graph/test/java/expected/33-compiler-lowering.edges diff --git a/test/java/expected/33-compiler-lowering.full-oracle b/graph/test/java/expected/33-compiler-lowering.full-oracle similarity index 100% rename from test/java/expected/33-compiler-lowering.full-oracle rename to graph/test/java/expected/33-compiler-lowering.full-oracle diff --git a/test/java/expected/33-compiler-lowering.oracle b/graph/test/java/expected/33-compiler-lowering.oracle similarity index 100% rename from test/java/expected/33-compiler-lowering.oracle rename to graph/test/java/expected/33-compiler-lowering.oracle diff --git a/test/java/expected/34-object-members.boundary b/graph/test/java/expected/34-object-members.boundary similarity index 100% rename from test/java/expected/34-object-members.boundary rename to graph/test/java/expected/34-object-members.boundary diff --git a/test/java/expected/34-object-members.edges b/graph/test/java/expected/34-object-members.edges similarity index 100% rename from test/java/expected/34-object-members.edges rename to graph/test/java/expected/34-object-members.edges diff --git a/test/java/expected/34-object-members.envelope b/graph/test/java/expected/34-object-members.envelope similarity index 100% rename from test/java/expected/34-object-members.envelope rename to graph/test/java/expected/34-object-members.envelope diff --git a/test/java/expected/34-object-members.oracle b/graph/test/java/expected/34-object-members.oracle similarity index 100% rename from test/java/expected/34-object-members.oracle rename to graph/test/java/expected/34-object-members.oracle diff --git a/test/java/expected/35-overload-phases.boundary b/graph/test/java/expected/35-overload-phases.boundary similarity index 100% rename from test/java/expected/35-overload-phases.boundary rename to graph/test/java/expected/35-overload-phases.boundary diff --git a/test/java/expected/35-overload-phases.edges b/graph/test/java/expected/35-overload-phases.edges similarity index 100% rename from test/java/expected/35-overload-phases.edges rename to graph/test/java/expected/35-overload-phases.edges diff --git a/test/java/expected/35-overload-phases.oracle b/graph/test/java/expected/35-overload-phases.oracle similarity index 100% rename from test/java/expected/35-overload-phases.oracle rename to graph/test/java/expected/35-overload-phases.oracle diff --git a/test/java/expected/36-generic-two-hop.boundary b/graph/test/java/expected/36-generic-two-hop.boundary similarity index 100% rename from test/java/expected/36-generic-two-hop.boundary rename to graph/test/java/expected/36-generic-two-hop.boundary diff --git a/test/java/expected/36-generic-two-hop.edges b/graph/test/java/expected/36-generic-two-hop.edges similarity index 100% rename from test/java/expected/36-generic-two-hop.edges rename to graph/test/java/expected/36-generic-two-hop.edges diff --git a/test/java/expected/36-generic-two-hop.oracle b/graph/test/java/expected/36-generic-two-hop.oracle similarity index 100% rename from test/java/expected/36-generic-two-hop.oracle rename to graph/test/java/expected/36-generic-two-hop.oracle diff --git a/test/java/expected/37-method-ref-arity.boundary b/graph/test/java/expected/37-method-ref-arity.boundary similarity index 100% rename from test/java/expected/37-method-ref-arity.boundary rename to graph/test/java/expected/37-method-ref-arity.boundary diff --git a/test/java/expected/37-method-ref-arity.edges b/graph/test/java/expected/37-method-ref-arity.edges similarity index 100% rename from test/java/expected/37-method-ref-arity.edges rename to graph/test/java/expected/37-method-ref-arity.edges diff --git a/test/java/expected/37-method-ref-arity.oracle b/graph/test/java/expected/37-method-ref-arity.oracle similarity index 100% rename from test/java/expected/37-method-ref-arity.oracle rename to graph/test/java/expected/37-method-ref-arity.oracle diff --git a/test/java/expected/38-record-patterns.edges b/graph/test/java/expected/38-record-patterns.edges similarity index 100% rename from test/java/expected/38-record-patterns.edges rename to graph/test/java/expected/38-record-patterns.edges diff --git a/test/java/expected/38-record-patterns.oracle b/graph/test/java/expected/38-record-patterns.oracle similarity index 100% rename from test/java/expected/38-record-patterns.oracle rename to graph/test/java/expected/38-record-patterns.oracle diff --git a/test/java/expected/39-library-supertype-dispatch.boundary b/graph/test/java/expected/39-library-supertype-dispatch.boundary similarity index 100% rename from test/java/expected/39-library-supertype-dispatch.boundary rename to graph/test/java/expected/39-library-supertype-dispatch.boundary diff --git a/test/java/expected/39-library-supertype-dispatch.edges b/graph/test/java/expected/39-library-supertype-dispatch.edges similarity index 100% rename from test/java/expected/39-library-supertype-dispatch.edges rename to graph/test/java/expected/39-library-supertype-dispatch.edges diff --git a/test/java/expected/39-library-supertype-dispatch.envelope b/graph/test/java/expected/39-library-supertype-dispatch.envelope similarity index 100% rename from test/java/expected/39-library-supertype-dispatch.envelope rename to graph/test/java/expected/39-library-supertype-dispatch.envelope diff --git a/test/java/expected/39-library-supertype-dispatch.oracle b/graph/test/java/expected/39-library-supertype-dispatch.oracle similarity index 100% rename from test/java/expected/39-library-supertype-dispatch.oracle rename to graph/test/java/expected/39-library-supertype-dispatch.oracle diff --git a/test/java/expected/oracle-agreement.txt b/graph/test/java/expected/oracle-agreement.txt similarity index 100% rename from test/java/expected/oracle-agreement.txt rename to graph/test/java/expected/oracle-agreement.txt diff --git a/test/java/run-tests.sh b/graph/test/java/run-tests.sh similarity index 95% rename from test/java/run-tests.sh rename to graph/test/java/run-tests.sh index ef43d8db7..1ef9c26d9 100755 --- a/test/java/run-tests.sh +++ b/graph/test/java/run-tests.sh @@ -4,7 +4,7 @@ # # For every case in test/java/cases//src: # 1. parse the source to IR (external parser, $AXIOM_PARSER) -# 2. solve with the engine (src/pipeline/run-souffle.sh) +# 2. solve with the engine (graph/pipeline/run-souffle.sh) # 3. COVERAGE GUARD: no call site may vanish silently # 4. normalize the edges to golden form and diff against test/java/expected/.edges # 5. CONFIG REPORT: if the case derives any config-resolution rows (beans, DI edges, @@ -32,7 +32,7 @@ # the debt list cannot silently rot. # # Environment: -# AXIOM_PARSER path to the parser entrypoint (default ../../../Parser/dist/index.js) +# AXIOM_PARSER path to the parser entrypoint (default parser/dist/index.js — the parser in this repository) # # NO EXTERNAL LIBRARY IR IS USED OR REQUIRED — see the note above the EMPTY_LIB line. A case # may however ship its own lib-src/ STUB library (kilobytes, in the repo), which is extracted @@ -52,14 +52,14 @@ set -uo pipefail # coverage guard still proves the site was not silently dropped. # ───────────────────────────────────────────────────────────────────────────── HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting # ── Nothing this suite depends on may be invisible to git ──────────────────── # Runs first because it is cheap and because the fault it catches makes every OTHER # result in this file untrustworthy: a fixture input that .gitignore matches is present # locally, absent from the repository, and so every assertion about it passes here and # fails on a clone. See test/tools/no-ignored-fixtures.sh. -if ! bash "$ROOT/test/tools/no-ignored-fixtures.sh"; then +if ! bash "$ROOT/graph/test/tools/no-ignored-fixtures.sh"; then echo "aborting: a fixture input is not in the repository, so nothing below would be a test" exit 1 fi @@ -68,7 +68,7 @@ fi # is several hundred KB. Every reader that opens an IR CSV then dies AFTER the extraction, the # solve and the oracle have all succeeded. Costs milliseconds and is repo-wide, because the # readers span all three languages. See issue #238. -if ! bash "$ROOT/test/tools/csv-limit-test.sh"; then +if ! bash "$ROOT/graph/test/tools/csv-limit-test.sh"; then echo "aborting: a reader of the IR would die on a large literal" exit 1 fi @@ -77,7 +77,7 @@ fi # header raises StopIteration -- and the TypeScript harness reported that as "refusing to measure # against a drifted schema", the one fault that gate exists to catch. Lints every CSV reader in # the tree for the same shape, because it was in three places and only one crashed. See #244. -if ! bash "$ROOT/test/tools/empty-relation-test.sh"; then +if ! bash "$ROOT/graph/test/tools/empty-relation-test.sh"; then echo "aborting: a reader would die on a relation that simply has no rows" exit 1 fi @@ -85,27 +85,27 @@ fi # Runs early and costs milliseconds, because a wrong key makes every number below meaningless: # the solve is answered from whichever library IR happened to be staged under that key, and it # reports success either way. Cheaper to assert than to debug as a phantom engine regression. -if ! bash "$ROOT/test/tools/portable-stat-test.sh"; then +if ! bash "$ROOT/graph/test/tools/portable-stat-test.sh"; then echo "aborting: the library-facts cache key is not a function of the library IR" exit 1 fi # ── The -I for soufflé's headers must be the one that actually compiles ────── # Also a preflight, and for the same reason: the old resolution returned a path that only built on # the machine it was written on, and every run here compiles the engine through it. -if ! bash "$ROOT/test/tools/souffle-include-test.sh"; then +if ! bash "$ROOT/graph/test/tools/souffle-include-test.sh"; then echo "aborting: the soufflé include path does not resolve to a compilable -I" exit 1 fi # ── The bundle stage must build the language-neutral output ───────────────── # Every solve below ends by joining the raw relations to the IR and writing graph.sqlite -# (src/bundle/SCHEMA.md); graph/*.csv is the same core tables and is written only under +# (graph/bundle/SCHEMA.md); graph/*.csv is the same core tables and is written only under # --debug. A broken bundler fails every case identically, after the # solve's cost; this checks it in milliseconds on hand-written fixtures for all three languages. -if ! bash "$ROOT/test/tools/bundle-test.sh"; then +if ! bash "$ROOT/graph/test/tools/bundle-test.sh"; then echo "aborting: the bundle stage does not produce the documented output" exit 1 fi -PARSER="${AXIOM_PARSER:-$ROOT/../Parser/dist/index.js}" +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" WORK="$HERE/.work" BLESS=0; KEEP=0; ORACLE=0; FILTERS=() for a in "$@"; do case "$a" in @@ -116,7 +116,7 @@ for a in "$@"; do case "$a" in # Runs before any case, because it is not about a case: a relation staged for the client but # not for libraries — or listed in lib.map with a suffix absent from LIB_SIG — is EMPTY on # every run and nothing errors. No golden can see that, so it is checked here. -if ! python3 "$ROOT/test/tools/check_staging.py" --lang java; then +if ! python3 "$ROOT/graph/test/tools/check_staging.py" --lang java; then echo "aborting: the IR staging maps are inconsistent, so some relation silently stages nothing" exit 1 fi @@ -209,7 +209,7 @@ for dir in "$HERE"/cases/*/; do case_lib="$w/lib-ir" fi - if ! bash "$ROOT/src/pipeline/run-souffle.sh" --debug --client-ir "$w/ir" --library "$case_lib" \ + if ! bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --client-ir "$w/ir" --library "$case_lib" \ --intermediate "$w/int" --output "$w/out" >"$w/solve.log" 2>&1; then echo "FAIL (solve — see $w/solve.log)"; fail=$((fail+1)); failed+=("$name"); continue; fi diff --git a/test/java/tools/ClassFileOracle.java b/graph/test/java/tools/ClassFileOracle.java similarity index 100% rename from test/java/tools/ClassFileOracle.java rename to graph/test/java/tools/ClassFileOracle.java diff --git a/test/java/tools/build-jdk-ir.sh b/graph/test/java/tools/build-jdk-ir.sh similarity index 97% rename from test/java/tools/build-jdk-ir.sh rename to graph/test/java/tools/build-jdk-ir.sh index 38e40976c..243e185a9 100755 --- a/test/java/tools/build-jdk-ir.sh +++ b/graph/test/java/tools/build-jdk-ir.sh @@ -36,7 +36,8 @@ SRC="${AXIOM_JDK_SRC:-/Users/swapnilpaliwal/Documents/Java-Projects/java/jdk26u/ # deliberately not decided here. SRCZIP="${AXIOM_JDK_SRCZIP:-$(/usr/libexec/java_home 2>/dev/null)/lib/src.zip}" OUT="${AXIOM_JDK_IR:-/Users/swapnilpaliwal/Documents/AxiomCode/jdk}" -PARSER="${AXIOM_PARSER:-/Users/swapnilpaliwal/Documents/AxiomCode/Parser/dist/index.js}" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" CHECK=0; FORCE=0; KEEP_STALE=0; ONLY=() while [ $# -gt 0 ]; do case "$1" in --src) SRC="$2"; shift 2;; --out) OUT="$2"; shift 2;; --parser) PARSER="$2"; shift 2;; @@ -47,12 +48,12 @@ while [ $# -gt 0 ]; do case "$1" in [ -d "$SRC" ] || { echo "no jdk source at $SRC" >&2; exit 1; } [ -f "$PARSER" ] || { echo "no parser at $PARSER (build it: npm run build)" >&2; exit 1; } -. "$(cd "$(dirname "$0")/../../.." && pwd)/src/pipeline/portable-stat.sh" +. "$ROOT/graph/pipeline/portable-stat.sh" PARSER_REPO="$(cd "$(dirname "$PARSER")/.." && pwd)" PARSER_REV="$(git -C "$PARSER_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown)" # THE REBUILD TRAP: dist/ is a build artefact; checking out a parser branch does not update it. if [ -d "$PARSER_REPO/.git" ]; then - # file_mtime, not `stat -f %m || stat -c %Y`: see src/pipeline/portable-stat.sh. On GNU coreutils + # file_mtime, not `stat -f %m || stat -c %Y`: see graph/pipeline/portable-stat.sh. On GNU coreutils # the old form left a filesystem report in DIST_T, so `[ "$DIST_T" -lt ... ]` exited 2 and this # guard — the one guarding the rebuild trap described at the top of this file — passed for every # input, including a genuinely stale dist/. diff --git a/test/java/tools/build-lib-ir.sh b/graph/test/java/tools/build-lib-ir.sh similarity index 92% rename from test/java/tools/build-lib-ir.sh rename to graph/test/java/tools/build-lib-ir.sh index 87e1f360e..a8fff7621 100755 --- a/test/java/tools/build-lib-ir.sh +++ b/graph/test/java/tools/build-lib-ir.sh @@ -16,7 +16,8 @@ set -uo pipefail OUT="${AXIOM_LIB_IR:-/Users/swapnilpaliwal/Documents/AxiomCode/other-lib}" -PARSER="${AXIOM_PARSER:-/Users/swapnilpaliwal/Documents/AxiomCode/Parser/dist/index.js}" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" COORD=""; CHECK=0; FORCE=0 while [ $# -gt 0 ]; do case "$1" in --coord) COORD="$2"; shift 2;; --out) OUT="$2"; shift 2;; --parser) PARSER="$2"; shift 2;; @@ -24,7 +25,7 @@ while [ $# -gt 0 ]; do case "$1" in -h|--help) sed -n '2,16p' "$0"; exit 0;; *) echo "unknown arg $1" >&2; exit 2;; esac; done [ -f "$PARSER" ] || { echo "no parser at $PARSER (build it: npm run build)" >&2; exit 1; } -. "$(cd "$(dirname "$0")/../../.." && pwd)/src/pipeline/portable-stat.sh" +. "$ROOT/graph/pipeline/portable-stat.sh" # rows(): records, not newlines. all-types.csv is written without a trailing newline (all-methods # is not), so `wc -l` - 1 under-reported every type count by one -- 6,781 against a real 6,782 on # java.base. Display only, but a wrong number in a build report is still a wrong number. @@ -33,7 +34,7 @@ PARSER_REPO="$(cd "$(dirname "$PARSER")/.." && pwd)" PARSER_REV="$(git -C "$PARSER_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown)" # THE REBUILD TRAP: dist/ is a build artefact and a branch switch does not update it. if [ -d "$PARSER_REPO/.git" ]; then - # file_mtime, not `stat -f %m || stat -c %Y` — see src/pipeline/portable-stat.sh. + # file_mtime, not `stat -f %m || stat -c %Y` — see graph/pipeline/portable-stat.sh. DT="$(file_mtime "$PARSER")" || DT="" HT=$(git -C "$PARSER_REPO" log -1 --format=%ct 2>/dev/null || echo 0) if [ -z "$DT" ]; then diff --git a/test/java/tools/bytecode_oracle.py b/graph/test/java/tools/bytecode_oracle.py similarity index 100% rename from test/java/tools/bytecode_oracle.py rename to graph/test/java/tools/bytecode_oracle.py diff --git a/test/java/tools/config_report.py b/graph/test/java/tools/config_report.py similarity index 100% rename from test/java/tools/config_report.py rename to graph/test/java/tools/config_report.py diff --git a/test/java/tools/coverage_guard.py b/graph/test/java/tools/coverage_guard.py similarity index 100% rename from test/java/tools/coverage_guard.py rename to graph/test/java/tools/coverage_guard.py diff --git a/test/java/tools/jdk-ir-stamp-test.sh b/graph/test/java/tools/jdk-ir-stamp-test.sh similarity index 94% rename from test/java/tools/jdk-ir-stamp-test.sh rename to graph/test/java/tools/jdk-ir-stamp-test.sh index 32ca7d18f..4c251a7a8 100755 --- a/test/java/tools/jdk-ir-stamp-test.sh +++ b/graph/test/java/tools/jdk-ir-stamp-test.sh @@ -15,8 +15,8 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../../.." && pwd)" -PARSER="${AXIOM_PARSER:-$ROOT/../Parser/dist/index.js}" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" [ -f "$PARSER" ] || { echo "jdk-ir-stamp: SKIP (no parser at $PARSER)"; exit 0; } command -v node >/dev/null 2>&1 || { echo "jdk-ir-stamp: SKIP (no node)"; exit 0; } diff --git a/test/java/tools/lib_names.py b/graph/test/java/tools/lib_names.py similarity index 100% rename from test/java/tools/lib_names.py rename to graph/test/java/tools/lib_names.py diff --git a/test/java/tools/normalize_edges.py b/graph/test/java/tools/normalize_edges.py similarity index 100% rename from test/java/tools/normalize_edges.py rename to graph/test/java/tools/normalize_edges.py diff --git a/test/java/tools/oracle_agreement.py b/graph/test/java/tools/oracle_agreement.py similarity index 100% rename from test/java/tools/oracle_agreement.py rename to graph/test/java/tools/oracle_agreement.py diff --git a/test/java/tools/oracle_diff.py b/graph/test/java/tools/oracle_diff.py similarity index 100% rename from test/java/tools/oracle_diff.py rename to graph/test/java/tools/oracle_diff.py diff --git a/test/java/tools/score_boundary.py b/graph/test/java/tools/score_boundary.py similarity index 100% rename from test/java/tools/score_boundary.py rename to graph/test/java/tools/score_boundary.py diff --git a/test/java/tools/score_scale.py b/graph/test/java/tools/score_scale.py similarity index 100% rename from test/java/tools/score_scale.py rename to graph/test/java/tools/score_scale.py diff --git a/test/java/tools/set_method_flag.py b/graph/test/java/tools/set_method_flag.py similarity index 100% rename from test/java/tools/set_method_flag.py rename to graph/test/java/tools/set_method_flag.py diff --git a/test/java/tools/spring-oracle/DumpContext.java b/graph/test/java/tools/spring-oracle/DumpContext.java similarity index 100% rename from test/java/tools/spring-oracle/DumpContext.java rename to graph/test/java/tools/spring-oracle/DumpContext.java diff --git a/test/java/tools/spring_oracle.sh b/graph/test/java/tools/spring_oracle.sh similarity index 100% rename from test/java/tools/spring_oracle.sh rename to graph/test/java/tools/spring_oracle.sh diff --git a/test/java/tools/spring_oracle_diff.py b/graph/test/java/tools/spring_oracle_diff.py similarity index 100% rename from test/java/tools/spring_oracle_diff.py rename to graph/test/java/tools/spring_oracle_diff.py diff --git a/test/java/tools/synthetic-callee-test.sh b/graph/test/java/tools/synthetic-callee-test.sh similarity index 100% rename from test/java/tools/synthetic-callee-test.sh rename to graph/test/java/tools/synthetic-callee-test.sh diff --git a/test/java/torture/.gitignore b/graph/test/java/torture/.gitignore similarity index 100% rename from test/java/torture/.gitignore rename to graph/test/java/torture/.gitignore diff --git a/test/java/torture/README.md b/graph/test/java/torture/README.md similarity index 100% rename from test/java/torture/README.md rename to graph/test/java/torture/README.md diff --git a/test/java/torture/client/it/example/F11Packages.java b/graph/test/java/torture/client/it/example/F11Packages.java similarity index 100% rename from test/java/torture/client/it/example/F11Packages.java rename to graph/test/java/torture/client/it/example/F11Packages.java diff --git a/test/java/torture/client/torture/F01Polymorphism.java b/graph/test/java/torture/client/torture/F01Polymorphism.java similarity index 100% rename from test/java/torture/client/torture/F01Polymorphism.java rename to graph/test/java/torture/client/torture/F01Polymorphism.java diff --git a/test/java/torture/client/torture/F02Generics.java b/graph/test/java/torture/client/torture/F02Generics.java similarity index 100% rename from test/java/torture/client/torture/F02Generics.java rename to graph/test/java/torture/client/torture/F02Generics.java diff --git a/test/java/torture/client/torture/F03Var.java b/graph/test/java/torture/client/torture/F03Var.java similarity index 100% rename from test/java/torture/client/torture/F03Var.java rename to graph/test/java/torture/client/torture/F03Var.java diff --git a/test/java/torture/client/torture/F04Events.java b/graph/test/java/torture/client/torture/F04Events.java similarity index 100% rename from test/java/torture/client/torture/F04Events.java rename to graph/test/java/torture/client/torture/F04Events.java diff --git a/test/java/torture/client/torture/F05Annotations.java b/graph/test/java/torture/client/torture/F05Annotations.java similarity index 100% rename from test/java/torture/client/torture/F05Annotations.java rename to graph/test/java/torture/client/torture/F05Annotations.java diff --git a/test/java/torture/client/torture/F06Functional.java b/graph/test/java/torture/client/torture/F06Functional.java similarity index 100% rename from test/java/torture/client/torture/F06Functional.java rename to graph/test/java/torture/client/torture/F06Functional.java diff --git a/test/java/torture/client/torture/F07Modern.java b/graph/test/java/torture/client/torture/F07Modern.java similarity index 100% rename from test/java/torture/client/torture/F07Modern.java rename to graph/test/java/torture/client/torture/F07Modern.java diff --git a/test/java/torture/client/torture/F08Nesting.java b/graph/test/java/torture/client/torture/F08Nesting.java similarity index 100% rename from test/java/torture/client/torture/F08Nesting.java rename to graph/test/java/torture/client/torture/F08Nesting.java diff --git a/test/java/torture/client/torture/F09BlindSpots.java b/graph/test/java/torture/client/torture/F09BlindSpots.java similarity index 100% rename from test/java/torture/client/torture/F09BlindSpots.java rename to graph/test/java/torture/client/torture/F09BlindSpots.java diff --git a/test/java/torture/client/torture/F10Flow.java b/graph/test/java/torture/client/torture/F10Flow.java similarity index 100% rename from test/java/torture/client/torture/F10Flow.java rename to graph/test/java/torture/client/torture/F10Flow.java diff --git a/test/java/torture/expected/coverage.txt b/graph/test/java/torture/expected/coverage.txt similarity index 100% rename from test/java/torture/expected/coverage.txt rename to graph/test/java/torture/expected/coverage.txt diff --git a/test/java/torture/expected/scale.txt b/graph/test/java/torture/expected/scale.txt similarity index 100% rename from test/java/torture/expected/scale.txt rename to graph/test/java/torture/expected/scale.txt diff --git a/test/java/torture/expected/torture.edges b/graph/test/java/torture/expected/torture.edges similarity index 100% rename from test/java/torture/expected/torture.edges rename to graph/test/java/torture/expected/torture.edges diff --git a/test/java/torture/harness/run.sh b/graph/test/java/torture/harness/run.sh similarity index 93% rename from test/java/torture/harness/run.sh rename to graph/test/java/torture/harness/run.sh index 0508ff57f..df740df49 100755 --- a/test/java/torture/harness/run.sh +++ b/graph/test/java/torture/harness/run.sh @@ -15,10 +15,10 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail R="$(cd "$(dirname "$0")/.." && pwd)" -ENG="$(cd "$R/../../.." && pwd)" -TOOLS="$ENG/test/java/tools" -SHARED="$ENG/test/tools" # language-independent checks live here (compare_runs, check_staging) -PARSER="${AXIOM_PARSER:-$ENG/../Parser/dist/index.js}" +ENG="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +TOOLS="$ENG/graph/test/java/tools" +SHARED="$ENG/graph/test/tools" # language-independent checks live here (compare_runs, check_staging) +PARSER="${AXIOM_PARSER:-$ENG/parser/dist/index.js}" BLESS=0; [ "${1:-}" = "--bless" ] && BLESS=1 cd "$R" [ -f "$PARSER" ] || { echo "SKIP: no parser at $PARSER (set AXIOM_PARSER)"; exit 77; } @@ -54,7 +54,7 @@ if [ "$jdk_mods" = 0 ]; then echo " this score measures the staging, not the rules. Set AXIOM_JDK_IR." fi -bash "$ENG/src/pipeline/run-souffle.sh" --debug --client-ir .work/client-ir --library "$LIBROOT" \ +bash "$ENG/graph/pipeline/run-souffle.sh" --debug --client-ir .work/client-ir --library "$LIBROOT" \ --intermediate .work/int --output .work/out >.work/solve.log 2>&1 || { echo "FAIL (solve)"; tail -5 .work/solve.log; exit 1; } # ── no call site may vanish ──────────────────────────────────────────────────────────────────── @@ -85,7 +85,7 @@ python3 "$TOOLS/score_scale.py" .work/client-ir .work/out/raw .work/scale-lb.txt # ── INVARIANT: staging a library must never REMOVE an answer ─────────────────────────────────── # Nothing else here can catch that, because every other assertion fixes the library input; a site # that loses its answer only shows up when the two runs are compared to each other. -bash "$ENG/src/pipeline/run-souffle.sh" --debug --client-ir .work/client-ir --library .work/emptylib \ +bash "$ENG/graph/pipeline/run-souffle.sh" --debug --client-ir .work/client-ir --library .work/emptylib \ --intermediate .work/int-nolib --output .work/out-nolib >/dev/null 2>&1 if ! python3 "$SHARED/compare_runs.py" .work/out-nolib/raw .work/out/raw --top 5 > .work/monotonicity.txt 2>&1; then echo "FAIL (library monotonicity: staging the stub library removed an answer)" diff --git a/test/java/torture/harness/score.py b/graph/test/java/torture/harness/score.py similarity index 100% rename from test/java/torture/harness/score.py rename to graph/test/java/torture/harness/score.py diff --git a/test/java/torture/lib/dep/Bus.java b/graph/test/java/torture/lib/dep/Bus.java similarity index 100% rename from test/java/torture/lib/dep/Bus.java rename to graph/test/java/torture/lib/dep/Bus.java diff --git a/test/java/torture/lib/dep/Event.java b/graph/test/java/torture/lib/dep/Event.java similarity index 100% rename from test/java/torture/lib/dep/Event.java rename to graph/test/java/torture/lib/dep/Event.java diff --git a/test/java/torture/lib/dep/Handler.java b/graph/test/java/torture/lib/dep/Handler.java similarity index 100% rename from test/java/torture/lib/dep/Handler.java rename to graph/test/java/torture/lib/dep/Handler.java diff --git a/test/java/torture/lib/dep/Registry.java b/graph/test/java/torture/lib/dep/Registry.java similarity index 100% rename from test/java/torture/lib/dep/Registry.java rename to graph/test/java/torture/lib/dep/Registry.java diff --git a/test/python/.gitignore b/graph/test/python/.gitignore similarity index 100% rename from test/python/.gitignore rename to graph/test/python/.gitignore diff --git a/test/python/cases/01-plain-calls/src/main.py b/graph/test/python/cases/01-plain-calls/src/main.py similarity index 100% rename from test/python/cases/01-plain-calls/src/main.py rename to graph/test/python/cases/01-plain-calls/src/main.py diff --git a/test/python/cases/02-methods/src/main.py b/graph/test/python/cases/02-methods/src/main.py similarity index 100% rename from test/python/cases/02-methods/src/main.py rename to graph/test/python/cases/02-methods/src/main.py diff --git a/test/python/cases/03-single-inheritance/src/main.py b/graph/test/python/cases/03-single-inheritance/src/main.py similarity index 100% rename from test/python/cases/03-single-inheritance/src/main.py rename to graph/test/python/cases/03-single-inheritance/src/main.py diff --git a/test/python/cases/04-mro-diamond/src/main.py b/graph/test/python/cases/04-mro-diamond/src/main.py similarity index 100% rename from test/python/cases/04-mro-diamond/src/main.py rename to graph/test/python/cases/04-mro-diamond/src/main.py diff --git a/test/python/cases/05-super/src/main.py b/graph/test/python/cases/05-super/src/main.py similarity index 100% rename from test/python/cases/05-super/src/main.py rename to graph/test/python/cases/05-super/src/main.py diff --git a/test/python/cases/06-duck-typing/src/main.py b/graph/test/python/cases/06-duck-typing/src/main.py similarity index 100% rename from test/python/cases/06-duck-typing/src/main.py rename to graph/test/python/cases/06-duck-typing/src/main.py diff --git a/test/python/cases/07-decorators/src/main.py b/graph/test/python/cases/07-decorators/src/main.py similarity index 100% rename from test/python/cases/07-decorators/src/main.py rename to graph/test/python/cases/07-decorators/src/main.py diff --git a/test/python/cases/08-callables/src/main.py b/graph/test/python/cases/08-callables/src/main.py similarity index 100% rename from test/python/cases/08-callables/src/main.py rename to graph/test/python/cases/08-callables/src/main.py diff --git a/test/python/cases/09-args-forwarding/src/main.py b/graph/test/python/cases/09-args-forwarding/src/main.py similarity index 100% rename from test/python/cases/09-args-forwarding/src/main.py rename to graph/test/python/cases/09-args-forwarding/src/main.py diff --git a/test/python/cases/10-comprehension-async/src/main.py b/graph/test/python/cases/10-comprehension-async/src/main.py similarity index 100% rename from test/python/cases/10-comprehension-async/src/main.py rename to graph/test/python/cases/10-comprehension-async/src/main.py diff --git a/test/python/cases/11-imports/src/alpha.py b/graph/test/python/cases/11-imports/src/alpha.py similarity index 100% rename from test/python/cases/11-imports/src/alpha.py rename to graph/test/python/cases/11-imports/src/alpha.py diff --git a/test/python/cases/11-imports/src/beta.py b/graph/test/python/cases/11-imports/src/beta.py similarity index 100% rename from test/python/cases/11-imports/src/beta.py rename to graph/test/python/cases/11-imports/src/beta.py diff --git a/test/python/cases/11-imports/src/main.py b/graph/test/python/cases/11-imports/src/main.py similarity index 100% rename from test/python/cases/11-imports/src/main.py rename to graph/test/python/cases/11-imports/src/main.py diff --git a/test/python/cases/11-imports/src/pkg/__init__.py b/graph/test/python/cases/11-imports/src/pkg/__init__.py similarity index 100% rename from test/python/cases/11-imports/src/pkg/__init__.py rename to graph/test/python/cases/11-imports/src/pkg/__init__.py diff --git a/test/python/cases/11-imports/src/pkg/core.py b/graph/test/python/cases/11-imports/src/pkg/core.py similarity index 100% rename from test/python/cases/11-imports/src/pkg/core.py rename to graph/test/python/cases/11-imports/src/pkg/core.py diff --git a/test/python/cases/11-imports/src/pkg/util.py b/graph/test/python/cases/11-imports/src/pkg/util.py similarity index 100% rename from test/python/cases/11-imports/src/pkg/util.py rename to graph/test/python/cases/11-imports/src/pkg/util.py diff --git a/test/python/cases/12-blind-spots/src/main.py b/graph/test/python/cases/12-blind-spots/src/main.py similarity index 100% rename from test/python/cases/12-blind-spots/src/main.py rename to graph/test/python/cases/12-blind-spots/src/main.py diff --git a/test/python/ci-conservation.sh b/graph/test/python/ci-conservation.sh similarity index 94% rename from test/python/ci-conservation.sh rename to graph/test/python/ci-conservation.sh index 79ddfa757..6eb2323a2 100755 --- a/test/python/ci-conservation.sh +++ b/graph/test/python/ci-conservation.sh @@ -20,7 +20,7 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting # Resolve the pinned interpreter through PATH, never as an absolute prefix. # run-tests.sh states the reason for its own copy of this and it applies verbatim # here: "pinning an absolute PATH is a different thing, and the wrong one" — a diff --git a/test/python/expected/.gitkeep b/graph/test/python/expected/.gitkeep similarity index 100% rename from test/python/expected/.gitkeep rename to graph/test/python/expected/.gitkeep diff --git a/test/python/expected/01-plain-calls.edges b/graph/test/python/expected/01-plain-calls.edges similarity index 100% rename from test/python/expected/01-plain-calls.edges rename to graph/test/python/expected/01-plain-calls.edges diff --git a/test/python/expected/01-plain-calls.tiers b/graph/test/python/expected/01-plain-calls.tiers similarity index 100% rename from test/python/expected/01-plain-calls.tiers rename to graph/test/python/expected/01-plain-calls.tiers diff --git a/test/python/expected/02-methods.edges b/graph/test/python/expected/02-methods.edges similarity index 100% rename from test/python/expected/02-methods.edges rename to graph/test/python/expected/02-methods.edges diff --git a/test/python/expected/02-methods.tiers b/graph/test/python/expected/02-methods.tiers similarity index 100% rename from test/python/expected/02-methods.tiers rename to graph/test/python/expected/02-methods.tiers diff --git a/test/python/expected/03-single-inheritance.edges b/graph/test/python/expected/03-single-inheritance.edges similarity index 100% rename from test/python/expected/03-single-inheritance.edges rename to graph/test/python/expected/03-single-inheritance.edges diff --git a/test/python/expected/03-single-inheritance.envelope b/graph/test/python/expected/03-single-inheritance.envelope similarity index 100% rename from test/python/expected/03-single-inheritance.envelope rename to graph/test/python/expected/03-single-inheritance.envelope diff --git a/test/python/expected/03-single-inheritance.tiers b/graph/test/python/expected/03-single-inheritance.tiers similarity index 100% rename from test/python/expected/03-single-inheritance.tiers rename to graph/test/python/expected/03-single-inheritance.tiers diff --git a/test/python/expected/04-mro-diamond.edges b/graph/test/python/expected/04-mro-diamond.edges similarity index 100% rename from test/python/expected/04-mro-diamond.edges rename to graph/test/python/expected/04-mro-diamond.edges diff --git a/test/python/expected/04-mro-diamond.envelope b/graph/test/python/expected/04-mro-diamond.envelope similarity index 100% rename from test/python/expected/04-mro-diamond.envelope rename to graph/test/python/expected/04-mro-diamond.envelope diff --git a/test/python/expected/04-mro-diamond.tiers b/graph/test/python/expected/04-mro-diamond.tiers similarity index 100% rename from test/python/expected/04-mro-diamond.tiers rename to graph/test/python/expected/04-mro-diamond.tiers diff --git a/test/python/expected/05-super.edges b/graph/test/python/expected/05-super.edges similarity index 100% rename from test/python/expected/05-super.edges rename to graph/test/python/expected/05-super.edges diff --git a/test/python/expected/05-super.envelope b/graph/test/python/expected/05-super.envelope similarity index 100% rename from test/python/expected/05-super.envelope rename to graph/test/python/expected/05-super.envelope diff --git a/test/python/expected/05-super.tiers b/graph/test/python/expected/05-super.tiers similarity index 100% rename from test/python/expected/05-super.tiers rename to graph/test/python/expected/05-super.tiers diff --git a/test/python/expected/06-duck-typing.edges b/graph/test/python/expected/06-duck-typing.edges similarity index 100% rename from test/python/expected/06-duck-typing.edges rename to graph/test/python/expected/06-duck-typing.edges diff --git a/test/python/expected/06-duck-typing.tiers b/graph/test/python/expected/06-duck-typing.tiers similarity index 100% rename from test/python/expected/06-duck-typing.tiers rename to graph/test/python/expected/06-duck-typing.tiers diff --git a/test/python/expected/07-decorators.edges b/graph/test/python/expected/07-decorators.edges similarity index 100% rename from test/python/expected/07-decorators.edges rename to graph/test/python/expected/07-decorators.edges diff --git a/test/python/expected/07-decorators.tiers b/graph/test/python/expected/07-decorators.tiers similarity index 100% rename from test/python/expected/07-decorators.tiers rename to graph/test/python/expected/07-decorators.tiers diff --git a/test/python/expected/08-callables.edges b/graph/test/python/expected/08-callables.edges similarity index 100% rename from test/python/expected/08-callables.edges rename to graph/test/python/expected/08-callables.edges diff --git a/test/python/expected/08-callables.tiers b/graph/test/python/expected/08-callables.tiers similarity index 100% rename from test/python/expected/08-callables.tiers rename to graph/test/python/expected/08-callables.tiers diff --git a/test/python/expected/09-args-forwarding.edges b/graph/test/python/expected/09-args-forwarding.edges similarity index 100% rename from test/python/expected/09-args-forwarding.edges rename to graph/test/python/expected/09-args-forwarding.edges diff --git a/test/python/expected/09-args-forwarding.tiers b/graph/test/python/expected/09-args-forwarding.tiers similarity index 100% rename from test/python/expected/09-args-forwarding.tiers rename to graph/test/python/expected/09-args-forwarding.tiers diff --git a/test/python/expected/10-comprehension-async.edges b/graph/test/python/expected/10-comprehension-async.edges similarity index 100% rename from test/python/expected/10-comprehension-async.edges rename to graph/test/python/expected/10-comprehension-async.edges diff --git a/test/python/expected/10-comprehension-async.tiers b/graph/test/python/expected/10-comprehension-async.tiers similarity index 100% rename from test/python/expected/10-comprehension-async.tiers rename to graph/test/python/expected/10-comprehension-async.tiers diff --git a/test/python/expected/11-imports.edges b/graph/test/python/expected/11-imports.edges similarity index 100% rename from test/python/expected/11-imports.edges rename to graph/test/python/expected/11-imports.edges diff --git a/test/python/expected/11-imports.tiers b/graph/test/python/expected/11-imports.tiers similarity index 100% rename from test/python/expected/11-imports.tiers rename to graph/test/python/expected/11-imports.tiers diff --git a/test/python/expected/12-blind-spots.edges b/graph/test/python/expected/12-blind-spots.edges similarity index 100% rename from test/python/expected/12-blind-spots.edges rename to graph/test/python/expected/12-blind-spots.edges diff --git a/test/python/expected/12-blind-spots.known-missing b/graph/test/python/expected/12-blind-spots.known-missing similarity index 100% rename from test/python/expected/12-blind-spots.known-missing rename to graph/test/python/expected/12-blind-spots.known-missing diff --git a/test/python/expected/12-blind-spots.tiers b/graph/test/python/expected/12-blind-spots.tiers similarity index 100% rename from test/python/expected/12-blind-spots.tiers rename to graph/test/python/expected/12-blind-spots.tiers diff --git a/test/python/expected/project-name-collision.tiers b/graph/test/python/expected/project-name-collision.tiers similarity index 100% rename from test/python/expected/project-name-collision.tiers rename to graph/test/python/expected/project-name-collision.tiers diff --git a/test/python/expected/project-two-service-fastapi.tiers b/graph/test/python/expected/project-two-service-fastapi.tiers similarity index 100% rename from test/python/expected/project-two-service-fastapi.tiers rename to graph/test/python/expected/project-two-service-fastapi.tiers diff --git a/test/python/projects/README.md b/graph/test/python/projects/README.md similarity index 98% rename from test/python/projects/README.md rename to graph/test/python/projects/README.md index a13e4fbed..b8346bc41 100644 --- a/test/python/projects/README.md +++ b/graph/test/python/projects/README.md @@ -5,8 +5,8 @@ opposite: realistic projects big enough that mechanisms interact, scored against CPython ground truth (no frozen lock — the oracle is rebuilt each run). ```bash -node ../../../../Parser/dist/index.js two-service-fastapi/ p false /tmp/ir -bash ../../../src/pipeline/run-souffle.sh --language python \ +node ../../../parser/dist/index.js two-service-fastapi/ p false /tmp/ir +bash ../../../graph/pipeline/run-souffle.sh --language python \ --client-ir /tmp/ir --library ~/Documents/AxiomCode/python/v3.10.4 \ --intermediate /tmp/int --output /tmp/out ``` diff --git a/test/python/projects/name-collision/geometry/__init__.py b/graph/test/python/projects/name-collision/geometry/__init__.py similarity index 100% rename from test/python/projects/name-collision/geometry/__init__.py rename to graph/test/python/projects/name-collision/geometry/__init__.py diff --git a/test/python/projects/name-collision/geometry/shapes.py b/graph/test/python/projects/name-collision/geometry/shapes.py similarity index 100% rename from test/python/projects/name-collision/geometry/shapes.py rename to graph/test/python/projects/name-collision/geometry/shapes.py diff --git a/test/python/projects/name-collision/journey.py b/graph/test/python/projects/name-collision/journey.py similarity index 100% rename from test/python/projects/name-collision/journey.py rename to graph/test/python/projects/name-collision/journey.py diff --git a/test/python/projects/name-collision/legacy/__init__.py b/graph/test/python/projects/name-collision/legacy/__init__.py similarity index 100% rename from test/python/projects/name-collision/legacy/__init__.py rename to graph/test/python/projects/name-collision/legacy/__init__.py diff --git a/test/python/projects/name-collision/legacy/routes.py b/graph/test/python/projects/name-collision/legacy/routes.py similarity index 100% rename from test/python/projects/name-collision/legacy/routes.py rename to graph/test/python/projects/name-collision/legacy/routes.py diff --git a/test/python/projects/name-collision/main.py b/graph/test/python/projects/name-collision/main.py similarity index 100% rename from test/python/projects/name-collision/main.py rename to graph/test/python/projects/name-collision/main.py diff --git a/test/python/projects/name-collision/metrics/__init__.py b/graph/test/python/projects/name-collision/metrics/__init__.py similarity index 100% rename from test/python/projects/name-collision/metrics/__init__.py rename to graph/test/python/projects/name-collision/metrics/__init__.py diff --git a/test/python/projects/name-collision/metrics/distance.py b/graph/test/python/projects/name-collision/metrics/distance.py similarity index 100% rename from test/python/projects/name-collision/metrics/distance.py rename to graph/test/python/projects/name-collision/metrics/distance.py diff --git a/test/python/projects/name-collision/transit/__init__.py b/graph/test/python/projects/name-collision/transit/__init__.py similarity index 100% rename from test/python/projects/name-collision/transit/__init__.py rename to graph/test/python/projects/name-collision/transit/__init__.py diff --git a/test/python/projects/name-collision/transit/modes.py b/graph/test/python/projects/name-collision/transit/modes.py similarity index 100% rename from test/python/projects/name-collision/transit/modes.py rename to graph/test/python/projects/name-collision/transit/modes.py diff --git a/test/python/projects/two-service-fastapi/inventory_service/__init__.py b/graph/test/python/projects/two-service-fastapi/inventory_service/__init__.py similarity index 100% rename from test/python/projects/two-service-fastapi/inventory_service/__init__.py rename to graph/test/python/projects/two-service-fastapi/inventory_service/__init__.py diff --git a/test/python/projects/two-service-fastapi/inventory_service/app.py b/graph/test/python/projects/two-service-fastapi/inventory_service/app.py similarity index 100% rename from test/python/projects/two-service-fastapi/inventory_service/app.py rename to graph/test/python/projects/two-service-fastapi/inventory_service/app.py diff --git a/test/python/projects/two-service-fastapi/inventory_service/domain.py b/graph/test/python/projects/two-service-fastapi/inventory_service/domain.py similarity index 100% rename from test/python/projects/two-service-fastapi/inventory_service/domain.py rename to graph/test/python/projects/two-service-fastapi/inventory_service/domain.py diff --git a/test/python/projects/two-service-fastapi/inventory_service/handlers.py b/graph/test/python/projects/two-service-fastapi/inventory_service/handlers.py similarity index 100% rename from test/python/projects/two-service-fastapi/inventory_service/handlers.py rename to graph/test/python/projects/two-service-fastapi/inventory_service/handlers.py diff --git a/test/python/projects/two-service-fastapi/inventory_service/store.py b/graph/test/python/projects/two-service-fastapi/inventory_service/store.py similarity index 100% rename from test/python/projects/two-service-fastapi/inventory_service/store.py rename to graph/test/python/projects/two-service-fastapi/inventory_service/store.py diff --git a/test/python/projects/two-service-fastapi/main.py b/graph/test/python/projects/two-service-fastapi/main.py similarity index 100% rename from test/python/projects/two-service-fastapi/main.py rename to graph/test/python/projects/two-service-fastapi/main.py diff --git a/test/python/projects/two-service-fastapi/orders_service/__init__.py b/graph/test/python/projects/two-service-fastapi/orders_service/__init__.py similarity index 100% rename from test/python/projects/two-service-fastapi/orders_service/__init__.py rename to graph/test/python/projects/two-service-fastapi/orders_service/__init__.py diff --git a/test/python/projects/two-service-fastapi/orders_service/app.py b/graph/test/python/projects/two-service-fastapi/orders_service/app.py similarity index 100% rename from test/python/projects/two-service-fastapi/orders_service/app.py rename to graph/test/python/projects/two-service-fastapi/orders_service/app.py diff --git a/test/python/projects/two-service-fastapi/orders_service/client.py b/graph/test/python/projects/two-service-fastapi/orders_service/client.py similarity index 100% rename from test/python/projects/two-service-fastapi/orders_service/client.py rename to graph/test/python/projects/two-service-fastapi/orders_service/client.py diff --git a/test/python/projects/two-service-fastapi/orders_service/domain.py b/graph/test/python/projects/two-service-fastapi/orders_service/domain.py similarity index 100% rename from test/python/projects/two-service-fastapi/orders_service/domain.py rename to graph/test/python/projects/two-service-fastapi/orders_service/domain.py diff --git a/test/python/projects/two-service-fastapi/orders_service/handlers.py b/graph/test/python/projects/two-service-fastapi/orders_service/handlers.py similarity index 100% rename from test/python/projects/two-service-fastapi/orders_service/handlers.py rename to graph/test/python/projects/two-service-fastapi/orders_service/handlers.py diff --git a/test/python/projects/two-service-fastapi/orders_service/store.py b/graph/test/python/projects/two-service-fastapi/orders_service/store.py similarity index 100% rename from test/python/projects/two-service-fastapi/orders_service/store.py rename to graph/test/python/projects/two-service-fastapi/orders_service/store.py diff --git a/test/python/projects/two-service-fastapi/shared/__init__.py b/graph/test/python/projects/two-service-fastapi/shared/__init__.py similarity index 100% rename from test/python/projects/two-service-fastapi/shared/__init__.py rename to graph/test/python/projects/two-service-fastapi/shared/__init__.py diff --git a/test/python/projects/two-service-fastapi/shared/errors.py b/graph/test/python/projects/two-service-fastapi/shared/errors.py similarity index 100% rename from test/python/projects/two-service-fastapi/shared/errors.py rename to graph/test/python/projects/two-service-fastapi/shared/errors.py diff --git a/test/python/projects/two-service-fastapi/shared/models.py b/graph/test/python/projects/two-service-fastapi/shared/models.py similarity index 100% rename from test/python/projects/two-service-fastapi/shared/models.py rename to graph/test/python/projects/two-service-fastapi/shared/models.py diff --git a/test/python/projects/two-service-fastapi/shared/protocols.py b/graph/test/python/projects/two-service-fastapi/shared/protocols.py similarity index 100% rename from test/python/projects/two-service-fastapi/shared/protocols.py rename to graph/test/python/projects/two-service-fastapi/shared/protocols.py diff --git a/test/python/projects/two-service-fastapi/shared/retry.py b/graph/test/python/projects/two-service-fastapi/shared/retry.py similarity index 100% rename from test/python/projects/two-service-fastapi/shared/retry.py rename to graph/test/python/projects/two-service-fastapi/shared/retry.py diff --git a/test/python/projects/two-service-fastapi/shared/serialization.py b/graph/test/python/projects/two-service-fastapi/shared/serialization.py similarity index 100% rename from test/python/projects/two-service-fastapi/shared/serialization.py rename to graph/test/python/projects/two-service-fastapi/shared/serialization.py diff --git a/test/python/projects/two-service-fastapi/shared/transport.py b/graph/test/python/projects/two-service-fastapi/shared/transport.py similarity index 100% rename from test/python/projects/two-service-fastapi/shared/transport.py rename to graph/test/python/projects/two-service-fastapi/shared/transport.py diff --git a/test/python/run-tests.sh b/graph/test/python/run-tests.sh similarity index 95% rename from test/python/run-tests.sh rename to graph/test/python/run-tests.sh index 00d194199..396e4df95 100755 --- a/test/python/run-tests.sh +++ b/graph/test/python/run-tests.sh @@ -4,7 +4,7 @@ # # For every case in test/python/cases//src: # 1. parse the source to IR (external parser, $AXIOM_PARSER) -# 2. solve with the engine (src/pipeline/run-souffle.sh --language python) +# 2. solve with the engine (graph/pipeline/run-souffle.sh --language python) # 3. COVERAGE GUARD: no call site may vanish silently # 4. GOLDEN DIFF: normalized edges vs expected/.edges # 4b. TIER/REASON CENSUS: site counts, tier mix and unresolved reasons vs @@ -35,7 +35,7 @@ # permanent expectation. # # Environment: -# AXIOM_PARSER parser entrypoint (default ../../../Parser/dist/index.js) +# AXIOM_PARSER parser entrypoint (default parser/dist/index.js — the parser in this repository) # AXIOM_PY_ORACLE harness checkout (--oracle only; default ../../../callchain-oracle/python) # AXIOM_PY_PYTHON pinned interpreter (default python3.10; also used by the # tier-1 attribution preflight, which is version-sensitive) @@ -44,33 +44,33 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting # ── Nothing this suite depends on may be invisible to git ──────────────────── # Runs first because it is cheap and because the fault it catches makes every OTHER # result in this file untrustworthy: a fixture input that .gitignore matches is present # locally, absent from the repository, and so every assertion about it passes here and # fails on a clone. See test/tools/no-ignored-fixtures.sh. -if ! bash "$ROOT/test/tools/no-ignored-fixtures.sh"; then +if ! bash "$ROOT/graph/test/tools/no-ignored-fixtures.sh"; then echo "aborting: a fixture input is not in the repository, so nothing below would be a test" exit 1 fi # ── The bundle stage must build the language-neutral output ───────────────── # Every solve below ends by joining the raw relations to the IR and writing graph.sqlite -# (src/bundle/SCHEMA.md); graph/*.csv is the same core tables and is written only under +# (graph/bundle/SCHEMA.md); graph/*.csv is the same core tables and is written only under # --debug. A broken bundler fails every case identically, after the # solve's cost; this checks it in milliseconds on hand-written fixtures for all three languages. -if ! bash "$ROOT/test/tools/bundle-test.sh"; then +if ! bash "$ROOT/graph/test/tools/bundle-test.sh"; then echo "aborting: the bundle stage does not produce the documented output" exit 1 fi -PARSER="${AXIOM_PARSER:-$ROOT/../Parser/dist/index.js}" +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" ORACLE_HOME="${AXIOM_PY_ORACLE:-$ROOT/../callchain-oracle/python}" # The oracle is PINNED to 3.10.4 because opcode shapes are not stable across minor # versions -- but pinning an absolute PATH is a different thing, and the wrong one: a # Homebrew-on-Intel-macOS location makes --oracle unrunnable on Linux, Apple Silicon, # pyenv, or any CI image, reported as a "not found" that reads like a broken checkout. -# Resolve it the way src/pipeline/run-souffle.sh resolves the souffle headers: search +# Resolve it the way graph/pipeline/run-souffle.sh resolves the souffle headers: search # PATH, never hardcode a prefix. AXIOM_PY_PYTHON still overrides for an unusual install. find_pinned_python() { local c p @@ -225,7 +225,7 @@ fi # prefix dropped) and Python keeps the prefix (`py_method` / `lib_py_method`), so # --lang python reported 19 failures that were all the tool's. It now infers the # convention from the maps. Issue #136 is what this catches, one language over. -if ! python3 "$ROOT/test/tools/check_staging.py" --lang python; then +if ! python3 "$ROOT/graph/test/tools/check_staging.py" --lang python; then echo "aborting: the IR staging maps are inconsistent, so some relation silently stages nothing" exit 1 fi @@ -245,10 +245,10 @@ EMPTY_LIB="$WORK/.empty-library"; mkdir -p "$EMPTY_LIB" # so precisely, or every case reports a C++ abort from the solver and reads like # a regression in something that was never built. ENGINE_READY=1; ENGINE_WHY="" -if [ ! -f "$ROOT/src/python/souffle/decls_all.dl" ]; then - ENGINE_READY=0; ENGINE_WHY="src/python/souffle/decls_all.dl missing" -elif [ -z "$(find "$ROOT/src/python/engine" -name '*.dl' -type f 2>/dev/null | head -1)" ]; then - ENGINE_READY=0; ENGINE_WHY="src/python/engine/**/*.dl is empty — no rules yet" +if [ ! -f "$ROOT/graph/python/souffle/decls_all.dl" ]; then + ENGINE_READY=0; ENGINE_WHY="graph/python/souffle/decls_all.dl missing" +elif [ -z "$(find "$ROOT/graph/python/engine" -name '*.dl' -type f 2>/dev/null | head -1)" ]; then + ENGINE_READY=0; ENGINE_WHY="graph/python/engine/**/*.dl is empty — no rules yet" fi if [ "$ENGINE_READY" = "0" ] && [ "$ORACLE_ONLY" = "0" ]; then echo "NOTE: no Python rule set yet ($ENGINE_WHY)." @@ -284,7 +284,7 @@ for dir in "$HERE"/cases/*/; do if ! node "$PARSER" "$dir/src" "$name" false "$w/ir" >"$w/parse.log" 2>&1; then echo "FAIL (parse — see $w/parse.log)"; fail=$((fail+1)); failed+=("$name"); continue; fi - if ! bash "$ROOT/src/pipeline/run-souffle.sh" --debug --language python \ + if ! bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language python \ --client-ir "$w/ir" --library "$EMPTY_LIB" \ --intermediate "$w/int" --output "$w/out" >"$w/solve.log" 2>&1; then if [ "$ENGINE_READY" = "0" ]; then @@ -401,7 +401,7 @@ if [ "$ORACLE_ONLY" = "0" ] && [ -d "$HERE/projects" ]; then pw="$WORK/project-$pname"; rm -rf "$pw"; mkdir -p "$pw/ir" if ! node "$PARSER" "$pdir" "$pname" false "$pw/ir" >"$pw/parse.log" 2>&1; then echo "FAIL (parse — see $pw/parse.log)"; fail=$((fail+1)); failed+=("project:$pname"); continue; fi - if ! bash "$ROOT/src/pipeline/run-souffle.sh" --debug --language python \ + if ! bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language python \ --client-ir "$pw/ir" --library "$EMPTY_LIB" \ --intermediate "$pw/int" --output "$pw/out" >"$pw/solve.log" 2>&1; then echo "FAIL (solve — $(tail -1 "$pw/solve.log" | cut -c1-70))" diff --git a/test/python/tools/.gitkeep b/graph/test/python/tools/.gitkeep similarity index 100% rename from test/python/tools/.gitkeep rename to graph/test/python/tools/.gitkeep diff --git a/test/python/tools/ambiguous-module-test.sh b/graph/test/python/tools/ambiguous-module-test.sh similarity index 94% rename from test/python/tools/ambiguous-module-test.sh rename to graph/test/python/tools/ambiguous-module-test.sh index 156fa900e..7028bfc2a 100755 --- a/test/python/tools/ambiguous-module-test.sh +++ b/graph/test/python/tools/ambiguous-module-test.sh @@ -21,8 +21,8 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../../.." && pwd)" -PARSER="${AXIOM_PARSER:-$ROOT/../Parser/dist/index.js}" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" EMPTY_LIB="${AXIOM_EMPTY_LIB:-$(mktemp -d)}" mkdir -p "$EMPTY_LIB" @@ -78,7 +78,7 @@ EOF solve(){ # $1 = tree dir node "$PARSER" "$1/src" ambmod false "$1/ir" > "$1/parse.log" 2>&1 || return 1 - bash "$ROOT/src/pipeline/run-souffle.sh" --debug --language python \ + bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language python \ --client-ir "$1/ir" --library "$EMPTY_LIB" \ --intermediate "$1/int" --output "$1/out" > "$1/solve.log" 2>&1 } diff --git a/test/python/tools/check_arity.py b/graph/test/python/tools/check_arity.py similarity index 96% rename from test/python/tools/check_arity.py rename to graph/test/python/tools/check_arity.py index c2129c7de..118a39b64 100644 --- a/test/python/tools/check_arity.py +++ b/graph/test/python/tools/check_arity.py @@ -14,11 +14,11 @@ """ import os, re, sys, glob, collections -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +ROOT = next(str(p) for p in __import__('pathlib').Path(__file__).resolve().parents if (p / 'package.json').exists() and (p / 'graph').is_dir()) # the repository root, by its marker LANG = 'python' if '--lang' in sys.argv: LANG = sys.argv[sys.argv.index('--lang') + 1] -SRC = os.path.join(ROOT, 'src', LANG) +SRC = os.path.join(ROOT, 'graph', LANG) DECL_RE = re.compile(r'^\.decl\s+(\w+)\s*\(([^)]*)\)', re.M) diff --git a/test/python/tools/check_tier1_attribution.py b/graph/test/python/tools/check_tier1_attribution.py similarity index 100% rename from test/python/tools/check_tier1_attribution.py rename to graph/test/python/tools/check_tier1_attribution.py diff --git a/test/python/tools/check_vendor.py b/graph/test/python/tools/check_vendor.py similarity index 97% rename from test/python/tools/check_vendor.py rename to graph/test/python/tools/check_vendor.py index 7b1f47124..36fd3b8da 100755 --- a/test/python/tools/check_vendor.py +++ b/graph/test/python/tools/check_vendor.py @@ -18,7 +18,7 @@ MODULES = ['normalize.py', 'tier1_sites.py'] root = os.environ.get('AXIOM_PY_ORACLE', - os.path.join(HERE, '..', '..', '..', '..', 'callchain-oracle', 'python')) + os.path.join(HERE, '..', '..', '..', '..', '..', 'callchain-oracle', 'python')) pkg = os.path.join(root, 'callchain_oracle') if not os.path.isdir(pkg): print(f'skip: no harness at {root} — cannot check vendored copies for drift') diff --git a/test/python/tools/coverage_guard.py b/graph/test/python/tools/coverage_guard.py similarity index 100% rename from test/python/tools/coverage_guard.py rename to graph/test/python/tools/coverage_guard.py diff --git a/test/python/tools/engine_edges.py b/graph/test/python/tools/engine_edges.py similarity index 100% rename from test/python/tools/engine_edges.py rename to graph/test/python/tools/engine_edges.py diff --git a/test/python/tools/excluded-files-test.sh b/graph/test/python/tools/excluded-files-test.sh similarity index 100% rename from test/python/tools/excluded-files-test.sh rename to graph/test/python/tools/excluded-files-test.sh diff --git a/test/python/tools/fan_report.py b/graph/test/python/tools/fan_report.py similarity index 98% rename from test/python/tools/fan_report.py rename to graph/test/python/tools/fan_report.py index 1ef22c2a0..91caeb469 100644 --- a/test/python/tools/fan_report.py +++ b/graph/test/python/tools/fan_report.py @@ -17,7 +17,7 @@ """ import csv, os, sys, collections csv.field_size_limit(10**9) # an IR literalValue can be a base64 asset; see test/tools/csv-limit-test.sh -sys.path.insert(0,'test/python/tools') +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from oracle_path import harness_root harness_root() from callchain_oracle import build diff --git a/test/python/tools/ir_audit.py b/graph/test/python/tools/ir_audit.py similarity index 100% rename from test/python/tools/ir_audit.py rename to graph/test/python/tools/ir_audit.py diff --git a/test/python/tools/known-missing-test.sh b/graph/test/python/tools/known-missing-test.sh similarity index 95% rename from test/python/tools/known-missing-test.sh rename to graph/test/python/tools/known-missing-test.sh index fab7d3119..517b818c5 100755 --- a/test/python/tools/known-missing-test.sh +++ b/graph/test/python/tools/known-missing-test.sh @@ -18,6 +18,7 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker PYTHON_DIR="$(cd "$HERE/.." && pwd)" CASE=12-blind-spots SRC="$PYTHON_DIR/cases/$CASE/src" @@ -25,7 +26,7 @@ KNOWN="$PYTHON_DIR/expected/$CASE.known-missing" PY="${AXIOM_PY_PYTHON:-/usr/local/bin/python3.10}" command -v "$PY" >/dev/null 2>&1 || { echo "known-missing: SKIP (no pinned interpreter at $PY)"; exit 0; } -ORACLE="${AXIOM_PY_ORACLE:-$PYTHON_DIR/../../../callchain-oracle/python}" +ORACLE="${AXIOM_PY_ORACLE:-$ROOT/../callchain-oracle/python}" [ -d "$ORACLE/callchain_oracle" ] || { echo "known-missing: SKIP (no harness at $ORACLE)"; exit 0; } [ -f "$KNOWN" ] || { echo " FAIL $KNOWN is missing"; echo "known-missing: FAILED"; exit 1; } diff --git a/test/python/tools/known_missing_pairs.py b/graph/test/python/tools/known_missing_pairs.py similarity index 100% rename from test/python/tools/known_missing_pairs.py rename to graph/test/python/tools/known_missing_pairs.py diff --git a/test/python/tools/library_monotonicity.py b/graph/test/python/tools/library_monotonicity.py similarity index 100% rename from test/python/tools/library_monotonicity.py rename to graph/test/python/tools/library_monotonicity.py diff --git a/test/python/tools/literal_gate.py b/graph/test/python/tools/literal_gate.py similarity index 96% rename from test/python/tools/literal_gate.py rename to graph/test/python/tools/literal_gate.py index a7bb69a80..f6e1ee509 100644 --- a/test/python/tools/literal_gate.py +++ b/graph/test/python/tools/literal_gate.py @@ -40,9 +40,8 @@ # repo root is FOUR levels up: tools -> python -> test -> . Getting this wrong # makes os.walk find nothing and the gate pass vacuously, which is exactly what happened # on the first attempt and is why this tool is checked against an injected literal. -_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname( - os.path.abspath(__file__))))) -ENGINE = os.path.join(_REPO, "src", "python", "engine") +_REPO = next(str(p) for p in __import__('pathlib').Path(__file__).resolve().parents if (p / 'package.json').exists() and (p / 'graph').is_dir()) # the repository root, by its marker +ENGINE = os.path.join(_REPO, "graph", "python", "engine") if not os.path.isdir(ENGINE): raise SystemExit(f"literal gate: engine dir not found at {ENGINE} -- refusing to pass vacuously") diff --git a/test/python/tools/monotonicity-test.sh b/graph/test/python/tools/monotonicity-test.sh similarity index 100% rename from test/python/tools/monotonicity-test.sh rename to graph/test/python/tools/monotonicity-test.sh diff --git a/test/python/tools/oracle_check.py b/graph/test/python/tools/oracle_check.py similarity index 100% rename from test/python/tools/oracle_check.py rename to graph/test/python/tools/oracle_check.py diff --git a/test/python/tools/oracle_path.py b/graph/test/python/tools/oracle_path.py similarity index 100% rename from test/python/tools/oracle_path.py rename to graph/test/python/tools/oracle_path.py diff --git a/test/python/tools/protocol-calls-test.sh b/graph/test/python/tools/protocol-calls-test.sh similarity index 97% rename from test/python/tools/protocol-calls-test.sh rename to graph/test/python/tools/protocol-calls-test.sh index de9ddfb19..e8d267d25 100755 --- a/test/python/tools/protocol-calls-test.sh +++ b/graph/test/python/tools/protocol-calls-test.sh @@ -33,8 +33,9 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker GUARD="$HERE/coverage_guard.py" -PARSER="${AXIOM_PARSER:-$HERE/../../../Parser/dist/index.js}" +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" if [ ! -f "$GUARD" ]; then echo " FAIL tools/coverage_guard.py is missing; the 'no longer a gap' checks below" diff --git a/test/python/tools/protocol-fan-test.sh b/graph/test/python/tools/protocol-fan-test.sh similarity index 94% rename from test/python/tools/protocol-fan-test.sh rename to graph/test/python/tools/protocol-fan-test.sh index 51c358d8c..009607d67 100755 --- a/test/python/tools/protocol-fan-test.sh +++ b/graph/test/python/tools/protocol-fan-test.sh @@ -27,8 +27,8 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../../.." && pwd)" -PARSER="${AXIOM_PARSER:-$ROOT/../Parser/dist/index.js}" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" EMPTY_LIB="${AXIOM_EMPTY_LIB:-$(mktemp -d)}"; mkdir -p "$EMPTY_LIB" [ -f "$PARSER" ] || { echo " SKIP protocol-fan: parser not found at $PARSER (set AXIOM_PARSER)"; exit 0; } @@ -64,7 +64,7 @@ PYEOF node "$PARSER" "$W/src" protofan false "$W/ir" > "$W/parse.log" 2>&1 || { echo " FAIL protocol-fan: the parser failed on the fixture"; sed 's/^/ /' "$W/parse.log" | tail -3 echo "protocol-fan: FAILED (1 check)"; exit 1; } -bash "$ROOT/src/pipeline/run-souffle.sh" --debug --language python --client-ir "$W/ir" \ +bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language python --client-ir "$W/ir" \ --library "$EMPTY_LIB" --intermediate "$W/int" --output "$W/out" > "$W/solve.log" 2>&1 || { echo " FAIL protocol-fan: the solve failed"; tail -3 "$W/solve.log" | sed 's/^/ /' echo "protocol-fan: FAILED (1 check)"; exit 1; } diff --git a/test/python/tools/site_accuracy.py b/graph/test/python/tools/site_accuracy.py similarity index 100% rename from test/python/tools/site_accuracy.py rename to graph/test/python/tools/site_accuracy.py diff --git a/test/python/tools/suite_report.py b/graph/test/python/tools/suite_report.py similarity index 100% rename from test/python/tools/suite_report.py rename to graph/test/python/tools/suite_report.py diff --git a/test/python/tools/tier_report.py b/graph/test/python/tools/tier_report.py similarity index 98% rename from test/python/tools/tier_report.py rename to graph/test/python/tools/tier_report.py index 9c140ae7e..614b6ae71 100644 --- a/test/python/tools/tier_report.py +++ b/graph/test/python/tools/tier_report.py @@ -94,7 +94,7 @@ def main(out): for k in sorted(summary): print(f' {summary[k]:>5s} {k}') # THE TWO TOTALS ARE ALLOWED TO DIFFER, and the difference is the point rather - # than a discrepancy: src/python/README.md decision 5 says a property read and a + # than a discrepancy: graph/python/README.md decision 5 says a property read and a # metaclass class-creation are real method->method edges that CPython's compiler # emits no CALL for, so they enter the graph and NOT the conserved site universe. # Printing the gap and which kinds account for it keeps a reader from reading the diff --git a/test/python/tools/vendor/__init__.py b/graph/test/python/tools/vendor/__init__.py similarity index 100% rename from test/python/tools/vendor/__init__.py rename to graph/test/python/tools/vendor/__init__.py diff --git a/test/python/tools/vendor/normalize.py b/graph/test/python/tools/vendor/normalize.py similarity index 100% rename from test/python/tools/vendor/normalize.py rename to graph/test/python/tools/vendor/normalize.py diff --git a/test/python/tools/vendor/tier1_sites.py b/graph/test/python/tools/vendor/tier1_sites.py similarity index 100% rename from test/python/tools/vendor/tier1_sites.py rename to graph/test/python/tools/vendor/tier1_sites.py diff --git a/test/python/torture/.gitignore b/graph/test/python/torture/.gitignore similarity index 100% rename from test/python/torture/.gitignore rename to graph/test/python/torture/.gitignore diff --git a/test/python/torture/client/accelstub.pyi b/graph/test/python/torture/client/accelstub.pyi similarity index 100% rename from test/python/torture/client/accelstub.pyi rename to graph/test/python/torture/client/accelstub.pyi diff --git a/test/python/torture/client/aliaspkg/__init__.py b/graph/test/python/torture/client/aliaspkg/__init__.py similarity index 100% rename from test/python/torture/client/aliaspkg/__init__.py rename to graph/test/python/torture/client/aliaspkg/__init__.py diff --git a/test/python/torture/client/aliaspkg/deep/__init__.py b/graph/test/python/torture/client/aliaspkg/deep/__init__.py similarity index 100% rename from test/python/torture/client/aliaspkg/deep/__init__.py rename to graph/test/python/torture/client/aliaspkg/deep/__init__.py diff --git a/test/python/torture/client/aliaspkg/deep/impl.py b/graph/test/python/torture/client/aliaspkg/deep/impl.py similarity index 100% rename from test/python/torture/client/aliaspkg/deep/impl.py rename to graph/test/python/torture/client/aliaspkg/deep/impl.py diff --git a/test/python/torture/client/f01_inheritance.py b/graph/test/python/torture/client/f01_inheritance.py similarity index 100% rename from test/python/torture/client/f01_inheritance.py rename to graph/test/python/torture/client/f01_inheritance.py diff --git a/test/python/torture/client/f02_callables.py b/graph/test/python/torture/client/f02_callables.py similarity index 100% rename from test/python/torture/client/f02_callables.py rename to graph/test/python/torture/client/f02_callables.py diff --git a/test/python/torture/client/f03_generics.py b/graph/test/python/torture/client/f03_generics.py similarity index 100% rename from test/python/torture/client/f03_generics.py rename to graph/test/python/torture/client/f03_generics.py diff --git a/test/python/torture/client/f04_descriptors.py b/graph/test/python/torture/client/f04_descriptors.py similarity index 100% rename from test/python/torture/client/f04_descriptors.py rename to graph/test/python/torture/client/f04_descriptors.py diff --git a/test/python/torture/client/f05_decorators.py b/graph/test/python/torture/client/f05_decorators.py similarity index 100% rename from test/python/torture/client/f05_decorators.py rename to graph/test/python/torture/client/f05_decorators.py diff --git a/test/python/torture/client/f06_flow.py b/graph/test/python/torture/client/f06_flow.py similarity index 100% rename from test/python/torture/client/f06_flow.py rename to graph/test/python/torture/client/f06_flow.py diff --git a/test/python/torture/client/f07_imports.py b/graph/test/python/torture/client/f07_imports.py similarity index 100% rename from test/python/torture/client/f07_imports.py rename to graph/test/python/torture/client/f07_imports.py diff --git a/test/python/torture/client/f08_dynamic.py b/graph/test/python/torture/client/f08_dynamic.py similarity index 100% rename from test/python/torture/client/f08_dynamic.py rename to graph/test/python/torture/client/f08_dynamic.py diff --git a/test/python/torture/client/f09_adversarial.py b/graph/test/python/torture/client/f09_adversarial.py similarity index 100% rename from test/python/torture/client/f09_adversarial.py rename to graph/test/python/torture/client/f09_adversarial.py diff --git a/test/python/torture/client/f10_forward_refs.py b/graph/test/python/torture/client/f10_forward_refs.py similarity index 100% rename from test/python/torture/client/f10_forward_refs.py rename to graph/test/python/torture/client/f10_forward_refs.py diff --git a/test/python/torture/client/f11_multiwrite.py b/graph/test/python/torture/client/f11_multiwrite.py similarity index 100% rename from test/python/torture/client/f11_multiwrite.py rename to graph/test/python/torture/client/f11_multiwrite.py diff --git a/test/python/torture/client/f12_value_flow.py b/graph/test/python/torture/client/f12_value_flow.py similarity index 100% rename from test/python/torture/client/f12_value_flow.py rename to graph/test/python/torture/client/f12_value_flow.py diff --git a/test/python/torture/client/f13_declared_dispatch.py b/graph/test/python/torture/client/f13_declared_dispatch.py similarity index 100% rename from test/python/torture/client/f13_declared_dispatch.py rename to graph/test/python/torture/client/f13_declared_dispatch.py diff --git a/test/python/torture/client/f14_class_objects.py b/graph/test/python/torture/client/f14_class_objects.py similarity index 100% rename from test/python/torture/client/f14_class_objects.py rename to graph/test/python/torture/client/f14_class_objects.py diff --git a/test/python/torture/client/f15_attribute_chains.py b/graph/test/python/torture/client/f15_attribute_chains.py similarity index 100% rename from test/python/torture/client/f15_attribute_chains.py rename to graph/test/python/torture/client/f15_attribute_chains.py diff --git a/test/python/torture/client/f16_element_types.py b/graph/test/python/torture/client/f16_element_types.py similarity index 100% rename from test/python/torture/client/f16_element_types.py rename to graph/test/python/torture/client/f16_element_types.py diff --git a/test/python/torture/client/f18_lambda_dispatch.py b/graph/test/python/torture/client/f18_lambda_dispatch.py similarity index 100% rename from test/python/torture/client/f18_lambda_dispatch.py rename to graph/test/python/torture/client/f18_lambda_dispatch.py diff --git a/test/python/torture/client/f19_reexports.py b/graph/test/python/torture/client/f19_reexports.py similarity index 100% rename from test/python/torture/client/f19_reexports.py rename to graph/test/python/torture/client/f19_reexports.py diff --git a/test/python/torture/client/f20_builtin_flow.py b/graph/test/python/torture/client/f20_builtin_flow.py similarity index 100% rename from test/python/torture/client/f20_builtin_flow.py rename to graph/test/python/torture/client/f20_builtin_flow.py diff --git a/test/python/torture/client/f21_module_alias.py b/graph/test/python/torture/client/f21_module_alias.py similarity index 100% rename from test/python/torture/client/f21_module_alias.py rename to graph/test/python/torture/client/f21_module_alias.py diff --git a/test/python/torture/client/f22_typeref_fk.py b/graph/test/python/torture/client/f22_typeref_fk.py similarity index 100% rename from test/python/torture/client/f22_typeref_fk.py rename to graph/test/python/torture/client/f22_typeref_fk.py diff --git a/test/python/torture/client/f23_typevar_bound.py b/graph/test/python/torture/client/f23_typevar_bound.py similarity index 100% rename from test/python/torture/client/f23_typevar_bound.py rename to graph/test/python/torture/client/f23_typevar_bound.py diff --git a/test/python/torture/client/f24_star_all.py b/graph/test/python/torture/client/f24_star_all.py similarity index 100% rename from test/python/torture/client/f24_star_all.py rename to graph/test/python/torture/client/f24_star_all.py diff --git a/test/python/torture/client/f25_var_params.py b/graph/test/python/torture/client/f25_var_params.py similarity index 100% rename from test/python/torture/client/f25_var_params.py rename to graph/test/python/torture/client/f25_var_params.py diff --git a/test/python/torture/client/f26_local_annotation.py b/graph/test/python/torture/client/f26_local_annotation.py similarity index 100% rename from test/python/torture/client/f26_local_annotation.py rename to graph/test/python/torture/client/f26_local_annotation.py diff --git a/test/python/torture/client/f27_with_target.py b/graph/test/python/torture/client/f27_with_target.py similarity index 100% rename from test/python/torture/client/f27_with_target.py rename to graph/test/python/torture/client/f27_with_target.py diff --git a/test/python/torture/client/f28_await.py b/graph/test/python/torture/client/f28_await.py similarity index 100% rename from test/python/torture/client/f28_await.py rename to graph/test/python/torture/client/f28_await.py diff --git a/test/python/torture/client/f29_subscript.py b/graph/test/python/torture/client/f29_subscript.py similarity index 100% rename from test/python/torture/client/f29_subscript.py rename to graph/test/python/torture/client/f29_subscript.py diff --git a/test/python/torture/client/f30_builtin_elements.py b/graph/test/python/torture/client/f30_builtin_elements.py similarity index 100% rename from test/python/torture/client/f30_builtin_elements.py rename to graph/test/python/torture/client/f30_builtin_elements.py diff --git a/test/python/torture/client/f31_named_blind_spots.py b/graph/test/python/torture/client/f31_named_blind_spots.py similarity index 100% rename from test/python/torture/client/f31_named_blind_spots.py rename to graph/test/python/torture/client/f31_named_blind_spots.py diff --git a/test/python/torture/client/f32_iteration_protocol.py b/graph/test/python/torture/client/f32_iteration_protocol.py similarity index 100% rename from test/python/torture/client/f32_iteration_protocol.py rename to graph/test/python/torture/client/f32_iteration_protocol.py diff --git a/test/python/torture/client/f33_type_stubs.py b/graph/test/python/torture/client/f33_type_stubs.py similarity index 100% rename from test/python/torture/client/f33_type_stubs.py rename to graph/test/python/torture/client/f33_type_stubs.py diff --git a/test/python/torture/client/f34_reexport_union.py b/graph/test/python/torture/client/f34_reexport_union.py similarity index 100% rename from test/python/torture/client/f34_reexport_union.py rename to graph/test/python/torture/client/f34_reexport_union.py diff --git a/test/python/torture/client/f35_generic_union.py b/graph/test/python/torture/client/f35_generic_union.py similarity index 100% rename from test/python/torture/client/f35_generic_union.py rename to graph/test/python/torture/client/f35_generic_union.py diff --git a/test/python/torture/client/f36_property_result.py b/graph/test/python/torture/client/f36_property_result.py similarity index 100% rename from test/python/torture/client/f36_property_result.py rename to graph/test/python/torture/client/f36_property_result.py diff --git a/test/python/torture/client/f37_classmethod_pairing.py b/graph/test/python/torture/client/f37_classmethod_pairing.py similarity index 100% rename from test/python/torture/client/f37_classmethod_pairing.py rename to graph/test/python/torture/client/f37_classmethod_pairing.py diff --git a/test/python/torture/client/f38_nested_class_scope.py b/graph/test/python/torture/client/f38_nested_class_scope.py similarity index 100% rename from test/python/torture/client/f38_nested_class_scope.py rename to graph/test/python/torture/client/f38_nested_class_scope.py diff --git a/test/python/torture/client/f39_lib_boundary_identity.py b/graph/test/python/torture/client/f39_lib_boundary_identity.py similarity index 100% rename from test/python/torture/client/f39_lib_boundary_identity.py rename to graph/test/python/torture/client/f39_lib_boundary_identity.py diff --git a/test/python/torture/client/f40_data_descriptor.py b/graph/test/python/torture/client/f40_data_descriptor.py similarity index 100% rename from test/python/torture/client/f40_data_descriptor.py rename to graph/test/python/torture/client/f40_data_descriptor.py diff --git a/test/python/torture/client/f41_class_attribute_absent.py b/graph/test/python/torture/client/f41_class_attribute_absent.py similarity index 100% rename from test/python/torture/client/f41_class_attribute_absent.py rename to graph/test/python/torture/client/f41_class_attribute_absent.py diff --git a/test/python/torture/client/f42_overload_stubs.py b/graph/test/python/torture/client/f42_overload_stubs.py similarity index 100% rename from test/python/torture/client/f42_overload_stubs.py rename to graph/test/python/torture/client/f42_overload_stubs.py diff --git a/test/python/torture/client/f43_def_rebind.py b/graph/test/python/torture/client/f43_def_rebind.py similarity index 100% rename from test/python/torture/client/f43_def_rebind.py rename to graph/test/python/torture/client/f43_def_rebind.py diff --git a/test/python/torture/client/main.py b/graph/test/python/torture/client/main.py similarity index 100% rename from test/python/torture/client/main.py rename to graph/test/python/torture/client/main.py diff --git a/test/python/torture/client/nestmod.py b/graph/test/python/torture/client/nestmod.py similarity index 100% rename from test/python/torture/client/nestmod.py rename to graph/test/python/torture/client/nestmod.py diff --git a/test/python/torture/client/pkgmod/__init__.py b/graph/test/python/torture/client/pkgmod/__init__.py similarity index 100% rename from test/python/torture/client/pkgmod/__init__.py rename to graph/test/python/torture/client/pkgmod/__init__.py diff --git a/test/python/torture/client/pkgmod/_gen.py b/graph/test/python/torture/client/pkgmod/_gen.py similarity index 100% rename from test/python/torture/client/pkgmod/_gen.py rename to graph/test/python/torture/client/pkgmod/_gen.py diff --git a/test/python/torture/client/pkgmod/user.py b/graph/test/python/torture/client/pkgmod/user.py similarity index 100% rename from test/python/torture/client/pkgmod/user.py rename to graph/test/python/torture/client/pkgmod/user.py diff --git a/test/python/torture/client/repkg/__init__.py b/graph/test/python/torture/client/repkg/__init__.py similarity index 100% rename from test/python/torture/client/repkg/__init__.py rename to graph/test/python/torture/client/repkg/__init__.py diff --git a/test/python/torture/client/repkg/inner/__init__.py b/graph/test/python/torture/client/repkg/inner/__init__.py similarity index 100% rename from test/python/torture/client/repkg/inner/__init__.py rename to graph/test/python/torture/client/repkg/inner/__init__.py diff --git a/test/python/torture/client/repkg/inner/leaf.py b/graph/test/python/torture/client/repkg/inner/leaf.py similarity index 100% rename from test/python/torture/client/repkg/inner/leaf.py rename to graph/test/python/torture/client/repkg/inner/leaf.py diff --git a/test/python/torture/client/rxaccel.py b/graph/test/python/torture/client/rxaccel.py similarity index 100% rename from test/python/torture/client/rxaccel.py rename to graph/test/python/torture/client/rxaccel.py diff --git a/test/python/torture/client/rxfacade.py b/graph/test/python/torture/client/rxfacade.py similarity index 100% rename from test/python/torture/client/rxfacade.py rename to graph/test/python/torture/client/rxfacade.py diff --git a/test/python/torture/client/rxlib.py b/graph/test/python/torture/client/rxlib.py similarity index 100% rename from test/python/torture/client/rxlib.py rename to graph/test/python/torture/client/rxlib.py diff --git a/test/python/torture/client/stubbed_impl.py b/graph/test/python/torture/client/stubbed_impl.py similarity index 100% rename from test/python/torture/client/stubbed_impl.py rename to graph/test/python/torture/client/stubbed_impl.py diff --git a/test/python/torture/client/stubbed_impl.pyi b/graph/test/python/torture/client/stubbed_impl.pyi similarity index 100% rename from test/python/torture/client/stubbed_impl.pyi rename to graph/test/python/torture/client/stubbed_impl.pyi diff --git a/test/python/torture/expected/coverage.txt b/graph/test/python/torture/expected/coverage.txt similarity index 100% rename from test/python/torture/expected/coverage.txt rename to graph/test/python/torture/expected/coverage.txt diff --git a/test/python/torture/expected/reasons.txt b/graph/test/python/torture/expected/reasons.txt similarity index 100% rename from test/python/torture/expected/reasons.txt rename to graph/test/python/torture/expected/reasons.txt diff --git a/test/python/torture/expected/torture.edges b/graph/test/python/torture/expected/torture.edges similarity index 100% rename from test/python/torture/expected/torture.edges rename to graph/test/python/torture/expected/torture.edges diff --git a/test/python/torture/expected/torture.oracle b/graph/test/python/torture/expected/torture.oracle similarity index 100% rename from test/python/torture/expected/torture.oracle rename to graph/test/python/torture/expected/torture.oracle diff --git a/test/python/torture/harness/reasons.py b/graph/test/python/torture/harness/reasons.py similarity index 100% rename from test/python/torture/harness/reasons.py rename to graph/test/python/torture/harness/reasons.py diff --git a/test/python/torture/harness/run.sh b/graph/test/python/torture/harness/run.sh similarity index 90% rename from test/python/torture/harness/run.sh rename to graph/test/python/torture/harness/run.sh index 081c9dca8..38410d111 100755 --- a/test/python/torture/harness/run.sh +++ b/graph/test/python/torture/harness/run.sh @@ -2,14 +2,14 @@ # parse both halves separately, link with --library, score per family set -u R="$(cd "$(dirname "$0")/.." && pwd)" -ENG="$(cd "$R/../../.." && pwd)" -PARSER="${AXIOM_PARSER:-$ENG/../Parser/dist/index.js}" +ENG="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +PARSER="${AXIOM_PARSER:-$ENG/parser/dist/index.js}" cd "$R" python3 harness/trace.py >/dev/null 2>&1 || { echo "trace failed"; exit 1; } rm -rf lib-ir client-ir out int node "$PARSER" lib torture-lib false lib-ir >/dev/null 2>&1 node "$PARSER" client torture-client false client-ir >/dev/null 2>&1 -bash "$ENG/src/pipeline/run-souffle.sh" --debug --language python \ +bash "$ENG/graph/pipeline/run-souffle.sh" --debug --language python \ --client-ir client-ir --library lib-ir --intermediate int --output out 2>&1 | tail -2 # Java's two artifacts, same shape: the golden edge list and the oracle scorecard. # engine_edges.py is the SUITE'S OWN normalizer (test/python/tools), not a second @@ -29,7 +29,7 @@ rm -f actual.edges actual.oracle # suite can catch a regression here, because every other case fixes the library input; # a site losing its answer only shows up when the two runs are compared to each other. mkdir -p emptylib -bash "$ENG/src/pipeline/run-souffle.sh" --debug --language python \ +bash "$ENG/graph/pipeline/run-souffle.sh" --debug --language python \ --client-ir client-ir --library emptylib --intermediate int-nolib --output out-nolib >/dev/null 2>&1 # Run ONCE and reuse the output: this used to run twice, discarding the first result and # printing the second, which doubled the report. The IR directories are passed so the @@ -48,7 +48,7 @@ rm -rf out-nolib int-nolib emptylib # library declares them, which is what exposes the gap. One library input does not # exercise the boundary logic; two do. if [ -d "${AXIOM_STUB_IR:-}" ]; then - bash "$ENG/src/pipeline/run-souffle.sh" --debug --language python \ + bash "$ENG/graph/pipeline/run-souffle.sh" --debug --language python \ --client-ir client-ir --library "$AXIOM_STUB_IR" \ --intermediate int-stub --output out-stub >/dev/null 2>&1 # A call comparing against `out-nolib-stub` stood here. Nothing ever created that @@ -57,7 +57,7 @@ if [ -d "${AXIOM_STUB_IR:-}" ]; then # comparison is out-nolib2 vs out-stub below; removed rather than left to look like # coverage. Found while fixing #313. mkdir -p emptylib2 - bash "$ENG/src/pipeline/run-souffle.sh" --debug --language python \ + bash "$ENG/graph/pipeline/run-souffle.sh" --debug --language python \ --client-ir client-ir --library emptylib2 \ --intermediate int-nolib2 --output out-nolib2 >/dev/null 2>&1 if ! mono=$(python3 ../tools/library_monotonicity.py out-nolib2/raw out-stub/raw \ diff --git a/test/python/torture/harness/score.py b/graph/test/python/torture/harness/score.py similarity index 100% rename from test/python/torture/harness/score.py rename to graph/test/python/torture/harness/score.py diff --git a/test/python/torture/harness/scorecard.py b/graph/test/python/torture/harness/scorecard.py similarity index 100% rename from test/python/torture/harness/scorecard.py rename to graph/test/python/torture/harness/scorecard.py diff --git a/test/python/torture/harness/trace.py b/graph/test/python/torture/harness/trace.py similarity index 100% rename from test/python/torture/harness/trace.py rename to graph/test/python/torture/harness/trace.py diff --git a/test/python/torture/lib/tlib/__init__.py b/graph/test/python/torture/lib/tlib/__init__.py similarity index 100% rename from test/python/torture/lib/tlib/__init__.py rename to graph/test/python/torture/lib/tlib/__init__.py diff --git a/test/python/torture/lib/tlib/asyncshapes.py b/graph/test/python/torture/lib/tlib/asyncshapes.py similarity index 100% rename from test/python/torture/lib/tlib/asyncshapes.py rename to graph/test/python/torture/lib/tlib/asyncshapes.py diff --git a/test/python/torture/lib/tlib/callables.py b/graph/test/python/torture/lib/tlib/callables.py similarity index 100% rename from test/python/torture/lib/tlib/callables.py rename to graph/test/python/torture/lib/tlib/callables.py diff --git a/test/python/torture/lib/tlib/decorated.py b/graph/test/python/torture/lib/tlib/decorated.py similarity index 100% rename from test/python/torture/lib/tlib/decorated.py rename to graph/test/python/torture/lib/tlib/decorated.py diff --git a/test/python/torture/lib/tlib/descriptors.py b/graph/test/python/torture/lib/tlib/descriptors.py similarity index 100% rename from test/python/torture/lib/tlib/descriptors.py rename to graph/test/python/torture/lib/tlib/descriptors.py diff --git a/test/python/torture/lib/tlib/exports.py b/graph/test/python/torture/lib/tlib/exports.py similarity index 100% rename from test/python/torture/lib/tlib/exports.py rename to graph/test/python/torture/lib/tlib/exports.py diff --git a/test/python/torture/lib/tlib/generics.py b/graph/test/python/torture/lib/tlib/generics.py similarity index 100% rename from test/python/torture/lib/tlib/generics.py rename to graph/test/python/torture/lib/tlib/generics.py diff --git a/test/python/torture/lib/tlib/shapes.py b/graph/test/python/torture/lib/tlib/shapes.py similarity index 100% rename from test/python/torture/lib/tlib/shapes.py rename to graph/test/python/torture/lib/tlib/shapes.py diff --git a/test/python/torture/lib/tlib/stubonly.pyi b/graph/test/python/torture/lib/tlib/stubonly.pyi similarity index 100% rename from test/python/torture/lib/tlib/stubonly.pyi rename to graph/test/python/torture/lib/tlib/stubonly.pyi diff --git a/test/tools/bundle-test.sh b/graph/test/tools/bundle-test.sh similarity index 93% rename from test/tools/bundle-test.sh rename to graph/test/tools/bundle-test.sh index 2869261e8..95635158b 100755 --- a/test/tools/bundle-test.sh +++ b/graph/test/tools/bundle-test.sh @@ -1,31 +1,31 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── -# The bundle stage (src/bundle/) — the language-neutral output every suite now solves into. +# The bundle stage (graph/bundle/) — the language-neutral output every suite now solves into. # # No parser, no soufflé: each language gets a HAND-WRITTEN raw/ dump and a minimal IR whose # headers carry only the columns the adapter asks for by name (that is the point of resolving # by name). Then the bundle is built and read back: -# 1. graph/
.csv exists for every core table, with the header the schema declares +# 1. csv/
.csv exists for every core table, with the header the schema declares # 2. a caller and its resolved callee join to qualified names, file and line — in every language # 3. an unresolved site is kept, with NULL callee, and lands in unresolved_sites # 4. the vocabulary inside the database knows the language's own values, and a value the # schema does not list is still recorded as undocumented rather than dropped -# 5. src/bundle/SCHEMA.md is what schema.ts renders — the two cannot drift +# 5. graph/bundle/SCHEMA.md is what schema.ts renders — the two cannot drift # Assertions read the CSVs; the sqlite3 CLI, when present, also queries the database. # ───────────────────────────────────────────────────────────────────────────── set -u HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting TSX="$ROOT/node_modules/.bin/tsx" [ -x "$TSX" ] || { echo "bundle-test: SKIP (no node_modules/.bin/tsx — run npm install)"; exit 0; } W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT fail=0; bad(){ echo " ✗ $*"; fail=$((fail+1)); } -# --debug, because every assertion below reads graph/*.csv. Without it the bundler writes +# --debug, because every assertion below reads csv/*.csv. Without it the bundler writes # graph.sqlite alone — the CSVs are a debugging view of the same core tables, and a # consumer that queries the database does not want a second copy of it on disk. The # default is asserted separately at the end. -BUNDLE(){ "$TSX" "$ROOT/src/bundle/cli.ts" --src "$ROOT/src" --debug "$@"; } -BUNDLE_NO_DEBUG(){ "$TSX" "$ROOT/src/bundle/cli.ts" --src "$ROOT/src" "$@"; } +BUNDLE(){ "$TSX" "$ROOT/graph/bundle/cli.ts" --src "$ROOT/graph" --debug "$@"; } +BUNDLE_NO_DEBUG(){ "$TSX" "$ROOT/graph/bundle/cli.ts" --src "$ROOT/graph" "$@"; } SQL(){ command -v sqlite3 >/dev/null && sqlite3 "$1" "$2"; } HAVE_SQLITE=0; command -v sqlite3 >/dev/null && HAVE_SQLITE=1 @@ -92,10 +92,10 @@ for lang in java typescript python; do if ! BUNDLE --language "$lang" --client-ir "$d/ir" --raw "$d/raw" --out "$d" > "$d/log" 2>&1; then bad "$lang: bundle failed:"; sed 's/^/ /' "$d/log" | tail -5; continue fi - G="$d/graph" + G="$d/csv" # 1. every core table, with the declared header for t in run methods types call_sites call_edges type_ancestors dispatch_candidates overrides entry_points entry_reachable unresolved_sites type_instantiated; do - [ -f "$G/$t.csv" ] || bad "$lang: graph/$t.csv missing" + [ -f "$G/$t.csv" ] || bad "$lang: csv/$t.csv missing" done [ "$(header "$G/call_edges.csv")" = "$(printf 'call_site_id\tcaller_id\tcallee_method_id\tcallee_label\tcallee_provenance\ttier\tkind')" ] || bad "$lang: call_edges header is $(header "$G/call_edges.csv")" [ "$(header "$G/methods.csv")" = "$(printf 'id\tname\tqualified_name\tsignature\tkind\towner_type_id\towner_qualified_name\tfile_path\tstart_line\tend_line\tprovenance')" ] || bad "$lang: methods header drifted" @@ -158,7 +158,7 @@ if [ "$HAVE_SQLITE" = 1 ]; then [ "$(cols java)" = "$(cols $other)" ] || bad "schema_columns differs between java and $other" [ "$(core java)" = "$(core $other)" ] || bad "core schema_tables rows differ between java and $other" done - [ "$(SQL "$W/java/graph.sqlite" "PRAGMA user_version")" = "$(grep -o "SCHEMA_VERSION = '[0-9]*'" "$ROOT/src/bundle/schema.ts" | grep -o '[0-9]*')" ] || bad "PRAGMA user_version is not SCHEMA_VERSION" + [ "$(SQL "$W/java/graph.sqlite" "PRAGMA user_version")" = "$(grep -o "SCHEMA_VERSION = '[0-9]*'" "$ROOT/graph/bundle/schema.ts" | grep -o '[0-9]*')" ] || bad "PRAGMA user_version is not SCHEMA_VERSION" # 6. the guide is there, and every canonical query runs on every language's bundle for lang in java typescript python; do DB="$W/$lang/graph.sqlite" @@ -187,20 +187,20 @@ if [ "$HAVE_SQLITE" = 1 ]; then fi # 7. SCHEMA.md is the rendering of schema.ts -if ! diff -q <(BUNDLE --print-schema) "$ROOT/src/bundle/SCHEMA.md" >/dev/null; then - bad "src/bundle/SCHEMA.md is stale — run: npm run schema-doc" +if ! diff -q <(BUNDLE --print-schema) "$ROOT/graph/bundle/SCHEMA.md" >/dev/null; then + bad "graph/bundle/SCHEMA.md is stale — run: npm run schema-doc" fi # ── the DEFAULT writes the database and nothing else ──────────────────────── -# The deliverable is graph.sqlite. graph/*.csv carries the same core tables, so writing +# The deliverable is graph.sqlite. csv/*.csv carries the same core tables, so writing # both unasked doubles the output for a consumer that reads neither by hand. Asserted # here rather than trusted, because the fallback below makes the condition non-obvious. d="$W/default"; mkdir -p "$d" BUNDLE_NO_DEBUG --language java --client-ir "$W/java/ir" --raw "$W/java/raw" --out "$d" >/dev/null 2>&1 [ -f "$d/graph.sqlite" ] || bad "default: graph.sqlite not written" -if [ -d "$d/graph" ] && [ -n "$(ls -A "$d/graph" 2>/dev/null)" ]; then - bad "default: graph/ should be empty without --debug, found $(ls "$d/graph" | wc -l | tr -d ' ') files" +if [ -d "$d/csv" ] && [ -n "$(ls -A "$d/csv" 2>/dev/null)" ]; then + bad "default: csv/ should be empty without --debug, found $(ls "$d/csv" | wc -l | tr -d ' ') files" fi if [ "$fail" -eq 0 ]; then diff --git a/test/tools/check_staging.py b/graph/test/tools/check_staging.py similarity index 95% rename from test/tools/check_staging.py rename to graph/test/tools/check_staging.py index 9c31e1026..9084e8852 100644 --- a/test/tools/check_staging.py +++ b/graph/test/tools/check_staging.py @@ -25,7 +25,7 @@ THE LIB NAMING CONVENTION IS NOT THE SAME IN EVERY FRONT END, and this tool used to assume Java's. Java pairs `java_method` with `lib_method` — the language prefix is dropped. Python pairs `py_method` with `lib_py_method` — it is kept, and -src/python/templates/staging.conf says so in as many words ("THE PREFIX MUST BE py_: +graph/python/templates/staging.conf says so in as many words ("THE PREFIX MUST BE py_: the executor strips only 'lib_'"). With Java's convention hardcoded, `--lang python` reported 19 counterpart failures that were all the tool's, so the guard had never run on that front end at all — the exact hole issue #136 records for Java, one language @@ -42,9 +42,9 @@ # tools -> test -> . This file used to live at test/java/tools and was FOUR levels # up; it is shared now, so it is three. Getting it wrong makes every path miss and the # guard pass vacuously, which is the one failure mode a guard must not have. -ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..') -TPL = os.path.join(ROOT, 'src', LANG, 'templates') -DECL = os.path.join(ROOT, 'src', LANG, 'souffle', 'decls_base.dl') +ROOT = next(str(p) for p in __import__('pathlib').Path(__file__).resolve().parents if (p / 'package.json').exists() and (p / 'graph').is_dir()) # the repository root, by its marker +TPL = os.path.join(ROOT, 'graph', LANG, 'templates') +DECL = os.path.join(ROOT, 'graph', LANG, 'souffle', 'decls_base.dl') # Relations that exist for the CLIENT only, each with the reason. A library's copy of one of # these would be meaningless, so the asymmetry is intended rather than forgotten. diff --git a/test/tools/compare_runs.py b/graph/test/tools/compare_runs.py similarity index 100% rename from test/tools/compare_runs.py rename to graph/test/tools/compare_runs.py diff --git a/test/tools/csv-limit-test.sh b/graph/test/tools/csv-limit-test.sh similarity index 92% rename from test/tools/csv-limit-test.sh rename to graph/test/tools/csv-limit-test.sh index 4b0824b98..32eb311fd 100755 --- a/test/tools/csv-limit-test.sh +++ b/graph/test/tools/csv-limit-test.sh @@ -19,7 +19,7 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting PY="${PYTHON:-python3}" fail=0; checks=0 @@ -54,7 +54,7 @@ p = os.path.join(w, 'all-typescript-expressions.csv') with open(p, 'w', encoding='utf-8') as fh: fh.write('kind\tliteralValue\n') fh.write(f'STRING\t{big}\n') -sys.path.insert(0, os.path.join(root, 'test', 'typescript', 'ground-truth')) +sys.path.insert(0, os.path.join(root, 'graph', 'test', 'typescript', 'ground-truth')) try: from score import read_tsv except Exception as e: # pragma: no cover - import shape diff --git a/test/tools/empty-relation-test.sh b/graph/test/tools/empty-relation-test.sh similarity index 92% rename from test/tools/empty-relation-test.sh rename to graph/test/tools/empty-relation-test.sh index af220f7cb..c99722fa0 100755 --- a/test/tools/empty-relation-test.sh +++ b/graph/test/tools/empty-relation-test.sh @@ -19,7 +19,7 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting PY="${PYTHON:-python3}" fail=0; checks=0 @@ -49,7 +49,7 @@ printf 'first\t0\tH1\n' >> "$W/ir/all-typescript-methods.csv" : > "$W/ir/all-typescript-method-parameters.csv" # zero bytes: the relation has no rows [ ! -s "$W/ir/all-typescript-method-parameters.csv" ] || { echo " FAIL fixture is not zero-byte"; exit 1; } -out="$("$PY" "$ROOT/test/typescript/tools/schema_drift.py" "$W/ir" 2>&1)"; rc=$? +out="$("$PY" "$ROOT/graph/test/typescript/tools/schema_drift.py" "$W/ir" 2>&1)"; rc=$? if printf '%s\n' "$out" | grep -qi 'stopiteration\|Traceback (most recent call last)'; then bad "a zero-byte relation still crashes schema_drift instead of producing a verdict:" printf '%s\n' "$out" | sed 's/^/ /' | tail -6 diff --git a/test/tools/empty_relation_lint.py b/graph/test/tools/empty_relation_lint.py similarity index 99% rename from test/tools/empty_relation_lint.py rename to graph/test/tools/empty_relation_lint.py index 88b0cad91..c9a9d5430 100755 --- a/test/tools/empty_relation_lint.py +++ b/graph/test/tools/empty_relation_lint.py @@ -49,7 +49,7 @@ def main() -> int: root = sys.argv[1] if len(sys.argv) > 1 else '.' bad = [] scanned = 0 - for base in ('src', 'test'): + for base in ('graph', 'bin'): for dirpath, dirnames, names in os.walk(os.path.join(root, base)): dirnames[:] = [d for d in dirnames if d not in ('__pycache__', '.git', 'node_modules')] for n in sorted(names): diff --git a/test/tools/envelope_report.py b/graph/test/tools/envelope_report.py similarity index 100% rename from test/tools/envelope_report.py rename to graph/test/tools/envelope_report.py diff --git a/test/tools/no-ignored-fixtures.sh b/graph/test/tools/no-ignored-fixtures.sh similarity index 100% rename from test/tools/no-ignored-fixtures.sh rename to graph/test/tools/no-ignored-fixtures.sh diff --git a/test/tools/portable-stat-test.sh b/graph/test/tools/portable-stat-test.sh similarity index 94% rename from test/tools/portable-stat-test.sh rename to graph/test/tools/portable-stat-test.sh index caba6fd34..bfd3be81f 100755 --- a/test/tools/portable-stat-test.sh +++ b/graph/test/tools/portable-stat-test.sh @@ -12,8 +12,8 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" -. "$ROOT/src/pipeline/portable-stat.sh" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +. "$ROOT/graph/pipeline/portable-stat.sh" W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT fail=0; checks=0 @@ -98,7 +98,7 @@ fi strays="$(grep -rn --include='*.sh' -E '(^|[^_[:alnum:]])stat[[:space:]]+-[fc][[:space:]]' "$ROOT/src" "$ROOT/test" 2>/dev/null \ | grep -v 'portable-stat' || true)" [ -z "$strays" ] && ok "no script calls stat -f / stat -c directly" \ - || { bad "these must use file_mtime/file_ident from src/pipeline/portable-stat.sh:"; echo "$strays" | sed 's/^/ /'; } + || { bad "these must use file_mtime/file_ident from graph/pipeline/portable-stat.sh:"; echo "$strays" | sed 's/^/ /'; } [ "$fail" = 0 ] && echo "portable-stat: ok ($checks checks)" || echo "portable-stat: FAILED" exit "$fail" diff --git a/test/tools/souffle-include-test.sh b/graph/test/tools/souffle-include-test.sh similarity index 96% rename from test/tools/souffle-include-test.sh rename to graph/test/tools/souffle-include-test.sh index 377049c43..802ae28f2 100755 --- a/test/tools/souffle-include-test.sh +++ b/graph/test/tools/souffle-include-test.sh @@ -15,8 +15,8 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" -. "$ROOT/src/pipeline/souffle-include.sh" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +. "$ROOT/graph/pipeline/souffle-include.sh" fail=0; checks=0 ok(){ checks=$((checks+1)); [ -n "${SOUFFLE_INCLUDE_VERBOSE:-}" ] && printf ' ok %s\n' "$1"; return 0; } diff --git a/test/typescript/README.md b/graph/test/typescript/README.md similarity index 97% rename from test/typescript/README.md rename to graph/test/typescript/README.md index e0625e68b..33efc5ab7 100644 --- a/test/typescript/README.md +++ b/graph/test/typescript/README.md @@ -95,4 +95,4 @@ who clones the repo: no 2 GB of library IR, no `npm install`. ## Environment -`AXIOM_PARSER` — path to the parser entrypoint (default `../../../Parser/dist/index.js`). +`AXIOM_PARSER` — path to the parser entrypoint (default `parser/dist/index.js`, the parser in this repository). diff --git a/test/typescript/cases/01-class-dispatch-and-super/lib/geometry.ts b/graph/test/typescript/cases/01-class-dispatch-and-super/lib/geometry.ts similarity index 100% rename from test/typescript/cases/01-class-dispatch-and-super/lib/geometry.ts rename to graph/test/typescript/cases/01-class-dispatch-and-super/lib/geometry.ts diff --git a/test/typescript/cases/01-class-dispatch-and-super/src/shapes.ts b/graph/test/typescript/cases/01-class-dispatch-and-super/src/shapes.ts similarity index 100% rename from test/typescript/cases/01-class-dispatch-and-super/src/shapes.ts rename to graph/test/typescript/cases/01-class-dispatch-and-super/src/shapes.ts diff --git a/test/typescript/cases/02-interface-fanout/lib/contracts.ts b/graph/test/typescript/cases/02-interface-fanout/lib/contracts.ts similarity index 100% rename from test/typescript/cases/02-interface-fanout/lib/contracts.ts rename to graph/test/typescript/cases/02-interface-fanout/lib/contracts.ts diff --git a/test/typescript/cases/02-interface-fanout/src/handlers.ts b/graph/test/typescript/cases/02-interface-fanout/src/handlers.ts similarity index 100% rename from test/typescript/cases/02-interface-fanout/src/handlers.ts rename to graph/test/typescript/cases/02-interface-fanout/src/handlers.ts diff --git a/test/typescript/cases/03-structural-satisfaction/lib/io.ts b/graph/test/typescript/cases/03-structural-satisfaction/lib/io.ts similarity index 100% rename from test/typescript/cases/03-structural-satisfaction/lib/io.ts rename to graph/test/typescript/cases/03-structural-satisfaction/lib/io.ts diff --git a/test/typescript/cases/03-structural-satisfaction/src/duck.ts b/graph/test/typescript/cases/03-structural-satisfaction/src/duck.ts similarity index 100% rename from test/typescript/cases/03-structural-satisfaction/src/duck.ts rename to graph/test/typescript/cases/03-structural-satisfaction/src/duck.ts diff --git a/test/typescript/cases/04-overload-selection/lib/fmt.ts b/graph/test/typescript/cases/04-overload-selection/lib/fmt.ts similarity index 100% rename from test/typescript/cases/04-overload-selection/lib/fmt.ts rename to graph/test/typescript/cases/04-overload-selection/lib/fmt.ts diff --git a/test/typescript/cases/04-overload-selection/src/overloads.ts b/graph/test/typescript/cases/04-overload-selection/src/overloads.ts similarity index 100% rename from test/typescript/cases/04-overload-selection/src/overloads.ts rename to graph/test/typescript/cases/04-overload-selection/src/overloads.ts diff --git a/test/typescript/cases/05-declaration-merging/lib/merged-lib.ts b/graph/test/typescript/cases/05-declaration-merging/lib/merged-lib.ts similarity index 100% rename from test/typescript/cases/05-declaration-merging/lib/merged-lib.ts rename to graph/test/typescript/cases/05-declaration-merging/lib/merged-lib.ts diff --git a/test/typescript/cases/05-declaration-merging/src/merged.ts b/graph/test/typescript/cases/05-declaration-merging/src/merged.ts similarity index 100% rename from test/typescript/cases/05-declaration-merging/src/merged.ts rename to graph/test/typescript/cases/05-declaration-merging/src/merged.ts diff --git a/test/typescript/cases/06-module-reexport-chain/lib/index.ts b/graph/test/typescript/cases/06-module-reexport-chain/lib/index.ts similarity index 100% rename from test/typescript/cases/06-module-reexport-chain/lib/index.ts rename to graph/test/typescript/cases/06-module-reexport-chain/lib/index.ts diff --git a/test/typescript/cases/06-module-reexport-chain/lib/vendor-core.ts b/graph/test/typescript/cases/06-module-reexport-chain/lib/vendor-core.ts similarity index 100% rename from test/typescript/cases/06-module-reexport-chain/lib/vendor-core.ts rename to graph/test/typescript/cases/06-module-reexport-chain/lib/vendor-core.ts diff --git a/test/typescript/cases/06-module-reexport-chain/src/barrel.ts b/graph/test/typescript/cases/06-module-reexport-chain/src/barrel.ts similarity index 100% rename from test/typescript/cases/06-module-reexport-chain/src/barrel.ts rename to graph/test/typescript/cases/06-module-reexport-chain/src/barrel.ts diff --git a/test/typescript/cases/06-module-reexport-chain/src/consumer.ts b/graph/test/typescript/cases/06-module-reexport-chain/src/consumer.ts similarity index 100% rename from test/typescript/cases/06-module-reexport-chain/src/consumer.ts rename to graph/test/typescript/cases/06-module-reexport-chain/src/consumer.ts diff --git a/test/typescript/cases/06-module-reexport-chain/src/core.ts b/graph/test/typescript/cases/06-module-reexport-chain/src/core.ts similarity index 100% rename from test/typescript/cases/06-module-reexport-chain/src/core.ts rename to graph/test/typescript/cases/06-module-reexport-chain/src/core.ts diff --git a/test/typescript/cases/06-module-reexport-chain/src/deep.ts b/graph/test/typescript/cases/06-module-reexport-chain/src/deep.ts similarity index 100% rename from test/typescript/cases/06-module-reexport-chain/src/deep.ts rename to graph/test/typescript/cases/06-module-reexport-chain/src/deep.ts diff --git a/test/typescript/cases/07-namespace-and-export-equals/lib/legacy-lib.ts b/graph/test/typescript/cases/07-namespace-and-export-equals/lib/legacy-lib.ts similarity index 100% rename from test/typescript/cases/07-namespace-and-export-equals/lib/legacy-lib.ts rename to graph/test/typescript/cases/07-namespace-and-export-equals/lib/legacy-lib.ts diff --git a/test/typescript/cases/07-namespace-and-export-equals/src/consumer.ts b/graph/test/typescript/cases/07-namespace-and-export-equals/src/consumer.ts similarity index 100% rename from test/typescript/cases/07-namespace-and-export-equals/src/consumer.ts rename to graph/test/typescript/cases/07-namespace-and-export-equals/src/consumer.ts diff --git a/test/typescript/cases/07-namespace-and-export-equals/src/legacy.ts b/graph/test/typescript/cases/07-namespace-and-export-equals/src/legacy.ts similarity index 100% rename from test/typescript/cases/07-namespace-and-export-equals/src/legacy.ts rename to graph/test/typescript/cases/07-namespace-and-export-equals/src/legacy.ts diff --git a/test/typescript/cases/08-function-values-and-callbacks/lib/hooks.ts b/graph/test/typescript/cases/08-function-values-and-callbacks/lib/hooks.ts similarity index 100% rename from test/typescript/cases/08-function-values-and-callbacks/lib/hooks.ts rename to graph/test/typescript/cases/08-function-values-and-callbacks/lib/hooks.ts diff --git a/test/typescript/cases/08-function-values-and-callbacks/src/values.ts b/graph/test/typescript/cases/08-function-values-and-callbacks/src/values.ts similarity index 100% rename from test/typescript/cases/08-function-values-and-callbacks/src/values.ts rename to graph/test/typescript/cases/08-function-values-and-callbacks/src/values.ts diff --git a/test/typescript/cases/09-generics-substitution/lib/container.ts b/graph/test/typescript/cases/09-generics-substitution/lib/container.ts similarity index 100% rename from test/typescript/cases/09-generics-substitution/lib/container.ts rename to graph/test/typescript/cases/09-generics-substitution/lib/container.ts diff --git a/test/typescript/cases/09-generics-substitution/src/generic.ts b/graph/test/typescript/cases/09-generics-substitution/src/generic.ts similarity index 100% rename from test/typescript/cases/09-generics-substitution/src/generic.ts rename to graph/test/typescript/cases/09-generics-substitution/src/generic.ts diff --git a/test/typescript/cases/10-jsx-component-calls/lib/ui.tsx b/graph/test/typescript/cases/10-jsx-component-calls/lib/ui.tsx similarity index 100% rename from test/typescript/cases/10-jsx-component-calls/lib/ui.tsx rename to graph/test/typescript/cases/10-jsx-component-calls/lib/ui.tsx diff --git a/test/typescript/cases/10-jsx-component-calls/src/components.tsx b/graph/test/typescript/cases/10-jsx-component-calls/src/components.tsx similarity index 100% rename from test/typescript/cases/10-jsx-component-calls/src/components.tsx rename to graph/test/typescript/cases/10-jsx-component-calls/src/components.tsx diff --git a/test/typescript/cases/11-async-and-chaining/lib/store.ts b/graph/test/typescript/cases/11-async-and-chaining/lib/store.ts similarity index 100% rename from test/typescript/cases/11-async-and-chaining/lib/store.ts rename to graph/test/typescript/cases/11-async-and-chaining/lib/store.ts diff --git a/test/typescript/cases/11-async-and-chaining/src/async.ts b/graph/test/typescript/cases/11-async-and-chaining/src/async.ts similarity index 100% rename from test/typescript/cases/11-async-and-chaining/src/async.ts rename to graph/test/typescript/cases/11-async-and-chaining/src/async.ts diff --git a/test/typescript/cases/12-getters-statics-and-enums/lib/metrics.ts b/graph/test/typescript/cases/12-getters-statics-and-enums/lib/metrics.ts similarity index 100% rename from test/typescript/cases/12-getters-statics-and-enums/lib/metrics.ts rename to graph/test/typescript/cases/12-getters-statics-and-enums/lib/metrics.ts diff --git a/test/typescript/cases/12-getters-statics-and-enums/src/members.ts b/graph/test/typescript/cases/12-getters-statics-and-enums/src/members.ts similarity index 100% rename from test/typescript/cases/12-getters-statics-and-enums/src/members.ts rename to graph/test/typescript/cases/12-getters-statics-and-enums/src/members.ts diff --git a/test/typescript/cases/13-receiver-forms/lib/graph.ts b/graph/test/typescript/cases/13-receiver-forms/lib/graph.ts similarity index 100% rename from test/typescript/cases/13-receiver-forms/lib/graph.ts rename to graph/test/typescript/cases/13-receiver-forms/lib/graph.ts diff --git a/test/typescript/cases/13-receiver-forms/src/receivers.ts b/graph/test/typescript/cases/13-receiver-forms/src/receivers.ts similarity index 100% rename from test/typescript/cases/13-receiver-forms/src/receivers.ts rename to graph/test/typescript/cases/13-receiver-forms/src/receivers.ts diff --git a/test/typescript/cases/14-cross-file-shadowing/lib/alpha.ts b/graph/test/typescript/cases/14-cross-file-shadowing/lib/alpha.ts similarity index 100% rename from test/typescript/cases/14-cross-file-shadowing/lib/alpha.ts rename to graph/test/typescript/cases/14-cross-file-shadowing/lib/alpha.ts diff --git a/test/typescript/cases/14-cross-file-shadowing/src/alpha.ts b/graph/test/typescript/cases/14-cross-file-shadowing/src/alpha.ts similarity index 100% rename from test/typescript/cases/14-cross-file-shadowing/src/alpha.ts rename to graph/test/typescript/cases/14-cross-file-shadowing/src/alpha.ts diff --git a/test/typescript/cases/14-cross-file-shadowing/src/beta.ts b/graph/test/typescript/cases/14-cross-file-shadowing/src/beta.ts similarity index 100% rename from test/typescript/cases/14-cross-file-shadowing/src/beta.ts rename to graph/test/typescript/cases/14-cross-file-shadowing/src/beta.ts diff --git a/test/typescript/cases/14-cross-file-shadowing/src/consumer.ts b/graph/test/typescript/cases/14-cross-file-shadowing/src/consumer.ts similarity index 100% rename from test/typescript/cases/14-cross-file-shadowing/src/consumer.ts rename to graph/test/typescript/cases/14-cross-file-shadowing/src/consumer.ts diff --git a/test/typescript/cases/15-type-only-and-erasure/lib/api.ts b/graph/test/typescript/cases/15-type-only-and-erasure/lib/api.ts similarity index 100% rename from test/typescript/cases/15-type-only-and-erasure/lib/api.ts rename to graph/test/typescript/cases/15-type-only-and-erasure/lib/api.ts diff --git a/test/typescript/cases/15-type-only-and-erasure/src/consumer.ts b/graph/test/typescript/cases/15-type-only-and-erasure/src/consumer.ts similarity index 100% rename from test/typescript/cases/15-type-only-and-erasure/src/consumer.ts rename to graph/test/typescript/cases/15-type-only-and-erasure/src/consumer.ts diff --git a/test/typescript/cases/15-type-only-and-erasure/src/types.ts b/graph/test/typescript/cases/15-type-only-and-erasure/src/types.ts similarity index 100% rename from test/typescript/cases/15-type-only-and-erasure/src/types.ts rename to graph/test/typescript/cases/15-type-only-and-erasure/src/types.ts diff --git a/test/typescript/cases/16-recursive-and-mutual/lib/walker.ts b/graph/test/typescript/cases/16-recursive-and-mutual/lib/walker.ts similarity index 100% rename from test/typescript/cases/16-recursive-and-mutual/lib/walker.ts rename to graph/test/typescript/cases/16-recursive-and-mutual/lib/walker.ts diff --git a/test/typescript/cases/16-recursive-and-mutual/src/cycle.ts b/graph/test/typescript/cases/16-recursive-and-mutual/src/cycle.ts similarity index 100% rename from test/typescript/cases/16-recursive-and-mutual/src/cycle.ts rename to graph/test/typescript/cases/16-recursive-and-mutual/src/cycle.ts diff --git a/test/typescript/cases/17-constrained-generics/lib/curried.ts b/graph/test/typescript/cases/17-constrained-generics/lib/curried.ts similarity index 100% rename from test/typescript/cases/17-constrained-generics/lib/curried.ts rename to graph/test/typescript/cases/17-constrained-generics/lib/curried.ts diff --git a/test/typescript/cases/17-constrained-generics/src/consumer.ts b/graph/test/typescript/cases/17-constrained-generics/src/consumer.ts similarity index 100% rename from test/typescript/cases/17-constrained-generics/src/consumer.ts rename to graph/test/typescript/cases/17-constrained-generics/src/consumer.ts diff --git a/test/typescript/cases/17-constrained-generics/src/curried-local.ts b/graph/test/typescript/cases/17-constrained-generics/src/curried-local.ts similarity index 100% rename from test/typescript/cases/17-constrained-generics/src/curried-local.ts rename to graph/test/typescript/cases/17-constrained-generics/src/curried-local.ts diff --git a/test/typescript/cases/18-heritage-across-boundary/lib/base.ts b/graph/test/typescript/cases/18-heritage-across-boundary/lib/base.ts similarity index 100% rename from test/typescript/cases/18-heritage-across-boundary/lib/base.ts rename to graph/test/typescript/cases/18-heritage-across-boundary/lib/base.ts diff --git a/test/typescript/cases/18-heritage-across-boundary/src/derived.ts b/graph/test/typescript/cases/18-heritage-across-boundary/src/derived.ts similarity index 100% rename from test/typescript/cases/18-heritage-across-boundary/src/derived.ts rename to graph/test/typescript/cases/18-heritage-across-boundary/src/derived.ts diff --git a/test/typescript/cases/19-overload-gauntlet/lib/api.ts b/graph/test/typescript/cases/19-overload-gauntlet/lib/api.ts similarity index 100% rename from test/typescript/cases/19-overload-gauntlet/lib/api.ts rename to graph/test/typescript/cases/19-overload-gauntlet/lib/api.ts diff --git a/test/typescript/cases/19-overload-gauntlet/src/consumer.ts b/graph/test/typescript/cases/19-overload-gauntlet/src/consumer.ts similarity index 100% rename from test/typescript/cases/19-overload-gauntlet/src/consumer.ts rename to graph/test/typescript/cases/19-overload-gauntlet/src/consumer.ts diff --git a/test/typescript/cases/20-lib-flow/lib/shapes.ts b/graph/test/typescript/cases/20-lib-flow/lib/shapes.ts similarity index 100% rename from test/typescript/cases/20-lib-flow/lib/shapes.ts rename to graph/test/typescript/cases/20-lib-flow/lib/shapes.ts diff --git a/test/typescript/cases/20-lib-flow/src/flow.ts b/graph/test/typescript/cases/20-lib-flow/src/flow.ts similarity index 100% rename from test/typescript/cases/20-lib-flow/src/flow.ts rename to graph/test/typescript/cases/20-lib-flow/src/flow.ts diff --git a/test/typescript/cases/20-lib-flow/src/tsconfig.json b/graph/test/typescript/cases/20-lib-flow/src/tsconfig.json similarity index 100% rename from test/typescript/cases/20-lib-flow/src/tsconfig.json rename to graph/test/typescript/cases/20-lib-flow/src/tsconfig.json diff --git a/test/typescript/cases/21-callable-function-members/src/globals.d.ts b/graph/test/typescript/cases/21-callable-function-members/src/globals.d.ts similarity index 100% rename from test/typescript/cases/21-callable-function-members/src/globals.d.ts rename to graph/test/typescript/cases/21-callable-function-members/src/globals.d.ts diff --git a/test/typescript/cases/21-callable-function-members/src/strict-bind.ts b/graph/test/typescript/cases/21-callable-function-members/src/strict-bind.ts similarity index 100% rename from test/typescript/cases/21-callable-function-members/src/strict-bind.ts rename to graph/test/typescript/cases/21-callable-function-members/src/strict-bind.ts diff --git a/test/typescript/cases/21-callable-function-members/src/tsconfig.json b/graph/test/typescript/cases/21-callable-function-members/src/tsconfig.json similarity index 100% rename from test/typescript/cases/21-callable-function-members/src/tsconfig.json rename to graph/test/typescript/cases/21-callable-function-members/src/tsconfig.json diff --git a/test/typescript/cases/22-loose-bind-call-apply/src/globals.d.ts b/graph/test/typescript/cases/22-loose-bind-call-apply/src/globals.d.ts similarity index 100% rename from test/typescript/cases/22-loose-bind-call-apply/src/globals.d.ts rename to graph/test/typescript/cases/22-loose-bind-call-apply/src/globals.d.ts diff --git a/test/typescript/cases/22-loose-bind-call-apply/src/loose-bind.ts b/graph/test/typescript/cases/22-loose-bind-call-apply/src/loose-bind.ts similarity index 100% rename from test/typescript/cases/22-loose-bind-call-apply/src/loose-bind.ts rename to graph/test/typescript/cases/22-loose-bind-call-apply/src/loose-bind.ts diff --git a/test/typescript/cases/22-loose-bind-call-apply/src/tsconfig.json b/graph/test/typescript/cases/22-loose-bind-call-apply/src/tsconfig.json similarity index 100% rename from test/typescript/cases/22-loose-bind-call-apply/src/tsconfig.json rename to graph/test/typescript/cases/22-loose-bind-call-apply/src/tsconfig.json diff --git a/test/typescript/cases/24-hedged-overload-return-union/lib/widgets.ts b/graph/test/typescript/cases/24-hedged-overload-return-union/lib/widgets.ts similarity index 100% rename from test/typescript/cases/24-hedged-overload-return-union/lib/widgets.ts rename to graph/test/typescript/cases/24-hedged-overload-return-union/lib/widgets.ts diff --git a/test/typescript/cases/24-hedged-overload-return-union/src/consumer.ts b/graph/test/typescript/cases/24-hedged-overload-return-union/src/consumer.ts similarity index 100% rename from test/typescript/cases/24-hedged-overload-return-union/src/consumer.ts rename to graph/test/typescript/cases/24-hedged-overload-return-union/src/consumer.ts diff --git a/test/typescript/cases/25-qualified-type-names/lib/shapes-lib.ts b/graph/test/typescript/cases/25-qualified-type-names/lib/shapes-lib.ts similarity index 100% rename from test/typescript/cases/25-qualified-type-names/lib/shapes-lib.ts rename to graph/test/typescript/cases/25-qualified-type-names/lib/shapes-lib.ts diff --git a/test/typescript/cases/25-qualified-type-names/src/client-to-lib.ts b/graph/test/typescript/cases/25-qualified-type-names/src/client-to-lib.ts similarity index 100% rename from test/typescript/cases/25-qualified-type-names/src/client-to-lib.ts rename to graph/test/typescript/cases/25-qualified-type-names/src/client-to-lib.ts diff --git a/test/typescript/cases/25-qualified-type-names/src/consumer.ts b/graph/test/typescript/cases/25-qualified-type-names/src/consumer.ts similarity index 100% rename from test/typescript/cases/25-qualified-type-names/src/consumer.ts rename to graph/test/typescript/cases/25-qualified-type-names/src/consumer.ts diff --git a/test/typescript/cases/25-qualified-type-names/src/shapes.ts b/graph/test/typescript/cases/25-qualified-type-names/src/shapes.ts similarity index 100% rename from test/typescript/cases/25-qualified-type-names/src/shapes.ts rename to graph/test/typescript/cases/25-qualified-type-names/src/shapes.ts diff --git a/test/typescript/cases/26-namespace-object-fallback/src/caller.ts b/graph/test/typescript/cases/26-namespace-object-fallback/src/caller.ts similarity index 100% rename from test/typescript/cases/26-namespace-object-fallback/src/caller.ts rename to graph/test/typescript/cases/26-namespace-object-fallback/src/caller.ts diff --git a/test/typescript/cases/26-namespace-object-fallback/src/globals.d.ts b/graph/test/typescript/cases/26-namespace-object-fallback/src/globals.d.ts similarity index 100% rename from test/typescript/cases/26-namespace-object-fallback/src/globals.d.ts rename to graph/test/typescript/cases/26-namespace-object-fallback/src/globals.d.ts diff --git a/test/typescript/cases/26-namespace-object-fallback/src/helper.ts b/graph/test/typescript/cases/26-namespace-object-fallback/src/helper.ts similarity index 100% rename from test/typescript/cases/26-namespace-object-fallback/src/helper.ts rename to graph/test/typescript/cases/26-namespace-object-fallback/src/helper.ts diff --git a/test/typescript/cases/26-namespace-object-fallback/src/tsconfig.json b/graph/test/typescript/cases/26-namespace-object-fallback/src/tsconfig.json similarity index 100% rename from test/typescript/cases/26-namespace-object-fallback/src/tsconfig.json rename to graph/test/typescript/cases/26-namespace-object-fallback/src/tsconfig.json diff --git a/test/typescript/cases/27-readonly-array-receiver/src/globals.d.ts b/graph/test/typescript/cases/27-readonly-array-receiver/src/globals.d.ts similarity index 100% rename from test/typescript/cases/27-readonly-array-receiver/src/globals.d.ts rename to graph/test/typescript/cases/27-readonly-array-receiver/src/globals.d.ts diff --git a/test/typescript/cases/27-readonly-array-receiver/src/readonly.ts b/graph/test/typescript/cases/27-readonly-array-receiver/src/readonly.ts similarity index 100% rename from test/typescript/cases/27-readonly-array-receiver/src/readonly.ts rename to graph/test/typescript/cases/27-readonly-array-receiver/src/readonly.ts diff --git a/test/typescript/cases/27-readonly-array-receiver/src/tsconfig.json b/graph/test/typescript/cases/27-readonly-array-receiver/src/tsconfig.json similarity index 100% rename from test/typescript/cases/27-readonly-array-receiver/src/tsconfig.json rename to graph/test/typescript/cases/27-readonly-array-receiver/src/tsconfig.json diff --git a/test/typescript/cases/28-property-chain-container/src/chain.ts b/graph/test/typescript/cases/28-property-chain-container/src/chain.ts similarity index 100% rename from test/typescript/cases/28-property-chain-container/src/chain.ts rename to graph/test/typescript/cases/28-property-chain-container/src/chain.ts diff --git a/test/typescript/cases/28-property-chain-container/src/globals.d.ts b/graph/test/typescript/cases/28-property-chain-container/src/globals.d.ts similarity index 100% rename from test/typescript/cases/28-property-chain-container/src/globals.d.ts rename to graph/test/typescript/cases/28-property-chain-container/src/globals.d.ts diff --git a/test/typescript/cases/28-property-chain-container/src/tsconfig.json b/graph/test/typescript/cases/28-property-chain-container/src/tsconfig.json similarity index 100% rename from test/typescript/cases/28-property-chain-container/src/tsconfig.json rename to graph/test/typescript/cases/28-property-chain-container/src/tsconfig.json diff --git a/test/typescript/cases/29-type-literal-callable-member/src/globals.d.ts b/graph/test/typescript/cases/29-type-literal-callable-member/src/globals.d.ts similarity index 100% rename from test/typescript/cases/29-type-literal-callable-member/src/globals.d.ts rename to graph/test/typescript/cases/29-type-literal-callable-member/src/globals.d.ts diff --git a/test/typescript/cases/29-type-literal-callable-member/src/members.ts b/graph/test/typescript/cases/29-type-literal-callable-member/src/members.ts similarity index 100% rename from test/typescript/cases/29-type-literal-callable-member/src/members.ts rename to graph/test/typescript/cases/29-type-literal-callable-member/src/members.ts diff --git a/test/typescript/cases/29-type-literal-callable-member/src/tsconfig.json b/graph/test/typescript/cases/29-type-literal-callable-member/src/tsconfig.json similarity index 100% rename from test/typescript/cases/29-type-literal-callable-member/src/tsconfig.json rename to graph/test/typescript/cases/29-type-literal-callable-member/src/tsconfig.json diff --git a/test/typescript/cases/30-super-into-construct-signature/lib/base.ts b/graph/test/typescript/cases/30-super-into-construct-signature/lib/base.ts similarity index 100% rename from test/typescript/cases/30-super-into-construct-signature/lib/base.ts rename to graph/test/typescript/cases/30-super-into-construct-signature/lib/base.ts diff --git a/test/typescript/cases/30-super-into-construct-signature/src/app.ts b/graph/test/typescript/cases/30-super-into-construct-signature/src/app.ts similarity index 100% rename from test/typescript/cases/30-super-into-construct-signature/src/app.ts rename to graph/test/typescript/cases/30-super-into-construct-signature/src/app.ts diff --git a/test/typescript/cases/31-asserts-predicate/src/narrow.ts b/graph/test/typescript/cases/31-asserts-predicate/src/narrow.ts similarity index 100% rename from test/typescript/cases/31-asserts-predicate/src/narrow.ts rename to graph/test/typescript/cases/31-asserts-predicate/src/narrow.ts diff --git a/test/typescript/cases/32-decorator-application/src/svc.ts b/graph/test/typescript/cases/32-decorator-application/src/svc.ts similarity index 100% rename from test/typescript/cases/32-decorator-application/src/svc.ts rename to graph/test/typescript/cases/32-decorator-application/src/svc.ts diff --git a/test/typescript/cases/32-decorator-application/src/tsconfig.json b/graph/test/typescript/cases/32-decorator-application/src/tsconfig.json similarity index 100% rename from test/typescript/cases/32-decorator-application/src/tsconfig.json rename to graph/test/typescript/cases/32-decorator-application/src/tsconfig.json diff --git a/test/typescript/cases/33-container-element-receiver/src/iter.ts b/graph/test/typescript/cases/33-container-element-receiver/src/iter.ts similarity index 100% rename from test/typescript/cases/33-container-element-receiver/src/iter.ts rename to graph/test/typescript/cases/33-container-element-receiver/src/iter.ts diff --git a/test/typescript/cases/34-callback-of-callback/src/exec.ts b/graph/test/typescript/cases/34-callback-of-callback/src/exec.ts similarity index 100% rename from test/typescript/cases/34-callback-of-callback/src/exec.ts rename to graph/test/typescript/cases/34-callback-of-callback/src/exec.ts diff --git a/test/typescript/cases/34-callback-of-callback/src/globals.d.ts b/graph/test/typescript/cases/34-callback-of-callback/src/globals.d.ts similarity index 100% rename from test/typescript/cases/34-callback-of-callback/src/globals.d.ts rename to graph/test/typescript/cases/34-callback-of-callback/src/globals.d.ts diff --git a/test/typescript/cases/34-callback-of-callback/src/tsconfig.json b/graph/test/typescript/cases/34-callback-of-callback/src/tsconfig.json similarity index 100% rename from test/typescript/cases/34-callback-of-callback/src/tsconfig.json rename to graph/test/typescript/cases/34-callback-of-callback/src/tsconfig.json diff --git a/test/typescript/cases/35-namespace-same-module/src/ns.ts b/graph/test/typescript/cases/35-namespace-same-module/src/ns.ts similarity index 100% rename from test/typescript/cases/35-namespace-same-module/src/ns.ts rename to graph/test/typescript/cases/35-namespace-same-module/src/ns.ts diff --git a/test/typescript/cases/35-namespace-same-module/src/use.ts b/graph/test/typescript/cases/35-namespace-same-module/src/use.ts similarity index 100% rename from test/typescript/cases/35-namespace-same-module/src/use.ts rename to graph/test/typescript/cases/35-namespace-same-module/src/use.ts diff --git a/test/typescript/cases/36-function-value-containers/src/r229.ts b/graph/test/typescript/cases/36-function-value-containers/src/r229.ts similarity index 100% rename from test/typescript/cases/36-function-value-containers/src/r229.ts rename to graph/test/typescript/cases/36-function-value-containers/src/r229.ts diff --git a/test/typescript/cases/37-destructuring-bindings/src/r388.ts b/graph/test/typescript/cases/37-destructuring-bindings/src/r388.ts similarity index 100% rename from test/typescript/cases/37-destructuring-bindings/src/r388.ts rename to graph/test/typescript/cases/37-destructuring-bindings/src/r388.ts diff --git a/test/typescript/cases/38-dynamic-import/src/handlers/alpha.ts b/graph/test/typescript/cases/38-dynamic-import/src/handlers/alpha.ts similarity index 100% rename from test/typescript/cases/38-dynamic-import/src/handlers/alpha.ts rename to graph/test/typescript/cases/38-dynamic-import/src/handlers/alpha.ts diff --git a/test/typescript/cases/38-dynamic-import/src/handlers/beta.ts b/graph/test/typescript/cases/38-dynamic-import/src/handlers/beta.ts similarity index 100% rename from test/typescript/cases/38-dynamic-import/src/handlers/beta.ts rename to graph/test/typescript/cases/38-dynamic-import/src/handlers/beta.ts diff --git a/test/typescript/cases/38-dynamic-import/src/r352.ts b/graph/test/typescript/cases/38-dynamic-import/src/r352.ts similarity index 100% rename from test/typescript/cases/38-dynamic-import/src/r352.ts rename to graph/test/typescript/cases/38-dynamic-import/src/r352.ts diff --git a/test/typescript/cases/39-tagged-template-overload/src/r354.ts b/graph/test/typescript/cases/39-tagged-template-overload/src/r354.ts similarity index 100% rename from test/typescript/cases/39-tagged-template-overload/src/r354.ts rename to graph/test/typescript/cases/39-tagged-template-overload/src/r354.ts diff --git a/test/typescript/cases/40-explicit-type-argument/src/r353.ts b/graph/test/typescript/cases/40-explicit-type-argument/src/r353.ts similarity index 100% rename from test/typescript/cases/40-explicit-type-argument/src/r353.ts rename to graph/test/typescript/cases/40-explicit-type-argument/src/r353.ts diff --git a/test/typescript/cases/41-library-generic-return/src/r356.ts b/graph/test/typescript/cases/41-library-generic-return/src/r356.ts similarity index 100% rename from test/typescript/cases/41-library-generic-return/src/r356.ts rename to graph/test/typescript/cases/41-library-generic-return/src/r356.ts diff --git a/test/typescript/cases/42-barrel-bare-reexport/src/hash/impl.ts b/graph/test/typescript/cases/42-barrel-bare-reexport/src/hash/impl.ts similarity index 100% rename from test/typescript/cases/42-barrel-bare-reexport/src/hash/impl.ts rename to graph/test/typescript/cases/42-barrel-bare-reexport/src/hash/impl.ts diff --git a/test/typescript/cases/42-barrel-bare-reexport/src/hash/index.ts b/graph/test/typescript/cases/42-barrel-bare-reexport/src/hash/index.ts similarity index 100% rename from test/typescript/cases/42-barrel-bare-reexport/src/hash/index.ts rename to graph/test/typescript/cases/42-barrel-bare-reexport/src/hash/index.ts diff --git a/test/typescript/cases/42-barrel-bare-reexport/src/main.ts b/graph/test/typescript/cases/42-barrel-bare-reexport/src/main.ts similarity index 100% rename from test/typescript/cases/42-barrel-bare-reexport/src/main.ts rename to graph/test/typescript/cases/42-barrel-bare-reexport/src/main.ts diff --git a/test/typescript/cases/43-string-literal-overload/src/main.ts b/graph/test/typescript/cases/43-string-literal-overload/src/main.ts similarity index 100% rename from test/typescript/cases/43-string-literal-overload/src/main.ts rename to graph/test/typescript/cases/43-string-literal-overload/src/main.ts diff --git a/test/typescript/cases/44-union-and-unknown-argument/src/main.ts b/graph/test/typescript/cases/44-union-and-unknown-argument/src/main.ts similarity index 100% rename from test/typescript/cases/44-union-and-unknown-argument/src/main.ts rename to graph/test/typescript/cases/44-union-and-unknown-argument/src/main.ts diff --git a/test/typescript/cases/45-explicit-type-argument-excludes-nongeneric/src/main.ts b/graph/test/typescript/cases/45-explicit-type-argument-excludes-nongeneric/src/main.ts similarity index 100% rename from test/typescript/cases/45-explicit-type-argument-excludes-nongeneric/src/main.ts rename to graph/test/typescript/cases/45-explicit-type-argument-excludes-nongeneric/src/main.ts diff --git a/test/typescript/cases/46-new-through-class-valued-variable/src/main.ts b/graph/test/typescript/cases/46-new-through-class-valued-variable/src/main.ts similarity index 100% rename from test/typescript/cases/46-new-through-class-valued-variable/src/main.ts rename to graph/test/typescript/cases/46-new-through-class-valued-variable/src/main.ts diff --git a/test/typescript/cases/47-for-of-tuple-destructuring/src/main.ts b/graph/test/typescript/cases/47-for-of-tuple-destructuring/src/main.ts similarity index 100% rename from test/typescript/cases/47-for-of-tuple-destructuring/src/main.ts rename to graph/test/typescript/cases/47-for-of-tuple-destructuring/src/main.ts diff --git a/test/typescript/cases/48-default-export-of-qualified-expression/src/main.ts b/graph/test/typescript/cases/48-default-export-of-qualified-expression/src/main.ts similarity index 100% rename from test/typescript/cases/48-default-export-of-qualified-expression/src/main.ts rename to graph/test/typescript/cases/48-default-export-of-qualified-expression/src/main.ts diff --git a/test/typescript/cases/48-default-export-of-qualified-expression/src/vlib/index.ts b/graph/test/typescript/cases/48-default-export-of-qualified-expression/src/vlib/index.ts similarity index 100% rename from test/typescript/cases/48-default-export-of-qualified-expression/src/vlib/index.ts rename to graph/test/typescript/cases/48-default-export-of-qualified-expression/src/vlib/index.ts diff --git a/test/typescript/cases/48-default-export-of-qualified-expression/src/vlib/isLen.ts b/graph/test/typescript/cases/48-default-export-of-qualified-expression/src/vlib/isLen.ts similarity index 100% rename from test/typescript/cases/48-default-export-of-qualified-expression/src/vlib/isLen.ts rename to graph/test/typescript/cases/48-default-export-of-qualified-expression/src/vlib/isLen.ts diff --git a/test/typescript/cases/49-indexed-access-return/src/main.ts b/graph/test/typescript/cases/49-indexed-access-return/src/main.ts similarity index 100% rename from test/typescript/cases/49-indexed-access-return/src/main.ts rename to graph/test/typescript/cases/49-indexed-access-return/src/main.ts diff --git a/test/typescript/cases/50-asserts-condition-narrowing/src/main.ts b/graph/test/typescript/cases/50-asserts-condition-narrowing/src/main.ts similarity index 100% rename from test/typescript/cases/50-asserts-condition-narrowing/src/main.ts rename to graph/test/typescript/cases/50-asserts-condition-narrowing/src/main.ts diff --git a/test/typescript/cases/51-polymorphic-this-across-boundary/lib/emit.ts b/graph/test/typescript/cases/51-polymorphic-this-across-boundary/lib/emit.ts similarity index 100% rename from test/typescript/cases/51-polymorphic-this-across-boundary/lib/emit.ts rename to graph/test/typescript/cases/51-polymorphic-this-across-boundary/lib/emit.ts diff --git a/test/typescript/cases/51-polymorphic-this-across-boundary/src/main.ts b/graph/test/typescript/cases/51-polymorphic-this-across-boundary/src/main.ts similarity index 100% rename from test/typescript/cases/51-polymorphic-this-across-boundary/src/main.ts rename to graph/test/typescript/cases/51-polymorphic-this-across-boundary/src/main.ts diff --git a/test/typescript/cases/52-cross-module-library-heritage/lib/core.ts b/graph/test/typescript/cases/52-cross-module-library-heritage/lib/core.ts similarity index 100% rename from test/typescript/cases/52-cross-module-library-heritage/lib/core.ts rename to graph/test/typescript/cases/52-cross-module-library-heritage/lib/core.ts diff --git a/test/typescript/cases/52-cross-module-library-heritage/lib/ext.ts b/graph/test/typescript/cases/52-cross-module-library-heritage/lib/ext.ts similarity index 100% rename from test/typescript/cases/52-cross-module-library-heritage/lib/ext.ts rename to graph/test/typescript/cases/52-cross-module-library-heritage/lib/ext.ts diff --git a/test/typescript/cases/52-cross-module-library-heritage/src/main.ts b/graph/test/typescript/cases/52-cross-module-library-heritage/src/main.ts similarity index 100% rename from test/typescript/cases/52-cross-module-library-heritage/src/main.ts rename to graph/test/typescript/cases/52-cross-module-library-heritage/src/main.ts diff --git a/test/typescript/cases/53-export-assignment-construction/src/legacy.ts b/graph/test/typescript/cases/53-export-assignment-construction/src/legacy.ts similarity index 100% rename from test/typescript/cases/53-export-assignment-construction/src/legacy.ts rename to graph/test/typescript/cases/53-export-assignment-construction/src/legacy.ts diff --git a/test/typescript/cases/53-export-assignment-construction/src/main.ts b/graph/test/typescript/cases/53-export-assignment-construction/src/main.ts similarity index 100% rename from test/typescript/cases/53-export-assignment-construction/src/main.ts rename to graph/test/typescript/cases/53-export-assignment-construction/src/main.ts diff --git a/test/typescript/cases/53-export-assignment-construction/src/named.ts b/graph/test/typescript/cases/53-export-assignment-construction/src/named.ts similarity index 100% rename from test/typescript/cases/53-export-assignment-construction/src/named.ts rename to graph/test/typescript/cases/53-export-assignment-construction/src/named.ts diff --git a/test/typescript/cases/54-subclass-of-value-declared-base/lib/collection.d.ts b/graph/test/typescript/cases/54-subclass-of-value-declared-base/lib/collection.d.ts similarity index 100% rename from test/typescript/cases/54-subclass-of-value-declared-base/lib/collection.d.ts rename to graph/test/typescript/cases/54-subclass-of-value-declared-base/lib/collection.d.ts diff --git a/test/typescript/cases/54-subclass-of-value-declared-base/src/main.ts b/graph/test/typescript/cases/54-subclass-of-value-declared-base/src/main.ts similarity index 100% rename from test/typescript/cases/54-subclass-of-value-declared-base/src/main.ts rename to graph/test/typescript/cases/54-subclass-of-value-declared-base/src/main.ts diff --git a/test/typescript/corpus/aggregate.py b/graph/test/typescript/corpus/aggregate.py similarity index 100% rename from test/typescript/corpus/aggregate.py rename to graph/test/typescript/corpus/aggregate.py diff --git a/test/typescript/corpus/corpus.tsv b/graph/test/typescript/corpus/corpus.tsv similarity index 100% rename from test/typescript/corpus/corpus.tsv rename to graph/test/typescript/corpus/corpus.tsv diff --git a/test/typescript/corpus/fetch.sh b/graph/test/typescript/corpus/fetch.sh similarity index 100% rename from test/typescript/corpus/fetch.sh rename to graph/test/typescript/corpus/fetch.sh diff --git a/test/typescript/corpus/run-corpus.sh b/graph/test/typescript/corpus/run-corpus.sh similarity index 93% rename from test/typescript/corpus/run-corpus.sh rename to graph/test/typescript/corpus/run-corpus.sh index d10432624..6d7340ad9 100755 --- a/test/typescript/corpus/run-corpus.sh +++ b/graph/test/typescript/corpus/run-corpus.sh @@ -13,6 +13,7 @@ set -u HERE="$(cd "$(dirname "$0")" && pwd)" TS="$(cd "$HERE/.." && pwd)" +REPO="$(d="$HERE"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker # NOT /tmp. Measured the hard way: all nine corpus projects lost every source file # mid-session to the system's /tmp reaper, directories and node_modules left standing and # .git gone, which surfaced as four unrelated-looking harness failures. A corpus in /tmp @@ -38,7 +39,7 @@ PARSER="${AXIOM_PARSER:-}" # The parser's module arity and this engine's declaration must agree. They drift on # every schema append, and the symptom downstream is a drift-gate refusal per project # with nothing saying they share one cause. -DECL=$(grep -o '^\.decl ts_module(.*' "$TS/../../src/typescript/souffle/decls_base.dl" 2>/dev/null \ +DECL=$(grep -o '^\.decl ts_module(.*' "$REPO/graph/typescript/souffle/decls_base.dl" 2>/dev/null \ | tr ',' '\n' | wc -l | tr -d ' ') echo "engine declares ts_module with $DECL columns; parser: $PARSER" diff --git a/test/typescript/expected/01-class-dispatch-and-super.edges b/graph/test/typescript/expected/01-class-dispatch-and-super.edges similarity index 100% rename from test/typescript/expected/01-class-dispatch-and-super.edges rename to graph/test/typescript/expected/01-class-dispatch-and-super.edges diff --git a/test/typescript/expected/01-class-dispatch-and-super.envelope b/graph/test/typescript/expected/01-class-dispatch-and-super.envelope similarity index 100% rename from test/typescript/expected/01-class-dispatch-and-super.envelope rename to graph/test/typescript/expected/01-class-dispatch-and-super.envelope diff --git a/test/typescript/expected/01-class-dispatch-and-super.lib.edges b/graph/test/typescript/expected/01-class-dispatch-and-super.lib.edges similarity index 100% rename from test/typescript/expected/01-class-dispatch-and-super.lib.edges rename to graph/test/typescript/expected/01-class-dispatch-and-super.lib.edges diff --git a/test/typescript/expected/01-class-dispatch-and-super.lib.envelope b/graph/test/typescript/expected/01-class-dispatch-and-super.lib.envelope similarity index 100% rename from test/typescript/expected/01-class-dispatch-and-super.lib.envelope rename to graph/test/typescript/expected/01-class-dispatch-and-super.lib.envelope diff --git a/test/typescript/expected/01-class-dispatch-and-super.lib.oracle b/graph/test/typescript/expected/01-class-dispatch-and-super.lib.oracle similarity index 100% rename from test/typescript/expected/01-class-dispatch-and-super.lib.oracle rename to graph/test/typescript/expected/01-class-dispatch-and-super.lib.oracle diff --git a/test/typescript/expected/01-class-dispatch-and-super.oracle b/graph/test/typescript/expected/01-class-dispatch-and-super.oracle similarity index 100% rename from test/typescript/expected/01-class-dispatch-and-super.oracle rename to graph/test/typescript/expected/01-class-dispatch-and-super.oracle diff --git a/test/typescript/expected/02-interface-fanout.edges b/graph/test/typescript/expected/02-interface-fanout.edges similarity index 100% rename from test/typescript/expected/02-interface-fanout.edges rename to graph/test/typescript/expected/02-interface-fanout.edges diff --git a/test/typescript/expected/02-interface-fanout.envelope b/graph/test/typescript/expected/02-interface-fanout.envelope similarity index 100% rename from test/typescript/expected/02-interface-fanout.envelope rename to graph/test/typescript/expected/02-interface-fanout.envelope diff --git a/test/typescript/expected/02-interface-fanout.lib.edges b/graph/test/typescript/expected/02-interface-fanout.lib.edges similarity index 100% rename from test/typescript/expected/02-interface-fanout.lib.edges rename to graph/test/typescript/expected/02-interface-fanout.lib.edges diff --git a/test/typescript/expected/02-interface-fanout.lib.envelope b/graph/test/typescript/expected/02-interface-fanout.lib.envelope similarity index 100% rename from test/typescript/expected/02-interface-fanout.lib.envelope rename to graph/test/typescript/expected/02-interface-fanout.lib.envelope diff --git a/test/typescript/expected/02-interface-fanout.lib.oracle b/graph/test/typescript/expected/02-interface-fanout.lib.oracle similarity index 100% rename from test/typescript/expected/02-interface-fanout.lib.oracle rename to graph/test/typescript/expected/02-interface-fanout.lib.oracle diff --git a/test/typescript/expected/02-interface-fanout.oracle b/graph/test/typescript/expected/02-interface-fanout.oracle similarity index 100% rename from test/typescript/expected/02-interface-fanout.oracle rename to graph/test/typescript/expected/02-interface-fanout.oracle diff --git a/test/typescript/expected/03-structural-satisfaction.edges b/graph/test/typescript/expected/03-structural-satisfaction.edges similarity index 100% rename from test/typescript/expected/03-structural-satisfaction.edges rename to graph/test/typescript/expected/03-structural-satisfaction.edges diff --git a/test/typescript/expected/03-structural-satisfaction.envelope b/graph/test/typescript/expected/03-structural-satisfaction.envelope similarity index 100% rename from test/typescript/expected/03-structural-satisfaction.envelope rename to graph/test/typescript/expected/03-structural-satisfaction.envelope diff --git a/test/typescript/expected/03-structural-satisfaction.lib.edges b/graph/test/typescript/expected/03-structural-satisfaction.lib.edges similarity index 100% rename from test/typescript/expected/03-structural-satisfaction.lib.edges rename to graph/test/typescript/expected/03-structural-satisfaction.lib.edges diff --git a/test/typescript/expected/03-structural-satisfaction.lib.envelope b/graph/test/typescript/expected/03-structural-satisfaction.lib.envelope similarity index 100% rename from test/typescript/expected/03-structural-satisfaction.lib.envelope rename to graph/test/typescript/expected/03-structural-satisfaction.lib.envelope diff --git a/test/typescript/expected/03-structural-satisfaction.lib.oracle b/graph/test/typescript/expected/03-structural-satisfaction.lib.oracle similarity index 100% rename from test/typescript/expected/03-structural-satisfaction.lib.oracle rename to graph/test/typescript/expected/03-structural-satisfaction.lib.oracle diff --git a/test/typescript/expected/03-structural-satisfaction.oracle b/graph/test/typescript/expected/03-structural-satisfaction.oracle similarity index 100% rename from test/typescript/expected/03-structural-satisfaction.oracle rename to graph/test/typescript/expected/03-structural-satisfaction.oracle diff --git a/test/typescript/expected/04-overload-selection.edges b/graph/test/typescript/expected/04-overload-selection.edges similarity index 100% rename from test/typescript/expected/04-overload-selection.edges rename to graph/test/typescript/expected/04-overload-selection.edges diff --git a/test/typescript/expected/04-overload-selection.lib.edges b/graph/test/typescript/expected/04-overload-selection.lib.edges similarity index 100% rename from test/typescript/expected/04-overload-selection.lib.edges rename to graph/test/typescript/expected/04-overload-selection.lib.edges diff --git a/test/typescript/expected/04-overload-selection.lib.oracle b/graph/test/typescript/expected/04-overload-selection.lib.oracle similarity index 100% rename from test/typescript/expected/04-overload-selection.lib.oracle rename to graph/test/typescript/expected/04-overload-selection.lib.oracle diff --git a/test/typescript/expected/04-overload-selection.oracle b/graph/test/typescript/expected/04-overload-selection.oracle similarity index 100% rename from test/typescript/expected/04-overload-selection.oracle rename to graph/test/typescript/expected/04-overload-selection.oracle diff --git a/test/typescript/expected/05-declaration-merging.edges b/graph/test/typescript/expected/05-declaration-merging.edges similarity index 100% rename from test/typescript/expected/05-declaration-merging.edges rename to graph/test/typescript/expected/05-declaration-merging.edges diff --git a/test/typescript/expected/05-declaration-merging.envelope b/graph/test/typescript/expected/05-declaration-merging.envelope similarity index 100% rename from test/typescript/expected/05-declaration-merging.envelope rename to graph/test/typescript/expected/05-declaration-merging.envelope diff --git a/test/typescript/expected/05-declaration-merging.known-missing b/graph/test/typescript/expected/05-declaration-merging.known-missing similarity index 100% rename from test/typescript/expected/05-declaration-merging.known-missing rename to graph/test/typescript/expected/05-declaration-merging.known-missing diff --git a/test/typescript/expected/05-declaration-merging.lib.edges b/graph/test/typescript/expected/05-declaration-merging.lib.edges similarity index 100% rename from test/typescript/expected/05-declaration-merging.lib.edges rename to graph/test/typescript/expected/05-declaration-merging.lib.edges diff --git a/test/typescript/expected/05-declaration-merging.lib.envelope b/graph/test/typescript/expected/05-declaration-merging.lib.envelope similarity index 100% rename from test/typescript/expected/05-declaration-merging.lib.envelope rename to graph/test/typescript/expected/05-declaration-merging.lib.envelope diff --git a/test/typescript/expected/05-declaration-merging.lib.known-missing b/graph/test/typescript/expected/05-declaration-merging.lib.known-missing similarity index 100% rename from test/typescript/expected/05-declaration-merging.lib.known-missing rename to graph/test/typescript/expected/05-declaration-merging.lib.known-missing diff --git a/test/typescript/expected/05-declaration-merging.lib.oracle b/graph/test/typescript/expected/05-declaration-merging.lib.oracle similarity index 100% rename from test/typescript/expected/05-declaration-merging.lib.oracle rename to graph/test/typescript/expected/05-declaration-merging.lib.oracle diff --git a/test/typescript/expected/05-declaration-merging.oracle b/graph/test/typescript/expected/05-declaration-merging.oracle similarity index 100% rename from test/typescript/expected/05-declaration-merging.oracle rename to graph/test/typescript/expected/05-declaration-merging.oracle diff --git a/test/typescript/expected/06-module-reexport-chain.edges b/graph/test/typescript/expected/06-module-reexport-chain.edges similarity index 100% rename from test/typescript/expected/06-module-reexport-chain.edges rename to graph/test/typescript/expected/06-module-reexport-chain.edges diff --git a/test/typescript/expected/06-module-reexport-chain.lib.edges b/graph/test/typescript/expected/06-module-reexport-chain.lib.edges similarity index 100% rename from test/typescript/expected/06-module-reexport-chain.lib.edges rename to graph/test/typescript/expected/06-module-reexport-chain.lib.edges diff --git a/test/typescript/expected/06-module-reexport-chain.lib.oracle b/graph/test/typescript/expected/06-module-reexport-chain.lib.oracle similarity index 100% rename from test/typescript/expected/06-module-reexport-chain.lib.oracle rename to graph/test/typescript/expected/06-module-reexport-chain.lib.oracle diff --git a/test/typescript/expected/06-module-reexport-chain.oracle b/graph/test/typescript/expected/06-module-reexport-chain.oracle similarity index 100% rename from test/typescript/expected/06-module-reexport-chain.oracle rename to graph/test/typescript/expected/06-module-reexport-chain.oracle diff --git a/test/typescript/expected/07-namespace-and-export-equals.edges b/graph/test/typescript/expected/07-namespace-and-export-equals.edges similarity index 100% rename from test/typescript/expected/07-namespace-and-export-equals.edges rename to graph/test/typescript/expected/07-namespace-and-export-equals.edges diff --git a/test/typescript/expected/07-namespace-and-export-equals.known-missing b/graph/test/typescript/expected/07-namespace-and-export-equals.known-missing similarity index 100% rename from test/typescript/expected/07-namespace-and-export-equals.known-missing rename to graph/test/typescript/expected/07-namespace-and-export-equals.known-missing diff --git a/test/typescript/expected/07-namespace-and-export-equals.lib.edges b/graph/test/typescript/expected/07-namespace-and-export-equals.lib.edges similarity index 100% rename from test/typescript/expected/07-namespace-and-export-equals.lib.edges rename to graph/test/typescript/expected/07-namespace-and-export-equals.lib.edges diff --git a/test/typescript/expected/07-namespace-and-export-equals.lib.known-missing b/graph/test/typescript/expected/07-namespace-and-export-equals.lib.known-missing similarity index 100% rename from test/typescript/expected/07-namespace-and-export-equals.lib.known-missing rename to graph/test/typescript/expected/07-namespace-and-export-equals.lib.known-missing diff --git a/test/typescript/expected/07-namespace-and-export-equals.lib.oracle b/graph/test/typescript/expected/07-namespace-and-export-equals.lib.oracle similarity index 100% rename from test/typescript/expected/07-namespace-and-export-equals.lib.oracle rename to graph/test/typescript/expected/07-namespace-and-export-equals.lib.oracle diff --git a/test/typescript/expected/07-namespace-and-export-equals.oracle b/graph/test/typescript/expected/07-namespace-and-export-equals.oracle similarity index 100% rename from test/typescript/expected/07-namespace-and-export-equals.oracle rename to graph/test/typescript/expected/07-namespace-and-export-equals.oracle diff --git a/test/typescript/expected/08-function-values-and-callbacks.edges b/graph/test/typescript/expected/08-function-values-and-callbacks.edges similarity index 100% rename from test/typescript/expected/08-function-values-and-callbacks.edges rename to graph/test/typescript/expected/08-function-values-and-callbacks.edges diff --git a/test/typescript/expected/08-function-values-and-callbacks.lib.edges b/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges similarity index 100% rename from test/typescript/expected/08-function-values-and-callbacks.lib.edges rename to graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges diff --git a/test/typescript/expected/08-function-values-and-callbacks.lib.oracle b/graph/test/typescript/expected/08-function-values-and-callbacks.lib.oracle similarity index 100% rename from test/typescript/expected/08-function-values-and-callbacks.lib.oracle rename to graph/test/typescript/expected/08-function-values-and-callbacks.lib.oracle diff --git a/test/typescript/expected/08-function-values-and-callbacks.oracle b/graph/test/typescript/expected/08-function-values-and-callbacks.oracle similarity index 100% rename from test/typescript/expected/08-function-values-and-callbacks.oracle rename to graph/test/typescript/expected/08-function-values-and-callbacks.oracle diff --git a/test/typescript/expected/09-generics-substitution.edges b/graph/test/typescript/expected/09-generics-substitution.edges similarity index 100% rename from test/typescript/expected/09-generics-substitution.edges rename to graph/test/typescript/expected/09-generics-substitution.edges diff --git a/test/typescript/expected/09-generics-substitution.known-missing b/graph/test/typescript/expected/09-generics-substitution.known-missing similarity index 100% rename from test/typescript/expected/09-generics-substitution.known-missing rename to graph/test/typescript/expected/09-generics-substitution.known-missing diff --git a/test/typescript/expected/09-generics-substitution.lib.edges b/graph/test/typescript/expected/09-generics-substitution.lib.edges similarity index 100% rename from test/typescript/expected/09-generics-substitution.lib.edges rename to graph/test/typescript/expected/09-generics-substitution.lib.edges diff --git a/test/typescript/expected/09-generics-substitution.lib.known-missing b/graph/test/typescript/expected/09-generics-substitution.lib.known-missing similarity index 100% rename from test/typescript/expected/09-generics-substitution.lib.known-missing rename to graph/test/typescript/expected/09-generics-substitution.lib.known-missing diff --git a/test/typescript/expected/09-generics-substitution.lib.oracle b/graph/test/typescript/expected/09-generics-substitution.lib.oracle similarity index 100% rename from test/typescript/expected/09-generics-substitution.lib.oracle rename to graph/test/typescript/expected/09-generics-substitution.lib.oracle diff --git a/test/typescript/expected/09-generics-substitution.oracle b/graph/test/typescript/expected/09-generics-substitution.oracle similarity index 100% rename from test/typescript/expected/09-generics-substitution.oracle rename to graph/test/typescript/expected/09-generics-substitution.oracle diff --git a/test/typescript/expected/10-jsx-component-calls.edges b/graph/test/typescript/expected/10-jsx-component-calls.edges similarity index 100% rename from test/typescript/expected/10-jsx-component-calls.edges rename to graph/test/typescript/expected/10-jsx-component-calls.edges diff --git a/test/typescript/expected/10-jsx-component-calls.known-missing b/graph/test/typescript/expected/10-jsx-component-calls.known-missing similarity index 100% rename from test/typescript/expected/10-jsx-component-calls.known-missing rename to graph/test/typescript/expected/10-jsx-component-calls.known-missing diff --git a/test/typescript/expected/10-jsx-component-calls.lib.edges b/graph/test/typescript/expected/10-jsx-component-calls.lib.edges similarity index 100% rename from test/typescript/expected/10-jsx-component-calls.lib.edges rename to graph/test/typescript/expected/10-jsx-component-calls.lib.edges diff --git a/test/typescript/expected/10-jsx-component-calls.lib.known-missing b/graph/test/typescript/expected/10-jsx-component-calls.lib.known-missing similarity index 100% rename from test/typescript/expected/10-jsx-component-calls.lib.known-missing rename to graph/test/typescript/expected/10-jsx-component-calls.lib.known-missing diff --git a/test/typescript/expected/10-jsx-component-calls.lib.oracle b/graph/test/typescript/expected/10-jsx-component-calls.lib.oracle similarity index 100% rename from test/typescript/expected/10-jsx-component-calls.lib.oracle rename to graph/test/typescript/expected/10-jsx-component-calls.lib.oracle diff --git a/test/typescript/expected/10-jsx-component-calls.oracle b/graph/test/typescript/expected/10-jsx-component-calls.oracle similarity index 100% rename from test/typescript/expected/10-jsx-component-calls.oracle rename to graph/test/typescript/expected/10-jsx-component-calls.oracle diff --git a/test/typescript/expected/11-async-and-chaining.edges b/graph/test/typescript/expected/11-async-and-chaining.edges similarity index 100% rename from test/typescript/expected/11-async-and-chaining.edges rename to graph/test/typescript/expected/11-async-and-chaining.edges diff --git a/test/typescript/expected/11-async-and-chaining.lib.edges b/graph/test/typescript/expected/11-async-and-chaining.lib.edges similarity index 100% rename from test/typescript/expected/11-async-and-chaining.lib.edges rename to graph/test/typescript/expected/11-async-and-chaining.lib.edges diff --git a/test/typescript/expected/11-async-and-chaining.lib.oracle b/graph/test/typescript/expected/11-async-and-chaining.lib.oracle similarity index 100% rename from test/typescript/expected/11-async-and-chaining.lib.oracle rename to graph/test/typescript/expected/11-async-and-chaining.lib.oracle diff --git a/test/typescript/expected/11-async-and-chaining.oracle b/graph/test/typescript/expected/11-async-and-chaining.oracle similarity index 100% rename from test/typescript/expected/11-async-and-chaining.oracle rename to graph/test/typescript/expected/11-async-and-chaining.oracle diff --git a/test/typescript/expected/12-getters-statics-and-enums.edges b/graph/test/typescript/expected/12-getters-statics-and-enums.edges similarity index 100% rename from test/typescript/expected/12-getters-statics-and-enums.edges rename to graph/test/typescript/expected/12-getters-statics-and-enums.edges diff --git a/test/typescript/expected/12-getters-statics-and-enums.lib.edges b/graph/test/typescript/expected/12-getters-statics-and-enums.lib.edges similarity index 100% rename from test/typescript/expected/12-getters-statics-and-enums.lib.edges rename to graph/test/typescript/expected/12-getters-statics-and-enums.lib.edges diff --git a/test/typescript/expected/12-getters-statics-and-enums.lib.oracle b/graph/test/typescript/expected/12-getters-statics-and-enums.lib.oracle similarity index 100% rename from test/typescript/expected/12-getters-statics-and-enums.lib.oracle rename to graph/test/typescript/expected/12-getters-statics-and-enums.lib.oracle diff --git a/test/typescript/expected/12-getters-statics-and-enums.oracle b/graph/test/typescript/expected/12-getters-statics-and-enums.oracle similarity index 100% rename from test/typescript/expected/12-getters-statics-and-enums.oracle rename to graph/test/typescript/expected/12-getters-statics-and-enums.oracle diff --git a/test/typescript/expected/13-receiver-forms.edges b/graph/test/typescript/expected/13-receiver-forms.edges similarity index 100% rename from test/typescript/expected/13-receiver-forms.edges rename to graph/test/typescript/expected/13-receiver-forms.edges diff --git a/test/typescript/expected/13-receiver-forms.lib.edges b/graph/test/typescript/expected/13-receiver-forms.lib.edges similarity index 100% rename from test/typescript/expected/13-receiver-forms.lib.edges rename to graph/test/typescript/expected/13-receiver-forms.lib.edges diff --git a/test/typescript/expected/13-receiver-forms.lib.oracle b/graph/test/typescript/expected/13-receiver-forms.lib.oracle similarity index 100% rename from test/typescript/expected/13-receiver-forms.lib.oracle rename to graph/test/typescript/expected/13-receiver-forms.lib.oracle diff --git a/test/typescript/expected/13-receiver-forms.oracle b/graph/test/typescript/expected/13-receiver-forms.oracle similarity index 100% rename from test/typescript/expected/13-receiver-forms.oracle rename to graph/test/typescript/expected/13-receiver-forms.oracle diff --git a/test/typescript/expected/14-cross-file-shadowing.edges b/graph/test/typescript/expected/14-cross-file-shadowing.edges similarity index 100% rename from test/typescript/expected/14-cross-file-shadowing.edges rename to graph/test/typescript/expected/14-cross-file-shadowing.edges diff --git a/test/typescript/expected/14-cross-file-shadowing.known-missing b/graph/test/typescript/expected/14-cross-file-shadowing.known-missing similarity index 100% rename from test/typescript/expected/14-cross-file-shadowing.known-missing rename to graph/test/typescript/expected/14-cross-file-shadowing.known-missing diff --git a/test/typescript/expected/14-cross-file-shadowing.lib.edges b/graph/test/typescript/expected/14-cross-file-shadowing.lib.edges similarity index 100% rename from test/typescript/expected/14-cross-file-shadowing.lib.edges rename to graph/test/typescript/expected/14-cross-file-shadowing.lib.edges diff --git a/test/typescript/expected/14-cross-file-shadowing.lib.known-missing b/graph/test/typescript/expected/14-cross-file-shadowing.lib.known-missing similarity index 100% rename from test/typescript/expected/14-cross-file-shadowing.lib.known-missing rename to graph/test/typescript/expected/14-cross-file-shadowing.lib.known-missing diff --git a/test/typescript/expected/14-cross-file-shadowing.lib.oracle b/graph/test/typescript/expected/14-cross-file-shadowing.lib.oracle similarity index 100% rename from test/typescript/expected/14-cross-file-shadowing.lib.oracle rename to graph/test/typescript/expected/14-cross-file-shadowing.lib.oracle diff --git a/test/typescript/expected/14-cross-file-shadowing.oracle b/graph/test/typescript/expected/14-cross-file-shadowing.oracle similarity index 100% rename from test/typescript/expected/14-cross-file-shadowing.oracle rename to graph/test/typescript/expected/14-cross-file-shadowing.oracle diff --git a/test/typescript/expected/15-type-only-and-erasure.edges b/graph/test/typescript/expected/15-type-only-and-erasure.edges similarity index 100% rename from test/typescript/expected/15-type-only-and-erasure.edges rename to graph/test/typescript/expected/15-type-only-and-erasure.edges diff --git a/test/typescript/expected/15-type-only-and-erasure.envelope b/graph/test/typescript/expected/15-type-only-and-erasure.envelope similarity index 100% rename from test/typescript/expected/15-type-only-and-erasure.envelope rename to graph/test/typescript/expected/15-type-only-and-erasure.envelope diff --git a/test/typescript/expected/15-type-only-and-erasure.lib.edges b/graph/test/typescript/expected/15-type-only-and-erasure.lib.edges similarity index 100% rename from test/typescript/expected/15-type-only-and-erasure.lib.edges rename to graph/test/typescript/expected/15-type-only-and-erasure.lib.edges diff --git a/test/typescript/expected/15-type-only-and-erasure.lib.envelope b/graph/test/typescript/expected/15-type-only-and-erasure.lib.envelope similarity index 100% rename from test/typescript/expected/15-type-only-and-erasure.lib.envelope rename to graph/test/typescript/expected/15-type-only-and-erasure.lib.envelope diff --git a/test/typescript/expected/15-type-only-and-erasure.lib.oracle b/graph/test/typescript/expected/15-type-only-and-erasure.lib.oracle similarity index 100% rename from test/typescript/expected/15-type-only-and-erasure.lib.oracle rename to graph/test/typescript/expected/15-type-only-and-erasure.lib.oracle diff --git a/test/typescript/expected/15-type-only-and-erasure.oracle b/graph/test/typescript/expected/15-type-only-and-erasure.oracle similarity index 100% rename from test/typescript/expected/15-type-only-and-erasure.oracle rename to graph/test/typescript/expected/15-type-only-and-erasure.oracle diff --git a/test/typescript/expected/16-recursive-and-mutual.edges b/graph/test/typescript/expected/16-recursive-and-mutual.edges similarity index 100% rename from test/typescript/expected/16-recursive-and-mutual.edges rename to graph/test/typescript/expected/16-recursive-and-mutual.edges diff --git a/test/typescript/expected/16-recursive-and-mutual.lib.edges b/graph/test/typescript/expected/16-recursive-and-mutual.lib.edges similarity index 100% rename from test/typescript/expected/16-recursive-and-mutual.lib.edges rename to graph/test/typescript/expected/16-recursive-and-mutual.lib.edges diff --git a/test/typescript/expected/16-recursive-and-mutual.lib.oracle b/graph/test/typescript/expected/16-recursive-and-mutual.lib.oracle similarity index 100% rename from test/typescript/expected/16-recursive-and-mutual.lib.oracle rename to graph/test/typescript/expected/16-recursive-and-mutual.lib.oracle diff --git a/test/typescript/expected/16-recursive-and-mutual.oracle b/graph/test/typescript/expected/16-recursive-and-mutual.oracle similarity index 100% rename from test/typescript/expected/16-recursive-and-mutual.oracle rename to graph/test/typescript/expected/16-recursive-and-mutual.oracle diff --git a/test/typescript/expected/17-constrained-generics.edges b/graph/test/typescript/expected/17-constrained-generics.edges similarity index 100% rename from test/typescript/expected/17-constrained-generics.edges rename to graph/test/typescript/expected/17-constrained-generics.edges diff --git a/test/typescript/expected/17-constrained-generics.lib.edges b/graph/test/typescript/expected/17-constrained-generics.lib.edges similarity index 100% rename from test/typescript/expected/17-constrained-generics.lib.edges rename to graph/test/typescript/expected/17-constrained-generics.lib.edges diff --git a/test/typescript/expected/17-constrained-generics.lib.oracle b/graph/test/typescript/expected/17-constrained-generics.lib.oracle similarity index 100% rename from test/typescript/expected/17-constrained-generics.lib.oracle rename to graph/test/typescript/expected/17-constrained-generics.lib.oracle diff --git a/test/typescript/expected/17-constrained-generics.oracle b/graph/test/typescript/expected/17-constrained-generics.oracle similarity index 100% rename from test/typescript/expected/17-constrained-generics.oracle rename to graph/test/typescript/expected/17-constrained-generics.oracle diff --git a/test/typescript/expected/18-heritage-across-boundary.edges b/graph/test/typescript/expected/18-heritage-across-boundary.edges similarity index 100% rename from test/typescript/expected/18-heritage-across-boundary.edges rename to graph/test/typescript/expected/18-heritage-across-boundary.edges diff --git a/test/typescript/expected/18-heritage-across-boundary.lib.edges b/graph/test/typescript/expected/18-heritage-across-boundary.lib.edges similarity index 100% rename from test/typescript/expected/18-heritage-across-boundary.lib.edges rename to graph/test/typescript/expected/18-heritage-across-boundary.lib.edges diff --git a/test/typescript/expected/18-heritage-across-boundary.lib.envelope b/graph/test/typescript/expected/18-heritage-across-boundary.lib.envelope similarity index 100% rename from test/typescript/expected/18-heritage-across-boundary.lib.envelope rename to graph/test/typescript/expected/18-heritage-across-boundary.lib.envelope diff --git a/test/typescript/expected/18-heritage-across-boundary.lib.oracle b/graph/test/typescript/expected/18-heritage-across-boundary.lib.oracle similarity index 100% rename from test/typescript/expected/18-heritage-across-boundary.lib.oracle rename to graph/test/typescript/expected/18-heritage-across-boundary.lib.oracle diff --git a/test/typescript/expected/18-heritage-across-boundary.oracle b/graph/test/typescript/expected/18-heritage-across-boundary.oracle similarity index 100% rename from test/typescript/expected/18-heritage-across-boundary.oracle rename to graph/test/typescript/expected/18-heritage-across-boundary.oracle diff --git a/test/typescript/expected/19-overload-gauntlet.edges b/graph/test/typescript/expected/19-overload-gauntlet.edges similarity index 100% rename from test/typescript/expected/19-overload-gauntlet.edges rename to graph/test/typescript/expected/19-overload-gauntlet.edges diff --git a/test/typescript/expected/19-overload-gauntlet.lib.edges b/graph/test/typescript/expected/19-overload-gauntlet.lib.edges similarity index 100% rename from test/typescript/expected/19-overload-gauntlet.lib.edges rename to graph/test/typescript/expected/19-overload-gauntlet.lib.edges diff --git a/test/typescript/expected/19-overload-gauntlet.lib.known-missing b/graph/test/typescript/expected/19-overload-gauntlet.lib.known-missing similarity index 100% rename from test/typescript/expected/19-overload-gauntlet.lib.known-missing rename to graph/test/typescript/expected/19-overload-gauntlet.lib.known-missing diff --git a/test/typescript/expected/19-overload-gauntlet.lib.oracle b/graph/test/typescript/expected/19-overload-gauntlet.lib.oracle similarity index 100% rename from test/typescript/expected/19-overload-gauntlet.lib.oracle rename to graph/test/typescript/expected/19-overload-gauntlet.lib.oracle diff --git a/test/typescript/expected/19-overload-gauntlet.oracle b/graph/test/typescript/expected/19-overload-gauntlet.oracle similarity index 100% rename from test/typescript/expected/19-overload-gauntlet.oracle rename to graph/test/typescript/expected/19-overload-gauntlet.oracle diff --git a/test/typescript/expected/20-lib-flow.edges b/graph/test/typescript/expected/20-lib-flow.edges similarity index 100% rename from test/typescript/expected/20-lib-flow.edges rename to graph/test/typescript/expected/20-lib-flow.edges diff --git a/test/typescript/expected/20-lib-flow.lib.edges b/graph/test/typescript/expected/20-lib-flow.lib.edges similarity index 100% rename from test/typescript/expected/20-lib-flow.lib.edges rename to graph/test/typescript/expected/20-lib-flow.lib.edges diff --git a/test/typescript/expected/20-lib-flow.lib.known-missing b/graph/test/typescript/expected/20-lib-flow.lib.known-missing similarity index 100% rename from test/typescript/expected/20-lib-flow.lib.known-missing rename to graph/test/typescript/expected/20-lib-flow.lib.known-missing diff --git a/test/typescript/expected/20-lib-flow.lib.oracle b/graph/test/typescript/expected/20-lib-flow.lib.oracle similarity index 100% rename from test/typescript/expected/20-lib-flow.lib.oracle rename to graph/test/typescript/expected/20-lib-flow.lib.oracle diff --git a/test/typescript/expected/20-lib-flow.oracle b/graph/test/typescript/expected/20-lib-flow.oracle similarity index 100% rename from test/typescript/expected/20-lib-flow.oracle rename to graph/test/typescript/expected/20-lib-flow.oracle diff --git a/test/typescript/expected/21-callable-function-members.edges b/graph/test/typescript/expected/21-callable-function-members.edges similarity index 100% rename from test/typescript/expected/21-callable-function-members.edges rename to graph/test/typescript/expected/21-callable-function-members.edges diff --git a/test/typescript/expected/21-callable-function-members.envelope b/graph/test/typescript/expected/21-callable-function-members.envelope similarity index 100% rename from test/typescript/expected/21-callable-function-members.envelope rename to graph/test/typescript/expected/21-callable-function-members.envelope diff --git a/test/typescript/expected/21-callable-function-members.known-missing b/graph/test/typescript/expected/21-callable-function-members.known-missing similarity index 100% rename from test/typescript/expected/21-callable-function-members.known-missing rename to graph/test/typescript/expected/21-callable-function-members.known-missing diff --git a/test/typescript/expected/21-callable-function-members.oracle b/graph/test/typescript/expected/21-callable-function-members.oracle similarity index 100% rename from test/typescript/expected/21-callable-function-members.oracle rename to graph/test/typescript/expected/21-callable-function-members.oracle diff --git a/test/typescript/expected/22-loose-bind-call-apply.edges b/graph/test/typescript/expected/22-loose-bind-call-apply.edges similarity index 100% rename from test/typescript/expected/22-loose-bind-call-apply.edges rename to graph/test/typescript/expected/22-loose-bind-call-apply.edges diff --git a/test/typescript/expected/22-loose-bind-call-apply.envelope b/graph/test/typescript/expected/22-loose-bind-call-apply.envelope similarity index 100% rename from test/typescript/expected/22-loose-bind-call-apply.envelope rename to graph/test/typescript/expected/22-loose-bind-call-apply.envelope diff --git a/test/typescript/expected/22-loose-bind-call-apply.known-missing b/graph/test/typescript/expected/22-loose-bind-call-apply.known-missing similarity index 100% rename from test/typescript/expected/22-loose-bind-call-apply.known-missing rename to graph/test/typescript/expected/22-loose-bind-call-apply.known-missing diff --git a/test/typescript/expected/22-loose-bind-call-apply.oracle b/graph/test/typescript/expected/22-loose-bind-call-apply.oracle similarity index 100% rename from test/typescript/expected/22-loose-bind-call-apply.oracle rename to graph/test/typescript/expected/22-loose-bind-call-apply.oracle diff --git a/test/typescript/expected/24-hedged-overload-return-union.edges b/graph/test/typescript/expected/24-hedged-overload-return-union.edges similarity index 100% rename from test/typescript/expected/24-hedged-overload-return-union.edges rename to graph/test/typescript/expected/24-hedged-overload-return-union.edges diff --git a/test/typescript/expected/24-hedged-overload-return-union.lib.edges b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges similarity index 100% rename from test/typescript/expected/24-hedged-overload-return-union.lib.edges rename to graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges diff --git a/test/typescript/expected/24-hedged-overload-return-union.lib.envelope b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.envelope similarity index 100% rename from test/typescript/expected/24-hedged-overload-return-union.lib.envelope rename to graph/test/typescript/expected/24-hedged-overload-return-union.lib.envelope diff --git a/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing similarity index 100% rename from test/typescript/expected/24-hedged-overload-return-union.lib.known-missing rename to graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing diff --git a/test/typescript/expected/24-hedged-overload-return-union.lib.oracle b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle similarity index 100% rename from test/typescript/expected/24-hedged-overload-return-union.lib.oracle rename to graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle diff --git a/test/typescript/expected/24-hedged-overload-return-union.oracle b/graph/test/typescript/expected/24-hedged-overload-return-union.oracle similarity index 100% rename from test/typescript/expected/24-hedged-overload-return-union.oracle rename to graph/test/typescript/expected/24-hedged-overload-return-union.oracle diff --git a/test/typescript/expected/25-qualified-type-names.edges b/graph/test/typescript/expected/25-qualified-type-names.edges similarity index 100% rename from test/typescript/expected/25-qualified-type-names.edges rename to graph/test/typescript/expected/25-qualified-type-names.edges diff --git a/test/typescript/expected/25-qualified-type-names.envelope b/graph/test/typescript/expected/25-qualified-type-names.envelope similarity index 100% rename from test/typescript/expected/25-qualified-type-names.envelope rename to graph/test/typescript/expected/25-qualified-type-names.envelope diff --git a/test/typescript/expected/25-qualified-type-names.lib.edges b/graph/test/typescript/expected/25-qualified-type-names.lib.edges similarity index 100% rename from test/typescript/expected/25-qualified-type-names.lib.edges rename to graph/test/typescript/expected/25-qualified-type-names.lib.edges diff --git a/test/typescript/expected/25-qualified-type-names.lib.envelope b/graph/test/typescript/expected/25-qualified-type-names.lib.envelope similarity index 100% rename from test/typescript/expected/25-qualified-type-names.lib.envelope rename to graph/test/typescript/expected/25-qualified-type-names.lib.envelope diff --git a/test/typescript/expected/25-qualified-type-names.lib.oracle b/graph/test/typescript/expected/25-qualified-type-names.lib.oracle similarity index 100% rename from test/typescript/expected/25-qualified-type-names.lib.oracle rename to graph/test/typescript/expected/25-qualified-type-names.lib.oracle diff --git a/test/typescript/expected/25-qualified-type-names.oracle b/graph/test/typescript/expected/25-qualified-type-names.oracle similarity index 100% rename from test/typescript/expected/25-qualified-type-names.oracle rename to graph/test/typescript/expected/25-qualified-type-names.oracle diff --git a/test/typescript/expected/26-namespace-object-fallback.edges b/graph/test/typescript/expected/26-namespace-object-fallback.edges similarity index 100% rename from test/typescript/expected/26-namespace-object-fallback.edges rename to graph/test/typescript/expected/26-namespace-object-fallback.edges diff --git a/test/typescript/expected/26-namespace-object-fallback.oracle b/graph/test/typescript/expected/26-namespace-object-fallback.oracle similarity index 100% rename from test/typescript/expected/26-namespace-object-fallback.oracle rename to graph/test/typescript/expected/26-namespace-object-fallback.oracle diff --git a/test/typescript/expected/27-readonly-array-receiver.edges b/graph/test/typescript/expected/27-readonly-array-receiver.edges similarity index 100% rename from test/typescript/expected/27-readonly-array-receiver.edges rename to graph/test/typescript/expected/27-readonly-array-receiver.edges diff --git a/test/typescript/expected/27-readonly-array-receiver.oracle b/graph/test/typescript/expected/27-readonly-array-receiver.oracle similarity index 100% rename from test/typescript/expected/27-readonly-array-receiver.oracle rename to graph/test/typescript/expected/27-readonly-array-receiver.oracle diff --git a/test/typescript/expected/28-property-chain-container.edges b/graph/test/typescript/expected/28-property-chain-container.edges similarity index 100% rename from test/typescript/expected/28-property-chain-container.edges rename to graph/test/typescript/expected/28-property-chain-container.edges diff --git a/test/typescript/expected/28-property-chain-container.oracle b/graph/test/typescript/expected/28-property-chain-container.oracle similarity index 100% rename from test/typescript/expected/28-property-chain-container.oracle rename to graph/test/typescript/expected/28-property-chain-container.oracle diff --git a/test/typescript/expected/29-type-literal-callable-member.edges b/graph/test/typescript/expected/29-type-literal-callable-member.edges similarity index 100% rename from test/typescript/expected/29-type-literal-callable-member.edges rename to graph/test/typescript/expected/29-type-literal-callable-member.edges diff --git a/test/typescript/expected/29-type-literal-callable-member.oracle b/graph/test/typescript/expected/29-type-literal-callable-member.oracle similarity index 100% rename from test/typescript/expected/29-type-literal-callable-member.oracle rename to graph/test/typescript/expected/29-type-literal-callable-member.oracle diff --git a/test/typescript/expected/30-super-into-construct-signature.edges b/graph/test/typescript/expected/30-super-into-construct-signature.edges similarity index 100% rename from test/typescript/expected/30-super-into-construct-signature.edges rename to graph/test/typescript/expected/30-super-into-construct-signature.edges diff --git a/test/typescript/expected/30-super-into-construct-signature.lib.edges b/graph/test/typescript/expected/30-super-into-construct-signature.lib.edges similarity index 100% rename from test/typescript/expected/30-super-into-construct-signature.lib.edges rename to graph/test/typescript/expected/30-super-into-construct-signature.lib.edges diff --git a/test/typescript/expected/30-super-into-construct-signature.lib.envelope b/graph/test/typescript/expected/30-super-into-construct-signature.lib.envelope similarity index 100% rename from test/typescript/expected/30-super-into-construct-signature.lib.envelope rename to graph/test/typescript/expected/30-super-into-construct-signature.lib.envelope diff --git a/test/typescript/expected/30-super-into-construct-signature.lib.oracle b/graph/test/typescript/expected/30-super-into-construct-signature.lib.oracle similarity index 100% rename from test/typescript/expected/30-super-into-construct-signature.lib.oracle rename to graph/test/typescript/expected/30-super-into-construct-signature.lib.oracle diff --git a/test/typescript/expected/30-super-into-construct-signature.oracle b/graph/test/typescript/expected/30-super-into-construct-signature.oracle similarity index 100% rename from test/typescript/expected/30-super-into-construct-signature.oracle rename to graph/test/typescript/expected/30-super-into-construct-signature.oracle diff --git a/test/typescript/expected/31-asserts-predicate.edges b/graph/test/typescript/expected/31-asserts-predicate.edges similarity index 100% rename from test/typescript/expected/31-asserts-predicate.edges rename to graph/test/typescript/expected/31-asserts-predicate.edges diff --git a/test/typescript/expected/31-asserts-predicate.oracle b/graph/test/typescript/expected/31-asserts-predicate.oracle similarity index 100% rename from test/typescript/expected/31-asserts-predicate.oracle rename to graph/test/typescript/expected/31-asserts-predicate.oracle diff --git a/test/typescript/expected/32-decorator-application.edges b/graph/test/typescript/expected/32-decorator-application.edges similarity index 100% rename from test/typescript/expected/32-decorator-application.edges rename to graph/test/typescript/expected/32-decorator-application.edges diff --git a/test/typescript/expected/32-decorator-application.known-missing b/graph/test/typescript/expected/32-decorator-application.known-missing similarity index 100% rename from test/typescript/expected/32-decorator-application.known-missing rename to graph/test/typescript/expected/32-decorator-application.known-missing diff --git a/test/typescript/expected/32-decorator-application.oracle b/graph/test/typescript/expected/32-decorator-application.oracle similarity index 100% rename from test/typescript/expected/32-decorator-application.oracle rename to graph/test/typescript/expected/32-decorator-application.oracle diff --git a/test/typescript/expected/33-container-element-receiver.edges b/graph/test/typescript/expected/33-container-element-receiver.edges similarity index 100% rename from test/typescript/expected/33-container-element-receiver.edges rename to graph/test/typescript/expected/33-container-element-receiver.edges diff --git a/test/typescript/expected/33-container-element-receiver.oracle b/graph/test/typescript/expected/33-container-element-receiver.oracle similarity index 100% rename from test/typescript/expected/33-container-element-receiver.oracle rename to graph/test/typescript/expected/33-container-element-receiver.oracle diff --git a/test/typescript/expected/34-callback-of-callback.edges b/graph/test/typescript/expected/34-callback-of-callback.edges similarity index 100% rename from test/typescript/expected/34-callback-of-callback.edges rename to graph/test/typescript/expected/34-callback-of-callback.edges diff --git a/test/typescript/expected/34-callback-of-callback.known-missing b/graph/test/typescript/expected/34-callback-of-callback.known-missing similarity index 100% rename from test/typescript/expected/34-callback-of-callback.known-missing rename to graph/test/typescript/expected/34-callback-of-callback.known-missing diff --git a/test/typescript/expected/34-callback-of-callback.oracle b/graph/test/typescript/expected/34-callback-of-callback.oracle similarity index 100% rename from test/typescript/expected/34-callback-of-callback.oracle rename to graph/test/typescript/expected/34-callback-of-callback.oracle diff --git a/test/typescript/expected/35-namespace-same-module.edges b/graph/test/typescript/expected/35-namespace-same-module.edges similarity index 100% rename from test/typescript/expected/35-namespace-same-module.edges rename to graph/test/typescript/expected/35-namespace-same-module.edges diff --git a/test/typescript/expected/35-namespace-same-module.oracle b/graph/test/typescript/expected/35-namespace-same-module.oracle similarity index 100% rename from test/typescript/expected/35-namespace-same-module.oracle rename to graph/test/typescript/expected/35-namespace-same-module.oracle diff --git a/test/typescript/expected/36-function-value-containers.edges b/graph/test/typescript/expected/36-function-value-containers.edges similarity index 100% rename from test/typescript/expected/36-function-value-containers.edges rename to graph/test/typescript/expected/36-function-value-containers.edges diff --git a/test/typescript/expected/36-function-value-containers.oracle b/graph/test/typescript/expected/36-function-value-containers.oracle similarity index 100% rename from test/typescript/expected/36-function-value-containers.oracle rename to graph/test/typescript/expected/36-function-value-containers.oracle diff --git a/test/typescript/expected/37-destructuring-bindings.edges b/graph/test/typescript/expected/37-destructuring-bindings.edges similarity index 100% rename from test/typescript/expected/37-destructuring-bindings.edges rename to graph/test/typescript/expected/37-destructuring-bindings.edges diff --git a/test/typescript/expected/37-destructuring-bindings.oracle b/graph/test/typescript/expected/37-destructuring-bindings.oracle similarity index 100% rename from test/typescript/expected/37-destructuring-bindings.oracle rename to graph/test/typescript/expected/37-destructuring-bindings.oracle diff --git a/test/typescript/expected/38-dynamic-import.edges b/graph/test/typescript/expected/38-dynamic-import.edges similarity index 100% rename from test/typescript/expected/38-dynamic-import.edges rename to graph/test/typescript/expected/38-dynamic-import.edges diff --git a/test/typescript/expected/38-dynamic-import.oracle b/graph/test/typescript/expected/38-dynamic-import.oracle similarity index 100% rename from test/typescript/expected/38-dynamic-import.oracle rename to graph/test/typescript/expected/38-dynamic-import.oracle diff --git a/test/typescript/expected/39-tagged-template-overload.edges b/graph/test/typescript/expected/39-tagged-template-overload.edges similarity index 100% rename from test/typescript/expected/39-tagged-template-overload.edges rename to graph/test/typescript/expected/39-tagged-template-overload.edges diff --git a/test/typescript/expected/39-tagged-template-overload.oracle b/graph/test/typescript/expected/39-tagged-template-overload.oracle similarity index 100% rename from test/typescript/expected/39-tagged-template-overload.oracle rename to graph/test/typescript/expected/39-tagged-template-overload.oracle diff --git a/test/typescript/expected/40-explicit-type-argument.edges b/graph/test/typescript/expected/40-explicit-type-argument.edges similarity index 100% rename from test/typescript/expected/40-explicit-type-argument.edges rename to graph/test/typescript/expected/40-explicit-type-argument.edges diff --git a/test/typescript/expected/40-explicit-type-argument.oracle b/graph/test/typescript/expected/40-explicit-type-argument.oracle similarity index 100% rename from test/typescript/expected/40-explicit-type-argument.oracle rename to graph/test/typescript/expected/40-explicit-type-argument.oracle diff --git a/test/typescript/expected/41-library-generic-return.edges b/graph/test/typescript/expected/41-library-generic-return.edges similarity index 100% rename from test/typescript/expected/41-library-generic-return.edges rename to graph/test/typescript/expected/41-library-generic-return.edges diff --git a/test/typescript/expected/41-library-generic-return.oracle b/graph/test/typescript/expected/41-library-generic-return.oracle similarity index 100% rename from test/typescript/expected/41-library-generic-return.oracle rename to graph/test/typescript/expected/41-library-generic-return.oracle diff --git a/test/typescript/expected/42-barrel-bare-reexport.edges b/graph/test/typescript/expected/42-barrel-bare-reexport.edges similarity index 100% rename from test/typescript/expected/42-barrel-bare-reexport.edges rename to graph/test/typescript/expected/42-barrel-bare-reexport.edges diff --git a/test/typescript/expected/42-barrel-bare-reexport.oracle b/graph/test/typescript/expected/42-barrel-bare-reexport.oracle similarity index 100% rename from test/typescript/expected/42-barrel-bare-reexport.oracle rename to graph/test/typescript/expected/42-barrel-bare-reexport.oracle diff --git a/test/typescript/expected/43-string-literal-overload.edges b/graph/test/typescript/expected/43-string-literal-overload.edges similarity index 100% rename from test/typescript/expected/43-string-literal-overload.edges rename to graph/test/typescript/expected/43-string-literal-overload.edges diff --git a/test/typescript/expected/43-string-literal-overload.oracle b/graph/test/typescript/expected/43-string-literal-overload.oracle similarity index 100% rename from test/typescript/expected/43-string-literal-overload.oracle rename to graph/test/typescript/expected/43-string-literal-overload.oracle diff --git a/test/typescript/expected/44-union-and-unknown-argument.edges b/graph/test/typescript/expected/44-union-and-unknown-argument.edges similarity index 100% rename from test/typescript/expected/44-union-and-unknown-argument.edges rename to graph/test/typescript/expected/44-union-and-unknown-argument.edges diff --git a/test/typescript/expected/44-union-and-unknown-argument.oracle b/graph/test/typescript/expected/44-union-and-unknown-argument.oracle similarity index 100% rename from test/typescript/expected/44-union-and-unknown-argument.oracle rename to graph/test/typescript/expected/44-union-and-unknown-argument.oracle diff --git a/test/typescript/expected/45-explicit-type-argument-excludes-nongeneric.edges b/graph/test/typescript/expected/45-explicit-type-argument-excludes-nongeneric.edges similarity index 100% rename from test/typescript/expected/45-explicit-type-argument-excludes-nongeneric.edges rename to graph/test/typescript/expected/45-explicit-type-argument-excludes-nongeneric.edges diff --git a/test/typescript/expected/45-explicit-type-argument-excludes-nongeneric.oracle b/graph/test/typescript/expected/45-explicit-type-argument-excludes-nongeneric.oracle similarity index 100% rename from test/typescript/expected/45-explicit-type-argument-excludes-nongeneric.oracle rename to graph/test/typescript/expected/45-explicit-type-argument-excludes-nongeneric.oracle diff --git a/test/typescript/expected/46-new-through-class-valued-variable.edges b/graph/test/typescript/expected/46-new-through-class-valued-variable.edges similarity index 100% rename from test/typescript/expected/46-new-through-class-valued-variable.edges rename to graph/test/typescript/expected/46-new-through-class-valued-variable.edges diff --git a/test/typescript/expected/46-new-through-class-valued-variable.known-missing b/graph/test/typescript/expected/46-new-through-class-valued-variable.known-missing similarity index 100% rename from test/typescript/expected/46-new-through-class-valued-variable.known-missing rename to graph/test/typescript/expected/46-new-through-class-valued-variable.known-missing diff --git a/test/typescript/expected/46-new-through-class-valued-variable.oracle b/graph/test/typescript/expected/46-new-through-class-valued-variable.oracle similarity index 100% rename from test/typescript/expected/46-new-through-class-valued-variable.oracle rename to graph/test/typescript/expected/46-new-through-class-valued-variable.oracle diff --git a/test/typescript/expected/47-for-of-tuple-destructuring.edges b/graph/test/typescript/expected/47-for-of-tuple-destructuring.edges similarity index 100% rename from test/typescript/expected/47-for-of-tuple-destructuring.edges rename to graph/test/typescript/expected/47-for-of-tuple-destructuring.edges diff --git a/test/typescript/expected/47-for-of-tuple-destructuring.oracle b/graph/test/typescript/expected/47-for-of-tuple-destructuring.oracle similarity index 100% rename from test/typescript/expected/47-for-of-tuple-destructuring.oracle rename to graph/test/typescript/expected/47-for-of-tuple-destructuring.oracle diff --git a/test/typescript/expected/48-default-export-of-qualified-expression.edges b/graph/test/typescript/expected/48-default-export-of-qualified-expression.edges similarity index 100% rename from test/typescript/expected/48-default-export-of-qualified-expression.edges rename to graph/test/typescript/expected/48-default-export-of-qualified-expression.edges diff --git a/test/typescript/expected/48-default-export-of-qualified-expression.oracle b/graph/test/typescript/expected/48-default-export-of-qualified-expression.oracle similarity index 100% rename from test/typescript/expected/48-default-export-of-qualified-expression.oracle rename to graph/test/typescript/expected/48-default-export-of-qualified-expression.oracle diff --git a/test/typescript/expected/49-indexed-access-return.edges b/graph/test/typescript/expected/49-indexed-access-return.edges similarity index 100% rename from test/typescript/expected/49-indexed-access-return.edges rename to graph/test/typescript/expected/49-indexed-access-return.edges diff --git a/test/typescript/expected/49-indexed-access-return.known-missing b/graph/test/typescript/expected/49-indexed-access-return.known-missing similarity index 100% rename from test/typescript/expected/49-indexed-access-return.known-missing rename to graph/test/typescript/expected/49-indexed-access-return.known-missing diff --git a/test/typescript/expected/49-indexed-access-return.oracle b/graph/test/typescript/expected/49-indexed-access-return.oracle similarity index 100% rename from test/typescript/expected/49-indexed-access-return.oracle rename to graph/test/typescript/expected/49-indexed-access-return.oracle diff --git a/test/typescript/expected/50-asserts-condition-narrowing.edges b/graph/test/typescript/expected/50-asserts-condition-narrowing.edges similarity index 100% rename from test/typescript/expected/50-asserts-condition-narrowing.edges rename to graph/test/typescript/expected/50-asserts-condition-narrowing.edges diff --git a/test/typescript/expected/50-asserts-condition-narrowing.oracle b/graph/test/typescript/expected/50-asserts-condition-narrowing.oracle similarity index 100% rename from test/typescript/expected/50-asserts-condition-narrowing.oracle rename to graph/test/typescript/expected/50-asserts-condition-narrowing.oracle diff --git a/test/typescript/expected/51-polymorphic-this-across-boundary.edges b/graph/test/typescript/expected/51-polymorphic-this-across-boundary.edges similarity index 100% rename from test/typescript/expected/51-polymorphic-this-across-boundary.edges rename to graph/test/typescript/expected/51-polymorphic-this-across-boundary.edges diff --git a/test/typescript/expected/51-polymorphic-this-across-boundary.lib.edges b/graph/test/typescript/expected/51-polymorphic-this-across-boundary.lib.edges similarity index 100% rename from test/typescript/expected/51-polymorphic-this-across-boundary.lib.edges rename to graph/test/typescript/expected/51-polymorphic-this-across-boundary.lib.edges diff --git a/test/typescript/expected/51-polymorphic-this-across-boundary.lib.oracle b/graph/test/typescript/expected/51-polymorphic-this-across-boundary.lib.oracle similarity index 100% rename from test/typescript/expected/51-polymorphic-this-across-boundary.lib.oracle rename to graph/test/typescript/expected/51-polymorphic-this-across-boundary.lib.oracle diff --git a/test/typescript/expected/51-polymorphic-this-across-boundary.oracle b/graph/test/typescript/expected/51-polymorphic-this-across-boundary.oracle similarity index 100% rename from test/typescript/expected/51-polymorphic-this-across-boundary.oracle rename to graph/test/typescript/expected/51-polymorphic-this-across-boundary.oracle diff --git a/test/typescript/expected/52-cross-module-library-heritage.edges b/graph/test/typescript/expected/52-cross-module-library-heritage.edges similarity index 100% rename from test/typescript/expected/52-cross-module-library-heritage.edges rename to graph/test/typescript/expected/52-cross-module-library-heritage.edges diff --git a/test/typescript/expected/52-cross-module-library-heritage.lib.edges b/graph/test/typescript/expected/52-cross-module-library-heritage.lib.edges similarity index 100% rename from test/typescript/expected/52-cross-module-library-heritage.lib.edges rename to graph/test/typescript/expected/52-cross-module-library-heritage.lib.edges diff --git a/test/typescript/expected/52-cross-module-library-heritage.lib.envelope b/graph/test/typescript/expected/52-cross-module-library-heritage.lib.envelope similarity index 100% rename from test/typescript/expected/52-cross-module-library-heritage.lib.envelope rename to graph/test/typescript/expected/52-cross-module-library-heritage.lib.envelope diff --git a/test/typescript/expected/52-cross-module-library-heritage.lib.oracle b/graph/test/typescript/expected/52-cross-module-library-heritage.lib.oracle similarity index 100% rename from test/typescript/expected/52-cross-module-library-heritage.lib.oracle rename to graph/test/typescript/expected/52-cross-module-library-heritage.lib.oracle diff --git a/test/typescript/expected/52-cross-module-library-heritage.oracle b/graph/test/typescript/expected/52-cross-module-library-heritage.oracle similarity index 100% rename from test/typescript/expected/52-cross-module-library-heritage.oracle rename to graph/test/typescript/expected/52-cross-module-library-heritage.oracle diff --git a/test/typescript/expected/53-export-assignment-construction.edges b/graph/test/typescript/expected/53-export-assignment-construction.edges similarity index 100% rename from test/typescript/expected/53-export-assignment-construction.edges rename to graph/test/typescript/expected/53-export-assignment-construction.edges diff --git a/test/typescript/expected/53-export-assignment-construction.oracle b/graph/test/typescript/expected/53-export-assignment-construction.oracle similarity index 100% rename from test/typescript/expected/53-export-assignment-construction.oracle rename to graph/test/typescript/expected/53-export-assignment-construction.oracle diff --git a/test/typescript/expected/54-subclass-of-value-declared-base.edges b/graph/test/typescript/expected/54-subclass-of-value-declared-base.edges similarity index 100% rename from test/typescript/expected/54-subclass-of-value-declared-base.edges rename to graph/test/typescript/expected/54-subclass-of-value-declared-base.edges diff --git a/test/typescript/expected/54-subclass-of-value-declared-base.lib.edges b/graph/test/typescript/expected/54-subclass-of-value-declared-base.lib.edges similarity index 100% rename from test/typescript/expected/54-subclass-of-value-declared-base.lib.edges rename to graph/test/typescript/expected/54-subclass-of-value-declared-base.lib.edges diff --git a/test/typescript/expected/54-subclass-of-value-declared-base.lib.oracle b/graph/test/typescript/expected/54-subclass-of-value-declared-base.lib.oracle similarity index 100% rename from test/typescript/expected/54-subclass-of-value-declared-base.lib.oracle rename to graph/test/typescript/expected/54-subclass-of-value-declared-base.lib.oracle diff --git a/test/typescript/expected/54-subclass-of-value-declared-base.oracle b/graph/test/typescript/expected/54-subclass-of-value-declared-base.oracle similarity index 100% rename from test/typescript/expected/54-subclass-of-value-declared-base.oracle rename to graph/test/typescript/expected/54-subclass-of-value-declared-base.oracle diff --git a/test/typescript/fixtures/dispatch/README.md b/graph/test/typescript/fixtures/dispatch/README.md similarity index 100% rename from test/typescript/fixtures/dispatch/README.md rename to graph/test/typescript/fixtures/dispatch/README.md diff --git a/test/typescript/fixtures/dispatch/package.json b/graph/test/typescript/fixtures/dispatch/package.json similarity index 100% rename from test/typescript/fixtures/dispatch/package.json rename to graph/test/typescript/fixtures/dispatch/package.json diff --git a/test/typescript/fixtures/dispatch/run.sh b/graph/test/typescript/fixtures/dispatch/run.sh similarity index 71% rename from test/typescript/fixtures/dispatch/run.sh rename to graph/test/typescript/fixtures/dispatch/run.sh index f35ccfe93..c48de351c 100755 --- a/test/typescript/fixtures/dispatch/run.sh +++ b/graph/test/typescript/fixtures/dispatch/run.sh @@ -4,7 +4,7 @@ # stays clean and so node_modules can be a symlink to whatever TypeScript is at hand. set -e HERE="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "$HERE/../../../.." && pwd)" +REPO="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting WORK="${1:-/tmp/ts-dispatch-fixture}" # Discovered, not hardcoded: this default used to be an absolute path inside one # developer's home directory, so the symlink below was silently skipped anywhere else @@ -16,4 +16,4 @@ cp -R "$HERE"/. "$WORK/project" rm -f "$WORK/project/run.sh" [ -d "$NM" ] && ln -sfn "$NM" "$WORK/project/node_modules" -bash "$REPO/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" +bash "$REPO/graph/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" diff --git a/test/typescript/fixtures/dispatch/src/consumer.ts b/graph/test/typescript/fixtures/dispatch/src/consumer.ts similarity index 100% rename from test/typescript/fixtures/dispatch/src/consumer.ts rename to graph/test/typescript/fixtures/dispatch/src/consumer.ts diff --git a/test/typescript/fixtures/dispatch/src/handler.ts b/graph/test/typescript/fixtures/dispatch/src/handler.ts similarity index 100% rename from test/typescript/fixtures/dispatch/src/handler.ts rename to graph/test/typescript/fixtures/dispatch/src/handler.ts diff --git a/test/typescript/fixtures/dispatch/src/inherit.ts b/graph/test/typescript/fixtures/dispatch/src/inherit.ts similarity index 100% rename from test/typescript/fixtures/dispatch/src/inherit.ts rename to graph/test/typescript/fixtures/dispatch/src/inherit.ts diff --git a/test/typescript/fixtures/dispatch/src/nominal.ts b/graph/test/typescript/fixtures/dispatch/src/nominal.ts similarity index 100% rename from test/typescript/fixtures/dispatch/src/nominal.ts rename to graph/test/typescript/fixtures/dispatch/src/nominal.ts diff --git a/test/typescript/fixtures/dispatch/src/structural.ts b/graph/test/typescript/fixtures/dispatch/src/structural.ts similarity index 100% rename from test/typescript/fixtures/dispatch/src/structural.ts rename to graph/test/typescript/fixtures/dispatch/src/structural.ts diff --git a/test/typescript/fixtures/dispatch/tsconfig.json b/graph/test/typescript/fixtures/dispatch/tsconfig.json similarity index 100% rename from test/typescript/fixtures/dispatch/tsconfig.json rename to graph/test/typescript/fixtures/dispatch/tsconfig.json diff --git a/test/typescript/fixtures/linking/README.md b/graph/test/typescript/fixtures/linking/README.md similarity index 100% rename from test/typescript/fixtures/linking/README.md rename to graph/test/typescript/fixtures/linking/README.md diff --git a/test/typescript/fixtures/linking/package.json b/graph/test/typescript/fixtures/linking/package.json similarity index 100% rename from test/typescript/fixtures/linking/package.json rename to graph/test/typescript/fixtures/linking/package.json diff --git a/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/deep.d.ts b/graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/deep.d.ts similarity index 100% rename from test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/deep.d.ts rename to graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/deep.d.ts diff --git a/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/deep.js b/graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/deep.js similarity index 100% rename from test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/deep.js rename to graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/deep.js diff --git a/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/package.json b/graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/package.json similarity index 100% rename from test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/package.json rename to graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/deep/package.json diff --git a/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/package.json b/graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/package.json similarity index 100% rename from test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/package.json rename to graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/package.json diff --git a/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/shallow.d.ts b/graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/shallow.d.ts similarity index 100% rename from test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/shallow.d.ts rename to graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/shallow.d.ts diff --git a/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/shallow.js b/graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/shallow.js similarity index 100% rename from test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/shallow.js rename to graph/test/typescript/fixtures/linking/pnpm-vendor/@tt/shallow/shallow.js diff --git a/test/typescript/fixtures/linking/run.sh b/graph/test/typescript/fixtures/linking/run.sh similarity index 96% rename from test/typescript/fixtures/linking/run.sh rename to graph/test/typescript/fixtures/linking/run.sh index 71ec861db..1ff66e291 100755 --- a/test/typescript/fixtures/linking/run.sh +++ b/graph/test/typescript/fixtures/linking/run.sh @@ -14,7 +14,7 @@ # a duplicate declaration is scored as a wrong target rather than being harmless. set -e HERE="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "$HERE/../../../.." && pwd)" +REPO="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting WORK="${1:-/tmp/ts-linking-fixture}" # Discovered, not hardcoded: this default used to be an absolute path inside one # developer's home directory, so the symlink below was silently skipped anywhere else @@ -49,7 +49,7 @@ mkdir -p "$NM/@tt" ln -sfn "$PS/@tt+shallow@1.0.0/node_modules/@tt/shallow" "$NM/@tt/shallow" rm -rf "$WORK/project/pnpm-vendor" -bash "$REPO/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" "${AXIOM_PARSER:-}" +bash "$REPO/graph/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" "${AXIOM_PARSER:-}" # ── the gate ───────────────────────────────────────────────────────────────── # Until this block existed the fixture only PRINTED: every mechanism below could @@ -64,7 +64,7 @@ SITES="$WORK/eval/sites.tsv" # ── a dependency must be staged once, even when it ships its own source ────── # `@tt/twinsrc` ships `dist/index.d.ts` (what its manifest points `types` at) AND -# `src/index.ts` (the same class, in source form) — the layout rxjs, immer and superjson +# `graph/index.ts` (the same class, in source form) — the layout rxjs, immer and superjson # all publish. The parser skips `dist` by name but not `src`, so staging the package root # and its `dist` both, as the harness does by default, declares `Twin` twice. # diff --git a/test/typescript/fixtures/linking/src/ambient-virtual.d.ts b/graph/test/typescript/fixtures/linking/src/ambient-virtual.d.ts similarity index 100% rename from test/typescript/fixtures/linking/src/ambient-virtual.d.ts rename to graph/test/typescript/fixtures/linking/src/ambient-virtual.d.ts diff --git a/test/typescript/fixtures/linking/src/link-barrel.ts b/graph/test/typescript/fixtures/linking/src/link-barrel.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-barrel.ts rename to graph/test/typescript/fixtures/linking/src/link-barrel.ts diff --git a/test/typescript/fixtures/linking/src/link-basic.ts b/graph/test/typescript/fixtures/linking/src/link-basic.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-basic.ts rename to graph/test/typescript/fixtures/linking/src/link-basic.ts diff --git a/test/typescript/fixtures/linking/src/link-chains.ts b/graph/test/typescript/fixtures/linking/src/link-chains.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-chains.ts rename to graph/test/typescript/fixtures/linking/src/link-chains.ts diff --git a/test/typescript/fixtures/linking/src/link-conditional.ts b/graph/test/typescript/fixtures/linking/src/link-conditional.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-conditional.ts rename to graph/test/typescript/fixtures/linking/src/link-conditional.ts diff --git a/test/typescript/fixtures/linking/src/link-crosspkg-reexport.ts b/graph/test/typescript/fixtures/linking/src/link-crosspkg-reexport.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-crosspkg-reexport.ts rename to graph/test/typescript/fixtures/linking/src/link-crosspkg-reexport.ts diff --git a/test/typescript/fixtures/linking/src/link-default.ts b/graph/test/typescript/fixtures/linking/src/link-default.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-default.ts rename to graph/test/typescript/fixtures/linking/src/link-default.ts diff --git a/test/typescript/fixtures/linking/src/link-generic.ts b/graph/test/typescript/fixtures/linking/src/link-generic.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-generic.ts rename to graph/test/typescript/fixtures/linking/src/link-generic.ts diff --git a/test/typescript/fixtures/linking/src/link-legacy.ts b/graph/test/typescript/fixtures/linking/src/link-legacy.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-legacy.ts rename to graph/test/typescript/fixtures/linking/src/link-legacy.ts diff --git a/test/typescript/fixtures/linking/src/link-merged.ts b/graph/test/typescript/fixtures/linking/src/link-merged.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-merged.ts rename to graph/test/typescript/fixtures/linking/src/link-merged.ts diff --git a/test/typescript/fixtures/linking/src/link-overload.ts b/graph/test/typescript/fixtures/linking/src/link-overload.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-overload.ts rename to graph/test/typescript/fixtures/linking/src/link-overload.ts diff --git a/test/typescript/fixtures/linking/src/link-pnpm-store.ts b/graph/test/typescript/fixtures/linking/src/link-pnpm-store.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-pnpm-store.ts rename to graph/test/typescript/fixtures/linking/src/link-pnpm-store.ts diff --git a/test/typescript/fixtures/linking/src/link-subpath.ts b/graph/test/typescript/fixtures/linking/src/link-subpath.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-subpath.ts rename to graph/test/typescript/fixtures/linking/src/link-subpath.ts diff --git a/test/typescript/fixtures/linking/src/link-twin-src.ts b/graph/test/typescript/fixtures/linking/src/link-twin-src.ts similarity index 100% rename from test/typescript/fixtures/linking/src/link-twin-src.ts rename to graph/test/typescript/fixtures/linking/src/link-twin-src.ts diff --git a/test/typescript/fixtures/linking/tsconfig.json b/graph/test/typescript/fixtures/linking/tsconfig.json similarity index 100% rename from test/typescript/fixtures/linking/tsconfig.json rename to graph/test/typescript/fixtures/linking/tsconfig.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-index.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-index.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-index.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-index.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-index.js b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-index.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-index.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-index.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-a.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-a.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-a.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-a.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-a.js b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-a.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-a.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-a.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-b.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-b.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-b.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-b.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-b.js b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-b.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-b.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-b.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-c.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-c.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-c.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-c.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-c.js b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-c.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-c.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/barrel-inner-c.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/barrel/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/barrel/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/barrel/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/barrel/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/chain/chain-leaf.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-leaf.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/chain/chain-leaf.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-leaf.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/chain/chain-leaf.js b/graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-leaf.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/chain/chain-leaf.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-leaf.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/chain/chain-mid.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-mid.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/chain/chain-mid.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-mid.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/chain/chain-mid.js b/graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-mid.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/chain/chain-mid.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-mid.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/chain/chain-top.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-top.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/chain/chain-top.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-top.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/chain/chain-top.js b/graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-top.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/chain/chain-top.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/chain/chain-top.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/chain/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/chain/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/chain/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/chain/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/collide/collide-api.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/collide/collide-api.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/collide/collide-api.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/collide/collide-api.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/collide/collide-api.js b/graph/test/typescript/fixtures/linking/vendor/@tt/collide/collide-api.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/collide/collide-api.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/collide/collide-api.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/collide/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/collide/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/collide/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/collide/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/cond/cond-api.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/cond/cond-api.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/cond/cond-api.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/cond/cond-api.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/cond/cond-api.js b/graph/test/typescript/fixtures/linking/vendor/@tt/cond/cond-api.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/cond/cond-api.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/cond/cond-api.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/cond/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/cond/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/cond/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/cond/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/default/default-codec.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/default/default-codec.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/default/default-codec.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/default/default-codec.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/default/default-codec.js b/graph/test/typescript/fixtures/linking/vendor/@tt/default/default-codec.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/default/default-codec.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/default/default-codec.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/default/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/default/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/default/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/default/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/generic/generic-lib.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/generic/generic-lib.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/generic/generic-lib.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/generic/generic-lib.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/generic/generic-lib.js b/graph/test/typescript/fixtures/linking/vendor/@tt/generic/generic-lib.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/generic/generic-lib.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/generic/generic-lib.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/generic/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/generic/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/generic/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/generic/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/legacy/legacy-pack.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/legacy/legacy-pack.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/legacy/legacy-pack.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/legacy/legacy-pack.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/legacy/legacy-pack.js b/graph/test/typescript/fixtures/linking/vendor/@tt/legacy/legacy-pack.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/legacy/legacy-pack.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/legacy/legacy-pack.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/legacy/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/legacy/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/legacy/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/legacy/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/merged/merged-lib.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/merged/merged-lib.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/merged/merged-lib.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/merged/merged-lib.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/merged/merged-lib.js b/graph/test/typescript/fixtures/linking/vendor/@tt/merged/merged-lib.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/merged/merged-lib.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/merged/merged-lib.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/merged/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/merged/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/merged/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/merged/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/named/named-api.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/named/named-api.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/named/named-api.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/named/named-api.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/named/named-api.js b/graph/test/typescript/fixtures/linking/vendor/@tt/named/named-api.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/named/named-api.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/named/named-api.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/named/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/named/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/named/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/named/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/overload/overload-lib.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/overload/overload-lib.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/overload/overload-lib.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/overload/overload-lib.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/overload/overload-lib.js b/graph/test/typescript/fixtures/linking/vendor/@tt/overload/overload-lib.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/overload/overload-lib.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/overload/overload-lib.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/overload/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/overload/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/overload/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/overload/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/probe-core/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/probe-core/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/probe-core/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/probe-core/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/probe-core/probe-core.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/probe-core/probe-core.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/probe-core/probe-core.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/probe-core/probe-core.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/probe-core/probe-core.js b/graph/test/typescript/fixtures/linking/vendor/@tt/probe-core/probe-core.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/probe-core/probe-core.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/probe-core/probe-core.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/probe/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/probe/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/probe/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/probe/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/probe/probe-index.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/probe/probe-index.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/probe/probe-index.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/probe/probe-index.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/probe/probe-index.js b/graph/test/typescript/fixtures/linking/vendor/@tt/probe/probe-index.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/probe/probe-index.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/probe/probe-index.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/subpath/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/subpath/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/subpath/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/subpath/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-deep.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-deep.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-deep.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-deep.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-deep.js b/graph/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-deep.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-deep.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-deep.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-root.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-root.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-root.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-root.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-root.js b/graph/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-root.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-root.js rename to graph/test/typescript/fixtures/linking/vendor/@tt/subpath/subpath-root.js diff --git a/test/typescript/fixtures/linking/vendor/@tt/twinsrc/dist/index.d.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/twinsrc/dist/index.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/twinsrc/dist/index.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/twinsrc/dist/index.d.ts diff --git a/test/typescript/fixtures/linking/vendor/@tt/twinsrc/package.json b/graph/test/typescript/fixtures/linking/vendor/@tt/twinsrc/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/twinsrc/package.json rename to graph/test/typescript/fixtures/linking/vendor/@tt/twinsrc/package.json diff --git a/test/typescript/fixtures/linking/vendor/@tt/twinsrc/src/index.ts b/graph/test/typescript/fixtures/linking/vendor/@tt/twinsrc/src/index.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@tt/twinsrc/src/index.ts rename to graph/test/typescript/fixtures/linking/vendor/@tt/twinsrc/src/index.ts diff --git a/test/typescript/fixtures/linking/vendor/@types/plainjs/package.json b/graph/test/typescript/fixtures/linking/vendor/@types/plainjs/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/@types/plainjs/package.json rename to graph/test/typescript/fixtures/linking/vendor/@types/plainjs/package.json diff --git a/test/typescript/fixtures/linking/vendor/@types/plainjs/plain-types.d.ts b/graph/test/typescript/fixtures/linking/vendor/@types/plainjs/plain-types.d.ts similarity index 100% rename from test/typescript/fixtures/linking/vendor/@types/plainjs/plain-types.d.ts rename to graph/test/typescript/fixtures/linking/vendor/@types/plainjs/plain-types.d.ts diff --git a/test/typescript/fixtures/linking/vendor/plainjs/package.json b/graph/test/typescript/fixtures/linking/vendor/plainjs/package.json similarity index 100% rename from test/typescript/fixtures/linking/vendor/plainjs/package.json rename to graph/test/typescript/fixtures/linking/vendor/plainjs/package.json diff --git a/test/typescript/fixtures/linking/vendor/plainjs/plain-runtime.js b/graph/test/typescript/fixtures/linking/vendor/plainjs/plain-runtime.js similarity index 100% rename from test/typescript/fixtures/linking/vendor/plainjs/plain-runtime.js rename to graph/test/typescript/fixtures/linking/vendor/plainjs/plain-runtime.js diff --git a/test/typescript/fixtures/multi-program/README.md b/graph/test/typescript/fixtures/multi-program/README.md similarity index 100% rename from test/typescript/fixtures/multi-program/README.md rename to graph/test/typescript/fixtures/multi-program/README.md diff --git a/test/typescript/fixtures/multi-program/dep/alpha/index.d.ts b/graph/test/typescript/fixtures/multi-program/dep/alpha/index.d.ts similarity index 100% rename from test/typescript/fixtures/multi-program/dep/alpha/index.d.ts rename to graph/test/typescript/fixtures/multi-program/dep/alpha/index.d.ts diff --git a/test/typescript/fixtures/multi-program/dep/alpha/package.json b/graph/test/typescript/fixtures/multi-program/dep/alpha/package.json similarity index 100% rename from test/typescript/fixtures/multi-program/dep/alpha/package.json rename to graph/test/typescript/fixtures/multi-program/dep/alpha/package.json diff --git a/test/typescript/fixtures/multi-program/dep/beta/index.d.ts b/graph/test/typescript/fixtures/multi-program/dep/beta/index.d.ts similarity index 100% rename from test/typescript/fixtures/multi-program/dep/beta/index.d.ts rename to graph/test/typescript/fixtures/multi-program/dep/beta/index.d.ts diff --git a/test/typescript/fixtures/multi-program/dep/beta/package.json b/graph/test/typescript/fixtures/multi-program/dep/beta/package.json similarity index 100% rename from test/typescript/fixtures/multi-program/dep/beta/package.json rename to graph/test/typescript/fixtures/multi-program/dep/beta/package.json diff --git a/test/typescript/fixtures/multi-program/dep/package.json b/graph/test/typescript/fixtures/multi-program/dep/package.json similarity index 100% rename from test/typescript/fixtures/multi-program/dep/package.json rename to graph/test/typescript/fixtures/multi-program/dep/package.json diff --git a/test/typescript/fixtures/multi-program/dual/cjs/index.d.cts b/graph/test/typescript/fixtures/multi-program/dual/cjs/index.d.cts similarity index 100% rename from test/typescript/fixtures/multi-program/dual/cjs/index.d.cts rename to graph/test/typescript/fixtures/multi-program/dual/cjs/index.d.cts diff --git a/test/typescript/fixtures/multi-program/dual/esm/index.d.ts b/graph/test/typescript/fixtures/multi-program/dual/esm/index.d.ts similarity index 100% rename from test/typescript/fixtures/multi-program/dual/esm/index.d.ts rename to graph/test/typescript/fixtures/multi-program/dual/esm/index.d.ts diff --git a/test/typescript/fixtures/multi-program/dual/package.json b/graph/test/typescript/fixtures/multi-program/dual/package.json similarity index 100% rename from test/typescript/fixtures/multi-program/dual/package.json rename to graph/test/typescript/fixtures/multi-program/dual/package.json diff --git a/test/typescript/fixtures/multi-program/run.sh b/graph/test/typescript/fixtures/multi-program/run.sh similarity index 64% rename from test/typescript/fixtures/multi-program/run.sh rename to graph/test/typescript/fixtures/multi-program/run.sh index ee04f71fd..e4c9fc1c4 100755 --- a/test/typescript/fixtures/multi-program/run.sh +++ b/graph/test/typescript/fixtures/multi-program/run.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Does library staging tell a lost sibling program from a dual-format build? (#230) +# Does library staging tell a sibling program from a dual-format build? (#230, now fixed in the parser) # # Both shapes make the parser analyse more files than it publishes. Only one is a defect: # dep/ sibling programs, each with its own manifest -> genuinely lost, stage them @@ -37,24 +37,31 @@ if [ -n "$dual" ]; then fail=1 fi -# ── 2. the defect is real: one invocation publishes one program ───────────── +# ── 2. one invocation over sibling programs publishes BOTH ─────────────────── +# This step used to assert the opposite — that one invocation published only one of the two +# siblings — because that was the parser's behaviour (#230): per-project analyses ran into one +# output directory with truncating writes, and the last one to finish won. The harness's +# answer was to detect the shortfall and stage the siblings one at a time (add_lib in +# run-evaluation.sh). The parser now merges per-project output, so the root invocation is +# whole and that workaround stays dormant; this asserts the fix, and step 3 that the +# shortfall detector has nothing to report. node "$PARSER" "$WORK/dep" mp false "$WORK/ir-dep" >"$WORK/dep.parser.log" 2>&1 || true pub=$(awk -F'\t' 'NR>1{print $4}' "$WORK/ir-dep/all-typescript-modules.csv" 2>/dev/null | wc -l | tr -d ' ') -if [ "$pub" -ne 1 ]; then - echo "FAIL one invocation over sibling packages published $pub modules, expected 1" - echo " the fixture is no longer reproducing the defect it exists for" +if [ "$pub" -ne 2 ]; then + echo "FAIL one invocation over sibling packages published $pub modules, expected 2 (alpha and beta)" + echo " the parser is losing a sibling program again" fail=1 fi +for s in alpha beta; do + grep -q "from_$s" "$WORK/ir-dep/all-typescript-methods.csv" 2>/dev/null || { echo "FAIL root invocation did not publish dep/$s's declaration from_$s"; fail=1; } +done -# ── 3. and the shortfall is DETECTED rather than silent ──────────────────── +# ── 3. and the shortfall detector sees no shortfall ───────────────────────── sf="$(lib_shortfall "$WORK/ir-dep" "$WORK/dep.parser.log")" -if [ -z "$sf" ]; then - echo "FAIL lib_shortfall reported nothing; the parser's own count is not being read" - fail=1 -else +if [ -n "$sf" ]; then set -- $sf - if [ "$1" -le "$2" ]; then - echo "FAIL lib_shortfall says analysed=$1 published=$2 — no shortfall seen" + if [ "$1" -gt "$2" ]; then + echo "FAIL lib_shortfall says analysed=$1 published=$2 — a shortfall on a whole root invocation" fail=1 fi fi @@ -68,5 +75,5 @@ for s in alpha beta; do fi done -[ "$fail" -eq 0 ] && echo "multi-program fixture: gate ok (siblings staged, dual-format not, shortfall detected)" +[ "$fail" -eq 0 ] && echo "multi-program fixture: gate ok (siblings both published by one invocation, dual-format not staged twice, no shortfall)" exit $fail diff --git a/test/typescript/fixtures/overloads/README.md b/graph/test/typescript/fixtures/overloads/README.md similarity index 100% rename from test/typescript/fixtures/overloads/README.md rename to graph/test/typescript/fixtures/overloads/README.md diff --git a/test/typescript/fixtures/overloads/package.json b/graph/test/typescript/fixtures/overloads/package.json similarity index 100% rename from test/typescript/fixtures/overloads/package.json rename to graph/test/typescript/fixtures/overloads/package.json diff --git a/test/typescript/fixtures/overloads/run.sh b/graph/test/typescript/fixtures/overloads/run.sh similarity index 71% rename from test/typescript/fixtures/overloads/run.sh rename to graph/test/typescript/fixtures/overloads/run.sh index b4ce31304..9c3c37536 100755 --- a/test/typescript/fixtures/overloads/run.sh +++ b/graph/test/typescript/fixtures/overloads/run.sh @@ -4,7 +4,7 @@ # stays clean and so node_modules can be a symlink to whatever TypeScript is at hand. set -e HERE="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "$HERE/../../../.." && pwd)" +REPO="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting WORK="${1:-/tmp/ts-overload-fixture}" # Discovered, not hardcoded: this default used to be an absolute path inside one # developer's home directory, so the symlink below was silently skipped anywhere else @@ -16,4 +16,4 @@ cp -R "$HERE"/. "$WORK/project" rm -f "$WORK/project/run.sh" [ -d "$NM" ] && ln -sfn "$NM" "$WORK/project/node_modules" -bash "$REPO/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" +bash "$REPO/graph/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" diff --git a/test/typescript/fixtures/overloads/src/callable.ts b/graph/test/typescript/fixtures/overloads/src/callable.ts similarity index 100% rename from test/typescript/fixtures/overloads/src/callable.ts rename to graph/test/typescript/fixtures/overloads/src/callable.ts diff --git a/test/typescript/fixtures/overloads/src/consumer-barrel.ts b/graph/test/typescript/fixtures/overloads/src/consumer-barrel.ts similarity index 100% rename from test/typescript/fixtures/overloads/src/consumer-barrel.ts rename to graph/test/typescript/fixtures/overloads/src/consumer-barrel.ts diff --git a/test/typescript/fixtures/overloads/src/consumer-direct.ts b/graph/test/typescript/fixtures/overloads/src/consumer-direct.ts similarity index 100% rename from test/typescript/fixtures/overloads/src/consumer-direct.ts rename to graph/test/typescript/fixtures/overloads/src/consumer-direct.ts diff --git a/test/typescript/fixtures/overloads/src/index.ts b/graph/test/typescript/fixtures/overloads/src/index.ts similarity index 100% rename from test/typescript/fixtures/overloads/src/index.ts rename to graph/test/typescript/fixtures/overloads/src/index.ts diff --git a/test/typescript/fixtures/overloads/src/math.ts b/graph/test/typescript/fixtures/overloads/src/math.ts similarity index 100% rename from test/typescript/fixtures/overloads/src/math.ts rename to graph/test/typescript/fixtures/overloads/src/math.ts diff --git a/test/typescript/fixtures/overloads/src/registry.ts b/graph/test/typescript/fixtures/overloads/src/registry.ts similarity index 100% rename from test/typescript/fixtures/overloads/src/registry.ts rename to graph/test/typescript/fixtures/overloads/src/registry.ts diff --git a/test/typescript/fixtures/overloads/src/shapes.ts b/graph/test/typescript/fixtures/overloads/src/shapes.ts similarity index 100% rename from test/typescript/fixtures/overloads/src/shapes.ts rename to graph/test/typescript/fixtures/overloads/src/shapes.ts diff --git a/test/typescript/fixtures/overloads/tsconfig.json b/graph/test/typescript/fixtures/overloads/tsconfig.json similarity index 100% rename from test/typescript/fixtures/overloads/tsconfig.json rename to graph/test/typescript/fixtures/overloads/tsconfig.json diff --git a/test/typescript/fixtures/specificity/README.md b/graph/test/typescript/fixtures/specificity/README.md similarity index 98% rename from test/typescript/fixtures/specificity/README.md rename to graph/test/typescript/fixtures/specificity/README.md index 5d9101965..cd663517a 100644 --- a/test/typescript/fixtures/specificity/README.md +++ b/graph/test/typescript/fixtures/specificity/README.md @@ -73,7 +73,7 @@ scoped rule, which is the trade in its smallest legible form. ## What Java did, and what it costs to buy the same thing -Java's engine (`src/java/engine/expression-resolution/overload.dl`) hit this and answered +Java's engine (`graph/java/engine/expression-resolution/overload.dl`) hit this and answered it with `win_fully_applicable`: a candidate may only DOMINATE a rival when it is verified applicable at every argument position, and where an argument's type is uncaptured no domination fires and the set is kept whole. Nothing dies by default. Ported to TypeScript diff --git a/test/typescript/fixtures/specificity/package.json b/graph/test/typescript/fixtures/specificity/package.json similarity index 100% rename from test/typescript/fixtures/specificity/package.json rename to graph/test/typescript/fixtures/specificity/package.json diff --git a/test/typescript/fixtures/specificity/run.sh b/graph/test/typescript/fixtures/specificity/run.sh similarity index 64% rename from test/typescript/fixtures/specificity/run.sh rename to graph/test/typescript/fixtures/specificity/run.sh index eef61ca8c..57c00d5ad 100755 --- a/test/typescript/fixtures/specificity/run.sh +++ b/graph/test/typescript/fixtures/specificity/run.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e HERE="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "$HERE/../../../.." && pwd)" +REPO="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting WORK="${1:-/tmp/ts-specificity-fixture}" # Discovered, not hardcoded: this default used to be an absolute path inside one # developer's home directory, so the symlink below was silently skipped anywhere else @@ -11,4 +11,4 @@ rm -rf "$WORK"; mkdir -p "$WORK" cp -R "$HERE"/. "$WORK/project" rm -f "$WORK/project/run.sh" "$WORK/project/README.md" [ -d "$NM" ] && ln -sfn "$NM" "$WORK/project/node_modules" -bash "$REPO/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" +bash "$REPO/graph/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" diff --git a/test/typescript/fixtures/specificity/src/api.ts b/graph/test/typescript/fixtures/specificity/src/api.ts similarity index 100% rename from test/typescript/fixtures/specificity/src/api.ts rename to graph/test/typescript/fixtures/specificity/src/api.ts diff --git a/test/typescript/fixtures/specificity/src/consumer.ts b/graph/test/typescript/fixtures/specificity/src/consumer.ts similarity index 100% rename from test/typescript/fixtures/specificity/src/consumer.ts rename to graph/test/typescript/fixtures/specificity/src/consumer.ts diff --git a/test/typescript/fixtures/specificity/tsconfig.json b/graph/test/typescript/fixtures/specificity/tsconfig.json similarity index 100% rename from test/typescript/fixtures/specificity/tsconfig.json rename to graph/test/typescript/fixtures/specificity/tsconfig.json diff --git a/test/typescript/fixtures/tsconfig-chain/README.md b/graph/test/typescript/fixtures/tsconfig-chain/README.md similarity index 100% rename from test/typescript/fixtures/tsconfig-chain/README.md rename to graph/test/typescript/fixtures/tsconfig-chain/README.md diff --git a/test/typescript/fixtures/tsconfig-chain/repo/packages/pkg/src/callsites.ts b/graph/test/typescript/fixtures/tsconfig-chain/repo/packages/pkg/src/callsites.ts similarity index 100% rename from test/typescript/fixtures/tsconfig-chain/repo/packages/pkg/src/callsites.ts rename to graph/test/typescript/fixtures/tsconfig-chain/repo/packages/pkg/src/callsites.ts diff --git a/test/typescript/fixtures/tsconfig-chain/repo/packages/pkg/tsconfig.json b/graph/test/typescript/fixtures/tsconfig-chain/repo/packages/pkg/tsconfig.json similarity index 100% rename from test/typescript/fixtures/tsconfig-chain/repo/packages/pkg/tsconfig.json rename to graph/test/typescript/fixtures/tsconfig-chain/repo/packages/pkg/tsconfig.json diff --git a/test/typescript/fixtures/tsconfig-chain/repo/tsconfig.base.json b/graph/test/typescript/fixtures/tsconfig-chain/repo/tsconfig.base.json similarity index 100% rename from test/typescript/fixtures/tsconfig-chain/repo/tsconfig.base.json rename to graph/test/typescript/fixtures/tsconfig-chain/repo/tsconfig.base.json diff --git a/test/typescript/fixtures/tsconfig-chain/run.sh b/graph/test/typescript/fixtures/tsconfig-chain/run.sh similarity index 100% rename from test/typescript/fixtures/tsconfig-chain/run.sh rename to graph/test/typescript/fixtures/tsconfig-chain/run.sh diff --git a/test/typescript/fixtures/typeflow/README.md b/graph/test/typescript/fixtures/typeflow/README.md similarity index 100% rename from test/typescript/fixtures/typeflow/README.md rename to graph/test/typescript/fixtures/typeflow/README.md diff --git a/test/typescript/fixtures/typeflow/package.json b/graph/test/typescript/fixtures/typeflow/package.json similarity index 100% rename from test/typescript/fixtures/typeflow/package.json rename to graph/test/typescript/fixtures/typeflow/package.json diff --git a/test/typescript/fixtures/typeflow/run.sh b/graph/test/typescript/fixtures/typeflow/run.sh similarity index 75% rename from test/typescript/fixtures/typeflow/run.sh rename to graph/test/typescript/fixtures/typeflow/run.sh index bbb429fa3..e805f4d17 100755 --- a/test/typescript/fixtures/typeflow/run.sh +++ b/graph/test/typescript/fixtures/typeflow/run.sh @@ -7,7 +7,7 @@ # and that is the same machinery a real dependency needs. set -e HERE="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "$HERE/../../../.." && pwd)" +REPO="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting WORK="${1:-/tmp/ts-typeflow-fixture}" # Discovered, not hardcoded: this default used to be an absolute path inside one # developer's home directory, so the symlink below was silently skipped anywhere else @@ -19,4 +19,4 @@ cp -R "$HERE"/. "$WORK/project" rm -f "$WORK/project/run.sh" "$WORK/project/README.md" [ -d "$NM" ] && ln -sfn "$NM" "$WORK/project/node_modules" -bash "$REPO/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" +bash "$REPO/graph/test/typescript/run-evaluation.sh" "$WORK/project" "$WORK/eval" diff --git a/test/typescript/fixtures/typeflow/src/flow-async.ts b/graph/test/typescript/fixtures/typeflow/src/flow-async.ts similarity index 100% rename from test/typescript/fixtures/typeflow/src/flow-async.ts rename to graph/test/typescript/fixtures/typeflow/src/flow-async.ts diff --git a/test/typescript/fixtures/typeflow/src/flow-callable.ts b/graph/test/typescript/fixtures/typeflow/src/flow-callable.ts similarity index 100% rename from test/typescript/fixtures/typeflow/src/flow-callable.ts rename to graph/test/typescript/fixtures/typeflow/src/flow-callable.ts diff --git a/test/typescript/fixtures/typeflow/src/flow-container.ts b/graph/test/typescript/fixtures/typeflow/src/flow-container.ts similarity index 100% rename from test/typescript/fixtures/typeflow/src/flow-container.ts rename to graph/test/typescript/fixtures/typeflow/src/flow-container.ts diff --git a/test/typescript/fixtures/typeflow/src/flow-enum.ts b/graph/test/typescript/fixtures/typeflow/src/flow-enum.ts similarity index 100% rename from test/typescript/fixtures/typeflow/src/flow-enum.ts rename to graph/test/typescript/fixtures/typeflow/src/flow-enum.ts diff --git a/test/typescript/fixtures/typeflow/src/flow-narrow.ts b/graph/test/typescript/fixtures/typeflow/src/flow-narrow.ts similarity index 100% rename from test/typescript/fixtures/typeflow/src/flow-narrow.ts rename to graph/test/typescript/fixtures/typeflow/src/flow-narrow.ts diff --git a/test/typescript/fixtures/typeflow/src/flow-shape.ts b/graph/test/typescript/fixtures/typeflow/src/flow-shape.ts similarity index 100% rename from test/typescript/fixtures/typeflow/src/flow-shape.ts rename to graph/test/typescript/fixtures/typeflow/src/flow-shape.ts diff --git a/test/typescript/fixtures/typeflow/src/shapes.ts b/graph/test/typescript/fixtures/typeflow/src/shapes.ts similarity index 100% rename from test/typescript/fixtures/typeflow/src/shapes.ts rename to graph/test/typescript/fixtures/typeflow/src/shapes.ts diff --git a/test/typescript/fixtures/typeflow/tsconfig.json b/graph/test/typescript/fixtures/typeflow/tsconfig.json similarity index 100% rename from test/typescript/fixtures/typeflow/tsconfig.json rename to graph/test/typescript/fixtures/typeflow/tsconfig.json diff --git a/test/typescript/ground-truth/chain-check.py b/graph/test/typescript/ground-truth/chain-check.py similarity index 100% rename from test/typescript/ground-truth/chain-check.py rename to graph/test/typescript/ground-truth/chain-check.py diff --git a/test/typescript/ground-truth/ledger.py b/graph/test/typescript/ground-truth/ledger.py similarity index 100% rename from test/typescript/ground-truth/ledger.py rename to graph/test/typescript/ground-truth/ledger.py diff --git a/test/typescript/ground-truth/lib-files.mjs b/graph/test/typescript/ground-truth/lib-files.mjs similarity index 100% rename from test/typescript/ground-truth/lib-files.mjs rename to graph/test/typescript/ground-truth/lib-files.mjs diff --git a/test/typescript/ground-truth/load-typescript.mjs b/graph/test/typescript/ground-truth/load-typescript.mjs similarity index 100% rename from test/typescript/ground-truth/load-typescript.mjs rename to graph/test/typescript/ground-truth/load-typescript.mjs diff --git a/test/typescript/ground-truth/overload-siblings.mjs b/graph/test/typescript/ground-truth/overload-siblings.mjs similarity index 100% rename from test/typescript/ground-truth/overload-siblings.mjs rename to graph/test/typescript/ground-truth/overload-siblings.mjs diff --git a/test/typescript/ground-truth/score.py b/graph/test/typescript/ground-truth/score.py similarity index 100% rename from test/typescript/ground-truth/score.py rename to graph/test/typescript/ground-truth/score.py diff --git a/test/typescript/ground-truth/signature-impls.mjs b/graph/test/typescript/ground-truth/signature-impls.mjs similarity index 100% rename from test/typescript/ground-truth/signature-impls.mjs rename to graph/test/typescript/ground-truth/signature-impls.mjs diff --git a/test/typescript/ground-truth/three-way.py b/graph/test/typescript/ground-truth/three-way.py similarity index 99% rename from test/typescript/ground-truth/three-way.py rename to graph/test/typescript/ground-truth/three-way.py index a5920a5e3..3c9db6c2e 100644 --- a/test/typescript/ground-truth/three-way.py +++ b/graph/test/typescript/ground-truth/three-way.py @@ -85,7 +85,7 @@ def main(): csv_out = a.split('=', 1)[1] ir = os.path.join(ev, 'ir') - out = os.path.join(ev, 'out', 'raw') # the engine's raw relations (see src/bundle/SCHEMA.md) + out = os.path.join(ev, 'out', 'raw') # the engine's raw relations (see graph/bundle/SCHEMA.md) # ── declaration identity, full path where the root is known ───────────── exact_identity = True diff --git a/test/typescript/ground-truth/tsc-envelope.mjs b/graph/test/typescript/ground-truth/tsc-envelope.mjs similarity index 100% rename from test/typescript/ground-truth/tsc-envelope.mjs rename to graph/test/typescript/ground-truth/tsc-envelope.mjs diff --git a/test/typescript/ground-truth/tsc-oracle.mjs b/graph/test/typescript/ground-truth/tsc-oracle.mjs similarity index 100% rename from test/typescript/ground-truth/tsc-oracle.mjs rename to graph/test/typescript/ground-truth/tsc-oracle.mjs diff --git a/test/typescript/run-evaluation.sh b/graph/test/typescript/run-evaluation.sh similarity index 98% rename from test/typescript/run-evaluation.sh rename to graph/test/typescript/run-evaluation.sh index df81e4b4d..5e651a1c8 100755 --- a/test/typescript/run-evaluation.sh +++ b/graph/test/typescript/run-evaluation.sh @@ -35,14 +35,14 @@ PROJECT="$(cd "$1" && pwd)" # directory can still be resolved. mkdir -p "$2" WORK="$(cd "$2" && pwd)" -# $3 wins, then $AXIOM_PARSER, then the conventional checkout. The env var matters: the +# $3 wins, then $AXIOM_PARSER, then the parser in this repository. The env var matters: the # fallback is an absolute path into a SHARED checkout whose dist belongs to whoever built # it last, so a caller that omits $3 silently measured a different parser than the one it # was told to use. That is how a fixture came to stage 27-column library IR against # 28-column declarations while the client IR was current. -PARSER_DIST="${3:-${AXIOM_PARSER:-/Users/swapnilpaliwal/Documents/AxiomCode/Parser/dist/index.js}}" HERE="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "$HERE/../.." && pwd)" +REPO="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting +PARSER_DIST="${3:-${AXIOM_PARSER:-$REPO/parser/dist/index.js}}" NAME="$(basename "$PROJECT")" mkdir -p "$WORK"/{ir,libir,int,out} @@ -177,7 +177,7 @@ if [ ! -d "$MIRROR_BASE" ]; then -o -name '*.cts' \) -print -quit 2>/dev/null)" ]; then echo " ! NO TYPESCRIPT SOURCE mirrored from $PROJECT (rsync exit $rs)" >&2 echo " The source tree is empty of .ts/.tsx/.mts/.cts. If the corpus lives under" >&2 - echo " /tmp it may have been reaped — reclone with test/typescript/corpus/fetch.sh." >&2 + echo " /tmp it may have been reaped — reclone with graph/test/typescript/corpus/fetch.sh." >&2 rm -rf "$MIRROR_TMP" exit 1 fi @@ -393,8 +393,8 @@ echo " $MODS modules, $CS call sites" # pipeline cannot see it: rows are consistent with their own HEADER, and the header is # not what drifted. python3 "$HERE/tools/schema_drift.py" "$WORK/ir" \ - "$REPO/src/typescript/souffle/decls_base.dl" \ - "$REPO/src/typescript/souffle/decls_all.dl" || { + "$REPO/graph/typescript/souffle/decls_base.dl" \ + "$REPO/graph/typescript/souffle/decls_all.dl" || { echo " ! refusing to measure against a drifted schema" >&2; exit 1; } # ── 2. library IR, DISCOVERED from the client's own resolved imports ───────── @@ -714,7 +714,7 @@ fi # ── 3. solve ───────────────────────────────────────────────────────────────── echo "▶ solving..." -bash "$REPO/src/pipeline/run-souffle.sh" --debug --language typescript \ +bash "$REPO/graph/pipeline/run-souffle.sh" --debug --language typescript \ --client-ir "$WORK/ir" ${LIBS:+--library "$LIBS"} \ --intermediate "$WORK/int" --output "$WORK/out" >"$WORK/solve.log" 2>&1 || { echo " solve failed; see $WORK/solve.log" >&2; tail -20 "$WORK/solve.log" >&2; exit 1; } diff --git a/test/typescript/run-tests.sh b/graph/test/typescript/run-tests.sh similarity index 97% rename from test/typescript/run-tests.sh rename to graph/test/typescript/run-tests.sh index 05d6f75fe..d15a9097f 100755 --- a/test/typescript/run-tests.sh +++ b/graph/test/typescript/run-tests.sh @@ -31,18 +31,18 @@ # that STARTS working also fails, so the debt list cannot silently rot. # # Environment: -# AXIOM_PARSER path to the parser entrypoint (default ../../../Parser/dist/index.js) +# AXIOM_PARSER path to the parser entrypoint (default parser/dist/index.js — the parser in this repository) # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$HERE/../.." && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting # ── Nothing this suite depends on may be invisible to git ──────────────────── # Runs first because it is cheap and because the fault it catches makes every OTHER # result in this file untrustworthy: a fixture input that .gitignore matches is present # locally, absent from the repository, and so every assertion about it passes here and # fails on a clone. See test/tools/no-ignored-fixtures.sh. -if ! bash "$ROOT/test/tools/no-ignored-fixtures.sh"; then +if ! bash "$ROOT/graph/test/tools/no-ignored-fixtures.sh"; then echo "aborting: a fixture input is not in the repository, so nothing below would be a test" exit 1 fi @@ -52,7 +52,7 @@ fi # is several hundred KB. Every reader that opens an IR CSV then dies AFTER the extraction, the # solve and the oracle have all succeeded. Costs milliseconds and is repo-wide, because the # readers span all three languages. See issue #238. -if ! bash "$ROOT/test/tools/csv-limit-test.sh"; then +if ! bash "$ROOT/graph/test/tools/csv-limit-test.sh"; then echo "aborting: a reader of the IR would die on a large literal" exit 1 fi @@ -61,16 +61,16 @@ fi # header raises StopIteration -- and the TypeScript harness reported that as "refusing to measure # against a drifted schema", the one fault that gate exists to catch. Lints every CSV reader in # the tree for the same shape, because it was in three places and only one crashed. See #244. -if ! bash "$ROOT/test/tools/empty-relation-test.sh"; then +if ! bash "$ROOT/graph/test/tools/empty-relation-test.sh"; then echo "aborting: a reader would die on a relation that simply has no rows" exit 1 fi # ── The bundle stage must build the language-neutral output ───────────────── # Every solve below ends by joining the raw relations to the IR and writing graph.sqlite -# (src/bundle/SCHEMA.md); graph/*.csv is the same core tables and is written only under +# (graph/bundle/SCHEMA.md); graph/*.csv is the same core tables and is written only under # --debug. A broken bundler fails every case identically, after the # solve's cost; this checks it in milliseconds on hand-written fixtures for all three languages. -if ! bash "$ROOT/test/tools/bundle-test.sh"; then +if ! bash "$ROOT/graph/test/tools/bundle-test.sh"; then echo "aborting: the bundle stage does not produce the documented output" exit 1 fi @@ -230,7 +230,7 @@ if ! bash "$HERE/tools/envelope-merge-test.sh"; then echo "aborting: the dispatch envelope is not the resolved symbol's declaration set" exit 1 fi -PARSER="${AXIOM_PARSER:-$ROOT/../Parser/dist/index.js}" +PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" WORK="$HERE/.work" BLESS=0; KEEP=0; ORACLE=0; FILTERS=() for a in "$@"; do case "$a" in @@ -275,7 +275,7 @@ pass=0; fail=0; failed=() # solve ; leaves edges in /out solve() { - bash "$ROOT/src/pipeline/run-souffle.sh" --debug --language typescript \ + bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language typescript \ --client-ir "$1" --library "$2" --intermediate "$3/int" --output "$3/out" \ >"$3/solve.log" 2>&1 } diff --git a/test/typescript/tools/arrow-naming-test.sh b/graph/test/typescript/tools/arrow-naming-test.sh similarity index 100% rename from test/typescript/tools/arrow-naming-test.sh rename to graph/test/typescript/tools/arrow-naming-test.sh diff --git a/test/typescript/tools/compiler-load-test.sh b/graph/test/typescript/tools/compiler-load-test.sh similarity index 100% rename from test/typescript/tools/compiler-load-test.sh rename to graph/test/typescript/tools/compiler-load-test.sh diff --git a/test/typescript/tools/coverage_guard.py b/graph/test/typescript/tools/coverage_guard.py similarity index 100% rename from test/typescript/tools/coverage_guard.py rename to graph/test/typescript/tools/coverage_guard.py diff --git a/test/typescript/tools/envelope-merge-test.sh b/graph/test/typescript/tools/envelope-merge-test.sh similarity index 97% rename from test/typescript/tools/envelope-merge-test.sh rename to graph/test/typescript/tools/envelope-merge-test.sh index 98c45b607..f12c1a838 100755 --- a/test/typescript/tools/envelope-merge-test.sh +++ b/graph/test/typescript/tools/envelope-merge-test.sh @@ -35,6 +35,7 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker PY="${PYTHON:-python3}" # ── locate a TypeScript compiler ──────────────────────────────────────────── @@ -48,7 +49,7 @@ find_ts() { local c for c in \ "${TS_MODULE_PATH:-}" \ - "$HERE/../../../node_modules/typescript" \ + "$ROOT/node_modules/typescript" \ "${AXIOM_PARSER:+$(dirname "$(dirname "$AXIOM_PARSER")")/node_modules/typescript}" \ "$HOME/.cache/axiom-ts-corpus/immer/node_modules/typescript" do diff --git a/test/typescript/tools/envelope_report_test.py b/graph/test/typescript/tools/envelope_report_test.py similarity index 100% rename from test/typescript/tools/envelope_report_test.py rename to graph/test/typescript/tools/envelope_report_test.py diff --git a/test/typescript/tools/find-node-modules.sh b/graph/test/typescript/tools/find-node-modules.sh similarity index 90% rename from test/typescript/tools/find-node-modules.sh rename to graph/test/typescript/tools/find-node-modules.sh index e45a9e355..b8bf6997e 100755 --- a/test/typescript/tools/find-node-modules.sh +++ b/graph/test/typescript/tools/find-node-modules.sh @@ -24,7 +24,7 @@ # repository's, then a corpus checkout's. # ───────────────────────────────────────────────────────────────────────────── HERE="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "$HERE/../../.." && pwd)" +REPO="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker — no level counting for c in "${TS_NODE_MODULES:-}" \ "${TS_MODULE_PATH:+$(dirname "$TS_MODULE_PATH")}" \ diff --git a/test/typescript/tools/lib-staging.sh b/graph/test/typescript/tools/lib-staging.sh similarity index 100% rename from test/typescript/tools/lib-staging.sh rename to graph/test/typescript/tools/lib-staging.sh diff --git a/test/typescript/tools/mirror-selflink-test.sh b/graph/test/typescript/tools/mirror-selflink-test.sh similarity index 100% rename from test/typescript/tools/mirror-selflink-test.sh rename to graph/test/typescript/tools/mirror-selflink-test.sh diff --git a/test/typescript/tools/normalize_edges.py b/graph/test/typescript/tools/normalize_edges.py similarity index 100% rename from test/typescript/tools/normalize_edges.py rename to graph/test/typescript/tools/normalize_edges.py diff --git a/test/typescript/tools/oracle_diff.py b/graph/test/typescript/tools/oracle_diff.py similarity index 100% rename from test/typescript/tools/oracle_diff.py rename to graph/test/typescript/tools/oracle_diff.py diff --git a/test/typescript/tools/overload-sibling-test.sh b/graph/test/typescript/tools/overload-sibling-test.sh similarity index 96% rename from test/typescript/tools/overload-sibling-test.sh rename to graph/test/typescript/tools/overload-sibling-test.sh index 74f30d35f..0d67dc07b 100755 --- a/test/typescript/tools/overload-sibling-test.sh +++ b/graph/test/typescript/tools/overload-sibling-test.sh @@ -38,10 +38,11 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker find_ts() { local c - for c in "${TS_MODULE_PATH:-}" "$HERE/../../../node_modules/typescript" \ + for c in "${TS_MODULE_PATH:-}" "$ROOT/node_modules/typescript" \ "${AXIOM_PARSER:+$(dirname "$(dirname "$AXIOM_PARSER")")/node_modules/typescript}" \ "$HOME/.cache/axiom-ts-corpus/immer/node_modules/typescript"; do [ -n "$c" ] && [ -f "$c/package.json" ] && { echo "$c"; return 0; } diff --git a/test/typescript/tools/path-space-test.sh b/graph/test/typescript/tools/path-space-test.sh similarity index 100% rename from test/typescript/tools/path-space-test.sh rename to graph/test/typescript/tools/path-space-test.sh diff --git a/test/typescript/tools/production-filter-test.py b/graph/test/typescript/tools/production-filter-test.py similarity index 100% rename from test/typescript/tools/production-filter-test.py rename to graph/test/typescript/tools/production-filter-test.py diff --git a/test/typescript/tools/schema_drift.py b/graph/test/typescript/tools/schema_drift.py similarity index 99% rename from test/typescript/tools/schema_drift.py rename to graph/test/typescript/tools/schema_drift.py index 1f0d21b50..b5dfc4b11 100755 --- a/test/typescript/tools/schema_drift.py +++ b/graph/test/typescript/tools/schema_drift.py @@ -37,7 +37,7 @@ def main(): ir = sys.argv[1] decl_files = sys.argv[2:] or glob.glob( os.path.join(os.path.dirname(__file__), '..', '..', '..', - 'src/typescript/souffle/decls_*.dl')) + 'graph/typescript/souffle/decls_*.dl')) arity = {} for f in decl_files: for m in re.finditer(r'\.decl\s+(\w+)\(([^)]*)\)', open(f).read()): diff --git a/test/typescript/tools/self-staging-test.sh b/graph/test/typescript/tools/self-staging-test.sh similarity index 100% rename from test/typescript/tools/self-staging-test.sh rename to graph/test/typescript/tools/self-staging-test.sh diff --git a/test/typescript/tools/shape-owner-test.sh b/graph/test/typescript/tools/shape-owner-test.sh similarity index 100% rename from test/typescript/tools/shape-owner-test.sh rename to graph/test/typescript/tools/shape-owner-test.sh diff --git a/test/typescript/tools/signature-impl-test.sh b/graph/test/typescript/tools/signature-impl-test.sh similarity index 98% rename from test/typescript/tools/signature-impl-test.sh rename to graph/test/typescript/tools/signature-impl-test.sh index 23ec81bb5..3df023c0c 100755 --- a/test/typescript/tools/signature-impl-test.sh +++ b/graph/test/typescript/tools/signature-impl-test.sh @@ -32,10 +32,11 @@ # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker find_ts() { local c - for c in "${TS_MODULE_PATH:-}" "$HERE/../../../node_modules/typescript" \ + for c in "${TS_MODULE_PATH:-}" "$ROOT/node_modules/typescript" \ "${AXIOM_PARSER:+$(dirname "$(dirname "$AXIOM_PARSER")")/node_modules/typescript}" \ "$HOME/.cache/axiom-ts-corpus/immer/node_modules/typescript"; do [ -n "$c" ] && [ -f "$c/package.json" ] && { echo "$c"; return 0; } diff --git a/test/typescript/tools/source_root_test.py b/graph/test/typescript/tools/source_root_test.py similarity index 100% rename from test/typescript/tools/source_root_test.py rename to graph/test/typescript/tools/source_root_test.py diff --git a/test/typescript/tools/stage-solution-src.mjs b/graph/test/typescript/tools/stage-solution-src.mjs similarity index 100% rename from test/typescript/tools/stage-solution-src.mjs rename to graph/test/typescript/tools/stage-solution-src.mjs diff --git a/test/typescript/tools/staging-guard-test.sh b/graph/test/typescript/tools/staging-guard-test.sh similarity index 100% rename from test/typescript/tools/staging-guard-test.sh rename to graph/test/typescript/tools/staging-guard-test.sh diff --git a/test/typescript/tools/staging_guard_lint.py b/graph/test/typescript/tools/staging_guard_lint.py similarity index 100% rename from test/typescript/tools/staging_guard_lint.py rename to graph/test/typescript/tools/staging_guard_lint.py diff --git a/test/typescript/tools/tsc_oracle_case.mjs b/graph/test/typescript/tools/tsc_oracle_case.mjs similarity index 100% rename from test/typescript/tools/tsc_oracle_case.mjs rename to graph/test/typescript/tools/tsc_oracle_case.mjs diff --git a/test/typescript/tools/tsconfig_chain.mjs b/graph/test/typescript/tools/tsconfig_chain.mjs similarity index 100% rename from test/typescript/tools/tsconfig_chain.mjs rename to graph/test/typescript/tools/tsconfig_chain.mjs diff --git a/src/typescript/PARSER-DEFECTS.md b/graph/typescript/PARSER-DEFECTS.md similarity index 99% rename from src/typescript/PARSER-DEFECTS.md rename to graph/typescript/PARSER-DEFECTS.md index 1ff3e6ec9..f850f6eb6 100644 --- a/src/typescript/PARSER-DEFECTS.md +++ b/graph/typescript/PARSER-DEFECTS.md @@ -1,6 +1,6 @@ # TypeScript parser defects and contract notes -Found while building `src/typescript/engine/` against the parser IR. Every item was +Found while building `graph/typescript/engine/` against the parser IR. Every item was measured against a **fresh** extraction, not a checked-in export — the engine notes record four Python defects that were retracted because they were filed against a stale IR, and the same mistake is available here. diff --git a/src/typescript/README.md b/graph/typescript/README.md similarity index 99% rename from src/typescript/README.md rename to graph/typescript/README.md index 468923db1..1b315cdd2 100644 --- a/src/typescript/README.md +++ b/graph/typescript/README.md @@ -9,7 +9,7 @@ far side rather than a name or a package. node /dist/index.js false # 2. solve -bash src/pipeline/run-souffle.sh --language typescript \ +bash graph/pipeline/run-souffle.sh --language typescript \ --client-ir --library [,...] \ --intermediate --output diff --git a/src/typescript/engine/call-edge-generation/call_chain.dl b/graph/typescript/engine/call-edge-generation/call_chain.dl similarity index 100% rename from src/typescript/engine/call-edge-generation/call_chain.dl rename to graph/typescript/engine/call-edge-generation/call_chain.dl diff --git a/src/typescript/engine/call-edge-generation/calls.dl b/graph/typescript/engine/call-edge-generation/calls.dl similarity index 100% rename from src/typescript/engine/call-edge-generation/calls.dl rename to graph/typescript/engine/call-edge-generation/calls.dl diff --git a/src/typescript/engine/containment/ownership.dl b/graph/typescript/engine/containment/ownership.dl similarity index 100% rename from src/typescript/engine/containment/ownership.dl rename to graph/typescript/engine/containment/ownership.dl diff --git a/src/typescript/engine/expression-resolution/callee-resolution.dl b/graph/typescript/engine/expression-resolution/callee-resolution.dl similarity index 100% rename from src/typescript/engine/expression-resolution/callee-resolution.dl rename to graph/typescript/engine/expression-resolution/callee-resolution.dl diff --git a/src/typescript/engine/expression-resolution/expr-type.dl b/graph/typescript/engine/expression-resolution/expr-type.dl similarity index 100% rename from src/typescript/engine/expression-resolution/expr-type.dl rename to graph/typescript/engine/expression-resolution/expr-type.dl diff --git a/src/typescript/engine/expression-resolution/overload.dl b/graph/typescript/engine/expression-resolution/overload.dl similarity index 100% rename from src/typescript/engine/expression-resolution/overload.dl rename to graph/typescript/engine/expression-resolution/overload.dl diff --git a/src/typescript/engine/projections/expressions.dl b/graph/typescript/engine/projections/expressions.dl similarity index 100% rename from src/typescript/engine/projections/expressions.dl rename to graph/typescript/engine/projections/expressions.dl diff --git a/src/typescript/engine/projections/imports-exports.dl b/graph/typescript/engine/projections/imports-exports.dl similarity index 100% rename from src/typescript/engine/projections/imports-exports.dl rename to graph/typescript/engine/projections/imports-exports.dl diff --git a/src/typescript/engine/projections/members.dl b/graph/typescript/engine/projections/members.dl similarity index 100% rename from src/typescript/engine/projections/members.dl rename to graph/typescript/engine/projections/members.dl diff --git a/src/typescript/engine/projections/methods.dl b/graph/typescript/engine/projections/methods.dl similarity index 100% rename from src/typescript/engine/projections/methods.dl rename to graph/typescript/engine/projections/methods.dl diff --git a/src/typescript/engine/projections/modules.dl b/graph/typescript/engine/projections/modules.dl similarity index 100% rename from src/typescript/engine/projections/modules.dl rename to graph/typescript/engine/projections/modules.dl diff --git a/src/typescript/engine/projections/types.dl b/graph/typescript/engine/projections/types.dl similarity index 100% rename from src/typescript/engine/projections/types.dl rename to graph/typescript/engine/projections/types.dl diff --git a/src/typescript/engine/resolution/contextual-params.dl b/graph/typescript/engine/resolution/contextual-params.dl similarity index 100% rename from src/typescript/engine/resolution/contextual-params.dl rename to graph/typescript/engine/resolution/contextual-params.dl diff --git a/src/typescript/engine/resolution/generics.dl b/graph/typescript/engine/resolution/generics.dl similarity index 100% rename from src/typescript/engine/resolution/generics.dl rename to graph/typescript/engine/resolution/generics.dl diff --git a/src/typescript/engine/resolution/lib-scope.dl b/graph/typescript/engine/resolution/lib-scope.dl similarity index 100% rename from src/typescript/engine/resolution/lib-scope.dl rename to graph/typescript/engine/resolution/lib-scope.dl diff --git a/src/typescript/engine/resolution/member-lookup.dl b/graph/typescript/engine/resolution/member-lookup.dl similarity index 100% rename from src/typescript/engine/resolution/member-lookup.dl rename to graph/typescript/engine/resolution/member-lookup.dl diff --git a/src/typescript/engine/resolution/module-graph.dl b/graph/typescript/engine/resolution/module-graph.dl similarity index 100% rename from src/typescript/engine/resolution/module-graph.dl rename to graph/typescript/engine/resolution/module-graph.dl diff --git a/src/typescript/engine/resolution/name-resolution.dl b/graph/typescript/engine/resolution/name-resolution.dl similarity index 100% rename from src/typescript/engine/resolution/name-resolution.dl rename to graph/typescript/engine/resolution/name-resolution.dl diff --git a/src/typescript/engine/resolution/overload-sets.dl b/graph/typescript/engine/resolution/overload-sets.dl similarity index 100% rename from src/typescript/engine/resolution/overload-sets.dl rename to graph/typescript/engine/resolution/overload-sets.dl diff --git a/src/typescript/engine/resolution/reference-types.dl b/graph/typescript/engine/resolution/reference-types.dl similarity index 100% rename from src/typescript/engine/resolution/reference-types.dl rename to graph/typescript/engine/resolution/reference-types.dl diff --git a/src/typescript/engine/resolution/structural-satisfaction.dl b/graph/typescript/engine/resolution/structural-satisfaction.dl similarity index 100% rename from src/typescript/engine/resolution/structural-satisfaction.dl rename to graph/typescript/engine/resolution/structural-satisfaction.dl diff --git a/src/typescript/engine/resolution/type-hierarchy.dl b/graph/typescript/engine/resolution/type-hierarchy.dl similarity index 100% rename from src/typescript/engine/resolution/type-hierarchy.dl rename to graph/typescript/engine/resolution/type-hierarchy.dl diff --git a/src/typescript/engine/resolution/type-resolution.dl b/graph/typescript/engine/resolution/type-resolution.dl similarity index 100% rename from src/typescript/engine/resolution/type-resolution.dl rename to graph/typescript/engine/resolution/type-resolution.dl diff --git a/src/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl similarity index 100% rename from src/typescript/engine/resolution/value-flow.dl rename to graph/typescript/engine/resolution/value-flow.dl diff --git a/src/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl similarity index 100% rename from src/typescript/souffle/decls_all.dl rename to graph/typescript/souffle/decls_all.dl diff --git a/src/typescript/souffle/decls_base.dl b/graph/typescript/souffle/decls_base.dl similarity index 100% rename from src/typescript/souffle/decls_base.dl rename to graph/typescript/souffle/decls_base.dl diff --git a/src/typescript/souffle/export_manifest.tsv b/graph/typescript/souffle/export_manifest.tsv similarity index 100% rename from src/typescript/souffle/export_manifest.tsv rename to graph/typescript/souffle/export_manifest.tsv diff --git a/src/typescript/templates/client-ir.map b/graph/typescript/templates/client-ir.map similarity index 100% rename from src/typescript/templates/client-ir.map rename to graph/typescript/templates/client-ir.map diff --git a/src/typescript/templates/lib.map b/graph/typescript/templates/lib.map similarity index 100% rename from src/typescript/templates/lib.map rename to graph/typescript/templates/lib.map diff --git a/src/typescript/templates/staging.conf b/graph/typescript/templates/staging.conf similarity index 100% rename from src/typescript/templates/staging.conf rename to graph/typescript/templates/staging.conf diff --git a/src/validate.ts b/graph/validate.ts similarity index 100% rename from src/validate.ts rename to graph/validate.ts diff --git a/package-lock.json b/package-lock.json index 19525f77e..de8f06d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,15 @@ { - "name": "@axiomcode/reasoning-java", + "name": "@axiomcode/code-graph", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@axiomcode/reasoning-java", + "name": "@axiomcode/code-graph", "version": "1.0.0", + "workspaces": [ + "parser" + ], "devDependencies": { "@types/node": "^20.10.0", "tsc-alias": "^1.8.16", @@ -17,6 +20,10 @@ "node": ">=18.0.0" } }, + "node_modules/@axiomcode/parser": { + "resolved": "parser", + "link": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -459,6 +466,43 @@ "node": ">=18" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -497,556 +541,2510 @@ "node": ">= 8" } }, - "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", + "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", + "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", + "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", + "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", + "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", + "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "engines": { - "node": "^12.20.0 || >=14" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", + "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", + "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "cpu": [ + "arm" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", + "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", + "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", + "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "cpu": [ + "loong64" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", + "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", + "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", + "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", + "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", + "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", + "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", + "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", + "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", + "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", + "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", + "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", + "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", + "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", + "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" }, "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" }, - "engines": { - "node": ">= 6" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", "dev": true, "license": "MIT", "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, "engines": { - "node": ">= 4" + "node": ">=0.4.0" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" + "acorn": "^8.11.0" }, "engines": { - "node": ">=8" + "node": ">=0.4.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "is-extglob": "^2.1.1" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">=0.10.0" + "node": ">= 8" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.12.0" + "node": ">=8" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": "*" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "license": "MIT", - "dependencies": { - "braces": "^3.0.3", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { - "node": ">=8.6" + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", + "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.2", + "@rollup/rollup-android-arm64": "4.63.2", + "@rollup/rollup-darwin-arm64": "4.63.2", + "@rollup/rollup-darwin-x64": "4.63.2", + "@rollup/rollup-freebsd-arm64": "4.63.2", + "@rollup/rollup-freebsd-x64": "4.63.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", + "@rollup/rollup-linux-arm-musleabihf": "4.63.2", + "@rollup/rollup-linux-arm64-gnu": "4.63.2", + "@rollup/rollup-linux-arm64-musl": "4.63.2", + "@rollup/rollup-linux-loong64-gnu": "4.63.2", + "@rollup/rollup-linux-loong64-musl": "4.63.2", + "@rollup/rollup-linux-ppc64-gnu": "4.63.2", + "@rollup/rollup-linux-ppc64-musl": "4.63.2", + "@rollup/rollup-linux-riscv64-gnu": "4.63.2", + "@rollup/rollup-linux-riscv64-musl": "4.63.2", + "@rollup/rollup-linux-s390x-gnu": "4.63.2", + "@rollup/rollup-linux-x64-gnu": "4.63.2", + "@rollup/rollup-linux-x64-musl": "4.63.2", + "@rollup/rollup-openbsd-x64": "4.63.2", + "@rollup/rollup-openharmony-arm64": "4.63.2", + "@rollup/rollup-win32-arm64-msvc": "4.63.2", + "@rollup/rollup-win32-ia32-msvc": "4.63.2", + "@rollup/rollup-win32-x64-gnu": "4.63.2", + "@rollup/rollup-win32-x64-msvc": "4.63.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-sitter": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", + "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } + }, + "node_modules/tree-sitter-groovy": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tree-sitter-groovy/-/tree-sitter-groovy-0.1.2.tgz", + "integrity": "sha512-4dDUP3XKMwKfDCkm50EmUGPHblyPw0oXTv2ce0VrVYv8erxntsM3WTThC+vZgYT2yh+rYbbDVLsjvfGnHJQ8aw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.3", + "tree-sitter-java": "0.23.4" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-groovy/node_modules/tree-sitter-java": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.23.4.tgz", + "integrity": "sha512-WmqZPzvaHpAcAdJBjwMFwusL+ahp2Liv6T0ASWU7sxGZGceSdP5MpW+2DwLNOiWld39C1WR+9qk99hk4qHK5vw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-java": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.21.0.tgz", + "integrity": "sha512-CKJiTo1uc3SUsgEcaZgufGx8my6dzihy8JR/JsJH40Tj3uSe2/eFLk+0q+fpbosGAyY4YiXJtEoFB2O4bS2yOw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.21.0.tgz", + "integrity": "sha512-IUKx7JcTVbByUx1iHGFS/QsIjx7pqwTMHL9bl/NGyhyyydbfNrpruo2C7W6V4KZrbkkCOlX8QVrCoGOFW5qecg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/tsc-alias": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.9.1.tgz", + "integrity": "sha512-sFZdVFthH8uvdplPJrOYGOHcxu6UPtcAcY678JPwEQiMzgLZYFO7Qc/rzELp7ingTc+OxtzH6n+8Pn2eVQep6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/mylas": { - "version": "2.1.14", - "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", - "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/raouldeheer" + "node": ">=12" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=12" } }, - "node_modules/plimit-lit": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", - "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "queue-lit": "^1.5.1" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { "node": ">=12" } }, - "node_modules/queue-lit": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", - "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=12" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" ], - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8.10.0" + "node": ">=12" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "@types/node": { + "optional": true }, - { - "type": "consulting", - "url": "https://feross.org/support" + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, "engines": { - "node": ">=8" + "node": ">= 8" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8.0" + "node": ">=8" } }, - "node_modules/tsc-alias": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.9.1.tgz", - "integrity": "sha512-sFZdVFthH8uvdplPJrOYGOHcxu6UPtcAcY678JPwEQiMzgLZYFO7Qc/rzELp7ingTc+OxtzH6n+8Pn2eVQep6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.3", - "commander": "^9.0.0", - "get-tsconfig": "^4.10.0", - "globby": "^11.0.4", - "mylas": "^2.1.9", - "normalize-path": "^3.0.0", - "plimit-lit": "^1.2.6" - }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", "bin": { - "tsc-alias": "dist/bin/index.js" + "yaml": "bin.mjs" }, "engines": { - "node": ">=16.20.2" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "parser": { + "name": "@axiomcode/parser", + "version": "1.0.0", "dependencies": { - "esbuild": "~0.28.0" + "sax": "^1.4.4", + "tree-sitter": "^0.21.1", + "tree-sitter-groovy": "^0.1.2", + "tree-sitter-java": "^0.21.0", + "tree-sitter-python": "^0.21.0", + "typescript": "^6.0.0", + "yaml": "^2.8.2" }, - "bin": { - "tsx": "dist/cli.mjs" + "devDependencies": { + "@types/node": "^20.10.0", + "@types/sax": "^1.2.7", + "tsc-alias": "^1.8.16", + "tsx": "^4.7.0", + "vitest": "^1.0.4" }, "engines": { "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "parser/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1055,13 +3053,6 @@ "engines": { "node": ">=14.17" } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" } } } diff --git a/package.json b/package.json index 921f5c14c..9c8ef5d70 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { - "name": "@axiomcode/reasoning-java", + "name": "@axiomcode/code-graph", "version": "1.0.0", - "description": "AxiomCode reasoning engine (Java) — renders import templates for the external library modules and the client IR, then runs the pruning/call-chain stages. Invoked by @axiomcode/agent-search for --language=java.", + "description": "AxiomCode code graph: parser (IR extraction) + type-directed reasoning engine + language-neutral graph.sqlite output. bin/axiomcode runs the whole pipeline.", "main": "dist/reason.js", "types": "dist/reason.d.ts", "scripts": { "reason": "node dist/index.js", "clean": "rm -rf dist", "prebuild": "npm run clean", - "build": "tsc && tsc-alias", + "build": "npm run build -w parser && tsc && tsc-alias", "prepare": "npm run build", "typecheck": "tsc --noEmit", - "schema-doc": "tsx src/bundle/cli.ts --print-schema > src/bundle/SCHEMA.md" + "schema-doc": "tsx graph/bundle/cli.ts --print-schema > graph/bundle/SCHEMA.md" }, "engines": { "node": ">=18.0.0" @@ -21,5 +21,9 @@ "tsc-alias": "^1.8.16", "tsx": "^4.7.0", "typescript": "^5.3.3" - } + }, + "workspaces": [ + "parser" + ], + "license": "FSL-1.1-Apache-2.0" } diff --git a/parser/.gitignore b/parser/.gitignore new file mode 100644 index 000000000..70baefdfb --- /dev/null +++ b/parser/.gitignore @@ -0,0 +1,82 @@ +# dependencies +# Both forms. `node_modules/` matches a DIRECTORY only, so a symlink named +# node_modules slips past it -- which is how the tracked absolute-path symlink +# came back in a121b77 after 5bf2db4 had already removed it once. +node_modules +node_modules/ + +# build output +dist/ +*.tsbuildinfo + +# local tooling +.claude/ + +# run output +analysis-results/ +logs/ +*.log + +# secrets +.env +.env.* + +# os +.DS_Store + +# python-work is a LOCAL validation scratch area: oracles, staging corpora and +# repro harnesses. None of it ships. Listing individual subfolders let new ones +# through, so the whole tree is ignored and the checks that matter live as gates +# in src/test instead, where they survive the folder being deleted. +# CPython test files are also PSF-licensed and belong to the interpreter, not to +# us; checking them in would pin them to one machine's stdlib. +python-work/ +.certify-report.txt +.certify-out/ +.jedi-gaps.json +.golden-out/ +.pep695-out/ +.select-out/ +.chain-out/ +.tp-out/ + +# Transient analyzer output. Every gate writes one and none of it is a source +# artifact — regenerated on each run, never read across runs. +.*-out/ +.certify-report.txt +.jedi-gaps.json +# Agent coordination is process, not product. +.agent-coordination/ +# Large corpora live outside the repo and are regenerated from the pinned +# interpreter by torture-corpus.ts / vendor-corpus.ts. +/tmp/py-corpus/ + +# generated python gate corpora and scratch output +src/test/python-gates/xpkg-corpus/ +**/__pycache__/ +*-out/ + +# The Python suite and its expectations ARE the deliverable and must ship. +# +# A blanket ignore here left python-tests.ts, the gates, every fixture and all +# 4,127 frozen facts untracked — a fresh clone had no Python tests at all, and +# the suite would have reported "no goldens" as though the corpus were missing +# rather than never committed. Only genuinely regenerable corpora are excluded: +# +# torture / vendored / mined rebuilt from the pinned interpreter on demand +# _holdout retired +src/test-data/python/torture/ +src/test-data/python/torture-pep695/ +src/test-data/python/torture-parse-gap/ +src/test-data/python/staging/ +src/test-data/python/_holdout/ +src/test/python-gates/xpkg-corpus/ + +# Analyzer output from the test runner; deleted at the end of every run. +.py-test-out/ +.probe/ + +# per-language contributing drafts — superseded by a single CONTRIBUTING.md. +# The dash matters: this ignores CONTRIBUTING-.md and leaves the eventual +# CONTRIBUTING.md tracked. +CONTRIBUTING-*.md diff --git a/parser/README.md b/parser/README.md new file mode 100644 index 000000000..b03a4adaa --- /dev/null +++ b/parser/README.md @@ -0,0 +1,382 @@ +
+ +# AxiomCode Parser + +**A multi-language static analysis front end that compiles source code into a relational intermediate representation.** + +Python +   +TypeScript +   +JavaScript +   +Java +   +XML +   +YAML +   +Gradle + +Full semantic resolution for Python and Java. JavaScript with a binder and JSDoc as its type channel. TypeScript in development. Build-graph and dependency resolution for Gradle. Structural extraction for XML, YAML, Properties and META-INF/services. + +[What it is](#what-this-is)  |  +[The IR](#the-intermediate-representation)  |  +[Languages](#language-support)  |  +[Architecture](#architecture)  |  +[Validation](#validation)  |  +[Usage](#usage) + +
+ +--- + +## What this is + +The parser reads a source tree and emits a set of tab separated relations. Every entity gets a +content addressed primary key, and every relationship between entities is a foreign key between +those relations. The result is a normalised, queryable model of a program that can be loaded into a +database, a Datalog engine, or a graph store without further transformation. + +It is a front end only. It resolves what can be decided from source text and declared types, and it +stops there. Whole program reasoning such as call chain traversal, data flow, and points to analysis +belongs to the downstream engine that consumes these relations. The contract between the two is that +a link the parser emits is correct, and a link it cannot decide is absent rather than guessed. + +There is no standalone CLI. It is consumed as a library through `extractProject()` or as a +subprocess through `dist/index.js`. + +## The intermediate representation + +The IR is relational rather than tree shaped. A conventional AST answers "what is the syntax here". +This IR answers "what entity is this, and what other entity does it refer to". + +Three properties make it useful downstream. + +**Content addressed identity.** Each row carries a primary key derived from the entity's own +identifying content, so the same declaration produces the same key across runs and across files that +reference it. Cross file links are ordinary joins rather than a name resolution pass. Child keys +chain off the parent key rather than being re derived from a qualified name, because re deriving +collides whenever two entities share a name, which happens constantly: two classes with a `run` +method, two comprehensions on one line, two parameters called `value`. + +**Explicit reference edges.** A method call is not merely text. It is a row in the call site relation +carrying a resolved callee key when the target can be determined, plus the receiver shape, the +argument count, and the enclosing scope. The same applies to type references, field accesses, +inheritance edges, and imports. + +**Absence is meaningful.** An unresolved link is empty, never approximate. A downstream query can +therefore distinguish "this call reaches a known target", "this call reaches something outside the +analysed corpus", and "this call cannot be resolved statically". Those three cases require different +handling, and collapsing them destroys information. + +Every row also carries a `serviceVersionLinkHash`, so several snapshots of a codebase can occupy the +same tables and be compared. + +## Language support + +| Language | Maturity | Relations | Parser | What is extracted | +|---|---|---|---|---| +| **Python** | Stable | 19 | tree-sitter-python | Modules, scopes, bindings, types, base classes, methods, parameters, imports, expressions, call sites, type references, fields, decorators and their arguments, blocks, comments, parse gaps, PEP 695 type parameters. | +| **JavaScript** | Stable | 16 | TypeScript compiler API, syntax only | Modules with their module system decided per file, scopes and bindings from a binder that models hoisting and the temporal dead zone, types including constructor functions and prototype members expressed as assignments and calls, methods, parameters, fields, variables, blocks, expressions as a tree, call sites by form, CommonJS and ESM edges wherever they sit, JSDoc types as trees, comments, directives and parse gaps. `.js`, `.mjs`, `.cjs`, `.jsx`. Flow is rejected, not parsed. | +| **Java** | Stable | 17 | tree-sitter-java | Types, methods, fields, annotations and their arguments, expressions, imports, local variables, blocks, comments, enum constants, generics and type parameters. Covers classes, interfaces, enums, records, and nested types. | +| **XML** | Stable | 3 | sax | Element hierarchy with XPath and namespaces, attributes, and value references including property placeholders and SpEL. | +| **Properties** | Stable | 2 | custom | Keys and typed value segments, with continuation and comment handling. | +| **YAML** | Beta | 2 | yaml | Configuration entries with anchor and alias tracking, multi document support. | +| **META-INF/services** | Stable | 2 | custom | Provider-configuration files: the service each file configures, taken from its name, and every implementation class it names, with the file and line. | +| **Gradle** | Beta | 8 | tree-sitter-groovy | Scripts and their role in the build, blocks, declarations, dependency coordinates split into group/artifact/version, version catalogs, value references with resolution, comments, and parse gaps. Groovy and Kotlin DSL. | + +Python and Java are the two languages with full semantic resolution. The configuration formats are +extracted structurally so that configuration values can be correlated with the code that reads them. + +JavaScript is parsed by the TypeScript compiler's syntax layer and never by its type checker: no +`Program` is built, so extraction is hermetic and bounded, and a checkout that does not resolve +cannot produce confident wrong answers. Types come from JSDoc, which the compiler parses into the +tree; scope and binding come from a binder written for the language's hoisting and dead-zone rules, +because in unannotated code that is where `this`, closures and shadowing are decided. The compiler +is the oracle, out of process, never the runtime. + +Gradle sits between the two. There is no type system to consult, so it is not semantically resolved +in the sense Java is, but it is more than structural: the settings file's project graph, `apply +from:` edges, `project(':core')` dependencies and version catalog accessors are all resolved to the +scripts and entries they name. "Which project declares this dependency, at which version, and where +did that version come from" is a join rather than a text search. + +### Gradle is parsed by a grammar that is not its own + +This is the one front end where the grammar does not match the language. `tree-sitter-groovy` parses +Groovy; it is handed Kotlin DSL as well, plus Groovy constructs it has no rule for. An `ERROR` node +in this grammar swallows the rest of the enclosing block, so a single unparseable operator can delete +every dependency below it with no signal at all. + +The extractor therefore rewrites the source before parsing, under two rules. Every rewrite preserves +line count, so a position reported against the rewritten text is a real line in the original file. +And every rewrite that deletes information emits a row in the parse gap relation, against the +original offsets. That second rule is what keeps "this block declares no dependency" from reading +identically to "this block was rewritten and never parsed" — both produce zero rows, and only one of +them is true. + +### Python and Java are modelled differently on purpose + +The two do not share a relation set, because a shared one would be the intersection of what each +language means, and that intersection loses the parts a reasoning engine needs most. + +Java has static types, so a receiver's type is usually written down. Python does not, so the Python +IR carries scope and binding structure that Java has no need for: a scope forest mirroring CPython's +own symbol tables, binding rows recording how each name resolves, and reaching assignment edges +through the expression tree so a downstream pass can type a receiver that was never annotated. + +Conversely Java carries overload signatures and annotation arguments in a form Python has no use +for, since Python has no overloading. + +## Architecture + +The pipeline is the same for every language. Only the parsing layer is language specific. + +``` +detection -> parsing -> extraction -> models -> resolution -> export +``` + +| Layer | Directory | Responsibility | +|---|---|---| +| Detection | `language-detectors/` | Identify which languages and build systems a project uses. | +| Parsing | `parsers//` | Produce a syntax tree. tree-sitter for Java, Python and Gradle; sax for XML; the `yaml` package for YAML; a hand written scanner for Properties and for META-INF/services. | +| Extraction | `parsers//extractors/` | Walk the tree and emit rows. One extractor per relation family, implementing `BaseExtractor`. | +| Models | `analysis-types//` | One class per relation. Builder pattern, content addressed key, CSV serialisation. | +| Resolution | `parsers//*-resolution-linker.ts` | Fill in cross entity foreign keys, first within a file and then across the project. | +| Export | `workflows//` | Orchestrate discovery, run extractors, stream rows to disk. | + +``` +src/ + extract.ts Public API. extractProject() is the single entry point. + index.ts Positional subprocess entry, for the orchestrator contract. + analysis-types/ Relation models, one directory per language. + parsers/ Syntax trees and extraction, one directory per language. + enums/ Categorical column values, one namespace per language. + constants/ CSV file names, entity identifier prefixes. + interfaces/ BaseExtractor and shared contracts. + language-detectors/ Project and build system detection. + schema// schema.json, the frozen relation schema, and its generated Datalog declarations. + test/ Extractor suites, differential gates, corpora. + test-data/ Committed fixtures, per language. + types/ Cross language types only. Language specific types live beside their parser. + utils/ Hashing, position mapping, project scanning, tree-sitter helpers. + workflows/ Per language orchestration. +``` + +Resolution runs in two passes. The single file pass links what is visible in one file. The project +pass runs once every file has been parsed and links the rest, retrying anything that has no key yet +rather than only rows explicitly marked unresolved. A single file pass can only conclude that a cross +module call is external, and treating that as final would lock in the weaker answer from the less +informed pass. + +### Two rules the code depends on + +Both were learned from defects that were silent at small scale and wrong at large scale. + +**Never store state on a tree-sitter node.** The node wrapper cache in `node-tree-sitter` evicts +entries, so a property assigned during one traversal is gone by the next, and parent walks return +objects that were never tagged. Code that does this works on small files and fails on large ones +with no error. Use side tables keyed on `node.id`. + +**Node identity is the byte range, not the start offset.** A start index alone collides for nested +calls such as `super().f()` and for repeated targets in a single statement. Cross stage joins use +`startIndex:endIndex`. + +### Adding a language + +1. Add a parser under `parsers//` returning a syntax tree, plus any dialect detection needed. +2. Define relation models in `analysis-types//`, each with a builder, a content addressed key, + and a CSV header and row. +3. Add enums under `enums//` for every categorical column. A string column that could be an + enum will drift. +4. Write extractors in `parsers//extractors/`, one per relation family. +5. Add a resolution linker if the language has cross file references. +6. Register the workflow in `workflows/` and the file names in `constants/`. +7. Add an external oracle. Not optional in practice: every column placed under one here has found at + least one defect, and columns without one have shipped wrong. + +## Validation + +The parser is not permitted to grade itself. A fixture written alongside a parser encodes the +author's belief about what the parser should do. It catches regressions, but not a wrong premise, +because the fixture and the code share that premise. A fixture in this repository once asserted that +a class reference must remain unresolved, with a comment explaining why. An independent tool resolved +it. The comment was wrong, the fixture had frozen the error, and the suite had been green for weeks. + +Every gate therefore uses something not written for this purpose. + +| Oracle | What it decides | Why it is authoritative | +|---|---|---| +| CPython `symtable` | Scope tree, every binding, ten predicates per binding | It is the structure the interpreter consults. A disagreement is a defect, not a modelling preference. | +| CPython `ast` | Decorators, blocks, expression structure | States presence, order and arguments outright rather than inferring them. | +| CPython bytecode | Every call the compiler emitted, and how each name resolves | The compiler has already decided whether a name is local, global, a cell, or an attribute, and records it in the opcode. | +| CPython `sys.settrace` | Which function a call actually reaches | Ground truth for target correctness, not merely call discovery. | +| JVM bytecode | Java call edges | The same role for the Java front end. | +| TypeScript compiler, with a `Program` | JavaScript call sites, module resolution, JSDoc tag structure, declaration kinds | It is the same compiler the parser reads syntax from, consulted with the type information the parser deliberately does not build. Where it declines — an `any` callee, an uninstalled package — the row is frozen for drift detection and reported separately, never counted as verified. | +| Gradle `projects` | The build's project graph | Gradle is the implementation that decides which projects a settings file creates. On its first real run it found a directory this parser was reporting as a project and Gradle was not. | + +Call graph quality is measured at three increasing strictnesses, because each answers a question the +previous cannot. **Discovery** asks whether a call site was found at all, against the compiled +bytecode, which lists every call whether or not the branch runs. **Target correctness** asks whether +the emitted target is the function the interpreter actually enters, measured by running a closed +world corpus under tracing. **Substrate sufficiency** rebuilds C3 linearisation, override edges, +class hierarchy analysis, rapid type analysis, and argument to parameter binding from the emitted +relations alone, with no parser objects in scope. + +Coverage separates unresolved references into two kinds, because conflating them measures the wrong +thing. **Resolvable** means the name matches an entity the run emitted, and is the parser's gap. +**External** means nothing by that name exists in the corpus, and is excluded. A raw resolved over +total ratio tracks how many dependencies a project has rather than how good the parser is. + +No scores are published here. They change with every commit, and a number copied into a document is +stale the moment it is written. Each gate prints its own results, including what it could not verify. +A gate that reports only successes is not reporting. + +### Known limits + +These are properties of the language, not defects. + +**Duck typing.** A receiver whose type is written nowhere cannot be typed. Emitting a guess would +produce a false edge, which is worse than no edge: a data flow query that follows it gets a confident +wrong answer. + +**Mixin dispatch.** When a class calls an attribute supplied by a sibling base under multiple +inheritance, the attribute genuinely does not exist on that class or its ancestors. It exists only +once a subclass combines them, and several combinations may supply different types. + +**Chains beyond one hop.** Each individual hop is linked. Composing them is a reaching definition +join, which belongs to the engine. + +**Gradle versions held outside the build scripts.** A build may keep its versions in a properties +file and read them as `versions.netty`. Those keys land in the Properties relations, and nothing +joins the two relation sets, so such references stay unresolved. This is the Gradle front end's +largest coverage gap, and it is real rather than a measurement artefact: the reference is resolvable, +just not from Gradle files alone. + +**JavaScript without annotations.** A receiver's type is written nowhere in most JavaScript, so a +call through it is emitted with its receiver named and its resolution left to the engine, exactly as +for Python. `f.call(x)`, `obj[expr]()`, getter invocation, `Proxy` traps and `eval` are reserved +rather than guessed: each is a fact about a value's runtime identity, and a reserved value carries a +zero-row assertion so the day it is switched on is a named change. Flow-annotated files are rejected +with a single module row saying so, because the compiler accepts Flow where it overlaps TypeScript +and mis-parses it where it diverges, silently; a file that is Flow without a pragma is the one case +the detector cannot see, and it is recorded as an open exposure rather than a clean result. + +**Grammar level hazards.** tree-sitter-python applies the PEP 695 soft `type` keyword greedily, so +`type(obj).attr = value` parses cleanly as a type alias and the call node disappears. That statement +is recovered, and the part that cannot be is recorded in the parse gap relation so its absence is +visible. Python 2 files are rejected explicitly for the same reason: `print "x"` parses cleanly under +this grammar and would otherwise emit confident nonsense. + +## Usage + +```bash +npm install # builds via the prepare hook +``` + +As a library: + +```ts +import { extractProject } from '@axiomcode/parser'; + +await extractProject({ + projectPath: '/path/to/repo', + versionLink: '', // stamped onto every row + excludeTests: false, + outputDir: '/path/to/analysis-results', +}); +``` + +As a subprocess: + +```bash +node dist/index.js [outputDir] +``` + +Both call the same core in `src/extract.ts`. + +### Output + +Tab separated files, one per relation. Java relations are named `all-*.csv`, Python relations +`all-python-*.csv`, Gradle relations `all-gradle-*.csv`, and the other configuration formats are +prefixed by format. `skipped-files.csv` and +`skipped-python-files.csv` record every file that was not analysed and why, so a consumer can +distinguish an empty result from an unanalysed one. + +## Development + +```bash +npm run build # tsc && tsc-alias, emits dist/ +npm run typecheck # tsc --noEmit +npm test # vitest +``` + +Extractor suites run three validation layers: per fixture expected values, column arity against the +frozen schema, and referential integrity across every foreign key in the emitted set. + +```bash +npx tsx src/test/java-extractor-tests.ts +npx tsx src/test/python-extractor-tests.ts +npx tsx src/test/javascript-tests.ts +npx tsx src/test/gradle-tests.ts +npx tsx src/test/services-tests.ts +``` + +The JavaScript suite is written so that every check can fail, and each was made to fail on purpose +before it was kept: primary-key uniqueness and foreign-key integrity with the relation list derived +from the output directory, determinism across two runs, an enum-emission audit in which every +reserved value carries a zero-row assertion, a meaning assertion on every populated link column, +and torture scripts — dense files where each line is a known trap for a hand-rolled resolver, with +the language's answer asserted by line. `--corpus ` adds sweeps over a real corpus that are +development-only: absent from the plain run and failing, not passing, when the corpus is missing. + +What ships is what Python and TypeScript ship: fixtures, in-repo gates, committed expectations. The +oracle harness that computes an expectation, and the `bless` command that writes it, live in a +separate repository so that a suite cannot authorise its own expectations. One consequence is real +and is not a defect: a contributor can run every gate and see a red, but cannot add a blessed fixture +without the blessing tool. Java does not have that limitation because it has nothing to bless — its +tests assert properties in code — and for JavaScript the blessed rows are split into those the +compiler independently confirmed and those it declined to decide, which are kept for drift detection +and carry no authority. + +The Gradle suite adds a fourth layer: twenty checks written from the Gradle DSL's documented +semantics rather than from parser output, so they do not move when the parser does. + +The services suite does the same against the `java.util.ServiceLoader` specification of the +provider-configuration file, and pairs each check with the naive implementation it rules out — a +comment-stripping check that a parser which only trims would fail, a nested-name check that one +replacing every `$` would fail. A check no plausible implementation fails proves nothing, so every +rule it pins was verified to break the suite when reverted. + +Differential gates compare emitted rows against the oracles above. + +```bash +npx tsx src/test/python-gates/diff-symtable.ts +npx tsx src/test/python-gates/diff-decorator.ts +npx tsx src/test/python-gates/diff-block.ts + +python3 src/test/python-gates/generate_xpkg.py /tmp/xpkg 60 +python3 src/test/python-gates/trace_calls.py /tmp/xpkg /tmp/xpkg/main.py > /tmp/edges.jsonl +npx tsx src/test/python-gates/diff-runtime-calls.ts /tmp/xpkg /tmp/edges.jsonl + +npx tsx src/test/python-gates/cha-rta-substrate.ts +npx tsx src/test/python-gates/link-coverage.ts + +npx tsx src/test/gradle-gates/corpus-invariants.ts [ …] +npx tsx src/test/gradle-gates/diff-gradle-model.ts +``` + +The Gradle corpus gate asserts nothing about what a given build should contain. It checks properties +true of any correct relational output — every foreign key resolves, no two rows share a key, the +block tree terminates, a script reporting a clean parse really produced no gaps — over repositories +nobody wrote for this parser. It found four defects the fixtures did not, including a key collision +that had merged 88 distinct references into single rows. + +`diff-gradle-model.ts` is the external oracle. It needs a JVM and a resolvable build, and when it +cannot run it exits non-zero saying NOT VERIFIED rather than reporting a pass. + +Each frozen relation schema is `src/schema//schema.json` — the column list per relation, +in order, and the declared enum domains — and `gen_decls.py` beside it generates the Datalog +declarations from it. Column order is the contract: new columns append only, and `gen_decls.py +--check` fails on any drift between the JSON, the generated `.dl` and what the parser emits. diff --git a/parser/package-lock.json b/parser/package-lock.json new file mode 100644 index 000000000..75543cd58 --- /dev/null +++ b/parser/package-lock.json @@ -0,0 +1,3007 @@ +{ + "name": "@axiomcode/parser", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@axiomcode/parser", + "version": "1.0.0", + "dependencies": { + "sax": "^1.4.4", + "tree-sitter": "^0.21.1", + "tree-sitter-groovy": "^0.1.2", + "tree-sitter-java": "^0.21.0", + "tree-sitter-python": "^0.21.0", + "yaml": "^2.8.2" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/sax": "^1.2.7", + "tsc-alias": "^1.8.16", + "tsx": "^4.7.0", + "typescript": "^6.0.0", + "vitest": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-sitter": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", + "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } + }, + "node_modules/tree-sitter-groovy": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tree-sitter-groovy/-/tree-sitter-groovy-0.1.2.tgz", + "integrity": "sha512-4dDUP3XKMwKfDCkm50EmUGPHblyPw0oXTv2ce0VrVYv8erxntsM3WTThC+vZgYT2yh+rYbbDVLsjvfGnHJQ8aw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.3", + "tree-sitter-java": "0.23.4" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-groovy/node_modules/tree-sitter-java": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.23.4.tgz", + "integrity": "sha512-WmqZPzvaHpAcAdJBjwMFwusL+ahp2Liv6T0ASWU7sxGZGceSdP5MpW+2DwLNOiWld39C1WR+9qk99hk4qHK5vw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-java": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.21.0.tgz", + "integrity": "sha512-CKJiTo1uc3SUsgEcaZgufGx8my6dzihy8JR/JsJH40Tj3uSe2/eFLk+0q+fpbosGAyY4YiXJtEoFB2O4bS2yOw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.21.0.tgz", + "integrity": "sha512-IUKx7JcTVbByUx1iHGFS/QsIjx7pqwTMHL9bl/NGyhyyydbfNrpruo2C7W6V4KZrbkkCOlX8QVrCoGOFW5qecg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/tsc-alias": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.9.0.tgz", + "integrity": "sha512-IZrCInovKxVCwXyKhcdr/HaxSYugKT0w4zyktOE/4115Nzo6gbp/NhkaEeyOG0/H7QoTeXtv+3UHffBYta9oWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/parser/package.json b/parser/package.json new file mode 100644 index 000000000..34f594794 --- /dev/null +++ b/parser/package.json @@ -0,0 +1,35 @@ +{ + "name": "@axiomcode/parser", + "version": "1.0.0", + "description": "AxiomCode Parser \u2014 extracts Java/Python/TypeScript/Gradle/XML/YAML/Properties facts from a codebase.", + "main": "dist/extract.js", + "types": "dist/extract.d.ts", + "scripts": { + "clean": "rm -rf dist", + "prebuild": "npm run clean", + "build": "tsc && tsc-alias", + "prepare": "npm run build", + "typecheck": "tsc --noEmit", + "test": "vitest" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "sax": "^1.4.4", + "tree-sitter": "^0.21.1", + "tree-sitter-groovy": "^0.1.2", + "tree-sitter-java": "^0.21.0", + "tree-sitter-python": "^0.21.0", + "typescript": "^6.0.0", + "yaml": "^2.8.2" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/sax": "^1.2.7", + "tsc-alias": "^1.8.16", + "tsx": "^4.7.0", + "vitest": "^1.0.4" + }, + "license": "FSL-1.1-Apache-2.0" +} diff --git a/parser/src/analysis-imports/index.ts b/parser/src/analysis-imports/index.ts new file mode 100644 index 000000000..9b439c24d --- /dev/null +++ b/parser/src/analysis-imports/index.ts @@ -0,0 +1 @@ +export { ImportRegistry } from '@/analysis-imports/java/ImportRegistry'; diff --git a/parser/src/analysis-imports/java/ImportRegistry.ts b/parser/src/analysis-imports/java/ImportRegistry.ts new file mode 100644 index 000000000..14e6fc16c --- /dev/null +++ b/parser/src/analysis-imports/java/ImportRegistry.ts @@ -0,0 +1,253 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { ImportKind } from '@/enums/java/imports'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents an import declaration in Java source code. + * + * ImportRegistry captures all import declarations across the codebase, supporting + * all five Java import types (as of Java 23): + * - Single type imports (import java.util.List;) + * - Type on-demand imports (import java.util.*;) + * - Single static imports (import static java.lang.Math.PI;) + * - Static on-demand imports (import static java.lang.Math.*;) + * - Module imports - Java 23+ (import module java.base;) + * + * ## Examples + * + * ```java + * // SINGLE_TYPE - imports a specific type + * import java.util.List; + * // importedPath: "java.util.List" + * // packageOrTypeName: "java.util" + * // simpleName: "List" + * + * // TYPE_ON_DEMAND - imports all public types from a package + * import java.util.*; + * // importedPath: "java.util.*" + * // packageOrTypeName: "java.util" + * // simpleName: "*" + * + * // SINGLE_STATIC - imports a specific static member + * import static java.lang.Math.PI; + * // importedPath: "java.lang.Math.PI" + * // packageOrTypeName: "java.lang.Math" + * // simpleName: "PI" + * + * // STATIC_ON_DEMAND - imports all static members from a type + * import static java.lang.Math.*; + * // importedPath: "java.lang.Math.*" + * // packageOrTypeName: "java.lang.Math" + * // simpleName: "*" + * + * // MODULE (Java 23+) - imports all public types from all packages exported by a module + * import module java.base; + * // importedPath: "java.base" + * // packageOrTypeName: "" (not applicable for modules) + * // simpleName: "java.base" (module name) + * ``` + * + * ## Field Descriptions + * + * - **importKind**: The type of import (SINGLE_TYPE, TYPE_ON_DEMAND, etc.) + * - **importedPath**: The full import path as written in source + * - **packageOrTypeName**: The package (for type imports) or containing type (for static imports) + * - **simpleName**: The imported type/member name, "*" for on-demand, or module name for module imports + * - **filePath**: The file containing this import + * - **lineNumber**: Line number of the import declaration + * - **isStatic**: true for SINGLE_STATIC and STATIC_ON_DEMAND + * - **isOnDemand**: true for TYPE_ON_DEMAND and STATIC_ON_DEMAND (wildcard imports) + * - **isModuleImport**: true for MODULE imports (Java 23+) + * + * ## CSV Export Format + * + * Column order: + * 1. importKind, importedPath, packageOrTypeName, simpleName + * 2. filePath, lineNumber + * 3. isStatic, isOnDemand, isModuleImport + * 4. serviceVersionLinkHash + * 5. importRegistryUniqueHash + */ +export class ImportRegistry implements EntityIdentifiable { + private importKind: ImportKind; + private importedPath: string; + private packageOrTypeName: string; + private simpleName: string; + private filePath: string; + private lineNumber: number; + private isStatic: boolean; + private isOnDemand: boolean; + private isModuleImport: boolean; + private serviceVersionLinkHash: string; + private importRegistryUniqueHash: string = ''; + + constructor( + importKind: ImportKind, + importedPath: string, + packageOrTypeName: string, + simpleName: string, + filePath: string, + lineNumber: number, + isStatic: boolean, + isOnDemand: boolean, + isModuleImport: boolean, + serviceVersionLinkHash: string + ) { + // Validate required fields + if (!importKind) { + throw new Error('importKind is required'); + } + if (!importedPath || importedPath.trim().length === 0) { + throw new Error('importedPath is required'); + } + if (!filePath || filePath.trim().length === 0) { + throw new Error('filePath is required'); + } + if (lineNumber <= 0) { + throw new Error('lineNumber must be > 0'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + + // Validate consistency between importKind and boolean flags + if (importKind === ImportKind.MODULE && !isModuleImport) { + throw new Error('MODULE import kind must have isModuleImport = true'); + } + if (importKind !== ImportKind.MODULE && isModuleImport) { + throw new Error('Only MODULE import kind can have isModuleImport = true'); + } + + const staticKinds = [ImportKind.SINGLE_STATIC, ImportKind.STATIC_ON_DEMAND]; + if (staticKinds.includes(importKind) && !isStatic) { + throw new Error(`${importKind} must have isStatic = true`); + } + if (!staticKinds.includes(importKind) && isStatic && importKind !== ImportKind.MODULE) { + throw new Error(`${importKind} cannot have isStatic = true`); + } + + const onDemandKinds = [ImportKind.TYPE_ON_DEMAND, ImportKind.STATIC_ON_DEMAND]; + if (onDemandKinds.includes(importKind) && !isOnDemand) { + throw new Error(`${importKind} must have isOnDemand = true`); + } + if (!onDemandKinds.includes(importKind) && isOnDemand && importKind !== ImportKind.MODULE) { + throw new Error(`${importKind} cannot have isOnDemand = true`); + } + + this.importKind = importKind; + this.importedPath = importedPath; + this.packageOrTypeName = packageOrTypeName; + this.simpleName = simpleName; + this.filePath = filePath; + this.lineNumber = lineNumber; + this.isStatic = isStatic; + this.isOnDemand = isOnDemand; + this.isModuleImport = isModuleImport; + this.serviceVersionLinkHash = serviceVersionLinkHash; + + this.generateHash(); + } + + getImportKind(): ImportKind { + return this.importKind; + } + + getImportedPath(): string { + return this.importedPath; + } + + getPackageOrTypeName(): string { + return this.packageOrTypeName; + } + + getSimpleName(): string { + return this.simpleName; + } + + getFilePath(): string { + return this.filePath; + } + + getLineNumber(): number { + return this.lineNumber; + } + + getIsStatic(): boolean { + return this.isStatic; + } + + getIsOnDemand(): boolean { + return this.isOnDemand; + } + + getIsModuleImport(): boolean { + return this.isModuleImport; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getImportRegistryUniqueHash(): string { + return this.importRegistryUniqueHash; + } + + getHash(): string { + return this.importRegistryUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.importKind + + '||' + + this.importedPath + + '||' + + this.lineNumber + + '||' + + this.serviceVersionLinkHash; + + this.importRegistryUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.IMPORT_REGISTRY, + content + ); + } + + getEntryCombined(): string { + return `java_import[kind=${this.importKind}, path=${this.importedPath}, simple=${this.simpleName}, static=${this.isStatic}, onDemand=${this.isOnDemand}, module=${this.isModuleImport}, file=${this.filePath}, line=${this.lineNumber}, hash=${this.importRegistryUniqueHash}]`; + } + + toCsv(): string { + return [ + this.importKind, + EntityUtils.escapeTsv(this.importedPath), + EntityUtils.escapeTsv(this.packageOrTypeName), + EntityUtils.escapeTsv(this.simpleName), + this.filePath, + this.lineNumber.toString(), + this.isStatic.toString(), + this.isOnDemand.toString(), + this.isModuleImport.toString(), + this.serviceVersionLinkHash, + this.importRegistryUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'importKind', + 'importedPath', + 'packageOrTypeName', + 'simpleName', + 'filePath', + 'lineNumber', + 'isStatic', + 'isOnDemand', + 'isModuleImport', + 'serviceVersionLinkHash', + 'importRegistryUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-methods/index.ts b/parser/src/analysis-methods/index.ts new file mode 100644 index 000000000..43621a77b --- /dev/null +++ b/parser/src/analysis-methods/index.ts @@ -0,0 +1,3 @@ +export { MethodRegistry } from '@/analysis-methods/java/MethodRegistry'; +export { MethodParameter } from '@/analysis-methods/java/MethodParameter'; +export { MethodTypeParameter } from '@/analysis-methods/java/MethodTypeParameter'; \ No newline at end of file diff --git a/parser/src/analysis-methods/java/MethodParameter.ts b/parser/src/analysis-methods/java/MethodParameter.ts new file mode 100644 index 000000000..746f20c99 --- /dev/null +++ b/parser/src/analysis-methods/java/MethodParameter.ts @@ -0,0 +1,245 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a method or constructor parameter in Java source code. + * + * This entity captures parameter metadata and links to: + * - The owning method via methodRegistryLinkHash + * - Parameter type details via TypeReference entities (referenced by this parameter's hash) + * + * ## Examples + * + * ```java + * // Regular parameters + * public void process(String name, int count) { } + * // - MethodParameter: name="name", position=0, isFinal=false + * // - MethodParameter: name="count", position=1, isFinal=false + * + * // Final parameter + * public void handle(final User user) { } + * // - MethodParameter: name="user", position=0, isFinal=true + * + * // Varargs parameter + * public void log(String... messages) { } + * // - MethodParameter: name="messages", position=0, isVarArgs=true + * + * // Receiver parameter (explicit 'this') + * public void method(OuterClass.this, String param) { } + * // - MethodParameter: name="this", position=0, isReceiverParameter=true + * // - MethodParameter: name="param", position=1 + * + * // Complex generic parameter with wildcards + * public void process(Map> data) { } + * // - MethodParameter: name="data", position=0 + * // - Multiple TypeReference entities linked to this parameter's hash: + * // - Map (PARAMETERIZED_TYPE, depth=0) + * // - String (CLASS_TYPE, depth=1, parent=Map) + * // - List (PARAMETERIZED_TYPE, depth=1, parent=Map) + * // - ? extends Number (WILDCARD, EXTENDS, depth=2, parent=List) + * // - Number (CLASS_TYPE, depth=3, parent=wildcard) + * ``` + * + * ## CSV Export Format + * + * Columns (tab-separated): + * - paramName + * - position + * - methodRegistryLinkHash (owner method) + * - isFinal + * - isVarArgs + * - isReceiverParameter + * - startLine + * - endLine + * - methodParameterUniqueHash (LAST - for easy viewing) + * + * ## Type Information Strategy + * + * Parameter type details are NOT stored in this entity. Instead: + * - Query TypeReference table with: + * - referenceOwnerKind = 'METHOD_PARAM' + * - typeReferenceOwnerHash = methodParameterUniqueHash + * - context = 'METHOD_PARAM' + * - This leverages existing wildcard/generic extraction logic + * - Supports complex nested types: Map> + */ +export class MethodParameter implements EntityIdentifiable { + private paramName: string; + private position: number; + private methodRegistryLinkHash: string; + private parameterBaseType: string; + private parameterTypeName: string; + private potentialQualifiedName: string | null; + private isAmbiguous: boolean; + private isFinal: boolean; + private isVarArgs: boolean; + private isReceiverParameter: boolean; + private startLine: number; + private endLine: number; + private methodParameterUniqueHash: string = ''; + + constructor( + paramName: string, + position: number, + methodRegistryLinkHash: string, + parameterBaseType: string, + parameterTypeName: string, + potentialQualifiedName: string | null, + isAmbiguous: boolean, + isFinal: boolean, + isVarArgs: boolean, + isReceiverParameter: boolean, + startLine: number, + endLine: number + ) { + // Validation + if (!paramName || paramName.trim().length === 0) { + throw new Error('paramName is required'); + } + if (position < 0) { + throw new Error('position must be >= 0'); + } + if (!methodRegistryLinkHash || methodRegistryLinkHash.trim().length === 0) { + throw new Error('methodRegistryLinkHash is required'); + } + if (!parameterBaseType || parameterBaseType.trim().length === 0) { + throw new Error('parameterBaseType is required'); + } + if (!parameterTypeName || parameterTypeName.trim().length === 0) { + throw new Error('parameterTypeName is required'); + } + if (startLine <= 0) { + throw new Error('startLine must be > 0'); + } + if (endLine <= 0) { + throw new Error('endLine must be > 0'); + } + if (endLine < startLine) { + throw new Error('endLine must be >= startLine'); + } + + this.paramName = paramName; + this.position = position; + this.methodRegistryLinkHash = methodRegistryLinkHash; + this.parameterBaseType = parameterBaseType; + this.parameterTypeName = parameterTypeName; + this.potentialQualifiedName = potentialQualifiedName; + this.isAmbiguous = isAmbiguous; + this.isFinal = isFinal; + this.isVarArgs = isVarArgs; + this.isReceiverParameter = isReceiverParameter; + this.startLine = startLine; + this.endLine = endLine; + + this.generateHash(); + } + + getParamName(): string { + return this.paramName; + } + + getPosition(): number { + return this.position; + } + + getMethodRegistryLinkHash(): string { + return this.methodRegistryLinkHash; + } + + getParameterBaseType(): string { + return this.parameterBaseType; + } + + getParameterTypeName(): string { + return this.parameterTypeName; + } + + getPotentialQualifiedName(): string | null { + return this.potentialQualifiedName; + } + + getIsAmbiguous(): boolean { + return this.isAmbiguous; + } + + getIsFinal(): boolean { + return this.isFinal; + } + + getIsVarArgs(): boolean { + return this.isVarArgs; + } + + getIsReceiverParameter(): boolean { + return this.isReceiverParameter; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getHash(): string { + return this.methodParameterUniqueHash; + } + + getEntityType(): string { + return ENTITY_IDENTIFIERS.METHOD_PARAMETER; + } + + generateHash(): void { + const components = [ + this.methodRegistryLinkHash, + this.position.toString(), + this.paramName, + ].join('|'); + this.methodParameterUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.METHOD_PARAMETER, + components + ); + } + + getEntryCombined(): string { + return `java_method_parameter[name=${this.paramName}, position=${this.position}, method=${this.methodRegistryLinkHash}, hash=${this.methodParameterUniqueHash}]`; + } + + toCsv(): string { + return [ + this.paramName, + this.position.toString(), + this.methodRegistryLinkHash, + EntityUtils.escapeTsv(this.parameterBaseType), + EntityUtils.escapeTsv(this.parameterTypeName), + EntityUtils.escapeTsv(this.potentialQualifiedName ?? ''), + this.isAmbiguous.toString(), + this.isFinal.toString(), + this.isVarArgs.toString(), + this.isReceiverParameter.toString(), + this.startLine.toString(), + this.endLine.toString(), + this.methodParameterUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'paramName', + 'position', + 'methodRegistryLinkHash', + 'parameterBaseType', + 'parameterTypeName', + 'potentialQualifiedName', + 'isAmbiguous', + 'isFinal', + 'isVarArgs', + 'isReceiverParameter', + 'startLine', + 'endLine', + 'methodParameterUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-methods/java/MethodRegistry.ts b/parser/src/analysis-methods/java/MethodRegistry.ts new file mode 100644 index 000000000..c9bbff277 --- /dev/null +++ b/parser/src/analysis-methods/java/MethodRegistry.ts @@ -0,0 +1,424 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { MethodAccess, MethodKind } from '@/enums/java/methods'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a method declaration in Java source code. + * + * MethodRegistry captures method declarations across the codebase, including: + * - Regular instance and static methods + * - Abstract methods (in abstract classes and interfaces) + * - Constructors (regular and compact for records) + * - Default interface methods (Java 8+) + * - Static and instance initializers + * - Annotation elements (@interface methods) + * + * ## Examples + * + * ```java + * public class UserService { + * // INSTANCE_METHOD + * public User getUserById(String id) { ... } + * + * // STATIC_METHOD + * public static UserService getInstance() { ... } + * + * // CONSTRUCTOR + * public UserService(UserRepository repo) { ... } + * } + * + * public interface PaymentGateway { + * // ABSTRACT_METHOD + * void process(Payment p); + * + * // DEFAULT_METHOD + * default void log(String msg) { ... } + * + * // STATIC_METHOD + * static void validate(Payment p) { ... } + * } + * + * public record Point(int x, int y) { + * // COMPACT_CONSTRUCTOR + * public Point { ... } + * } + * + * // STATIC_INITIALIZER + * static { + * loadConfig(); + * } + * + * // INSTANCE_INITIALIZER + * { + * this.id = UUID.randomUUID(); + * } + * ``` + * + * ## Return Type Conventions + * + * - **Regular methods**: returnTypeName contains the actual return type + * - `public User getUser()` → returnTypeName = "User", signature = "getUser():User" + * - `public void process()` → returnTypeName = "void", signature = "process():void" + * - `public List getItems()` → returnTypeName = "List", signature = "getItems():List" + * + * - **Constructors** (CONSTRUCTOR, COMPACT_CONSTRUCTOR): returnTypeName = `undefined` + * - Constructors have no return type in Java + * - The `returnTypeName` **field** is undefined/not set + * - The `signature` **string** still uses `:void` as a syntactic placeholder + * - Example: `UserService(String)` → returnTypeName = undefined, signature = "UserService(String):void" + * - This allows consistent signature parsing across all method types + * + * - **Initializers** (STATIC_INITIALIZER, INSTANCE_INITIALIZER): returnTypeName = `"void"` + * - Both the field AND signature use "void" + * - Static: name = "", returnTypeName = "void", signature = "():void" + * - Instance: name = "", returnTypeName = "void", signature = "():void" + * + * - **Annotation Elements** (ANNOTATION_ELEMENT): returnTypeName = **required** + * - Annotation elements ALWAYS have a return type (Java language requirement) + * - Valid types: primitives, String, Class, enums, annotations, or arrays thereof + * - Example: `String value()` → returnTypeName = "String", signature = "value():String" + * - Example: `int timeout()` → returnTypeName = "int", signature = "timeout():int" + * - Cannot be void or missing + * + * ## Signature vs DetailedSignature + * + * - **signature**: Canonical form with generics stripped, varargs normalized + * - `process(List,String[]):Map` + * - Used for method identity and overload resolution + * + * - **detailedSignature**: Preserves all type information as written + * - `process(List,String...):Map` + * - Used for display and exact source matching + * + * ## Field Links + * + * - **typeRegistryLinkHash**: Always links to the enclosing type + * - **methodRegistryUniqueHash**: Unique identifier for this method (last column in CSV) + * - **serviceVersionLinkHash**: Links to the service version (stored internally, not in CSV) + * + * ## CSV Export Format + * + * The CSV output includes all method metadata in tab-separated format. + * Column order optimized for readability: + * 1. name, signature, detailedSignature, qualifiedName + * 2. filePath, startLine, endLine + * 3. typeRegistryLinkHash (owner type) + * 4. ownerTypeName, ownerQualifiedName + * 5. methodAccess, methodModifier, returnTypeName + * 6. isVarArgs, hasReceiverParameter, defaultValueExpression + * 7. methodKind + * 8. parameterCount, hasTypeParameters, throwsExceptions + * 9. methodRegistryUniqueHash (LAST - for easy viewing) + * + */ +export class MethodRegistry implements EntityIdentifiable { + private name: string; + private signature: string; + private detailedSignature: string; + private qualifiedName: string; + private filePath: string; + private startLine: number; + private endLine: number; + private typeRegistryLinkHash: string; + private ownerTypeName: string; + private ownerQualifiedName: string; + private methodAccess: MethodAccess; + private methodModifier?: string; + private returnTypeName?: string; + private isVarArgs: boolean; + private hasReceiverParameter: boolean; + private defaultValueExpression?: string; + private methodKind: MethodKind; + private serviceVersionLinkHash: string; + private methodRegistryUniqueHash: string = ''; + private parameterCount: number; + private hasTypeParameters: boolean; + private throwsExceptions: boolean; + private enclosingMemberLinkHash?: string; + + constructor( + name: string, + signature: string, + detailedSignature: string, + qualifiedName: string, + filePath: string, + startLine: number, + endLine: number, + typeRegistryLinkHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + methodAccess: MethodAccess, + methodKind: MethodKind, + serviceVersionLinkHash: string, + parameterCount: number, + isVarArgs: boolean, + hasReceiverParameter: boolean, + hasTypeParameters: boolean, + throwsExceptions: boolean, + methodModifier?: string, + returnTypeName?: string, + defaultValueExpression?: string, + enclosingMemberLinkHash?: string + ) { + // Validate required fields + if (!name || name.trim().length === 0) { + throw new Error('name is required'); + } + if (!signature || signature.trim().length === 0) { + throw new Error('signature is required'); + } + if (!methodKind) { + throw new Error('methodKind is required'); + } + if (!methodAccess) { + throw new Error('methodAccess is required'); + } + if (!typeRegistryLinkHash || typeRegistryLinkHash.trim().length === 0) { + throw new Error('typeRegistryLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (!filePath || filePath.trim().length === 0) { + throw new Error('filePath is required'); + } + if (startLine <= 0) { + throw new Error('startLine must be > 0'); + } + if (endLine <= 0) { + throw new Error('endLine must be > 0'); + } + if (endLine < startLine) { + throw new Error('endLine must be >= startLine'); + } + if (parameterCount < 0) { + throw new Error('parameterCount must be >= 0'); + } + + // Validate return type conventions + const constructorKinds = [MethodKind.CONSTRUCTOR, MethodKind.COMPACT_CONSTRUCTOR]; + if (constructorKinds.includes(methodKind) && returnTypeName) { + throw new Error(`${methodKind} should not have a returnTypeName (must be undefined)`); + } + + const initializerKinds = [MethodKind.STATIC_INITIALIZER, MethodKind.INSTANCE_INITIALIZER]; + if (initializerKinds.includes(methodKind)) { + if (!returnTypeName) { + throw new Error(`${methodKind} must have returnTypeName = "void"`); + } + if (returnTypeName !== 'void') { + throw new Error(`${methodKind} must have returnTypeName = "void", got "${returnTypeName}"`); + } + } + + if (methodKind === MethodKind.ANNOTATION_ELEMENT) { + if (!returnTypeName) { + throw new Error('ANNOTATION_ELEMENT must have a returnTypeName'); + } + if (returnTypeName === 'void') { + throw new Error('ANNOTATION_ELEMENT cannot have returnTypeName = "void"'); + } + } + + this.name = name; + this.signature = signature; + this.detailedSignature = detailedSignature; + this.qualifiedName = qualifiedName; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + this.typeRegistryLinkHash = typeRegistryLinkHash; + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + this.methodAccess = methodAccess; + this.methodModifier = methodModifier; + this.returnTypeName = returnTypeName; + this.isVarArgs = isVarArgs; + this.hasReceiverParameter = hasReceiverParameter; + this.defaultValueExpression = defaultValueExpression; + this.methodKind = methodKind; + this.serviceVersionLinkHash = serviceVersionLinkHash; + this.parameterCount = parameterCount; + this.hasTypeParameters = hasTypeParameters; + this.throwsExceptions = throwsExceptions; + this.enclosingMemberLinkHash = enclosingMemberLinkHash; + + this.generateHash(); + } + + getName(): string { + return this.name; + } + + getSignature(): string { + return this.signature; + } + + getDetailedSignature(): string { + return this.detailedSignature; + } + + getQualifiedName(): string { + return this.qualifiedName; + } + + getFilePath(): string { + return this.filePath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getTypeRegistryLinkHash(): string { + return this.typeRegistryLinkHash; + } + + getOwnerTypeName(): string { + return this.ownerTypeName; + } + + getOwnerQualifiedName(): string { + return this.ownerQualifiedName; + } + + getMethodAccess(): MethodAccess { + return this.methodAccess; + } + + getMethodModifier(): string | undefined { + return this.methodModifier; + } + + getReturnTypeName(): string | undefined { + return this.returnTypeName; + } + + getIsVarArgs(): boolean { + return this.isVarArgs; + } + + getHasReceiverParameter(): boolean { + return this.hasReceiverParameter; + } + + getDefaultValueExpression(): string | undefined { + return this.defaultValueExpression; + } + + getMethodKind(): MethodKind { + return this.methodKind; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getMethodRegistryUniqueHash(): string { + return this.methodRegistryUniqueHash; + } + + getHash(): string { + return this.methodRegistryUniqueHash; + } + + getParameterCount(): number { + return this.parameterCount; + } + + getHasTypeParameters(): boolean { + return this.hasTypeParameters; + } + + getThrowsExceptions(): boolean { + return this.throwsExceptions; + } + + getEnclosingMemberLinkHash(): string | undefined { + return this.enclosingMemberLinkHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.typeRegistryLinkHash + + '||' + + this.methodKind + + '||' + + this.signature + + '||' + + this.startLine + + '||' + + this.endLine; + + this.methodRegistryUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.METHOD_REGISTRY, + content + ); + } + + getEntryCombined(): string { + return `java_method[name=${this.name}, signature=${this.signature}, kind=${this.methodKind}, access=${this.methodAccess}, type=${this.ownerTypeName}, lines ${this.startLine}-${this.endLine}, hash=${this.methodRegistryUniqueHash}]`; + } + + toCsv(): string { + return [ + this.name, + EntityUtils.escapeTsv(this.signature), + EntityUtils.escapeTsv(this.detailedSignature), + EntityUtils.escapeTsv(this.qualifiedName), + this.filePath, + this.startLine.toString(), + this.endLine.toString(), + this.typeRegistryLinkHash, + this.ownerTypeName, + this.ownerQualifiedName, + this.methodAccess, + this.methodModifier || '', + EntityUtils.escapeTsv(this.returnTypeName || ''), + this.isVarArgs.toString(), + this.hasReceiverParameter.toString(), + EntityUtils.escapeTsv(this.defaultValueExpression || ''), + this.methodKind, + this.parameterCount.toString(), + this.hasTypeParameters.toString(), + this.throwsExceptions.toString(), + this.enclosingMemberLinkHash || '', + this.methodRegistryUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'signature', + 'detailedSignature', + 'qualifiedName', + 'filePath', + 'startLine', + 'endLine', + 'typeRegistryLinkHash', + 'ownerTypeName', + 'ownerQualifiedName', + 'methodAccess', + 'methodModifier', + 'returnTypeName', + 'isVarArgs', + 'hasReceiverParameter', + 'defaultValueExpression', + 'methodKind', + 'parameterCount', + 'hasTypeParameters', + 'throwsExceptions', + 'enclosingMemberLinkHash', + 'methodRegistryUniqueHash', + ].join('\t'); + } + +} diff --git a/parser/src/analysis-methods/java/MethodTypeParameter.ts b/parser/src/analysis-methods/java/MethodTypeParameter.ts new file mode 100644 index 000000000..dd15151ea --- /dev/null +++ b/parser/src/analysis-methods/java/MethodTypeParameter.ts @@ -0,0 +1,232 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a type parameter (generic parameter) declared on a method in Java. + * + * Method type parameters appear in the method signature before the return type. + * They can have bounds that constrain which types can be used as type arguments. + * + * ## Examples + * + * ```java + * // Single unbounded type parameter + * public T process(T item) { ... } + * // MethodTypeParameter: name=T, position=0, hasBounds=false + * + * // Single bounded type parameter + * public static double totalArea(List shapes) { ... } + * // MethodTypeParameter: name=T, position=0, hasBounds=true + * // Bounds tracked in TypeReference with context=METHOD_TYPE_PARAM_BOUND + * + * // Multiple type parameters with bounds + * public > void compare(T t, U u) { ... } + * // MethodTypeParameter 1: name=T, position=0, hasBounds=true + * // MethodTypeParameter 2: name=U, position=1, hasBounds=true + * + * // Multiple bounds (intersection types) + * public void execute(T task) { ... } + * // MethodTypeParameter: name=T, position=0, hasBounds=true + * // Multiple TypeReferences for Runnable and Closeable + * + * // Recursive bounds + * public > T max(T a, T b) { ... } + * // MethodTypeParameter: name=T, position=0, hasBounds=true + * ``` + * + * ## Scope and Resolution + * + * Method type parameters: + * - Shadow class-level type parameters with the same name + * - Are only in scope within the method they're declared on + * - Can be referenced in: return type, parameter types, throws clauses, method body + * + * Example of shadowing: + * ```java + * class Container { + * // Method T shadows class T + * public T process(T item) { + * // T here refers to method's T, not Container's T + * } + * } + * ``` + * + * ## Bounds + * + * Type parameter bounds are tracked separately as TypeReference entities: + * - Single bound: `` → 1 TypeReference with kind=CLASS + * - Multiple bounds: `` → 2 TypeReferences + * - Parameterized bound: `>` → Nested TypeReferences + * + * The `hasBounds` flag indicates whether bounds exist, and the bounds themselves + * are linked via `methodTypeParameterLinkHash` in the TypeReference table. + * + * ## CSV Export Format + * + * Column order: + * 1. paramName - The name of the type parameter (e.g., "T", "E", "K") + * 2. position - Zero-based position in method's type parameter list + * 3. ownerMethodName - Simple name of the method + * 4. ownerMethodSignature - Full signature of the method + * 5. ownerQualifiedMethodName - Fully qualified method name + * 6. filePath - Source file path + * 7. startLine - Line where type parameter is declared + * 8. methodRegistryLinkHash - Links to owner method in all-methods.csv + * 9. hasBounds - Whether this type parameter has any bounds + * 10. methodTypeParameterUniqueHash - Unique identifier (LAST column) + */ +export class MethodTypeParameter implements EntityIdentifiable { + private paramName: string; + private position: number; + private ownerMethodName: string; + private ownerMethodSignature: string; + private ownerQualifiedMethodName: string; + private filePath: string; + private startLine: number; + private methodRegistryLinkHash: string; + private hasBounds: boolean; + private methodTypeParameterUniqueHash: string = ''; + + constructor( + paramName: string, + position: number, + ownerMethodName: string, + ownerMethodSignature: string, + ownerQualifiedMethodName: string, + filePath: string, + startLine: number, + methodRegistryLinkHash: string, + hasBounds: boolean + ) { + // Validate required fields + if (!paramName || paramName.trim().length === 0) { + throw new Error('paramName is required'); + } + if (position < 0) { + throw new Error('position must be >= 0'); + } + if (!ownerMethodName || ownerMethodName.trim().length === 0) { + throw new Error('ownerMethodName is required'); + } + if (!ownerMethodSignature || ownerMethodSignature.trim().length === 0) { + throw new Error('ownerMethodSignature is required'); + } + if (!ownerQualifiedMethodName || ownerQualifiedMethodName.trim().length === 0) { + throw new Error('ownerQualifiedMethodName is required'); + } + if (!filePath || filePath.trim().length === 0) { + throw new Error('filePath is required'); + } + if (startLine <= 0) { + throw new Error('startLine must be > 0'); + } + if (!methodRegistryLinkHash || methodRegistryLinkHash.trim().length === 0) { + throw new Error('methodRegistryLinkHash is required'); + } + + this.paramName = paramName; + this.position = position; + this.ownerMethodName = ownerMethodName; + this.ownerMethodSignature = ownerMethodSignature; + this.ownerQualifiedMethodName = ownerQualifiedMethodName; + this.filePath = filePath; + this.startLine = startLine; + this.methodRegistryLinkHash = methodRegistryLinkHash; + this.hasBounds = hasBounds; + + this.generateHash(); + } + + getParamName(): string { + return this.paramName; + } + + getPosition(): number { + return this.position; + } + + getOwnerMethodName(): string { + return this.ownerMethodName; + } + + getOwnerMethodSignature(): string { + return this.ownerMethodSignature; + } + + getOwnerQualifiedMethodName(): string { + return this.ownerQualifiedMethodName; + } + + getFilePath(): string { + return this.filePath; + } + + getStartLine(): number { + return this.startLine; + } + + getMethodRegistryLinkHash(): string { + return this.methodRegistryLinkHash; + } + + getHasBounds(): boolean { + return this.hasBounds; + } + + getMethodTypeParameterUniqueHash(): string { + return this.methodTypeParameterUniqueHash; + } + + getHash(): string { + return this.methodTypeParameterUniqueHash; + } + + generateHash(): void { + const content = `${this.paramName}||${this.position}||${this.ownerQualifiedMethodName}||${this.methodRegistryLinkHash}`; + this.methodTypeParameterUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.METHOD_TYPE_PARAMETER, + content + ); + } + + getEntryCombined(): string { + return `java_method_type_parameter[name=${this.paramName}, position=${this.position}, method=${this.ownerQualifiedMethodName}, hasBounds=${this.hasBounds}, hash=${this.methodTypeParameterUniqueHash}]`; + } + + /** + * Converts MethodTypeParameter to CSV row format + */ + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.paramName), + this.position.toString(), + EntityUtils.escapeTsv(this.ownerMethodName), + EntityUtils.escapeTsv(this.ownerMethodSignature), + EntityUtils.escapeTsv(this.ownerQualifiedMethodName), + this.filePath, + this.startLine.toString(), + this.methodRegistryLinkHash, + this.hasBounds.toString(), + this.methodTypeParameterUniqueHash, + ].join('\t'); + } + + /** + * Returns CSV header for MethodTypeParameter export + */ + getCsvHeader(): string { + return [ + 'paramName', + 'position', + 'ownerMethodName', + 'ownerMethodSignature', + 'ownerQualifiedMethodName', + 'filePath', + 'startLine', + 'methodRegistryLinkHash', + 'hasBounds', + 'methodTypeParameterUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/gradle/GradleBlock.ts b/parser/src/analysis-types/gradle/GradleBlock.ts new file mode 100644 index 000000000..b59146d81 --- /dev/null +++ b/parser/src/analysis-types/gradle/GradleBlock.ts @@ -0,0 +1,278 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; +import { GradleBlockType } from '@/enums/gradle/blocks/GradleBlockType'; +import { GradleDSLDialect } from '@/enums/gradle/files/GradleDSLDialect'; + +/** + * Represents a single block/closure in a Gradle build file. + * + * Blocks form a tree via `parentBlockHash`. Every nested `{ }` in a Gradle + * file — DSL blocks, control flow, closures — gets a row. + * + * ## CSV Export Format + * + * Column order: + * 1. blockType, blockName, expression, depth, childBlockCount, declarationCount + * 2. dslDialect + * 3. parentBlockHash, tryStatementHash, caughtExceptionTypes, scriptHash + * 4. filePath, baseMservPath, startLine, endLine, startColumn, endColumn + * 5. serviceVersionLinkHash + * 6. gradleBlockUniqueHash (LAST) + */ +export class GradleBlock implements EntityIdentifiable { + private blockType: GradleBlockType; + private blockName: string; + private expression: string; + private depth: number; + private childBlockCount: number; + private declarationCount: number; + private dslDialect: GradleDSLDialect; + private parentBlockHash: string; + private tryStatementHash: string; + private caughtExceptionTypes: string; + private scriptHash: string; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private startColumn: number; + private endColumn: number; + private serviceVersionLinkHash: string; + private gradleBlockUniqueHash: string = ''; + + private constructor(builder: GradleBlockBuilder) { + this.blockType = builder.blockType; + this.blockName = builder.blockName; + this.expression = builder.expression; + this.depth = builder.depth; + this.childBlockCount = builder.childBlockCount; + this.declarationCount = builder.declarationCount; + this.dslDialect = builder.dslDialect; + this.parentBlockHash = builder.parentBlockHash; + this.tryStatementHash = builder.tryStatementHash; + this.caughtExceptionTypes = builder.caughtExceptionTypes; + this.scriptHash = builder.scriptHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startColumn = builder.startColumn; + this.endColumn = builder.endColumn; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + blockType: GradleBlockType, + depth: number, + dslDialect: GradleDSLDialect, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ): GradleBlockBuilder { + return new GradleBlockBuilder( + blockType, depth, dslDialect, scriptHash, filePath, baseMservPath, + startLine, endLine, startColumn, endColumn, serviceVersionLinkHash + ); + } + + getBlockType(): GradleBlockType { return this.blockType; } + getBlockName(): string { return this.blockName; } + getExpression(): string { return this.expression; } + getDepth(): number { return this.depth; } + getChildBlockCount(): number { return this.childBlockCount; } + getDeclarationCount(): number { return this.declarationCount; } + getDslDialect(): GradleDSLDialect { return this.dslDialect; } + getParentBlockHash(): string { return this.parentBlockHash; } + getTryStatementHash(): string { return this.tryStatementHash; } + getCaughtExceptionTypes(): string { return this.caughtExceptionTypes; } + getScriptHash(): string { return this.scriptHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getStartColumn(): number { return this.startColumn; } + getEndColumn(): number { return this.endColumn; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + /** + * Both counts are known only after the whole subtree has been walked, so + * they are written back rather than passed to the builder. Neither re-keys + * the row: a block that turned out to contain three declarations is the same + * block it was before they were counted, and re-hashing would orphan every + * child that already chained off the old key. + */ + setChildBlockCount(count: number): void { this.childBlockCount = count; } + setDeclarationCount(count: number): void { this.declarationCount = count; } + + getHash(): string { + return this.gradleBlockUniqueHash; + } + + generateHash(): void { + // Chains off the parent block and the script, and uses the full byte range + // rather than the start offset. Both matter here. Every subproject has a + // `dependencies` block at some line, and `a.each { b.each { } }` puts two + // closures on one line whose start positions differ but whose type and + // name do not. A key derived from type + name + start alone collides in + // both cases, and a collision in the block relation silently reparents + // every declaration underneath it. + const content = + this.scriptHash + + '||' + this.parentBlockHash + + '||' + this.blockType + + '||' + this.blockName + + '||' + this.startLine + ':' + this.startColumn + + '||' + this.endLine + ':' + this.endColumn; + + this.gradleBlockUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.GRADLE_BLOCK, + content + ); + } + + getEntryCombined(): string { + return `gradle_block[type=${this.blockType}, name=${this.blockName}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + this.blockType, + this.blockName, + EntityUtils.escapeTsv(this.expression), + this.depth.toString(), + this.childBlockCount.toString(), + this.declarationCount.toString(), + this.dslDialect, + this.parentBlockHash, + this.tryStatementHash, + this.caughtExceptionTypes, + this.scriptHash, + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.startColumn.toString(), + this.endColumn.toString(), + this.serviceVersionLinkHash, + this.gradleBlockUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'blockType', + 'blockName', + 'expression', + 'depth', + 'childBlockCount', + 'declarationCount', + 'dslDialect', + 'parentBlockHash', + 'tryStatementHash', + 'caughtExceptionTypes', + 'scriptHash', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'startColumn', + 'endColumn', + 'serviceVersionLinkHash', + 'gradleBlockUniqueHash', + ].join('\t'); + } +} + +class GradleBlockBuilder { + blockType: GradleBlockType; + blockName: string = ''; + expression: string = ''; + depth: number; + childBlockCount: number = 0; + declarationCount: number = 0; + dslDialect: GradleDSLDialect; + parentBlockHash: string = ''; + tryStatementHash: string = ''; + caughtExceptionTypes: string = ''; + scriptHash: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + serviceVersionLinkHash: string; + + constructor( + blockType: GradleBlockType, + depth: number, + dslDialect: GradleDSLDialect, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ) { + this.blockType = blockType; + this.depth = depth; + this.dslDialect = dslDialect; + this.scriptHash = scriptHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.startColumn = startColumn; + this.endColumn = endColumn; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withBlockName(blockName: string): GradleBlockBuilder { + this.blockName = blockName; + return this; + } + + withExpression(expression: string): GradleBlockBuilder { + this.expression = expression; + return this; + } + + withChildBlockCount(count: number): GradleBlockBuilder { + this.childBlockCount = count; + return this; + } + + withDeclarationCount(count: number): GradleBlockBuilder { + this.declarationCount = count; + return this; + } + + withParentBlockHash(hash: string): GradleBlockBuilder { + this.parentBlockHash = hash; + return this; + } + + withTryStatementHash(hash: string): GradleBlockBuilder { + this.tryStatementHash = hash; + return this; + } + + withCaughtExceptionTypes(types: string): GradleBlockBuilder { + this.caughtExceptionTypes = types; + return this; + } + + build(): GradleBlock { + return new (GradleBlock as any)(this); + } +} diff --git a/parser/src/analysis-types/gradle/GradleCatalogEntry.ts b/parser/src/analysis-types/gradle/GradleCatalogEntry.ts new file mode 100644 index 000000000..ea9b9bef9 --- /dev/null +++ b/parser/src/analysis-types/gradle/GradleCatalogEntry.ts @@ -0,0 +1,274 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { GradleCatalogEntryKind } from '@/enums/gradle/catalog/GradleCatalogEntryKind'; +import { GradleCatalogNotation } from '@/enums/gradle/catalog/GradleCatalogNotation'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One entry from a Gradle version catalog (`gradle/libs.versions.toml`). + * + * This relation is what makes a modern Gradle build legible. In a build that + * uses a catalog, `implementation libs.spring.boot.starter.web` is the entire + * dependency declaration — the group, the artifact and the version are all in + * the TOML file, and the build script names only an alias. Parsing the build + * script alone yields a dependency on a string with no coordinate in it. + * + * `accessorPath` is the join column. Gradle derives an accessor from an alias + * by replacing `-` and `_` with `.`, so the alias `spring-boot-starter-web` + * is reached as `libs.spring.boot.starter.web`. Storing the derived form means + * a value reference found in a build file joins here directly rather than + * through a normalisation step every consumer would have to reimplement. + * + * `versionRef` and `resolvedVersion` are kept apart on purpose. `versionRef` + * is what the entry literally says; `resolvedVersion` is filled in only when + * that ref was found in the same catalog's `[versions]` table. An entry whose + * ref points at nothing keeps an empty `resolvedVersion` rather than echoing + * the ref back as though it were a version. + * + * ## CSV Export Format + * + * Column order: + * 1. entryKind, alias, accessorPath, notation + * 2. group, artifact, pluginId, version, versionRef, resolvedVersion, richVersionConstraint + * 3. bundleMembers, catalogName + * 4. versionEntryHash, scriptHash + * 5. filePath, baseMservPath, startLine, endLine + * 6. serviceVersionLinkHash + * 7. gradleCatalogEntryUniqueHash (LAST) + */ +export class GradleCatalogEntry implements EntityIdentifiable { + private entryKind: GradleCatalogEntryKind; + private alias: string; + private accessorPath: string; + private notation: GradleCatalogNotation; + private group: string; + private artifact: string; + private pluginId: string; + private version: string; + private versionRef: string; + private resolvedVersion: string; + private richVersionConstraint: string; + private bundleMembers: string; + private catalogName: string; + private versionEntryHash: string; + private scriptHash: string; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private serviceVersionLinkHash: string; + private gradleCatalogEntryUniqueHash: string = ''; + + private constructor(builder: GradleCatalogEntryBuilder) { + this.entryKind = builder.entryKind; + this.alias = builder.alias; + this.accessorPath = builder.accessorPath; + this.notation = builder.notation; + this.group = builder.group; + this.artifact = builder.artifact; + this.pluginId = builder.pluginId; + this.version = builder.version; + this.versionRef = builder.versionRef; + this.resolvedVersion = builder.resolvedVersion; + this.richVersionConstraint = builder.richVersionConstraint; + this.bundleMembers = builder.bundleMembers; + this.catalogName = builder.catalogName; + this.versionEntryHash = builder.versionEntryHash; + this.scriptHash = builder.scriptHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + entryKind: GradleCatalogEntryKind, + alias: string, + notation: GradleCatalogNotation, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ): GradleCatalogEntryBuilder { + return new GradleCatalogEntryBuilder( + entryKind, alias, notation, scriptHash, + filePath, baseMservPath, startLine, endLine, serviceVersionLinkHash + ); + } + + /** + * Gradle's alias-to-accessor rule: `-` and `_` both become `.`. + * `spring-boot-starter-web` is reached as `libs.spring.boot.starter.web`. + */ + static toAccessorPath(alias: string): string { + return alias.replace(/[-_]/g, '.'); + } + + getEntryKind(): GradleCatalogEntryKind { return this.entryKind; } + getAlias(): string { return this.alias; } + getAccessorPath(): string { return this.accessorPath; } + getNotation(): GradleCatalogNotation { return this.notation; } + getGroup(): string { return this.group; } + getArtifact(): string { return this.artifact; } + getPluginId(): string { return this.pluginId; } + getVersion(): string { return this.version; } + getVersionRef(): string { return this.versionRef; } + getResolvedVersion(): string { return this.resolvedVersion; } + getRichVersionConstraint(): string { return this.richVersionConstraint; } + getBundleMembers(): string { return this.bundleMembers; } + getCatalogName(): string { return this.catalogName; } + getVersionEntryHash(): string { return this.versionEntryHash; } + getScriptHash(): string { return this.scriptHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + /** + * Filled by the version-ref pass once every [versions] entry is known. + * Does not re-key: the entry's identity is its alias within its catalog, + * and resolving its ref did not make it a different entry. + */ + setResolvedVersion(version: string, versionEntryHash: string): void { + this.resolvedVersion = version; + this.versionEntryHash = versionEntryHash; + } + + getHash(): string { + return this.gradleCatalogEntryUniqueHash; + } + + generateHash(): void { + const content = + this.scriptHash + + '||' + this.entryKind + + '||' + this.alias; + + this.gradleCatalogEntryUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.GRADLE_CATALOG_ENTRY, + content + ); + } + + getEntryCombined(): string { + return `gradle_catalog_entry[kind=${this.entryKind}, alias=${this.alias}, accessor=${this.accessorPath}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + this.entryKind, + EntityUtils.escapeTsv(this.alias), + EntityUtils.escapeTsv(this.accessorPath), + this.notation, + EntityUtils.escapeTsv(this.group), + EntityUtils.escapeTsv(this.artifact), + EntityUtils.escapeTsv(this.pluginId), + EntityUtils.escapeTsv(this.version), + EntityUtils.escapeTsv(this.versionRef), + EntityUtils.escapeTsv(this.resolvedVersion), + EntityUtils.escapeTsv(this.richVersionConstraint), + EntityUtils.escapeTsv(this.bundleMembers), + EntityUtils.escapeTsv(this.catalogName), + this.versionEntryHash, + this.scriptHash, + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.serviceVersionLinkHash, + this.gradleCatalogEntryUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'entryKind', + 'alias', + 'accessorPath', + 'notation', + 'group', + 'artifact', + 'pluginId', + 'version', + 'versionRef', + 'resolvedVersion', + 'richVersionConstraint', + 'bundleMembers', + 'catalogName', + 'versionEntryHash', + 'scriptHash', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'serviceVersionLinkHash', + 'gradleCatalogEntryUniqueHash', + ].join('\t'); + } +} + +class GradleCatalogEntryBuilder { + entryKind: GradleCatalogEntryKind; + alias: string; + accessorPath: string; + notation: GradleCatalogNotation; + group: string = ''; + artifact: string = ''; + pluginId: string = ''; + version: string = ''; + versionRef: string = ''; + resolvedVersion: string = ''; + richVersionConstraint: string = ''; + bundleMembers: string = ''; + catalogName: string = ''; + versionEntryHash: string = ''; + scriptHash: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + serviceVersionLinkHash: string; + + constructor( + entryKind: GradleCatalogEntryKind, + alias: string, + notation: GradleCatalogNotation, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ) { + this.entryKind = entryKind; + this.alias = alias; + this.accessorPath = GradleCatalogEntry.toAccessorPath(alias); + this.notation = notation; + this.scriptHash = scriptHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withGroup(v: string): GradleCatalogEntryBuilder { this.group = v; return this; } + withArtifact(v: string): GradleCatalogEntryBuilder { this.artifact = v; return this; } + withPluginId(v: string): GradleCatalogEntryBuilder { this.pluginId = v; return this; } + withVersion(v: string): GradleCatalogEntryBuilder { this.version = v; return this; } + withVersionRef(v: string): GradleCatalogEntryBuilder { this.versionRef = v; return this; } + withResolvedVersion(v: string): GradleCatalogEntryBuilder { this.resolvedVersion = v; return this; } + withRichVersionConstraint(v: string): GradleCatalogEntryBuilder { this.richVersionConstraint = v; return this; } + withBundleMembers(v: string): GradleCatalogEntryBuilder { this.bundleMembers = v; return this; } + withCatalogName(v: string): GradleCatalogEntryBuilder { this.catalogName = v; return this; } + + build(): GradleCatalogEntry { + return new (GradleCatalogEntry as any)(this); + } +} diff --git a/parser/src/analysis-types/gradle/GradleComment.ts b/parser/src/analysis-types/gradle/GradleComment.ts new file mode 100644 index 000000000..57e0838e4 --- /dev/null +++ b/parser/src/analysis-types/gradle/GradleComment.ts @@ -0,0 +1,206 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { GradleCommentKind } from '@/enums/gradle/comments/GradleCommentKind'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A comment in a Gradle script. + * + * Java and Python both carry a comment relation; Gradle needs one more than + * either. Build files put a disproportionate share of their meaning in + * comments — a pinned version nearly always has a "// pinned: CVE-…" beside + * it, an exclusion has the ticket that motivated it, and a commented-out + * dependency is a deliberate statement about what the build does not have. + * None of that survives if comments are dropped at the lexer. + * + * `isCommentedOutCode` is a structural guess, not a semantic one: it marks a + * line comment whose body parses as something the extractor would otherwise + * have recognised (a dependency configuration, an `apply`, an `id`). It is a + * hint for a consumer, never used to emit a phantom declaration. + * + * ## CSV Export Format + * + * Column order: + * 1. commentKind, text, isCommentedOutCode + * 2. ownerBlockHash, nextDeclarationHash, scriptHash + * 3. filePath, baseMservPath, startLine, endLine, startColumn, endColumn + * 4. serviceVersionLinkHash + * 5. gradleCommentUniqueHash (LAST) + */ +export class GradleComment implements EntityIdentifiable { + private commentKind: GradleCommentKind; + private text: string; + private isCommentedOutCode: boolean; + private ownerBlockHash: string; + private nextDeclarationHash: string; + private scriptHash: string; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private startColumn: number; + private endColumn: number; + private serviceVersionLinkHash: string; + private gradleCommentUniqueHash: string = ''; + + private constructor(builder: GradleCommentBuilder) { + this.commentKind = builder.commentKind; + this.text = builder.text; + this.isCommentedOutCode = builder.isCommentedOutCode; + this.ownerBlockHash = builder.ownerBlockHash; + this.nextDeclarationHash = builder.nextDeclarationHash; + this.scriptHash = builder.scriptHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startColumn = builder.startColumn; + this.endColumn = builder.endColumn; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + commentKind: GradleCommentKind, + text: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ): GradleCommentBuilder { + return new GradleCommentBuilder( + commentKind, text, scriptHash, filePath, baseMservPath, + startLine, endLine, startColumn, endColumn, serviceVersionLinkHash + ); + } + + getCommentKind(): GradleCommentKind { return this.commentKind; } + getText(): string { return this.text; } + getIsCommentedOutCode(): boolean { return this.isCommentedOutCode; } + getOwnerBlockHash(): string { return this.ownerBlockHash; } + getNextDeclarationHash(): string { return this.nextDeclarationHash; } + getScriptHash(): string { return this.scriptHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getStartColumn(): number { return this.startColumn; } + getEndColumn(): number { return this.endColumn; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + setOwnerBlockHash(hash: string): void { this.ownerBlockHash = hash; } + setNextDeclarationHash(hash: string): void { this.nextDeclarationHash = hash; } + + getHash(): string { + return this.gradleCommentUniqueHash; + } + + generateHash(): void { + // Byte range, not start offset: two comments can begin on the same line + // (`dep 'x' // a /* b */`) and a start-only key would collide. + const content = + this.scriptHash + + '||' + this.startLine + ':' + this.startColumn + + '||' + this.endLine + ':' + this.endColumn; + + this.gradleCommentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.GRADLE_COMMENT, + content + ); + } + + getEntryCombined(): string { + return `gradle_comment[kind=${this.commentKind}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + this.commentKind, + EntityUtils.escapeTsv(this.text), + this.isCommentedOutCode.toString(), + this.ownerBlockHash, + this.nextDeclarationHash, + this.scriptHash, + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.startColumn.toString(), + this.endColumn.toString(), + this.serviceVersionLinkHash, + this.gradleCommentUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'commentKind', + 'text', + 'isCommentedOutCode', + 'ownerBlockHash', + 'nextDeclarationHash', + 'scriptHash', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'startColumn', + 'endColumn', + 'serviceVersionLinkHash', + 'gradleCommentUniqueHash', + ].join('\t'); + } +} + +class GradleCommentBuilder { + commentKind: GradleCommentKind; + text: string; + isCommentedOutCode: boolean = false; + ownerBlockHash: string = ''; + nextDeclarationHash: string = ''; + scriptHash: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + serviceVersionLinkHash: string; + + constructor( + commentKind: GradleCommentKind, + text: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ) { + this.commentKind = commentKind; + this.text = text; + this.scriptHash = scriptHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.startColumn = startColumn; + this.endColumn = endColumn; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withIsCommentedOutCode(v: boolean): GradleCommentBuilder { this.isCommentedOutCode = v; return this; } + withOwnerBlockHash(v: string): GradleCommentBuilder { this.ownerBlockHash = v; return this; } + withNextDeclarationHash(v: string): GradleCommentBuilder { this.nextDeclarationHash = v; return this; } + + build(): GradleComment { + return new (GradleComment as any)(this); + } +} diff --git a/parser/src/analysis-types/gradle/GradleDeclaration.ts b/parser/src/analysis-types/gradle/GradleDeclaration.ts new file mode 100644 index 000000000..307158958 --- /dev/null +++ b/parser/src/analysis-types/gradle/GradleDeclaration.ts @@ -0,0 +1,289 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; +import { GradleDeclarationType } from '@/enums/gradle/declarations/GradleDeclarationType'; +import { GradleDSLDialect } from '@/enums/gradle/files/GradleDSLDialect'; + +/** + * Represents a single declaration/statement within a Gradle block. + * + * Uses `declarationType` as a discriminator — the meaning of `name`, `value`, + * `notation`, and `qualifier` varies by type (see design doc §3.2.1). + * + * ## CSV Export Format + * + * Column order: + * 1. declarationType, name, value, notation, qualifier + * 2. hasConfigBlock, reason + * 3. dslDialect + * 4. parentBlockHash, scriptHash, resolvedTargetHash + * 5. filePath, baseMservPath, startLine, endLine + * 6. serviceVersionLinkHash + * 7. gradleDeclarationUniqueHash (LAST) + * + * ## resolvedTargetHash + * + * A Gradle declaration can name another script: `include ':core'` names the + * script that configures `:core`, `apply from: 'gradle/deps.gradle'` names the + * script it pulls in, and `implementation project(':core')` names the same + * subproject a second way. Where the named script is present in the analysed + * corpus, its GRADLE_SCRIPT hash goes here, and the build's project graph + * becomes an ordinary join. + * + * Where it is not present, this stays empty. It is never filled with a guess + * or with the raw path: an unresolved edge and an edge to something outside + * the corpus are both "no target", and a downstream traversal must be able to + * stop rather than follow a fabricated one. + */ +export class GradleDeclaration implements EntityIdentifiable { + private declarationType: GradleDeclarationType; + private name: string; + private value: string; + private notation: string; + private qualifier: string; + private hasConfigBlock: boolean; + private reason: string; + private dslDialect: GradleDSLDialect; + private parentBlockHash: string; + private scriptHash: string; + private resolvedTargetHash: string; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private startColumn: number; + private endColumn: number; + private serviceVersionLinkHash: string; + private gradleDeclarationUniqueHash: string = ''; + + private constructor(builder: GradleDeclarationBuilder) { + this.declarationType = builder.declarationType; + this.name = builder.name; + this.value = builder.value; + this.notation = builder.notation; + this.qualifier = builder.qualifier; + this.hasConfigBlock = builder.hasConfigBlock; + this.reason = builder.reason; + this.dslDialect = builder.dslDialect; + this.parentBlockHash = builder.parentBlockHash; + this.scriptHash = builder.scriptHash; + this.resolvedTargetHash = builder.resolvedTargetHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startColumn = builder.startColumn; + this.endColumn = builder.endColumn; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + declarationType: GradleDeclarationType, + name: string, + dslDialect: GradleDSLDialect, + parentBlockHash: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ): GradleDeclarationBuilder { + return new GradleDeclarationBuilder( + declarationType, name, dslDialect, parentBlockHash, scriptHash, + filePath, baseMservPath, startLine, endLine, + startColumn, endColumn, serviceVersionLinkHash + ); + } + + getDeclarationType(): GradleDeclarationType { return this.declarationType; } + getName(): string { return this.name; } + getValue(): string { return this.value; } + getNotation(): string { return this.notation; } + getQualifier(): string { return this.qualifier; } + getHasConfigBlock(): boolean { return this.hasConfigBlock; } + getReason(): string { return this.reason; } + getDslDialect(): GradleDSLDialect { return this.dslDialect; } + getParentBlockHash(): string { return this.parentBlockHash; } + getScriptHash(): string { return this.scriptHash; } + getResolvedTargetHash(): string { return this.resolvedTargetHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getStartColumn(): number { return this.startColumn; } + getEndColumn(): number { return this.endColumn; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + /** + * Filled by the project pass, which is the only pass that can. A single-file + * pass sees `include ':core'` and can conclude nothing except that ':core' + * is not in this file — which is the weaker answer, and freezing it would + * lock out the stronger one the project pass is about to produce. + */ + setResolvedTargetHash(hash: string): void { this.resolvedTargetHash = hash; } + + getHash(): string { + return this.gradleDeclarationUniqueHash; + } + + generateHash(): void { + // Chains off the owning block, which chains off the script. The byte range + // rather than the start line, because `exclude group: 'a'; exclude group: + // 'b'` is two declarations on one line, and a `dependencies` block can + // hold two textually identical `implementation` lines that a name+value + // key would fold into one row. + const content = + this.scriptHash + + '||' + this.parentBlockHash + + '||' + this.declarationType + + '||' + this.name + + '||' + this.value + + '||' + this.startLine + ':' + this.startColumn + + '||' + this.endLine + ':' + this.endColumn; + + this.gradleDeclarationUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.GRADLE_DECLARATION, + content + ); + } + + getEntryCombined(): string { + return `gradle_declaration[type=${this.declarationType}, name=${this.name}, value=${this.value}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + this.declarationType, + EntityUtils.escapeTsv(this.name), + EntityUtils.escapeTsv(this.value), + this.notation, + EntityUtils.escapeTsv(this.qualifier), + this.hasConfigBlock.toString(), + EntityUtils.escapeTsv(this.reason), + this.dslDialect, + this.parentBlockHash, + this.scriptHash, + this.resolvedTargetHash, + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.startColumn.toString(), + this.endColumn.toString(), + this.serviceVersionLinkHash, + this.gradleDeclarationUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'declarationType', + 'name', + 'value', + 'notation', + 'qualifier', + 'hasConfigBlock', + 'reason', + 'dslDialect', + 'parentBlockHash', + 'scriptHash', + 'resolvedTargetHash', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'startColumn', + 'endColumn', + 'serviceVersionLinkHash', + 'gradleDeclarationUniqueHash', + ].join('\t'); + } +} + +class GradleDeclarationBuilder { + declarationType: GradleDeclarationType; + name: string; + value: string = ''; + notation: string = ''; + qualifier: string = ''; + hasConfigBlock: boolean = false; + reason: string = ''; + dslDialect: GradleDSLDialect; + parentBlockHash: string; + scriptHash: string; + resolvedTargetHash: string = ''; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + serviceVersionLinkHash: string; + + constructor( + declarationType: GradleDeclarationType, + name: string, + dslDialect: GradleDSLDialect, + parentBlockHash: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ) { + this.declarationType = declarationType; + this.name = name; + this.dslDialect = dslDialect; + this.parentBlockHash = parentBlockHash; + this.scriptHash = scriptHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.startColumn = startColumn; + this.endColumn = endColumn; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withValue(value: string): GradleDeclarationBuilder { + this.value = value; + return this; + } + + withNotation(notation: string): GradleDeclarationBuilder { + this.notation = notation; + return this; + } + + withQualifier(qualifier: string): GradleDeclarationBuilder { + this.qualifier = qualifier; + return this; + } + + withHasConfigBlock(hasConfigBlock: boolean): GradleDeclarationBuilder { + this.hasConfigBlock = hasConfigBlock; + return this; + } + + withReason(reason: string): GradleDeclarationBuilder { + this.reason = reason; + return this; + } + + withResolvedTargetHash(hash: string): GradleDeclarationBuilder { + this.resolvedTargetHash = hash; + return this; + } + + build(): GradleDeclaration { + return new (GradleDeclaration as any)(this); + } +} diff --git a/parser/src/analysis-types/gradle/GradleDependencyCoordinate.ts b/parser/src/analysis-types/gradle/GradleDependencyCoordinate.ts new file mode 100644 index 000000000..1be4df821 --- /dev/null +++ b/parser/src/analysis-types/gradle/GradleDependencyCoordinate.ts @@ -0,0 +1,348 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { GradleDependencyNotation } from '@/enums/gradle/declarations/GradleDependencyNotation'; +import { GradleVersionSource } from '@/enums/gradle/dependencies/GradleVersionSource'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A dependency declaration split into its parts. + * + * Chains 1:1 off a DEPENDENCY declaration. The declaration says what the build + * file literally wrote; this says what that means as a coordinate. + * + * The split is a separate relation rather than extra columns on the + * declaration for one reason: `implementation libs.bundles.spring` is one + * declaration and several coordinates, and a map-notation dependency written + * across three lines is one declaration whose group, name and version each + * need their own resolved value. Widening the declaration row would force + * either a packed column or a lie about cardinality. + * + * `versionSource` distinguishes the empty `version` that means "a BOM supplies + * it" from the empty `version` that means "this parser could not read it". A + * "which projects pin log4j below 2.17" query gets a wrong answer if those two + * are merged, and gets it confidently. + * + * ## CSV Export Format + * + * Column order: + * 1. configuration, notation, group, artifact, version, classifier, extension + * 2. versionSource, resolvedVersion, catalogAlias, projectPath, fileSpec + * 3. isTransitive, isChanging, isForced, hasConfigBlock + * 4. catalogEntryHash, versionReferenceHash, declarationHash, blockHash, scriptHash + * 5. filePath, baseMservPath, startLine, endLine + * 6. serviceVersionLinkHash + * 7. gradleDependencyCoordinateUniqueHash (LAST) + */ +export class GradleDependencyCoordinate implements EntityIdentifiable { + private configuration: string; + private notation: GradleDependencyNotation; + private group: string; + private artifact: string; + private version: string; + private classifier: string; + private extension: string; + private versionSource: GradleVersionSource; + private resolvedVersion: string; + private catalogAlias: string; + private projectPath: string; + private fileSpec: string; + private isTransitive: string; + private isChanging: boolean; + private isForced: boolean; + private hasConfigBlock: boolean; + private catalogEntryHash: string; + private versionReferenceHash: string; + private declarationHash: string; + private blockHash: string; + private scriptHash: string; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private serviceVersionLinkHash: string; + private gradleDependencyCoordinateUniqueHash: string = ''; + + private constructor(builder: GradleDependencyCoordinateBuilder) { + this.configuration = builder.configuration; + this.notation = builder.notation; + this.group = builder.group; + this.artifact = builder.artifact; + this.version = builder.version; + this.classifier = builder.classifier; + this.extension = builder.extension; + this.versionSource = builder.versionSource; + this.resolvedVersion = builder.resolvedVersion; + this.catalogAlias = builder.catalogAlias; + this.projectPath = builder.projectPath; + this.fileSpec = builder.fileSpec; + this.isTransitive = builder.isTransitive; + this.isChanging = builder.isChanging; + this.isForced = builder.isForced; + this.hasConfigBlock = builder.hasConfigBlock; + this.catalogEntryHash = builder.catalogEntryHash; + this.versionReferenceHash = builder.versionReferenceHash; + this.declarationHash = builder.declarationHash; + this.blockHash = builder.blockHash; + this.scriptHash = builder.scriptHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + configuration: string, + notation: GradleDependencyNotation, + declarationHash: string, + blockHash: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ): GradleDependencyCoordinateBuilder { + return new GradleDependencyCoordinateBuilder( + configuration, notation, declarationHash, blockHash, scriptHash, + filePath, baseMservPath, startLine, endLine, serviceVersionLinkHash + ); + } + + getConfiguration(): string { return this.configuration; } + getNotation(): GradleDependencyNotation { return this.notation; } + getGroup(): string { return this.group; } + getArtifact(): string { return this.artifact; } + getVersion(): string { return this.version; } + getClassifier(): string { return this.classifier; } + getExtension(): string { return this.extension; } + getVersionSource(): GradleVersionSource { return this.versionSource; } + getResolvedVersion(): string { return this.resolvedVersion; } + getCatalogAlias(): string { return this.catalogAlias; } + getProjectPath(): string { return this.projectPath; } + getFileSpec(): string { return this.fileSpec; } + getIsTransitive(): string { return this.isTransitive; } + getIsChanging(): boolean { return this.isChanging; } + getIsForced(): boolean { return this.isForced; } + getHasConfigBlock(): boolean { return this.hasConfigBlock; } + getCatalogEntryHash(): string { return this.catalogEntryHash; } + getVersionReferenceHash(): string { return this.versionReferenceHash; } + getDeclarationHash(): string { return this.declarationHash; } + getBlockHash(): string { return this.blockHash; } + getScriptHash(): string { return this.scriptHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + /** + * Filled by the project pass when the catalog alias or the interpolated + * version reference is matched. Never invents a version: an unmatched + * accessor leaves `resolvedVersion` empty and `versionSource` unchanged. + */ + setResolvedFromCatalog(entry: { + hash: string; group: string; artifact: string; version: string; + }): void { + this.catalogEntryHash = entry.hash; + if (!this.group) this.group = entry.group; + if (!this.artifact) this.artifact = entry.artifact; + if (entry.version) { + this.resolvedVersion = entry.version; + this.versionSource = GradleVersionSource.CATALOG; + return; + } + // The entry was found and carries no version. That is not a failure to + // read one — a catalog entry written `{ group = "…", name = "…" }` is + // deliberately version-less because a BOM or platform supplies it, which + // is precisely what ABSENT means. Leaving it UNKNOWN would report a + // correctly-read BOM-managed dependency as one the parser could not + // handle, and a "which dependencies are unpinned" query would then have + // to treat the parser's failures and the build's intent as one bucket. + this.versionSource = GradleVersionSource.ABSENT; + } + + /** + * Replaces the provisional path a type-safe accessor produced with the one + * the settings file declared. Does NOT re-key: the coordinate's identity is + * its declaration and its parts, and learning how the build spells a project + * name did not make it a different dependency. + */ + setProjectPath(projectPath: string): void { + this.projectPath = projectPath; + } + + setResolvedVersion(version: string, versionReferenceHash: string, source: GradleVersionSource): void { + this.resolvedVersion = version; + this.versionReferenceHash = versionReferenceHash; + this.versionSource = source; + } + + getHash(): string { + return this.gradleDependencyCoordinateUniqueHash; + } + + generateHash(): void { + // Chains off the declaration. A declaration can yield more than one + // coordinate (a bundle, a multi-arg files()), so the ordinal-free parts + // that distinguish them — group, artifact, classifier — are mixed in too. + const content = + this.declarationHash + + '||' + this.configuration + + '||' + this.group + + '||' + this.artifact + + '||' + this.version + + '||' + this.classifier + + '||' + this.projectPath + + '||' + this.fileSpec; + + this.gradleDependencyCoordinateUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.GRADLE_DEPENDENCY_COORDINATE, + content + ); + } + + getEntryCombined(): string { + return `gradle_coordinate[config=${this.configuration}, ga=${this.group}:${this.artifact}, version=${this.version}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.configuration), + this.notation, + EntityUtils.escapeTsv(this.group), + EntityUtils.escapeTsv(this.artifact), + EntityUtils.escapeTsv(this.version), + EntityUtils.escapeTsv(this.classifier), + EntityUtils.escapeTsv(this.extension), + this.versionSource, + EntityUtils.escapeTsv(this.resolvedVersion), + EntityUtils.escapeTsv(this.catalogAlias), + EntityUtils.escapeTsv(this.projectPath), + EntityUtils.escapeTsv(this.fileSpec), + this.isTransitive, + this.isChanging.toString(), + this.isForced.toString(), + this.hasConfigBlock.toString(), + this.catalogEntryHash, + this.versionReferenceHash, + this.declarationHash, + this.blockHash, + this.scriptHash, + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.serviceVersionLinkHash, + this.gradleDependencyCoordinateUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'configuration', + 'notation', + 'group', + 'artifact', + 'version', + 'classifier', + 'extension', + 'versionSource', + 'resolvedVersion', + 'catalogAlias', + 'projectPath', + 'fileSpec', + 'isTransitive', + 'isChanging', + 'isForced', + 'hasConfigBlock', + 'catalogEntryHash', + 'versionReferenceHash', + 'declarationHash', + 'blockHash', + 'scriptHash', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'serviceVersionLinkHash', + 'gradleDependencyCoordinateUniqueHash', + ].join('\t'); + } +} + +class GradleDependencyCoordinateBuilder { + configuration: string; + notation: GradleDependencyNotation; + group: string = ''; + artifact: string = ''; + version: string = ''; + classifier: string = ''; + extension: string = ''; + versionSource: GradleVersionSource = GradleVersionSource.UNKNOWN; + resolvedVersion: string = ''; + catalogAlias: string = ''; + projectPath: string = ''; + fileSpec: string = ''; + /** Tri-state on purpose: '' means the build said nothing, not "false". */ + isTransitive: string = ''; + isChanging: boolean = false; + isForced: boolean = false; + hasConfigBlock: boolean = false; + catalogEntryHash: string = ''; + versionReferenceHash: string = ''; + declarationHash: string; + blockHash: string; + scriptHash: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + serviceVersionLinkHash: string; + + constructor( + configuration: string, + notation: GradleDependencyNotation, + declarationHash: string, + blockHash: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ) { + this.configuration = configuration; + this.notation = notation; + this.declarationHash = declarationHash; + this.blockHash = blockHash; + this.scriptHash = scriptHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withGroup(v: string): GradleDependencyCoordinateBuilder { this.group = v; return this; } + withArtifact(v: string): GradleDependencyCoordinateBuilder { this.artifact = v; return this; } + withVersion(v: string): GradleDependencyCoordinateBuilder { this.version = v; return this; } + withClassifier(v: string): GradleDependencyCoordinateBuilder { this.classifier = v; return this; } + withExtension(v: string): GradleDependencyCoordinateBuilder { this.extension = v; return this; } + withVersionSource(v: GradleVersionSource): GradleDependencyCoordinateBuilder { this.versionSource = v; return this; } + withResolvedVersion(v: string): GradleDependencyCoordinateBuilder { this.resolvedVersion = v; return this; } + withCatalogAlias(v: string): GradleDependencyCoordinateBuilder { this.catalogAlias = v; return this; } + withProjectPath(v: string): GradleDependencyCoordinateBuilder { this.projectPath = v; return this; } + withFileSpec(v: string): GradleDependencyCoordinateBuilder { this.fileSpec = v; return this; } + withIsTransitive(v: string): GradleDependencyCoordinateBuilder { this.isTransitive = v; return this; } + withIsChanging(v: boolean): GradleDependencyCoordinateBuilder { this.isChanging = v; return this; } + withIsForced(v: boolean): GradleDependencyCoordinateBuilder { this.isForced = v; return this; } + withHasConfigBlock(v: boolean): GradleDependencyCoordinateBuilder { this.hasConfigBlock = v; return this; } + withCatalogEntryHash(v: string): GradleDependencyCoordinateBuilder { this.catalogEntryHash = v; return this; } + + build(): GradleDependencyCoordinate { + return new (GradleDependencyCoordinate as any)(this); + } +} diff --git a/parser/src/analysis-types/gradle/GradleParseGap.ts b/parser/src/analysis-types/gradle/GradleParseGap.ts new file mode 100644 index 000000000..a48d1cf5b --- /dev/null +++ b/parser/src/analysis-types/gradle/GradleParseGap.ts @@ -0,0 +1,197 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { GradleParseGapReason } from '@/enums/gradle/parse-gaps/GradleParseGapReason'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A region of a Gradle script that is not fully represented in the other rows. + * + * The Gradle front end is the one place in this parser where the grammar does + * not match the language. tree-sitter-groovy parses Groovy; it is handed + * Kotlin DSL as well, plus Groovy constructs it has no rule for. To get a + * usable tree the extractor rewrites the source first, and some of those + * rewrites delete tokens. + * + * Every ERROR node, every MISSING node, and every lossy rewrite gets a row + * here, with the ORIGINAL source text and the ORIGINAL offsets — not the + * rewritten ones — so a consumer can go and read what was actually there. + * + * This relation is the difference between "this block declares no dependency" + * and "this block was rewritten and the parser never saw inside it". Both + * produce zero dependency rows. Only one of them is true. + * + * ## CSV Export Format + * + * Column order: + * 1. reason, nodeType, originalText + * 2. enclosingBlockHash, scriptHash + * 3. filePath, baseMservPath, startLine, endLine, startColumn, endColumn + * 4. serviceVersionLinkHash + * 5. gradleParseGapUniqueHash (LAST) + */ +export class GradleParseGap implements EntityIdentifiable { + private reason: GradleParseGapReason; + private nodeType: string; + private originalText: string; + private enclosingBlockHash: string; + private scriptHash: string; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private startColumn: number; + private endColumn: number; + private serviceVersionLinkHash: string; + private gradleParseGapUniqueHash: string = ''; + + private constructor(builder: GradleParseGapBuilder) { + this.reason = builder.reason; + this.nodeType = builder.nodeType; + this.originalText = builder.originalText; + this.enclosingBlockHash = builder.enclosingBlockHash; + this.scriptHash = builder.scriptHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startColumn = builder.startColumn; + this.endColumn = builder.endColumn; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + reason: GradleParseGapReason, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ): GradleParseGapBuilder { + return new GradleParseGapBuilder( + reason, scriptHash, filePath, baseMservPath, + startLine, endLine, startColumn, endColumn, serviceVersionLinkHash + ); + } + + getReason(): GradleParseGapReason { return this.reason; } + getNodeType(): string { return this.nodeType; } + getOriginalText(): string { return this.originalText; } + getEnclosingBlockHash(): string { return this.enclosingBlockHash; } + getScriptHash(): string { return this.scriptHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getStartColumn(): number { return this.startColumn; } + getEndColumn(): number { return this.endColumn; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + setEnclosingBlockHash(hash: string): void { this.enclosingBlockHash = hash; } + + getHash(): string { + return this.gradleParseGapUniqueHash; + } + + generateHash(): void { + const content = + this.scriptHash + + '||' + this.reason + + '||' + this.startLine + ':' + this.startColumn + + '||' + this.endLine + ':' + this.endColumn; + + this.gradleParseGapUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.GRADLE_PARSE_GAP, + content + ); + } + + getEntryCombined(): string { + return `gradle_parse_gap[reason=${this.reason}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + this.reason, + EntityUtils.escapeTsv(this.nodeType), + EntityUtils.escapeTsv(this.originalText), + this.enclosingBlockHash, + this.scriptHash, + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.startColumn.toString(), + this.endColumn.toString(), + this.serviceVersionLinkHash, + this.gradleParseGapUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'reason', + 'nodeType', + 'originalText', + 'enclosingBlockHash', + 'scriptHash', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'startColumn', + 'endColumn', + 'serviceVersionLinkHash', + 'gradleParseGapUniqueHash', + ].join('\t'); + } +} + +class GradleParseGapBuilder { + reason: GradleParseGapReason; + nodeType: string = ''; + originalText: string = ''; + enclosingBlockHash: string = ''; + scriptHash: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + serviceVersionLinkHash: string; + + constructor( + reason: GradleParseGapReason, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ) { + this.reason = reason; + this.scriptHash = scriptHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.startColumn = startColumn; + this.endColumn = endColumn; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withNodeType(v: string): GradleParseGapBuilder { this.nodeType = v; return this; } + withOriginalText(v: string): GradleParseGapBuilder { this.originalText = v; return this; } + withEnclosingBlockHash(v: string): GradleParseGapBuilder { this.enclosingBlockHash = v; return this; } + + build(): GradleParseGap { + return new (GradleParseGap as any)(this); + } +} diff --git a/parser/src/analysis-types/gradle/GradleScript.ts b/parser/src/analysis-types/gradle/GradleScript.ts new file mode 100644 index 000000000..a4c0ad647 --- /dev/null +++ b/parser/src/analysis-types/gradle/GradleScript.ts @@ -0,0 +1,263 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { GradleDSLDialect } from '@/enums/gradle/files/GradleDSLDialect'; +import { GradleParseStatus } from '@/enums/gradle/files/GradleParseStatus'; +import { GradleScriptKind } from '@/enums/gradle/files/GradleScriptKind'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One row per Gradle file, and the root of the Gradle key chain. + * + * Everything else in the Gradle relation set — blocks, declarations, value + * references, coordinates, comments, parse gaps — chains off a script hash. + * Without this anchor there is no way to answer "which project does this + * dependency belong to", because a `.gradle` file's meaning depends entirely + * on where it sits: `core/build.gradle` configures `:core`, the root + * `build.gradle` may configure every project through `subprojects { }`, and a + * script plugin configures whoever applied it. + * + * `parseStatus` and the count columns are what let a consumer tell an empty + * result from an unanalysed one. A script with zero declarations and + * `parseStatus = OK` declares nothing; the same script with `PARTIAL` and a + * non-zero `parseGapCount` was not fully read, and the parse-gap relation says + * which regions. + * + * ## CSV Export Format + * + * Column order: + * 1. scriptKind, dslDialect, gradleProjectPath, relativePath, fileName + * 2. parseStatus, lineCount + * 3. blockCount, declarationCount, valueReferenceCount, coordinateCount, + * commentCount, parseGapCount + * 4. settingsScriptHash, rootScriptHash + * 5. filePath, baseMservPath + * 6. serviceVersionLinkHash + * 7. gradleScriptUniqueHash (LAST) + */ +export class GradleScript implements EntityIdentifiable { + private scriptKind: GradleScriptKind; + private dslDialect: GradleDSLDialect; + private gradleProjectPath: string; + private relativePath: string; + private fileName: string; + private parseStatus: GradleParseStatus; + private lineCount: number; + private blockCount: number = 0; + private declarationCount: number = 0; + private valueReferenceCount: number = 0; + private coordinateCount: number = 0; + private commentCount: number = 0; + private parseGapCount: number = 0; + private settingsScriptHash: string; + private rootScriptHash: string; + private filePath: string; + private baseMservPath: string; + private serviceVersionLinkHash: string; + private gradleScriptUniqueHash: string = ''; + + private constructor(builder: GradleScriptBuilder) { + this.scriptKind = builder.scriptKind; + this.dslDialect = builder.dslDialect; + this.gradleProjectPath = builder.gradleProjectPath; + this.relativePath = builder.relativePath; + this.fileName = builder.fileName; + this.parseStatus = builder.parseStatus; + this.lineCount = builder.lineCount; + this.settingsScriptHash = builder.settingsScriptHash; + this.rootScriptHash = builder.rootScriptHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + scriptKind: GradleScriptKind, + dslDialect: GradleDSLDialect, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): GradleScriptBuilder { + return new GradleScriptBuilder( + scriptKind, dslDialect, filePath, baseMservPath, serviceVersionLinkHash + ); + } + + getScriptKind(): GradleScriptKind { return this.scriptKind; } + getDslDialect(): GradleDSLDialect { return this.dslDialect; } + getGradleProjectPath(): string { return this.gradleProjectPath; } + getRelativePath(): string { return this.relativePath; } + getFileName(): string { return this.fileName; } + getParseStatus(): GradleParseStatus { return this.parseStatus; } + getLineCount(): number { return this.lineCount; } + getBlockCount(): number { return this.blockCount; } + getDeclarationCount(): number { return this.declarationCount; } + getValueReferenceCount(): number { return this.valueReferenceCount; } + getCoordinateCount(): number { return this.coordinateCount; } + getCommentCount(): number { return this.commentCount; } + getParseGapCount(): number { return this.parseGapCount; } + getSettingsScriptHash(): string { return this.settingsScriptHash; } + getRootScriptHash(): string { return this.rootScriptHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + /** + * Counts and status are written after extraction, not at build time: the + * script row has to exist before anything can chain off it, and the totals + * are only known once everything has. Mutating them does NOT re-key the row — + * the identity is the file, and a file that gained a declaration is the same + * file. Re-hashing here would silently orphan every child that already + * chained off the old key. + */ + setCounts(counts: { + blocks: number; declarations: number; valueReferences: number; + coordinates: number; comments: number; parseGaps: number; + }): void { + this.blockCount = counts.blocks; + this.declarationCount = counts.declarations; + this.valueReferenceCount = counts.valueReferences; + this.coordinateCount = counts.coordinates; + this.commentCount = counts.comments; + this.parseGapCount = counts.parseGaps; + } + + setParseStatus(status: GradleParseStatus): void { + this.parseStatus = status; + } + + setSettingsScriptHash(hash: string): void { + this.settingsScriptHash = hash; + } + + setRootScriptHash(hash: string): void { + this.rootScriptHash = hash; + } + + setGradleProjectPath(projectPath: string): void { + this.gradleProjectPath = projectPath; + } + + /** + * Upgrades the path-derived kind once the settings file has been read. + * + * A build that renames its build files — `buildFileName = "${name}.gradle"`, + * which Spring Framework does — leaves every subproject script looking like + * a script plugin from its path alone. When a settings `include` actually + * resolves to it, that is direct evidence of what it is, and the evidence + * beats the convention. + */ + setScriptKind(kind: GradleScriptKind): void { + this.scriptKind = kind; + } + + getHash(): string { + return this.gradleScriptUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + this.baseMservPath + + '||' + this.serviceVersionLinkHash; + + this.gradleScriptUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.GRADLE_SCRIPT, + content + ); + } + + getEntryCombined(): string { + return `gradle_script[kind=${this.scriptKind}, project=${this.gradleProjectPath}, file=${this.relativePath}]`; + } + + toCsv(): string { + return [ + this.scriptKind, + this.dslDialect, + EntityUtils.escapeTsv(this.gradleProjectPath), + EntityUtils.escapeTsv(this.relativePath), + EntityUtils.escapeTsv(this.fileName), + this.parseStatus, + this.lineCount.toString(), + this.blockCount.toString(), + this.declarationCount.toString(), + this.valueReferenceCount.toString(), + this.coordinateCount.toString(), + this.commentCount.toString(), + this.parseGapCount.toString(), + this.settingsScriptHash, + this.rootScriptHash, + this.filePath, + this.baseMservPath, + this.serviceVersionLinkHash, + this.gradleScriptUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'scriptKind', + 'dslDialect', + 'gradleProjectPath', + 'relativePath', + 'fileName', + 'parseStatus', + 'lineCount', + 'blockCount', + 'declarationCount', + 'valueReferenceCount', + 'coordinateCount', + 'commentCount', + 'parseGapCount', + 'settingsScriptHash', + 'rootScriptHash', + 'filePath', + 'baseMservPath', + 'serviceVersionLinkHash', + 'gradleScriptUniqueHash', + ].join('\t'); + } +} + +class GradleScriptBuilder { + scriptKind: GradleScriptKind; + dslDialect: GradleDSLDialect; + gradleProjectPath: string = ''; + relativePath: string = ''; + fileName: string = ''; + parseStatus: GradleParseStatus = GradleParseStatus.OK; + lineCount: number = 0; + settingsScriptHash: string = ''; + rootScriptHash: string = ''; + filePath: string; + baseMservPath: string; + serviceVersionLinkHash: string; + + constructor( + scriptKind: GradleScriptKind, + dslDialect: GradleDSLDialect, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ) { + this.scriptKind = scriptKind; + this.dslDialect = dslDialect; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withGradleProjectPath(v: string): GradleScriptBuilder { this.gradleProjectPath = v; return this; } + withRelativePath(v: string): GradleScriptBuilder { this.relativePath = v; return this; } + withFileName(v: string): GradleScriptBuilder { this.fileName = v; return this; } + withParseStatus(v: GradleParseStatus): GradleScriptBuilder { this.parseStatus = v; return this; } + withLineCount(v: number): GradleScriptBuilder { this.lineCount = v; return this; } + withSettingsScriptHash(v: string): GradleScriptBuilder { this.settingsScriptHash = v; return this; } + withRootScriptHash(v: string): GradleScriptBuilder { this.rootScriptHash = v; return this; } + + build(): GradleScript { + return new (GradleScript as any)(this); + } +} diff --git a/parser/src/analysis-types/gradle/GradleValueReference.ts b/parser/src/analysis-types/gradle/GradleValueReference.ts new file mode 100644 index 000000000..c883b2715 --- /dev/null +++ b/parser/src/analysis-types/gradle/GradleValueReference.ts @@ -0,0 +1,268 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; +import { GradleReferenceResolution } from '@/enums/gradle/value-references/GradleReferenceResolution'; +import { GradleValueReferenceType } from '@/enums/gradle/value-references/GradleValueReferenceType'; + +/** + * Represents a value reference found within a Gradle declaration or block. + * + * Captures every `${...}`, `$var`, `System.getenv()`, `findProperty()`, + * provider call, ext property access, etc. Linked to both the owning + * declaration AND owning block via hash. + * + * ## CSV Export Format + * + * Column order: + * 1. referenceExpression, referenceType, rawFragment, defaultValue + * 2. resolutionKind, resolvedContext + * 3. ownerDeclarationHash, ownerBlockHash, scriptHash + * 4. filePath, baseMservPath, startLine, endLine, startColumn, endColumn + * 5. serviceVersionLinkHash + * 6. gradleValueReferenceUniqueHash (LAST) + * + * ## resolutionKind and resolvedContext are a pair + * + * `resolvedContext` carries the hash of whatever the reference resolved to and + * is empty when nothing was found. `resolutionKind` says what kind of thing + * that was — or, when the hash is empty, WHY it is empty. + * + * That second case is the one that matters. `UNRESOLVED_IN_CORPUS` means a + * declaration by that name exists in the analysed files and the link was still + * not made: the parser's gap, and the only bucket that should shrink as it + * improves. `EXTERNAL` means nothing by that name exists anywhere in the + * corpus — `System.getenv('CI')` has no declaration to point at and never + * will. Collapsing the two makes a coverage number that tracks how many + * environment variables a build reads. + */ +export class GradleValueReference implements EntityIdentifiable { + private referenceExpression: string; + private referenceType: GradleValueReferenceType; + private rawFragment: string; + private resolvedContext: string; + private resolutionKind: GradleReferenceResolution; + private defaultValue: string; + private ownerDeclarationHash: string; + private ownerBlockHash: string; + private scriptHash: string; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private startColumn: number; + private endColumn: number; + private serviceVersionLinkHash: string; + private gradleValueReferenceUniqueHash: string = ''; + + private constructor(builder: GradleValueReferenceBuilder) { + this.referenceExpression = builder.referenceExpression; + this.referenceType = builder.referenceType; + this.rawFragment = builder.rawFragment; + this.resolvedContext = builder.resolvedContext; + this.resolutionKind = builder.resolutionKind; + this.defaultValue = builder.defaultValue; + this.ownerDeclarationHash = builder.ownerDeclarationHash; + this.ownerBlockHash = builder.ownerBlockHash; + this.scriptHash = builder.scriptHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startColumn = builder.startColumn; + this.endColumn = builder.endColumn; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + referenceExpression: string, + referenceType: GradleValueReferenceType, + rawFragment: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ): GradleValueReferenceBuilder { + return new GradleValueReferenceBuilder( + referenceExpression, referenceType, rawFragment, scriptHash, + filePath, baseMservPath, startLine, endLine, + startColumn, endColumn, serviceVersionLinkHash + ); + } + + getReferenceExpression(): string { return this.referenceExpression; } + getReferenceType(): GradleValueReferenceType { return this.referenceType; } + getRawFragment(): string { return this.rawFragment; } + getResolvedContext(): string { return this.resolvedContext; } + getResolutionKind(): GradleReferenceResolution { return this.resolutionKind; } + getScriptHash(): string { return this.scriptHash; } + getDefaultValue(): string { return this.defaultValue; } + getOwnerDeclarationHash(): string { return this.ownerDeclarationHash; } + getOwnerBlockHash(): string { return this.ownerBlockHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getStartColumn(): number { return this.startColumn; } + getEndColumn(): number { return this.endColumn; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + /** + * Both halves are written together so a hash can never be recorded without + * saying what kind of thing it points at, and a kind can never claim a + * resolution that left no target behind. + */ + setResolution(kind: GradleReferenceResolution, targetHash: string = ''): void { + this.resolutionKind = kind; + this.resolvedContext = targetHash; + } + + getHash(): string { + return this.gradleValueReferenceUniqueHash; + } + + generateHash(): void { + // Chains off the owning declaration. The byte range is part of the key + // because one declaration routinely holds several references — + // `"$group:$name:$version"` is three — and they differ only by position. + const content = + this.ownerDeclarationHash + + '||' + this.referenceType + + '||' + this.referenceExpression + + '||' + this.rawFragment + + '||' + this.startLine + ':' + this.startColumn + + '||' + this.endLine + ':' + this.endColumn; + + this.gradleValueReferenceUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.GRADLE_VALUE_REFERENCE, + content + ); + } + + getEntryCombined(): string { + return `gradle_value_ref[expr=${this.referenceExpression}, type=${this.referenceType}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.referenceExpression), + this.referenceType, + EntityUtils.escapeTsv(this.rawFragment), + EntityUtils.escapeTsv(this.defaultValue), + this.resolutionKind, + this.resolvedContext, + this.ownerDeclarationHash, + this.ownerBlockHash, + this.scriptHash, + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.startColumn.toString(), + this.endColumn.toString(), + this.serviceVersionLinkHash, + this.gradleValueReferenceUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'referenceExpression', + 'referenceType', + 'rawFragment', + 'defaultValue', + 'resolutionKind', + 'resolvedContext', + 'ownerDeclarationHash', + 'ownerBlockHash', + 'scriptHash', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'startColumn', + 'endColumn', + 'serviceVersionLinkHash', + 'gradleValueReferenceUniqueHash', + ].join('\t'); + } +} + +class GradleValueReferenceBuilder { + referenceExpression: string; + referenceType: GradleValueReferenceType; + rawFragment: string; + resolvedContext: string = ''; + resolutionKind: GradleReferenceResolution = GradleReferenceResolution.UNRESOLVED_IN_CORPUS; + defaultValue: string = ''; + ownerDeclarationHash: string = ''; + ownerBlockHash: string = ''; + scriptHash: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + serviceVersionLinkHash: string; + + constructor( + referenceExpression: string, + referenceType: GradleValueReferenceType, + rawFragment: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionLinkHash: string + ) { + this.referenceExpression = referenceExpression; + this.referenceType = referenceType; + this.rawFragment = rawFragment; + this.scriptHash = scriptHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.startColumn = startColumn; + this.endColumn = endColumn; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withResolvedContext(context: string): GradleValueReferenceBuilder { + this.resolvedContext = context; + return this; + } + + withResolutionKind(kind: GradleReferenceResolution): GradleValueReferenceBuilder { + this.resolutionKind = kind; + return this; + } + + withDefaultValue(defaultValue: string): GradleValueReferenceBuilder { + this.defaultValue = defaultValue; + return this; + } + + withOwnerDeclarationHash(hash: string): GradleValueReferenceBuilder { + this.ownerDeclarationHash = hash; + return this; + } + + withOwnerBlockHash(hash: string): GradleValueReferenceBuilder { + this.ownerBlockHash = hash; + return this; + } + + build(): GradleValueReference { + return new (GradleValueReference as any)(this); + } +} diff --git a/parser/src/analysis-types/gradle/index.ts b/parser/src/analysis-types/gradle/index.ts new file mode 100644 index 000000000..9e77fe79b --- /dev/null +++ b/parser/src/analysis-types/gradle/index.ts @@ -0,0 +1,8 @@ +export { GradleScript } from '@/analysis-types/gradle/GradleScript'; +export { GradleBlock } from '@/analysis-types/gradle/GradleBlock'; +export { GradleDeclaration } from '@/analysis-types/gradle/GradleDeclaration'; +export { GradleValueReference } from '@/analysis-types/gradle/GradleValueReference'; +export { GradleDependencyCoordinate } from '@/analysis-types/gradle/GradleDependencyCoordinate'; +export { GradleCatalogEntry } from '@/analysis-types/gradle/GradleCatalogEntry'; +export { GradleComment } from '@/analysis-types/gradle/GradleComment'; +export { GradleParseGap } from '@/analysis-types/gradle/GradleParseGap'; diff --git a/parser/src/analysis-types/index.ts b/parser/src/analysis-types/index.ts new file mode 100644 index 000000000..d5aa18f45 --- /dev/null +++ b/parser/src/analysis-types/index.ts @@ -0,0 +1,21 @@ +// Java analysis types +export { TypeRegistry } from '@/analysis-types/java/TypeRegistry'; +export { TypeParameter } from '@/analysis-types/java/TypeParameter'; +export { TypeReference } from '@/analysis-types/java/TypeReference'; +export { EnumConstant } from '@/analysis-types/java/EnumConstant'; +export { FieldRegistry } from '@/analysis-types/java/FieldRegistry'; +export { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +export { LocalVariableRegistry } from '@/analysis-types/java/LocalVariableRegistry'; + +// Properties analysis types +export { PropertyKey } from '@/analysis-types/properties/PropertyKey'; +export { PropertyValueSegment } from '@/analysis-types/properties/PropertyValueSegment'; + +// XML analysis types +export { XmlElement } from '@/analysis-types/xml/XmlElement'; +export { XmlAttribute } from '@/analysis-types/xml/XmlAttribute'; +export { XmlValueReference } from '@/analysis-types/xml/XmlValueReference'; + +// YAML analysis types +export { YamlProperty } from '@/analysis-types/yaml/YamlProperty'; +export { YamlValueSegment } from '@/analysis-types/yaml/YamlValueSegment'; diff --git a/parser/src/analysis-types/java/AnnotationArgumentReference.ts b/parser/src/analysis-types/java/AnnotationArgumentReference.ts new file mode 100644 index 000000000..eb0ee7f16 --- /dev/null +++ b/parser/src/analysis-types/java/AnnotationArgumentReference.ts @@ -0,0 +1,256 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { ArgumentValueType } from '@/enums/java/annotations/ArgumentValueType'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a reference to an annotation argument value. + * + * Similar to TypeReference, this tracks individual annotation arguments + * as separate entities for relationship analysis and querying. + * + * ## Examples + * + * ```java + * @Table( + * name = "users", // ← AnnotationArgumentReference + * schema = "public" // ← AnnotationArgumentReference + * ) + * + * @EntityListeners(AuditListener.class) // ← AnnotationArgumentReference (CLASS_REFERENCE) + * + * @Retention(RetentionPolicy.RUNTIME) // ← AnnotationArgumentReference (ENUM_CONSTANT) + * + * @Target({ ElementType.TYPE, ElementType.METHOD }) // ← AnnotationArgumentReference (ARRAY) + * ``` + */ +export class AnnotationArgumentReference implements EntityIdentifiable { + private argumentName: string; + private argumentValue: string; + private valueType: ArgumentValueType; + private position: number; + private parentAnnotationHash: string; + private referencedTypeHash?: string; + private nestedAnnotationHash?: string; + private arrayIndex?: number; + private startLine?: number; + private endLine?: number; + private annotationArgumentReferenceUniqueHash: string = ''; + + constructor(builder: AnnotationArgumentReferenceBuilder) { + this.argumentName = builder.argumentName; + this.argumentValue = builder.argumentValue; + this.valueType = builder.valueType; + this.position = builder.position; + this.parentAnnotationHash = builder.parentAnnotationHash; + this.referencedTypeHash = builder.referencedTypeHash; + this.nestedAnnotationHash = builder.nestedAnnotationHash; + this.arrayIndex = builder.arrayIndex; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + } + + getArgumentName(): string { + return this.argumentName; + } + + getArgumentValue(): string { + return this.argumentValue; + } + + getValueType(): ArgumentValueType { + return this.valueType; + } + + getPosition(): number { + return this.position; + } + + getParentAnnotationHash(): string { + return this.parentAnnotationHash; + } + + getReferencedTypeHash(): string | undefined { + return this.referencedTypeHash; + } + + getNestedAnnotationHash(): string | undefined { + return this.nestedAnnotationHash; + } + + getArrayIndex(): number | undefined { + return this.arrayIndex; + } + + getStartLine(): number | undefined { + return this.startLine; + } + + getEndLine(): number | undefined { + return this.endLine; + } + + getHash(): string { + return this.annotationArgumentReferenceUniqueHash; + } + + generateHash(): void { + const content = + this.argumentName + + '||' + + this.argumentValue + + '||' + + this.valueType + + '||' + + this.position + + '||' + + this.parentAnnotationHash + + '||' + + (this.referencedTypeHash ? this.referencedTypeHash + '||' : '') + + (this.nestedAnnotationHash ? this.nestedAnnotationHash + '||' : '') + + (this.arrayIndex !== undefined ? this.arrayIndex + '||' : '') + + (this.startLine !== undefined ? this.startLine + '||' : '') + + (this.endLine !== undefined ? this.endLine : ''); + + this.annotationArgumentReferenceUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.ANNOTATION_ARGUMENT_REFERENCE, + content + ); + } + + getHashFieldName(): string { + return 'annotationArgumentReferenceUniqueHash'; + } + + getEntryCombined(): string { + const locationInfo = + this.startLine !== undefined && this.endLine !== undefined + ? `${this.startLine}:${this.endLine}` + : ''; + return `${this.argumentName}=${this.argumentValue} [${this.valueType}] @${locationInfo}`; + } + + toCsv(): string { + return [ + this.argumentName, + EntityUtils.escapeTsv(this.argumentValue), + this.valueType, + this.position, + this.parentAnnotationHash, + this.referencedTypeHash || '', + this.nestedAnnotationHash || '', + this.arrayIndex !== undefined ? this.arrayIndex : '', + this.startLine !== undefined ? this.startLine : '', + this.endLine !== undefined ? this.endLine : '', + this.annotationArgumentReferenceUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'argumentName', + 'argumentValue', + 'valueType', + 'position', + 'parentAnnotationHash', + 'referencedTypeHash', + 'nestedAnnotationHash', + 'arrayIndex', + 'startLine', + 'endLine', + 'annotationArgumentReferenceUniqueHash', + ].join('\t'); + } + + static builder( + argumentName: string, + argumentValue: string, + valueType: ArgumentValueType, + position: number, + parentAnnotationHash: string + ): AnnotationArgumentReferenceBuilder { + return new AnnotationArgumentReferenceBuilder( + argumentName, + argumentValue, + valueType, + position, + parentAnnotationHash + ); + } +} + +/** + * Builder for AnnotationArgumentReference with validation. + */ +export class AnnotationArgumentReferenceBuilder { + argumentName: string; + argumentValue: string; + valueType: ArgumentValueType; + position: number; + parentAnnotationHash: string; + referencedTypeHash?: string; + nestedAnnotationHash?: string; + arrayIndex?: number; + startLine?: number; + endLine?: number; + + constructor( + argumentName: string, + argumentValue: string, + valueType: ArgumentValueType, + position: number, + parentAnnotationHash: string + ) { + this.argumentName = argumentName; + this.argumentValue = argumentValue; + this.valueType = valueType; + this.position = position; + this.parentAnnotationHash = parentAnnotationHash; + } + + referencedType(typeHash: string): this { + this.referencedTypeHash = typeHash; + return this; + } + + nestedAnnotation(annotationHash: string): this { + this.nestedAnnotationHash = annotationHash; + return this; + } + + arrayPosition(index: number): this { + this.arrayIndex = index; + return this; + } + + location(startLine: number, endLine: number): this { + this.startLine = startLine; + this.endLine = endLine; + return this; + } + + build(): AnnotationArgumentReference { + this.validate(); + const arg = new AnnotationArgumentReference(this); + arg.generateHash(); + return arg; + } + + private validate(): void { + if (!this.argumentName || this.argumentName.trim().length === 0) { + throw new Error('argumentName is required'); + } + if (!this.argumentValue || this.argumentValue.trim().length === 0) { + throw new Error('argumentValue is required'); + } + if (!this.valueType) { + throw new Error('valueType is required'); + } + if (this.position < 0) { + throw new Error('position must be >= 0'); + } + if (!this.parentAnnotationHash || this.parentAnnotationHash.trim().length === 0) { + throw new Error('parentAnnotationHash is required'); + } + } +} diff --git a/parser/src/analysis-types/java/BlockRegistry.ts b/parser/src/analysis-types/java/BlockRegistry.ts new file mode 100644 index 000000000..b526a2e4d --- /dev/null +++ b/parser/src/analysis-types/java/BlockRegistry.ts @@ -0,0 +1,398 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { BlockKind } from '@/enums/java/blocks'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a code block that can contain expressions and local variables. + * + * BlockRegistry captures block-level constructs including: + * - Exception handling: try, catch, finally, try-with-resources + * - Loops: for, enhanced-for, while, do-while + * - Conditionals: if, else, switch case + * - Synchronization: synchronized blocks + * + * ## Ownership Model + * + * Blocks form a hierarchy where: + * - `parentContainerHash` links to the containing block or lambda expression + * - `methodOwnerHash` always points to the containing method (for quick lookup) + * - `tryStatementHash` groups TRY + CATCH + FINALLY blocks together + * + * ## Example: Nested Try Blocks + * + * ```java + * void process() { // METHOD_abc + * try { // BLOCK_try1 (parentContainerHash=null) + * try { // BLOCK_try2 (parentContainerHash=try1) + * riskyOp(); + * } catch (IOException e) { // BLOCK_catch2 (tryStatementHash=try2) + * handle(e); + * } + * } catch (Exception e) { // BLOCK_catch1 (tryStatementHash=try1) + * log(e); + * } + * } + * ``` + * + * ## Example: Lambda with Try Block + * + * ```java + * Future future = executor.submit(() -> { // EXPR_lambda1 + * try { // BLOCK_try1 (parentContainerHash=lambda1) + * return doWork(); + * } catch (Exception e) { // BLOCK_catch1 (tryStatementHash=try1) + * return Result.failure(e); + * } + * }); + * ``` + * + * ## Links + * + * - **parentContainerHash**: Immediate container (block hash or lambda expression hash) + * - **methodOwnerHash**: The method containing this block (for quick queries) + * - **tryStatementHash**: Groups TRY + CATCH + FINALLY (only for exception blocks) + * - **typeRegistryLinkHash**: The enclosing type + */ +export class BlockRegistry implements EntityIdentifiable { + private kind: BlockKind; + private order: number; + private filePath: string; + private startLine: number; + private endLine: number; + private startColumn: number; + private endColumn: number; + private nestingDepth: number; + private typeRegistryLinkHash: string; + private methodOwnerHash: string; + private parentContainerHash?: string; + private tryStatementHash?: string; + private resourceCount?: number; + private caughtExceptionTypes?: string; + private ownerTypeName: string; + private ownerQualifiedName: string; + private ownerMethodName: string; + private blockRegistryUniqueHash: string = ''; + + private constructor(builder: BlockRegistryBuilder) { + this.kind = builder.kind; + this.order = builder.order; + this.filePath = builder.filePath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startColumn = builder.startColumn; + this.endColumn = builder.endColumn; + this.nestingDepth = builder.nestingDepth; + this.typeRegistryLinkHash = builder.typeRegistryLinkHash; + this.methodOwnerHash = builder.methodOwnerHash; + this.parentContainerHash = builder.parentContainerHash; + this.tryStatementHash = builder.tryStatementHash; + this.resourceCount = builder.resourceCount; + this.caughtExceptionTypes = builder.caughtExceptionTypes; + this.ownerTypeName = builder.ownerTypeName; + this.ownerQualifiedName = builder.ownerQualifiedName; + this.ownerMethodName = builder.ownerMethodName; + + this.generateHash(); + } + + /** + * Pre-computes the hash for a block based on its properties. + * Useful for computing block hash before building, so it can be used + * as parentContainerHash for nested blocks or tryStatementHash for CATCH/FINALLY. + */ + static computeHash( + kind: BlockKind, + filePath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + typeRegistryLinkHash: string, + methodOwnerHash: string + ): string { + const content = + filePath + + '||' + + typeRegistryLinkHash + + '||' + + methodOwnerHash + + '||' + + kind + + '||' + + startLine + + '||' + + startColumn + + '||' + + endLine + + '||' + + endColumn; + + return EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.BLOCK_REGISTRY, + content + ); + } + + static builder( + kind: BlockKind, + order: number, + filePath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + typeRegistryLinkHash: string, + methodOwnerHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string + ): BlockRegistryBuilder { + return new BlockRegistryBuilder( + kind, + order, + filePath, + startLine, + endLine, + startColumn, + endColumn, + typeRegistryLinkHash, + methodOwnerHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName + ); + } + + // === Getters === + + getKind(): BlockKind { + return this.kind; + } + + getOrder(): number { + return this.order; + } + + getFilePath(): string { + return this.filePath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getStartColumn(): number { + return this.startColumn; + } + + getEndColumn(): number { + return this.endColumn; + } + + getNestingDepth(): number { + return this.nestingDepth; + } + + getTypeRegistryLinkHash(): string { + return this.typeRegistryLinkHash; + } + + getMethodOwnerHash(): string { + return this.methodOwnerHash; + } + + getParentContainerHash(): string | undefined { + return this.parentContainerHash; + } + + getTryStatementHash(): string | undefined { + return this.tryStatementHash; + } + + getResourceCount(): number | undefined { + return this.resourceCount; + } + + getCaughtExceptionTypes(): string | undefined { + return this.caughtExceptionTypes; + } + + getOwnerTypeName(): string { + return this.ownerTypeName; + } + + getOwnerQualifiedName(): string { + return this.ownerQualifiedName; + } + + getOwnerMethodName(): string { + return this.ownerMethodName; + } + + getBlockRegistryUniqueHash(): string { + return this.blockRegistryUniqueHash; + } + + getHash(): string { + return this.blockRegistryUniqueHash; + } + + getEntryCombined(): string { + return `java_block[kind=${this.kind}, method=${this.ownerMethodName}, depth=${this.nestingDepth}, lines=${this.startLine}-${this.endLine}, hash=${this.blockRegistryUniqueHash}]`; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.typeRegistryLinkHash + + '||' + + this.methodOwnerHash + + '||' + + this.kind + + '||' + + this.startLine + + '||' + + this.startColumn + + '||' + + this.endLine + + '||' + + this.endColumn; + + this.blockRegistryUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.BLOCK_REGISTRY, + content + ); + } + + toCsv(): string { + return [ + this.kind, + this.order.toString(), + this.filePath, + this.startLine.toString(), + this.endLine.toString(), + this.startColumn.toString(), + this.endColumn.toString(), + this.nestingDepth.toString(), + this.typeRegistryLinkHash, + this.methodOwnerHash, + this.parentContainerHash || '', + this.tryStatementHash || '', + this.resourceCount?.toString() || '', + EntityUtils.escapeTsv(this.caughtExceptionTypes || ''), + EntityUtils.escapeTsv(this.ownerTypeName), + EntityUtils.escapeTsv(this.ownerQualifiedName), + EntityUtils.escapeTsv(this.ownerMethodName), + this.blockRegistryUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'kind', + 'order', + 'filePath', + 'startLine', + 'endLine', + 'startColumn', + 'endColumn', + 'nestingDepth', + 'typeRegistryLinkHash', + 'methodOwnerHash', + 'parentContainerHash', + 'tryStatementHash', + 'resourceCount', + 'caughtExceptionTypes', + 'ownerTypeName', + 'ownerQualifiedName', + 'ownerMethodName', + 'blockRegistryUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for BlockRegistry + */ +class BlockRegistryBuilder { + kind: BlockKind; + order: number; + filePath: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + nestingDepth: number = 0; + typeRegistryLinkHash: string; + methodOwnerHash: string; + parentContainerHash?: string; + tryStatementHash?: string; + resourceCount?: number; + caughtExceptionTypes?: string; + ownerTypeName: string; + ownerQualifiedName: string; + ownerMethodName: string; + + constructor( + kind: BlockKind, + order: number, + filePath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + typeRegistryLinkHash: string, + methodOwnerHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string + ) { + this.kind = kind; + this.order = order; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + this.startColumn = startColumn; + this.endColumn = endColumn; + this.typeRegistryLinkHash = typeRegistryLinkHash; + this.methodOwnerHash = methodOwnerHash; + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + this.ownerMethodName = ownerMethodName; + } + + withNestingDepth(depth: number): BlockRegistryBuilder { + this.nestingDepth = depth; + return this; + } + + withParentContainerHash(hash: string): BlockRegistryBuilder { + this.parentContainerHash = hash; + return this; + } + + withTryStatementHash(hash: string): BlockRegistryBuilder { + this.tryStatementHash = hash; + return this; + } + + withResourceCount(count: number): BlockRegistryBuilder { + this.resourceCount = count; + return this; + } + + withCaughtExceptionTypes(types: string): BlockRegistryBuilder { + this.caughtExceptionTypes = types; + return this; + } + + build(): BlockRegistry { + return new (BlockRegistry as any)(this); + } +} diff --git a/parser/src/analysis-types/java/CommentRegistry.ts b/parser/src/analysis-types/java/CommentRegistry.ts new file mode 100644 index 000000000..d64f36556 --- /dev/null +++ b/parser/src/analysis-types/java/CommentRegistry.ts @@ -0,0 +1,219 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { CommentKind } from '@/enums/java/comments'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a comment in Java source code, linked to the entity it documents. + * + * ## Comment Kinds + * + * - **LINE_COMMENT**: `// single line comment` + * - **BLOCK_COMMENT**: `/* multi-line block comment *​/` + * - **JAVADOC**: `/** documentation comment *​/` + * + * ## Association Rules + * + * Comments are associated with the nearest following declaration or statement: + * - Comments in `class_body` → linked to the next field, method, or inner type + * - Comments in `program` → linked to the next type declaration + * - Comments in method `block` → linked to the next statement or variable + * - End-of-line comments → linked to the entity on the same line + * + * ## Comment Index + * + * When multiple comments precede the same entity, `commentIndex` preserves + * their order (0-based). For example: + * + * ```java + * // Comment 0 + * /** Comment 1 *​/ + * // Comment 2 + * public void method() { ... } + * ``` + * + * All three comments have the same `ownerHash` (the method), with + * `commentIndex` values 0, 1, 2 respectively. + */ +export class CommentRegistry implements EntityIdentifiable { + private kind: CommentKind; + private text: string; + private startLine: number; + private startColumn: number; + private endLine: number; + private endColumn: number; + private ownerHash: string; + private commentIndex: number; + private filePath: string; + private commentUniqueHash: string = ''; + + private constructor(builder: CommentRegistryBuilder) { + this.kind = builder.kind; + this.text = builder.text; + this.startLine = builder.startLine; + this.startColumn = builder.startColumn; + this.endLine = builder.endLine; + this.endColumn = builder.endColumn; + this.ownerHash = builder.ownerHash; + this.commentIndex = builder.commentIndex; + this.filePath = builder.filePath; + + this.generateHash(); + } + + static builder( + kind: CommentKind, + text: string, + filePath: string, + startLine: number, + startColumn: number, + endLine: number, + endColumn: number, + ownerHash: string, + commentIndex: number + ): CommentRegistryBuilder { + return new CommentRegistryBuilder( + kind, text, filePath, + startLine, startColumn, endLine, endColumn, + ownerHash, commentIndex + ); + } + + // === Getters === + + getKind(): CommentKind { + return this.kind; + } + + getText(): string { + return this.text; + } + + getStartLine(): number { + return this.startLine; + } + + getStartColumn(): number { + return this.startColumn; + } + + getEndLine(): number { + return this.endLine; + } + + getEndColumn(): number { + return this.endColumn; + } + + getOwnerHash(): string { + return this.ownerHash; + } + + getCommentIndex(): number { + return this.commentIndex; + } + + getFilePath(): string { + return this.filePath; + } + + getHash(): string { + return this.commentUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.kind + + '||' + + this.startLine + + '||' + + this.startColumn + + '||' + + this.endLine + + '||' + + this.endColumn; + + this.commentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.COMMENT_REGISTRY, + content + ); + } + + getEntryCombined(): string { + return `java_comment[kind=${this.kind}, owner=${this.ownerHash}, index=${this.commentIndex}, lines=${this.startLine}-${this.endLine}, hash=${this.commentUniqueHash}]`; + } + + toCsv(): string { + return [ + this.kind, + EntityUtils.escapeTsv(this.text), + this.startLine.toString(), + this.startColumn.toString(), + this.endLine.toString(), + this.endColumn.toString(), + this.ownerHash, + this.commentIndex.toString(), + this.filePath, + this.commentUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'commentKind', + 'commentText', + 'startLine', + 'startColumn', + 'endLine', + 'endColumn', + 'ownerHash', + 'commentIndex', + 'filePath', + 'commentUniqueHash', + ].join('\t'); + } + +} + +/** + * Builder for CommentRegistry + */ +class CommentRegistryBuilder { + kind: CommentKind; + text: string; + filePath: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + ownerHash: string; + commentIndex: number; + + constructor( + kind: CommentKind, + text: string, + filePath: string, + startLine: number, + startColumn: number, + endLine: number, + endColumn: number, + ownerHash: string, + commentIndex: number + ) { + this.kind = kind; + this.text = text; + this.filePath = filePath; + this.startLine = startLine; + this.startColumn = startColumn; + this.endLine = endLine; + this.endColumn = endColumn; + this.ownerHash = ownerHash; + this.commentIndex = commentIndex; + } + + build(): CommentRegistry { + return new (CommentRegistry as any)(this); + } +} diff --git a/parser/src/analysis-types/java/EnumConstant.ts b/parser/src/analysis-types/java/EnumConstant.ts new file mode 100644 index 000000000..7018d3359 --- /dev/null +++ b/parser/src/analysis-types/java/EnumConstant.ts @@ -0,0 +1,258 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents an enum constant declaration in Java source code. + * + * EnumConstant captures enum constant definitions, including: + * - The constant name (e.g., ACTIVE, PENDING) + * - Constructor arguments passed to the enum constant + * - Position (ordinal) within the enum + * - Whether it has an anonymous class body with overridden methods + * + * ## Examples + * + * ```java + * public enum Status { + * // Simple constant (no arguments) + * UNKNOWN, + * + * // Constant with arguments + * ACTIVE("Active", 1), + * INACTIVE("Inactive", 0), + * + * // Constant with anonymous class body + * PENDING("Pending", 2) { + * @Override + * public boolean isTransient() { + * return true; + * } + * }; + * + * private final String label; + * private final int code; + * + * Status(String label, int code) { + * this.label = label; + * this.code = code; + * } + * } + * ``` + * + * For the above enum, three EnumConstant entities would be created: + * - ACTIVE with arguments ["Active", 1], ordinal 0 + * - INACTIVE with arguments ["Inactive", 0], ordinal 1 + * - PENDING with arguments ["Pending", 2], ordinal 2, hasBody = true + * + * ## Anonymous Class Bodies + * + * When an enum constant has a body (e.g., PENDING above), the `hasBody` flag + * is set to true. Methods within this body are extracted separately by the + * TypeMethodExtractor and linked via the enum's TypeRegistry hash. + * + * ## Arguments + * + * The `arguments` field contains the raw text of each argument passed to the + * enum constant's constructor. For complex expressions, the full expression + * text is captured (e.g., "HttpMethod.GET", "new Date()", "1 + 2"). + * + * ## Field Links + * + * - **typeRegistryLinkHash**: Links to the enclosing enum type + * - **enumConstantUniqueHash**: Unique identifier for this enum constant + * + */ +export class EnumConstant implements EntityIdentifiable { + private name: string; + private qualifiedName: string; + private ordinal: number; + private arguments: string[]; + private argumentsText: string; + private hasBody: boolean; + private filePath: string; + private startLine: number; + private endLine: number; + private typeRegistryLinkHash: string; + private ownerTypeName: string; + private ownerQualifiedName: string; + private serviceVersionLinkHash: string; + private enumConstantUniqueHash: string = ''; + + constructor( + name: string, + qualifiedName: string, + ordinal: number, + args: string[], + hasBody: boolean, + filePath: string, + startLine: number, + endLine: number, + typeRegistryLinkHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionLinkHash: string + ) { + // Validate required fields + if (!name || name.trim().length === 0) { + throw new Error('name is required'); + } + if (ordinal < 0) { + throw new Error('ordinal must be >= 0'); + } + if (!typeRegistryLinkHash || typeRegistryLinkHash.trim().length === 0) { + throw new Error('typeRegistryLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (!filePath || filePath.trim().length === 0) { + throw new Error('filePath is required'); + } + if (startLine <= 0) { + throw new Error('startLine must be > 0'); + } + if (endLine <= 0) { + throw new Error('endLine must be > 0'); + } + if (endLine < startLine) { + throw new Error('endLine must be >= startLine'); + } + + this.name = name; + this.qualifiedName = qualifiedName; + this.ordinal = ordinal; + this.arguments = args; + this.argumentsText = args.join(', '); + this.hasBody = hasBody; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + this.typeRegistryLinkHash = typeRegistryLinkHash; + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + this.serviceVersionLinkHash = serviceVersionLinkHash; + + this.generateHash(); + } + + getName(): string { + return this.name; + } + + getQualifiedName(): string { + return this.qualifiedName; + } + + getOrdinal(): number { + return this.ordinal; + } + + getArguments(): string[] { + return this.arguments; + } + + getArgumentsText(): string { + return this.argumentsText; + } + + getHasBody(): boolean { + return this.hasBody; + } + + getFilePath(): string { + return this.filePath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getTypeRegistryLinkHash(): string { + return this.typeRegistryLinkHash; + } + + getOwnerTypeName(): string { + return this.ownerTypeName; + } + + getOwnerQualifiedName(): string { + return this.ownerQualifiedName; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getEnumConstantUniqueHash(): string { + return this.enumConstantUniqueHash; + } + + getHash(): string { + return this.enumConstantUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.typeRegistryLinkHash + + '||' + + this.name + + '||' + + this.ordinal + + '||' + + this.startLine + + '||' + + this.endLine; + + this.enumConstantUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.ENUM_CONSTANT, + content + ); + } + + getEntryCombined(): string { + return `java_enum_constant[name=${this.name}, ordinal=${this.ordinal}, args=${this.argumentsText}, hasBody=${this.hasBody}, owner=${this.ownerTypeName}, lines ${this.startLine}-${this.endLine}, hash=${this.enumConstantUniqueHash}]`; + } + + toCsv(): string { + return [ + this.name, + this.qualifiedName, + this.ordinal.toString(), + EntityUtils.escapeTsv(this.argumentsText), + this.arguments.length.toString(), + this.hasBody.toString(), + this.filePath, + this.startLine.toString(), + this.endLine.toString(), + this.typeRegistryLinkHash, + this.ownerTypeName, + this.ownerQualifiedName, + this.enumConstantUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'qualifiedName', + 'ordinal', + 'arguments', + 'argumentCount', + 'hasBody', + 'filePath', + 'startLine', + 'endLine', + 'typeRegistryLinkHash', + 'ownerTypeName', + 'ownerQualifiedName', + 'enumConstantUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/java/ExpressionReference.ts b/parser/src/analysis-types/java/ExpressionReference.ts new file mode 100644 index 000000000..ab2c3b12e --- /dev/null +++ b/parser/src/analysis-types/java/ExpressionReference.ts @@ -0,0 +1,585 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + ExpressionKind, + EdgeRole, + RootContext, + ExpressionOwnerKind, + LiteralType, + MethodReferenceKind, + UnaryFixity, + ReferencedEntityKind, +} from '@/enums/java/expressions'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents an expression node in a Java expression tree. + * + * ExpressionReference captures the structure and semantics of expressions in Java source code, + * forming trees that can be analyzed for data flow, dependencies, and code patterns. + * + * ## Expression Tree Structure + * + * Expressions form trees where: + * - **ROOT** is the outermost expression (depth 0, no parent) + * - Children have increasing depth and reference their parent via parentExpressionHash + * - Trees are stored "outside-in" (ROOT first, then children) + * + * ## Key Fields + * + * - **kind**: What type of expression (LITERAL, METHOD_INVOCATION, BINARY_EXPRESSION, etc.) + * - **edgeRole**: Relationship to parent (ROOT, RECEIVER, LEFT_OPERAND, ARGUMENT, etc.) + * - **rootContext**: Where this expression tree is rooted (FIELD_INITIALIZER, RETURN_VALUE, etc.) + * - **expressionOwnerKind**: What kind of entity owns this expression tree + * + * ## Examples + * + * ```java + * // Field initializer: "hello".toUpperCase() + * // Tree structure: + * // ROOT: METHOD_INVOCATION (toUpperCase) + * // └─ RECEIVER: METHOD_INVOCATION (implicit receiver for String literal) + * // └─ RECEIVER: LITERAL "hello" + * + * private String greeting = "hello".toUpperCase(); + * + * // Binary expression: 10 + 20 + * // Tree structure: + * // ROOT: BINARY_EXPRESSION (+) + * // ├─ LEFT_OPERAND: LITERAL 10 + * // └─ RIGHT_OPERAND: LITERAL 20 + * + * private int sum = 10 + 20; + * ``` + */ +export class ExpressionReference implements EntityIdentifiable { + private typeRegistryLinkHash: string; + private expressionOwnerHash: string; + private expressionOwnerKind: ExpressionOwnerKind; + private rootContext: RootContext; + private kind: ExpressionKind; + private edgeRole: EdgeRole; + private parentExpressionHash?: string; + private position: number; + private depth: number; + private literalType?: LiteralType; + private literalValue?: string; + private methodReferenceKind?: MethodReferenceKind; + private unaryFixity?: UnaryFixity; + private operatorString?: string; + private referencedEntityKind?: ReferencedEntityKind; + private referencedEntityHash?: string; + private anonymousTypeHash?: string; + private potentialQualifiedName?: string; + private isAmbiguous?: boolean; + private returnStatementIndex?: number; + private startLine?: number; + private startColumn?: number; + private endLine?: number; + private endColumn?: number; + private expressionUniqueHash: string = ''; + + constructor(builder: ExpressionReferenceBuilder) { + this.typeRegistryLinkHash = builder.typeRegistryLinkHash; + this.expressionOwnerHash = builder.expressionOwnerHash; + this.expressionOwnerKind = builder.expressionOwnerKind; + this.rootContext = builder.rootContext; + this.kind = builder.kind; + this.edgeRole = builder.edgeRole; + this.parentExpressionHash = builder.parentExpressionHash; + this.position = builder.position; + this.depth = builder.depth; + this.literalType = builder.literalType; + this.literalValue = builder.literalValue; + this.methodReferenceKind = builder.methodReferenceKind; + this.unaryFixity = builder.unaryFixity; + this.operatorString = builder.operatorString; + this.referencedEntityKind = builder.referencedEntityKind; + this.referencedEntityHash = builder.referencedEntityHash; + this.anonymousTypeHash = builder.anonymousTypeHash; + this.potentialQualifiedName = builder.potentialQualifiedName; + this.isAmbiguous = builder.isAmbiguous; + this.returnStatementIndex = builder.returnStatementIndex; + this.startLine = builder.startLine; + this.startColumn = builder.startColumn; + this.endLine = builder.endLine; + this.endColumn = builder.endColumn; + } + + getTypeRegistryLinkHash(): string { + return this.typeRegistryLinkHash; + } + + getExpressionOwnerHash(): string { + return this.expressionOwnerHash; + } + + getExpressionOwnerKind(): ExpressionOwnerKind { + return this.expressionOwnerKind; + } + + getRootContext(): RootContext { + return this.rootContext; + } + + getKind(): ExpressionKind { + return this.kind; + } + + getEdgeRole(): EdgeRole { + return this.edgeRole; + } + + getParentExpressionHash(): string | undefined { + return this.parentExpressionHash; + } + + getPosition(): number { + return this.position; + } + + getDepth(): number { + return this.depth; + } + + getLiteralType(): LiteralType | undefined { + return this.literalType; + } + + getLiteralValue(): string | undefined { + return this.literalValue; + } + + getMethodReferenceKind(): MethodReferenceKind | undefined { + return this.methodReferenceKind; + } + + getUnaryFixity(): UnaryFixity | undefined { + return this.unaryFixity; + } + + getOperatorString(): string | undefined { + return this.operatorString; + } + + getReferencedEntityKind(): ReferencedEntityKind | undefined { + return this.referencedEntityKind; + } + + getReferencedEntityHash(): string | undefined { + return this.referencedEntityHash; + } + + getAnonymousTypeHash(): string | undefined { + return this.anonymousTypeHash; + } + + getPotentialQualifiedName(): string | undefined { + return this.potentialQualifiedName; + } + + getIsAmbiguous(): boolean | undefined { + return this.isAmbiguous; + } + + getReturnStatementIndex(): number | undefined { + return this.returnStatementIndex; + } + + getStartLine(): number | undefined { + return this.startLine; + } + + getStartColumn(): number | undefined { + return this.startColumn; + } + + getEndLine(): number | undefined { + return this.endLine; + } + + getEndColumn(): number | undefined { + return this.endColumn; + } + + getExpressionUniqueHash(): string { + return this.expressionUniqueHash; + } + + getHash(): string { + return this.expressionUniqueHash; + } + + generateHash(): void { + const content = + this.typeRegistryLinkHash + + '||' + + this.expressionOwnerHash + + '||' + + this.expressionOwnerKind + + '||' + + this.rootContext + + '||' + + this.kind + + '||' + + this.edgeRole + + '||' + + (this.parentExpressionHash ? this.parentExpressionHash + '||' : '') + + this.position + + '||' + + this.depth + + '||' + + (this.literalType ? this.literalType + '||' : '') + + (this.literalValue !== undefined ? this.literalValue + '||' : '') + + (this.methodReferenceKind ? this.methodReferenceKind + '||' : '') + + (this.unaryFixity ? this.unaryFixity + '||' : '') + + (this.operatorString ? this.operatorString + '||' : '') + + (this.referencedEntityKind ? this.referencedEntityKind + '||' : '') + + (this.referencedEntityHash ? this.referencedEntityHash + '||' : '') + + (this.anonymousTypeHash ? this.anonymousTypeHash + '||' : '') + + (this.startLine !== undefined ? this.startLine + '||' : '') + + (this.startColumn !== undefined ? this.startColumn + '||' : '') + + (this.endLine !== undefined ? this.endLine + '||' : '') + + (this.endColumn !== undefined ? this.endColumn : ''); + + this.expressionUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.EXPRESSION_REFERENCE, + content + ); + } + + getEntryCombined(): string { + const kindInfo = this.kind.toString(); + const roleInfo = this.edgeRole.toString(); + const contextInfo = this.rootContext.toString(); + + const locationInfo = + this.startLine !== undefined && this.endLine !== undefined + ? `lines ${this.startLine}-${this.endLine}` + : 'location unknown'; + + const detailInfo = this.literalType + ? `literal=${this.literalType}:${this.literalValue}` + : this.operatorString + ? `op=${this.operatorString}` + : this.referencedEntityKind + ? `ref=${this.referencedEntityKind}` + : ''; + + return `java_expression[kind=${kindInfo}, role=${roleInfo}, context=${contextInfo}, owner=${this.expressionOwnerKind}, depth=${this.depth}, pos=${this.position}, ${detailInfo ? detailInfo + ', ' : ''}${locationInfo}, hash=${this.expressionUniqueHash}]`; + } + + toCsv(): string { + return [ + this.kind, + this.edgeRole, + this.rootContext, + this.expressionOwnerKind, + this.typeRegistryLinkHash, + this.expressionOwnerHash, + this.parentExpressionHash || '', + this.position.toString(), + this.depth.toString(), + this.literalType || '', + this.literalValue !== undefined ? EntityUtils.escapeTsv(this.literalValue) : '', + this.methodReferenceKind || '', + this.unaryFixity || '', + this.operatorString || '', + this.referencedEntityKind || '', + this.referencedEntityHash || '', + this.anonymousTypeHash || '', + EntityUtils.escapeTsv(this.potentialQualifiedName || ''), + this.isAmbiguous !== undefined ? this.isAmbiguous.toString() : '', + this.returnStatementIndex !== undefined ? this.returnStatementIndex.toString() : '', + this.startLine !== undefined ? this.startLine.toString() : '', + this.startColumn !== undefined ? this.startColumn.toString() : '', + this.endLine !== undefined ? this.endLine.toString() : '', + this.endColumn !== undefined ? this.endColumn.toString() : '', + this.expressionUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'kind', + 'edgeRole', + 'rootContext', + 'expressionOwnerKind', + 'typeRegistryLinkHash', + 'expressionOwnerHash', + 'parentExpressionHash', + 'position', + 'depth', + 'literalType', + 'literalValue', + 'methodReferenceKind', + 'unaryFixity', + 'operatorString', + 'referencedEntityKind', + 'referencedEntityHash', + 'anonymousTypeHash', + 'potentialQualifiedName', + 'isAmbiguous', + 'returnStatementIndex', + 'startLine', + 'startColumn', + 'endLine', + 'endColumn', + 'expressionUniqueHash', + ].join('\t'); + } + + static builder( + typeRegistryLinkHash: string, + expressionOwnerHash: string, + expressionOwnerKind: ExpressionOwnerKind, + rootContext: RootContext, + kind: ExpressionKind, + edgeRole: EdgeRole + ): ExpressionReferenceBuilder { + return new ExpressionReferenceBuilder( + typeRegistryLinkHash, + expressionOwnerHash, + expressionOwnerKind, + rootContext, + kind, + edgeRole + ); + } +} + +/** + * Builder for ExpressionReference with comprehensive validation. + */ +export class ExpressionReferenceBuilder { + typeRegistryLinkHash: string; + expressionOwnerHash: string; + expressionOwnerKind: ExpressionOwnerKind; + rootContext: RootContext; + kind: ExpressionKind; + edgeRole: EdgeRole; + parentExpressionHash?: string; + position: number = 0; + depth: number = 0; + literalType?: LiteralType; + literalValue?: string; + methodReferenceKind?: MethodReferenceKind; + unaryFixity?: UnaryFixity; + operatorString?: string; + referencedEntityKind?: ReferencedEntityKind; + referencedEntityHash?: string; + anonymousTypeHash?: string; + potentialQualifiedName?: string; + isAmbiguous?: boolean; + returnStatementIndex?: number; + startLine?: number; + startColumn?: number; + endLine?: number; + endColumn?: number; + + constructor( + typeRegistryLinkHash: string, + expressionOwnerHash: string, + expressionOwnerKind: ExpressionOwnerKind, + rootContext: RootContext, + kind: ExpressionKind, + edgeRole: EdgeRole + ) { + this.typeRegistryLinkHash = typeRegistryLinkHash; + this.expressionOwnerHash = expressionOwnerHash; + this.expressionOwnerKind = expressionOwnerKind; + this.rootContext = rootContext; + this.kind = kind; + this.edgeRole = edgeRole; + } + + parent(hash: string): this { + this.parentExpressionHash = hash; + return this; + } + + positionAndDepth(position: number, depth: number): this { + this.position = position; + this.depth = depth; + return this; + } + + literal(type: LiteralType, value: string): this { + this.literalType = type; + this.literalValue = value; + return this; + } + + classLiteralTypeName(typeName: string): this { + this.literalValue = typeName; + return this; + } + + qualifiedName(potentialQualifiedName: string, isAmbiguous: boolean): this { + this.potentialQualifiedName = potentialQualifiedName; + this.isAmbiguous = isAmbiguous; + return this; + } + + returnIndex(index: number): this { + this.returnStatementIndex = index; + return this; + } + + methodReference(kind: MethodReferenceKind): this { + this.methodReferenceKind = kind; + return this; + } + + unary(fixity: UnaryFixity): this { + this.unaryFixity = fixity; + return this; + } + + operator(op: string): this { + this.operatorString = op; + return this; + } + + referencesEntity(kind: ReferencedEntityKind, hash?: string): this { + this.referencedEntityKind = kind; + this.referencedEntityHash = hash; + return this; + } + + anonymousType(hash: string): this { + this.anonymousTypeHash = hash; + return this; + } + + location(startLine: number, startColumn: number, endLine: number, endColumn: number): this { + this.startLine = startLine; + this.startColumn = startColumn; + this.endLine = endLine; + this.endColumn = endColumn; + return this; + } + + build(): ExpressionReference { + this.validate(); + const ref = new ExpressionReference(this); + ref.generateHash(); + return ref; + } + + private validate(): void { + this.validateRequiredFields(); + this.validateTreeStructure(); + this.validateKindRequirements(); + this.validateEdgeRoleRequirements(); + } + + private validateRequiredFields(): void { + if (!this.typeRegistryLinkHash || this.typeRegistryLinkHash.trim().length === 0) { + throw new Error('typeRegistryLinkHash is required'); + } + if (!this.expressionOwnerHash || this.expressionOwnerHash.trim().length === 0) { + throw new Error('expressionOwnerHash is required'); + } + if (!this.expressionOwnerKind) { + throw new Error('expressionOwnerKind is required'); + } + if (!this.rootContext) { + throw new Error('rootContext is required'); + } + if (!this.kind) { + throw new Error('kind is required'); + } + if (!this.edgeRole) { + throw new Error('edgeRole is required'); + } + } + + private validateTreeStructure(): void { + if (this.edgeRole === EdgeRole.ROOT) { + if (this.parentExpressionHash) { + throw new Error('ROOT expressions cannot have a parent'); + } + if (this.depth !== 0) { + throw new Error('ROOT expressions must have depth = 0'); + } + } else { + if (!this.parentExpressionHash) { + throw new Error('Non-ROOT expressions must have a parent'); + } + if (this.depth === 0) { + throw new Error('Non-ROOT expressions must have depth > 0'); + } + } + } + + private validateKindRequirements(): void { + switch (this.kind) { + case ExpressionKind.LITERAL: + if (!this.literalType) { + throw new Error('LITERAL expressions require literalType'); + } + if (this.literalValue === undefined) { + throw new Error('LITERAL expressions require literalValue'); + } + break; + + case ExpressionKind.METHOD_REFERENCE: + if (!this.methodReferenceKind) { + throw new Error('METHOD_REFERENCE expressions require methodReferenceKind'); + } + break; + + case ExpressionKind.UNARY_EXPRESSION: + if (!this.unaryFixity) { + throw new Error('UNARY_EXPRESSION requires unaryFixity'); + } + if (!this.operatorString) { + throw new Error('UNARY_EXPRESSION requires operatorString'); + } + break; + + case ExpressionKind.BINARY_EXPRESSION: + if (!this.operatorString) { + throw new Error('BINARY_EXPRESSION requires operatorString'); + } + break; + + case ExpressionKind.ASSIGNMENT_EXPRESSION: + case ExpressionKind.COMPOUND_ASSIGNMENT: + if (!this.operatorString) { + throw new Error(`${this.kind} requires operatorString (=, +=, etc.)`); + } + break; + + case ExpressionKind.ANONYMOUS_CLASS_CREATION: + if (!this.anonymousTypeHash) { + throw new Error('ANONYMOUS_CLASS_CREATION requires anonymousTypeHash'); + } + break; + + // IDENTIFIER_REFERENCE is intentionally excluded - it's ambiguous at extraction time + // and cannot be resolved to TYPE vs FIELD without semantic analysis + case ExpressionKind.FIELD_ACCESS: + case ExpressionKind.METHOD_INVOCATION: + case ExpressionKind.CONSTRUCTOR_INVOCATION: + if (!this.referencedEntityKind) { + throw new Error(`${this.kind} requires referencedEntityKind`); + } + break; + } + } + + private validateEdgeRoleRequirements(): void { + switch (this.edgeRole) { + case EdgeRole.ARGUMENT: + case EdgeRole.ARRAY_ELEMENT: + case EdgeRole.ARRAY_DIMENSION: + case EdgeRole.SWITCH_CASE_LABEL: + if (this.position < 0) { + throw new Error(`${this.edgeRole} requires non-negative position`); + } + break; + } + } +} diff --git a/parser/src/analysis-types/java/FieldRegistry.ts b/parser/src/analysis-types/java/FieldRegistry.ts new file mode 100644 index 000000000..e4c4b9df6 --- /dev/null +++ b/parser/src/analysis-types/java/FieldRegistry.ts @@ -0,0 +1,388 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { FieldModifier } from '@/enums/java/fields'; +import { TypeAccess } from '@/enums/java/types'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a field declaration in Java source code. + * + * FieldRegistry captures field declarations across the codebase, including: + * - Instance fields (regular object state) + * - Static fields (class-level state) + * - Final fields (immutable references) + * - Volatile fields (thread-visible) + * - Transient fields (excluded from serialization) + * - Constants (static final) + * - Interface constants (implicitly public static final) + * - Enum instance fields + * + * ## Examples + * + * ```java + * public class UserService { + * // Instance field + * private String name; + * + * // Static field + * private static int instanceCount; + * + * // Constant + * public static final String VERSION = "1.0"; + * + * // Volatile field + * private volatile boolean running; + * + * // Transient field + * private transient Connection connection; + * + * // Generic field + * private List items; + * + * // Wildcard field + * private List numbers; + * } + * + * public interface Constants { + * // Implicitly public static final + * String NAME = "value"; + * int COUNT = 42; + * } + * ``` + * + * ## Type Information + * + * - **fieldTypeName**: The full type as written in source (e.g., "List") + * - **fieldBaseType**: Base type without generics (e.g., "List") + * - **potentialQualifiedName**: Resolved qualified name (e.g., "java.util.List") + * - **isAmbiguous**: True if qualified name resolution is uncertain (star imports) + * + * ## Modifiers + * + * - **fieldAccess**: Access level (PUBLIC, PROTECTED, PRIVATE, PACKAGE) + * - **modifiers**: Array of field modifiers (STATIC, FINAL, VOLATILE, TRANSIENT) + * + * ## Links + * + * - **typeRegistryLinkHash**: Links to the enclosing type + * - **fieldRegistryUniqueHash**: Unique identifier for this field + * + * ## CSV Export Format + * + * Column order optimized for readability: + * 1. name, fieldTypeName, fieldBaseType, potentialQualifiedName, isAmbiguous + * 2. filePath, startLine, endLine + * 3. typeRegistryLinkHash (owner type) + * 4. ownerTypeName, ownerQualifiedName + * 5. fieldAccess, fieldModifier + * 6. fieldRegistryUniqueHash (LAST - for easy viewing) + */ +export class FieldRegistry implements EntityIdentifiable { + private name: string; + private fieldTypeName: string; + private fieldBaseType: string; + private potentialQualifiedName?: string; + private isAmbiguous: boolean; + private filePath: string; + private startLine: number; + private endLine: number; + private typeRegistryLinkHash: string; + private ownerTypeName: string; + private ownerQualifiedName: string; + private fieldAccess: TypeAccess; + private modifiers: Set; + private serviceVersionLinkHash: string; + private fieldRegistryUniqueHash: string = ''; + + private constructor(builder: FieldRegistryBuilder) { + this.name = builder.name; + this.fieldTypeName = builder.fieldTypeName; + this.fieldBaseType = builder.fieldBaseType; + this.potentialQualifiedName = builder.potentialQualifiedName; + this.isAmbiguous = builder.isAmbiguous; + this.filePath = builder.filePath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.typeRegistryLinkHash = builder.typeRegistryLinkHash; + this.ownerTypeName = builder.ownerTypeName; + this.ownerQualifiedName = builder.ownerQualifiedName; + this.fieldAccess = builder.fieldAccess; + this.modifiers = builder.modifiers; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + name: string, + fieldTypeName: string, + fieldBaseType: string, + filePath: string, + startLine: number, + endLine: number, + typeRegistryLinkHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + fieldAccess: TypeAccess, + serviceVersionLinkHash: string + ): FieldRegistryBuilder { + return new FieldRegistryBuilder( + name, + fieldTypeName, + fieldBaseType, + filePath, + startLine, + endLine, + typeRegistryLinkHash, + ownerTypeName, + ownerQualifiedName, + fieldAccess, + serviceVersionLinkHash + ); + } + + getName(): string { + return this.name; + } + + getFieldTypeName(): string { + return this.fieldTypeName; + } + + getFieldBaseType(): string { + return this.fieldBaseType; + } + + getPotentialQualifiedName(): string | undefined { + return this.potentialQualifiedName; + } + + getIsAmbiguous(): boolean { + return this.isAmbiguous; + } + + getFilePath(): string { + return this.filePath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getTypeRegistryLinkHash(): string { + return this.typeRegistryLinkHash; + } + + getOwnerTypeName(): string { + return this.ownerTypeName; + } + + getOwnerQualifiedName(): string { + return this.ownerQualifiedName; + } + + getFieldAccess(): TypeAccess { + return this.fieldAccess; + } + + getModifiers(): Set { + return this.modifiers; + } + + isStatic(): boolean { + return this.modifiers.has(FieldModifier.STATIC); + } + + isFinal(): boolean { + return this.modifiers.has(FieldModifier.FINAL); + } + + isVolatile(): boolean { + return this.modifiers.has(FieldModifier.VOLATILE); + } + + isTransient(): boolean { + return this.modifiers.has(FieldModifier.TRANSIENT); + } + + /** + * Get modifiers as a comma-separated string for CSV output. + * Returns empty string if no modifiers. + * Example: "STATIC,FINAL" or "VOLATILE" or "" + */ + getFieldModifier(): string { + if (this.modifiers.size === 0) return ''; + return Array.from(this.modifiers).join(','); + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getFieldRegistryUniqueHash(): string { + return this.fieldRegistryUniqueHash; + } + + getHash(): string { + return this.fieldRegistryUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.typeRegistryLinkHash + + '||' + + this.name + + '||' + + this.fieldTypeName + + '||' + + this.startLine; + + this.fieldRegistryUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.FIELD_REGISTRY, + content + ); + } + + getEntryCombined(): string { + return `java_field[name=${this.name}, type=${this.fieldTypeName}, access=${this.fieldAccess}, owner=${this.ownerTypeName}, line ${this.startLine}, hash=${this.fieldRegistryUniqueHash}]`; + } + + toCsv(): string { + return [ + this.name, + EntityUtils.escapeTsv(this.fieldTypeName), + EntityUtils.escapeTsv(this.fieldBaseType), + EntityUtils.escapeTsv(this.potentialQualifiedName || ''), + this.isAmbiguous.toString(), + this.filePath, + this.startLine.toString(), + this.endLine.toString(), + this.typeRegistryLinkHash, + this.ownerTypeName, + this.ownerQualifiedName, + this.fieldAccess, + this.getFieldModifier(), + this.fieldRegistryUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'fieldTypeName', + 'fieldBaseType', + 'potentialQualifiedName', + 'isAmbiguous', + 'filePath', + 'startLine', + 'endLine', + 'typeRegistryLinkHash', + 'ownerTypeName', + 'ownerQualifiedName', + 'fieldAccess', + 'fieldModifier', + 'fieldRegistryUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for FieldRegistry to handle optional parameters + */ +class FieldRegistryBuilder { + name: string; + fieldTypeName: string; + fieldBaseType: string; + potentialQualifiedName?: string; + isAmbiguous: boolean = false; + filePath: string; + startLine: number; + endLine: number; + typeRegistryLinkHash: string; + ownerTypeName: string; + ownerQualifiedName: string; + fieldAccess: TypeAccess; + modifiers: Set = new Set(); + serviceVersionLinkHash: string; + + constructor( + name: string, + fieldTypeName: string, + fieldBaseType: string, + filePath: string, + startLine: number, + endLine: number, + typeRegistryLinkHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + fieldAccess: TypeAccess, + serviceVersionLinkHash: string + ) { + if (!name || name.trim().length === 0) { + throw new Error('name is required'); + } + if (!fieldTypeName || fieldTypeName.trim().length === 0) { + throw new Error('fieldTypeName is required'); + } + if (!typeRegistryLinkHash || typeRegistryLinkHash.trim().length === 0) { + throw new Error('typeRegistryLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (!filePath || filePath.trim().length === 0) { + throw new Error('filePath is required'); + } + if (startLine <= 0) { + throw new Error('startLine must be > 0'); + } + if (endLine <= 0) { + throw new Error('endLine must be > 0'); + } + if (endLine < startLine) { + throw new Error('endLine must be >= startLine'); + } + + this.name = name; + this.fieldTypeName = fieldTypeName; + this.fieldBaseType = fieldBaseType; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + this.typeRegistryLinkHash = typeRegistryLinkHash; + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + this.fieldAccess = fieldAccess; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withPotentialQualifiedName(qualifiedName: string): FieldRegistryBuilder { + this.potentialQualifiedName = qualifiedName; + return this; + } + + withIsAmbiguous(isAmbiguous: boolean): FieldRegistryBuilder { + this.isAmbiguous = isAmbiguous; + return this; + } + + withModifier(modifier: FieldModifier): FieldRegistryBuilder { + this.modifiers.add(modifier); + return this; + } + + withModifiers(modifiers: FieldModifier[]): FieldRegistryBuilder { + modifiers.forEach(m => this.modifiers.add(m)); + return this; + } + + build(): FieldRegistry { + return new (FieldRegistry as any)(this); + } +} diff --git a/parser/src/analysis-types/java/LocalVariableRegistry.ts b/parser/src/analysis-types/java/LocalVariableRegistry.ts new file mode 100644 index 000000000..30181342d --- /dev/null +++ b/parser/src/analysis-types/java/LocalVariableRegistry.ts @@ -0,0 +1,454 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { LocalVariableScopeKind } from '@/enums/java/local-variables'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a local variable declaration in Java source code. + * + * LocalVariableRegistry captures local variable declarations across the codebase, including: + * - Method body variables + * - Constructor body variables + * - Lambda body variables (including nested lambdas) + * - Static/instance initializer block variables + * - Loop variables (for, enhanced-for) + * - Try-with-resources variables + * - Catch clause exception variables + * - Pattern binding variables (instanceof, switch, record patterns) + * + * ## Examples + * + * ```java + * public class Example { + * static { + * int staticBlockVar = 10; // STATIC_INITIALIZER scope + * } + * + * public void method(String param) { + * // Basic declarations + * int count = 42; // METHOD_BODY scope + * final String name = "test"; // METHOD_BODY, isFinal=true + * var inferred = new ArrayList<>(); // METHOD_BODY, isVarInferred=true + * + * // Lambda with nested scopes + * Supplier s = () -> { + * int lambdaVar = 5; // LAMBDA_BODY, scopeDepth=1 + * return (() -> { + * int nested = 10; // LAMBDA_BODY, scopeDepth=2 + * return nested; + * }).get(); + * }; + * + * // Loop variables + * for (int i = 0; i < 10; i++) { // FOR_LOOP scope + * int loopBody = i * 2; // METHOD_BODY (inside loop) + * } + * + * for (String item : items) { // ENHANCED_FOR_LOOP scope + * String processed = item.trim(); // METHOD_BODY (inside loop) + * } + * + * // Exception handling + * try (var reader = getReader()) { // TRY_WITH_RESOURCES scope + * String line = reader.readLine(); + * } catch (IOException e) { // CATCH_CLAUSE scope + * String msg = e.getMessage(); + * } + * + * // Pattern matching (Java 16+) + * if (obj instanceof String s) { // INSTANCEOF_PATTERN scope + * int len = s.length(); + * } + * } + * } + * ``` + * + * ## Type Information + * + * - **variableTypeName**: The full type as written in source (e.g., "List") + * - **variableBaseType**: Base type without generics (e.g., "List") + * - **potentialQualifiedName**: Resolved qualified name (e.g., "java.util.List") + * - **isAmbiguous**: True if qualified name resolution is uncertain (star imports) + * + * ## Scope Information + * + * - **scopeKind**: Classification of where the variable is declared + * - **scopeDepth**: Nesting level for lambdas (0 = not in lambda, 1+ = lambda depth) + * - **methodRegistryLinkHash**: Links to the enclosing method (if applicable) + * - **typeRegistryLinkHash**: Links to the enclosing type + * + * ## Modifiers + * + * - **isFinal**: True if declared with `final` keyword + * - **isVarInferred**: True if using `var` type inference (Java 10+) + * + * ## Links + * + * - **typeRegistryLinkHash**: Links to the enclosing type + * - **methodRegistryLinkHash**: Links to the enclosing method (null for initializer blocks) + * - **localVariableRegistryUniqueHash**: Unique identifier for this variable + * + * ## CSV Export Format + * + * Column order optimized for readability: + * 1. name, variableTypeName, variableBaseType, potentialQualifiedName, isAmbiguous + * 2. filePath, startLine, endLine + * 3. scopeKind, scopeDepth, isFinal, isVarInferred + * 4. typeRegistryLinkHash, methodRegistryLinkHash + * 5. ownerTypeName, ownerQualifiedName, ownerMethodName + * 6. localVariableRegistryUniqueHash (LAST - for easy viewing) + */ +export class LocalVariableRegistry implements EntityIdentifiable { + private name: string; + private variableTypeName: string; + private variableBaseType: string; + private potentialQualifiedName?: string; + private isAmbiguous: boolean; + private filePath: string; + private startLine: number; + private endLine: number; + private scopeKind: LocalVariableScopeKind; + private scopeDepth: number; + private isFinal: boolean; + private isVarInferred: boolean; + private typeRegistryLinkHash: string; + private methodRegistryLinkHash?: string; + private ownerTypeName: string; + private ownerQualifiedName: string; + private ownerMethodName?: string; + private serviceVersionLinkHash: string; + private parentExpressionLinkHash?: string; + private localVariableRegistryUniqueHash: string = ''; + + private constructor(builder: LocalVariableRegistryBuilder) { + this.name = builder.name; + this.variableTypeName = builder.variableTypeName; + this.variableBaseType = builder.variableBaseType; + this.potentialQualifiedName = builder.potentialQualifiedName; + this.isAmbiguous = builder.isAmbiguous; + this.filePath = builder.filePath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.scopeKind = builder.scopeKind; + this.scopeDepth = builder.scopeDepth; + this.isFinal = builder.isFinal; + this.isVarInferred = builder.isVarInferred; + this.typeRegistryLinkHash = builder.typeRegistryLinkHash; + this.methodRegistryLinkHash = builder.methodRegistryLinkHash; + this.ownerTypeName = builder.ownerTypeName; + this.ownerQualifiedName = builder.ownerQualifiedName; + this.ownerMethodName = builder.ownerMethodName; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + this.parentExpressionLinkHash = builder.parentExpressionLinkHash; + + this.generateHash(); + } + + static builder( + name: string, + variableTypeName: string, + variableBaseType: string, + filePath: string, + startLine: number, + endLine: number, + scopeKind: LocalVariableScopeKind, + typeRegistryLinkHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionLinkHash: string + ): LocalVariableRegistryBuilder { + return new LocalVariableRegistryBuilder( + name, + variableTypeName, + variableBaseType, + filePath, + startLine, + endLine, + scopeKind, + typeRegistryLinkHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionLinkHash + ); + } + + getName(): string { + return this.name; + } + + getVariableTypeName(): string { + return this.variableTypeName; + } + + getVariableBaseType(): string { + return this.variableBaseType; + } + + getPotentialQualifiedName(): string | undefined { + return this.potentialQualifiedName; + } + + getIsAmbiguous(): boolean { + return this.isAmbiguous; + } + + getFilePath(): string { + return this.filePath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getScopeKind(): LocalVariableScopeKind { + return this.scopeKind; + } + + getScopeDepth(): number { + return this.scopeDepth; + } + + getIsFinal(): boolean { + return this.isFinal; + } + + getIsVarInferred(): boolean { + return this.isVarInferred; + } + + getTypeRegistryLinkHash(): string { + return this.typeRegistryLinkHash; + } + + getMethodRegistryLinkHash(): string | undefined { + return this.methodRegistryLinkHash; + } + + getOwnerTypeName(): string { + return this.ownerTypeName; + } + + getOwnerQualifiedName(): string { + return this.ownerQualifiedName; + } + + getOwnerMethodName(): string | undefined { + return this.ownerMethodName; + } + + getParentExpressionLinkHash(): string | undefined { + return this.parentExpressionLinkHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getLocalVariableRegistryUniqueHash(): string { + return this.localVariableRegistryUniqueHash; + } + + getHash(): string { + return this.localVariableRegistryUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.typeRegistryLinkHash + + '||' + + (this.methodRegistryLinkHash || 'NO_METHOD') + + '||' + + this.name + + '||' + + this.variableTypeName + + '||' + + this.startLine + + '||' + + this.scopeKind + + '||' + + this.scopeDepth; + + this.localVariableRegistryUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.LOCAL_VARIABLE_REGISTRY, + content + ); + } + + getEntryCombined(): string { + return `java_local_var[name=${this.name}, type=${this.variableTypeName}, scope=${this.scopeKind}, depth=${this.scopeDepth}, method=${this.ownerMethodName || 'N/A'}, line ${this.startLine}, hash=${this.localVariableRegistryUniqueHash}]`; + } + + toCsv(): string { + return [ + this.name, + EntityUtils.escapeTsv(this.variableTypeName), + EntityUtils.escapeTsv(this.variableBaseType), + EntityUtils.escapeTsv(this.potentialQualifiedName || ''), + this.isAmbiguous.toString(), + this.filePath, + this.startLine.toString(), + this.endLine.toString(), + this.scopeKind, + this.scopeDepth.toString(), + this.isFinal.toString(), + this.isVarInferred.toString(), + this.typeRegistryLinkHash, + this.methodRegistryLinkHash || '', + this.ownerTypeName, + this.ownerQualifiedName, + this.ownerMethodName || '', + this.parentExpressionLinkHash || '', + this.localVariableRegistryUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'variableTypeName', + 'variableBaseType', + 'potentialQualifiedName', + 'isAmbiguous', + 'filePath', + 'startLine', + 'endLine', + 'scopeKind', + 'scopeDepth', + 'isFinal', + 'isVarInferred', + 'typeRegistryLinkHash', + 'methodRegistryLinkHash', + 'ownerTypeName', + 'ownerQualifiedName', + 'ownerMethodName', + 'parentExpressionLinkHash', + 'localVariableRegistryUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for LocalVariableRegistry to handle optional parameters + */ +class LocalVariableRegistryBuilder { + name: string; + variableTypeName: string; + variableBaseType: string; + potentialQualifiedName?: string; + isAmbiguous: boolean = false; + filePath: string; + startLine: number; + endLine: number; + scopeKind: LocalVariableScopeKind; + scopeDepth: number = 0; + isFinal: boolean = false; + isVarInferred: boolean = false; + typeRegistryLinkHash: string; + methodRegistryLinkHash?: string; + ownerTypeName: string; + ownerQualifiedName: string; + ownerMethodName?: string; + serviceVersionLinkHash: string; + parentExpressionLinkHash?: string; + + constructor( + name: string, + variableTypeName: string, + variableBaseType: string, + filePath: string, + startLine: number, + endLine: number, + scopeKind: LocalVariableScopeKind, + typeRegistryLinkHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionLinkHash: string + ) { + if (!name || name.trim().length === 0) { + throw new Error('name is required'); + } + if (!variableTypeName || variableTypeName.trim().length === 0) { + throw new Error('variableTypeName is required'); + } + if (!typeRegistryLinkHash || typeRegistryLinkHash.trim().length === 0) { + throw new Error('typeRegistryLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (!filePath || filePath.trim().length === 0) { + throw new Error('filePath is required'); + } + if (startLine <= 0) { + throw new Error('startLine must be > 0'); + } + if (endLine <= 0) { + throw new Error('endLine must be > 0'); + } + if (endLine < startLine) { + throw new Error('endLine must be >= startLine'); + } + + this.name = name; + this.variableTypeName = variableTypeName; + this.variableBaseType = variableBaseType; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + this.scopeKind = scopeKind; + this.typeRegistryLinkHash = typeRegistryLinkHash; + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withPotentialQualifiedName(qualifiedName: string): LocalVariableRegistryBuilder { + this.potentialQualifiedName = qualifiedName; + return this; + } + + withIsAmbiguous(isAmbiguous: boolean): LocalVariableRegistryBuilder { + this.isAmbiguous = isAmbiguous; + return this; + } + + withScopeDepth(depth: number): LocalVariableRegistryBuilder { + this.scopeDepth = depth; + return this; + } + + withIsFinal(isFinal: boolean): LocalVariableRegistryBuilder { + this.isFinal = isFinal; + return this; + } + + withIsVarInferred(isVarInferred: boolean): LocalVariableRegistryBuilder { + this.isVarInferred = isVarInferred; + return this; + } + + withMethodRegistryLinkHash(methodHash: string): LocalVariableRegistryBuilder { + this.methodRegistryLinkHash = methodHash; + return this; + } + + withOwnerMethodName(methodName: string): LocalVariableRegistryBuilder { + this.ownerMethodName = methodName; + return this; + } + + withParentExpressionLinkHash(expressionHash: string): LocalVariableRegistryBuilder { + this.parentExpressionLinkHash = expressionHash; + return this; + } + + build(): LocalVariableRegistry { + return new (LocalVariableRegistry as any)(this); + } +} diff --git a/parser/src/analysis-types/java/ModuleDirective.ts b/parser/src/analysis-types/java/ModuleDirective.ts new file mode 100644 index 000000000..bae0656c2 --- /dev/null +++ b/parser/src/analysis-types/java/ModuleDirective.ts @@ -0,0 +1,158 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { ModuleDirectiveKind, ModuleDirectiveModifier } from '@/enums/java/modules'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One directive inside a module declaration (JLS 7.7.1 - 7.7.4). + * + * A directive that names several targets becomes several rows, one per target, differing only in + * `targetName` and `position`. `exports com.example.internal to a, b` is two rows; a plain + * `exports com.example.api` is one row with an empty `targetName`. Keeping the list flat means a + * consumer joins on a column instead of splitting a delimited string, and an unqualified export + * is distinguishable from a qualified one by `targetName` being empty rather than by parsing. + * + * See {@link ModuleDirectiveKind} for what subject and target mean per kind. + */ +export class ModuleDirective implements EntityIdentifiable { + private readonly moduleRegistryLinkHash: string; + private readonly directiveKind: ModuleDirectiveKind; + private readonly subjectName: string; + private readonly targetName: string; + private readonly modifiers: ModuleDirectiveModifier[]; + private readonly position: number; + private readonly filePath: string; + private readonly startLine: number; + private readonly endLine: number; + private readonly serviceVersionLinkHash: string; + private moduleDirectiveUniqueHash = ''; + + constructor( + moduleRegistryLinkHash: string, + directiveKind: ModuleDirectiveKind, + subjectName: string, + targetName: string, + modifiers: ModuleDirectiveModifier[], + position: number, + filePath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ) { + if (!moduleRegistryLinkHash || moduleRegistryLinkHash.trim().length === 0) { + throw new Error('moduleRegistryLinkHash is required'); + } + if (!directiveKind) { + throw new Error('directiveKind is required'); + } + if (!subjectName || subjectName.trim().length === 0) { + throw new Error('subjectName is required'); + } + if (position < 0) { + throw new Error('position must be >= 0'); + } + + this.moduleRegistryLinkHash = moduleRegistryLinkHash; + this.directiveKind = directiveKind; + this.subjectName = subjectName; + this.targetName = targetName; + this.modifiers = modifiers; + this.position = position; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + + this.generateHash(); + } + + getModuleRegistryLinkHash(): string { + return this.moduleRegistryLinkHash; + } + + getDirectiveKind(): ModuleDirectiveKind { + return this.directiveKind; + } + + getSubjectName(): string { + return this.subjectName; + } + + getTargetName(): string { + return this.targetName; + } + + getModifiers(): ModuleDirectiveModifier[] { + return this.modifiers; + } + + getPosition(): number { + return this.position; + } + + getStartLine(): number { + return this.startLine; + } + + getHash(): string { + return this.moduleDirectiveUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.moduleRegistryLinkHash + + '||' + + this.directiveKind + + '||' + + this.subjectName + + '||' + + this.targetName + + '||' + + this.position + + '||' + + this.startLine; + + this.moduleDirectiveUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.MODULE_DIRECTIVE, + content + ); + } + + getEntryCombined(): string { + return `java_module_directive[kind=${this.directiveKind}, subject=${this.subjectName}, target=${this.targetName}, modifiers=${this.modifiers.join(',')}, position=${this.position}, line=${this.startLine}, hash=${this.moduleDirectiveUniqueHash}]`; + } + + toCsv(): string { + return [ + this.directiveKind, + EntityUtils.escapeTsv(this.subjectName), + EntityUtils.escapeTsv(this.targetName), + this.modifiers.join(','), + this.position.toString(), + this.filePath, + this.startLine.toString(), + this.endLine.toString(), + this.moduleRegistryLinkHash, + this.serviceVersionLinkHash, + this.moduleDirectiveUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'directiveKind', + 'subjectName', + 'targetName', + 'modifiers', + 'position', + 'filePath', + 'startLine', + 'endLine', + 'moduleRegistryLinkHash', + 'serviceVersionLinkHash', + 'moduleDirectiveUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/java/ModuleRegistry.ts b/parser/src/analysis-types/java/ModuleRegistry.ts new file mode 100644 index 000000000..93f54be6b --- /dev/null +++ b/parser/src/analysis-types/java/ModuleRegistry.ts @@ -0,0 +1,130 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a `module-info.java` module declaration (JLS 7.7). + * + * A module declaration is the only place the JPMS module graph is written down. It is not + * derivable from imports: `requires` names a module, not a package, and `exports` decides + * whether a public type is reachable at all from outside the module. + * + * ```java + * // module-info.java + * open module com.example.app { + * requires java.sql; + * exports com.example.api; + * } + * // name: "com.example.app" + * // isOpen: true + * ``` + * + * ## Field descriptions + * + * - **name** — the module name as written, e.g. `com.example.app` + * - **isOpen** — `open module ...`, which opens every package for deep reflection. A directive + * list may then contain no `opens`, because the whole module is already open. + * - **filePath** — the declaring file, always named `module-info.java` + * - **startLine** / **endLine** — the extent of the declaration + */ +export class ModuleRegistry implements EntityIdentifiable { + private readonly name: string; + private readonly isOpen: boolean; + private readonly filePath: string; + private readonly startLine: number; + private readonly endLine: number; + private readonly serviceVersionLinkHash: string; + private moduleUniqueHash = ''; + + constructor( + name: string, + isOpen: boolean, + filePath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ) { + if (!name || name.trim().length === 0) { + throw new Error('name is required'); + } + if (!filePath || filePath.trim().length === 0) { + throw new Error('filePath is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + + this.name = name; + this.isOpen = isOpen; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + + this.generateHash(); + } + + getName(): string { + return this.name; + } + + getIsOpen(): boolean { + return this.isOpen; + } + + getFilePath(): string { + return this.filePath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getHash(): string { + return this.moduleUniqueHash; + } + + generateHash(): void { + const content = this.filePath + '||' + this.name + '||' + this.startLine; + this.moduleUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.MODULE_REGISTRY, + content + ); + } + + getEntryCombined(): string { + return `java_module[name=${this.name}, isOpen=${this.isOpen}, lines ${this.startLine}-${this.endLine}, hash=${this.moduleUniqueHash}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.name), + this.isOpen.toString(), + this.filePath, + this.startLine.toString(), + this.endLine.toString(), + this.serviceVersionLinkHash, + this.moduleUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'isOpen', + 'filePath', + 'startLine', + 'endLine', + 'serviceVersionLinkHash', + 'moduleUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/java/TypeAnnotation.ts b/parser/src/analysis-types/java/TypeAnnotation.ts new file mode 100644 index 000000000..1b7d91556 --- /dev/null +++ b/parser/src/analysis-types/java/TypeAnnotation.ts @@ -0,0 +1,302 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { AnnotationContext, AnnotationKind } from '@/enums'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a type annotation in Java source code. + * + * TypeAnnotation captures annotation usage across the codebase, including: + * - Simple marker annotations: @Deprecated, @Nullable + * - Parameterized annotations: @Something(name = "...", value = 42) + * - Single-value shorthand: @Timeout(1000) + * - Array and brace-based values: @Something({ "a", "b" }) + * - Nested annotations: @Something(meta = @Other(x = 1)) + * - Meta-annotations on annotation declarations: @Retention, @Target + * - Type-use annotations: List<@NonNull String> + * - Type parameter annotations: class Box<@NonNull T> (Java 8+) + * + * ## Examples + * + * ```java + * @Deprecated // MARKER, context: TYPE_DECLARATION + * @Timeout(1000) // SINGLE_VALUE, context: METHOD_DECLARATION + * @Column(name = "id", nullable = false) // NAMED_ARGUMENTS, context: FIELD_DECLARATION + * @Target({ ElementType.TYPE }) // NAMED_ARGUMENTS with array, context: ANNOTATION_TYPE_DECLARATION + * List<@NonNull String> items; // TYPE_USE (not currently extracted) + * + * // Type parameter annotations (Java 8+) + * class Container<@NonNull T, // context: TYPE_PARAMETER, typeParameterHash: hash of T + * @Validated(validator = SizeValidator.class) U> { // Links to type parameter U + * // typeParameterHash enables linking annotations to specific type parameters + * } + * ``` + * + * ## Field Links + * + * - **typeRegistryHash**: Always links to the enclosing type + * - **typeParameterHash**: Only set for annotations on type parameters (context: TYPE_PARAMETER) + * - **parentAnnotationHash**: Set for nested annotations (depth > 0) + * - **ownerHash**: Links to the specific entity being annotated (type, field, method, parameter, etc.) + */ +export class TypeAnnotation implements EntityIdentifiable { + private annotationName: string; + private kind: AnnotationKind; + private context: AnnotationContext; + private ownerHash: string; + private typeRegistryHash?: string; + private typeParameterHash?: string; + private parentAnnotationHash?: string; + private depth: number; + private position: number; + private startLine?: number; + private endLine?: number; + private isMetaAnnotation: boolean; + private typeAnnotationUniqueHash: string = ''; + + constructor(builder: TypeAnnotationBuilder) { + this.annotationName = builder.annotationName; + this.kind = builder.kind; + this.context = builder.context; + this.ownerHash = builder.ownerHash; + this.typeRegistryHash = builder.typeRegistryHash; + this.typeParameterHash = builder.typeParameterHash; + this.parentAnnotationHash = builder.parentAnnotationHash; + this.depth = builder.depth; + this.position = builder.position; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.isMetaAnnotation = builder.isMetaAnnotation; + } + + getAnnotationName(): string { + return this.annotationName; + } + + getKind(): AnnotationKind { + return this.kind; + } + + getContext(): AnnotationContext { + return this.context; + } + + getOwnerHash(): string { + return this.ownerHash; + } + + getTypeRegistryHash(): string | undefined { + return this.typeRegistryHash; + } + + getTypeParameterHash(): string | undefined { + return this.typeParameterHash; + } + + getParentAnnotationHash(): string | undefined { + return this.parentAnnotationHash; + } + + getDepth(): number { + return this.depth; + } + + getPosition(): number { + return this.position; + } + + getStartLine(): number | undefined { + return this.startLine; + } + + getEndLine(): number | undefined { + return this.endLine; + } + + getIsMetaAnnotation(): boolean { + return this.isMetaAnnotation; + } + + getTypeAnnotationUniqueHash(): string { + return this.typeAnnotationUniqueHash; + } + + getHash(): string { + return this.typeAnnotationUniqueHash; + } + + generateHash(): void { + const content = + this.annotationName + + '||' + + this.kind + + '||' + + this.context + + '||' + + this.ownerHash + + '||' + + (this.typeRegistryHash ? this.typeRegistryHash + '||' : '') + + (this.typeParameterHash ? this.typeParameterHash + '||' : '') + + (this.parentAnnotationHash ? this.parentAnnotationHash + '||' : '') + + this.depth + + '||' + + this.position + + '||' + + (this.startLine !== undefined ? this.startLine + '||' : '') + + (this.endLine !== undefined ? this.endLine + '||' : '') + + this.isMetaAnnotation; + + this.typeAnnotationUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TYPE_ANNOTATION, + content + ); + } + + getEntryCombined(): string { + const locationInfo = + this.startLine !== undefined && this.endLine !== undefined + ? `lines ${this.startLine}-${this.endLine}` + : 'location unknown'; + + return `java_type_annotation[name=@${this.annotationName}, kind=${this.kind}, context=${this.context}, owner=${this.ownerHash}, depth=${this.depth}, meta=${this.isMetaAnnotation}, ${locationInfo}, hash=${this.typeAnnotationUniqueHash}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.annotationName), + this.kind, + this.context, + this.ownerHash, + this.typeRegistryHash || '', + this.typeParameterHash || '', + this.parentAnnotationHash || '', + this.depth.toString(), + this.position.toString(), + this.startLine !== undefined ? this.startLine.toString() : '', + this.endLine !== undefined ? this.endLine.toString() : '', + this.isMetaAnnotation.toString(), + this.typeAnnotationUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'annotationName', + 'kind', + 'context', + 'ownerHash', + 'typeRegistryHash', + 'typeParameterHash', + 'parentAnnotationHash', + 'depth', + 'position', + 'startLine', + 'endLine', + 'isMetaAnnotation', + 'typeAnnotationUniqueHash', + ].join('\t'); + } + + static builder( + annotationName: string, + kind: AnnotationKind, + context: AnnotationContext, + ownerHash: string + ): TypeAnnotationBuilder { + return new TypeAnnotationBuilder(annotationName, kind, context, ownerHash); + } +} + +/** + * Builder for TypeAnnotation with validation. + */ +export class TypeAnnotationBuilder { + annotationName: string; + kind: AnnotationKind; + context: AnnotationContext; + ownerHash: string; + typeRegistryHash?: string; + typeParameterHash?: string; + parentAnnotationHash?: string; + depth: number = 0; + position: number = 0; + startLine?: number; + endLine?: number; + isMetaAnnotation: boolean = false; + + constructor( + annotationName: string, + kind: AnnotationKind, + context: AnnotationContext, + ownerHash: string + ) { + this.annotationName = annotationName; + this.kind = kind; + this.context = context; + this.ownerHash = ownerHash; + } + + typeRegistry(hash: string): this { + this.typeRegistryHash = hash; + return this; + } + + typeParameter(hash: string): this { + this.typeParameterHash = hash; + return this; + } + + parentAnnotation(hash: string): this { + this.parentAnnotationHash = hash; + return this; + } + + setDepth(depth: number): this { + this.depth = depth; + return this; + } + + setPosition(position: number): this { + this.position = position; + return this; + } + + location(startLine: number, endLine: number): this { + this.startLine = startLine; + this.endLine = endLine; + return this; + } + + metaAnnotation(isMeta: boolean): this { + this.isMetaAnnotation = isMeta; + return this; + } + + build(): TypeAnnotation { + this.validate(); + const annotation = new TypeAnnotation(this); + annotation.generateHash(); + return annotation; + } + + private validate(): void { + if (!this.annotationName || this.annotationName.trim().length === 0) { + throw new Error('annotationName is required'); + } + if (!this.kind) { + throw new Error('kind is required'); + } + if (!this.context) { + throw new Error('context is required'); + } + if (!this.ownerHash || this.ownerHash.trim().length === 0) { + throw new Error('ownerHash is required'); + } + if (this.depth < 0) { + throw new Error('depth must be >= 0'); + } + if (this.position < 0) { + throw new Error('position must be >= 0'); + } + } +} diff --git a/parser/src/analysis-types/java/TypeParameter.ts b/parser/src/analysis-types/java/TypeParameter.ts new file mode 100644 index 000000000..a4e84a7b6 --- /dev/null +++ b/parser/src/analysis-types/java/TypeParameter.ts @@ -0,0 +1,117 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a type parameter (generic parameter) in Java + * Example: In `class Box`, T is a type parameter + * Example: In `class Pair`, K and V are type parameters at positions 0 and 1 + */ +export class TypeParameter implements EntityIdentifiable { + private name: string; + private position: number; + private ownerTypeName: string; + private ownerQualifiedName: string; + private filePath: string; + private startLine: number; + private typeRegistryLinkHash: string; + private typeParameterUniqueHash: string = ''; + + constructor( + name: string, + position: number, + ownerTypeName: string, + ownerQualifiedName: string, + filePath: string, + startLine: number, + typeRegistryLinkHash: string + ) { + this.name = name; + this.position = position; + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + this.filePath = filePath; + this.startLine = startLine; + this.typeRegistryLinkHash = typeRegistryLinkHash; + } + + getName(): string { + return this.name; + } + + getPosition(): number { + return this.position; + } + + getOwnerTypeName(): string { + return this.ownerTypeName; + } + + getOwnerQualifiedName(): string { + return this.ownerQualifiedName; + } + + getFilePath(): string { + return this.filePath; + } + + getStartLine(): number { + return this.startLine; + } + + getTypeRegistryLinkHash(): string { + return this.typeRegistryLinkHash; + } + + getTypeParameterUniqueHash(): string { + return this.typeParameterUniqueHash; + } + + getHash(): string { + return this.typeParameterUniqueHash; + } + + generateHash(): void { + const content = `${this.name}||${this.position}||${this.ownerQualifiedName}||${this.typeRegistryLinkHash}`; + this.typeParameterUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TYPE_PARAMETER, + content + ); + } + + getEntryCombined(): string { + return `java_type_parameter[name=${this.name}, position=${this.position}, owner=${this.ownerQualifiedName}, hash=${this.typeParameterUniqueHash}]`; + } + + /** + * Converts TypeParameter to CSV row format + */ + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.name), + this.position.toString(), + EntityUtils.escapeTsv(this.ownerTypeName), + EntityUtils.escapeTsv(this.ownerQualifiedName), + this.filePath, + this.startLine.toString(), + this.typeRegistryLinkHash, + this.typeParameterUniqueHash, + ].join('\t'); + } + + /** + * Returns CSV header for TypeParameter export + */ + getCsvHeader(): string { + return [ + 'paramName', + 'position', + 'ownerTypeName', + 'ownerQualifiedName', + 'filePath', + 'startLine', + 'typeRegistryLinkHash', + 'typeParameterUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/java/TypeReference.ts b/parser/src/analysis-types/java/TypeReference.ts new file mode 100644 index 000000000..7edab5c1c --- /dev/null +++ b/parser/src/analysis-types/java/TypeReference.ts @@ -0,0 +1,560 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TypeRefKind, + TypeRefContext, + ReferenceOwnerKind, + WildcardVariance, +} from '@/enums/java/type-references'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityJavaUtils } from '@/utils/java/entity-java-utils'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a type reference in Java source code. + * + * TypeReference captures how types are used throughout the codebase, forming a complete + * graph of type dependencies. It supports nested generics, wildcards, type variables, + * arrays, and primitives. + * + * ## Field Notes + * + * - **typeName**: The simple name as it appears in source code (e.g., `Number`, `List`, `String`). + * - **typeVariableName**: Used only for TYPE_VARIABLE kind (e.g., `T`, `E`, `K`). + * + * ## Examples + * + * ```java + * class UserService // TYPE_PARAM_BOUND: BaseEntity + * extends AbstractService // SUPER_TYPE: AbstractService + * implements Repository { // IMPLEMENTS_INTERFACE: Repository + * + * private List names; // FIELD_TYPE: List + * public Optional find(Long id) { } // METHOD_RETURN: Optional, METHOD_PARAM: Long + * } + * ``` + */ +export class TypeReference implements EntityIdentifiable { + private typeRegistryLinkHash: string; + private typeParameterLinkHash?: string; + private referencedTypeRegistryLinkHash?: string; + private kind: TypeRefKind; + private context: TypeRefContext; + private wildcardVariance?: WildcardVariance; + private parentReferenceHash?: string; + private position: number; + private depth: number; + private typeName?: string; + private completeTypeName?: string; + private typeVariableName?: string; + private arrayDimensions?: number; + private startLine?: number; + private endLine?: number; + private typeReferenceOwnerHash: string; + private referenceOwnerKind: ReferenceOwnerKind; + private typeReferenceUniqueHash: string = ''; + + constructor(builder: TypeReferenceBuilder) { + this.typeRegistryLinkHash = builder.typeRegistryLinkHash; + this.typeParameterLinkHash = builder.typeParameterLinkHash; + this.referencedTypeRegistryLinkHash = builder.referencedTypeRegistryLinkHash; + this.kind = builder.kind; + this.context = builder.context; + this.wildcardVariance = builder.wildcardVariance; + this.parentReferenceHash = builder.parentReferenceHash; + this.position = builder.position; + this.depth = builder.depth; + this.typeName = builder.typeName; + this.completeTypeName = builder.completeTypeName; + this.typeVariableName = builder.typeVariableName; + this.arrayDimensions = builder.arrayDimensions; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.typeReferenceOwnerHash = builder.typeReferenceOwnerHash; + this.referenceOwnerKind = builder.referenceOwnerKind; + } + + getTypeRegistryLinkHash(): string { + return this.typeRegistryLinkHash; + } + + getTypeParameterLinkHash(): string | undefined { + return this.typeParameterLinkHash; + } + + getReferencedTypeRegistryLinkHash(): string | undefined { + return this.referencedTypeRegistryLinkHash; + } + + getKind(): TypeRefKind { + return this.kind; + } + + getContext(): TypeRefContext { + return this.context; + } + + getWildcardVariance(): WildcardVariance | undefined { + return this.wildcardVariance; + } + + getParentReferenceHash(): string | undefined { + return this.parentReferenceHash; + } + + getPosition(): number { + return this.position; + } + + getDepth(): number { + return this.depth; + } + + getTypeName(): string | undefined { + return this.typeName; + } + + getCompleteTypeName(): string | undefined { + return this.completeTypeName; + } + + getTypeVariableName(): string | undefined { + return this.typeVariableName; + } + + getArrayDimensions(): number | undefined { + return this.arrayDimensions; + } + + getStartLine(): number | undefined { + return this.startLine; + } + + getEndLine(): number | undefined { + return this.endLine; + } + + getTypeReferenceOwnerHash(): string { + return this.typeReferenceOwnerHash; + } + + getReferenceOwnerKind(): ReferenceOwnerKind { + return this.referenceOwnerKind; + } + + getTypeReferenceUniqueHash(): string { + return this.typeReferenceUniqueHash; + } + + getHash(): string { + return this.typeReferenceUniqueHash; + } + + generateHash(): void { + const content = + this.typeRegistryLinkHash + + '||' + + (this.typeParameterLinkHash ? this.typeParameterLinkHash + '||' : '') + + (this.referencedTypeRegistryLinkHash ? this.referencedTypeRegistryLinkHash + '||' : '') + + this.kind + + '||' + + this.context + + '||' + + (this.parentReferenceHash ? this.parentReferenceHash + '||' : '') + + this.position + + '||' + + this.depth + + '||' + + (this.typeName ? this.typeName + '||' : '') + + (this.wildcardVariance ? this.wildcardVariance + '||' : '') + + (this.typeVariableName ? this.typeVariableName + '||' : '') + + (this.arrayDimensions !== undefined ? this.arrayDimensions + '||' : '') + + (this.startLine !== undefined ? this.startLine + '||' : '') + + (this.endLine !== undefined ? this.endLine : '') + + this.typeReferenceOwnerHash + + '||' + + this.referenceOwnerKind; + + this.typeReferenceUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TYPE_REFERENCE, + content + ); + } + + getEntryCombined(): string { + const typeInfo = this.typeName + ? this.typeName + : this.typeVariableName + ? this.typeVariableName + : this.kind.toString(); + + const locationInfo = + this.startLine !== undefined && this.endLine !== undefined + ? `lines ${this.startLine}-${this.endLine}` + : 'location unknown'; + + return `java_type_reference[kind=${this.kind}, context=${this.context}, type=${typeInfo}, owner=${this.referenceOwnerKind}, position=${this.position}, depth=${this.depth}, parent=${this.parentReferenceHash ? 'HAS_PARENT' : 'TOP_LEVEL'}, variance=${this.wildcardVariance || 'NONE'}, ${locationInfo}, hash=${this.typeReferenceUniqueHash}]`; + } + + toCsv(): string { + return [ + this.kind, + this.context, + this.typeRegistryLinkHash, + this.typeParameterLinkHash || '', + this.referencedTypeRegistryLinkHash || '', + this.parentReferenceHash || '', + this.position.toString(), + this.depth.toString(), + EntityUtils.escapeTsv(this.typeName || ''), + EntityUtils.escapeTsv(this.completeTypeName || ''), + EntityUtils.escapeTsv(this.typeVariableName || ''), + this.arrayDimensions !== undefined ? this.arrayDimensions.toString() : '', + this.wildcardVariance || '', + this.startLine !== undefined ? this.startLine.toString() : '', + this.endLine !== undefined ? this.endLine.toString() : '', + this.typeReferenceOwnerHash, + this.referenceOwnerKind, + this.typeReferenceUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'kind', + 'context', + 'typeRegistryLinkHash', + 'typeParameterLinkHash', + 'referencedTypeRegistryLinkHash', + 'parentReferenceHash', + 'position', + 'depth', + 'typeName', + 'completeTypeName', + 'typeVariableName', + 'arrayDimensions', + 'wildcardVariance', + 'startLine', + 'endLine', + 'typeReferenceOwnerHash', + 'referenceOwnerKind', + 'typeReferenceUniqueHash', + ].join('\t'); + } + + static builder( + typeRegistryLinkHash: string, + kind: TypeRefKind, + context: TypeRefContext, + referenceOwnerKind: ReferenceOwnerKind, + typeReferenceOwnerHash: string + ): TypeReferenceBuilder { + return new TypeReferenceBuilder( + typeRegistryLinkHash, + kind, + context, + referenceOwnerKind, + typeReferenceOwnerHash + ); + } +} + +/** + * Builder for TypeReference with comprehensive validation. + */ +export class TypeReferenceBuilder { + typeRegistryLinkHash: string; + typeParameterLinkHash?: string; + referencedTypeRegistryLinkHash?: string; + kind: TypeRefKind; + context: TypeRefContext; + wildcardVariance?: WildcardVariance; + parentReferenceHash?: string; + position: number = 0; + depth: number = 0; + typeName?: string; + completeTypeName?: string; + typeVariableName?: string; + arrayDimensions?: number; + startLine?: number; + endLine?: number; + typeReferenceOwnerHash: string; + referenceOwnerKind: ReferenceOwnerKind; + + constructor( + typeRegistryLinkHash: string, + kind: TypeRefKind, + context: TypeRefContext, + referenceOwnerKind: ReferenceOwnerKind, + typeReferenceOwnerHash: string + ) { + this.typeRegistryLinkHash = typeRegistryLinkHash; + this.kind = kind; + this.context = context; + this.referenceOwnerKind = referenceOwnerKind; + this.typeReferenceOwnerHash = typeReferenceOwnerHash; + } + + typeParameter(hash: string): this { + this.typeParameterLinkHash = hash; + return this; + } + + referencedType(hash: string): this { + this.referencedTypeRegistryLinkHash = hash; + return this; + } + + wildcard(variance: WildcardVariance): this { + this.wildcardVariance = variance; + return this; + } + + parent(hash: string): this { + this.parentReferenceHash = hash; + return this; + } + + positionAndDepth(position: number, depth: number): this { + this.position = position; + this.depth = depth; + return this; + } + + setTypeName(name: string): this { + this.typeName = name; + return this; + } + + setCompleteTypeName(name: string): this { + this.completeTypeName = name; + return this; + } + + typeVariable(name: string): this { + this.typeVariableName = name; + return this; + } + + array(dimensions: number): this { + this.arrayDimensions = dimensions; + return this; + } + + location(startLine: number, endLine: number): this { + this.startLine = startLine; + this.endLine = endLine; + return this; + } + + build(): TypeReference { + this.validate(); + const ref = new TypeReference(this); + ref.generateHash(); + return ref; + } + + private validate(): void { + this.validateRequiredFields(); + this.validateKindRequirements(); + this.validateContextRequirements(); + this.validateOwnerConsistency(); + this.validateTreeStructure(); + } + + private validateRequiredFields(): void { + if (!this.typeRegistryLinkHash || this.typeRegistryLinkHash.trim().length === 0) { + throw new Error('typeRegistryLinkHash is required'); + } + if (!this.kind) { + throw new Error('kind is required'); + } + if (!this.context) { + throw new Error('context is required'); + } + if (!this.referenceOwnerKind) { + throw new Error('referenceOwnerKind is required'); + } + if (!this.typeReferenceOwnerHash || this.typeReferenceOwnerHash.trim().length === 0) { + throw new Error('typeReferenceOwnerHash is required'); + } + } + + /** + * Checks if this reference is in a bound context (TYPE_PARAM_BOUND or METHOD_TYPE_PARAM_BOUND). + * In these contexts, wildcardVariance is allowed on TYPE_VARIABLE and CLASS types. + */ + private isBoundContext(): boolean { + return this.context === TypeRefContext.TYPE_PARAM_BOUND || + this.context === TypeRefContext.METHOD_TYPE_PARAM_BOUND; + } + + private validateKindRequirements(): void { + switch (this.kind) { + case TypeRefKind.CLASS: + case TypeRefKind.PARAMETERIZED: + if (!this.typeName || this.typeName.trim().length === 0) { + throw new Error(`${this.kind} requires a non-empty typeName`); + } + if (this.typeVariableName) { + throw new Error(`${this.kind} cannot have a typeVariableName`); + } + // wildcardVariance is allowed for CLASS in bound contexts (TYPE_PARAM_BOUND, METHOD_TYPE_PARAM_BOUND) + if (this.wildcardVariance && !this.isBoundContext()) { + throw new Error(`${this.kind} cannot have a wildcardVariance outside of bound contexts`); + } + break; + + case TypeRefKind.TYPE_VARIABLE: + if (!this.typeVariableName || this.typeVariableName.trim().length === 0) { + throw new Error('TYPE_VARIABLE requires a non-empty typeVariableName'); + } + // wildcardVariance is allowed for TYPE_VARIABLE in bound contexts (TYPE_PARAM_BOUND, METHOD_TYPE_PARAM_BOUND) + // e.g., where T is a TYPE_VARIABLE with EXTENDS variance + if (this.wildcardVariance && !this.isBoundContext()) { + throw new Error('TYPE_VARIABLE cannot have wildcardVariance outside of bound contexts'); + } + break; + + case TypeRefKind.WILDCARD: + if (!this.wildcardVariance) { + throw new Error('WILDCARD requires a wildcardVariance'); + } + if (!this.parentReferenceHash) { + throw new Error('WILDCARD requires a parentReferenceHash'); + } + if (this.arrayDimensions !== undefined) { + throw new Error('WILDCARD cannot have arrayDimensions'); + } + break; + + case TypeRefKind.ARRAY: + if (this.arrayDimensions === undefined || this.arrayDimensions <= 0) { + throw new Error('ARRAY requires arrayDimensions > 0'); + } + if (!this.typeName || this.typeName.trim().length === 0) { + throw new Error('ARRAY requires typeName for element type'); + } + if (this.wildcardVariance) { + throw new Error('ARRAY cannot have wildcardVariance'); + } + break; + + case TypeRefKind.PRIMITIVE: + if (!this.typeName || !EntityJavaUtils.isPrimitiveType(this.typeName)) { + throw new Error('PRIMITIVE requires valid primitive type name'); + } + if (this.wildcardVariance) { + throw new Error('PRIMITIVE cannot have wildcardVariance'); + } + if (this.typeVariableName) { + throw new Error('PRIMITIVE cannot have typeVariableName'); + } + break; + } + } + + private validateContextRequirements(): void { + switch (this.context) { + case TypeRefContext.TYPE_PARAM_BOUND: + if (!this.typeParameterLinkHash) { + throw new Error('TYPE_PARAM_BOUND must have a typeParameterLinkHash'); + } + if (this.referenceOwnerKind !== ReferenceOwnerKind.TYPE) { + throw new Error('TYPE_PARAM_BOUND must have TYPE owner kind'); + } + if (this.kind === TypeRefKind.WILDCARD && this.depth === 0) { + throw new Error('TYPE_PARAM_BOUND cannot be a wildcard at depth 0'); + } + if (this.kind === TypeRefKind.ARRAY && this.depth === 0) { + throw new Error('TYPE_PARAM_BOUND cannot be an array at depth 0'); + } + if (this.kind === TypeRefKind.PRIMITIVE && this.depth === 0) { + throw new Error('TYPE_PARAM_BOUND cannot be a primitive type at depth 0'); + } + break; + + case TypeRefContext.SUPER_TYPE: + case TypeRefContext.IMPLEMENTS_INTERFACE: + case TypeRefContext.PERMITS: + if (this.referenceOwnerKind !== ReferenceOwnerKind.TYPE) { + throw new Error(`${this.context} requires TYPE owner kind`); + } + // Only disallow ARRAY/PRIMITIVE at depth=0 (the actual super type) + // At depth>0, arrays are valid as type arguments (e.g., Callable) + if (this.depth === 0) { + if (this.kind === TypeRefKind.ARRAY) { + throw new Error(`ARRAY kind is not allowed in ${this.context} context at depth 0`); + } + if (this.kind === TypeRefKind.PRIMITIVE) { + throw new Error(`PRIMITIVE kind not allowed in ${this.context} context at depth 0`); + } + } + break; + + case TypeRefContext.FIELD_TYPE: + if (this.referenceOwnerKind !== ReferenceOwnerKind.FIELD) { + throw new Error('FIELD_TYPE requires FIELD owner kind'); + } + break; + + case TypeRefContext.METHOD_RETURN: + case TypeRefContext.THROWS_CLAUSE: + if (this.referenceOwnerKind !== ReferenceOwnerKind.METHOD) { + throw new Error(`${this.context} requires METHOD owner kind`); + } + break; + + case TypeRefContext.METHOD_PARAM: + if (this.referenceOwnerKind !== ReferenceOwnerKind.METHOD_PARAM) { + throw new Error(`${this.context} requires METHOD_PARAM owner kind`); + } + break; + + case TypeRefContext.ANNOTATION_PARAM: + if (this.referenceOwnerKind !== ReferenceOwnerKind.ANNOTATION_ARGUMENT) { + throw new Error(`${this.context} requires ANNOTATION_ARGUMENT owner kind`); + } + break; + + case TypeRefContext.TYPE_PARAMETER_ANNOTATION: + if (this.referenceOwnerKind !== ReferenceOwnerKind.TYPE_PARAMETER) { + throw new Error(`${this.context} requires TYPE_PARAMETER owner kind`); + } + break; + + case TypeRefContext.CAST_EXPRESSION: + if (this.referenceOwnerKind !== ReferenceOwnerKind.EXPRESSION) { + throw new Error(`${this.context} requires EXPRESSION owner kind`); + } + break; + } + } + + private validateOwnerConsistency(): void { + if ( + this.referenceOwnerKind === ReferenceOwnerKind.TYPE && + this.typeReferenceOwnerHash !== this.typeRegistryLinkHash + ) { + throw new Error( + 'TYPE owner requires typeReferenceOwnerHash to match typeRegistryLinkHash' + ); + } + if ( + this.referenceOwnerKind !== ReferenceOwnerKind.TYPE && + this.typeReferenceOwnerHash === this.typeRegistryLinkHash + ) { + throw new Error( + `${this.referenceOwnerKind} owner should not have typeReferenceOwnerHash matching typeRegistryLinkHash` + ); + } + } + + private validateTreeStructure(): void { + if (this.parentReferenceHash && this.depth === 0) { + throw new Error('Child nodes must have depth > 0'); + } + if (!this.parentReferenceHash && this.depth !== 0) { + throw new Error('Root nodes must have depth = 0'); + } + } +} diff --git a/parser/src/analysis-types/java/TypeRegistry.ts b/parser/src/analysis-types/java/TypeRegistry.ts new file mode 100644 index 000000000..5c7c3ebf1 --- /dev/null +++ b/parser/src/analysis-types/java/TypeRegistry.ts @@ -0,0 +1,162 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TypeAccess, TypeCategory, TypeModifier, TypePlacement } from '@/enums'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +export class TypeRegistry implements EntityIdentifiable { + private name: string; + private qualifiedName: string; + private fileName: string; + private typeCategory: TypeCategory; + private typeAccess: TypeAccess; + private typeModifier?: string; + private typePlacement: TypePlacement; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private isExternal: boolean; + private serviceVersionLinkHash: string; + private typeRegistryUniqueHash?: string; + + constructor( + name: string, + qualifiedName: string, + fileName: string, + typeCategory: TypeCategory, + typeAccess: TypeAccess, + typePlacement: TypePlacement, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + isExternal: boolean, + serviceVersionHash: string + ) { + this.name = name; + this.qualifiedName = qualifiedName; + this.fileName = fileName; + this.typeCategory = typeCategory; + this.typeAccess = typeAccess; + this.typePlacement = typePlacement; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.isExternal = isExternal; + this.serviceVersionLinkHash = serviceVersionHash; + } + + getName(): string { + return this.name; + } + + getQualifiedName(): string { + return this.qualifiedName; + } + + getFileName(): string { + return this.fileName; + } + + getTypeCategory(): TypeCategory { + return this.typeCategory; + } + + getTypeAccess(): TypeAccess { + return this.typeAccess; + } + + getTypeModifier(): string | undefined { + return this.typeModifier; + } + + getTypePlacement(): TypePlacement { + return this.typePlacement; + } + + getFilePath(): string { + return this.filePath; + } + + getBaseMservPath(): string { + return this.baseMservPath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + isExternalType(): boolean { + return this.isExternal; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getTypeRegistryUniqueHash(): string | undefined { + return this.typeRegistryUniqueHash; + } + + addModifier(modifier: TypeModifier | null): void { + if (modifier === null) return; + + if (!this.typeModifier || this.typeModifier.trim() === '') { + this.typeModifier = modifier; + } else { + this.typeModifier += ',' + modifier; + } + } + + getHash(): string { + return this.typeRegistryUniqueHash || ''; + } + + /** + * Sets a pre-generated hash for this type registry entry. + * Used for anonymous classes where the hash is generated during expression extraction. + */ + setHash(hash: string): void { + this.typeRegistryUniqueHash = hash; + } + + generateHash(): void { + const content = `${this.name}${this.qualifiedName}${this.fileName}${this.filePath.trim()}${this.baseMservPath.trim()}${this.startLine}${this.endLine}${this.isExternal}${this.serviceVersionLinkHash}`; + this.typeRegistryUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TYPE_REGISTRY, + content + ); + } + + getEntryCombined(): string { + return `java_type_registry[name=${this.getName()}, qualified=${this.getQualifiedName()}, category=${this.getTypeCategory()}, access=${this.getTypeAccess()}, placement=${this.getTypePlacement()}, modifier=${this.getTypeModifier() || 'NONE'}, external=${this.isExternalType()}, file=${this.getFileName()}, lines=${this.getStartLine()}-${this.getEndLine()}, service=${this.getServiceVersionLinkHash()}, hash=${this.getTypeRegistryUniqueHash()}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.name), + EntityUtils.escapeTsv(this.qualifiedName), + this.fileName, + this.typeCategory, + this.typeAccess, + this.typeModifier || '', + this.typePlacement, + this.filePath, + this.baseMservPath, + this.startLine, + this.endLine, + this.isExternal, + this.serviceVersionLinkHash, + this.typeRegistryUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return 'name\tqualifiedName\tfileName\ttypeCategory\ttypeAccess\ttypeModifier\ttypePlacement\tfilePath\tbaseMservPath\tstartLine\tendLine\tisExternal\tserviceVersionLinkHash\ttypeRegistryUniqueHash'; + } +} diff --git a/parser/src/analysis-types/javascript/JsBlockRegistry.ts b/parser/src/analysis-types/javascript/JsBlockRegistry.ts new file mode 100644 index 000000000..87db6cc31 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsBlockRegistry.ts @@ -0,0 +1,158 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsBlockKind, +} from '@/enums/javascript/blocks'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A lexical block — schema §3.12, 17 columns. + * + * ## Distinct from `js_scope`: a block is SYNTAX, a scope is BINDING + * + * They are not in 1:1 correspondence. A bare `{}` containing only `var` + * declarations opens **no scope**; one scope may span several blocks. `opensScope` + * and `scopeLinkHash` carry the join, and a `""` there is a real answer rather + * than a missing one. + * + * ## Every block form gets a row, including the ones that emit nothing else + * + * TypeScript's enum audit found `NAMESPACE_BODY` and `MODULE_BODY` producing **no + * block row at all**, on two separate early-return paths, and nothing else caught + * it because no row was misplaced — there simply were none. + * + * `label` is the other half of that lesson: `outer: for (…)` emitted the `FOR` + * and **dropped the label**, so a `break outer` named a target nothing in the + * fact base identified. This column is why that cannot happen here. + */ +export class JsBlockRegistry implements EntityIdentifiable { + static readonly ARITY = 17; + + readonly blockKind: JsBlockKind; + /** `outer:`. TypeScript emitted the loop and DROPPED the label; this column is why that cannot happen here. */ + readonly label: string; + readonly parentBlockLinkHash: string; + readonly depth: number; + readonly childIndex: number; + readonly scopeLinkHash: string; + /** False for a block that binds nothing — a bare `{}` holding only `var` declarations. */ + readonly opensScope: boolean; + private conditionExpressionLinkHash = ABSENT; + readonly ownerMethodLinkHash: string; + readonly ownerModuleLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + readonly endColumn: number; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsBlockUniqueHash = ABSENT; + + constructor(props: { + blockKind: JsBlockKind; + label: string; + parentBlockLinkHash: string; + depth: number; + childIndex: number; + scopeLinkHash: string; + opensScope: boolean; + ownerMethodLinkHash: string; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + serviceVersionLinkHash: string; + }) { + this.blockKind = props.blockKind; + this.label = props.label; + this.parentBlockLinkHash = props.parentBlockLinkHash; + this.depth = props.depth; + this.childIndex = props.childIndex; + this.scopeLinkHash = props.scopeLinkHash; + this.opensScope = props.opensScope; + this.ownerMethodLinkHash = props.ownerMethodLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.endLine = props.endLine; + this.endColumn = props.endColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsBlockUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_BLOCK, + keyOf(this.ownerModuleLinkHash, this.blockKind, this.startLine, this.startColumn, this.endLine, this.endColumn) + ); + } + + getHash(): string { + return this.jsBlockUniqueHash; + } + + setConditionExpressionLinkHash(hash: string): void { + this.conditionExpressionLinkHash = hash; + } + + getEntryCombined(): string { + return `js_block[hash=${this.jsBlockUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.blockKind, + text(this.label), + this.parentBlockLinkHash, + num(this.depth), + num(this.childIndex), + this.scopeLinkHash, + bool(this.opensScope), + this.conditionExpressionLinkHash, + this.ownerMethodLinkHash, + this.ownerModuleLinkHash, + num(this.startLine), + num(this.startColumn), + num(this.endLine), + num(this.endColumn), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsBlockUniqueHash, + ], + JsBlockRegistry.ARITY, + 'js_block' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'blockKind', + 'label', + 'parentBlockLinkHash', + 'depth', + 'childIndex', + 'scopeLinkHash', + 'opensScope', + 'conditionExpressionLinkHash', + 'ownerMethodLinkHash', + 'ownerModuleLinkHash', + 'startLine', + 'startColumn', + 'endLine', + 'endColumn', + 'isExternal', + 'serviceVersionLinkHash', + 'jsBlockUniqueHash', + ], + JsBlockRegistry.ARITY, + 'js_block' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsCallSiteRegistry.ts b/parser/src/analysis-types/javascript/JsCallSiteRegistry.ts new file mode 100644 index 000000000..ca9de3107 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsCallSiteRegistry.ts @@ -0,0 +1,231 @@ +import { ABSENT, bool, boundedText, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { JS_EXPRESSION_TEXT_LIMIT } from '@/constants/javascript-constants'; +import { + JsCallKind, + JsCallResolutionOutcome, + JsReceiverPosition, + JsReceiverTypeSource, +} from '@/enums/javascript/call-sites'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Exactly one row per call-like expression — schema §3.11, 25 columns. + * + * **`require()` is not here.** It is a module edge by ruling, and counting its + * 9,055 sites as unresolved calls is what made the raw resolution figure look + * worse than it is — they were listed as declines in a table whose denominator + * the next paragraph removed them from. + * + * ## The primary key is derived from the expression, which makes the 1:1 + * structural rather than asserted + * + * `JS_CALL_SITE_md5(expressionLinkHash)`. A second call-site row for one + * expression is impossible by construction rather than by a check that might not + * run. + * + * ## `receiverPosition` exists for 1,048 sites and prevents a WRONG edge + * + * `f.call(obj, a)` and `f.apply(obj, args)` move the receiver into an + * **argument**. An engine reading the syntactic receiver resolves `.call` and + * gets `Function.prototype.call` as the target, with the real receiver never + * consulted. `receiverExpressionLinkHash` points at the real receiver wherever + * it sits. + * + * ## Columns 9-11 exist because of the 52.6% measurement + * + * The oracle itself — tsc with `checkJs` — decides only 52.6% of call sites. + * The parser will not name the target for roughly half of all calls, and + * pretending otherwise produces a confidently wrong fact base. What it can + * always emit is the name as written, the receiver's declared type when JSDoc + * supplies one, and the import hop — and those three let the engine finish. + * + * `resolvedMethodLinkHash` is **tier 3 and stays empty.** + */ +export class JsCallSiteRegistry implements EntityIdentifiable { + static readonly ARITY = 25; + + readonly callKind: JsCallKind; + readonly calleeText: string; + readonly calleeName: string; + readonly receiverText: string; + /** Without this the engine reads `Function.prototype.call` as the target of `f.call(obj)`. */ + readonly receiverPosition: JsReceiverPosition; + /** Points at the **real** receiver, wherever it sits — including argument 0. */ + private receiverExpressionLinkHash = ABSENT; + readonly argumentCount: number; + /** 1,301 spreads measured; the count is not the arity. */ + readonly hasSpreadArgument: boolean; + readonly isOptionalCall: boolean; + readonly declaredReceiverTypeName: string; + readonly receiverTypeSource: JsReceiverTypeSource; + private importLinkHash = ABSENT; + /** + * **TIER 3 — declared, never staged.** Always `""`, and there is + * deliberately no setter. + * + * Java is the precedent: `referencedTypeRegistryLinkHash` is populated 0 + * times in 67,938 rows and that is the design, not an oversight. + * Cross-file resolution is the engine's work, and a parser that fills + * this column is `type-resolution.dl` rewritten in TypeScript — which was + * written once and then deleted. The gate asserts zero populated rows. + */ + private readonly resolvedMethodLinkHash = ABSENT; + readonly resolutionOutcome: JsCallResolutionOutcome; + readonly isDynamicCode: boolean; + readonly enclosingMethodLinkHash: string; + /** FK→`js_expression`, and the PK is derived from it — the 1:1 is structural. */ + readonly expressionLinkHash: string; + readonly ownerScopeLinkHash: string; + readonly ownerModuleLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + /** Must be `false` in every row; the gate asserts it. */ + readonly isTypeOnlyTarget: boolean; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsCallSiteUniqueHash = ABSENT; + + constructor(props: { + callKind: JsCallKind; + calleeText: string; + calleeName: string; + receiverText: string; + receiverPosition: JsReceiverPosition; + argumentCount: number; + hasSpreadArgument: boolean; + isOptionalCall: boolean; + declaredReceiverTypeName: string; + receiverTypeSource: JsReceiverTypeSource; + resolutionOutcome: JsCallResolutionOutcome; + isDynamicCode: boolean; + enclosingMethodLinkHash: string; + expressionLinkHash: string; + ownerScopeLinkHash: string; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + isTypeOnlyTarget: boolean; + serviceVersionLinkHash: string; + }) { + this.callKind = props.callKind; + this.calleeText = props.calleeText; + this.calleeName = props.calleeName; + this.receiverText = props.receiverText; + this.receiverPosition = props.receiverPosition; + this.argumentCount = props.argumentCount; + this.hasSpreadArgument = props.hasSpreadArgument; + this.isOptionalCall = props.isOptionalCall; + this.declaredReceiverTypeName = props.declaredReceiverTypeName; + this.receiverTypeSource = props.receiverTypeSource; + this.resolutionOutcome = props.resolutionOutcome; + this.isDynamicCode = props.isDynamicCode; + this.enclosingMethodLinkHash = props.enclosingMethodLinkHash; + this.expressionLinkHash = props.expressionLinkHash; + this.ownerScopeLinkHash = props.ownerScopeLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.isTypeOnlyTarget = props.isTypeOnlyTarget; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsCallSiteUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_CALL_SITE, + keyOf(this.expressionLinkHash) + ); + } + + getHash(): string { + return this.jsCallSiteUniqueHash; + } + + setReceiverExpressionLinkHash(hash: string): void { + this.receiverExpressionLinkHash = hash; + } + setImportLinkHash(hash: string): void { + this.importLinkHash = hash; + } + /** Read by the IR-completeness measure, which asks whether the hop is present. */ + importLinkHashValue(): string { + return this.importLinkHash; + } + + getEntryCombined(): string { + return `js_call_site[hash=${this.jsCallSiteUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.callKind, + boundedText(this.calleeText, JS_EXPRESSION_TEXT_LIMIT), + text(this.calleeName), + boundedText(this.receiverText, JS_EXPRESSION_TEXT_LIMIT), + this.receiverPosition, + this.receiverExpressionLinkHash, + num(this.argumentCount), + bool(this.hasSpreadArgument), + bool(this.isOptionalCall), + text(this.declaredReceiverTypeName), + this.receiverTypeSource, + this.importLinkHash, + this.resolvedMethodLinkHash, + this.resolutionOutcome, + bool(this.isDynamicCode), + this.enclosingMethodLinkHash, + this.expressionLinkHash, + this.ownerScopeLinkHash, + this.ownerModuleLinkHash, + num(this.startLine), + num(this.startColumn), + bool(this.isTypeOnlyTarget), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsCallSiteUniqueHash, + ], + JsCallSiteRegistry.ARITY, + 'js_call_site' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'callKind', + 'calleeText', + 'calleeName', + 'receiverText', + 'receiverPosition', + 'receiverExpressionLinkHash', + 'argumentCount', + 'hasSpreadArgument', + 'isOptionalCall', + 'declaredReceiverTypeName', + 'receiverTypeSource', + 'importLinkHash', + 'resolvedMethodLinkHash', + 'resolutionOutcome', + 'isDynamicCode', + 'enclosingMethodLinkHash', + 'expressionLinkHash', + 'ownerScopeLinkHash', + 'ownerModuleLinkHash', + 'startLine', + 'startColumn', + 'isTypeOnlyTarget', + 'isExternal', + 'serviceVersionLinkHash', + 'jsCallSiteUniqueHash', + ], + JsCallSiteRegistry.ARITY, + 'js_call_site' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsCommentRegistry.ts b/parser/src/analysis-types/javascript/JsCommentRegistry.ts new file mode 100644 index 000000000..2d6f4c147 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsCommentRegistry.ts @@ -0,0 +1,161 @@ +import { ABSENT, bool, boundedText, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { JS_COMMENT_TEXT_LIMIT } from '@/constants/javascript-constants'; +import { + JsCommentAttachmentKind, + JsCommentKind, + JsDirectiveKind, +} from '@/enums/javascript/comments'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A comment — schema §3.15, 16 columns. + * + * Comments are **trivia**: not in the AST, so no tree walk reaches them and the + * scan is separate by necessity. + * + * ## `declaresType` is what makes "a comment can be a declaration" checkable + * + * 1,825 `@typedef` and 103 `@callback` tags declare types with no declaration + * syntax anywhere. The gate asserts that every `js_type` row with + * `evidenceKind = COMMENT_ONLY` has a `jsdocCommentLinkHash` pointing at a + * comment whose `declaresType` is true — so the two relations have to agree + * about which comments are declarations, rather than each asserting it alone. + * + * `jsdocTagNames` is a comma **list**, ordered and with repeats — + * `param,param,returns` — because the repetition and the order are both + * information. It is not a sorted set. + */ +export class JsCommentRegistry implements EntityIdentifiable { + static readonly ARITY = 16; + + readonly commentKind: JsCommentKind; + readonly text: string; + readonly isJsdoc: boolean; + /** Comma-joined and ORDERED, with repeats: `param,param,returns`. Not a sorted set. */ + readonly jsdocTagNames: string; + readonly jsdocTagCount: number; + /** Carries `@typedef`/`@callback` — this comment IS a declaration. */ + readonly declaresType: boolean; + readonly directiveKind: JsDirectiveKind; + private attachedToKind: JsCommentAttachmentKind; + private attachedToLinkHash = ABSENT; + readonly ownerModuleLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsCommentUniqueHash = ABSENT; + + constructor(props: { + commentKind: JsCommentKind; + text: string; + isJsdoc: boolean; + jsdocTagNames: string; + jsdocTagCount: number; + declaresType: boolean; + directiveKind: JsDirectiveKind; + attachedToKind: JsCommentAttachmentKind; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + endLine: number; + serviceVersionLinkHash: string; + }) { + this.commentKind = props.commentKind; + this.text = props.text; + this.isJsdoc = props.isJsdoc; + this.jsdocTagNames = props.jsdocTagNames; + this.jsdocTagCount = props.jsdocTagCount; + this.declaresType = props.declaresType; + this.directiveKind = props.directiveKind; + this.attachedToKind = props.attachedToKind; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.endLine = props.endLine; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsCommentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_COMMENT, + keyOf(this.ownerModuleLinkHash, this.startLine, this.startColumn, this.endLine) + ); + } + + getHash(): string { + return this.jsCommentUniqueHash; + } + + setAttachedToKind(value: JsCommentAttachmentKind): void { + this.attachedToKind = value; + } + setAttachedToLinkHash(hash: string): void { + this.attachedToLinkHash = hash; + } + /** Read when the declaration relations link back to the comment. */ + attachedToLinkHashValue(): string { + return this.attachedToLinkHash; + } + + getEntryCombined(): string { + return `js_comment[hash=${this.jsCommentUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.commentKind, + boundedText(this.text, JS_COMMENT_TEXT_LIMIT), + bool(this.isJsdoc), + text(this.jsdocTagNames), + num(this.jsdocTagCount), + bool(this.declaresType), + this.directiveKind, + this.attachedToKind, + this.attachedToLinkHash, + this.ownerModuleLinkHash, + num(this.startLine), + num(this.startColumn), + num(this.endLine), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsCommentUniqueHash, + ], + JsCommentRegistry.ARITY, + 'js_comment' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'commentKind', + 'text', + 'isJsdoc', + 'jsdocTagNames', + 'jsdocTagCount', + 'declaresType', + 'directiveKind', + 'attachedToKind', + 'attachedToLinkHash', + 'ownerModuleLinkHash', + 'startLine', + 'startColumn', + 'endLine', + 'isExternal', + 'serviceVersionLinkHash', + 'jsCommentUniqueHash', + ], + JsCommentRegistry.ARITY, + 'js_comment' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsExportRegistry.ts b/parser/src/analysis-types/javascript/JsExportRegistry.ts new file mode 100644 index 000000000..2195c7324 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsExportRegistry.ts @@ -0,0 +1,200 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsExportForm, + JsExportTargetKind, + JsExportedValueKind, +} from '@/enums/javascript/exports'; +import { JsEdgeBearer } from '@/enums/javascript/imports'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One module edge OUT — schema §3.9, 22 columns. + * + * `module.exports = X` (2,102), `module.exports.x =` (380), `exports.x =` + * (118), `Object.defineProperty(exports, …)`, plus every ESM `export` form. + * + * ## `overwritesPreviousExport`, and why it is not a runtime conclusion + * + * ```js + * exports.a = 1; + * module.exports = { b }; // exports ONLY b. `a` is gone. + * ``` + * + * A fact base recording both edges with no ordering tells the engine this module + * exports `a`, which is false. Suppressing the earlier row loses the fact that + * the assignment executed. The flag keeps both and lets the engine decide. + * + * **It is only set when the overwrite is unconditional.** `if (x) module.exports + * = {}` *may* overwrite, and a boolean that collapses those two cases is + * asserting a runtime conclusion from syntax — the thing this schema is most + * careful not to do. `isConditional` is the column that keeps them apart. + * + * ## `isReExport`: one line, two relations + * + * `module.exports = require('./y')` — 81 measured — is **simultaneously an + * import and an export**. It mints a row in each, joined by + * `reExportImportLinkHash`, because dropping either half loses a real edge. + */ +export class JsExportRegistry implements EntityIdentifiable { + static readonly ARITY = 22; + + readonly exportedName: string; + readonly localName: string; + readonly edgeBearer: JsEdgeBearer; + readonly exportForm: JsExportForm; + readonly exportedValueKind: JsExportedValueKind; + /** `module.exports = require("./y")` — 81 measured; an import and an export in one line. */ + readonly isReExport: boolean; + readonly reExportSpecifier: string; + private reExportImportLinkHash = ABSENT; + readonly isTopLevel: boolean; + readonly isConditional: boolean; + private targetKind: JsExportTargetKind; + private targetLinkHash = ABSENT; + private sourceExpressionLinkHash = ABSENT; + /** Set only when the overwrite is UNCONDITIONAL; `isConditional` keeps `if (x) module.exports = {}` apart. */ + private overwritesPreviousExport = false; + readonly ownerScopeLinkHash: string; + readonly ownerMethodLinkHash: string; + readonly ownerModuleLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsExportUniqueHash = ABSENT; + + constructor(props: { + exportedName: string; + localName: string; + edgeBearer: JsEdgeBearer; + exportForm: JsExportForm; + exportedValueKind: JsExportedValueKind; + isReExport: boolean; + reExportSpecifier: string; + isTopLevel: boolean; + isConditional: boolean; + targetKind: JsExportTargetKind; + ownerScopeLinkHash: string; + ownerMethodLinkHash: string; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.exportedName = props.exportedName; + this.localName = props.localName; + this.edgeBearer = props.edgeBearer; + this.exportForm = props.exportForm; + this.exportedValueKind = props.exportedValueKind; + this.isReExport = props.isReExport; + this.reExportSpecifier = props.reExportSpecifier; + this.isTopLevel = props.isTopLevel; + this.isConditional = props.isConditional; + this.targetKind = props.targetKind; + this.ownerScopeLinkHash = props.ownerScopeLinkHash; + this.ownerMethodLinkHash = props.ownerMethodLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsExportUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_EXPORT, + keyOf(this.ownerModuleLinkHash, this.exportedName, this.exportForm, this.startLine, this.startColumn) + ); + } + + getHash(): string { + return this.jsExportUniqueHash; + } + + setReExportImportLinkHash(hash: string): void { + this.reExportImportLinkHash = hash; + } + setTargetKind(value: JsExportTargetKind): void { + this.targetKind = value; + } + setTargetLinkHash(hash: string): void { + this.targetLinkHash = hash; + } + setSourceExpressionLinkHash(hash: string): void { + this.sourceExpressionLinkHash = hash; + } + setOverwritesPreviousExport(value = true): void { + this.overwritesPreviousExport = value; + } + + getEntryCombined(): string { + return `js_export[hash=${this.jsExportUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.exportedName), + text(this.localName), + this.edgeBearer, + this.exportForm, + this.exportedValueKind, + bool(this.isReExport), + text(this.reExportSpecifier), + this.reExportImportLinkHash, + bool(this.isTopLevel), + bool(this.isConditional), + this.targetKind, + this.targetLinkHash, + this.sourceExpressionLinkHash, + bool(this.overwritesPreviousExport), + this.ownerScopeLinkHash, + this.ownerMethodLinkHash, + this.ownerModuleLinkHash, + num(this.startLine), + num(this.startColumn), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsExportUniqueHash, + ], + JsExportRegistry.ARITY, + 'js_export' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'exportedName', + 'localName', + 'edgeBearer', + 'exportForm', + 'exportedValueKind', + 'isReExport', + 'reExportSpecifier', + 'reExportImportLinkHash', + 'isTopLevel', + 'isConditional', + 'targetKind', + 'targetLinkHash', + 'sourceExpressionLinkHash', + 'overwritesPreviousExport', + 'ownerScopeLinkHash', + 'ownerMethodLinkHash', + 'ownerModuleLinkHash', + 'startLine', + 'startColumn', + 'isExternal', + 'serviceVersionLinkHash', + 'jsExportUniqueHash', + ], + JsExportRegistry.ARITY, + 'js_export' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsExpressionRegistry.ts b/parser/src/analysis-types/javascript/JsExpressionRegistry.ts new file mode 100644 index 000000000..25ca87814 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsExpressionRegistry.ts @@ -0,0 +1,317 @@ +import { ABSENT, bool, boundedText, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { JS_EXPRESSION_TEXT_LIMIT } from '@/constants/javascript-constants'; +import { + JsBindingResolution, + JsExpressionKind, + JsLiteralKind, + JsRootContext, +} from '@/enums/javascript/expressions'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * The spine — schema §3.10, 32 columns. + * + * Every expression reached by an **allowlist of expression positions**, never a + * generic tree walk: a generic walk puts JSDoc type names into this relation and + * type-only constructs then reach the call graph. + * + * ## Wrapper nodes are mandatory + * + * `x += 1` emits **one** `ASSIGNMENT` row with the target and value parented to + * it under `ASSIGNMENT_TARGET`/`ASSIGNMENT_VALUE`, and `+=` in + * `operatorString`. Flat emission fails on `a += 1; b += 2`: the engine-side + * workaround pairs on `(scope, line, rootContext)` and yields four pairs, two of + * them **inventing value flow that does not exist**. That is worse than dropping + * the rows, which is why the operator is a column and not a kind. + * + * ## Two ways a subtree dies silently + * + * **A tree rooted at a non-emitting node dies before its children are + * enqueued.** Parentheses produce no row, and `return ( a && b.c() )` lost the + * whole tree — 1,808 expressions on one TypeScript corpus. JSX braces produce no + * row, and every call inside one vanished — **4,488 of admin-ui's 14,335 call + * sites.** Unwrapped at the root, in one place, so every position is fixed at + * once. + * + * **The worklist stops at function boundaries.** `return function () { … }` + * emitted the function and nothing inside it. Descent is explicit. + * + * ## `isModuleEdge` and `isDeclarationBearing` are where JavaScript diverges + * + * An expression here can *be* an import (`require`), *be* an export + * (`module.exports =`), or *declare a method* (`Foo.prototype.bar = function`). + * Those three flags and their link columns are how a row in this relation + * announces that it is also a row somewhere else. + * + * Identity is the **byte range**: `endLine` and `endColumn` are in the key, + * because a call and its callee share a start offset constantly. + */ +export class JsExpressionRegistry implements EntityIdentifiable { + static readonly ARITY = 35; + + readonly expressionKind: JsExpressionKind; + readonly text: string; + readonly name: string; + readonly isComputedName: boolean; + /** `+=`, `??=`, `?.` — **the operator is a column, not a kind**, per Java's precedent. */ + readonly operatorString: string; + readonly depth: number; + readonly parentExpressionLinkHash: string; + readonly edgeRole: string; + readonly childIndex: number; + readonly rootContext: JsRootContext; + /** This expression IS an import or export edge. The second pass reads exactly these rows. */ + private isModuleEdge = false; + private moduleEdgeLinkHash = ABSENT; + /** This assignment DECLARES a member: `Foo.prototype.bar = function () {}`. */ + private isDeclarationBearing = false; + private declarationLinkHash = ABSENT; + private callSiteLinkHash = ABSENT; + readonly referencedName: string; + readonly referenceKind: string; + private resolvedBindingLinkHash = ABSENT; + private bindingResolution: JsBindingResolution | '' = ABSENT; + /** Must be `false` in every row; the gate asserts it. */ + readonly isTypeOnlyReachable: boolean; + readonly literalKind: JsLiteralKind; + /** The depth cap of 32 was reached. Max observed depth is 67. */ + private isTruncated = false; + readonly ownerScopeLinkHash: string; + readonly ownerMethodLinkHash: string; + readonly ownerModuleLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + /** **In the primary key** — identity is the byte range, never the start offset. */ + readonly endColumn: number; + /** + * FK->`js_method`: **the callable this expression IS**. + * + * Set on every `ARROW`, `FUNCTION_EXPRESSION` and `CLASS_EXPRESSION` + * row. Appended AFTER the primary key, because column order is frozen + * and inserting it in place would shift `jsExpressionUniqueHash` from + * c31 to c32 -- every consumer reading c31 as the expression hash would + * silently read something else, misbinding every FK in the fact base. + * + * The consequence to know: this is the ONE relation whose PK is not its + * last column. A check that finds a primary key positionally is wrong + * here and must find it by name. + */ + private introducesDeclarationLinkHash = ABSENT; + /** + * c33: FK→`js_method_parameter`, the sibling of c17 for a reference that + * resolves to a PARAMETER — the case c17, FK→`js_variable`, could not hold. + * + * The binder resolved these correctly all along (`bindingResolution` LOCAL or + * CLOSURE) and had nowhere to write the answer: 137,960 references naming a + * parameter of their own method, 15,382 more one scope up, 58,483 parameter + * rows reachable from nothing. A parameter is where data ENTERS a function. + * Appended after the PK, like c32; a widened c17 would be a polymorphic FK, + * which defeats the integrity gate. + */ + private resolvedParameterLinkHash = ABSENT; + /** + * c34: the path within a destructuring pattern, AS WRITTEN. `a` for `{a, b}`, + * `b.c` for `{b: {c}}`, `0` for `[x]`, `1.name` for `[, {name}]`; `""` when + * the binding is not destructured. c33 names the parameter; this says which + * property of the argument the bound name reads, which the engine could + * otherwise learn only by re-parsing the source. + */ + private bindingPath = ''; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsExpressionUniqueHash = ABSENT; + + constructor(props: { + expressionKind: JsExpressionKind; + text: string; + name: string; + isComputedName: boolean; + operatorString: string; + depth: number; + parentExpressionLinkHash: string; + edgeRole: string; + childIndex: number; + rootContext: JsRootContext; + referencedName: string; + referenceKind: string; + isTypeOnlyReachable: boolean; + literalKind: JsLiteralKind; + ownerScopeLinkHash: string; + ownerMethodLinkHash: string; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + serviceVersionLinkHash: string; + }) { + this.expressionKind = props.expressionKind; + this.text = props.text; + this.name = props.name; + this.isComputedName = props.isComputedName; + this.operatorString = props.operatorString; + this.depth = props.depth; + this.parentExpressionLinkHash = props.parentExpressionLinkHash; + this.edgeRole = props.edgeRole; + this.childIndex = props.childIndex; + this.rootContext = props.rootContext; + this.referencedName = props.referencedName; + this.referenceKind = props.referenceKind; + this.isTypeOnlyReachable = props.isTypeOnlyReachable; + this.literalKind = props.literalKind; + this.ownerScopeLinkHash = props.ownerScopeLinkHash; + this.ownerMethodLinkHash = props.ownerMethodLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.endLine = props.endLine; + this.endColumn = props.endColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsExpressionUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_EXPRESSION, + keyOf(this.ownerModuleLinkHash, this.expressionKind, this.startLine, this.startColumn, this.endLine, this.endColumn) + ); + } + + getHash(): string { + return this.jsExpressionUniqueHash; + } + + setIsModuleEdge(value = true): void { + this.isModuleEdge = value; + } + setModuleEdgeLinkHash(hash: string): void { + this.moduleEdgeLinkHash = hash; + } + setIsDeclarationBearing(value = true): void { + this.isDeclarationBearing = value; + } + setDeclarationLinkHash(hash: string): void { + this.declarationLinkHash = hash; + } + setCallSiteLinkHash(hash: string): void { + this.callSiteLinkHash = hash; + } + setResolvedBindingLinkHash(hash: string): void { + this.resolvedBindingLinkHash = hash; + } + setBindingResolution(value: JsBindingResolution): void { + this.bindingResolution = value; + } + setIsTruncated(value = true): void { + this.isTruncated = value; + } + /** Read by the parse-gap pass, which derives its rows from the fact base. */ + wasTruncated(): boolean { + return this.isTruncated; + } + setIntroducesDeclarationLinkHash(hash: string): void { + this.introducesDeclarationLinkHash = hash; + } + setResolvedParameterLinkHash(hash: string, path: string): void { + this.resolvedParameterLinkHash = hash; + this.bindingPath = path; + } + + getEntryCombined(): string { + return `js_expression[hash=${this.jsExpressionUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.expressionKind, + boundedText(this.text, JS_EXPRESSION_TEXT_LIMIT), + text(this.name), + bool(this.isComputedName), + text(this.operatorString), + num(this.depth), + this.parentExpressionLinkHash, + text(this.edgeRole), + num(this.childIndex), + this.rootContext, + bool(this.isModuleEdge), + this.moduleEdgeLinkHash, + bool(this.isDeclarationBearing), + this.declarationLinkHash, + this.callSiteLinkHash, + text(this.referencedName), + text(this.referenceKind), + this.resolvedBindingLinkHash, + this.bindingResolution, + bool(this.isTypeOnlyReachable), + this.literalKind, + bool(this.isTruncated), + this.ownerScopeLinkHash, + this.ownerMethodLinkHash, + this.ownerModuleLinkHash, + num(this.startLine), + num(this.startColumn), + num(this.endLine), + num(this.endColumn), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsExpressionUniqueHash, + this.introducesDeclarationLinkHash, + this.resolvedParameterLinkHash, + text(this.bindingPath), + ], + JsExpressionRegistry.ARITY, + 'js_expression' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'expressionKind', + 'text', + 'name', + 'isComputedName', + 'operatorString', + 'depth', + 'parentExpressionLinkHash', + 'edgeRole', + 'childIndex', + 'rootContext', + 'isModuleEdge', + 'moduleEdgeLinkHash', + 'isDeclarationBearing', + 'declarationLinkHash', + 'callSiteLinkHash', + 'referencedName', + 'referenceKind', + 'resolvedBindingLinkHash', + 'bindingResolution', + 'isTypeOnlyReachable', + 'literalKind', + 'isTruncated', + 'ownerScopeLinkHash', + 'ownerMethodLinkHash', + 'ownerModuleLinkHash', + 'startLine', + 'startColumn', + 'endLine', + 'endColumn', + 'isExternal', + 'serviceVersionLinkHash', + 'jsExpressionUniqueHash', + 'introducesDeclarationLinkHash', + 'resolvedParameterLinkHash', + 'bindingPath', + ], + JsExpressionRegistry.ARITY, + 'js_expression' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsFieldRegistry.ts b/parser/src/analysis-types/javascript/JsFieldRegistry.ts new file mode 100644 index 000000000..1605a1e11 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsFieldRegistry.ts @@ -0,0 +1,215 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsAccessorPairKind, + JsFieldDeclarationForm, +} from '@/enums/javascript/fields'; +import { JsDeclaredTypeSource } from '@/enums/javascript/common'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A class field, a prototype property, or a member installed by + * `Object.defineProperty` — schema §3.6, 23 columns. + * + * ## `declarationForm` is in the primary key, and it earns its place + * + * `this.x = 1` in a constructor and `Foo.prototype.x = 1` at module level are + * **two declarations of one member**, at different lines, and both are real: one + * sets an own property per instance, the other a shared prototype property. + * Keying without the form would merge them into one row and lose the + * distinction; keying with it keeps both and lets the engine choose. + * + * ## `accessorPairKind` is the declaration half of a reserved call kind + * + * 1,225 getters and 137 setters were measured. Each means that somewhere, + * `obj.x` is a **function call written as a property read**. The parser emits + * the declaration — `get x() {}` is right there — and cannot emit the + * invocation, because whether a given `obj.x` hits an accessor depends on what + * `obj` turns out to be. `GETTER_INVOCATION` is therefore reserved with a + * zero-row assertion, and this column carries the half syntax can answer. + * + * `isPrivateName` is `#x` specifically, and not `_x`: the first is a real access + * boundary the runtime enforces, the second is a naming convention. + */ +export class JsFieldRegistry implements EntityIdentifiable { + static readonly ARITY = 23; + + readonly name: string; + readonly qualifiedName: string; + readonly ownerTypeLinkHash: string; + readonly declarationForm: JsFieldDeclarationForm; + readonly isStatic: boolean; + /** `#x` — a real access boundary the runtime enforces, unlike the `_x` convention. */ + readonly isPrivateName: boolean; + /** `writable: false` via `Object.defineProperty`. */ + private isReadonly = false; + private declaredTypeName: string; + private declaredTypeSource: JsDeclaredTypeSource; + private typeReferenceLinkHash = ABSENT; + readonly hasInitializer: boolean; + private initializerExpressionLinkHash = ABSENT; + private accessorPairKind: JsAccessorPairKind; + private getterMethodLinkHash = ABSENT; + private setterMethodLinkHash = ABSENT; + readonly isComputedName: boolean; + private sourceExpressionLinkHash = ABSENT; + readonly startLine: number; + readonly startColumn: number; + readonly ownerModuleLinkHash: string; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsFieldUniqueHash = ABSENT; + + constructor(props: { + name: string; + qualifiedName: string; + ownerTypeLinkHash: string; + declarationForm: JsFieldDeclarationForm; + isStatic: boolean; + isPrivateName: boolean; + declaredTypeName: string; + declaredTypeSource: JsDeclaredTypeSource; + hasInitializer: boolean; + accessorPairKind: JsAccessorPairKind; + isComputedName: boolean; + startLine: number; + startColumn: number; + ownerModuleLinkHash: string; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.qualifiedName = props.qualifiedName; + this.ownerTypeLinkHash = props.ownerTypeLinkHash; + this.declarationForm = props.declarationForm; + this.isStatic = props.isStatic; + this.isPrivateName = props.isPrivateName; + this.declaredTypeName = props.declaredTypeName; + this.declaredTypeSource = props.declaredTypeSource; + this.hasInitializer = props.hasInitializer; + this.accessorPairKind = props.accessorPairKind; + this.isComputedName = props.isComputedName; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsFieldUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_FIELD, + keyOf(this.ownerTypeLinkHash, this.name, this.startLine, this.startColumn, this.declarationForm) + ); + } + + getHash(): string { + return this.jsFieldUniqueHash; + } + + setIsReadonly(value = true): void { + this.isReadonly = value; + } + setTypeReferenceLinkHash(hash: string): void { + this.typeReferenceLinkHash = hash; + } + setInitializerExpressionLinkHash(hash: string): void { + this.initializerExpressionLinkHash = hash; + } + setAccessorPairKind(value: JsAccessorPairKind): void { + this.accessorPairKind = value; + } + /** + * An accessor PAIR is one field row, minted at whichever accessor comes + * first — and the `@type` may sit on the other one. `get x() {}` then + * `/** @type {boolean} *\/ set x(v) {}` typed nothing, because the row was + * built at the getter with no type and the setter's tag had no row to land on. + */ + setDeclaredType(name: string, source: JsDeclaredTypeSource): void { + this.declaredTypeName = name; + this.declaredTypeSource = source; + } + declaredTypeNameValue(): string { + return this.declaredTypeName; + } + setGetterMethodLinkHash(hash: string): void { + this.getterMethodLinkHash = hash; + } + setSetterMethodLinkHash(hash: string): void { + this.setterMethodLinkHash = hash; + } + setSourceExpressionLinkHash(hash: string): void { + this.sourceExpressionLinkHash = hash; + } + + getEntryCombined(): string { + return `js_field[hash=${this.jsFieldUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.qualifiedName), + this.ownerTypeLinkHash, + this.declarationForm, + bool(this.isStatic), + bool(this.isPrivateName), + bool(this.isReadonly), + text(this.declaredTypeName), + this.declaredTypeSource, + this.typeReferenceLinkHash, + bool(this.hasInitializer), + this.initializerExpressionLinkHash, + this.accessorPairKind, + this.getterMethodLinkHash, + this.setterMethodLinkHash, + bool(this.isComputedName), + this.sourceExpressionLinkHash, + num(this.startLine), + num(this.startColumn), + this.ownerModuleLinkHash, + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsFieldUniqueHash, + ], + JsFieldRegistry.ARITY, + 'js_field' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', + 'qualifiedName', + 'ownerTypeLinkHash', + 'declarationForm', + 'isStatic', + 'isPrivateName', + 'isReadonly', + 'declaredTypeName', + 'declaredTypeSource', + 'typeReferenceLinkHash', + 'hasInitializer', + 'initializerExpressionLinkHash', + 'accessorPairKind', + 'getterMethodLinkHash', + 'setterMethodLinkHash', + 'isComputedName', + 'sourceExpressionLinkHash', + 'startLine', + 'startColumn', + 'ownerModuleLinkHash', + 'isExternal', + 'serviceVersionLinkHash', + 'jsFieldUniqueHash', + ], + JsFieldRegistry.ARITY, + 'js_field' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsImportRegistry.ts b/parser/src/analysis-types/javascript/JsImportRegistry.ts new file mode 100644 index 000000000..a6be5939c --- /dev/null +++ b/parser/src/analysis-types/javascript/JsImportRegistry.ts @@ -0,0 +1,216 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsEdgeBearer, + JsImportBindingForm, + JsImportForm, + JsImportResolutionOutcome, + JsSpecifierKind, +} from '@/enums/javascript/imports'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One module edge IN — schema §3.8, 23 columns. + * + * ## 83.6% of these rows are minted from expressions, in a second pass + * + * `require('./x')` is a call and `module.exports = X` is an assignment, so the + * module graph lives in the expression relation. This relation is therefore + * built **after** `js_expression`, inverting §1's build order for its last step, + * and `sourceExpressionLinkHash` points back at the expression each row was + * minted from — which is what makes the second pass auditable. Gate 7.3.1: + * every expression with `isModuleEdge` is pointed at by **exactly one** edge + * row, because a second pass that double-mints **doubles** the edge count rather + * than colliding. + * + * ## `isTopLevel` is false for 13.6% of requires, and that is the whole reason + * the extractor cannot walk the statement list + * + * 1,227 of 9,055 measured `require()` calls are not top-level — 1,048 inside a + * function body, 179 inside a block. Every TypeScript module-edge extractor + * walks `sourceFile.statements`, because every TypeScript module edge is a + * top-level declaration. Doing that here misses one require in seven. + * + * ## Recorded as written; nothing reaches through it + * + * `specifier` as it appears in source, `specifierKind`, and `resolvedFilePath` + * from `ts.resolveModuleName` — a pure function needing no Program. Nothing in + * this relation names what `./router` exports. `resolvedModuleLinkHash` is + * **tier 3 and stays empty**: `resolvedFilePath` is a path string the parser + * computed, and turning it into a link is cross-file following. + * + * `startColumn` is in the key because `const { a, b } = require('x')` produces + * two rows on one line with one specifier. + */ +export class JsImportRegistry implements EntityIdentifiable { + static readonly ARITY = 23; + + readonly specifier: string; + readonly specifierKind: JsSpecifierKind; + readonly edgeBearer: JsEdgeBearer; + readonly importForm: JsImportForm; + /** **False for 13.6%** of requires — 1,048 in a function body, 179 in a block. */ + readonly isTopLevel: boolean; + /** Inside an `if` or `try` — a module edge that may never execute. */ + readonly isConditional: boolean; + readonly bindingForm: JsImportBindingForm; + readonly importedName: string; + readonly localName: string; + /** **The hop the engine needs.** From `ts.resolveModuleName`, which needs no Program. */ + readonly resolvedFilePath: string; + readonly resolutionOutcome: JsImportResolutionOutcome; + /** + * **TIER 3 — declared, never staged.** Always `""`, and there is + * deliberately no setter. + * + * Java is the precedent: `referencedTypeRegistryLinkHash` is populated 0 + * times in 67,938 rows and that is the design, not an oversight. + * Cross-file resolution is the engine's work, and a parser that fills + * this column is `type-resolution.dl` rewritten in TypeScript — which was + * written once and then deleted. The gate asserts zero populated rows. + */ + private readonly resolvedModuleLinkHash = ABSENT; + /** Parity slot, always `false` — JavaScript has no `import type`. */ + readonly isTypeOnly: boolean; + private sourceExpressionLinkHash = ABSENT; + private boundVariableLinkHash = ABSENT; + readonly ownerScopeLinkHash: string; + readonly ownerMethodLinkHash: string; + readonly ownerModuleLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsImportUniqueHash = ABSENT; + + constructor(props: { + specifier: string; + specifierKind: JsSpecifierKind; + edgeBearer: JsEdgeBearer; + importForm: JsImportForm; + isTopLevel: boolean; + isConditional: boolean; + bindingForm: JsImportBindingForm; + importedName: string; + localName: string; + resolvedFilePath: string; + resolutionOutcome: JsImportResolutionOutcome; + isTypeOnly: boolean; + ownerScopeLinkHash: string; + ownerMethodLinkHash: string; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.specifier = props.specifier; + this.specifierKind = props.specifierKind; + this.edgeBearer = props.edgeBearer; + this.importForm = props.importForm; + this.isTopLevel = props.isTopLevel; + this.isConditional = props.isConditional; + this.bindingForm = props.bindingForm; + this.importedName = props.importedName; + this.localName = props.localName; + this.resolvedFilePath = props.resolvedFilePath; + this.resolutionOutcome = props.resolutionOutcome; + this.isTypeOnly = props.isTypeOnly; + this.ownerScopeLinkHash = props.ownerScopeLinkHash; + this.ownerMethodLinkHash = props.ownerMethodLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsImportUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_IMPORT, + keyOf(this.ownerModuleLinkHash, this.specifier, this.localName, this.startLine, this.startColumn) + ); + } + + getHash(): string { + return this.jsImportUniqueHash; + } + + setSourceExpressionLinkHash(hash: string): void { + this.sourceExpressionLinkHash = hash; + } + setBoundVariableLinkHash(hash: string): void { + this.boundVariableLinkHash = hash; + } + + getEntryCombined(): string { + return `js_import[hash=${this.jsImportUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.specifier), + this.specifierKind, + this.edgeBearer, + this.importForm, + bool(this.isTopLevel), + bool(this.isConditional), + this.bindingForm, + text(this.importedName), + text(this.localName), + text(this.resolvedFilePath), + this.resolutionOutcome, + this.resolvedModuleLinkHash, + bool(this.isTypeOnly), + this.sourceExpressionLinkHash, + this.boundVariableLinkHash, + this.ownerScopeLinkHash, + this.ownerMethodLinkHash, + this.ownerModuleLinkHash, + num(this.startLine), + num(this.startColumn), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsImportUniqueHash, + ], + JsImportRegistry.ARITY, + 'js_import' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'specifier', + 'specifierKind', + 'edgeBearer', + 'importForm', + 'isTopLevel', + 'isConditional', + 'bindingForm', + 'importedName', + 'localName', + 'resolvedFilePath', + 'resolutionOutcome', + 'resolvedModuleLinkHash', + 'isTypeOnly', + 'sourceExpressionLinkHash', + 'boundVariableLinkHash', + 'ownerScopeLinkHash', + 'ownerMethodLinkHash', + 'ownerModuleLinkHash', + 'startLine', + 'startColumn', + 'isExternal', + 'serviceVersionLinkHash', + 'jsImportUniqueHash', + ], + JsImportRegistry.ARITY, + 'js_import' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsMethodParameterRegistry.ts b/parser/src/analysis-types/javascript/JsMethodParameterRegistry.ts new file mode 100644 index 000000000..943660c21 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsMethodParameterRegistry.ts @@ -0,0 +1,193 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsParameterBindingForm, +} from '@/enums/javascript/method-parameters'; +import { JsDeclaredTypeSource } from '@/enums/javascript/common'; +import { JsBindingRegime } from '@/enums/javascript/variables'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One declared parameter — schema §3.5, 22 columns. + * + * ## A destructured parameter is ONE row, plus N variables + * + * 6,909 destructuring patterns were measured. The row keeps `position` and + * records `patternBindingCount`; the names it binds are `js_variable` rows with + * `bindingRegime = PARAMETER`. + * + * Both alternatives are wrong and both are tempting. **N parameter rows** breaks + * `position` — `function f({ a, b }, c)` has `c` at position 1, and emitting + * `a` and `b` as parameters puts it at 2, so every arity join is off by one. + * **One row with no binding information** loses every name, so a call through a + * destructured parameter resolves to nothing. That is the §3 defect class, and + * the fix is Java's: one node for the construct, its parts parented to it, and + * the variant in a column. + * + * ## `declaredTypeSource` is where the language's type channel actually is + * + * 62.1% of parameters carry no declared type, **37.9% carry a JSDoc one**, and + * effectively none carry a syntactic one: 64 syntactic annotations were once + * reported, all of them Flow, and the replication corpus measures 0 because all + * 64 were in one package it does not contain. TypeScript's + * schema is Java-shaped because 85.3% of its parameters are annotated; that + * mechanism is absent from this language's syntax entirely. + */ +export class JsMethodParameterRegistry implements EntityIdentifiable { + static readonly ARITY = 22; + + readonly name: string; + readonly position: number; + readonly ownerMethodLinkHash: string; + readonly declaredTypeName: string; + readonly declaredTypeSource: JsDeclaredTypeSource; + private typeReferenceLinkHash = ABSENT; + readonly isOptional: boolean; + readonly hasDefault: boolean; + readonly defaultValueText: string; + readonly isRest: boolean; + readonly bindingForm: JsParameterBindingForm; + /** How many names this parameter actually binds; > 0 for a destructuring pattern. */ + readonly patternBindingCount: number; + /** Always `PARAMETER`. */ + readonly bindingRegime: JsBindingRegime; + readonly scopeLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + /** Parity slot with TypeScript, always `false`. */ + readonly isParameterProperty: boolean; + private jsdocCommentLinkHash = ABSENT; + readonly ownerModuleLinkHash: string; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsMethodParameterUniqueHash = ABSENT; + + constructor(props: { + name: string; + position: number; + ownerMethodLinkHash: string; + declaredTypeName: string; + declaredTypeSource: JsDeclaredTypeSource; + isOptional: boolean; + hasDefault: boolean; + defaultValueText: string; + isRest: boolean; + bindingForm: JsParameterBindingForm; + patternBindingCount: number; + bindingRegime: JsBindingRegime; + scopeLinkHash: string; + startLine: number; + startColumn: number; + isParameterProperty: boolean; + ownerModuleLinkHash: string; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.position = props.position; + this.ownerMethodLinkHash = props.ownerMethodLinkHash; + this.declaredTypeName = props.declaredTypeName; + this.declaredTypeSource = props.declaredTypeSource; + this.isOptional = props.isOptional; + this.hasDefault = props.hasDefault; + this.defaultValueText = props.defaultValueText; + this.isRest = props.isRest; + this.bindingForm = props.bindingForm; + this.patternBindingCount = props.patternBindingCount; + this.bindingRegime = props.bindingRegime; + this.scopeLinkHash = props.scopeLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.isParameterProperty = props.isParameterProperty; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsMethodParameterUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_METHOD_PARAMETER, + keyOf(this.ownerMethodLinkHash, this.position, this.name) + ); + } + + getHash(): string { + return this.jsMethodParameterUniqueHash; + } + + setTypeReferenceLinkHash(hash: string): void { + this.typeReferenceLinkHash = hash; + } + setJsdocCommentLinkHash(hash: string): void { + this.jsdocCommentLinkHash = hash; + } + + getEntryCombined(): string { + return `js_method_parameter[hash=${this.jsMethodParameterUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + num(this.position), + this.ownerMethodLinkHash, + text(this.declaredTypeName), + this.declaredTypeSource, + this.typeReferenceLinkHash, + bool(this.isOptional), + bool(this.hasDefault), + text(this.defaultValueText), + bool(this.isRest), + this.bindingForm, + num(this.patternBindingCount), + this.bindingRegime, + this.scopeLinkHash, + num(this.startLine), + num(this.startColumn), + bool(this.isParameterProperty), + this.jsdocCommentLinkHash, + this.ownerModuleLinkHash, + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsMethodParameterUniqueHash, + ], + JsMethodParameterRegistry.ARITY, + 'js_method_parameter' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', + 'position', + 'ownerMethodLinkHash', + 'declaredTypeName', + 'declaredTypeSource', + 'typeReferenceLinkHash', + 'isOptional', + 'hasDefault', + 'defaultValueText', + 'isRest', + 'bindingForm', + 'patternBindingCount', + 'bindingRegime', + 'scopeLinkHash', + 'startLine', + 'startColumn', + 'isParameterProperty', + 'jsdocCommentLinkHash', + 'ownerModuleLinkHash', + 'isExternal', + 'serviceVersionLinkHash', + 'jsMethodParameterUniqueHash', + ], + JsMethodParameterRegistry.ARITY, + 'js_method_parameter' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsMethodRegistry.ts b/parser/src/analysis-types/javascript/JsMethodRegistry.ts new file mode 100644 index 000000000..71fc5e385 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsMethodRegistry.ts @@ -0,0 +1,275 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsBodyPresence, + JsHoisting, + JsMethodDeclarationForm, + JsMethodKind, + JsThisBinding, +} from '@/enums/javascript/methods'; +import { JsDeclaredTypeSource } from '@/enums/javascript/common'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Every callable — schema §3.4, 36 columns. + * + * Function declarations, function expressions, arrows, class methods, + * accessors, prototype-assigned methods, and the synthetic `` + * initializer. + * + * ## `thisBinding` and `usesArguments` have no analogue in any other front end + * + * Both are load-bearing rather than descriptive: + * + * - **`thisBinding`.** 33,189 `this` references were measured, and what `this` + * refers to is decided by the **call form**, not the declaration. + * `obj.m()`, `m()`, `m.call(x)` and `m.bind(x)()` invoke one function body + * with four different receivers. An engine that treats every callable as + * rebinding `this` gets arrows wrong; one that treats none of them as + * rebinding gets the other four wrong. + * - **`usesArguments`.** `arguments` is **a parameter list nobody declared.** A + * function reading it accepts arguments no `js_method_parameter` row + * describes, so an engine modelling only named parameters silently loses the + * whole channel. + * + * ## `hoisting` separates two things that look like one category + * + * 5,271 function declarations hoist entirely — name and body — so a call above + * the declaration works. 4,325 function expressions do not hoist at all. Same + * syntax category, opposite behaviour, decided by position. + * + * `startColumn` is in the primary key because 9,391 arrows were measured and + * many share a line. + */ +export class JsMethodRegistry implements EntityIdentifiable { + static readonly ARITY = 36; + + readonly name: string; + readonly qualifiedName: string; + readonly fileName: string; + readonly filePath: string; + readonly baseMservPath: string; + readonly startLine: number; + readonly endLine: number; + readonly startColumn: number; + readonly methodKind: JsMethodKind; + readonly declarationForm: JsMethodDeclarationForm; + readonly hoisting: JsHoisting; + readonly isAsync: boolean; + readonly isGenerator: boolean; + readonly isStatic: boolean; + readonly parameterCount: number; + readonly hasRestParameter: boolean; + /** A second parameter channel with no declaration. An engine modelling only named parameters loses it. */ + readonly usesArguments: boolean; + readonly thisBinding: JsThisBinding; + readonly returnTypeName: string; + readonly declaredTypeSource: JsDeclaredTypeSource; + private returnTypeReferenceLinkHash = ABSENT; + readonly bodyPresence: JsBodyPresence; + readonly ownerTypeLinkHash: string; + readonly ownerModuleLinkHash: string; + /** The scope this method is DECLARED IN. */ + readonly ownerScopeLinkHash: string; + /** The scope this method OPENS. */ + readonly bodyScopeLinkHash: string; + readonly enclosingMethodLinkHash: string; + private sourceExpressionLinkHash = ABSENT; + private jsdocCommentLinkHash = ABSENT; + private isExported = false; + /** Parity slot. JavaScript has no signature that marks an entry point — `main` is a convention, a bin script is a package.json field, and a Lambda handler is a deployment setting. Naming one from syntax would be guessing, so this is permanently false and the module-level `` initializer is the honest entry. */ + readonly isEntryPoint: boolean; + /** Parity slot with Java, always `""` — JavaScript has no `::`. */ + readonly methodReferenceKind: string; + readonly modifiers: string; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsMethodUniqueHash = ABSENT; + + constructor(props: { + name: string; + qualifiedName: string; + fileName: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + startColumn: number; + methodKind: JsMethodKind; + declarationForm: JsMethodDeclarationForm; + hoisting: JsHoisting; + isAsync: boolean; + isGenerator: boolean; + isStatic: boolean; + parameterCount: number; + hasRestParameter: boolean; + usesArguments: boolean; + thisBinding: JsThisBinding; + returnTypeName: string; + declaredTypeSource: JsDeclaredTypeSource; + bodyPresence: JsBodyPresence; + ownerTypeLinkHash: string; + ownerModuleLinkHash: string; + ownerScopeLinkHash: string; + bodyScopeLinkHash: string; + enclosingMethodLinkHash: string; + isEntryPoint: boolean; + methodReferenceKind: string; + modifiers: string; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.qualifiedName = props.qualifiedName; + this.fileName = props.fileName; + this.filePath = props.filePath; + this.baseMservPath = props.baseMservPath; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.startColumn = props.startColumn; + this.methodKind = props.methodKind; + this.declarationForm = props.declarationForm; + this.hoisting = props.hoisting; + this.isAsync = props.isAsync; + this.isGenerator = props.isGenerator; + this.isStatic = props.isStatic; + this.parameterCount = props.parameterCount; + this.hasRestParameter = props.hasRestParameter; + this.usesArguments = props.usesArguments; + this.thisBinding = props.thisBinding; + this.returnTypeName = props.returnTypeName; + this.declaredTypeSource = props.declaredTypeSource; + this.bodyPresence = props.bodyPresence; + this.ownerTypeLinkHash = props.ownerTypeLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.ownerScopeLinkHash = props.ownerScopeLinkHash; + this.bodyScopeLinkHash = props.bodyScopeLinkHash; + this.enclosingMethodLinkHash = props.enclosingMethodLinkHash; + this.isEntryPoint = props.isEntryPoint; + this.methodReferenceKind = props.methodReferenceKind; + this.modifiers = props.modifiers; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsMethodUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_METHOD, + keyOf(this.ownerModuleLinkHash, this.qualifiedName, this.startLine, this.startColumn, this.methodKind) + ); + } + + getHash(): string { + return this.jsMethodUniqueHash; + } + + setReturnTypeReferenceLinkHash(hash: string): void { + this.returnTypeReferenceLinkHash = hash; + } + setSourceExpressionLinkHash(hash: string): void { + this.sourceExpressionLinkHash = hash; + } + setJsdocCommentLinkHash(hash: string): void { + this.jsdocCommentLinkHash = hash; + } + setIsExported(value = true): void { + this.isExported = value; + } + + getEntryCombined(): string { + return `js_method[hash=${this.jsMethodUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.qualifiedName), + text(this.fileName), + text(this.filePath), + text(this.baseMservPath), + num(this.startLine), + num(this.endLine), + num(this.startColumn), + this.methodKind, + this.declarationForm, + this.hoisting, + bool(this.isAsync), + bool(this.isGenerator), + bool(this.isStatic), + num(this.parameterCount), + bool(this.hasRestParameter), + bool(this.usesArguments), + this.thisBinding, + text(this.returnTypeName), + this.declaredTypeSource, + this.returnTypeReferenceLinkHash, + this.bodyPresence, + this.ownerTypeLinkHash, + this.ownerModuleLinkHash, + this.ownerScopeLinkHash, + this.bodyScopeLinkHash, + this.enclosingMethodLinkHash, + this.sourceExpressionLinkHash, + this.jsdocCommentLinkHash, + bool(this.isExported), + bool(this.isEntryPoint), + text(this.methodReferenceKind), + text(this.modifiers), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsMethodUniqueHash, + ], + JsMethodRegistry.ARITY, + 'js_method' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', + 'qualifiedName', + 'fileName', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'startColumn', + 'methodKind', + 'declarationForm', + 'hoisting', + 'isAsync', + 'isGenerator', + 'isStatic', + 'parameterCount', + 'hasRestParameter', + 'usesArguments', + 'thisBinding', + 'returnTypeName', + 'declaredTypeSource', + 'returnTypeReferenceLinkHash', + 'bodyPresence', + 'ownerTypeLinkHash', + 'ownerModuleLinkHash', + 'ownerScopeLinkHash', + 'bodyScopeLinkHash', + 'enclosingMethodLinkHash', + 'sourceExpressionLinkHash', + 'jsdocCommentLinkHash', + 'isExported', + 'isEntryPoint', + 'methodReferenceKind', + 'modifiers', + 'isExternal', + 'serviceVersionLinkHash', + 'jsMethodUniqueHash', + ], + JsMethodRegistry.ARITY, + 'js_method' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsModuleRegistry.ts b/parser/src/analysis-types/javascript/JsModuleRegistry.ts new file mode 100644 index 000000000..3a4c99a36 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsModuleRegistry.ts @@ -0,0 +1,237 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsContradictionKind, + JsModuleKind, + JsModuleSystem, + JsModuleSystemSource, + JsScriptKind, + JsSourceProvenance, +} from '@/enums/javascript/modules'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A JavaScript module — schema §3.1, 28 columns. + * + * **One row per file, always.** That is the first way this relation differs + * from `ts_module`, which mints extra rows for `declare module "x"` and + * `declare global`. JavaScript has neither, so there is no ambient-module + * analogue and no `declaredSpecifier` in the key. + * + * ## `moduleSystem` is in the primary key, and that is the whole design + * + * The three columns c7–c9 are one decision recorded in three parts, and they + * are load-bearing in a way no `ts_module` column is: + * + * - **c7 `moduleSystem`** is the conclusion, and it is **in the key**. `import` + * under a CommonJS config is a different program from `import` under an ESM + * one, and the deciding input is a `package.json` the file does not contain. + * Keying on it means a repo analysed before and after a `"type": "module"` + * edit yields two distinguishable fact sets rather than one silently + * overwriting the other. + * - **c8 `moduleSystemSource`** is *how* it was decided. 91.4% of measured files + * are defaulted rather than declared, so without this a default is + * indistinguishable from a declaration. + * - **c9 `governingPackageJsonPath`** is the evidence, and it is deliberately + * **out** of the key. Evidence moving without the conclusion moving must not + * cascade every child hash — and it moves often, because the governing file + * is frequently outside the repository entirely. + * + * ## `emissionRegime` is in the key; `targetTsVersion` is not + * + * Same split as `ts_module`, same reason. The regime is coarse + * (`js-ts6-inproc`) and propagates into every child hash, so a 6.x fact base can + * never be silently mixed with a future one. The exact version (`6.0.3`) is + * provenance: in the key it would invalidate the entire fact base on a patch + * bump, for a change that alters nothing about the facts. + */ +export class JsModuleRegistry implements EntityIdentifiable { + static readonly ARITY = 28; + + readonly name: string; + readonly qualifiedName: string; + readonly fileName: string; + readonly filePath: string; + readonly baseMservPath: string; + readonly moduleKind: JsModuleKind; + readonly scriptKind: JsScriptKind; + readonly moduleSystem: JsModuleSystem; + readonly moduleSystemSource: JsModuleSystemSource; + readonly governingPackageJsonPath: string; + readonly contradictsGoverningConfig: boolean; + readonly contradictionKind: JsContradictionKind; + readonly packageName: string; + readonly isExternalModule: boolean; + readonly hasTopLevelAwait: boolean; + readonly hasJsxContent: boolean; + readonly hasFlowPragma: boolean; + readonly emissionRegime: string; + readonly targetTsVersion: string; + readonly startLine: number; + readonly endLine: number; + + /** + * Back-patched: the module row is minted from the PATH ALONE, before the file + * has been parsed and therefore before its `` initializer, its root + * scope or its default export exist. + * + * That ordering is not an accident of implementation — it is what lets a + * declaration in file B key itself under file A's module hash without file A + * having been read (§1 of `BUILDING-A-PARSER.md`). + */ + private moduleInitMethodLinkHash = ABSENT; + private moduleScopeLinkHash = ABSENT; + private defaultExportLinkHash = ABSENT; + + readonly sourceProvenance: JsSourceProvenance; + /** Parity slot, always `false` on parser output so `lib_js_module` is byte-identical. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsModuleUniqueHash = ABSENT; + + constructor(props: { + name: string; + qualifiedName: string; + fileName: string; + filePath: string; + baseMservPath: string; + moduleKind: JsModuleKind; + scriptKind: JsScriptKind; + moduleSystem: JsModuleSystem; + moduleSystemSource: JsModuleSystemSource; + governingPackageJsonPath: string; + contradictsGoverningConfig: boolean; + contradictionKind: JsContradictionKind; + packageName: string; + isExternalModule: boolean; + hasTopLevelAwait: boolean; + hasJsxContent: boolean; + hasFlowPragma: boolean; + emissionRegime: string; + targetTsVersion: string; + startLine: number; + endLine: number; + sourceProvenance: JsSourceProvenance; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.qualifiedName = props.qualifiedName; + this.fileName = props.fileName; + this.filePath = props.filePath; + this.baseMservPath = props.baseMservPath; + this.moduleKind = props.moduleKind; + this.scriptKind = props.scriptKind; + this.moduleSystem = props.moduleSystem; + this.moduleSystemSource = props.moduleSystemSource; + this.governingPackageJsonPath = props.governingPackageJsonPath; + this.contradictsGoverningConfig = props.contradictsGoverningConfig; + this.contradictionKind = props.contradictionKind; + this.packageName = props.packageName; + this.isExternalModule = props.isExternalModule; + this.hasTopLevelAwait = props.hasTopLevelAwait; + this.hasJsxContent = props.hasJsxContent; + this.hasFlowPragma = props.hasFlowPragma; + this.emissionRegime = props.emissionRegime; + this.targetTsVersion = props.targetTsVersion; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.sourceProvenance = props.sourceProvenance; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `JS_MODULE_md5(filePath ‖ baseMservPath ‖ moduleSystem ‖ emissionRegime ‖ serviceVersionLinkHash)` + * + * Note what is absent: `startLine` (always 1 — one row per file), + * `governingPackageJsonPath` (evidence, not conclusion) and `targetTsVersion` + * (provenance). Each omission is argued in the class comment. + */ + generateHash(): void { + this.jsModuleUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_MODULE, + keyOf( + this.filePath, + this.baseMservPath, + this.moduleSystem, + this.emissionRegime, + this.serviceVersionLinkHash + ) + ); + } + + getHash(): string { + return this.jsModuleUniqueHash; + } + + setModuleInitMethodLinkHash(hash: string): void { + this.moduleInitMethodLinkHash = hash; + } + + setModuleScopeLinkHash(hash: string): void { + this.moduleScopeLinkHash = hash; + } + + setDefaultExportLinkHash(hash: string): void { + this.defaultExportLinkHash = hash; + } + + getEntryCombined(): string { + return `js_module[name=${this.name}, kind=${this.moduleKind}, system=${this.moduleSystem}, hash=${this.jsModuleUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.qualifiedName), + text(this.fileName), + text(this.filePath), + text(this.baseMservPath), + this.moduleKind, + this.scriptKind, + this.moduleSystem, + this.moduleSystemSource, + text(this.governingPackageJsonPath), + bool(this.contradictsGoverningConfig), + this.contradictionKind, + text(this.packageName), + bool(this.isExternalModule), + bool(this.hasTopLevelAwait), + bool(this.hasJsxContent), + bool(this.hasFlowPragma), + this.emissionRegime, + this.targetTsVersion, + num(this.startLine), + num(this.endLine), + this.moduleInitMethodLinkHash, + this.moduleScopeLinkHash, + this.defaultExportLinkHash, + this.sourceProvenance, + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsModuleUniqueHash, + ], + JsModuleRegistry.ARITY, + 'js_module' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', 'qualifiedName', 'fileName', 'filePath', 'baseMservPath', 'moduleKind', + 'scriptKind', 'moduleSystem', 'moduleSystemSource', 'governingPackageJsonPath', + 'contradictsGoverningConfig', 'contradictionKind', 'packageName', 'isExternalModule', + 'hasTopLevelAwait', 'hasJsxContent', 'hasFlowPragma', 'emissionRegime', + 'targetTsVersion', 'startLine', 'endLine', 'moduleInitMethodLinkHash', + 'moduleScopeLinkHash', 'defaultExportLinkHash', 'sourceProvenance', 'isExternal', + 'serviceVersionLinkHash', 'jsModuleUniqueHash', + ], + JsModuleRegistry.ARITY, + 'js_module' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsParseGapRegistry.ts b/parser/src/analysis-types/javascript/JsParseGapRegistry.ts new file mode 100644 index 000000000..3e67d37b6 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsParseGapRegistry.ts @@ -0,0 +1,146 @@ +import { ABSENT, bool, boundedText, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { JS_COMMENT_TEXT_LIMIT } from '@/constants/javascript-constants'; +import { + JsParseGapKind, +} from '@/enums/javascript/parse-gaps'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * What the parser could not do — schema §3.16, 11 columns. + * + * Recorded as **data rather than a log line**. §9 of `BUILDING-A-PARSER.md`: if + * the analyzer drops something for a structural reason it must say so, and on + * one large framework checkout a nested config silently excluded 1,270 of 1,821 files because nothing + * counted them — the run reported success with a fact base missing two thirds of + * the project. + * + * A log line is not a count, and a count that is not in the fact base cannot be + * joined against the rows that are. `relatedRelation` names the relation that + * would have had the row, so a consumer can ask "what is missing from + * `js_call_site` here" and get an answer. + */ +export class JsParseGapRegistry implements EntityIdentifiable { + static readonly ARITY = 11; + + readonly gapKind: JsParseGapKind; + readonly detail: string; + /** Which relation would have had the row. */ + readonly relatedRelation: string; + readonly relatedLinkHash: string; + readonly ownerModuleLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + readonly isRecoverable: boolean; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsParseGapUniqueHash = ABSENT; + + constructor(props: { + gapKind: JsParseGapKind; + detail: string; + relatedRelation: string; + relatedLinkHash: string; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + isRecoverable: boolean; + serviceVersionLinkHash: string; + }) { + this.gapKind = props.gapKind; + this.detail = props.detail; + this.relatedRelation = props.relatedRelation; + this.relatedLinkHash = props.relatedLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.isRecoverable = props.isRecoverable; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `JS_PARSE_GAP_md5(ownerModuleLinkHash ‖ gapKind ‖ startLine ‖ startColumn ‖ relatedLinkHash ‖ detail)` + * + * ## Why the position and the kind are not enough + * + * They were, on a 816-file corpus. On 4,561 files the key produced **121 + * duplicate primary keys**, and a duplicate does not collide — it DOUBLES, + * which is the one failure shape that leaves nothing looking wrong. + * + * The cause is the compiler, not the walk. On an unterminated JSX element + * `ts.createSourceFile` reports `1005: '>` is **three rows**, not a string — the same + * parent-FK tree shape `ts_type_reference` uses, capped at depth 32. A consumer + * that has to re-parse a string to find a generic argument is one that will get + * it wrong on the first nested union. + * + * ## `isTypeOnly` is `true` in every row, and the gate asserts it + * + * No call-graph rule may traverse this relation. A `@typedef` naming a function + * shape is **not a call target**, and `@callback` — 103 measured — is exactly + * the row most likely to be mistaken for one. + * + * ## `UNKNOWN_SYNTAX` is deliberate and expected to be non-empty + * + * JSDoc type syntax is not standardised; Closure, TypeScript and jsdoc.app all + * differ. A type expression the parser cannot decompose gets **one row with its + * text preserved**, rather than a guess or a dropped tag. + */ +export class JsTypeReferenceRegistry implements EntityIdentifiable { + static readonly ARITY = 23; + + readonly typeName: string; + readonly referenceKind: JsTypeReferenceKind; + readonly parentReferenceLinkHash: string; + readonly depth: number; + readonly childIndex: number; + private childCount = 0; + private isTruncated = false; + readonly contextKind: JsTypeReferenceContextKind; + readonly tagName: string; + readonly ownerKind: JsTypeReferenceOwnerKind; + readonly ownerLinkHash: string; + /** + * **TIER 3 — declared, never staged.** Always `""`, and there is + * deliberately no setter. + * + * Java is the precedent: `referencedTypeRegistryLinkHash` is populated 0 + * times in 67,938 rows and that is the design, not an oversight. + * Cross-file resolution is the engine's work, and a parser that fills + * this column is `type-resolution.dl` rewritten in TypeScript — which was + * written once and then deleted. The gate asserts zero populated rows. + */ + private readonly resolvedTypeLinkHash = ABSENT; + private resolvedFilePath = ABSENT; + private importLinkHash = ABSENT; + /** **Always `true`.** No call-graph rule may traverse this relation. */ + readonly isTypeOnly: boolean; + readonly isBuiltinType: boolean; + readonly commentLinkHash: string; + readonly ownerModuleLinkHash: string; + /** Line **within the comment**. */ + readonly startLine: number; + readonly startColumn: number; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsTypeReferenceUniqueHash = ABSENT; + + constructor(props: { + typeName: string; + referenceKind: JsTypeReferenceKind; + parentReferenceLinkHash: string; + depth: number; + childIndex: number; + contextKind: JsTypeReferenceContextKind; + tagName: string; + ownerKind: JsTypeReferenceOwnerKind; + ownerLinkHash: string; + isTypeOnly: boolean; + isBuiltinType: boolean; + commentLinkHash: string; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.typeName = props.typeName; + this.referenceKind = props.referenceKind; + this.parentReferenceLinkHash = props.parentReferenceLinkHash; + this.depth = props.depth; + this.childIndex = props.childIndex; + this.contextKind = props.contextKind; + this.tagName = props.tagName; + this.ownerKind = props.ownerKind; + this.ownerLinkHash = props.ownerLinkHash; + this.isTypeOnly = props.isTypeOnly; + this.isBuiltinType = props.isBuiltinType; + this.commentLinkHash = props.commentLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsTypeReferenceUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_TYPE_REFERENCE, + keyOf(this.ownerLinkHash, this.contextKind, this.depth, this.childIndex, this.startLine, this.startColumn) + ); + } + + getHash(): string { + return this.jsTypeReferenceUniqueHash; + } + + setChildCount(value: number): void { + this.childCount = value; + } + setIsTruncated(value = true): void { + this.isTruncated = value; + } + /** Read by the parse-gap pass, which derives its rows from the fact base. */ + wasTruncated(): boolean { + return this.isTruncated; + } + setResolvedFilePath(hash: string): void { + this.resolvedFilePath = hash; + } + setImportLinkHash(hash: string): void { + this.importLinkHash = hash; + } + /** Read by the IR-completeness measure, which asks whether the hop is present. */ + importLinkHashValue(): string { + return this.importLinkHash; + } + + getEntryCombined(): string { + return `js_type_reference[hash=${this.jsTypeReferenceUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.typeName), + this.referenceKind, + this.parentReferenceLinkHash, + num(this.depth), + num(this.childIndex), + num(this.childCount), + bool(this.isTruncated), + this.contextKind, + text(this.tagName), + this.ownerKind, + this.ownerLinkHash, + this.resolvedTypeLinkHash, + this.resolvedFilePath, + this.importLinkHash, + bool(this.isTypeOnly), + bool(this.isBuiltinType), + this.commentLinkHash, + this.ownerModuleLinkHash, + num(this.startLine), + num(this.startColumn), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsTypeReferenceUniqueHash, + ], + JsTypeReferenceRegistry.ARITY, + 'js_type_reference' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'typeName', + 'referenceKind', + 'parentReferenceLinkHash', + 'depth', + 'childIndex', + 'childCount', + 'isTruncated', + 'contextKind', + 'tagName', + 'ownerKind', + 'ownerLinkHash', + 'resolvedTypeLinkHash', + 'resolvedFilePath', + 'importLinkHash', + 'isTypeOnly', + 'isBuiltinType', + 'commentLinkHash', + 'ownerModuleLinkHash', + 'startLine', + 'startColumn', + 'isExternal', + 'serviceVersionLinkHash', + 'jsTypeReferenceUniqueHash', + ], + JsTypeReferenceRegistry.ARITY, + 'js_type_reference' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsTypeRegistry.ts b/parser/src/analysis-types/javascript/JsTypeRegistry.ts new file mode 100644 index 000000000..e52b87a4f --- /dev/null +++ b/parser/src/analysis-types/javascript/JsTypeRegistry.ts @@ -0,0 +1,217 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsEvidenceKind, + JsTypeCategory, + JsTypeDeclarationForm, +} from '@/enums/javascript/types'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A type declaration — schema §3.2, 26 columns. + * + * An ES class, a constructor function with prototype members, or a JSDoc + * `@typedef`/`@callback`. **Object literals are not here.** They are values, and + * treating every one as a type is how a JavaScript fact base acquires 50,000 + * meaningless types — a tempting mistake, because the pre-ES6 module pattern + * really does use an object literal where a modern codebase uses a class. + * + * ## There is no `declarationGroupKey`, and that is a decision + * + * TypeScript needs one because declaration merging makes `name -> single entity` + * false: 1,986 multi-declaration symbols were measured there, one name reaching + * 43 declarations. **JavaScript has no declaration merging.** A second + * `class Foo` is a redeclaration error, and `Foo.prototype.x = …` after + * `class Foo` mutates the *same* entity — which the FK from `js_method` already + * expresses. Adding a merge key here would be porting a solution to a problem + * this language does not have. + * + * ## The key chains off the module, not off a name + * + * `module.exports = class {}` yields a type whose only name is its file's, so + * two such files in one directory would collide on any name-derived key. + * `startColumn` is in the key because two class expressions can share a line. + */ +export class JsTypeRegistry implements EntityIdentifiable { + static readonly ARITY = 26; + + readonly name: string; + readonly qualifiedName: string; + readonly fileName: string; + readonly filePath: string; + readonly baseMservPath: string; + readonly startLine: number; + readonly endLine: number; + readonly startColumn: number; + readonly typeCategory: JsTypeCategory; + readonly declarationForm: JsTypeDeclarationForm; + /** Parity slot, always `false` — JavaScript has no `abstract`. */ + readonly isAbstract: boolean; + /** Permanently `""`. A JavaScript class declaration carries no modifiers; `static` belongs to its MEMBERS and is on js_method/js_field. */ + readonly modifiers: string; + readonly evidenceKind: JsEvidenceKind; + /** True for `JSDOC_TYPEDEF`/`JSDOC_CALLBACK`. No call-graph rule may traverse these rows. */ + readonly isTypeOnly: boolean; + private declaredMemberCount = 0; + /** True when members arrived by assignment rather than in a class body. */ + private hasPrototypeMembers = false; + private constructorMethodLinkHash = ABSENT; + readonly ownerModuleLinkHash: string; + readonly ownerScopeLinkHash: string; + readonly enclosingMethodLinkHash: string; + private sourceExpressionLinkHash = ABSENT; + private jsdocCommentLinkHash = ABSENT; + private isExported = false; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsTypeUniqueHash = ABSENT; + + constructor(props: { + name: string; + qualifiedName: string; + fileName: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + startColumn: number; + typeCategory: JsTypeCategory; + declarationForm: JsTypeDeclarationForm; + isAbstract: boolean; + modifiers: string; + evidenceKind: JsEvidenceKind; + isTypeOnly: boolean; + ownerModuleLinkHash: string; + ownerScopeLinkHash: string; + enclosingMethodLinkHash: string; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.qualifiedName = props.qualifiedName; + this.fileName = props.fileName; + this.filePath = props.filePath; + this.baseMservPath = props.baseMservPath; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.startColumn = props.startColumn; + this.typeCategory = props.typeCategory; + this.declarationForm = props.declarationForm; + this.isAbstract = props.isAbstract; + this.modifiers = props.modifiers; + this.evidenceKind = props.evidenceKind; + this.isTypeOnly = props.isTypeOnly; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.ownerScopeLinkHash = props.ownerScopeLinkHash; + this.enclosingMethodLinkHash = props.enclosingMethodLinkHash; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsTypeUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_TYPE, + keyOf(this.ownerModuleLinkHash, this.qualifiedName, this.startLine, this.startColumn) + ); + } + + getHash(): string { + return this.jsTypeUniqueHash; + } + + setDeclaredMemberCount(value: number): void { + this.declaredMemberCount = value; + } + setHasPrototypeMembers(value = true): void { + this.hasPrototypeMembers = value; + } + setConstructorMethodLinkHash(hash: string): void { + this.constructorMethodLinkHash = hash; + } + setSourceExpressionLinkHash(hash: string): void { + this.sourceExpressionLinkHash = hash; + } + setJsdocCommentLinkHash(hash: string): void { + this.jsdocCommentLinkHash = hash; + } + setIsExported(value = true): void { + this.isExported = value; + } + + getEntryCombined(): string { + return `js_type[hash=${this.jsTypeUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.qualifiedName), + text(this.fileName), + text(this.filePath), + text(this.baseMservPath), + num(this.startLine), + num(this.endLine), + num(this.startColumn), + this.typeCategory, + this.declarationForm, + bool(this.isAbstract), + text(this.modifiers), + this.evidenceKind, + bool(this.isTypeOnly), + num(this.declaredMemberCount), + bool(this.hasPrototypeMembers), + this.constructorMethodLinkHash, + this.ownerModuleLinkHash, + this.ownerScopeLinkHash, + this.enclosingMethodLinkHash, + this.sourceExpressionLinkHash, + this.jsdocCommentLinkHash, + bool(this.isExported), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsTypeUniqueHash, + ], + JsTypeRegistry.ARITY, + 'js_type' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', + 'qualifiedName', + 'fileName', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'startColumn', + 'typeCategory', + 'declarationForm', + 'isAbstract', + 'modifiers', + 'evidenceKind', + 'isTypeOnly', + 'declaredMemberCount', + 'hasPrototypeMembers', + 'constructorMethodLinkHash', + 'ownerModuleLinkHash', + 'ownerScopeLinkHash', + 'enclosingMethodLinkHash', + 'sourceExpressionLinkHash', + 'jsdocCommentLinkHash', + 'isExported', + 'isExternal', + 'serviceVersionLinkHash', + 'jsTypeUniqueHash', + ], + JsTypeRegistry.ARITY, + 'js_type' + ); + } +} diff --git a/parser/src/analysis-types/javascript/JsVariableRegistry.ts b/parser/src/analysis-types/javascript/JsVariableRegistry.ts new file mode 100644 index 000000000..79d0aafa3 --- /dev/null +++ b/parser/src/analysis-types/javascript/JsVariableRegistry.ts @@ -0,0 +1,224 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './js-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JsBindingRegime, + JsInitializerKind, + JsVariableBindingForm, +} from '@/enums/javascript/variables'; +import { JsDeclaredTypeSource } from '@/enums/javascript/common'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Every binding that is not a parameter or a member — schema §3.7, 25 columns. + * + * ## Columns 3 and 4 are the whole hoisting model, and they are why this is not + * `ts_variable` renamed + * + * - **`declarationScopeLinkHash`** — where the name is **visible from**. + * - **`syntacticScopeLinkHash`** — where the declaration is **written**. + * + * For `let` and `const` they are equal. For the 3,705 measured `var` bindings + * they differ whenever the declaration sits inside a block, and **that + * difference is hoisting**. It is not recoverable from anything else in the fact + * base: an engine would have to re-implement JavaScript's scoping rules to + * derive one from the other. Emitting one column and calling it "scope" is §3's + * defect class — the parts present, the structure absent. + * + * Gate 7.3.3 asserts every `VAR_*` binding's declaration scope has + * `isFunctionScope = true`, so the model is checked rather than assumed and + * fails loudly if the binder ever regresses to one column. + * + * ## `initializerKind = REQUIRE_CALL` is the single largest resolution lever + * + * `const x = require('y'); x.foo()` is **34.4% of all oracle declines** — + * 15,759 sites, the biggest cause by a wide margin. The call is unresolvable to + * the checker because `x` has no declared type, and perfectly *reconstructable* + * by an engine: this column plus `importLinkHash` says the name is a module + * alias, and the import row carries `resolvedFilePath`. + */ +export class JsVariableRegistry implements EntityIdentifiable { + static readonly ARITY = 25; + + readonly name: string; + readonly qualifiedName: string; + readonly bindingRegime: JsBindingRegime; + /** Where the name is **visible from** — the function scope for a `var`, the block for a `let`. */ + readonly declarationScopeLinkHash: string; + /** Where the declaration is **written**. Differs from the above for every `var` in a block; that difference IS hoisting. */ + readonly syntacticScopeLinkHash: string; + readonly hasTemporalDeadZone: boolean; + readonly bindingForm: JsVariableBindingForm; + /** For names bound by one destructuring, the root that binds them together. */ + private patternRootVariableLinkHash = ABSENT; + readonly declaredTypeName: string; + readonly declaredTypeSource: JsDeclaredTypeSource; + private typeReferenceLinkHash = ABSENT; + readonly hasInitializer: boolean; + private initializerExpressionLinkHash = ABSENT; + readonly initializerKind: JsInitializerKind; + private importLinkHash = ABSENT; + private isReassigned = false; + readonly ownerMethodLinkHash: string; + readonly ownerModuleLinkHash: string; + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + private isExported = false; + + /** Parity slot, always `false` on parser output. */ + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private jsVariableUniqueHash = ABSENT; + + constructor(props: { + name: string; + qualifiedName: string; + bindingRegime: JsBindingRegime; + declarationScopeLinkHash: string; + syntacticScopeLinkHash: string; + hasTemporalDeadZone: boolean; + bindingForm: JsVariableBindingForm; + declaredTypeName: string; + declaredTypeSource: JsDeclaredTypeSource; + hasInitializer: boolean; + initializerKind: JsInitializerKind; + ownerMethodLinkHash: string; + ownerModuleLinkHash: string; + startLine: number; + startColumn: number; + endLine: number; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.qualifiedName = props.qualifiedName; + this.bindingRegime = props.bindingRegime; + this.declarationScopeLinkHash = props.declarationScopeLinkHash; + this.syntacticScopeLinkHash = props.syntacticScopeLinkHash; + this.hasTemporalDeadZone = props.hasTemporalDeadZone; + this.bindingForm = props.bindingForm; + this.declaredTypeName = props.declaredTypeName; + this.declaredTypeSource = props.declaredTypeSource; + this.hasInitializer = props.hasInitializer; + this.initializerKind = props.initializerKind; + this.ownerMethodLinkHash = props.ownerMethodLinkHash; + this.ownerModuleLinkHash = props.ownerModuleLinkHash; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.endLine = props.endLine; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + generateHash(): void { + this.jsVariableUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_VARIABLE, + keyOf(this.ownerModuleLinkHash, this.declarationScopeLinkHash, this.name, this.startLine, this.startColumn) + ); + } + + getHash(): string { + return this.jsVariableUniqueHash; + } + + setPatternRootVariableLinkHash(hash: string): void { + this.patternRootVariableLinkHash = hash; + } + /** The link as currently set, so a later reference does not displace the declaration's. */ + typeReferenceLinkHashValue(): string { + return this.typeReferenceLinkHash === ABSENT ? '' : this.typeReferenceLinkHash; + } + + setTypeReferenceLinkHash(hash: string): void { + this.typeReferenceLinkHash = hash; + } + setInitializerExpressionLinkHash(hash: string): void { + this.initializerExpressionLinkHash = hash; + } + setImportLinkHash(hash: string): void { + this.importLinkHash = hash; + } + /** Read by the IR-completeness measure, which asks whether the hop is present. */ + importLinkHashValue(): string { + return this.importLinkHash; + } + setIsReassigned(value = true): void { + this.isReassigned = value; + } + setIsExported(value = true): void { + this.isExported = value; + } + + getEntryCombined(): string { + return `js_variable[hash=${this.jsVariableUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.qualifiedName), + this.bindingRegime, + this.declarationScopeLinkHash, + this.syntacticScopeLinkHash, + bool(this.hasTemporalDeadZone), + this.bindingForm, + this.patternRootVariableLinkHash, + text(this.declaredTypeName), + this.declaredTypeSource, + this.typeReferenceLinkHash, + bool(this.hasInitializer), + this.initializerExpressionLinkHash, + this.initializerKind, + this.importLinkHash, + bool(this.isReassigned), + this.ownerMethodLinkHash, + this.ownerModuleLinkHash, + num(this.startLine), + num(this.startColumn), + num(this.endLine), + bool(this.isExported), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.jsVariableUniqueHash, + ], + JsVariableRegistry.ARITY, + 'js_variable' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', + 'qualifiedName', + 'bindingRegime', + 'declarationScopeLinkHash', + 'syntacticScopeLinkHash', + 'hasTemporalDeadZone', + 'bindingForm', + 'patternRootVariableLinkHash', + 'declaredTypeName', + 'declaredTypeSource', + 'typeReferenceLinkHash', + 'hasInitializer', + 'initializerExpressionLinkHash', + 'initializerKind', + 'importLinkHash', + 'isReassigned', + 'ownerMethodLinkHash', + 'ownerModuleLinkHash', + 'startLine', + 'startColumn', + 'endLine', + 'isExported', + 'isExternal', + 'serviceVersionLinkHash', + 'jsVariableUniqueHash', + ], + JsVariableRegistry.ARITY, + 'js_variable' + ); + } +} diff --git a/parser/src/analysis-types/javascript/index.ts b/parser/src/analysis-types/javascript/index.ts new file mode 100644 index 000000000..a269d24b3 --- /dev/null +++ b/parser/src/analysis-types/javascript/index.ts @@ -0,0 +1,17 @@ +export * from './JsBlockRegistry'; +export * from './JsCallSiteRegistry'; +export * from './JsCommentRegistry'; +export * from './JsExportRegistry'; +export * from './JsExpressionRegistry'; +export * from './JsFieldRegistry'; +export * from './JsImportRegistry'; +export * from './JsMethodParameterRegistry'; +export * from './JsMethodRegistry'; +export * from './JsModuleRegistry'; +export * from './JsParseGapRegistry'; +export * from './JsScopeRegistry'; +export * from './JsTypeHeritageRegistry'; +export * from './JsTypeReferenceRegistry'; +export * from './JsTypeRegistry'; +export * from './JsVariableRegistry'; +export * from './js-row'; diff --git a/parser/src/analysis-types/javascript/js-row.ts b/parser/src/analysis-types/javascript/js-row.ts new file mode 100644 index 000000000..983d34c12 --- /dev/null +++ b/parser/src/analysis-types/javascript/js-row.ts @@ -0,0 +1,106 @@ +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Shared row plumbing for the JavaScript fact relations. + * + * A deliberate near-copy of `ts-row.ts`, and the duplication is the point: the + * two front ends are separate (schema Q1), so a change to TypeScript's row + * discipline must not silently reshape JavaScript's output. What they share is + * the reasoning, restated here because it is what the file exists for. + * + * ## Why arity is asserted at runtime and not merely reviewed + * + * `decls_base_js.dl` carries positional `c0..cN` and nothing else, so a column + * dropped or transposed in a `toCsv()` produces a file that loads cleanly into + * Souffle and means something different. There is no type error, no parse + * error, and no failing join — every later column is shifted by one and the + * relation still has rows. That is the exact failure mode `gen_decls.py --check` + * prevents on the schema side; this is its counterpart on the emit side. + */ +export function joinRow( + columns: readonly string[], + expectedArity: number, + relation: string +): string { + if (columns.length !== expectedArity) { + throw new Error( + `${relation}: emitted ${columns.length} columns, schema declares ${expectedArity}. ` + + 'Column ORDER is the contract and a shifted column loads without error — ' + + 'fix the toCsv(), never the arity constant.' + ); + } + return columns.join('\t'); +} + +/** Header row, held to the same arity as the data rows for the same reason. */ +export function joinHeader( + names: readonly string[], + expectedArity: number, + relation: string +): string { + return joinRow(names, expectedArity, relation); +} + +/** Souffle has no nulls: `""` is the legal "absent" value everywhere in this schema. */ +export const ABSENT = ''; + +/** A boolean column. Written as the literal `true`/`false` the `.dl` compares against. */ +export function bool(value: boolean): string { + return value ? 'true' : 'false'; +} + +/** A numeric column. */ +export function num(value: number): string { + return String(value); +} + +/** + * An optional numeric column: `""` when there is no number, NOT `-1` and NOT `0`. + * + * `position` and `childIndex` are both legitimately `0`, so a sentinel would be + * indistinguishable from the first position. + */ +export function optionalNum(value: number | undefined): string { + return value === undefined ? ABSENT : String(value); +} + +/** Free text bound for TSV: newlines and tabs escaped, quotes doubled. */ +export function text(value: string): string { + return EntityUtils.escapeTsv(value); +} + +/** + * Free text with a length bound, applied BEFORE escaping. + * + * Truncating after escaping can cut an escape sequence in half, which produces + * a cell that is not merely short but malformed. + */ +export function boundedText(value: string, limit: number): string { + return EntityUtils.escapeTsv(value.length > limit ? value.slice(0, limit) : value); +} + +/** A comma-set column, sorted so the value is independent of source order. */ +export function commaSet(values: Iterable): string { + return Array.from(new Set(values)).sort().join(','); +} + +/** + * A comma-LIST column, order preserved. + * + * `js_comment.jsdocTagNames` is `param,param,returns` — the repetition and the + * order are both information, so this is not {@link commaSet}. + */ +export function commaList(values: Iterable): string { + return Array.from(values).join(','); +} + +/** + * The `||` join used by every primary key in this schema. + * + * Components go through {@link String} untouched: they are hashes, names, + * numbers and enum values, and escaping them would make two different keys + * collide in the escaping rather than in the content. + */ +export function keyOf(...components: (string | number)[]): string { + return components.map(String).join('||'); +} diff --git a/parser/src/analysis-types/properties/PropertyKey.ts b/parser/src/analysis-types/properties/PropertyKey.ts new file mode 100644 index 000000000..39ccf18fd --- /dev/null +++ b/parser/src/analysis-types/properties/PropertyKey.ts @@ -0,0 +1,267 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PropertyDelimiter } from '@/enums/properties/PropertyDelimiter'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a key in a .properties file. + * + * PropertyKey captures property key declarations, including: + * - Simple keys: `app.name=value` + * - Keys with different delimiters: `=`, `:`, whitespace, or none + * - Unicode-escaped keys: `\u0041pp.key=value` + * - Multi-line keys: `app.multi\\\n .line.key=value` + * - No-value keys: `some.flag` (value defaults to empty string) + * + * ## CSV Export Format + * + * Column order: + * 1. key, rawKey, delimiter, hasValue, isMultiLineKey, isMultiLineValue + * 2. filePath, baseMservPath, startLine, endLine, startCol, endCol + * 3. serviceVersionLinkHash + * 4. propertyKeyUniqueHash (LAST) + */ +export class PropertyKey implements EntityIdentifiable { + private key: string; + private rawKey: string; + private delimiter: PropertyDelimiter; + private hasValue: boolean; + private isMultiLineKey: boolean; + private isMultiLineValue: boolean; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private startCol: number; + private endCol: number; + private serviceVersionLinkHash: string; + private propertyKeyUniqueHash: string = ''; + + private constructor(builder: PropertyKeyBuilder) { + this.key = builder.key; + this.rawKey = builder.rawKey; + this.delimiter = builder.delimiter; + this.hasValue = builder.hasValue; + this.isMultiLineKey = builder.isMultiLineKey; + this.isMultiLineValue = builder.isMultiLineValue; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startCol = builder.startCol; + this.endCol = builder.endCol; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + key: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startCol: number, + endCol: number, + delimiter: PropertyDelimiter, + serviceVersionLinkHash: string + ): PropertyKeyBuilder { + return new PropertyKeyBuilder( + key, + filePath, + baseMservPath, + startLine, + endLine, + startCol, + endCol, + delimiter, + serviceVersionLinkHash + ); + } + + getKey(): string { + return this.key; + } + + getRawKey(): string { + return this.rawKey; + } + + getDelimiter(): PropertyDelimiter { + return this.delimiter; + } + + getHasValue(): boolean { + return this.hasValue; + } + + getIsMultiLineKey(): boolean { + return this.isMultiLineKey; + } + + getIsMultiLineValue(): boolean { + return this.isMultiLineValue; + } + + getFilePath(): string { + return this.filePath; + } + + getBaseMservPath(): string { + return this.baseMservPath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getStartCol(): number { + return this.startCol; + } + + getEndCol(): number { + return this.endCol; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPropertyKeyUniqueHash(): string { + return this.propertyKeyUniqueHash; + } + + getHash(): string { + return this.propertyKeyUniqueHash; + } + + generateHash(): void { + const content = + this.key + + '||' + + this.filePath + + '||' + + this.baseMservPath + + '||' + + this.startLine + + '||' + + this.serviceVersionLinkHash; + + this.propertyKeyUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PROPERTY_KEY, + content + ); + } + + getEntryCombined(): string { + return `property_key[key=${this.key}, delimiter=${this.delimiter}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.key), + EntityUtils.escapeTsv(this.rawKey), + this.delimiter, + this.hasValue.toString(), + this.isMultiLineKey.toString(), + this.isMultiLineValue.toString(), + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.startCol.toString(), + this.endCol.toString(), + this.serviceVersionLinkHash, + this.propertyKeyUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'key', + 'rawKey', + 'delimiter', + 'hasValue', + 'isMultiLineKey', + 'isMultiLineValue', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'startCol', + 'endCol', + 'serviceVersionLinkHash', + 'propertyKeyUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for PropertyKey + */ +class PropertyKeyBuilder { + key: string; + rawKey: string; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + startCol: number; + endCol: number; + delimiter: PropertyDelimiter; + hasValue: boolean = true; + isMultiLineKey: boolean = false; + isMultiLineValue: boolean = false; + serviceVersionLinkHash: string; + + constructor( + key: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startCol: number, + endCol: number, + delimiter: PropertyDelimiter, + serviceVersionLinkHash: string + ) { + this.key = key; + this.rawKey = key; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.startCol = startCol; + this.endCol = endCol; + this.delimiter = delimiter; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withRawKey(rawKey: string): PropertyKeyBuilder { + this.rawKey = rawKey; + return this; + } + + withHasValue(hasValue: boolean): PropertyKeyBuilder { + this.hasValue = hasValue; + return this; + } + + withIsMultiLineKey(isMultiLineKey: boolean): PropertyKeyBuilder { + this.isMultiLineKey = isMultiLineKey; + return this; + } + + withIsMultiLineValue(isMultiLineValue: boolean): PropertyKeyBuilder { + this.isMultiLineValue = isMultiLineValue; + return this; + } + + build(): PropertyKey { + return new (PropertyKey as any)(this); + } +} diff --git a/parser/src/analysis-types/properties/PropertyValueSegment.ts b/parser/src/analysis-types/properties/PropertyValueSegment.ts new file mode 100644 index 000000000..477556877 --- /dev/null +++ b/parser/src/analysis-types/properties/PropertyValueSegment.ts @@ -0,0 +1,257 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PropertyValueSegmentType } from '@/enums/properties/PropertyValueSegmentType'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a single segment within a property value expression. + * + * Property values are decomposed into segments to capture the structure of + * references, defaults, and literal text. Each `${...}` reference, SpEL + * expression, or literal chunk gets its own row. + * + * ## Nesting via depth + parentSegmentLinkHash + * + * For `${PRIMARY:${SECONDARY:${TERTIARY:fallback}}}`: + * - depth=0: PRIMARY (ENV_VARIABLE) + * - depth=1: SECONDARY (ENV_VARIABLE), parent → PRIMARY segment + * - depth=2: TERTIARY (ENV_WITH_DEFAULT, defaultValue="fallback"), parent → SECONDARY segment + * + * ## Mixed Nested Example + * + * For `jdbc:postgresql://${DB_HOST:${FALLBACK:127.0.0.1}}:${DB_PORT:5432}`: + * - pos=0, depth=0: LITERAL "jdbc:postgresql://" + * - pos=1, depth=0: ENV_VARIABLE "DB_HOST" + * - pos=0, depth=1: ENV_WITH_DEFAULT "FALLBACK", default="127.0.0.1", parent → DB_HOST + * - pos=2, depth=0: LITERAL ":" + * - pos=3, depth=0: ENV_WITH_DEFAULT "DB_PORT", default="5432" + * + * ## CSV Export Format + * + * Column order: + * 1. segmentValue, segmentType, defaultValue, position, depth + * 2. parentSegmentLinkHash, propertyKeyLinkHash + * 3. startLine, endLine, startCol, endCol + * 4. propertyValueSegmentUniqueHash (LAST) + */ +export class PropertyValueSegment implements EntityIdentifiable { + private segmentValue: string; + private segmentType: PropertyValueSegmentType; + private defaultValue: string; + private position: number; + private depth: number; + private parentSegmentLinkHash: string; + private propertyKeyLinkHash: string; + private startLine: number; + private endLine: number; + private startCol: number; + private endCol: number; + private propertyValueSegmentUniqueHash: string = ''; + + private constructor(builder: PropertyValueSegmentBuilder) { + this.segmentValue = builder.segmentValue; + this.segmentType = builder.segmentType; + this.defaultValue = builder.defaultValue; + this.position = builder.position; + this.depth = builder.depth; + this.parentSegmentLinkHash = builder.parentSegmentLinkHash; + this.propertyKeyLinkHash = builder.propertyKeyLinkHash; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startCol = builder.startCol; + this.endCol = builder.endCol; + + this.generateHash(); + } + + static builder( + segmentValue: string, + segmentType: PropertyValueSegmentType, + position: number, + depth: number, + propertyKeyLinkHash: string, + startLine: number, + endLine: number, + startCol: number, + endCol: number + ): PropertyValueSegmentBuilder { + return new PropertyValueSegmentBuilder( + segmentValue, + segmentType, + position, + depth, + propertyKeyLinkHash, + startLine, + endLine, + startCol, + endCol + ); + } + + getSegmentValue(): string { + return this.segmentValue; + } + + getSegmentType(): PropertyValueSegmentType { + return this.segmentType; + } + + getDefaultValue(): string { + return this.defaultValue; + } + + getPosition(): number { + return this.position; + } + + getDepth(): number { + return this.depth; + } + + getParentSegmentLinkHash(): string { + return this.parentSegmentLinkHash; + } + + getPropertyKeyLinkHash(): string { + return this.propertyKeyLinkHash; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getStartCol(): number { + return this.startCol; + } + + getEndCol(): number { + return this.endCol; + } + + getPropertyValueSegmentUniqueHash(): string { + return this.propertyValueSegmentUniqueHash; + } + + getHash(): string { + return this.propertyValueSegmentUniqueHash; + } + + generateHash(): void { + const content = + this.propertyKeyLinkHash + + '||' + + this.segmentValue + + '||' + + this.segmentType + + '||' + + this.position + + '||' + + this.depth + + '||' + + this.parentSegmentLinkHash + + '||' + + this.startLine + + '||' + + this.startCol; + + this.propertyValueSegmentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PROPERTY_VALUE_SEGMENT, + content + ); + } + + getEntryCombined(): string { + return `property_value_segment[value=${this.segmentValue}, type=${this.segmentType}, pos=${this.position}, depth=${this.depth}, line=${this.startLine}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.segmentValue), + this.segmentType, + EntityUtils.escapeTsv(this.defaultValue), + this.position.toString(), + this.depth.toString(), + this.parentSegmentLinkHash, + this.propertyKeyLinkHash, + this.startLine.toString(), + this.endLine.toString(), + this.startCol.toString(), + this.endCol.toString(), + this.propertyValueSegmentUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'segmentValue', + 'segmentType', + 'defaultValue', + 'position', + 'depth', + 'parentSegmentLinkHash', + 'propertyKeyLinkHash', + 'startLine', + 'endLine', + 'startCol', + 'endCol', + 'propertyValueSegmentUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for PropertyValueSegment + */ +class PropertyValueSegmentBuilder { + segmentValue: string; + segmentType: PropertyValueSegmentType; + defaultValue: string = ''; + position: number; + depth: number; + parentSegmentLinkHash: string = ''; + propertyKeyLinkHash: string; + startLine: number; + endLine: number; + startCol: number; + endCol: number; + + constructor( + segmentValue: string, + segmentType: PropertyValueSegmentType, + position: number, + depth: number, + propertyKeyLinkHash: string, + startLine: number, + endLine: number, + startCol: number, + endCol: number + ) { + this.segmentValue = segmentValue; + this.segmentType = segmentType; + this.position = position; + this.depth = depth; + this.propertyKeyLinkHash = propertyKeyLinkHash; + this.startLine = startLine; + this.endLine = endLine; + this.startCol = startCol; + this.endCol = endCol; + } + + withDefaultValue(defaultValue: string): PropertyValueSegmentBuilder { + this.defaultValue = defaultValue; + return this; + } + + withParentSegmentLinkHash(hash: string): PropertyValueSegmentBuilder { + this.parentSegmentLinkHash = hash; + return this; + } + + build(): PropertyValueSegment { + return new (PropertyValueSegment as any)(this); + } +} diff --git a/parser/src/analysis-types/python/PyBindingRegistry.ts b/parser/src/analysis-types/python/PyBindingRegistry.ts new file mode 100644 index 000000000..f4c3894ea --- /dev/null +++ b/parser/src/analysis-types/python/PyBindingRegistry.ts @@ -0,0 +1,466 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonBindingKind, + PythonBindingOrigin, + PythonBindingTargetKind, +} from '@/enums/python/bindings'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents one **(scope, name)** pair — exactly `symtable.Symbol`. + * + * This is Python's `local_variable` table *and* its global/nonlocal/free/import/ + * parameter table, unified into one relation. It is the highest-value mechanism + * in the whole schema: 50.7% of attribute calls have a **bare name** as the + * receiver, and those become resolvable only through this table. + * + * ## Examples + * + * ```python + * counter = 0 # module scope: LOCAL and GLOBAL simultaneously — + * # CPython reports both for module-level bindings + * def make_counter(): + * count = 0 # CELL — bound here, captured below + * def bump(): + * nonlocal count # FREE — resolves to make_counter's binding + * count += 1 # is_assigned, and NOT is_referenced + * return bump + * + * [y for y in xs if (last := y)] + * # listcomp scope: last -> assigned, free, nonlocal + * # enclosing scope: last -> assigned, local, referenced + * ``` + * + * ## Columns 4–14 are the complete `symtable.Symbol` predicate set + * + * All eleven predicates, in symtable's own declaration order: `is_parameter`, + * `is_local`, `is_global`, `is_nonlocal`, `is_free`, `is_imported`, + * `is_assigned`, `is_referenced`, `is_declared_global`, `is_annotated`, + * `is_namespace`. There are no others — `is_cell` is not public — and all eleven + * exist on every supported target, so the harness compares the full set + * unconditionally with no feature detection. Column-for-column comparison + * against CPython is the harness's core assertion, which is why the order here + * is not negotiable. + * + * ## Column order (frozen — schema v6 §2.3, 29 columns) + * + * **PK** `PY_BINDING_md5(pyScopeLinkHash ‖ name)` — one row per symbol per + * scope, by construction. Collisions are impossible, which is what makes the + * PK-collision check a real assertion rather than a formality. + */ +export class PyBindingRegistry implements EntityIdentifiable { + private name: string; + private pyScopeLinkHash: string; + private bindingKind: PythonBindingKind; + private bindingOrigin: PythonBindingOrigin; + private isParameter: boolean; + private isLocal: boolean; + private isGlobal: boolean; + private isNonlocal: boolean; + private isFree: boolean; + private isImported: boolean; + private isAssigned: boolean; + private isReferenced: boolean; + private isDeclaredGlobal: boolean; + private isAnnotated: boolean; + private isNamespace: boolean; + private bindingCount: number; + private firstBindingLine: number; + private lastBindingLine: number; + private declaredTypeName: string; + private declaredBaseType: string; + private potentialQualifiedName: string; + private isAmbiguous: boolean; + private targetEntityKind: PythonBindingTargetKind; + private targetEntityHash: string; + private pyModuleLinkHash: string; + private pyMethodLinkHash: string; + private filePath: string; + private serviceVersionLinkHash: string; + private pyBindingUniqueHash: string = ''; + + private constructor(builder: PyBindingRegistryBuilder) { + this.name = builder.name; + this.pyScopeLinkHash = builder.pyScopeLinkHash; + this.bindingKind = builder.bindingKind; + this.bindingOrigin = builder.bindingOrigin; + this.isParameter = builder.isParameter; + this.isLocal = builder.isLocal; + this.isGlobal = builder.isGlobal; + this.isNonlocal = builder.isNonlocal; + this.isFree = builder.isFree; + this.isImported = builder.isImported; + this.isAssigned = builder.isAssigned; + this.isReferenced = builder.isReferenced; + this.isDeclaredGlobal = builder.isDeclaredGlobal; + this.isAnnotated = builder.isAnnotated; + this.isNamespace = builder.isNamespace; + this.bindingCount = builder.bindingCount; + this.firstBindingLine = builder.firstBindingLine; + this.lastBindingLine = builder.lastBindingLine; + this.declaredTypeName = builder.declaredTypeName; + this.declaredBaseType = builder.declaredBaseType; + this.potentialQualifiedName = builder.potentialQualifiedName; + this.isAmbiguous = builder.isAmbiguous; + this.targetEntityKind = builder.targetEntityKind; + this.targetEntityHash = builder.targetEntityHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.pyMethodLinkHash = builder.pyMethodLinkHash; + this.filePath = builder.filePath; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + name: string, + pyScopeLinkHash: string, + pyModuleLinkHash: string, + filePath: string, + serviceVersionLinkHash: string + ): PyBindingRegistryBuilder { + return new PyBindingRegistryBuilder( + name, + pyScopeLinkHash, + pyModuleLinkHash, + filePath, + serviceVersionLinkHash + ); + } + + getName(): string { + return this.name; + } + + getPyScopeLinkHash(): string { + return this.pyScopeLinkHash; + } + + getBindingKind(): PythonBindingKind { + return this.bindingKind; + } + + getBindingOrigin(): PythonBindingOrigin { + return this.bindingOrigin; + } + + getIsParameter(): boolean { + return this.isParameter; + } + + getIsLocal(): boolean { + return this.isLocal; + } + + getIsGlobal(): boolean { + return this.isGlobal; + } + + getIsNonlocal(): boolean { + return this.isNonlocal; + } + + getIsFree(): boolean { + return this.isFree; + } + + getIsNamespace(): boolean { + return this.isNamespace; + } + + getIsAssigned(): boolean { + return this.isAssigned; + } + + getIsImported(): boolean { + return this.isImported; + } + + /** + * Whether this row represents a name genuinely BOUND in its scope, as opposed + * to merely referenced there. + * + * symtable emits a Symbol for any name a scope mentions, including one it only + * reads — a `GLOBAL_IMPLICIT` reference. Treating such a row as a binding makes + * a scope look like it shadows an outer definition when it does not, which + * silently halts any outward name lookup at the first mention. + */ + isBound(): boolean { + return this.isAssigned || this.isImported || this.isParameter; + } + + getDeclaredTypeName(): string { + return this.declaredTypeName; + } + + /** Records the annotation's base type and the parser's resolution of it. */ + setResolvedAnnotation( + declaredBaseType: string, + potentialQualifiedName: string, + isAmbiguous: boolean + ): void { + this.declaredBaseType = declaredBaseType; + this.potentialQualifiedName = potentialQualifiedName; + this.isAmbiguous = isAmbiguous; + } + + /** + * Sets the enclosing-method FK. + * + * This is the `java_local_variable` column-13 analogue, which is what lets + * `local-flow.dl` port directly. Empty at module and class scope. + */ + setPyMethodLinkHash(pyMethodLinkHash: string): void { + this.pyMethodLinkHash = pyMethodLinkHash; + } + + /** Sets the polymorphic target FK, back-patched once declarations exist. */ + /** `Symbol.is_free()` — the name is bound in an enclosing function scope. */ + isFreeVariable(): boolean { + return this.isFree; + } + + getTargetEntityKind(): PythonBindingTargetKind { + return this.targetEntityKind; + } + + getTargetEntityHash(): string { + return this.targetEntityHash; + } + + setTargetEntity(targetEntityKind: PythonBindingTargetKind, targetEntityHash: string): void { + this.targetEntityKind = targetEntityKind; + this.targetEntityHash = targetEntityHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyBindingUniqueHash(): string { + return this.pyBindingUniqueHash; + } + + getHash(): string { + return this.pyBindingUniqueHash; + } + + generateHash(): void { + const content = this.pyScopeLinkHash + '||' + this.name; + + this.pyBindingUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_BINDING, + content + ); + } + + getEntryCombined(): string { + return `py_binding[name=${this.name}, kind=${this.bindingKind}, origin=${this.bindingOrigin}, scope=${this.pyScopeLinkHash}, hash=${this.pyBindingUniqueHash}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.name), + this.pyScopeLinkHash, + this.bindingKind, + this.bindingOrigin, + this.isParameter.toString(), + this.isLocal.toString(), + this.isGlobal.toString(), + this.isNonlocal.toString(), + this.isFree.toString(), + this.isImported.toString(), + this.isAssigned.toString(), + this.isReferenced.toString(), + this.isDeclaredGlobal.toString(), + this.isAnnotated.toString(), + this.isNamespace.toString(), + this.bindingCount.toString(), + this.firstBindingLine.toString(), + this.lastBindingLine.toString(), + EntityUtils.escapeTsv(this.declaredTypeName), + EntityUtils.escapeTsv(this.declaredBaseType), + EntityUtils.escapeTsv(this.potentialQualifiedName), + this.isAmbiguous.toString(), + this.targetEntityKind, + this.targetEntityHash, + this.pyModuleLinkHash, + this.pyMethodLinkHash, + EntityUtils.escapeTsv(this.filePath), + this.serviceVersionLinkHash, + this.pyBindingUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'pyScopeLinkHash', + 'bindingKind', + 'bindingOrigin', + 'isParameter', + 'isLocal', + 'isGlobal', + 'isNonlocal', + 'isFree', + 'isImported', + 'isAssigned', + 'isReferenced', + 'isDeclaredGlobal', + 'isAnnotated', + 'isNamespace', + 'bindingCount', + 'firstBindingLine', + 'lastBindingLine', + 'declaredTypeName', + 'declaredBaseType', + 'potentialQualifiedName', + 'isAmbiguous', + 'targetEntityKind', + 'targetEntityHash', + 'pyModuleLinkHash', + 'pyMethodLinkHash', + 'filePath', + 'serviceVersionLinkHash', + 'pyBindingUniqueHash', + ].join('\t'); + } +} + +/** Builder for PyBindingRegistry. */ +export class PyBindingRegistryBuilder { + name: string; + pyScopeLinkHash: string; + bindingKind: PythonBindingKind = PythonBindingKind.UNKNOWN; + bindingOrigin: PythonBindingOrigin = PythonBindingOrigin.ASSIGNMENT; + isParameter: boolean = false; + isLocal: boolean = false; + isGlobal: boolean = false; + isNonlocal: boolean = false; + isFree: boolean = false; + isImported: boolean = false; + isAssigned: boolean = false; + isReferenced: boolean = false; + isDeclaredGlobal: boolean = false; + isAnnotated: boolean = false; + isNamespace: boolean = false; + bindingCount: number = 0; + firstBindingLine: number = 0; + lastBindingLine: number = 0; + declaredTypeName: string = ''; + declaredBaseType: string = ''; + potentialQualifiedName: string = ''; + isAmbiguous: boolean = false; + targetEntityKind: PythonBindingTargetKind = PythonBindingTargetKind.NONE; + targetEntityHash: string = ''; + pyModuleLinkHash: string; + pyMethodLinkHash: string = ''; + filePath: string; + serviceVersionLinkHash: string; + + constructor( + name: string, + pyScopeLinkHash: string, + pyModuleLinkHash: string, + filePath: string, + serviceVersionLinkHash: string + ) { + if (!name || name.length === 0) { + throw new Error('name is required'); + } + if (!pyScopeLinkHash || pyScopeLinkHash.trim().length === 0) { + throw new Error('pyScopeLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + + this.name = name; + this.pyScopeLinkHash = pyScopeLinkHash; + this.pyModuleLinkHash = pyModuleLinkHash; + this.filePath = filePath; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withKindAndOrigin(bindingKind: PythonBindingKind, bindingOrigin: PythonBindingOrigin): this { + this.bindingKind = bindingKind; + this.bindingOrigin = bindingOrigin; + return this; + } + + /** + * Sets all eleven `symtable.Symbol` predicates at once, in symtable's own + * order. + * + * One setter rather than eleven, deliberately: the predicates are a *set* that + * CPython computes together, and setting them individually invites a caller to + * set ten of them and leave the eleventh silently false. + */ + withSymbolPredicates(predicates: { + isParameter: boolean; + isLocal: boolean; + isGlobal: boolean; + isNonlocal: boolean; + isFree: boolean; + isImported: boolean; + isAssigned: boolean; + isReferenced: boolean; + isDeclaredGlobal: boolean; + isAnnotated: boolean; + isNamespace: boolean; + }): this { + this.isParameter = predicates.isParameter; + this.isLocal = predicates.isLocal; + this.isGlobal = predicates.isGlobal; + this.isNonlocal = predicates.isNonlocal; + this.isFree = predicates.isFree; + this.isImported = predicates.isImported; + this.isAssigned = predicates.isAssigned; + this.isReferenced = predicates.isReferenced; + this.isDeclaredGlobal = predicates.isDeclaredGlobal; + this.isAnnotated = predicates.isAnnotated; + this.isNamespace = predicates.isNamespace; + return this; + } + + withBindingSites(bindingCount: number, firstBindingLine: number, lastBindingLine: number): this { + this.bindingCount = bindingCount; + this.firstBindingLine = firstBindingLine; + this.lastBindingLine = lastBindingLine; + return this; + } + + withDeclaredType( + declaredTypeName: string, + declaredBaseType: string, + potentialQualifiedName: string, + isAmbiguous: boolean + ): this { + this.declaredTypeName = declaredTypeName; + this.declaredBaseType = declaredBaseType; + this.potentialQualifiedName = potentialQualifiedName; + this.isAmbiguous = isAmbiguous; + return this; + } + + withTargetEntity( + targetEntityKind: PythonBindingTargetKind, + targetEntityHash: string + ): this { + this.targetEntityKind = targetEntityKind; + this.targetEntityHash = targetEntityHash; + return this; + } + + withPyMethodLinkHash(pyMethodLinkHash: string): this { + this.pyMethodLinkHash = pyMethodLinkHash; + return this; + } + + build(): PyBindingRegistry { + return new (PyBindingRegistry as unknown as { + new (builder: PyBindingRegistryBuilder): PyBindingRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyBlockRegistry.ts b/parser/src/analysis-types/python/PyBlockRegistry.ts new file mode 100644 index 000000000..e6e4f2ffe --- /dev/null +++ b/parser/src/analysis-types/python/PyBlockRegistry.ts @@ -0,0 +1,388 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PythonBlockKind } from '@/enums/python/blocks'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One block of statements — an `if` body, a `for` body, an `except` handler. + * + * Positions 0–16 mirror `java_block`, so a control-flow rule ports by relation + * rename. Containment is by SPAN rather than by a link on each expression, which + * is also how Java does it: an expression is inside a block when its span is, + * and putting a block FK on every expression row would cost a column on the + * largest relation to encode what the spans already say. + * + * The column that earns this relation its place is `conditionExpressionLinkHash`. + * A narrowing guard is the single most valuable control-flow fact for + * resolution: + * + * ```python + * if isinstance(x, Foo): + * x.method() # x is a Foo HERE, and only here + * ``` + * + * There are 2,123 `isinstance` sites in the measured corpus, each capable of + * narrowing a receiver from a wide candidate fan to one. The FK points at the + * already-parsed test, so an engine reads its operands from the expression tree + * instead of re-parsing `conditionText`. + * + * `isTypeCheckingGuard` is the other one worth naming: 338 `if TYPE_CHECKING:` + * blocks contain imports that exist for a type checker and never execute, so a + * rule that treats them as runtime imports is wrong about every one. + * + * ## Column order (frozen — schema v7 §2.18, 27 columns) + * + * **PK** `PY_BLOCK_md5(filePath ‖ pyTypeLinkHash ‖ methodOwnerHash ‖ kind ‖ startLine ‖ startColumn ‖ endLine ‖ endColumn)` + */ +export class PyBlockRegistry implements EntityIdentifiable { + private kind: PythonBlockKind; + private order: number; + private filePath: string; + private startLine: number; + private endLine: number; + private startColumn: number; + private endColumn: number; + private nestingDepth: number; + private pyTypeLinkHash: string; + private methodOwnerHash: string; + private parentContainerHash: string; + private tryStatementHash: string; + private resourceCount: string; + private caughtExceptionTypes: string; + private ownerTypeName: string; + private ownerQualifiedName: string; + private ownerMethodName: string; + private pyScopeLinkHash: string; + private pyModuleLinkHash: string; + private conditionExpressionLinkHash: string; + private conditionText: string; + private exceptTargetName: string; + private hasElseClause: boolean; + private isModuleLevel: boolean; + private isTypeCheckingGuard: boolean; + private serviceVersionLinkHash: string; + private pyBlockUniqueHash: string = ''; + + private constructor(builder: PyBlockRegistryBuilder) { + this.kind = builder.kind; + this.order = builder.order; + this.filePath = builder.filePath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startColumn = builder.startColumn; + this.endColumn = builder.endColumn; + this.nestingDepth = builder.nestingDepth; + this.pyTypeLinkHash = builder.pyTypeLinkHash; + this.methodOwnerHash = builder.methodOwnerHash; + this.parentContainerHash = builder.parentContainerHash; + this.tryStatementHash = builder.tryStatementHash; + this.resourceCount = builder.resourceCount; + this.caughtExceptionTypes = builder.caughtExceptionTypes; + this.ownerTypeName = builder.ownerTypeName; + this.ownerQualifiedName = builder.ownerQualifiedName; + this.ownerMethodName = builder.ownerMethodName; + this.pyScopeLinkHash = builder.pyScopeLinkHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.conditionExpressionLinkHash = builder.conditionExpressionLinkHash; + this.conditionText = builder.conditionText; + this.exceptTargetName = builder.exceptTargetName; + this.hasElseClause = builder.hasElseClause; + this.isModuleLevel = builder.isModuleLevel; + this.isTypeCheckingGuard = builder.isTypeCheckingGuard; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + kind: PythonBlockKind, + filePath: string, + startLine: number, + startColumn: number, + endLine: number, + endColumn: number, + methodOwnerHash: string, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ): PyBlockRegistryBuilder { + return new PyBlockRegistryBuilder( + kind, + filePath, + startLine, + startColumn, + endLine, + endColumn, + methodOwnerHash, + pyModuleLinkHash, + serviceVersionLinkHash + ); + } + + getKind(): PythonBlockKind { + return this.kind; + } + + getNestingDepth(): number { + return this.nestingDepth; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + /** UTF-8 byte column, as everywhere else in the schema. */ + getStartColumn(): number { + return this.startColumn; + } + + getEndColumn(): number { + return this.endColumn; + } + + getMethodOwnerHash(): string { + return this.methodOwnerHash; + } + + getParentContainerHash(): string { + return this.parentContainerHash; + } + + getTryStatementHash(): string { + return this.tryStatementHash; + } + + getConditionText(): string { + return this.conditionText; + } + + getIsTypeCheckingGuard(): boolean { + return this.isTypeCheckingGuard; + } + + getCaughtExceptionTypes(): string { + return this.caughtExceptionTypes; + } + + getConditionExpressionLinkHash(): string { + return this.conditionExpressionLinkHash; + } + + /** FK→`py_expression` — the `if`/`while` test, set once expressions exist. */ + setConditionExpressionLinkHash(conditionExpressionLinkHash: string): void { + this.conditionExpressionLinkHash = conditionExpressionLinkHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getHash(): string { + return this.pyBlockUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.pyTypeLinkHash + + '||' + + this.methodOwnerHash + + '||' + + this.kind + + '||' + + this.startLine + + '||' + + this.startColumn + + '||' + + this.endLine + + '||' + + this.endColumn; + + this.pyBlockUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_BLOCK, + content + ); + } + + getEntryCombined(): string { + return `py_block[kind=${this.kind}, depth=${this.nestingDepth}, lines=${this.startLine}-${this.endLine}, hash=${this.pyBlockUniqueHash}]`; + } + + toCsv(): string { + return [ + this.kind, + this.order, + EntityUtils.escapeTsv(this.filePath), + this.startLine, + this.endLine, + this.startColumn, + this.endColumn, + this.nestingDepth, + this.pyTypeLinkHash, + this.methodOwnerHash, + this.parentContainerHash, + this.tryStatementHash, + this.resourceCount, + this.caughtExceptionTypes, + EntityUtils.escapeTsv(this.ownerTypeName), + EntityUtils.escapeTsv(this.ownerQualifiedName), + EntityUtils.escapeTsv(this.ownerMethodName), + this.pyScopeLinkHash, + this.pyModuleLinkHash, + this.conditionExpressionLinkHash, + EntityUtils.escapeTsv(this.conditionText), + EntityUtils.escapeTsv(this.exceptTargetName), + this.hasElseClause, + this.isModuleLevel, + this.isTypeCheckingGuard, + this.serviceVersionLinkHash, + this.pyBlockUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'kind', + 'order', + 'filePath', + 'startLine', + 'endLine', + 'startColumn', + 'endColumn', + 'nestingDepth', + 'pyTypeLinkHash', + 'methodOwnerHash', + 'parentContainerHash', + 'tryStatementHash', + 'resourceCount', + 'caughtExceptionTypes', + 'ownerTypeName', + 'ownerQualifiedName', + 'ownerMethodName', + 'pyScopeLinkHash', + 'pyModuleLinkHash', + 'conditionExpressionLinkHash', + 'conditionText', + 'exceptTargetName', + 'hasElseClause', + 'isModuleLevel', + 'isTypeCheckingGuard', + 'serviceVersionLinkHash', + 'pyBlockUniqueHash', + ].join('\t'); + } +} + +export class PyBlockRegistryBuilder { + kind: PythonBlockKind; + order: number = 0; + filePath: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + nestingDepth: number = 0; + pyTypeLinkHash: string = ''; + methodOwnerHash: string; + parentContainerHash: string = ''; + tryStatementHash: string = ''; + resourceCount: string = ''; + caughtExceptionTypes: string = ''; + ownerTypeName: string = ''; + ownerQualifiedName: string = ''; + ownerMethodName: string = ''; + pyScopeLinkHash: string = ''; + pyModuleLinkHash: string; + conditionExpressionLinkHash: string = ''; + conditionText: string = ''; + exceptTargetName: string = ''; + hasElseClause: boolean = false; + isModuleLevel: boolean = false; + isTypeCheckingGuard: boolean = false; + serviceVersionLinkHash: string; + + constructor( + kind: PythonBlockKind, + filePath: string, + startLine: number, + startColumn: number, + endLine: number, + endColumn: number, + methodOwnerHash: string, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ) { + this.kind = kind; + this.filePath = filePath; + this.startLine = startLine; + this.startColumn = startColumn; + this.endLine = endLine; + this.endColumn = endColumn; + this.methodOwnerHash = methodOwnerHash; + this.pyModuleLinkHash = pyModuleLinkHash; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withOrder(order: number, nestingDepth: number): this { + this.order = order; + this.nestingDepth = nestingDepth; + return this; + } + + withOwner( + pyTypeLinkHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string + ): this { + this.pyTypeLinkHash = pyTypeLinkHash; + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + this.ownerMethodName = ownerMethodName; + return this; + } + + withContainer(parentContainerHash: string, pyScopeLinkHash: string): this { + this.parentContainerHash = parentContainerHash; + this.pyScopeLinkHash = pyScopeLinkHash; + return this; + } + + withTryStatement(tryStatementHash: string): this { + this.tryStatementHash = tryStatementHash; + return this; + } + + withCondition(conditionText: string, isTypeCheckingGuard: boolean): this { + this.conditionText = conditionText; + this.isTypeCheckingGuard = isTypeCheckingGuard; + return this; + } + + withHandler(caughtExceptionTypes: string, exceptTargetName: string): this { + this.caughtExceptionTypes = caughtExceptionTypes; + this.exceptTargetName = exceptTargetName; + return this; + } + + withResourceCount(resourceCount: number): this { + this.resourceCount = String(resourceCount); + return this; + } + + withFlags(hasElseClause: boolean, isModuleLevel: boolean): this { + this.hasElseClause = hasElseClause; + this.isModuleLevel = isModuleLevel; + return this; + } + + build(): PyBlockRegistry { + return new (PyBlockRegistry as unknown as { + new (builder: PyBlockRegistryBuilder): PyBlockRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyCallSiteRegistry.ts b/parser/src/analysis-types/python/PyCallSiteRegistry.ts new file mode 100644 index 000000000..b5ff7cf4f --- /dev/null +++ b/parser/src/analysis-types/python/PyCallSiteRegistry.ts @@ -0,0 +1,419 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonCallKind, + PythonReceiverKind, + PythonResolvedCalleeKind, +} from '@/enums/python/call-sites'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents one call site, 1:1 with its `CALL` expression. + * + * In Java this relation is *derived* in `call-site.dl`. For Python it is a + * **base** relation, because the call shape is not recoverable from one + * positional pattern: keyword arguments, `*`/`**` spreading, chained receivers, + * `super()`, and — the point — the receiver's syntactic shape, which is all we + * honestly know about a duck-typed receiver. + * + * ## Imprecision is recorded, not hidden + * + * `argFlowIsPrecise` is false whenever `*args` or `**kwargs` appear at the call + * site, because positional argument→parameter flow is **provably** unsound + * there. Making that a fact lets the engine report "unresolvable by + * construction" instead of silently emitting a wrong edge — which is the whole + * discipline this schema is organised around, given that naive name-based + * dispatch yields 24.63 candidate classes per attribute call. + * + * ## Examples + * + * ```python + * helper(1) # SIMPLE_CALL, receiverKind=NONE + * self.save(x) # SELF_CALL, receiverKind=SELF + * super().save(x) # SUPER_CALL, receiverKind=SUPER + * self.repo.get(k) # METHOD_CALL, receiverKind=ATTRIBUTE + * factory().build() # CHAINED_CALL, receiverKind=CALL_RESULT + * f(*args, **kwargs) # argFlowIsPrecise=false + * ``` + * + * `pyMethodLinkHash` is **never empty**: a module-level call is owned by the + * synthetic `` initializer, which is what keeps `expr_ultimate_method` + * total. + * + * ## Column order (frozen — schema v6 §2.16, 26 columns) + * + * **PK** `PY_CALL_SITE_md5(pyExpressionLinkHash)` — a pure chain off the parent + * expression, since the relationship is 1:1. + */ +export class PyCallSiteRegistry implements EntityIdentifiable { + private callKind: PythonCallKind; + private calleeName: string; + private calleeDottedPath: string; + private receiverText: string; + private receiverKind: PythonReceiverKind; + private pyExpressionLinkHash: string; + private receiverExpressionLinkHash: string; + private pyScopeLinkHash: string; + private pyMethodLinkHash: string; + private pyTypeLinkHash: string; + private pyModuleLinkHash: string; + private positionalArgCount: number; + private keywordArgCount: number; + private hasStarArgs: boolean; + private hasDoubleStarArgs: boolean; + private keywordNames: string[]; + private argFlowIsPrecise: boolean; + private resolvedCalleeKind: PythonResolvedCalleeKind; + private resolvedCalleeHash: string; + private isModuleLevelCall: boolean; + private isConditional: boolean; + private startLine: number; + private startColumn: number; + private endLine: number; + private serviceVersionLinkHash: string; + private pyCallSiteUniqueHash: string = ''; + + private constructor(builder: PyCallSiteRegistryBuilder) { + this.callKind = builder.callKind; + this.calleeName = builder.calleeName; + this.calleeDottedPath = builder.calleeDottedPath; + this.receiverText = builder.receiverText; + this.receiverKind = builder.receiverKind; + this.pyExpressionLinkHash = builder.pyExpressionLinkHash; + this.receiverExpressionLinkHash = builder.receiverExpressionLinkHash; + this.pyScopeLinkHash = builder.pyScopeLinkHash; + this.pyMethodLinkHash = builder.pyMethodLinkHash; + this.pyTypeLinkHash = builder.pyTypeLinkHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.positionalArgCount = builder.positionalArgCount; + this.keywordArgCount = builder.keywordArgCount; + this.hasStarArgs = builder.hasStarArgs; + this.hasDoubleStarArgs = builder.hasDoubleStarArgs; + this.keywordNames = builder.keywordNames; + // Derived, never trusted from a caller: the whole point is that it is a + // mechanical consequence of the call's shape. + this.argFlowIsPrecise = !builder.hasStarArgs && !builder.hasDoubleStarArgs; + this.resolvedCalleeKind = builder.resolvedCalleeKind; + this.resolvedCalleeHash = builder.resolvedCalleeHash; + this.isModuleLevelCall = builder.isModuleLevelCall; + this.isConditional = builder.isConditional; + this.startLine = builder.startLine; + this.startColumn = builder.startColumn; + this.endLine = builder.endLine; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + callKind: PythonCallKind, + calleeName: string, + pyExpressionLinkHash: string, + pyScopeLinkHash: string, + pyMethodLinkHash: string, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ): PyCallSiteRegistryBuilder { + return new PyCallSiteRegistryBuilder( + callKind, + calleeName, + pyExpressionLinkHash, + pyScopeLinkHash, + pyMethodLinkHash, + pyModuleLinkHash, + serviceVersionLinkHash + ); + } + + getCallKind(): PythonCallKind { + return this.callKind; + } + + getCalleeName(): string { + return this.calleeName; + } + + getCalleeDottedPath(): string { + return this.calleeDottedPath; + } + + getReceiverKind(): PythonReceiverKind { + return this.receiverKind; + } + + getReceiverText(): string { + return this.receiverText; + } + + getPyExpressionLinkHash(): string { + return this.pyExpressionLinkHash; + } + + getPyMethodLinkHash(): string { + return this.pyMethodLinkHash; + } + + getPositionalArgCount(): number { + return this.positionalArgCount; + } + + getKeywordArgCount(): number { + return this.keywordArgCount; + } + + getArgFlowIsPrecise(): boolean { + return this.argFlowIsPrecise; + } + + /** Comma-set of keyword argument names, in source order. */ + getKeywordNamesValue(): string { + return this.keywordNames.join(','); + } + + getStartLine(): number { + return this.startLine; + } + + getPyScopeLinkHash(): string { + return this.pyScopeLinkHash; + } + + getPyTypeLinkHash(): string { + return this.pyTypeLinkHash; + } + + getResolvedCalleeKind(): PythonResolvedCalleeKind { + return this.resolvedCalleeKind; + } + + getResolvedCalleeHash(): string { + return this.resolvedCalleeHash; + } + + /** + * Back-patches the receiver's expression FK. + * + * The call site is minted when the CALL node is emitted, which is before its + * children exist, so the receiver's PK is not yet known. Patched afterwards + * rather than reordering emission, because the call's own PK must be stable + * for the children to chain off it. + */ + setReceiverExpressionLinkHash(receiverExpressionLinkHash: string): void { + this.receiverExpressionLinkHash = receiverExpressionLinkHash; + } + + /** Back-patches parser-local callee resolution. */ + setResolvedCallee( + resolvedCalleeKind: PythonResolvedCalleeKind, + resolvedCalleeHash: string + ): void { + this.resolvedCalleeKind = resolvedCalleeKind; + this.resolvedCalleeHash = resolvedCalleeHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyCallSiteUniqueHash(): string { + return this.pyCallSiteUniqueHash; + } + + getHash(): string { + return this.pyCallSiteUniqueHash; + } + + generateHash(): void { + this.pyCallSiteUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_CALL_SITE, + this.pyExpressionLinkHash + ); + } + + getEntryCombined(): string { + return `py_call_site[kind=${this.callKind}, callee=${this.calleeName}, receiver=${this.receiverKind}, args=${this.positionalArgCount}+${this.keywordArgCount}, precise=${this.argFlowIsPrecise}, hash=${this.pyCallSiteUniqueHash}]`; + } + + toCsv(): string { + return [ + this.callKind, + EntityUtils.escapeTsv(this.calleeName), + EntityUtils.escapeTsv(this.calleeDottedPath), + EntityUtils.escapeTsv(this.receiverText), + this.receiverKind, + this.pyExpressionLinkHash, + this.receiverExpressionLinkHash, + this.pyScopeLinkHash, + this.pyMethodLinkHash, + this.pyTypeLinkHash, + this.pyModuleLinkHash, + this.positionalArgCount.toString(), + this.keywordArgCount.toString(), + this.hasStarArgs.toString(), + this.hasDoubleStarArgs.toString(), + EntityUtils.escapeTsv(this.getKeywordNamesValue()), + this.argFlowIsPrecise.toString(), + this.resolvedCalleeKind, + this.resolvedCalleeHash, + this.isModuleLevelCall.toString(), + this.isConditional.toString(), + this.startLine.toString(), + this.startColumn.toString(), + this.endLine.toString(), + this.serviceVersionLinkHash, + this.pyCallSiteUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'callKind', + 'calleeName', + 'calleeDottedPath', + 'receiverText', + 'receiverKind', + 'pyExpressionLinkHash', + 'receiverExpressionLinkHash', + 'pyScopeLinkHash', + 'pyMethodLinkHash', + 'pyTypeLinkHash', + 'pyModuleLinkHash', + 'positionalArgCount', + 'keywordArgCount', + 'hasStarArgs', + 'hasDoubleStarArgs', + 'keywordNames', + 'argFlowIsPrecise', + 'resolvedCalleeKind', + 'resolvedCalleeHash', + 'isModuleLevelCall', + 'isConditional', + 'startLine', + 'startColumn', + 'endLine', + 'serviceVersionLinkHash', + 'pyCallSiteUniqueHash', + ].join('\t'); + } +} + +/** Builder for PyCallSiteRegistry. */ +export class PyCallSiteRegistryBuilder { + callKind: PythonCallKind; + calleeName: string; + calleeDottedPath: string = ''; + receiverText: string = ''; + receiverKind: PythonReceiverKind = PythonReceiverKind.NONE; + pyExpressionLinkHash: string; + receiverExpressionLinkHash: string = ''; + pyScopeLinkHash: string; + pyMethodLinkHash: string; + pyTypeLinkHash: string = ''; + pyModuleLinkHash: string; + positionalArgCount: number = 0; + keywordArgCount: number = 0; + hasStarArgs: boolean = false; + hasDoubleStarArgs: boolean = false; + keywordNames: string[] = []; + resolvedCalleeKind: PythonResolvedCalleeKind = PythonResolvedCalleeKind.UNRESOLVED; + resolvedCalleeHash: string = ''; + isModuleLevelCall: boolean = false; + isConditional: boolean = false; + startLine: number = 0; + startColumn: number = 0; + endLine: number = 0; + serviceVersionLinkHash: string; + + constructor( + callKind: PythonCallKind, + calleeName: string, + pyExpressionLinkHash: string, + pyScopeLinkHash: string, + pyMethodLinkHash: string, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ) { + if (!pyExpressionLinkHash || pyExpressionLinkHash.trim().length === 0) { + throw new Error('pyExpressionLinkHash is required'); + } + if (!pyMethodLinkHash || pyMethodLinkHash.trim().length === 0) { + // Never empty by construction: module-level calls belong to ``. + throw new Error('pyMethodLinkHash is required (module-level calls use )'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + + this.callKind = callKind; + this.calleeName = calleeName; + this.pyExpressionLinkHash = pyExpressionLinkHash; + this.pyScopeLinkHash = pyScopeLinkHash; + this.pyMethodLinkHash = pyMethodLinkHash; + this.pyModuleLinkHash = pyModuleLinkHash; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withCallee(calleeDottedPath: string): this { + this.calleeDottedPath = calleeDottedPath; + return this; + } + + withReceiver( + receiverKind: PythonReceiverKind, + receiverText: string, + receiverExpressionLinkHash: string + ): this { + this.receiverKind = receiverKind; + this.receiverText = receiverText; + this.receiverExpressionLinkHash = receiverExpressionLinkHash; + return this; + } + + withPyTypeLinkHash(pyTypeLinkHash: string): this { + this.pyTypeLinkHash = pyTypeLinkHash; + return this; + } + + withArguments(args: { + positionalArgCount: number; + keywordArgCount: number; + hasStarArgs: boolean; + hasDoubleStarArgs: boolean; + keywordNames: string[]; + }): this { + this.positionalArgCount = args.positionalArgCount; + this.keywordArgCount = args.keywordArgCount; + this.hasStarArgs = args.hasStarArgs; + this.hasDoubleStarArgs = args.hasDoubleStarArgs; + this.keywordNames = args.keywordNames; + return this; + } + + withFlags(flags: { isModuleLevelCall?: boolean; isConditional?: boolean }): this { + this.isModuleLevelCall = flags.isModuleLevelCall ?? this.isModuleLevelCall; + this.isConditional = flags.isConditional ?? this.isConditional; + return this; + } + + withSpan(startLine: number, startColumn: number, endLine: number): this { + this.startLine = startLine; + this.startColumn = startColumn; + this.endLine = endLine; + return this; + } + + withResolvedCallee( + resolvedCalleeKind: PythonResolvedCalleeKind, + resolvedCalleeHash: string + ): this { + this.resolvedCalleeKind = resolvedCalleeKind; + this.resolvedCalleeHash = resolvedCalleeHash; + return this; + } + + build(): PyCallSiteRegistry { + return new (PyCallSiteRegistry as unknown as { + new (builder: PyCallSiteRegistryBuilder): PyCallSiteRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyCommentRegistry.ts b/parser/src/analysis-types/python/PyCommentRegistry.ts new file mode 100644 index 000000000..cdcd0a2e2 --- /dev/null +++ b/parser/src/analysis-types/python/PyCommentRegistry.ts @@ -0,0 +1,168 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PythonCommentKind } from '@/enums/python/comments'; +import { PythonExpressionOwnerKind } from '@/enums/python/expressions'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One comment or docstring. + * + * Positions 0–8 mirror `java_comment`. What makes this more than prose capture + * is that in Python most of these are DIRECTIVES rather than commentary, and a + * consumer treating them as text loses the instruction: an encoding cookie + * decides how the file decodes, a `# type:` comment carries a real annotation + * that `ast` will parse, and a `# noqa` is an explicit decision that a rule + * reporting the suppressed thing is arguing with. + * + * Docstrings appear here AND in `py_expression` as a `LITERAL`. The duplication + * is intentional — a docstring genuinely is a string expression and `ast` says + * so — and §2.17 requires a recall check to whitelist it, or it reads as a + * permanent recall failure. + * + * ## Column order (frozen — schema v7 §2.17, 15 columns) + * + * **PK** `PY_COMMENT_md5(filePath ‖ kind ‖ startLine ‖ startColumn ‖ endLine ‖ endColumn)` + */ +export class PyCommentRegistry implements EntityIdentifiable { + private kind: PythonCommentKind; + private text: string; + private filePath: string; + private startLine: number; + private startColumn: number; + private endLine: number; + private endColumn: number; + private ownerHash: string; + private commentIndex: number; + private ownerKind: PythonExpressionOwnerKind; + private pyModuleLinkHash: string; + private typeCommentPayload: string; + private isDocstring: boolean; + private serviceVersionLinkHash: string; + private pyCommentUniqueHash: string = ''; + + constructor( + kind: PythonCommentKind, + text: string, + filePath: string, + startLine: number, + startColumn: number, + endLine: number, + endColumn: number, + ownerHash: string, + commentIndex: number, + ownerKind: PythonExpressionOwnerKind, + pyModuleLinkHash: string, + typeCommentPayload: string, + isDocstring: boolean, + serviceVersionLinkHash: string + ) { + this.kind = kind; + this.text = text; + this.filePath = filePath; + this.startLine = startLine; + this.startColumn = startColumn; + this.endLine = endLine; + this.endColumn = endColumn; + this.ownerHash = ownerHash; + this.commentIndex = commentIndex; + this.ownerKind = ownerKind; + this.pyModuleLinkHash = pyModuleLinkHash; + this.typeCommentPayload = typeCommentPayload; + this.isDocstring = isDocstring; + this.serviceVersionLinkHash = serviceVersionLinkHash; + + this.generateHash(); + } + + getKind(): PythonCommentKind { + return this.kind; + } + + getText(): string { + return this.text; + } + + getStartLine(): number { + return this.startLine; + } + + getOwnerHash(): string { + return this.ownerHash; + } + + getTypeCommentPayload(): string { + return this.typeCommentPayload; + } + + getIsDocstring(): boolean { + return this.isDocstring; + } + + getHash(): string { + return this.pyCommentUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.kind + + '||' + + this.startLine + + '||' + + this.startColumn + + '||' + + this.endLine + + '||' + + this.endColumn; + + this.pyCommentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_COMMENT, + content + ); + } + + getEntryCombined(): string { + return `py_comment[kind=${this.kind}, line=${this.startLine}, text=${this.text.slice(0, 40)}]`; + } + + toCsv(): string { + return [ + this.kind, + EntityUtils.escapeTsv(this.text), + EntityUtils.escapeTsv(this.filePath), + this.startLine, + this.startColumn, + this.endLine, + this.endColumn, + this.ownerHash, + this.commentIndex, + this.ownerKind, + this.pyModuleLinkHash, + EntityUtils.escapeTsv(this.typeCommentPayload), + this.isDocstring, + this.serviceVersionLinkHash, + this.pyCommentUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'kind', + 'text', + 'filePath', + 'startLine', + 'startColumn', + 'endLine', + 'endColumn', + 'ownerHash', + 'commentIndex', + 'ownerKind', + 'pyModuleLinkHash', + 'typeCommentPayload', + 'isDocstring', + 'serviceVersionLinkHash', + 'pyCommentUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/python/PyDecoratorArgumentRegistry.ts b/parser/src/analysis-types/python/PyDecoratorArgumentRegistry.ts new file mode 100644 index 000000000..c1e8bbfa3 --- /dev/null +++ b/parser/src/analysis-types/python/PyDecoratorArgumentRegistry.ts @@ -0,0 +1,189 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PythonDecoratorArgumentValueType } from '@/enums/python/decorators'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One argument of a decorator call. + * + * Positions 0–9 mirror `java_annotation_argument`, and this is where framework + * semantics actually live: `@app.route("/admin/", methods=["POST"])` is a + * route and a verb, and a rule about unauthenticated admin endpoints reads both + * from here. Nothing else in the schema carries them. + * + * `arrayIndex` handles a list-valued argument by emitting one row per element, + * exactly as Java does, so `methods=["GET", "POST"]` is two rows rather than one + * row holding text a consumer would have to re-parse. + * + * ## Column order (frozen — schema v7 §2.13, 15 columns) + * + * **PK** `PY_DECORATOR_ARGUMENT_md5(parentDecoratorLinkHash ‖ position ‖ arrayIndex ‖ argumentName ‖ argumentValue)` + */ +export class PyDecoratorArgumentRegistry implements EntityIdentifiable { + private argumentName: string; + private argumentValue: string; + private valueType: PythonDecoratorArgumentValueType; + private position: number; + private parentDecoratorLinkHash: string; + private referencedTypeHash: string; + private nestedDecoratorHash: string; + private arrayIndex: string; + private startLine: number; + private endLine: number; + private isKeyword: boolean; + private isStarred: boolean; + private pyExpressionLinkHash: string; + private serviceVersionLinkHash: string; + private pyDecoratorArgumentUniqueHash: string = ''; + + constructor( + argumentName: string, + argumentValue: string, + valueType: PythonDecoratorArgumentValueType, + position: number, + parentDecoratorLinkHash: string, + arrayIndex: string, + startLine: number, + endLine: number, + isKeyword: boolean, + isStarred: boolean, + serviceVersionLinkHash: string + ) { + this.argumentName = argumentName; + this.argumentValue = argumentValue; + this.valueType = valueType; + this.position = position; + this.parentDecoratorLinkHash = parentDecoratorLinkHash; + this.referencedTypeHash = ''; + // Reserved by the schema and always empty: a decorator argument that is + // itself a decorator has no meaning in Python, unlike a nested annotation in + // Java. Kept for column parity so a ported rule still finds the slot. + this.nestedDecoratorHash = ''; + this.arrayIndex = arrayIndex; + this.startLine = startLine; + this.endLine = endLine; + this.isKeyword = isKeyword; + this.isStarred = isStarred; + this.pyExpressionLinkHash = ''; + this.serviceVersionLinkHash = serviceVersionLinkHash; + + this.generateHash(); + } + + getArgumentName(): string { + return this.argumentName; + } + + getArgumentValue(): string { + return this.argumentValue; + } + + getValueType(): PythonDecoratorArgumentValueType { + return this.valueType; + } + + getPosition(): number { + return this.position; + } + + getParentDecoratorLinkHash(): string { + return this.parentDecoratorLinkHash; + } + + getArrayIndex(): string { + return this.arrayIndex; + } + + getIsKeyword(): boolean { + return this.isKeyword; + } + + /** + * Records that this argument NAMES A TYPE, with the FK to it. + * + * Java declares the same column and never populates it — `referencedType()` + * is not called anywhere in the Java parser — so a rule ported across finds an + * empty field on both sides. Populating it here is the difference between a + * consumer reading `HandlerClass` as text and reaching the class. + */ + setReferencedType(referencedTypeHash: string, valueType: PythonDecoratorArgumentValueType): void { + // No re-hash: the PK is built from parent, position, arrayIndex, name and + // VALUE, none of which change here, so resolution cannot move a row. + this.referencedTypeHash = referencedTypeHash; + this.valueType = valueType; + } + + getReferencedTypeHash(): string { + return this.referencedTypeHash; + } + + setPyExpressionLinkHash(pyExpressionLinkHash: string): void { + this.pyExpressionLinkHash = pyExpressionLinkHash; + } + + getHash(): string { + return this.pyDecoratorArgumentUniqueHash; + } + + generateHash(): void { + const content = + this.parentDecoratorLinkHash + + '||' + + this.position + + '||' + + this.arrayIndex + + '||' + + this.argumentName + + '||' + + this.argumentValue; + + this.pyDecoratorArgumentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_DECORATOR_ARGUMENT, + content + ); + } + + getEntryCombined(): string { + return `py_decorator_argument[name=${this.argumentName || '-'}, value=${this.argumentValue}, type=${this.valueType}, position=${this.position}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.argumentName), + EntityUtils.escapeTsv(this.argumentValue), + this.valueType, + this.position, + this.parentDecoratorLinkHash, + this.referencedTypeHash, + this.nestedDecoratorHash, + this.arrayIndex, + this.startLine, + this.endLine, + this.isKeyword, + this.isStarred, + this.pyExpressionLinkHash, + this.serviceVersionLinkHash, + this.pyDecoratorArgumentUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'argumentName', + 'argumentValue', + 'valueType', + 'position', + 'parentDecoratorLinkHash', + 'referencedTypeHash', + 'nestedDecoratorHash', + 'arrayIndex', + 'startLine', + 'endLine', + 'isKeyword', + 'isStarred', + 'pyExpressionLinkHash', + 'serviceVersionLinkHash', + 'pyDecoratorArgumentUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/python/PyDecoratorRegistry.ts b/parser/src/analysis-types/python/PyDecoratorRegistry.ts new file mode 100644 index 000000000..76f348ad8 --- /dev/null +++ b/parser/src/analysis-types/python/PyDecoratorRegistry.ts @@ -0,0 +1,338 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonBuiltinDecoratorKind, + PythonDecoratorContext, + PythonDecoratorKind, +} from '@/enums/python/decorators'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One decorator application. + * + * This is Python's `java_annotation`: positions 0–3 mirror it, and both project + * to a shared `annotation_on`. The names differ because §1.1 says the fact layer + * follows the language, but the shape and the projection are Java's. + * + * Where it diverges is semantics, and the divergence is the reason the relation + * matters. A Java annotation is metadata; a Python decorator is a FUNCTION CALL + * that replaces the thing it decorates. Two columns carry that: + * + * - `applicationOrder` — decorators execute BOTTOM-UP, so the source order a + * reader sees is the reverse of the order that runs. `position` records what + * is written and `applicationOrder` what happens, because a rule about which + * decorator wins needs the second. + * - `replacesTarget` — `@lru_cache` returns a wrapper, so after decoration the + * name no longer refers to the `def`. A call graph that assumes otherwise + * follows an edge that does not exist at runtime. + * + * ```python + * @app.route("/admin") # position 0, applicationOrder 1, ATTRIBUTE_CALL + * @requires_auth # position 1, applicationOrder 0, BARE + * def admin(): ... + * ``` + * + * ## Column order (frozen — schema v7 §2.12, 21 columns) + * + * **PK** `PY_DECORATOR_md5(ownerHash ‖ position ‖ fullText ‖ startLine)` + */ +export class PyDecoratorRegistry implements EntityIdentifiable { + private decoratorName: string; + private kind: PythonDecoratorKind; + private context: PythonDecoratorContext; + private ownerHash: string; + private pyTypeLinkHash: string; + private pyMethodLinkHash: string; + private position: number; + private applicationOrder: number; + private startLine: number; + private endLine: number; + private dottedPath: string; + private fullText: string; + private argumentCount: string; + private pyExpressionLinkHash: string; + private resolvedTargetHash: string; + private isKnownBuiltin: boolean; + private builtinKind: PythonBuiltinDecoratorKind; + private replacesTarget: boolean; + private pyModuleLinkHash: string; + private serviceVersionLinkHash: string; + private pyDecoratorUniqueHash: string = ''; + + private constructor(builder: PyDecoratorRegistryBuilder) { + this.decoratorName = builder.decoratorName; + this.kind = builder.kind; + this.context = builder.context; + this.ownerHash = builder.ownerHash; + this.pyTypeLinkHash = builder.pyTypeLinkHash; + this.pyMethodLinkHash = builder.pyMethodLinkHash; + this.position = builder.position; + this.applicationOrder = builder.applicationOrder; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.dottedPath = builder.dottedPath; + this.fullText = builder.fullText; + this.argumentCount = builder.argumentCount; + this.pyExpressionLinkHash = builder.pyExpressionLinkHash; + this.resolvedTargetHash = builder.resolvedTargetHash; + this.isKnownBuiltin = builder.isKnownBuiltin; + this.builtinKind = builder.builtinKind; + this.replacesTarget = builder.replacesTarget; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + decoratorName: string, + kind: PythonDecoratorKind, + context: PythonDecoratorContext, + ownerHash: string, + fullText: string, + position: number, + startLine: number, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ): PyDecoratorRegistryBuilder { + return new PyDecoratorRegistryBuilder( + decoratorName, + kind, + context, + ownerHash, + fullText, + position, + startLine, + pyModuleLinkHash, + serviceVersionLinkHash + ); + } + + getDecoratorName(): string { + return this.decoratorName; + } + + getKind(): PythonDecoratorKind { + return this.kind; + } + + getContext(): PythonDecoratorContext { + return this.context; + } + + getOwnerHash(): string { + return this.ownerHash; + } + + /** 0-based source order, top-down — what a reader sees. */ + getPosition(): number { + return this.position; + } + + /** 0-based bottom-up — the order decorators actually execute. */ + getApplicationOrder(): number { + return this.applicationOrder; + } + + getBuiltinKind(): PythonBuiltinDecoratorKind { + return this.builtinKind; + } + + /** True when the decorated name no longer refers to the `def`. */ + getReplacesTarget(): boolean { + return this.replacesTarget; + } + + getFullText(): string { + return this.fullText; + } + + getDottedPath(): string { + return this.dottedPath; + } + + getArgumentCount(): string { + return this.argumentCount; + } + + getStartLine(): number { + return this.startLine; + } + + /** FK→`py_expression` — the decorator expression's root node. */ + getPyExpressionLinkHash(): string { + return this.pyExpressionLinkHash; + } + + setPyExpressionLinkHash(pyExpressionLinkHash: string): void { + this.pyExpressionLinkHash = pyExpressionLinkHash; + } + + /** Parser-local resolution of the decorator to a `py_method`/`py_type`. */ + setResolvedTargetHash(resolvedTargetHash: string): void { + this.resolvedTargetHash = resolvedTargetHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyDecoratorUniqueHash(): string { + return this.pyDecoratorUniqueHash; + } + + getHash(): string { + return this.pyDecoratorUniqueHash; + } + + generateHash(): void { + const content = + this.ownerHash + '||' + this.position + '||' + this.fullText + '||' + this.startLine; + + this.pyDecoratorUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_DECORATOR, + content + ); + } + + getEntryCombined(): string { + return `py_decorator[name=${this.decoratorName}, kind=${this.kind}, position=${this.position}, order=${this.applicationOrder}, hash=${this.pyDecoratorUniqueHash}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.decoratorName), + this.kind, + this.context, + this.ownerHash, + this.pyTypeLinkHash, + this.pyMethodLinkHash, + this.position, + this.applicationOrder, + this.startLine, + this.endLine, + EntityUtils.escapeTsv(this.dottedPath), + EntityUtils.escapeTsv(this.fullText), + this.argumentCount, + this.pyExpressionLinkHash, + this.resolvedTargetHash, + this.isKnownBuiltin, + this.builtinKind, + this.replacesTarget, + this.pyModuleLinkHash, + this.serviceVersionLinkHash, + this.pyDecoratorUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'decoratorName', + 'kind', + 'context', + 'ownerHash', + 'pyTypeLinkHash', + 'pyMethodLinkHash', + 'position', + 'applicationOrder', + 'startLine', + 'endLine', + 'dottedPath', + 'fullText', + 'argumentCount', + 'pyExpressionLinkHash', + 'resolvedTargetHash', + 'isKnownBuiltin', + 'builtinKind', + 'replacesTarget', + 'pyModuleLinkHash', + 'serviceVersionLinkHash', + 'pyDecoratorUniqueHash', + ].join('\t'); + } +} + +export class PyDecoratorRegistryBuilder { + decoratorName: string; + kind: PythonDecoratorKind; + context: PythonDecoratorContext; + ownerHash: string; + pyTypeLinkHash: string = ''; + pyMethodLinkHash: string = ''; + position: number; + applicationOrder: number = 0; + startLine: number; + endLine: number; + dottedPath: string = ''; + fullText: string; + argumentCount: string = ''; + pyExpressionLinkHash: string = ''; + resolvedTargetHash: string = ''; + isKnownBuiltin: boolean = false; + builtinKind: PythonBuiltinDecoratorKind = PythonBuiltinDecoratorKind.NONE; + replacesTarget: boolean = false; + pyModuleLinkHash: string; + serviceVersionLinkHash: string; + + constructor( + decoratorName: string, + kind: PythonDecoratorKind, + context: PythonDecoratorContext, + ownerHash: string, + fullText: string, + position: number, + startLine: number, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ) { + this.decoratorName = decoratorName; + this.kind = kind; + this.context = context; + this.ownerHash = ownerHash; + this.fullText = fullText; + this.position = position; + this.startLine = startLine; + this.endLine = startLine; + this.pyModuleLinkHash = pyModuleLinkHash; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withOwnerLinks(pyTypeLinkHash: string, pyMethodLinkHash: string): this { + this.pyTypeLinkHash = pyTypeLinkHash; + this.pyMethodLinkHash = pyMethodLinkHash; + return this; + } + + withApplicationOrder(applicationOrder: number): this { + this.applicationOrder = applicationOrder; + return this; + } + + withEndLine(endLine: number): this { + this.endLine = endLine; + return this; + } + + withDottedPath(dottedPath: string): this { + this.dottedPath = dottedPath; + return this; + } + + withArgumentCount(argumentCount: string): this { + this.argumentCount = argumentCount; + return this; + } + + withBuiltin(builtinKind: PythonBuiltinDecoratorKind, replacesTarget: boolean): this { + this.builtinKind = builtinKind; + this.isKnownBuiltin = builtinKind !== PythonBuiltinDecoratorKind.NONE; + this.replacesTarget = replacesTarget; + return this; + } + + build(): PyDecoratorRegistry { + return new (PyDecoratorRegistry as unknown as { + new (builder: PyDecoratorRegistryBuilder): PyDecoratorRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyExpressionRegistry.ts b/parser/src/analysis-types/python/PyExpressionRegistry.ts new file mode 100644 index 000000000..4827694b0 --- /dev/null +++ b/parser/src/analysis-types/python/PyExpressionRegistry.ts @@ -0,0 +1,638 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonComprehensionKind, + PythonEdgeRole, + PythonExpressionKind, + PythonExpressionOwnerKind, + PythonLiteralType, + PythonNameContext, + PythonReferencedEntityKind, + PythonRootContext, + PythonUnaryFixity, +} from '@/enums/python/expressions'; +import { + PythonInferenceConfidence, + PythonInferenceEvidence, + PythonInferredTypeKind, +} from '@/enums/python/inference'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents one node in a Python expression tree. + * + * **Positions 0–23 mirror `java_expression` 0–23**, so `expressions.dl` ports by + * moving the hash index and adding placeholders. + * + * ## The one column that matters most: `pyScopeLinkHash` + * + * Java resolves a name by file. Python resolves it by walking a **scope chain**, + * so an expression without its scope is unresolvable. This column is what + * connects a bare-name receiver — 50.7% of attribute calls — to the binding that + * tells you what it holds. `bindingLinkHash` then carries the resolution itself. + * + * ## `argumentKeywordName` exists because positional linking loses 12,000 args + * + * With 68.2% of parameters unannotated, argument→parameter flow is the primary + * typing mechanism, and 12,000 measured arguments are passed by keyword. Linking + * only by position silently drops every one of them. + * + * ## Examples + * + * ```python + * self.repo.get_user(uid, force=True) + * # CALL edgeRole=ROOT + * # ATTRIBUTE_ACCESS edgeRole=RECEIVER, dottedPath="self.repo.get_user" + * # ATTRIBUTE_ACCESS edgeRole=ATTRIBUTE_OBJECT + * # NAME_REFERENCE literalValue="self", kind SELF_REFERENCE + * # NAME_REFERENCE edgeRole=ARGUMENT, position=0 + * # LITERAL edgeRole=KEYWORD_ARGUMENT, argumentKeywordName="force" + * ``` + * + * ## Column order (frozen — schema v6 §2.15, 35 columns) + * + * **PK** `PY_EXPRESSION_md5(pyScopeLinkHash ‖ expressionOwnerHash ‖ + * expressionOwnerKind ‖ rootContext ‖ kind ‖ edgeRole ‖ parentExpressionHash ‖ + * position ‖ depth ‖ literalValue ‖ startLine ‖ startColumn ‖ endLine ‖ endColumn)` + * + * The full span is in the key, not just the start offset. `startIndex` alone + * collides for nested calls such as `super().f()` and for repeated targets in + * one statement, so node identity is the **byte range**. + */ +export class PyExpressionRegistry implements EntityIdentifiable { + private kind: PythonExpressionKind; + private edgeRole: PythonEdgeRole; + private rootContext: PythonRootContext; + private expressionOwnerKind: PythonExpressionOwnerKind; + private pyTypeLinkHash: string; + private expressionOwnerHash: string; + private parentExpressionHash: string; + private position: number; + private depth: number; + private literalType: string; + private literalValue: string; + private comprehensionKind: PythonComprehensionKind; + private unaryFixity: PythonUnaryFixity; + private operatorString: string; + private referencedEntityKind: PythonReferencedEntityKind; + private referencedEntityHash: string; + private lambdaScopeHash: string; + private potentialQualifiedName: string; + private isAmbiguous: boolean; + private returnStatementIndex: string; + private startLine: number; + private startColumn: number; + private endLine: number; + private endColumn: number; + private pyScopeLinkHash: string; + private pyModuleLinkHash: string; + private bindingLinkHash: string; + private nameContext: PythonNameContext; + private isWrite: boolean; + private argumentKeywordName: string; + private isAwaited: boolean; + private isStarred: boolean; + private dottedPath: string; + private inferredTypeName: string; + private inferredTypeKind: PythonInferredTypeKind; + private inferenceEvidence: PythonInferenceEvidence; + private inferenceConfidence: PythonInferenceConfidence; + private serviceVersionLinkHash: string; + private pyExpressionUniqueHash: string = ''; + + private constructor(builder: PyExpressionRegistryBuilder) { + this.kind = builder.kind; + this.edgeRole = builder.edgeRole; + this.rootContext = builder.rootContext; + this.expressionOwnerKind = builder.expressionOwnerKind; + this.pyTypeLinkHash = builder.pyTypeLinkHash; + this.expressionOwnerHash = builder.expressionOwnerHash; + this.parentExpressionHash = builder.parentExpressionHash; + this.position = builder.position; + this.depth = builder.depth; + this.literalType = builder.literalType; + this.literalValue = builder.literalValue; + this.comprehensionKind = builder.comprehensionKind; + this.unaryFixity = builder.unaryFixity; + this.operatorString = builder.operatorString; + this.referencedEntityKind = builder.referencedEntityKind; + this.referencedEntityHash = builder.referencedEntityHash; + this.lambdaScopeHash = builder.lambdaScopeHash; + this.potentialQualifiedName = builder.potentialQualifiedName; + this.isAmbiguous = builder.isAmbiguous; + this.returnStatementIndex = builder.returnStatementIndex; + this.startLine = builder.startLine; + this.startColumn = builder.startColumn; + this.endLine = builder.endLine; + this.endColumn = builder.endColumn; + this.pyScopeLinkHash = builder.pyScopeLinkHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.bindingLinkHash = builder.bindingLinkHash; + this.nameContext = builder.nameContext; + this.isWrite = builder.nameContext !== PythonNameContext.LOAD; + this.argumentKeywordName = builder.argumentKeywordName; + this.isAwaited = builder.isAwaited; + this.isStarred = builder.isStarred; + this.dottedPath = builder.dottedPath; + this.inferredTypeName = builder.inferredTypeName; + this.inferredTypeKind = builder.inferredTypeKind; + this.inferenceEvidence = builder.inferenceEvidence; + this.inferenceConfidence = builder.inferenceConfidence; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + kind: PythonExpressionKind, + edgeRole: PythonEdgeRole, + rootContext: PythonRootContext, + expressionOwnerKind: PythonExpressionOwnerKind, + expressionOwnerHash: string, + pyScopeLinkHash: string, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ): PyExpressionRegistryBuilder { + return new PyExpressionRegistryBuilder( + kind, + edgeRole, + rootContext, + expressionOwnerKind, + expressionOwnerHash, + pyScopeLinkHash, + pyModuleLinkHash, + serviceVersionLinkHash + ); + } + + getKind(): PythonExpressionKind { + return this.kind; + } + + getEdgeRole(): PythonEdgeRole { + return this.edgeRole; + } + + /** The name slot: callee name, attribute name, identifier, or literal text. */ + getLiteralValue(): string { + return this.literalValue; + } + + getPosition(): number { + return this.position; + } + + getDepth(): number { + return this.depth; + } + + getPyScopeLinkHash(): string { + return this.pyScopeLinkHash; + } + + getParentExpressionHash(): string { + return this.parentExpressionHash; + } + + /** FK→`py_type` — the class this expression sits inside, if any. */ + getPyTypeLinkHash(): string { + return this.pyTypeLinkHash; + } + + /** True when this node is an assignment or deletion TARGET, not a read. */ + getIsWrite(): boolean { + return this.isWrite; + } + + getIsAwaited(): boolean { + return this.isAwaited; + } + + /** `*x` / `**x` in a call or a literal. */ + getIsStarred(): boolean { + return this.isStarred; + } + + getDottedPath(): string { + return this.dottedPath; + } + + getStartLine(): number { + return this.startLine; + } + + getStartColumn(): number { + return this.startColumn; + } + + getEndLine(): number { + return this.endLine; + } + + /** + * The end column, in UTF-8 bytes. + * + * With `startColumn` it makes the BYTE RANGE, which is what identifies a node. + * A start offset alone collides: in `super().f()` the outer call and the inner + * `super()` share a start, so anything keyed on the start conflates them. + */ + getEndColumn(): number { + return this.endColumn; + } + + getNameContext(): PythonNameContext { + return this.nameContext; + } + + getReferencedEntityKind(): PythonReferencedEntityKind { + return this.referencedEntityKind; + } + + getReferencedEntityHash(): string { + return this.referencedEntityHash; + } + + /** FK→the METHOD/TYPE that owns this expression, per `expressionOwnerKind`. */ + getExpressionOwnerHash(): string { + return this.expressionOwnerHash; + } + + getRootContext(): PythonRootContext { + return this.rootContext; + } + + getArgumentKeywordName(): string { + return this.argumentKeywordName; + } + + /** Back-patches the binding FK once name resolution has run. */ + /** FK→`py_binding` — the binding this name reference resolves to. */ + getBindingLinkHash(): string { + return this.bindingLinkHash; + } + + setBindingLinkHash(bindingLinkHash: string): void { + this.bindingLinkHash = bindingLinkHash; + } + + /** Back-patches the resolved-entity FK. */ + setReferencedEntity( + referencedEntityKind: PythonReferencedEntityKind, + referencedEntityHash: string + ): void { + this.referencedEntityKind = referencedEntityKind; + this.referencedEntityHash = referencedEntityHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + /** + * The type of THIS node, when the syntax alone settles it. + * + * Folded in from the deleted `py_type_inference` relation (schema v7 §2.21): + * every inference a PARSER may make is 1:1 with one node, so it is a column, + * not a relation. `x = 3; x = "hi"` is two singly-typed nodes, and the union is + * a join over the binding rather than something to store. + */ + getInferredTypeName(): string { + return this.inferredTypeName; + } + + getInferredTypeKind(): PythonInferredTypeKind { + return this.inferredTypeKind; + } + + /** The syntactic ground for the claim — the parser/engine tier boundary. */ + getInferenceEvidence(): PythonInferenceEvidence { + return this.inferenceEvidence; + } + + getInferenceConfidence(): PythonInferenceConfidence { + return this.inferenceConfidence; + } + + /** + * Records a type derived after the row was minted. + * + * Annotation-based inference needs the resolved type reference, which does not + * exist during the expression walk. None of these four columns is in the PK, so + * patching one changes no hash. + */ + setInference( + inferredTypeName: string, + inferredTypeKind: PythonInferredTypeKind, + inferenceEvidence: PythonInferenceEvidence, + inferenceConfidence: PythonInferenceConfidence + ): void { + this.inferredTypeName = inferredTypeName; + this.inferredTypeKind = inferredTypeKind; + this.inferenceEvidence = inferenceEvidence; + this.inferenceConfidence = inferenceConfidence; + } + + getPyExpressionUniqueHash(): string { + return this.pyExpressionUniqueHash; + } + + getHash(): string { + return this.pyExpressionUniqueHash; + } + + generateHash(): void { + const content = [ + this.pyScopeLinkHash, + this.expressionOwnerHash, + this.expressionOwnerKind, + this.rootContext, + this.kind, + this.edgeRole, + this.parentExpressionHash, + this.position, + this.depth, + this.literalValue, + this.startLine, + this.startColumn, + this.endLine, + this.endColumn, + ].join('||'); + + this.pyExpressionUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_EXPRESSION, + content + ); + } + + getEntryCombined(): string { + return `py_expression[kind=${this.kind}, role=${this.edgeRole}, value=${this.literalValue}, ${this.startLine}:${this.startColumn}-${this.endLine}:${this.endColumn}, hash=${this.pyExpressionUniqueHash}]`; + } + + toCsv(): string { + return [ + this.kind, + this.edgeRole, + this.rootContext, + this.expressionOwnerKind, + this.pyTypeLinkHash, + this.expressionOwnerHash, + this.parentExpressionHash, + this.position.toString(), + this.depth.toString(), + this.literalType, + EntityUtils.escapeTsv(this.literalValue), + this.comprehensionKind, + this.unaryFixity, + EntityUtils.escapeTsv(this.operatorString), + this.referencedEntityKind, + this.referencedEntityHash, + this.lambdaScopeHash, + EntityUtils.escapeTsv(this.potentialQualifiedName), + this.isAmbiguous.toString(), + this.returnStatementIndex, + this.startLine.toString(), + this.startColumn.toString(), + this.endLine.toString(), + this.endColumn.toString(), + this.pyScopeLinkHash, + this.pyModuleLinkHash, + this.bindingLinkHash, + this.nameContext, + this.isWrite.toString(), + EntityUtils.escapeTsv(this.argumentKeywordName), + this.isAwaited.toString(), + this.isStarred.toString(), + EntityUtils.escapeTsv(this.dottedPath), + EntityUtils.escapeTsv(this.inferredTypeName), + this.inferredTypeKind, + this.inferenceEvidence, + this.inferenceConfidence, + this.serviceVersionLinkHash, + this.pyExpressionUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'kind', + 'edgeRole', + 'rootContext', + 'expressionOwnerKind', + 'pyTypeLinkHash', + 'expressionOwnerHash', + 'parentExpressionHash', + 'position', + 'depth', + 'literalType', + 'literalValue', + 'comprehensionKind', + 'unaryFixity', + 'operatorString', + 'referencedEntityKind', + 'referencedEntityHash', + 'lambdaScopeHash', + 'potentialQualifiedName', + 'isAmbiguous', + 'returnStatementIndex', + 'startLine', + 'startColumn', + 'endLine', + 'endColumn', + 'pyScopeLinkHash', + 'pyModuleLinkHash', + 'bindingLinkHash', + 'nameContext', + 'isWrite', + 'argumentKeywordName', + 'isAwaited', + 'isStarred', + 'dottedPath', + 'inferredTypeName', + 'inferredTypeKind', + 'inferenceEvidence', + 'inferenceConfidence', + 'serviceVersionLinkHash', + 'pyExpressionUniqueHash', + ].join('\t'); + } +} + +/** Builder for PyExpressionRegistry. */ +export class PyExpressionRegistryBuilder { + kind: PythonExpressionKind; + edgeRole: PythonEdgeRole; + rootContext: PythonRootContext; + expressionOwnerKind: PythonExpressionOwnerKind; + pyTypeLinkHash: string = ''; + expressionOwnerHash: string; + parentExpressionHash: string = ''; + position: number = 0; + depth: number = 0; + literalType: string = ''; + literalValue: string = ''; + comprehensionKind: PythonComprehensionKind = PythonComprehensionKind.NONE; + unaryFixity: PythonUnaryFixity = PythonUnaryFixity.NONE; + operatorString: string = ''; + referencedEntityKind: PythonReferencedEntityKind = PythonReferencedEntityKind.UNKNOWN; + referencedEntityHash: string = ''; + lambdaScopeHash: string = ''; + potentialQualifiedName: string = ''; + isAmbiguous: boolean = false; + returnStatementIndex: string = ''; + startLine: number = 0; + startColumn: number = 0; + endLine: number = 0; + endColumn: number = 0; + pyScopeLinkHash: string; + pyModuleLinkHash: string; + bindingLinkHash: string = ''; + nameContext: PythonNameContext = PythonNameContext.LOAD; + argumentKeywordName: string = ''; + isAwaited: boolean = false; + isStarred: boolean = false; + dottedPath: string = ''; + inferredTypeName: string = ''; + inferredTypeKind: PythonInferredTypeKind = PythonInferredTypeKind.UNKNOWN; + inferenceEvidence: PythonInferenceEvidence = PythonInferenceEvidence.NONE; + inferenceConfidence: PythonInferenceConfidence = PythonInferenceConfidence.NONE; + serviceVersionLinkHash: string; + + constructor( + kind: PythonExpressionKind, + edgeRole: PythonEdgeRole, + rootContext: PythonRootContext, + expressionOwnerKind: PythonExpressionOwnerKind, + expressionOwnerHash: string, + pyScopeLinkHash: string, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ) { + if (!expressionOwnerHash || expressionOwnerHash.trim().length === 0) { + throw new Error('expressionOwnerHash is required'); + } + if (!pyScopeLinkHash || pyScopeLinkHash.trim().length === 0) { + throw new Error('pyScopeLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + + this.kind = kind; + this.edgeRole = edgeRole; + this.rootContext = rootContext; + this.expressionOwnerKind = expressionOwnerKind; + this.expressionOwnerHash = expressionOwnerHash; + this.pyScopeLinkHash = pyScopeLinkHash; + this.pyModuleLinkHash = pyModuleLinkHash; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withParent(parentExpressionHash: string, position: number, depth: number): this { + this.parentExpressionHash = parentExpressionHash; + this.position = position; + this.depth = depth; + return this; + } + + withSpan(startLine: number, startColumn: number, endLine: number, endColumn: number): this { + this.startLine = startLine; + this.startColumn = startColumn; + this.endLine = endLine; + this.endColumn = endColumn; + return this; + } + + withLiteral(literalType: PythonLiteralType, literalValue: string): this { + this.literalType = literalType; + this.literalValue = literalValue; + return this; + } + + /** The name slot — used for callee, attribute and identifier names. */ + withName(literalValue: string): this { + this.literalValue = literalValue; + return this; + } + + withOperator(operatorString: string, unaryFixity: PythonUnaryFixity): this { + this.operatorString = operatorString; + this.unaryFixity = unaryFixity; + return this; + } + + withComprehensionKind(comprehensionKind: PythonComprehensionKind): this { + this.comprehensionKind = comprehensionKind; + return this; + } + + withPyTypeLinkHash(pyTypeLinkHash: string): this { + this.pyTypeLinkHash = pyTypeLinkHash; + return this; + } + + /** The scope a lambda or comprehension node introduces. */ + withLambdaScopeHash(lambdaScopeHash: string): this { + this.lambdaScopeHash = lambdaScopeHash; + return this; + } + + withReferencedEntity( + referencedEntityKind: PythonReferencedEntityKind, + referencedEntityHash: string + ): this { + this.referencedEntityKind = referencedEntityKind; + this.referencedEntityHash = referencedEntityHash; + return this; + } + + withBindingLinkHash(bindingLinkHash: string): this { + this.bindingLinkHash = bindingLinkHash; + return this; + } + + withNameContext(nameContext: PythonNameContext): this { + this.nameContext = nameContext; + return this; + } + + withArgumentKeywordName(argumentKeywordName: string): this { + this.argumentKeywordName = argumentKeywordName; + return this; + } + + withFlags(flags: { isAwaited?: boolean; isStarred?: boolean }): this { + this.isAwaited = flags.isAwaited ?? this.isAwaited; + this.isStarred = flags.isStarred ?? this.isStarred; + return this; + } + + withDottedPath(dottedPath: string): this { + this.dottedPath = dottedPath; + return this; + } + + /** Sets the four inference columns (schema v7 §2.15 c33-c36). */ + withInference( + inferredTypeName: string, + inferredTypeKind: PythonInferredTypeKind, + inferenceEvidence: PythonInferenceEvidence, + inferenceConfidence: PythonInferenceConfidence + ): this { + this.inferredTypeName = inferredTypeName; + this.inferredTypeKind = inferredTypeKind; + this.inferenceEvidence = inferenceEvidence; + this.inferenceConfidence = inferenceConfidence; + return this; + } + + withReturnStatementIndex(returnStatementIndex: number): this { + this.returnStatementIndex = returnStatementIndex.toString(); + return this; + } + + build(): PyExpressionRegistry { + return new (PyExpressionRegistry as unknown as { + new (builder: PyExpressionRegistryBuilder): PyExpressionRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyFieldPositionRegistry.ts b/parser/src/analysis-types/python/PyFieldPositionRegistry.ts new file mode 100644 index 000000000..c4ad81850 --- /dev/null +++ b/parser/src/analysis-types/python/PyFieldPositionRegistry.ts @@ -0,0 +1,72 @@ +/** + * An attribute's declaration position within its class body. + * + * Exact `java_field_position` shape, but it earns its place beyond parity: + * `@dataclass` and `NamedTuple` generate `__init__` **in field-declaration + * order**, so positional argument flow into a dataclass constructor is undefined + * without this row. + * + * ```python + * @dataclass + * class Point: + * x: int # position 0 + * y: int # position 1 + * + * Point(3, 4) # 3 -> x, 4 -> y, knowable only from position + * ``` + * + * EVERY field gets a position, and getting this wrong once is instructive. My + * first version gave positions only to class-body declarations, reasoning that a + * `self.x` recovered from a method body has no declaration order to report. That + * inverted the relation's purpose: Python's constructor-assigned fields are + * almost all `SELF_ASSIGN`, so the rows being skipped were exactly the ones a + * rule matching constructor arguments to fields needs. It emitted 2 rows for 11 + * fields where Java is strictly 1:1. + * + * Order is therefore defined for both, in the order the class actually + * establishes its attributes: class-body declarations first, in declaration + * order, then attributes recovered from methods, in FIRST-WRITE order. For a + * `@dataclass` this is exactly the generated `__init__` signature. For a + * hand-written `__init__` it is the order the attributes come into existence, + * which is the closest true statement available — and unlike a line number it + * stays stable when the class is reformatted. + * + * ## Column order (frozen — schema v6 §2.11, 3 columns) + * + * No PK: the row IS its key, exactly as in Java. + */ +export class PyFieldPositionRegistry { + private pyTypeLinkHash: string; + private pyFieldLinkHash: string; + private position: number; + + constructor(pyTypeLinkHash: string, pyFieldLinkHash: string, position: number) { + this.pyTypeLinkHash = pyTypeLinkHash; + this.pyFieldLinkHash = pyFieldLinkHash; + this.position = position; + } + + getPyTypeLinkHash(): string { + return this.pyTypeLinkHash; + } + + getPyFieldLinkHash(): string { + return this.pyFieldLinkHash; + } + + getPosition(): number { + return this.position; + } + + getEntryCombined(): string { + return `py_field_position[field=${this.pyFieldLinkHash}, position=${this.position}]`; + } + + toCsv(): string { + return [this.pyTypeLinkHash, this.pyFieldLinkHash, this.position].join('\t'); + } + + getCsvHeader(): string { + return ['pyTypeLinkHash', 'pyFieldLinkHash', 'position'].join('\t'); + } +} diff --git a/parser/src/analysis-types/python/PyFieldRegistry.ts b/parser/src/analysis-types/python/PyFieldRegistry.ts new file mode 100644 index 000000000..3632825a7 --- /dev/null +++ b/parser/src/analysis-types/python/PyFieldRegistry.ts @@ -0,0 +1,534 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonFieldModifier, + PythonFieldOrigin, + PythonInitializerKind, +} from '@/enums/python/fields'; +import { PythonMethodAccess } from '@/enums/python/methods'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One attribute of a class — declared in the class body, or recovered from + * `self.x = …` inside a method. + * + * Python has no field declarations, and that single fact drives every unusual + * column here. Three consequences worth stating, because each one is a place a + * Java-shaped model gives the wrong answer: + * + * 1. **An attribute is a MERGED fact, not a syntactic one.** `self.buf` written + * in `__init__`, `reset` and `close` is *one* attribute with three write + * sites, so the PK is `(owner, name, origin)` and deliberately **not** + * line-based. `writeCount` and `writtenInMethodCount` preserve what the merge + * would otherwise destroy, and `writtenInMethodCount > 1` is the signal that + * matters: it means cross-method mutable state. + * 2. **Only 67% of instance attributes are created in `__init__`.** A model that + * reads `__init__` alone misses a third of them, so `isDeclaredInInit` is + * recorded as an observation rather than assumed. + * 3. **`self.x` has no binding.** CPython's symtable records `self` as a local + * and the attribute name nowhere at all — it is resolved at runtime through + * `__dict__`. So `bindingLinkHash` is populated for class-body fields and + * **empty** for `self.*`, which is not an omission but the modelling problem + * itself (§2.9 c25). + * + * `fieldOrigin` is part of identity for a real reason: a name can arrive by two + * mechanisms in one class, and they are different facts. + * + * ```python + * class Conn: + * retries = 3 # CLASS_BODY_ASSIGN, CLASS_VAR + * host: str # CLASS_BODY_ANNOTATION_ONLY + * __slots__ = ("sock",) # SLOTS_ENTRY -> sock + * + * def __init__(self, host): + * self.host = host # SELF_ASSIGN, INSTANCE_VAR — shadows the annotation + * self.retries += 1 # SELF_AUGASSIGN — reads the CLASS_VAR, writes an instance one + * ``` + * + * ## Column order (frozen — schema v6 §2.9, 29 columns) + * + * Positions 0–12 mirror `java_field` 0–12 so ported rules keep working. + * + * **PK** `PY_FIELD_md5(pyTypeLinkHash ‖ name ‖ fieldOrigin)` + */ +export class PyFieldRegistry implements EntityIdentifiable { + private name: string; + private fieldTypeName: string; + private fieldBaseType: string; + private potentialQualifiedName: string; + private isAmbiguous: boolean; + private filePath: string; + private startLine: number; + private endLine: number; + private pyTypeLinkHash: string; + private ownerTypeName: string; + private ownerQualifiedName: string; + private fieldAccess: PythonMethodAccess; + private fieldModifier: string; + private fieldOrigin: PythonFieldOrigin; + private declaringMethodLinkHash: string; + private receiverName: string; + private isDeclaredInInit: boolean; + private writeCount: number; + private writtenInMethodCount: number; + private firstWriteLine: number; + private hasAnnotation: boolean; + private annotationIsString: boolean; + private initializerText: string; + private initializerKind: PythonInitializerKind; + private pyModuleLinkHash: string; + private bindingLinkHash: string; + private pyExpressionLinkHash: string; + private serviceVersionLinkHash: string; + private pyFieldUniqueHash: string = ''; + + private constructor(builder: PyFieldRegistryBuilder) { + this.name = builder.name; + this.fieldTypeName = builder.fieldTypeName; + this.fieldBaseType = builder.fieldBaseType; + this.potentialQualifiedName = builder.potentialQualifiedName; + this.isAmbiguous = builder.isAmbiguous; + this.filePath = builder.filePath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.pyTypeLinkHash = builder.pyTypeLinkHash; + this.ownerTypeName = builder.ownerTypeName; + this.ownerQualifiedName = builder.ownerQualifiedName; + this.fieldAccess = builder.fieldAccess; + this.fieldModifier = builder.fieldModifier; + this.fieldOrigin = builder.fieldOrigin; + this.declaringMethodLinkHash = builder.declaringMethodLinkHash; + this.receiverName = builder.receiverName; + this.isDeclaredInInit = builder.isDeclaredInInit; + this.writeCount = builder.writeCount; + this.writtenInMethodCount = builder.writtenInMethodCount; + this.firstWriteLine = builder.firstWriteLine; + this.hasAnnotation = builder.hasAnnotation; + this.annotationIsString = builder.annotationIsString; + this.initializerText = builder.initializerText; + this.initializerKind = builder.initializerKind; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.bindingLinkHash = builder.bindingLinkHash; + this.pyExpressionLinkHash = builder.pyExpressionLinkHash; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + name: string, + fieldOrigin: PythonFieldOrigin, + pyTypeLinkHash: string, + pyModuleLinkHash: string, + filePath: string, + startLine: number, + serviceVersionLinkHash: string + ): PyFieldRegistryBuilder { + return new PyFieldRegistryBuilder( + name, + fieldOrigin, + pyTypeLinkHash, + pyModuleLinkHash, + filePath, + startLine, + serviceVersionLinkHash + ); + } + + getName(): string { + return this.name; + } + + getFieldTypeName(): string { + return this.fieldTypeName; + } + + getFieldBaseType(): string { + return this.fieldBaseType; + } + + getFieldOrigin(): PythonFieldOrigin { + return this.fieldOrigin; + } + + getFieldModifier(): string { + return this.fieldModifier; + } + + getFieldAccess(): PythonMethodAccess { + return this.fieldAccess; + } + + getPyTypeLinkHash(): string { + return this.pyTypeLinkHash; + } + + getOwnerTypeName(): string { + return this.ownerTypeName; + } + + getOwnerQualifiedName(): string { + return this.ownerQualifiedName; + } + + getDeclaringMethodLinkHash(): string { + return this.declaringMethodLinkHash; + } + + getReceiverName(): string { + return this.receiverName; + } + + getIsDeclaredInInit(): boolean { + return this.isDeclaredInInit; + } + + /** Distinct write sites merged into this row. */ + getWriteCount(): number { + return this.writeCount; + } + + /** Distinct methods writing it — `> 1` means cross-method state. */ + getWrittenInMethodCount(): number { + return this.writtenInMethodCount; + } + + getFirstWriteLine(): number { + return this.firstWriteLine; + } + + getHasAnnotation(): boolean { + return this.hasAnnotation; + } + + getInitializerKind(): PythonInitializerKind { + return this.initializerKind; + } + + getInitializerText(): string { + return this.initializerText; + } + + /** FK→`py_binding`; empty for `self.*`, where no binding exists. */ + getBindingLinkHash(): string { + return this.bindingLinkHash; + } + + getPyExpressionLinkHash(): string { + return this.pyExpressionLinkHash; + } + + getStartLine(): number { + return this.startLine; + } + + getPotentialQualifiedName(): string { + return this.potentialQualifiedName; + } + + getIsAmbiguous(): boolean { + return this.isAmbiguous; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyFieldUniqueHash(): string { + return this.pyFieldUniqueHash; + } + + getHash(): string { + return this.pyFieldUniqueHash; + } + + /** + * Folds one further write into this row. + * + * Called when the same attribute is written again, which is the common case — + * the merge is what makes this relation an attribute table rather than a write + * log. `endLine` grows to the furthest write so the row spans them all, while + * the initialiser columns stay pinned to the FIRST write, since that is the + * one that establishes the attribute. + */ + recordAdditionalWrite(line: number, methodHash: string, writingMethods: Set): void { + this.writeCount += 1; + if (methodHash !== '') { + writingMethods.add(methodHash); + } + this.writtenInMethodCount = writingMethods.size === 0 ? this.writtenInMethodCount : writingMethods.size; + if (line > this.endLine) { + this.endLine = line; + } + } + + /** + * Marks the attribute as holding more than one kind of value. + * + * `self.result = []` in one method and `self.result = None` in another is not a + * `list`, and a resolver that treats the first write as definitive would claim + * `self.result.append(x)` reaches `list.append` — a confident wrong answer on + * code where the attribute is often `None`. Column 4 exists for exactly this + * (`java_field` parity), so a disagreement between writes is RECORDED rather + * than silently resolved by write order. + */ + /** + * Links the field to the expression node of its FIRST write. + * + * Set after the fact because the field stage and the expression stage mint + * their rows independently and are joined on the target's byte range. §2.10 + * removed `py_field_write` on the grounds that the write facts already live on + * `py_expression`; this is the pointer that makes that true rather than merely + * arguable. + */ + setPyExpressionLinkHash(pyExpressionLinkHash: string): void { + this.pyExpressionLinkHash = pyExpressionLinkHash; + } + + markAmbiguous(): void { + this.isAmbiguous = true; + } + + getFieldModifierIncludes(modifier: string): boolean { + return this.fieldModifier.split(',').includes(modifier); + } + + /** Marks a `@property` of the same name — a read of this attribute is a call. */ + addModifier(modifier: PythonFieldModifier): void { + const parts = this.fieldModifier === '' ? [] : this.fieldModifier.split(','); + if (!parts.includes(modifier)) { + parts.push(modifier); + this.fieldModifier = parts.join(','); + } + } + + /** + * Adopts an annotation discovered after the row was created. + * + * `self.x: int = 0` in `__init__` and a bare `x: int` in the class body are the + * same attribute annotated in two places; whichever is seen first wins, and + * this lets the other contribute its type rather than being lost. + */ + adoptAnnotation(fieldTypeName: string, fieldBaseType: string, annotationIsString: boolean): void { + if (this.hasAnnotation) { + return; + } + this.hasAnnotation = true; + this.fieldTypeName = fieldTypeName; + this.fieldBaseType = fieldBaseType; + this.annotationIsString = annotationIsString; + } + + generateHash(): void { + const content = this.pyTypeLinkHash + '||' + this.name + '||' + this.fieldOrigin; + + this.pyFieldUniqueHash = EntityUtils.generateEntityHash(ENTITY_IDENTIFIERS.PY_FIELD, content); + } + + getEntryCombined(): string { + return `py_field[name=${this.name}, origin=${this.fieldOrigin}, owner=${this.ownerTypeName}, writes=${this.writeCount}, hash=${this.pyFieldUniqueHash}]`; + } + + toCsv(): string { + // Every free-text column is escaped, not just the ones that have been seen + // to need it. A relation either loads or does not, so ONE unescaped value + // anywhere removes every field fact from the solve -- the failure is total + // and silent rather than local. fieldBaseType is the value that showed it: + // a forward reference made it start with a quote, which is invalid CSV + // unless the whole cell is quoted. + return [ + EntityUtils.escapeTsv(this.name), + EntityUtils.escapeTsv(this.fieldTypeName), + EntityUtils.escapeTsv(this.fieldBaseType), + EntityUtils.escapeTsv(this.potentialQualifiedName), + this.isAmbiguous, + EntityUtils.escapeTsv(this.filePath), + this.startLine, + this.endLine, + this.pyTypeLinkHash, + EntityUtils.escapeTsv(this.ownerTypeName), + EntityUtils.escapeTsv(this.ownerQualifiedName), + this.fieldAccess, + this.fieldModifier, + this.fieldOrigin, + this.declaringMethodLinkHash, + EntityUtils.escapeTsv(this.receiverName), + this.isDeclaredInInit, + this.writeCount, + this.writtenInMethodCount, + this.firstWriteLine, + this.hasAnnotation, + this.annotationIsString, + EntityUtils.escapeTsv(this.initializerText), + this.initializerKind, + this.pyModuleLinkHash, + this.bindingLinkHash, + this.pyExpressionLinkHash, + this.serviceVersionLinkHash, + this.pyFieldUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'fieldTypeName', + 'fieldBaseType', + 'potentialQualifiedName', + 'isAmbiguous', + 'filePath', + 'startLine', + 'endLine', + 'pyTypeLinkHash', + 'ownerTypeName', + 'ownerQualifiedName', + 'fieldAccess', + 'fieldModifier', + 'fieldOrigin', + 'declaringMethodLinkHash', + 'receiverName', + 'isDeclaredInInit', + 'writeCount', + 'writtenInMethodCount', + 'firstWriteLine', + 'hasAnnotation', + 'annotationIsString', + 'initializerText', + 'initializerKind', + 'pyModuleLinkHash', + 'bindingLinkHash', + 'pyExpressionLinkHash', + 'serviceVersionLinkHash', + 'pyFieldUniqueHash', + ].join('\t'); + } +} + +export class PyFieldRegistryBuilder { + name: string; + fieldTypeName: string = ''; + fieldBaseType: string = ''; + potentialQualifiedName: string = ''; + isAmbiguous: boolean = false; + filePath: string; + startLine: number; + endLine: number; + pyTypeLinkHash: string; + ownerTypeName: string = ''; + ownerQualifiedName: string = ''; + fieldAccess: PythonMethodAccess = PythonMethodAccess.PUBLIC_ACCESS; + fieldModifier: string = ''; + fieldOrigin: PythonFieldOrigin; + declaringMethodLinkHash: string = ''; + receiverName: string = ''; + isDeclaredInInit: boolean = false; + writeCount: number = 1; + writtenInMethodCount: number = 0; + firstWriteLine: number; + hasAnnotation: boolean = false; + annotationIsString: boolean = false; + initializerText: string = ''; + initializerKind: PythonInitializerKind = PythonInitializerKind.NONE; + pyModuleLinkHash: string; + bindingLinkHash: string = ''; + pyExpressionLinkHash: string = ''; + serviceVersionLinkHash: string; + + constructor( + name: string, + fieldOrigin: PythonFieldOrigin, + pyTypeLinkHash: string, + pyModuleLinkHash: string, + filePath: string, + startLine: number, + serviceVersionLinkHash: string + ) { + this.name = name; + this.fieldOrigin = fieldOrigin; + this.pyTypeLinkHash = pyTypeLinkHash; + this.pyModuleLinkHash = pyModuleLinkHash; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = startLine; + this.firstWriteLine = startLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withEndLine(endLine: number): this { + this.endLine = endLine; + return this; + } + + withOwner(ownerTypeName: string, ownerQualifiedName: string): this { + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + return this; + } + + withAccess(fieldAccess: PythonMethodAccess): this { + this.fieldAccess = fieldAccess; + return this; + } + + withModifier(fieldModifier: string): this { + this.fieldModifier = fieldModifier; + return this; + } + + withAnnotation( + fieldTypeName: string, + fieldBaseType: string, + annotationIsString: boolean + ): this { + this.hasAnnotation = fieldTypeName !== ''; + this.fieldTypeName = fieldTypeName; + this.fieldBaseType = fieldBaseType; + this.annotationIsString = annotationIsString; + return this; + } + + withPotentialQualifiedName(potentialQualifiedName: string, isAmbiguous: boolean): this { + this.potentialQualifiedName = potentialQualifiedName; + this.isAmbiguous = isAmbiguous; + return this; + } + + withDeclaringMethod(declaringMethodLinkHash: string, isDeclaredInInit: boolean): this { + this.declaringMethodLinkHash = declaringMethodLinkHash; + this.isDeclaredInInit = isDeclaredInInit; + return this; + } + + withReceiverName(receiverName: string): this { + this.receiverName = receiverName; + return this; + } + + withWriteCounts(writeCount: number, writtenInMethodCount: number): this { + this.writeCount = writeCount; + this.writtenInMethodCount = writtenInMethodCount; + return this; + } + + withInitializer(initializerText: string, initializerKind: PythonInitializerKind): this { + this.initializerText = initializerText; + this.initializerKind = initializerKind; + return this; + } + + withBindingLinkHash(bindingLinkHash: string): this { + this.bindingLinkHash = bindingLinkHash; + return this; + } + + withPyExpressionLinkHash(pyExpressionLinkHash: string): this { + this.pyExpressionLinkHash = pyExpressionLinkHash; + return this; + } + + build(): PyFieldRegistry { + return new (PyFieldRegistry as unknown as { + new (builder: PyFieldRegistryBuilder): PyFieldRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyImportRegistry.ts b/parser/src/analysis-types/python/PyImportRegistry.ts new file mode 100644 index 000000000..6b56bb76a --- /dev/null +++ b/parser/src/analysis-types/python/PyImportRegistry.ts @@ -0,0 +1,406 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PythonImportKind, PythonImportTargetKind } from '@/enums/python/imports'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents one imported name. + * + * Positions 0–8 mirror `java_import` 0–8. From-imports outnumber module-imports + * 6:1 in the measured corpus and **38% of from-imports are relative**, so + * `relativeLevel` and parser-side module resolution are mandatory. + * + * ## One row per BOUND NAME + * + * ```python + * import a.b.c # ONE row: simpleName=a, importedPath=a.b.c, + * # isModuleImport=true — the statement binds + * # only `a`, and `b`/`c` are reached by + * # attribute access afterwards + * from m import x, y # TWO rows, one per bound name + * from .. import sib # ONE row, relativeLevel=2 + * ``` + * + * ## `isTypeCheckingOnly` produces findings + * + * An import under `if TYPE_CHECKING:` — 338 blocks measured — **does not exist + * at runtime**. Calling one of those names outside an annotation is a bug, so + * this is a fact worth carrying rather than a detail. + * + * ## `isExternalTarget` is an honest negative + * + * It means precisely "did not resolve to a `py_module` in this analysis" — not a + * claim about the outside world. Deciding what is genuinely third-party is the + * engine's job, which is why the parser has no site-packages walk to do. + * + * ## Column order (frozen — schema v6 §2.14, 24 columns) + * + * **PK** `PY_IMPORT_md5(pyModuleLinkHash ‖ lineNumber ‖ importKind ‖ importedPath ‖ simpleName)` + */ +export class PyImportRegistry implements EntityIdentifiable { + private importKind: PythonImportKind; + private importedPath: string; + private packageOrTypeName: string; + private simpleName: string; + private filePath: string; + private lineNumber: number; + private isStatic: boolean; + private isWildcard: boolean; + private isModuleImport: boolean; + private relativeLevel: number; + private originalName: string; + private aliasName: string; + private resolvedModuleLinkHash: string; + private resolvedTargetKind: PythonImportTargetKind; + private resolvedTargetHash: string; + private isExternalTarget: boolean; + private distributionName: string; + private isTypeCheckingOnly: boolean; + private isConditional: boolean; + private pyScopeLinkHash: string; + private pyModuleLinkHash: string; + private bindingLinkHash: string; + private serviceVersionLinkHash: string; + private pyImportUniqueHash: string = ''; + + private constructor(builder: PyImportRegistryBuilder) { + this.importKind = builder.importKind; + this.importedPath = builder.importedPath; + this.packageOrTypeName = builder.packageOrTypeName; + this.simpleName = builder.simpleName; + this.filePath = builder.filePath; + this.lineNumber = builder.lineNumber; + this.isStatic = builder.isStatic; + this.isWildcard = builder.isWildcard; + this.isModuleImport = builder.isModuleImport; + this.relativeLevel = builder.relativeLevel; + this.originalName = builder.originalName; + this.aliasName = builder.aliasName; + this.resolvedModuleLinkHash = builder.resolvedModuleLinkHash; + this.resolvedTargetKind = builder.resolvedTargetKind; + this.resolvedTargetHash = builder.resolvedTargetHash; + this.isExternalTarget = builder.isExternalTarget; + this.distributionName = builder.distributionName; + this.isTypeCheckingOnly = builder.isTypeCheckingOnly; + this.isConditional = builder.isConditional; + this.pyScopeLinkHash = builder.pyScopeLinkHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.bindingLinkHash = builder.bindingLinkHash; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + importKind: PythonImportKind, + importedPath: string, + simpleName: string, + filePath: string, + lineNumber: number, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ): PyImportRegistryBuilder { + return new PyImportRegistryBuilder( + importKind, + importedPath, + simpleName, + filePath, + lineNumber, + pyModuleLinkHash, + serviceVersionLinkHash + ); + } + + getImportKind(): PythonImportKind { + return this.importKind; + } + + getImportedPath(): string { + return this.importedPath; + } + + /** The name actually bound in the importing namespace. */ + getSimpleName(): string { + return this.simpleName; + } + + getLineNumber(): number { + return this.lineNumber; + } + + getRelativeLevel(): number { + return this.relativeLevel; + } + + getIsWildcard(): boolean { + return this.isWildcard; + } + + getIsTypeCheckingOnly(): boolean { + return this.isTypeCheckingOnly; + } + + getPyModuleLinkHash(): string { + return this.pyModuleLinkHash; + } + + getPackageOrTypeName(): string { + return this.packageOrTypeName; + } + + /** The pre-alias name — what the target is called in the source module. */ + getOriginalName(): string { + return this.originalName; + } + + getAliasName(): string { + return this.aliasName; + } + + getBindingLinkHash(): string { + return this.bindingLinkHash; + } + + getResolvedModuleLinkHash(): string { + return this.resolvedModuleLinkHash; + } + + getIsModuleImport(): boolean { + return this.isModuleImport; + } + + getPyScopeLinkHash(): string { + return this.pyScopeLinkHash; + } + + getResolvedTargetKind(): PythonImportTargetKind { + return this.resolvedTargetKind; + } + + getResolvedTargetHash(): string { + return this.resolvedTargetHash; + } + + /** Back-patches the FK to the binding this import creates. */ + setBindingLinkHash(bindingLinkHash: string): void { + this.bindingLinkHash = bindingLinkHash; + } + + /** Records repo-local resolution. The engine decides true externality. */ + setResolution( + resolvedModuleLinkHash: string, + resolvedTargetKind: PythonImportTargetKind, + resolvedTargetHash: string + ): void { + this.resolvedModuleLinkHash = resolvedModuleLinkHash; + this.resolvedTargetKind = resolvedTargetKind; + this.resolvedTargetHash = resolvedTargetHash; + this.isExternalTarget = resolvedModuleLinkHash.length === 0; + // §2.9 defines isModuleImport as "the bound name refers to a MODULE, not a + // member". It was set at parse time from `!isFrom`, which answers a + // different question: whether the STATEMENT was a bare `import`. So + // `from . import models` bound a module and reported false, contradicting + // its own resolvedTargetKind=MODULE on the same row. + // + // Resolution is the first point that knows, since at parse time + // `from pkg import x` could bind either a module or a class. Only widened + // here, never cleared: a bare `import a.b.c` is already true before + // resolution runs and stays true even if the module is external. + if (resolvedTargetKind === PythonImportTargetKind.MODULE) { + this.isModuleImport = true; + } + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyImportUniqueHash(): string { + return this.pyImportUniqueHash; + } + + getHash(): string { + return this.pyImportUniqueHash; + } + + generateHash(): void { + const content = + this.pyModuleLinkHash + + '||' + + this.lineNumber + + '||' + + this.importKind + + '||' + + this.importedPath + + '||' + + this.simpleName; + + this.pyImportUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_IMPORT, + content + ); + } + + getEntryCombined(): string { + return `py_import[kind=${this.importKind}, path=${this.importedPath}, binds=${this.simpleName}, level=${this.relativeLevel}, line ${this.lineNumber}, hash=${this.pyImportUniqueHash}]`; + } + + toCsv(): string { + return [ + this.importKind, + EntityUtils.escapeTsv(this.importedPath), + EntityUtils.escapeTsv(this.packageOrTypeName), + EntityUtils.escapeTsv(this.simpleName), + EntityUtils.escapeTsv(this.filePath), + this.lineNumber.toString(), + this.isStatic.toString(), + this.isWildcard.toString(), + this.isModuleImport.toString(), + this.relativeLevel.toString(), + EntityUtils.escapeTsv(this.originalName), + EntityUtils.escapeTsv(this.aliasName), + this.resolvedModuleLinkHash, + this.resolvedTargetKind, + this.resolvedTargetHash, + this.isExternalTarget.toString(), + EntityUtils.escapeTsv(this.distributionName), + this.isTypeCheckingOnly.toString(), + this.isConditional.toString(), + this.pyScopeLinkHash, + this.pyModuleLinkHash, + this.bindingLinkHash, + this.serviceVersionLinkHash, + this.pyImportUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'importKind', + 'importedPath', + 'packageOrTypeName', + 'simpleName', + 'filePath', + 'lineNumber', + 'isStatic', + 'isWildcard', + 'isModuleImport', + 'relativeLevel', + 'originalName', + 'aliasName', + 'resolvedModuleLinkHash', + 'resolvedTargetKind', + 'resolvedTargetHash', + 'isExternalTarget', + 'distributionName', + 'isTypeCheckingOnly', + 'isConditional', + 'pyScopeLinkHash', + 'pyModuleLinkHash', + 'bindingLinkHash', + 'serviceVersionLinkHash', + 'pyImportUniqueHash', + ].join('\t'); + } +} + +/** Builder for PyImportRegistry. `isStatic` is a parity slot, always false. */ +export class PyImportRegistryBuilder { + importKind: PythonImportKind; + importedPath: string; + packageOrTypeName: string = ''; + simpleName: string; + filePath: string; + lineNumber: number; + readonly isStatic: boolean = false; + isWildcard: boolean = false; + isModuleImport: boolean = false; + relativeLevel: number = 0; + originalName: string = ''; + aliasName: string = ''; + resolvedModuleLinkHash: string = ''; + resolvedTargetKind: PythonImportTargetKind = PythonImportTargetKind.UNRESOLVED; + resolvedTargetHash: string = ''; + isExternalTarget: boolean = true; + distributionName: string = ''; + isTypeCheckingOnly: boolean = false; + isConditional: boolean = false; + pyScopeLinkHash: string = ''; + pyModuleLinkHash: string; + bindingLinkHash: string = ''; + serviceVersionLinkHash: string; + + constructor( + importKind: PythonImportKind, + importedPath: string, + simpleName: string, + filePath: string, + lineNumber: number, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ) { + if (!pyModuleLinkHash || pyModuleLinkHash.trim().length === 0) { + throw new Error('pyModuleLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (lineNumber <= 0) { + throw new Error('lineNumber must be > 0'); + } + + this.importKind = importKind; + this.importedPath = importedPath; + this.simpleName = simpleName; + this.filePath = filePath; + this.lineNumber = lineNumber; + this.pyModuleLinkHash = pyModuleLinkHash; + this.serviceVersionLinkHash = serviceVersionLinkHash; + this.originalName = simpleName; + } + + withPackageOrTypeName(packageOrTypeName: string): this { + this.packageOrTypeName = packageOrTypeName; + return this; + } + + withNames(originalName: string, aliasName: string): this { + this.originalName = originalName; + this.aliasName = aliasName; + return this; + } + + withRelativeLevel(relativeLevel: number): this { + this.relativeLevel = relativeLevel; + return this; + } + + withFlags(flags: { + isWildcard?: boolean; + isModuleImport?: boolean; + isTypeCheckingOnly?: boolean; + isConditional?: boolean; + }): this { + this.isWildcard = flags.isWildcard ?? this.isWildcard; + this.isModuleImport = flags.isModuleImport ?? this.isModuleImport; + this.isTypeCheckingOnly = flags.isTypeCheckingOnly ?? this.isTypeCheckingOnly; + this.isConditional = flags.isConditional ?? this.isConditional; + return this; + } + + withPyScopeLinkHash(pyScopeLinkHash: string): this { + this.pyScopeLinkHash = pyScopeLinkHash; + return this; + } + + withBindingLinkHash(bindingLinkHash: string): this { + this.bindingLinkHash = bindingLinkHash; + return this; + } + + build(): PyImportRegistry { + return new (PyImportRegistry as unknown as { + new (builder: PyImportRegistryBuilder): PyImportRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyMethodParameterRegistry.ts b/parser/src/analysis-types/python/PyMethodParameterRegistry.ts new file mode 100644 index 000000000..fe42b7f38 --- /dev/null +++ b/parser/src/analysis-types/python/PyMethodParameterRegistry.ts @@ -0,0 +1,373 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PythonDefaultValueKind, PythonParameterKind } from '@/enums/python/methods'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents one parameter of a `def`, `async def`, or `lambda`. + * + * Positions 0–11 mirror `java_method_parameter` 0–11, with the hash moved to the + * end. + * + * This relation is the **primary typing mechanism** for Python, not a secondary + * one. 68.2% of parameters carry no annotation (30,745 of 45,092 measured), so + * declared-type receiver typing — Java's main lever — covers under a third of + * the language. Types arrive instead by flowing arguments into parameters, which + * requires knowing each parameter's kind and position exactly. + * + * ## Examples + * + * ```python + * def f(self, a, /, b=1, *args, c, **kwargs): ... + * # ^0 ^1 ^2 ^3 ^4 ^5 + * # receiver POSITIONAL_OR_KEYWORD, hasDefault=True + * # POSITIONAL_ONLY + * # VAR_POSITIONAL + * # KEYWORD_ONLY + * # VAR_KEYWORD + * + * def g(items=[]): ... # isMutableDefault=True — one list shared by all calls + * def h(x: "Node"): ... # annotationIsString=True — a forward reference + * ``` + * + * The bare `/` and `*` markers get rows with an empty `paramName`, because they + * occupy a position in the signature and shift the meaning of every parameter + * after them. + * + * ## Column order (frozen — schema v6 §2.8, 22 columns) + * + * **PK** `PY_METHOD_PARAMETER_md5(pyMethodLinkHash ‖ position ‖ paramName ‖ paramKind)` + */ +export class PyMethodParameterRegistry implements EntityIdentifiable { + private paramName: string; + private position: number; + private pyMethodLinkHash: string; + private parameterBaseType: string; + private parameterTypeName: string; + private potentialQualifiedName: string; + private isAmbiguous: boolean; + private isFinal: boolean; + private isVarArgs: boolean; + private isReceiverParameter: boolean; + private startLine: number; + private endLine: number; + private paramKind: PythonParameterKind; + private hasDefault: boolean; + private defaultValueText: string; + private defaultValueKind: PythonDefaultValueKind; + private isMutableDefault: boolean; + private annotationIsString: boolean; + private bindingLinkHash: string; + private pyExpressionLinkHash: string; + private serviceVersionLinkHash: string; + private pyMethodParameterUniqueHash: string = ''; + + private constructor(builder: PyMethodParameterRegistryBuilder) { + this.paramName = builder.paramName; + this.position = builder.position; + this.pyMethodLinkHash = builder.pyMethodLinkHash; + this.parameterBaseType = builder.parameterBaseType; + this.parameterTypeName = builder.parameterTypeName; + this.potentialQualifiedName = builder.potentialQualifiedName; + this.isAmbiguous = builder.isAmbiguous; + this.isFinal = builder.isFinal; + this.isVarArgs = builder.isVarArgs; + this.isReceiverParameter = builder.isReceiverParameter; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.paramKind = builder.paramKind; + this.hasDefault = builder.hasDefault; + this.defaultValueText = builder.defaultValueText; + this.defaultValueKind = builder.defaultValueKind; + this.isMutableDefault = builder.isMutableDefault; + this.annotationIsString = builder.annotationIsString; + this.bindingLinkHash = builder.bindingLinkHash; + this.pyExpressionLinkHash = builder.pyExpressionLinkHash; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + paramName: string, + position: number, + pyMethodLinkHash: string, + paramKind: PythonParameterKind, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ): PyMethodParameterRegistryBuilder { + return new PyMethodParameterRegistryBuilder( + paramName, + position, + pyMethodLinkHash, + paramKind, + startLine, + endLine, + serviceVersionLinkHash + ); + } + + getParamName(): string { + return this.paramName; + } + + getPosition(): number { + return this.position; + } + + getParamKind(): PythonParameterKind { + return this.paramKind; + } + + getPyMethodLinkHash(): string { + return this.pyMethodLinkHash; + } + + getParameterTypeName(): string { + return this.parameterTypeName; + } + + getIsReceiverParameter(): boolean { + return this.isReceiverParameter; + } + + getIsMutableDefault(): boolean { + return this.isMutableDefault; + } + + getParameterBaseType(): string { + return this.parameterBaseType; + } + + /** + * Records the parser's resolution of the annotation. + * + * `isAmbiguous` is the honest half: a wildcard import in scope means a + * same-named class could come from somewhere unenumerable, so the resolution + * is a best guess rather than a fact. + */ + setResolvedAnnotation(potentialQualifiedName: string, isAmbiguous: boolean): void { + this.potentialQualifiedName = potentialQualifiedName; + this.isAmbiguous = isAmbiguous; + } + + /** Back-patches the FK to this parameter's binding in the function scope. */ + /** FK→`py_binding` — the local this parameter binds in the function's scope. */ + getBindingLinkHash(): string { + return this.bindingLinkHash; + } + + setBindingLinkHash(bindingLinkHash: string): void { + this.bindingLinkHash = bindingLinkHash; + } + + /** + * Back-patches the FK to the default value's expression root. + * + * The parameter row is minted by the declaration stage and the default's + * expression tree by the expression stage, so the two are joined afterwards on + * the default's byte range. + */ + setPyExpressionLinkHash(pyExpressionLinkHash: string): void { + this.pyExpressionLinkHash = pyExpressionLinkHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyMethodParameterUniqueHash(): string { + return this.pyMethodParameterUniqueHash; + } + + getHash(): string { + return this.pyMethodParameterUniqueHash; + } + + generateHash(): void { + const content = + this.pyMethodLinkHash + + '||' + + this.position + + '||' + + this.paramName + + '||' + + this.paramKind; + + this.pyMethodParameterUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_METHOD_PARAMETER, + content + ); + } + + getEntryCombined(): string { + return `py_method_parameter[name=${this.paramName || ''}, position=${this.position}, kind=${this.paramKind}, type=${this.parameterTypeName || '-'}, hash=${this.pyMethodParameterUniqueHash}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.paramName), + this.position.toString(), + this.pyMethodLinkHash, + EntityUtils.escapeTsv(this.parameterBaseType), + EntityUtils.escapeTsv(this.parameterTypeName), + EntityUtils.escapeTsv(this.potentialQualifiedName), + this.isAmbiguous.toString(), + this.isFinal.toString(), + this.isVarArgs.toString(), + this.isReceiverParameter.toString(), + this.startLine.toString(), + this.endLine.toString(), + this.paramKind, + this.hasDefault.toString(), + EntityUtils.escapeTsv(this.defaultValueText), + this.defaultValueKind, + this.isMutableDefault.toString(), + this.annotationIsString.toString(), + this.bindingLinkHash, + this.pyExpressionLinkHash, + this.serviceVersionLinkHash, + this.pyMethodParameterUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'paramName', + 'position', + 'pyMethodLinkHash', + 'parameterBaseType', + 'parameterTypeName', + 'potentialQualifiedName', + 'isAmbiguous', + 'isFinal', + 'isVarArgs', + 'isReceiverParameter', + 'startLine', + 'endLine', + 'paramKind', + 'hasDefault', + 'defaultValueText', + 'defaultValueKind', + 'isMutableDefault', + 'annotationIsString', + 'bindingLinkHash', + 'pyExpressionLinkHash', + 'serviceVersionLinkHash', + 'pyMethodParameterUniqueHash', + ].join('\t'); + } +} + +/** Builder for PyMethodParameterRegistry. `isFinal` is a parity slot, always false. */ +export class PyMethodParameterRegistryBuilder { + paramName: string; + position: number; + pyMethodLinkHash: string; + parameterBaseType: string = ''; + parameterTypeName: string = ''; + potentialQualifiedName: string = ''; + isAmbiguous: boolean = false; + readonly isFinal: boolean = false; + isVarArgs: boolean = false; + isReceiverParameter: boolean = false; + startLine: number; + endLine: number; + paramKind: PythonParameterKind; + hasDefault: boolean = false; + defaultValueText: string = ''; + defaultValueKind: PythonDefaultValueKind = PythonDefaultValueKind.NONE; + isMutableDefault: boolean = false; + annotationIsString: boolean = false; + bindingLinkHash: string = ''; + pyExpressionLinkHash: string = ''; + serviceVersionLinkHash: string; + + constructor( + paramName: string, + position: number, + pyMethodLinkHash: string, + paramKind: PythonParameterKind, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ) { + if (!pyMethodLinkHash || pyMethodLinkHash.trim().length === 0) { + throw new Error('pyMethodLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (position < 0) { + throw new Error('position must be >= 0'); + } + + this.paramName = paramName; + this.position = position; + this.pyMethodLinkHash = pyMethodLinkHash; + this.paramKind = paramKind; + this.startLine = startLine; + this.endLine = endLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + this.isVarArgs = paramKind === PythonParameterKind.VAR_POSITIONAL; + } + + withAnnotation( + parameterTypeName: string, + parameterBaseType: string, + annotationIsString: boolean + ): this { + this.parameterTypeName = parameterTypeName; + this.parameterBaseType = parameterBaseType; + this.annotationIsString = annotationIsString; + return this; + } + + withPotentialQualifiedName(potentialQualifiedName: string, isAmbiguous: boolean): this { + this.potentialQualifiedName = potentialQualifiedName; + this.isAmbiguous = isAmbiguous; + return this; + } + + withIsReceiverParameter(isReceiverParameter: boolean): this { + this.isReceiverParameter = isReceiverParameter; + return this; + } + + /** + * Records the default value. + * + * `isMutableDefault` is derived here rather than trusted from a caller, + * because the whole point is that it is a mechanical property of the default's + * shape: a list, dict, set or call default is evaluated **once**, at definition + * time, and shared by every call. + */ + withDefault(defaultValueText: string, defaultValueKind: PythonDefaultValueKind): this { + this.hasDefault = true; + this.defaultValueText = defaultValueText; + this.defaultValueKind = defaultValueKind; + this.isMutableDefault = + defaultValueKind === PythonDefaultValueKind.LIST || + defaultValueKind === PythonDefaultValueKind.DICT || + defaultValueKind === PythonDefaultValueKind.SET || + defaultValueKind === PythonDefaultValueKind.CALL; + return this; + } + + withBindingLinkHash(bindingLinkHash: string): this { + this.bindingLinkHash = bindingLinkHash; + return this; + } + + withPyExpressionLinkHash(pyExpressionLinkHash: string): this { + this.pyExpressionLinkHash = pyExpressionLinkHash; + return this; + } + + build(): PyMethodParameterRegistry { + return new (PyMethodParameterRegistry as unknown as { + new (builder: PyMethodParameterRegistryBuilder): PyMethodParameterRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyMethodRegistry.ts b/parser/src/analysis-types/python/PyMethodRegistry.ts new file mode 100644 index 000000000..3c7188d2f --- /dev/null +++ b/parser/src/analysis-types/python/PyMethodRegistry.ts @@ -0,0 +1,553 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonMethodAccess, + PythonMethodKind, + PythonMethodModifier, +} from '@/enums/python/methods'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a `def`, `async def`, `lambda`, or one of the two synthetic + * initializers. + * + * **Positions 0–20 are byte-for-byte `java_method` 0–20**, so `method_decl`, + * `method_owner` and `method_kind` port as literal renames. + * + * ## Examples + * + * ```python + * def get_user(self, user_id): ... # signature = "get_user(self, user_id)" + * async def fetch(url): ... # ASYNC modifier, ASYNC_FUNCTION kind + * def gen(): yield 1 # GENERATOR modifier, isGenerator=true + * f = lambda x: x # name = + * @overload + * def g(x: int) -> int: ... # bodyIsStub=true — NEVER a call target + * ``` + * + * ## The two synthetic methods + * + * `ownership.dl`'s `expr_ultimate_method` requires every expression to reach a + * method, or call attribution fails. Python allows code outside any function — + * 3,006 executable module-level statements across 826 measured files — so a + * synthetic `` method (`MODULE_INITIALIZER`) and a `` method + * (`CLASS_INITIALIZER`) are minted per module and per class. Every module-level + * block, expression and call site takes one as owner, which is why + * `py_call_site.pyMethodLinkHash` is **never** empty. This mirrors what the Java + * parser already does with `` / ``, so the entire call-chain layer + * works with zero new rules. + * + * ## Why the key needs both `startLine` and `startColumn` + * + * `__qualname__` alone is not unique: `@overload` stubs repeat name and + * signature, and `if X: def f() else: def f()` redefines it. `startColumn` is + * needed on top of that because of lambdas — `g = (lambda: 1, lambda: 2)` + * produces two rows whose qualifiedName, signature and startLine are all + * identical, and the previous key merged them into one. Two `def`s cannot share + * a line, so this is a lambda-only hazard, but lambdas are 369 rows in the + * corpus and the failure is silent. + * + * ## Column order (frozen — schema v6 §2.7, 36 columns) + * + * **PK** `PY_METHOD_md5(pyModuleLinkHash ‖ pyTypeLinkHash ‖ qualifiedName ‖ signature ‖ startLine ‖ startColumn)` + */ +export class PyMethodRegistry implements EntityIdentifiable { + private name: string; + private signature: string; + private detailedSignature: string; + private qualifiedName: string; + private filePath: string; + private startLine: number; + private endLine: number; + private pyTypeLinkHash: string; + private ownerTypeName: string; + private ownerQualifiedName: string; + private methodAccess: PythonMethodAccess; + private modifiers: Set; + private returnTypeName: string; + private isVarArgs: boolean; + private hasReceiverParameter: boolean; + private defaultValueExpression: string; + private methodKind: PythonMethodKind; + private parameterCount: number; + private hasTypeParameters: boolean; + private throwsExceptions: string[]; + private enclosingMemberLinkHash: string; + private pyModuleLinkHash: string; + private scopeLinkHash: string; + private declaringBindingLinkHash: string; + private posOnlyCount: number; + private kwOnlyCount: number; + private hasKwArgs: boolean; + private isArgsKwargsPassthrough: boolean; + private isAsync: boolean; + private isGenerator: boolean; + private decoratorCount: number; + private bodyIsStub: boolean; + private startColumn: number; + private endColumn: number; + private serviceVersionLinkHash: string; + private pyMethodUniqueHash: string = ''; + + private constructor(builder: PyMethodRegistryBuilder) { + this.name = builder.name; + this.signature = builder.signature; + this.detailedSignature = builder.detailedSignature; + this.qualifiedName = builder.qualifiedName; + this.filePath = builder.filePath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.pyTypeLinkHash = builder.pyTypeLinkHash; + this.ownerTypeName = builder.ownerTypeName; + this.ownerQualifiedName = builder.ownerQualifiedName; + this.methodAccess = builder.methodAccess; + this.modifiers = builder.modifiers; + this.returnTypeName = builder.returnTypeName; + this.isVarArgs = builder.isVarArgs; + this.hasReceiverParameter = builder.hasReceiverParameter; + this.defaultValueExpression = builder.defaultValueExpression; + this.methodKind = builder.methodKind; + this.parameterCount = builder.parameterCount; + this.hasTypeParameters = builder.hasTypeParameters; + this.throwsExceptions = builder.throwsExceptions; + this.enclosingMemberLinkHash = builder.enclosingMemberLinkHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.scopeLinkHash = builder.scopeLinkHash; + this.declaringBindingLinkHash = builder.declaringBindingLinkHash; + this.posOnlyCount = builder.posOnlyCount; + this.kwOnlyCount = builder.kwOnlyCount; + this.hasKwArgs = builder.hasKwArgs; + this.isArgsKwargsPassthrough = builder.isArgsKwargsPassthrough; + this.isAsync = builder.isAsync; + this.isGenerator = builder.isGenerator; + this.decoratorCount = builder.decoratorCount; + this.bodyIsStub = builder.bodyIsStub; + this.startColumn = builder.startColumn; + this.endColumn = builder.endColumn; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + name: string, + signature: string, + qualifiedName: string, + filePath: string, + startLine: number, + endLine: number, + startColumn: number, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ): PyMethodRegistryBuilder { + return new PyMethodRegistryBuilder( + name, + signature, + qualifiedName, + filePath, + startLine, + endLine, + startColumn, + pyModuleLinkHash, + serviceVersionLinkHash + ); + } + + getName(): string { + return this.name; + } + + /** The `-> T` annotation text, or `''`. */ + getReturnTypeName(): string { + return this.returnTypeName; + } + + getSignature(): string { + return this.signature; + } + + getQualifiedName(): string { + return this.qualifiedName; + } + + getMethodKind(): PythonMethodKind { + return this.methodKind; + } + + getPyTypeLinkHash(): string { + return this.pyTypeLinkHash; + } + + getPyModuleLinkHash(): string { + return this.pyModuleLinkHash; + } + + getScopeLinkHash(): string { + return this.scopeLinkHash; + } + + getStartLine(): number { + return this.startLine; + } + + getParameterCount(): number { + return this.parameterCount; + } + + getModifiers(): Set { + return this.modifiers; + } + + /** Comma-separated modifiers for CSV output; `''` when none. */ + getMethodModifier(): string { + if (this.modifiers.size === 0) { + return ''; + } + return Array.from(this.modifiers).join(','); + } + + /** + * Comma-set of `raise X` type names found in the body. + * + * **Inferred, not declared** — Python has no `throws` clause, so this is a + * body scan and is honestly incomplete: it cannot see exceptions raised by + * callees. It is useful as a lower bound, never as a guarantee. + */ + getThrowsExceptionsValue(): string { + return this.throwsExceptions.join(','); + } + + getBodyIsStub(): boolean { + return this.bodyIsStub; + } + + getDeclaringBindingLinkHash(): string { + return this.declaringBindingLinkHash; + } + + getEnclosingMemberLinkHash(): string { + return this.enclosingMemberLinkHash; + } + + /** + * Whether this is a member of its class BODY, as opposed to a function nested + * inside one of its methods. + * + * A nested `def` carries the enclosing class in `pyTypeLinkHash`, so a naive + * "methods of this type" lookup would offer `run..inner` as a + * candidate for `self.inner()` — a target that is not reachable that way. + */ + isClassBodyMember(): boolean { + return this.pyTypeLinkHash !== '' && this.enclosingMemberLinkHash === ''; + } + + getScopeLinkHashValue(): string { + return this.scopeLinkHash; + } + + /** Back-patches the FK to the binding this def creates; `''` for lambdas. */ + setDeclaringBindingLinkHash(declaringBindingLinkHash: string): void { + this.declaringBindingLinkHash = declaringBindingLinkHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyMethodUniqueHash(): string { + return this.pyMethodUniqueHash; + } + + getHash(): string { + return this.pyMethodUniqueHash; + } + + generateHash(): void { + const content = + this.pyModuleLinkHash + + '||' + + this.pyTypeLinkHash + + '||' + + this.qualifiedName + + '||' + + this.signature + + '||' + + this.startLine + + '||' + + this.startColumn; + + this.pyMethodUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_METHOD, + content + ); + } + + getEntryCombined(): string { + return `py_method[name=${this.name}, signature=${this.signature}, kind=${this.methodKind}, owner=${this.ownerTypeName || ''}, line ${this.startLine}:${this.startColumn}, hash=${this.pyMethodUniqueHash}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.name), + EntityUtils.escapeTsv(this.signature), + EntityUtils.escapeTsv(this.detailedSignature), + EntityUtils.escapeTsv(this.qualifiedName), + EntityUtils.escapeTsv(this.filePath), + this.startLine.toString(), + this.endLine.toString(), + this.pyTypeLinkHash, + EntityUtils.escapeTsv(this.ownerTypeName), + EntityUtils.escapeTsv(this.ownerQualifiedName), + this.methodAccess, + this.getMethodModifier(), + EntityUtils.escapeTsv(this.returnTypeName), + this.isVarArgs.toString(), + this.hasReceiverParameter.toString(), + EntityUtils.escapeTsv(this.defaultValueExpression), + this.methodKind, + this.parameterCount.toString(), + this.hasTypeParameters.toString(), + EntityUtils.escapeTsv(this.getThrowsExceptionsValue()), + this.enclosingMemberLinkHash, + this.pyModuleLinkHash, + this.scopeLinkHash, + this.declaringBindingLinkHash, + this.posOnlyCount.toString(), + this.kwOnlyCount.toString(), + this.hasKwArgs.toString(), + this.isArgsKwargsPassthrough.toString(), + this.isAsync.toString(), + this.isGenerator.toString(), + this.decoratorCount.toString(), + this.bodyIsStub.toString(), + this.startColumn.toString(), + this.endColumn.toString(), + this.serviceVersionLinkHash, + this.pyMethodUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'signature', + 'detailedSignature', + 'qualifiedName', + 'filePath', + 'startLine', + 'endLine', + 'pyTypeLinkHash', + 'ownerTypeName', + 'ownerQualifiedName', + 'methodAccess', + 'methodModifier', + 'returnTypeName', + 'isVarArgs', + 'hasReceiverParameter', + 'defaultValueExpression', + 'methodKind', + 'parameterCount', + 'hasTypeParameters', + 'throwsExceptions', + 'enclosingMemberLinkHash', + 'pyModuleLinkHash', + 'scopeLinkHash', + 'declaringBindingLinkHash', + 'posOnlyCount', + 'kwOnlyCount', + 'hasKwArgs', + 'isArgsKwargsPassthrough', + 'isAsync', + 'isGenerator', + 'decoratorCount', + 'bodyIsStub', + 'startColumn', + 'endColumn', + 'serviceVersionLinkHash', + 'pyMethodUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for PyMethodRegistry. + * + * `defaultValueExpression` and `hasTypeParameters` are parity slots: the former + * is unused in Python (defaults live on the parameter, not the method), the + * latter stays false until PEP 695 is un-deferred. + */ +export class PyMethodRegistryBuilder { + name: string; + signature: string; + detailedSignature: string = ''; + qualifiedName: string; + filePath: string; + startLine: number; + endLine: number; + pyTypeLinkHash: string = ''; + ownerTypeName: string = ''; + ownerQualifiedName: string = ''; + methodAccess: PythonMethodAccess = PythonMethodAccess.PUBLIC_ACCESS; + modifiers: Set = new Set(); + returnTypeName: string = ''; + isVarArgs: boolean = false; + hasReceiverParameter: boolean = false; + readonly defaultValueExpression: string = ''; + methodKind: PythonMethodKind = PythonMethodKind.FUNCTION; + parameterCount: number = 0; + hasTypeParameters: boolean = false; + throwsExceptions: string[] = []; + enclosingMemberLinkHash: string = ''; + pyModuleLinkHash: string; + scopeLinkHash: string = ''; + declaringBindingLinkHash: string = ''; + posOnlyCount: number = 0; + kwOnlyCount: number = 0; + hasKwArgs: boolean = false; + isArgsKwargsPassthrough: boolean = false; + isAsync: boolean = false; + isGenerator: boolean = false; + decoratorCount: number = 0; + bodyIsStub: boolean = false; + startColumn: number; + endColumn: number = 0; + serviceVersionLinkHash: string; + + constructor( + name: string, + signature: string, + qualifiedName: string, + filePath: string, + startLine: number, + endLine: number, + startColumn: number, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ) { + if (!name || name.trim().length === 0) { + throw new Error('name is required'); + } + if (!pyModuleLinkHash || pyModuleLinkHash.trim().length === 0) { + throw new Error('pyModuleLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (startLine < 0) { + throw new Error('startLine must be >= 0'); + } + if (endLine < startLine) { + throw new Error('endLine must be >= startLine'); + } + + this.name = name; + this.signature = signature; + this.qualifiedName = qualifiedName; + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + this.startColumn = startColumn; + this.pyModuleLinkHash = pyModuleLinkHash; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withDetailedSignature(detailedSignature: string): this { + this.detailedSignature = detailedSignature; + return this; + } + + withOwner( + pyTypeLinkHash: string, + ownerTypeName: string, + ownerQualifiedName: string + ): this { + this.pyTypeLinkHash = pyTypeLinkHash; + this.ownerTypeName = ownerTypeName; + this.ownerQualifiedName = ownerQualifiedName; + return this; + } + + withKindAndAccess( + methodKind: PythonMethodKind, + methodAccess: PythonMethodAccess + ): this { + this.methodKind = methodKind; + this.methodAccess = methodAccess; + return this; + } + + withModifiers(modifiers: PythonMethodModifier[]): this { + modifiers.forEach(m => this.modifiers.add(m)); + return this; + } + + withReturnTypeName(returnTypeName: string): this { + this.returnTypeName = returnTypeName; + return this; + } + + /** + * Records the parameter shape. + * + * `isArgsKwargsPassthrough` marks a function taking **both** `*args` and + * `**kwargs` — 2.8% of functions. It exists because positional + * argument→parameter flow is *provably* unsound there, so the engine can + * report the imprecision instead of inventing an answer. + */ + withParameterShape(shape: { + parameterCount: number; + posOnlyCount: number; + kwOnlyCount: number; + isVarArgs: boolean; + hasKwArgs: boolean; + hasReceiverParameter: boolean; + }): this { + this.parameterCount = shape.parameterCount; + this.posOnlyCount = shape.posOnlyCount; + this.kwOnlyCount = shape.kwOnlyCount; + this.isVarArgs = shape.isVarArgs; + this.hasKwArgs = shape.hasKwArgs; + this.hasReceiverParameter = shape.hasReceiverParameter; + this.isArgsKwargsPassthrough = shape.isVarArgs && shape.hasKwArgs; + return this; + } + + withBodyFlags(flags: { + isAsync?: boolean; + isGenerator?: boolean; + bodyIsStub?: boolean; + decoratorCount?: number; + }): this { + this.isAsync = flags.isAsync ?? this.isAsync; + this.isGenerator = flags.isGenerator ?? this.isGenerator; + this.bodyIsStub = flags.bodyIsStub ?? this.bodyIsStub; + this.decoratorCount = flags.decoratorCount ?? this.decoratorCount; + return this; + } + + withThrowsExceptions(throwsExceptions: string[]): this { + this.throwsExceptions = throwsExceptions; + return this; + } + + withScopeLinkHash(scopeLinkHash: string): this { + this.scopeLinkHash = scopeLinkHash; + return this; + } + + withEnclosingMemberLinkHash(enclosingMemberLinkHash: string): this { + this.enclosingMemberLinkHash = enclosingMemberLinkHash; + return this; + } + + withEndColumn(endColumn: number): this { + this.endColumn = endColumn; + return this; + } + + build(): PyMethodRegistry { + return new (PyMethodRegistry as unknown as { + new (builder: PyMethodRegistryBuilder): PyMethodRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyModuleRegistry.ts b/parser/src/analysis-types/python/PyModuleRegistry.ts new file mode 100644 index 000000000..7689da36a --- /dev/null +++ b/parser/src/analysis-types/python/PyModuleRegistry.ts @@ -0,0 +1,415 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonDialect, + PythonEmissionRegime, + PythonGrammarUsed, + PythonModuleKind, +} from '@/enums/python/modules'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a Python module — one `.py` or `.pyi` file. + * + * There is no Java analogue. Java's package is implicit in a type's qualified + * name, but Python's module is a first-class runtime namespace object, is the + * unit of import resolution, and **executes top to bottom**. It is the root of + * every FK chain in the Python fact base. + * + * ## Examples + * + * ``` + * app/web/views.py -> name=views, qualifiedName=app.web.views, MODULE + * app/web/__init__.py -> name=web, qualifiedName=app.web, PACKAGE_INIT + * stubs/views.pyi -> name=views, isStub=true, STUB + * ``` + * + * ## Why `emissionRegime` is a column *and* part of the key + * + * A 3.10 run and a 3.12 run over identical source produce structurally + * different fact sets — roughly 1,383 comprehension scopes present versus + * absent in the measured corpus — while both would be stamped + * `pythonDialect=PY3`. Recording the regime in the PK propagates it through + * every child key, so 3.10 facts and 3.12 facts for the same file can never + * collide even if both databases are loaded at once. + * + * `pythonDialect` and `emissionRegime` answer different questions and are + * deliberately separate: the dialect is a property of the *file* and is now a + * detection/rejection signal, while the regime is a property of the *analysis + * run*. A rejected file has no regime at all. + * + * ## Column order (frozen — schema v6 §2.1, 24 columns) + * + * `name, qualifiedName, fileName, filePath, baseMservPath, moduleKind, + * packageQualifiedName, isPackage, isStub, pythonDialect, targetVersion, + * emissionRegime, grammarUsed, futureImports, encodingDeclared, + * hasModuleDocstring, hasDunderAll, dunderAllIsStatic, dunderAllNames, + * moduleInitMethodLinkHash, moduleScopeLinkHash, isExternal, + * serviceVersionLinkHash, pyModuleUniqueHash` + * + * **PK** `PY_MODULE_md5(filePath ‖ baseMservPath ‖ qualifiedName ‖ emissionRegime ‖ serviceVersionLinkHash)` + */ +export class PyModuleRegistry implements EntityIdentifiable { + private name: string; + private qualifiedName: string; + private fileName: string; + private filePath: string; + private baseMservPath: string; + private moduleKind: PythonModuleKind; + private packageQualifiedName: string; + private isPackage: boolean; + private isStub: boolean; + private pythonDialect: PythonDialect; + private targetVersion: string; + private emissionRegime: PythonEmissionRegime; + private grammarUsed: PythonGrammarUsed; + private futureImports: Set; + private encodingDeclared: string; + private hasModuleDocstring: boolean; + private hasDunderAll: boolean; + private dunderAllIsStatic: boolean; + private dunderAllNames: string[]; + private moduleInitMethodLinkHash: string; + private moduleScopeLinkHash: string; + private isExternal: boolean; + private serviceVersionLinkHash: string; + private pyModuleUniqueHash: string = ''; + + private constructor(builder: PyModuleRegistryBuilder) { + this.name = builder.name; + this.qualifiedName = builder.qualifiedName; + this.fileName = builder.fileName; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.moduleKind = builder.moduleKind; + this.packageQualifiedName = builder.packageQualifiedName; + this.isPackage = builder.isPackage; + this.isStub = builder.isStub; + this.pythonDialect = builder.pythonDialect; + this.targetVersion = builder.targetVersion; + this.emissionRegime = builder.emissionRegime; + this.grammarUsed = builder.grammarUsed; + this.futureImports = builder.futureImports; + this.encodingDeclared = builder.encodingDeclared; + this.hasModuleDocstring = builder.hasModuleDocstring; + this.hasDunderAll = builder.hasDunderAll; + this.dunderAllIsStatic = builder.dunderAllIsStatic; + this.dunderAllNames = builder.dunderAllNames; + this.moduleInitMethodLinkHash = builder.moduleInitMethodLinkHash; + this.moduleScopeLinkHash = builder.moduleScopeLinkHash; + this.isExternal = builder.isExternal; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + name: string, + qualifiedName: string, + fileName: string, + filePath: string, + baseMservPath: string, + moduleKind: PythonModuleKind, + emissionRegime: PythonEmissionRegime, + targetVersion: string, + serviceVersionLinkHash: string + ): PyModuleRegistryBuilder { + return new PyModuleRegistryBuilder( + name, + qualifiedName, + fileName, + filePath, + baseMservPath, + moduleKind, + emissionRegime, + targetVersion, + serviceVersionLinkHash + ); + } + + getName(): string { + return this.name; + } + + getQualifiedName(): string { + return this.qualifiedName; + } + + getFilePath(): string { + return this.filePath; + } + + getModuleKind(): PythonModuleKind { + return this.moduleKind; + } + + getPythonDialect(): PythonDialect { + return this.pythonDialect; + } + + getEmissionRegime(): PythonEmissionRegime { + return this.emissionRegime; + } + + getGrammarUsed(): PythonGrammarUsed { + return this.grammarUsed; + } + + getFutureImports(): Set { + return this.futureImports; + } + + /** Comma-set of `__future__` imports for CSV output; `''` when none. */ + getFutureImportsValue(): string { + return Array.from(this.futureImports).sort().join(','); + } + + /** Comma-set of `__all__` names; `''` when absent **or** not static. */ + getDunderAllNamesValue(): string { + if (!this.hasDunderAll || !this.dunderAllIsStatic) { + return ''; + } + return this.dunderAllNames.join(','); + } + + getModuleScopeLinkHash(): string { + return this.moduleScopeLinkHash; + } + + /** + * Back-patches the module scope FK. + * + * The module row is minted before its scope exists, so this FK is filled in + * afterwards. Accumulate-then-export makes that free — nothing has been + * written when the patch happens — and it does **not** disturb the PK, which + * is derived only from path, name, regime and service version. + */ + setModuleScopeLinkHash(moduleScopeLinkHash: string): void { + this.moduleScopeLinkHash = moduleScopeLinkHash; + } + + getModuleInitMethodLinkHash(): string { + return this.moduleInitMethodLinkHash; + } + + /** Back-patches the synthetic `` initializer FK. See above. */ + setModuleInitMethodLinkHash(moduleInitMethodLinkHash: string): void { + this.moduleInitMethodLinkHash = moduleInitMethodLinkHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyModuleUniqueHash(): string { + return this.pyModuleUniqueHash; + } + + getHash(): string { + return this.pyModuleUniqueHash; + } + + generateHash(): void { + const content = + this.filePath + + '||' + + this.baseMservPath + + '||' + + this.qualifiedName + + '||' + + this.emissionRegime + + '||' + + this.serviceVersionLinkHash; + + this.pyModuleUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_MODULE, + content + ); + } + + getEntryCombined(): string { + return `py_module[name=${this.name}, qname=${this.qualifiedName}, kind=${this.moduleKind}, dialect=${this.pythonDialect}, regime=${this.emissionRegime}, hash=${this.pyModuleUniqueHash}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.name), + EntityUtils.escapeTsv(this.qualifiedName), + EntityUtils.escapeTsv(this.fileName), + EntityUtils.escapeTsv(this.filePath), + EntityUtils.escapeTsv(this.baseMservPath), + this.moduleKind, + EntityUtils.escapeTsv(this.packageQualifiedName), + this.isPackage.toString(), + this.isStub.toString(), + this.pythonDialect, + this.targetVersion, + this.emissionRegime, + this.grammarUsed, + EntityUtils.escapeTsv(this.getFutureImportsValue()), + EntityUtils.escapeTsv(this.encodingDeclared), + this.hasModuleDocstring.toString(), + this.hasDunderAll.toString(), + this.dunderAllIsStatic.toString(), + EntityUtils.escapeTsv(this.getDunderAllNamesValue()), + this.moduleInitMethodLinkHash, + this.moduleScopeLinkHash, + this.isExternal.toString(), + this.serviceVersionLinkHash, + this.pyModuleUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'qualifiedName', + 'fileName', + 'filePath', + 'baseMservPath', + 'moduleKind', + 'packageQualifiedName', + 'isPackage', + 'isStub', + 'pythonDialect', + 'targetVersion', + 'emissionRegime', + 'grammarUsed', + 'futureImports', + 'encodingDeclared', + 'hasModuleDocstring', + 'hasDunderAll', + 'dunderAllIsStatic', + 'dunderAllNames', + 'moduleInitMethodLinkHash', + 'moduleScopeLinkHash', + 'isExternal', + 'serviceVersionLinkHash', + 'pyModuleUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for PyModuleRegistry. + * + * `isExternal` has no setter: it is a parity slot that is **always false** on + * parser output, so that when the engine stages `lib_py_module` the layout is + * byte-identical. Deciding what is external is the engine's job, not the + * parser's. + */ +export class PyModuleRegistryBuilder { + name: string; + qualifiedName: string; + fileName: string; + filePath: string; + baseMservPath: string; + moduleKind: PythonModuleKind; + packageQualifiedName: string = ''; + isPackage: boolean = false; + isStub: boolean = false; + pythonDialect: PythonDialect = PythonDialect.PY3; + targetVersion: string; + emissionRegime: PythonEmissionRegime; + grammarUsed: PythonGrammarUsed = PythonGrammarUsed.TS_PYTHON3; + futureImports: Set = new Set(); + encodingDeclared: string = ''; + hasModuleDocstring: boolean = false; + hasDunderAll: boolean = false; + dunderAllIsStatic: boolean = false; + dunderAllNames: string[] = []; + moduleInitMethodLinkHash: string = ''; + moduleScopeLinkHash: string = ''; + readonly isExternal: boolean = false; + serviceVersionLinkHash: string; + + constructor( + name: string, + qualifiedName: string, + fileName: string, + filePath: string, + baseMservPath: string, + moduleKind: PythonModuleKind, + emissionRegime: PythonEmissionRegime, + targetVersion: string, + serviceVersionLinkHash: string + ) { + if (!qualifiedName || qualifiedName.trim().length === 0) { + throw new Error('qualifiedName is required'); + } + if (!filePath || filePath.trim().length === 0) { + throw new Error('filePath is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + + this.name = name; + this.qualifiedName = qualifiedName; + this.fileName = fileName; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.moduleKind = moduleKind; + this.emissionRegime = emissionRegime; + this.targetVersion = targetVersion; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withPackage(packageQualifiedName: string, isPackage: boolean): this { + this.packageQualifiedName = packageQualifiedName; + this.isPackage = isPackage; + return this; + } + + withIsStub(isStub: boolean): this { + this.isStub = isStub; + return this; + } + + withDialect(pythonDialect: PythonDialect): this { + this.pythonDialect = pythonDialect; + return this; + } + + withGrammarUsed(grammarUsed: PythonGrammarUsed): this { + this.grammarUsed = grammarUsed; + return this; + } + + withFutureImports(futureImports: string[]): this { + futureImports.forEach(f => this.futureImports.add(f)); + return this; + } + + withEncodingDeclared(encodingDeclared: string): this { + this.encodingDeclared = encodingDeclared; + return this; + } + + withHasModuleDocstring(hasModuleDocstring: boolean): this { + this.hasModuleDocstring = hasModuleDocstring; + return this; + } + + /** + * Records `__all__`. + * + * `dunderAllIsStatic` is separate because `__all__` is not always a literal: + * `__all__ = __all__ + _d` and `__all__.extend(...)` both occur in real code. + * Treating a dynamically built `__all__` as complete is silently wrong in + * exactly the direction that hides public API, so the flag marks it rather + * than the parser guessing. + */ + withDunderAll(hasDunderAll: boolean, isStatic: boolean, names: string[]): this { + this.hasDunderAll = hasDunderAll; + this.dunderAllIsStatic = isStatic; + this.dunderAllNames = names; + return this; + } + + build(): PyModuleRegistry { + return new (PyModuleRegistry as unknown as { + new (builder: PyModuleRegistryBuilder): PyModuleRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyParseGapRegistry.ts b/parser/src/analysis-types/python/PyParseGapRegistry.ts new file mode 100644 index 000000000..dceef8824 --- /dev/null +++ b/parser/src/analysis-types/python/PyParseGapRegistry.ts @@ -0,0 +1,135 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonParseGapDisposition, + PythonParseGapKind, +} from '@/enums/python/parse-gaps'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One construct the grammar could not represent. + * + * The relation whose ABSENCE is invisible, which is exactly why it has to exist. + * Every other gap in this schema shows up as a missing row somewhere a consumer + * is already looking; a region the parser could not read produces silence, and + * silence is indistinguishable from "there was nothing there". A rule that finds + * no `eval` call cannot tell a clean file from one where the parser gave up on + * the block containing it. + * + * Zero rows is the expected state for ~99.6% of modules, Python 2 included. A + * non-empty table names exactly what could not be represented and where. + * + * ## Column order (frozen — schema v7 §2.19, 10 columns) + * + * **PK** `PY_PARSE_GAP_md5(pyModuleLinkHash ‖ startLine ‖ startColumn ‖ endLine ‖ endColumn ‖ constructKind)` + */ +export class PyParseGapRegistry implements EntityIdentifiable { + private pyModuleLinkHash: string; + private constructKind: PythonParseGapKind; + private disposition: PythonParseGapDisposition; + private startLine: number; + private startColumn: number; + private endLine: number; + private endColumn: number; + private sourceText: string; + private serviceVersionLinkHash: string; + private pyParseGapUniqueHash: string = ''; + + constructor( + pyModuleLinkHash: string, + constructKind: PythonParseGapKind, + disposition: PythonParseGapDisposition, + startLine: number, + startColumn: number, + endLine: number, + endColumn: number, + sourceText: string, + serviceVersionLinkHash: string + ) { + this.pyModuleLinkHash = pyModuleLinkHash; + this.constructKind = constructKind; + this.disposition = disposition; + this.startLine = startLine; + this.startColumn = startColumn; + this.endLine = endLine; + this.endColumn = endColumn; + this.sourceText = sourceText; + this.serviceVersionLinkHash = serviceVersionLinkHash; + + this.generateHash(); + } + + getConstructKind(): PythonParseGapKind { + return this.constructKind; + } + + getDisposition(): PythonParseGapDisposition { + return this.disposition; + } + + getStartLine(): number { + return this.startLine; + } + + getSourceText(): string { + return this.sourceText; + } + + getHash(): string { + return this.pyParseGapUniqueHash; + } + + generateHash(): void { + const content = + this.pyModuleLinkHash + + '||' + + this.startLine + + '||' + + this.startColumn + + '||' + + this.endLine + + '||' + + this.endColumn + + '||' + + this.constructKind; + + this.pyParseGapUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_PARSE_GAP, + content + ); + } + + getEntryCombined(): string { + return `py_parse_gap[kind=${this.constructKind}, disposition=${this.disposition}, line=${this.startLine}]`; + } + + toCsv(): string { + return [ + this.pyModuleLinkHash, + this.constructKind, + this.disposition, + this.startLine, + this.startColumn, + this.endLine, + this.endColumn, + EntityUtils.escapeTsv(this.sourceText), + this.serviceVersionLinkHash, + this.pyParseGapUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'pyModuleLinkHash', + 'constructKind', + 'disposition', + 'startLine', + 'startColumn', + 'endLine', + 'endColumn', + 'sourceText', + 'serviceVersionLinkHash', + 'pyParseGapUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/python/PyScopeRegistry.ts b/parser/src/analysis-types/python/PyScopeRegistry.ts new file mode 100644 index 000000000..1e3b4198c --- /dev/null +++ b/parser/src/analysis-types/python/PyScopeRegistry.ts @@ -0,0 +1,410 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PythonScopeKind, PythonScopeOwnerKind } from '@/enums/python/scopes'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a Python scope — a direct mirror of `symtable.SymbolTable`. + * + * No Java analogue. This relation exists so the oracle can assert **set + * equality** with CPython rather than eyeballing structure; it is the reason + * precision and recall are well-defined for this schema at all. Java resolves + * names by file; Python resolves them by walking a scope chain, so this is the + * spine the whole resolution layer hangs from. + * + * ## Examples + * + * ```python + * def outer(): # FUNCTION outer + * def inner(): ... # FUNCTION outer..inner + * squares = [x for x in xs] # COMPREHENSION_LIST outer..listcomp + * f = lambda: 1 # LAMBDA outer..lambda + * + * class K: # CLASS K + * def m(self): ... # FUNCTION K.m (no ) + * ``` + * + * ## Why `startColumn` is in the primary key + * + * `(module, parent, kind, name, startLine)` is **not unique**, and this is a + * measured collision rather than a theoretical one — 37 colliding scopes out of + * 6,129 scope-introducing nodes (0.60%) across ~4,000 site-packages files: + * + * ```python + * g = (lambda: 1, lambda: 2) # two scopes, both named `lambda`, one line + * h = [x for x in a] + [y for y in b] # two `listcomp` scopes, one line + * ``` + * + * The blast radius is not contained to this relation. `py_binding` is keyed on + * `(pyScopeLinkHash, name)`, so one collision silently **merges two scopes' + * entire binding sets** — and in the listcomp case above those sets genuinely + * differ (`x` versus `y`). `scopeOrdinal` is deliberately *not* in the key: it + * is unique but shifts when a sibling is inserted, which would churn every + * descendant hash. + * + * ## Column order (frozen — schema v6 §2.2, 25 columns) + * + * `scopeKind, name, qualifiedName, nestingDepth, parentScopeLinkHash, + * pyModuleLinkHash, ownerKind, ownerHash, isNested, isOptimized, hasChildren, + * symtableId, usesWildcardImport, isGenerator, isCoroutine, declaresGlobal, + * declaresNonlocal, filePath, startLine, startColumn, endLine, endColumn, + * scopeOrdinal, serviceVersionLinkHash, pyScopeUniqueHash` + * + * **PK** `PY_SCOPE_md5(pyModuleLinkHash ‖ parentScopeLinkHash ‖ scopeKind ‖ name ‖ startLine ‖ startColumn)` + */ +export class PyScopeRegistry implements EntityIdentifiable { + private scopeKind: PythonScopeKind; + private name: string; + private qualifiedName: string; + private nestingDepth: number; + private parentScopeLinkHash: string; + private pyModuleLinkHash: string; + private ownerKind: PythonScopeOwnerKind; + private ownerHash: string; + private isNested: boolean; + private isOptimized: boolean; + private hasChildren: boolean; + private symtableId: number; + private usesWildcardImport: boolean; + private isGenerator: boolean; + private isCoroutine: boolean; + private declaresGlobal: boolean; + private declaresNonlocal: boolean; + private filePath: string; + private startLine: number; + private startColumn: number; + private endLine: number; + private endColumn: number; + private scopeOrdinal: number; + private serviceVersionLinkHash: string; + private pyScopeUniqueHash: string = ''; + + private constructor(builder: PyScopeRegistryBuilder) { + this.scopeKind = builder.scopeKind; + this.name = builder.name; + this.qualifiedName = builder.qualifiedName; + this.nestingDepth = builder.nestingDepth; + this.parentScopeLinkHash = builder.parentScopeLinkHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.ownerKind = builder.ownerKind; + this.ownerHash = builder.ownerHash; + this.isNested = builder.isNested; + this.isOptimized = builder.isOptimized; + this.hasChildren = builder.hasChildren; + this.symtableId = builder.symtableId; + this.usesWildcardImport = builder.usesWildcardImport; + this.isGenerator = builder.isGenerator; + this.isCoroutine = builder.isCoroutine; + this.declaresGlobal = builder.declaresGlobal; + this.declaresNonlocal = builder.declaresNonlocal; + this.filePath = builder.filePath; + this.startLine = builder.startLine; + this.startColumn = builder.startColumn; + this.endLine = builder.endLine; + this.endColumn = builder.endColumn; + this.scopeOrdinal = builder.scopeOrdinal; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + scopeKind: PythonScopeKind, + name: string, + qualifiedName: string, + pyModuleLinkHash: string, + filePath: string, + startLine: number, + startColumn: number, + serviceVersionLinkHash: string + ): PyScopeRegistryBuilder { + return new PyScopeRegistryBuilder( + scopeKind, + name, + qualifiedName, + pyModuleLinkHash, + filePath, + startLine, + startColumn, + serviceVersionLinkHash + ); + } + + getScopeKind(): PythonScopeKind { + return this.scopeKind; + } + + getName(): string { + return this.name; + } + + getQualifiedName(): string { + return this.qualifiedName; + } + + getNestingDepth(): number { + return this.nestingDepth; + } + + getParentScopeLinkHash(): string { + return this.parentScopeLinkHash; + } + + getPyModuleLinkHash(): string { + return this.pyModuleLinkHash; + } + + getOwnerKind(): PythonScopeOwnerKind { + return this.ownerKind; + } + + getOwnerHash(): string { + return this.ownerHash; + } + + /** + * Sets the polymorphic owner FK. + * + * A scope is minted before the `py_type` or `py_method` that owns it, so this + * is back-patched. It is not part of the PK, so patching it is safe. + */ + setOwner(ownerKind: PythonScopeOwnerKind, ownerHash: string): void { + this.ownerKind = ownerKind; + this.ownerHash = ownerHash; + } + + getStartLine(): number { + return this.startLine; + } + + getStartColumn(): number { + return this.startColumn; + } + + getScopeOrdinal(): number { + return this.scopeOrdinal; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyScopeUniqueHash(): string { + return this.pyScopeUniqueHash; + } + + getHash(): string { + return this.pyScopeUniqueHash; + } + + generateHash(): void { + const content = + this.pyModuleLinkHash + + '||' + + this.parentScopeLinkHash + + '||' + + this.scopeKind + + '||' + + this.name + + '||' + + this.startLine + + '||' + + this.startColumn; + + this.pyScopeUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_SCOPE, + content + ); + } + + getEntryCombined(): string { + return `py_scope[kind=${this.scopeKind}, qname=${this.qualifiedName}, line ${this.startLine}:${this.startColumn}, ordinal=${this.scopeOrdinal}, hash=${this.pyScopeUniqueHash}]`; + } + + toCsv(): string { + return [ + this.scopeKind, + EntityUtils.escapeTsv(this.name), + EntityUtils.escapeTsv(this.qualifiedName), + this.nestingDepth.toString(), + this.parentScopeLinkHash, + this.pyModuleLinkHash, + this.ownerKind, + this.ownerHash, + this.isNested.toString(), + this.isOptimized.toString(), + this.hasChildren.toString(), + this.symtableId.toString(), + this.usesWildcardImport.toString(), + this.isGenerator.toString(), + this.isCoroutine.toString(), + this.declaresGlobal.toString(), + this.declaresNonlocal.toString(), + EntityUtils.escapeTsv(this.filePath), + this.startLine.toString(), + this.startColumn.toString(), + this.endLine.toString(), + this.endColumn.toString(), + this.scopeOrdinal.toString(), + this.serviceVersionLinkHash, + this.pyScopeUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'scopeKind', + 'name', + 'qualifiedName', + 'nestingDepth', + 'parentScopeLinkHash', + 'pyModuleLinkHash', + 'ownerKind', + 'ownerHash', + 'isNested', + 'isOptimized', + 'hasChildren', + 'symtableId', + 'usesWildcardImport', + 'isGenerator', + 'isCoroutine', + 'declaresGlobal', + 'declaresNonlocal', + 'filePath', + 'startLine', + 'startColumn', + 'endLine', + 'endColumn', + 'scopeOrdinal', + 'serviceVersionLinkHash', + 'pyScopeUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for PyScopeRegistry. + * + * `symtableId` is a canonical pre-order ordinal, **not** `SymbolTable.get_id()`. + * CPython's `get_id()` returns `id()` of the underlying object, which is stable + * within a process but varies across them, so emitting it would make every + * golden file differ run-to-run and break the byte-identical-output invariant. + * The column's documented purpose is "oracle cross-check handle only; never + * joined on", which an ordinal serves exactly and reproducibly. + */ +export class PyScopeRegistryBuilder { + scopeKind: PythonScopeKind; + name: string; + qualifiedName: string; + nestingDepth: number = 0; + parentScopeLinkHash: string = ''; + pyModuleLinkHash: string; + ownerKind: PythonScopeOwnerKind = PythonScopeOwnerKind.MODULE; + ownerHash: string = ''; + isNested: boolean = false; + isOptimized: boolean = false; + hasChildren: boolean = false; + symtableId: number = 0; + usesWildcardImport: boolean = false; + isGenerator: boolean = false; + isCoroutine: boolean = false; + declaresGlobal: boolean = false; + declaresNonlocal: boolean = false; + filePath: string; + startLine: number; + startColumn: number; + endLine: number = 0; + endColumn: number = 0; + scopeOrdinal: number = 0; + serviceVersionLinkHash: string; + + constructor( + scopeKind: PythonScopeKind, + name: string, + qualifiedName: string, + pyModuleLinkHash: string, + filePath: string, + startLine: number, + startColumn: number, + serviceVersionLinkHash: string + ) { + if (!pyModuleLinkHash || pyModuleLinkHash.trim().length === 0) { + throw new Error('pyModuleLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (startLine < 0) { + throw new Error('startLine must be >= 0'); + } + if (startColumn < 0) { + throw new Error('startColumn must be >= 0'); + } + + this.scopeKind = scopeKind; + this.name = name; + this.qualifiedName = qualifiedName; + this.pyModuleLinkHash = pyModuleLinkHash; + this.filePath = filePath; + this.startLine = startLine; + this.startColumn = startColumn; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withParent(parentScopeLinkHash: string, nestingDepth: number): this { + this.parentScopeLinkHash = parentScopeLinkHash; + this.nestingDepth = nestingDepth; + return this; + } + + withOwner(ownerKind: PythonScopeOwnerKind, ownerHash: string): this { + this.ownerKind = ownerKind; + this.ownerHash = ownerHash; + return this; + } + + /** The three verbatim `SymbolTable` predicates, in symtable's own order. */ + withSymtablePredicates(isNested: boolean, isOptimized: boolean, hasChildren: boolean): this { + this.isNested = isNested; + this.isOptimized = isOptimized; + this.hasChildren = hasChildren; + return this; + } + + withSymtableId(symtableId: number): this { + this.symtableId = symtableId; + return this; + } + + withFlags(flags: { + usesWildcardImport?: boolean; + isGenerator?: boolean; + isCoroutine?: boolean; + declaresGlobal?: boolean; + declaresNonlocal?: boolean; + }): this { + this.usesWildcardImport = flags.usesWildcardImport ?? this.usesWildcardImport; + this.isGenerator = flags.isGenerator ?? this.isGenerator; + this.isCoroutine = flags.isCoroutine ?? this.isCoroutine; + this.declaresGlobal = flags.declaresGlobal ?? this.declaresGlobal; + this.declaresNonlocal = flags.declaresNonlocal ?? this.declaresNonlocal; + return this; + } + + withEndPosition(endLine: number, endColumn: number): this { + this.endLine = endLine; + this.endColumn = endColumn; + return this; + } + + withScopeOrdinal(scopeOrdinal: number): this { + this.scopeOrdinal = scopeOrdinal; + return this; + } + + build(): PyScopeRegistry { + return new (PyScopeRegistry as unknown as { + new (builder: PyScopeRegistryBuilder): PyScopeRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyTypeBaseRegistry.ts b/parser/src/analysis-types/python/PyTypeBaseRegistry.ts new file mode 100644 index 000000000..e89b0eee8 --- /dev/null +++ b/parser/src/analysis-types/python/PyTypeBaseRegistry.ts @@ -0,0 +1,309 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PythonBaseKind } from '@/enums/python/types'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents one entry in a class's base list. + * + * Deliberately **not** a type reference. Python bases differ from Java's + * `extends`/`implements` in three ways that a type-reference row cannot carry: + * + * 1. **They are ordered, and C3 linearisation depends on the order.** + * `position` is therefore load-bearing, not decorative — 12.1% of classes + * have more than one base. + * 2. **They can be arbitrary expressions.** `class D(mixin_factory())` is legal, + * and its MRO is not statically knowable. + * 3. **`metaclass=` and `total=` are syntactically in the base list but are not + * bases.** They must be distinguishable, not silently counted as position 2. + * + * ## Examples + * + * ```python + * class Repo(Base, Mixin, metaclass=Meta): + * # ^0 ^1 ^keyword row, position='' + * + * class Box(Generic[T]): # SUBSCRIPT, baseSimpleName=Generic + * class Impl(pkg.mod.Iface): # DOTTED_NAME, baseDottedPath=pkg.mod.Iface + * class Dyn(factory()): # CALL, isDynamic=true + * ``` + * + * ## Column order (frozen — schema v6 §2.5, 16 columns) + * + * **PK** `PY_TYPE_BASE_md5(pyTypeLinkHash ‖ position ‖ keywordName ‖ baseText ‖ startLine)` + */ +export class PyTypeBaseRegistry implements EntityIdentifiable { + private baseKind: PythonBaseKind; + private position: string; + private baseText: string; + private baseSimpleName: string; + private baseDottedPath: string; + private keywordName: string; + private pyTypeLinkHash: string; + private pyModuleLinkHash: string; + private pyExpressionLinkHash: string; + private pyTypeReferenceLinkHash: string; + private resolvedTypeLinkHash: string; + private isResolvedLocally: boolean; + private isDynamic: boolean; + private startLine: number; + private serviceVersionLinkHash: string; + private pyTypeBaseUniqueHash: string = ''; + + private constructor(builder: PyTypeBaseRegistryBuilder) { + this.baseKind = builder.baseKind; + this.position = builder.position; + this.baseText = builder.baseText; + this.baseSimpleName = builder.baseSimpleName; + this.baseDottedPath = builder.baseDottedPath; + this.keywordName = builder.keywordName; + this.pyTypeLinkHash = builder.pyTypeLinkHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.pyExpressionLinkHash = builder.pyExpressionLinkHash; + this.pyTypeReferenceLinkHash = builder.pyTypeReferenceLinkHash; + this.resolvedTypeLinkHash = builder.resolvedTypeLinkHash; + this.isResolvedLocally = builder.isResolvedLocally; + this.isDynamic = builder.isDynamic; + this.startLine = builder.startLine; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + baseKind: PythonBaseKind, + baseText: string, + pyTypeLinkHash: string, + pyModuleLinkHash: string, + startLine: number, + serviceVersionLinkHash: string + ): PyTypeBaseRegistryBuilder { + return new PyTypeBaseRegistryBuilder( + baseKind, + baseText, + pyTypeLinkHash, + pyModuleLinkHash, + startLine, + serviceVersionLinkHash + ); + } + + getBaseKind(): PythonBaseKind { + return this.baseKind; + } + + /** 0-based MRO order among positional bases; `''` for keyword rows. */ + getPosition(): string { + return this.position; + } + + getBaseText(): string { + return this.baseText; + } + + getBaseSimpleName(): string { + return this.baseSimpleName; + } + + getKeywordName(): string { + return this.keywordName; + } + + getPyTypeLinkHash(): string { + return this.pyTypeLinkHash; + } + + getIsDynamic(): boolean { + return this.isDynamic; + } + + getBaseDottedPath(): string { + return this.baseDottedPath; + } + + getResolvedTypeLinkHash(): string { + return this.resolvedTypeLinkHash; + } + + getIsResolvedLocally(): boolean { + return this.isResolvedLocally; + } + + getPyTypeReferenceLinkHash(): string { + return this.pyTypeReferenceLinkHash; + } + + /** + * Links this base to its twin `py_type_reference` row. + * + * §2.5 c9 calls it "the twin row that feeds the shared name→type resolver", + * and it is what `type-hierarchy.dl` traverses — base → type_reference — so a + * rule ported from Java finds nothing without it. + */ + setPyTypeReferenceLinkHash(pyTypeReferenceLinkHash: string): void { + this.pyTypeReferenceLinkHash = pyTypeReferenceLinkHash; + } + + /** + * Records that this base was resolved to a type in the same analysis. + * + * MRO resolution depends on this: `super().m()` can only find the parent's `m` + * once the base has an actual `py_type` to walk into. + */ + setResolution(resolvedTypeLinkHash: string, isResolvedLocally: boolean): void { + this.resolvedTypeLinkHash = resolvedTypeLinkHash; + this.isResolvedLocally = isResolvedLocally; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyTypeBaseUniqueHash(): string { + return this.pyTypeBaseUniqueHash; + } + + getHash(): string { + return this.pyTypeBaseUniqueHash; + } + + generateHash(): void { + const content = + this.pyTypeLinkHash + + '||' + + this.position + + '||' + + this.keywordName + + '||' + + this.baseText + + '||' + + this.startLine; + + this.pyTypeBaseUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_TYPE_BASE, + content + ); + } + + getEntryCombined(): string { + return `py_type_base[kind=${this.baseKind}, position=${this.position || '-'}, text=${this.baseText}, keyword=${this.keywordName || '-'}, hash=${this.pyTypeBaseUniqueHash}]`; + } + + toCsv(): string { + return [ + this.baseKind, + this.position, + EntityUtils.escapeTsv(this.baseText), + EntityUtils.escapeTsv(this.baseSimpleName), + EntityUtils.escapeTsv(this.baseDottedPath), + EntityUtils.escapeTsv(this.keywordName), + this.pyTypeLinkHash, + this.pyModuleLinkHash, + this.pyExpressionLinkHash, + this.pyTypeReferenceLinkHash, + this.resolvedTypeLinkHash, + this.isResolvedLocally.toString(), + this.isDynamic.toString(), + this.startLine.toString(), + this.serviceVersionLinkHash, + this.pyTypeBaseUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'baseKind', + 'position', + 'baseText', + 'baseSimpleName', + 'baseDottedPath', + 'keywordName', + 'pyTypeLinkHash', + 'pyModuleLinkHash', + 'pyExpressionLinkHash', + 'pyTypeReferenceLinkHash', + 'resolvedTypeLinkHash', + 'isResolvedLocally', + 'isDynamic', + 'startLine', + 'serviceVersionLinkHash', + 'pyTypeBaseUniqueHash', + ].join('\t'); + } +} + +/** Builder for PyTypeBaseRegistry. */ +export class PyTypeBaseRegistryBuilder { + baseKind: PythonBaseKind; + position: string = ''; + baseText: string; + baseSimpleName: string = ''; + baseDottedPath: string = ''; + keywordName: string = ''; + pyTypeLinkHash: string; + pyModuleLinkHash: string; + pyExpressionLinkHash: string = ''; + pyTypeReferenceLinkHash: string = ''; + resolvedTypeLinkHash: string = ''; + isResolvedLocally: boolean = false; + isDynamic: boolean = false; + startLine: number; + serviceVersionLinkHash: string; + + constructor( + baseKind: PythonBaseKind, + baseText: string, + pyTypeLinkHash: string, + pyModuleLinkHash: string, + startLine: number, + serviceVersionLinkHash: string + ) { + if (!pyTypeLinkHash || pyTypeLinkHash.trim().length === 0) { + throw new Error('pyTypeLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + + this.baseKind = baseKind; + this.baseText = baseText; + this.pyTypeLinkHash = pyTypeLinkHash; + this.pyModuleLinkHash = pyModuleLinkHash; + this.startLine = startLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + /** Positional bases carry a 0-based MRO index; keyword rows leave it empty. */ + withPosition(position: number): this { + this.position = position.toString(); + return this; + } + + withNameParts(baseSimpleName: string, baseDottedPath: string): this { + this.baseSimpleName = baseSimpleName; + this.baseDottedPath = baseDottedPath; + return this; + } + + withKeywordName(keywordName: string): this { + this.keywordName = keywordName; + return this; + } + + withIsDynamic(isDynamic: boolean): this { + this.isDynamic = isDynamic; + return this; + } + + withResolution(resolvedTypeLinkHash: string, isResolvedLocally: boolean): this { + this.resolvedTypeLinkHash = resolvedTypeLinkHash; + this.isResolvedLocally = isResolvedLocally; + return this; + } + + build(): PyTypeBaseRegistry { + return new (PyTypeBaseRegistry as unknown as { + new (builder: PyTypeBaseRegistryBuilder): PyTypeBaseRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyTypeParameterRegistry.ts b/parser/src/analysis-types/python/PyTypeParameterRegistry.ts new file mode 100644 index 000000000..0a64e856c --- /dev/null +++ b/parser/src/analysis-types/python/PyTypeParameterRegistry.ts @@ -0,0 +1,176 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { PythonExpressionOwnerKind } from '@/enums/python/expressions'; +import { + PythonTypeParameterKind, + PythonTypeParameterVariance, +} from '@/enums/python/type-parameters'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One PEP 695 type parameter — the `T` in `class Box[T]`. + * + * Emitted only for the 3.12 SYNTAX. On 3.11 and earlier a `TypeVar` is a runtime + * assignment rather than syntax — 255 of them in the corpus — and §2.20 puts + * those in `py_binding` with `targetEntityKind=TYPE_VAR`, because they are + * genuinely a different thing: an assignment the interpreter executes, not a + * declaration the grammar recognises. + * + * `boundText` carries the constraint, and it is where the scope subtlety lives: + * CPython opens a SEPARATE scope for a bound (`TypeVar bound`, verified on + * 3.12.4) because the bound is evaluated lazily and can refer to the parameters + * around it. `pyScopeLinkHash` therefore points at that scope when one exists, + * and at the type-parameter scope otherwise. + * + * `variance` is `INFERRED` for everything here, and that is a statement rather + * than a default: PEP 695 removed the explicit `covariant=True` spelling, so the + * source genuinely does not say and a checker works it out from usage. + * + * ## Column order (frozen — schema v7 §2.20, 14 columns) + * + * **PK** `PY_TYPE_PARAMETER_md5(ownerLinkHash ‖ paramName ‖ position)` + */ +export class PyTypeParameterRegistry implements EntityIdentifiable { + private paramName: string; + private position: number; + private ownerName: string; + private ownerQualifiedName: string; + private filePath: string; + private startLine: number; + private ownerLinkHash: string; + private ownerKind: PythonExpressionOwnerKind; + private boundText: string; + private variance: PythonTypeParameterVariance; + private defaultText: string; + private pyScopeLinkHash: string; + private kind: PythonTypeParameterKind; + private serviceVersionLinkHash: string; + private pyTypeParameterUniqueHash: string = ''; + + constructor( + paramName: string, + position: number, + ownerName: string, + ownerQualifiedName: string, + filePath: string, + startLine: number, + ownerLinkHash: string, + ownerKind: PythonExpressionOwnerKind, + boundText: string, + variance: PythonTypeParameterVariance, + defaultText: string, + pyScopeLinkHash: string, + kind: PythonTypeParameterKind, + serviceVersionLinkHash: string + ) { + this.paramName = paramName; + this.position = position; + this.ownerName = ownerName; + this.ownerQualifiedName = ownerQualifiedName; + this.filePath = filePath; + this.startLine = startLine; + this.ownerLinkHash = ownerLinkHash; + this.ownerKind = ownerKind; + this.boundText = boundText; + this.variance = variance; + this.defaultText = defaultText; + this.pyScopeLinkHash = pyScopeLinkHash; + this.kind = kind; + this.serviceVersionLinkHash = serviceVersionLinkHash; + + this.generateHash(); + } + + getParamName(): string { + return this.paramName; + } + + getPosition(): number { + return this.position; + } + + /** + * TYPE_VAR, TYPE_VAR_TUPLE or PARAM_SPEC. + * + * Inserted at position 12 rather than appended, on A0's reasoning that the + * insert is free ONLY until a golden freezes a row of this relation — after + * which the column would have to go on the end and break the convention that + * the version hash sits immediately before the key. + * + * Without it `*Ts` and `**P` were indistinguishable: tree-sitter gives both + * `splat_type` with an identifier under it, so the distinction lives in the + * source text and nowhere else. + */ + getKind(): PythonTypeParameterKind { + return this.kind; + } + + getBoundText(): string { + return this.boundText; + } + + getOwnerLinkHash(): string { + return this.ownerLinkHash; + } + + getOwnerQualifiedName(): string { + return this.ownerQualifiedName; + } + + getHash(): string { + return this.pyTypeParameterUniqueHash; + } + + generateHash(): void { + const content = this.ownerLinkHash + '||' + this.paramName + '||' + this.position; + + this.pyTypeParameterUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_TYPE_PARAMETER, + content + ); + } + + getEntryCombined(): string { + return `py_type_parameter[name=${this.paramName}, position=${this.position}, owner=${this.ownerName}, bound=${this.boundText || '-'}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.paramName), + this.position, + EntityUtils.escapeTsv(this.ownerName), + EntityUtils.escapeTsv(this.ownerQualifiedName), + EntityUtils.escapeTsv(this.filePath), + this.startLine, + this.ownerLinkHash, + this.ownerKind, + EntityUtils.escapeTsv(this.boundText), + this.variance, + EntityUtils.escapeTsv(this.defaultText), + this.pyScopeLinkHash, + this.kind, + this.serviceVersionLinkHash, + this.pyTypeParameterUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'paramName', + 'position', + 'ownerName', + 'ownerQualifiedName', + 'filePath', + 'startLine', + 'ownerLinkHash', + 'ownerKind', + 'boundText', + 'variance', + 'defaultText', + 'pyScopeLinkHash', + 'kind', + 'serviceVersionLinkHash', + 'pyTypeParameterUniqueHash', + ].join('\t'); + } +} diff --git a/parser/src/analysis-types/python/PyTypeReferenceRegistry.ts b/parser/src/analysis-types/python/PyTypeReferenceRegistry.ts new file mode 100644 index 000000000..a83db724c --- /dev/null +++ b/parser/src/analysis-types/python/PyTypeReferenceRegistry.ts @@ -0,0 +1,404 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonTypeRefContext, + PythonTypeRefKind, + PythonTypeRefOwnerKind, +} from '@/enums/python/type-references'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents one **type reference** — a use of a type, linked to the type. + * + * Positions 0–16 mirror `java_type_reference` 0–16, so the ~250-line + * `type-resolution.dl` name→type layer ports with a relation rename. + * + * ## Why this is a tree and not one row per annotation + * + * This is the whole point of the relation. A composite annotation references + * several types, and they are **related to each other**, so one row cannot carry + * it: + * + * ```python + * def f(m: Dict[TypeA, TypeB]): ... + * + * depth 0 SUBSCRIPT typeName=Dict completeTypeName=Dict[TypeA, TypeB] + * depth 1 NAME typeName=TypeA parent= position=0 + * depth 1 NAME typeName=TypeB parent= position=1 + * ``` + * + * `parentReferenceHash` + `position` + `depth` are what make nesting + * navigable, and they compose to any depth: + * + * ```python + * x: Dict[TypeA, List[Optional[TypeB]]] + * + * d0 SUBSCRIPT Dict + * d1 NAME TypeA parent=Dict position=0 + * d1 SUBSCRIPT List parent=Dict position=1 + * d2 OPTIONAL Optional parent=List position=0 isOptional=true + * d3 NAME TypeB parent=Optional position=0 + * ``` + * + * A single `potentialQualifiedName` slot on the parameter cannot express any of + * that, which is exactly why the relation exists. + * + * `depth` is load-bearing beyond navigation: `type-hierarchy.dl` filters on + * `depth == "0"` to find the outermost reference, so a flattened tree would make + * that filter select every nested argument as well. + * + * ## Column order (frozen — schema v6 §2.6, 25 columns) + * + * **PK** `PY_TYPE_REFERENCE_md5(typeReferenceOwnerHash ‖ context ‖ + * parentReferenceHash ‖ position ‖ depth ‖ completeTypeName ‖ startLine)` + */ +export class PyTypeReferenceRegistry implements EntityIdentifiable { + private kind: PythonTypeRefKind; + private context: PythonTypeRefContext; + private pyTypeLinkHash: string; + private typeParameterLinkHash: string; + private referencedTypeLinkHash: string; + private parentReferenceHash: string; + private position: number; + private depth: number; + private typeName: string; + private completeTypeName: string; + private typeVariableName: string; + private arrayDimensions: string; + private wildcardVariance: string; + private startLine: number; + private endLine: number; + private typeReferenceOwnerHash: string; + private referenceOwnerKind: PythonTypeRefOwnerKind; + private pyScopeLinkHash: string; + private pyModuleLinkHash: string; + private isStringForwardRef: boolean; + private isTypeCommentDerived: boolean; + private isOptional: boolean; + private pyExpressionLinkHash: string; + private serviceVersionLinkHash: string; + private pyTypeReferenceUniqueHash: string = ''; + + private constructor(builder: PyTypeReferenceRegistryBuilder) { + this.kind = builder.kind; + this.context = builder.context; + this.pyTypeLinkHash = builder.pyTypeLinkHash; + this.typeParameterLinkHash = builder.typeParameterLinkHash; + this.referencedTypeLinkHash = builder.referencedTypeLinkHash; + this.parentReferenceHash = builder.parentReferenceHash; + this.position = builder.position; + this.depth = builder.depth; + this.typeName = builder.typeName; + this.completeTypeName = builder.completeTypeName; + this.typeVariableName = builder.typeVariableName; + this.arrayDimensions = builder.arrayDimensions; + this.wildcardVariance = builder.wildcardVariance; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.typeReferenceOwnerHash = builder.typeReferenceOwnerHash; + this.referenceOwnerKind = builder.referenceOwnerKind; + this.pyScopeLinkHash = builder.pyScopeLinkHash; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.isStringForwardRef = builder.isStringForwardRef; + this.isTypeCommentDerived = builder.isTypeCommentDerived; + this.isOptional = builder.isOptional; + this.pyExpressionLinkHash = builder.pyExpressionLinkHash; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + kind: PythonTypeRefKind, + context: PythonTypeRefContext, + typeName: string, + completeTypeName: string, + typeReferenceOwnerHash: string, + referenceOwnerKind: PythonTypeRefOwnerKind, + pyModuleLinkHash: string, + startLine: number, + serviceVersionLinkHash: string + ): PyTypeReferenceRegistryBuilder { + return new PyTypeReferenceRegistryBuilder( + kind, + context, + typeName, + completeTypeName, + typeReferenceOwnerHash, + referenceOwnerKind, + pyModuleLinkHash, + startLine, + serviceVersionLinkHash + ); + } + + getKind(): PythonTypeRefKind { + return this.kind; + } + + getContext(): PythonTypeRefContext { + return this.context; + } + + getTypeName(): string { + return this.typeName; + } + + getCompleteTypeName(): string { + return this.completeTypeName; + } + + getParentReferenceHash(): string { + return this.parentReferenceHash; + } + + getPosition(): number { + return this.position; + } + + getDepth(): number { + return this.depth; + } + + getTypeReferenceOwnerHash(): string { + return this.typeReferenceOwnerHash; + } + + getReferenceOwnerKind(): PythonTypeRefOwnerKind { + return this.referenceOwnerKind; + } + + getReferencedTypeLinkHash(): string { + return this.referencedTypeLinkHash; + } + + getIsOptional(): boolean { + return this.isOptional; + } + + getStartLine(): number { + return this.startLine; + } + + /** Back-patches the resolved type once name resolution has run. */ + setReferencedTypeLinkHash(referencedTypeLinkHash: string): void { + this.referencedTypeLinkHash = referencedTypeLinkHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyTypeReferenceUniqueHash(): string { + return this.pyTypeReferenceUniqueHash; + } + + getHash(): string { + return this.pyTypeReferenceUniqueHash; + } + + generateHash(): void { + const content = [ + this.typeReferenceOwnerHash, + this.context, + this.parentReferenceHash, + this.position, + this.depth, + this.completeTypeName, + this.startLine, + ].join('||'); + + this.pyTypeReferenceUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_TYPE_REFERENCE, + content + ); + } + + getEntryCombined(): string { + return `py_type_reference[kind=${this.kind}, context=${this.context}, name=${this.typeName}, depth=${this.depth}, position=${this.position}, parent=${this.parentReferenceHash || '-'}, hash=${this.pyTypeReferenceUniqueHash}]`; + } + + toCsv(): string { + return [ + this.kind, + this.context, + this.pyTypeLinkHash, + this.typeParameterLinkHash, + this.referencedTypeLinkHash, + this.parentReferenceHash, + this.position.toString(), + this.depth.toString(), + EntityUtils.escapeTsv(this.typeName), + EntityUtils.escapeTsv(this.completeTypeName), + EntityUtils.escapeTsv(this.typeVariableName), + this.arrayDimensions, + this.wildcardVariance, + this.startLine.toString(), + this.endLine.toString(), + this.typeReferenceOwnerHash, + this.referenceOwnerKind, + this.pyScopeLinkHash, + this.pyModuleLinkHash, + this.isStringForwardRef.toString(), + this.isTypeCommentDerived.toString(), + this.isOptional.toString(), + this.pyExpressionLinkHash, + this.serviceVersionLinkHash, + this.pyTypeReferenceUniqueHash, + ].join('\t'); + } + + /** Set by the fact extractor once the expression stage has minted rows. */ + setPyExpressionLinkHash(pyExpressionLinkHash: string): void { + this.pyExpressionLinkHash = pyExpressionLinkHash; + } + + getCsvHeader(): string { + return [ + 'kind', + 'context', + 'pyTypeLinkHash', + 'typeParameterLinkHash', + 'referencedTypeLinkHash', + 'parentReferenceHash', + 'position', + 'depth', + 'typeName', + 'completeTypeName', + 'typeVariableName', + 'arrayDimensions', + 'wildcardVariance', + 'startLine', + 'endLine', + 'typeReferenceOwnerHash', + 'referenceOwnerKind', + 'pyScopeLinkHash', + 'pyModuleLinkHash', + 'isStringForwardRef', + 'isTypeCommentDerived', + 'isOptional', + 'pyExpressionLinkHash', + 'serviceVersionLinkHash', + 'pyTypeReferenceUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for PyTypeReferenceRegistry. + * + * `arrayDimensions` has no setter: Python has no array types, so it is a parity + * slot held at `''` to keep Java's column positions aligned. + */ +export class PyTypeReferenceRegistryBuilder { + kind: PythonTypeRefKind; + context: PythonTypeRefContext; + pyTypeLinkHash: string = ''; + typeParameterLinkHash: string = ''; + referencedTypeLinkHash: string = ''; + parentReferenceHash: string = ''; + position: number = 0; + depth: number = 0; + typeName: string; + completeTypeName: string; + typeVariableName: string = ''; + readonly arrayDimensions: string = ''; + wildcardVariance: string = ''; + startLine: number; + endLine: number = 0; + typeReferenceOwnerHash: string; + referenceOwnerKind: PythonTypeRefOwnerKind; + pyScopeLinkHash: string = ''; + pyModuleLinkHash: string; + isStringForwardRef: boolean = false; + isTypeCommentDerived: boolean = false; + isOptional: boolean = false; + pyExpressionLinkHash: string = ''; + serviceVersionLinkHash: string; + + constructor( + kind: PythonTypeRefKind, + context: PythonTypeRefContext, + typeName: string, + completeTypeName: string, + typeReferenceOwnerHash: string, + referenceOwnerKind: PythonTypeRefOwnerKind, + pyModuleLinkHash: string, + startLine: number, + serviceVersionLinkHash: string + ) { + if (!typeReferenceOwnerHash || typeReferenceOwnerHash.trim().length === 0) { + throw new Error('typeReferenceOwnerHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + + this.kind = kind; + this.context = context; + this.typeName = typeName; + this.completeTypeName = completeTypeName; + this.typeReferenceOwnerHash = typeReferenceOwnerHash; + this.referenceOwnerKind = referenceOwnerKind; + this.pyModuleLinkHash = pyModuleLinkHash; + this.startLine = startLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + /** The nesting link: parent reference, index among siblings, and depth. */ + withNesting(parentReferenceHash: string, position: number, depth: number): this { + this.parentReferenceHash = parentReferenceHash; + this.position = position; + this.depth = depth; + return this; + } + + withEnclosingType(pyTypeLinkHash: string): this { + this.pyTypeLinkHash = pyTypeLinkHash; + return this; + } + + withScope(pyScopeLinkHash: string): this { + this.pyScopeLinkHash = pyScopeLinkHash; + return this; + } + + withSpan(startLine: number, endLine: number): this { + this.startLine = startLine; + this.endLine = endLine; + return this; + } + + withFlags(flags: { + isStringForwardRef?: boolean; + isTypeCommentDerived?: boolean; + isOptional?: boolean; + }): this { + this.isStringForwardRef = flags.isStringForwardRef ?? this.isStringForwardRef; + this.isTypeCommentDerived = flags.isTypeCommentDerived ?? this.isTypeCommentDerived; + this.isOptional = flags.isOptional ?? this.isOptional; + return this; + } + + withTypeVariable(typeVariableName: string, wildcardVariance: string): this { + this.typeVariableName = typeVariableName; + this.wildcardVariance = wildcardVariance; + return this; + } + + withPyExpressionLinkHash(pyExpressionLinkHash: string): this { + this.pyExpressionLinkHash = pyExpressionLinkHash; + return this; + } + + withReferencedTypeLinkHash(referencedTypeLinkHash: string): this { + this.referencedTypeLinkHash = referencedTypeLinkHash; + return this; + } + + build(): PyTypeReferenceRegistry { + return new (PyTypeReferenceRegistry as unknown as { + new (builder: PyTypeReferenceRegistryBuilder): PyTypeReferenceRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/PyTypeRegistry.ts b/parser/src/analysis-types/python/PyTypeRegistry.ts new file mode 100644 index 000000000..b30d388b1 --- /dev/null +++ b/parser/src/analysis-types/python/PyTypeRegistry.ts @@ -0,0 +1,423 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + PythonMroKind, + PythonTypeAccess, + PythonTypeCategory, + PythonTypeModifier, + PythonTypePlacement, +} from '@/enums/python/types'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a Python `class` statement. + * + * Positions 0–11 mirror `java_type` 0–11 exactly, so the type-hierarchy + * projections port as a literal relation rename. + * + * ## Examples + * + * ```python + * class UserService: # CLASS_TYPE, IMPLICIT_OBJECT MRO + * ... + * + * @dataclass(frozen=True) + * class Point(Base, Mixin): # DATACLASS_TYPE, FROZEN, + * x: int # C3_LINEARIZABLE (2 bases) + * + * class Registry(dict, metaclass=Meta): # metaclassName=Meta + * ... + * + * def factory(): + * class Local: ... # LOCAL_PLACEMENT, enclosingMethod set + * ``` + * + * ## Why `enclosingTypeLinkHash` is explicit + * + * Java's parser recovers nesting from line ranges. Here the enclosing type and + * enclosing method are explicit FKs, so no `type_lines` range trick is needed — + * and 60 classes in the measured corpus are defined inside a *function*, which a + * line-range heuristic attributes to the wrong owner. + * + * ## Column order (frozen — schema v6 §2.4, 25 columns) + * + * **PK** `PY_TYPE_md5(pyModuleLinkHash ‖ qualifiedName ‖ name ‖ startLine ‖ endLine)` + */ +export class PyTypeRegistry implements EntityIdentifiable { + private name: string; + private qualifiedName: string; + private fileName: string; + private typeCategory: PythonTypeCategory; + private typeAccess: PythonTypeAccess; + private modifiers: Set; + private typePlacement: PythonTypePlacement; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private isExternal: boolean; + private pyModuleLinkHash: string; + private enclosingTypeLinkHash: string; + private enclosingMethodLinkHash: string; + private scopeLinkHash: string; + private classInitMethodLinkHash: string; + private declaringBindingLinkHash: string; + private metaclassName: string; + private baseCount: number; + private hasDynamicBase: boolean; + private mroKind: PythonMroKind; + private docstring: string; + private serviceVersionLinkHash: string; + private pyTypeUniqueHash: string = ''; + + private constructor(builder: PyTypeRegistryBuilder) { + this.name = builder.name; + this.qualifiedName = builder.qualifiedName; + this.fileName = builder.fileName; + this.typeCategory = builder.typeCategory; + this.typeAccess = builder.typeAccess; + this.modifiers = builder.modifiers; + this.typePlacement = builder.typePlacement; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.isExternal = builder.isExternal; + this.pyModuleLinkHash = builder.pyModuleLinkHash; + this.enclosingTypeLinkHash = builder.enclosingTypeLinkHash; + this.enclosingMethodLinkHash = builder.enclosingMethodLinkHash; + this.scopeLinkHash = builder.scopeLinkHash; + this.classInitMethodLinkHash = builder.classInitMethodLinkHash; + this.declaringBindingLinkHash = builder.declaringBindingLinkHash; + this.metaclassName = builder.metaclassName; + this.baseCount = builder.baseCount; + this.hasDynamicBase = builder.hasDynamicBase; + this.mroKind = builder.mroKind; + this.docstring = builder.docstring; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + name: string, + qualifiedName: string, + fileName: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ): PyTypeRegistryBuilder { + return new PyTypeRegistryBuilder( + name, + qualifiedName, + fileName, + filePath, + baseMservPath, + startLine, + endLine, + pyModuleLinkHash, + serviceVersionLinkHash + ); + } + + getName(): string { + return this.name; + } + + getQualifiedName(): string { + return this.qualifiedName; + } + + getTypeCategory(): PythonTypeCategory { + return this.typeCategory; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getPyModuleLinkHash(): string { + return this.pyModuleLinkHash; + } + + getScopeLinkHash(): string { + return this.scopeLinkHash; + } + + getModifiers(): Set { + return this.modifiers; + } + + /** Comma-separated modifiers for CSV output; `''` when none. */ + getTypeModifier(): string { + if (this.modifiers.size === 0) { + return ''; + } + return Array.from(this.modifiers).join(','); + } + + getMroKind(): PythonMroKind { + return this.mroKind; + } + + getDeclaringBindingLinkHash(): string { + return this.declaringBindingLinkHash; + } + + getEnclosingTypeLinkHash(): string { + return this.enclosingTypeLinkHash; + } + + getEnclosingMethodLinkHash(): string { + return this.enclosingMethodLinkHash; + } + + /** + * Refines the category after same-module base resolution. + * + * Safe to patch: `typeCategory` is not part of the primary key, which is + * derived from module, qualified name, name and line span. + */ + setTypeCategory(typeCategory: PythonTypeCategory): void { + this.typeCategory = typeCategory; + } + + /** Back-patches the synthetic `` initializer FK. */ + setClassInitMethodLinkHash(classInitMethodLinkHash: string): void { + this.classInitMethodLinkHash = classInitMethodLinkHash; + } + + /** Back-patches the FK to the binding this class name creates in its parent scope. */ + setDeclaringBindingLinkHash(declaringBindingLinkHash: string): void { + this.declaringBindingLinkHash = declaringBindingLinkHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getPyTypeUniqueHash(): string { + return this.pyTypeUniqueHash; + } + + getHash(): string { + return this.pyTypeUniqueHash; + } + + generateHash(): void { + const content = + this.pyModuleLinkHash + + '||' + + this.qualifiedName + + '||' + + this.name + + '||' + + this.startLine + + '||' + + this.endLine; + + this.pyTypeUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.PY_TYPE, + content + ); + } + + getEntryCombined(): string { + return `py_type[name=${this.name}, qname=${this.qualifiedName}, category=${this.typeCategory}, mro=${this.mroKind}, line ${this.startLine}, hash=${this.pyTypeUniqueHash}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.name), + EntityUtils.escapeTsv(this.qualifiedName), + EntityUtils.escapeTsv(this.fileName), + this.typeCategory, + this.typeAccess, + this.getTypeModifier(), + this.typePlacement, + EntityUtils.escapeTsv(this.filePath), + EntityUtils.escapeTsv(this.baseMservPath), + this.startLine.toString(), + this.endLine.toString(), + this.isExternal.toString(), + this.pyModuleLinkHash, + this.enclosingTypeLinkHash, + this.enclosingMethodLinkHash, + this.scopeLinkHash, + this.classInitMethodLinkHash, + this.declaringBindingLinkHash, + EntityUtils.escapeTsv(this.metaclassName), + this.baseCount.toString(), + this.hasDynamicBase.toString(), + this.mroKind, + EntityUtils.escapeTsv(this.docstring), + this.serviceVersionLinkHash, + this.pyTypeUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'qualifiedName', + 'fileName', + 'typeCategory', + 'typeAccess', + 'typeModifier', + 'typePlacement', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'isExternal', + 'pyModuleLinkHash', + 'enclosingTypeLinkHash', + 'enclosingMethodLinkHash', + 'scopeLinkHash', + 'classInitMethodLinkHash', + 'declaringBindingLinkHash', + 'metaclassName', + 'baseCount', + 'hasDynamicBase', + 'mroKind', + 'docstring', + 'serviceVersionLinkHash', + 'pyTypeUniqueHash', + ].join('\t'); + } +} + +/** Builder for PyTypeRegistry. `isExternal` is a parity slot, always false. */ +export class PyTypeRegistryBuilder { + name: string; + qualifiedName: string; + fileName: string; + typeCategory: PythonTypeCategory = PythonTypeCategory.CLASS_TYPE; + typeAccess: PythonTypeAccess = PythonTypeAccess.PUBLIC_ACCESS; + modifiers: Set = new Set(); + typePlacement: PythonTypePlacement = PythonTypePlacement.TOP_LEVEL_PLACEMENT; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + readonly isExternal: boolean = false; + pyModuleLinkHash: string; + enclosingTypeLinkHash: string = ''; + enclosingMethodLinkHash: string = ''; + scopeLinkHash: string = ''; + classInitMethodLinkHash: string = ''; + declaringBindingLinkHash: string = ''; + metaclassName: string = ''; + baseCount: number = 0; + hasDynamicBase: boolean = false; + mroKind: PythonMroKind = PythonMroKind.IMPLICIT_OBJECT; + docstring: string = ''; + serviceVersionLinkHash: string; + + constructor( + name: string, + qualifiedName: string, + fileName: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + pyModuleLinkHash: string, + serviceVersionLinkHash: string + ) { + if (!name || name.trim().length === 0) { + throw new Error('name is required'); + } + if (!pyModuleLinkHash || pyModuleLinkHash.trim().length === 0) { + throw new Error('pyModuleLinkHash is required'); + } + if (!serviceVersionLinkHash || serviceVersionLinkHash.trim().length === 0) { + throw new Error('serviceVersionLinkHash is required'); + } + if (startLine <= 0) { + throw new Error('startLine must be > 0'); + } + if (endLine < startLine) { + throw new Error('endLine must be >= startLine'); + } + + this.name = name; + this.qualifiedName = qualifiedName; + this.fileName = fileName; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.pyModuleLinkHash = pyModuleLinkHash; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withCategoryAndAccess( + typeCategory: PythonTypeCategory, + typeAccess: PythonTypeAccess + ): this { + this.typeCategory = typeCategory; + this.typeAccess = typeAccess; + return this; + } + + withModifiers(modifiers: PythonTypeModifier[]): this { + modifiers.forEach(m => this.modifiers.add(m)); + return this; + } + + withPlacement(typePlacement: PythonTypePlacement): this { + this.typePlacement = typePlacement; + return this; + } + + withEnclosing(enclosingTypeLinkHash: string, enclosingMethodLinkHash: string): this { + this.enclosingTypeLinkHash = enclosingTypeLinkHash; + this.enclosingMethodLinkHash = enclosingMethodLinkHash; + return this; + } + + withScopeLinkHash(scopeLinkHash: string): this { + this.scopeLinkHash = scopeLinkHash; + return this; + } + + /** + * Records the base list summary. + * + * `mroKind` is carried rather than re-derived because 19.5% of classes have no + * explicit base and 12.1% have more than one, so the engine must distinguish a + * trivial MRO from a real C3 linearisation from an unlinearisable dynamic base + * without walking `py_type_base` on every query. + */ + withBases( + baseCount: number, + hasDynamicBase: boolean, + mroKind: PythonMroKind, + metaclassName: string + ): this { + this.baseCount = baseCount; + this.hasDynamicBase = hasDynamicBase; + this.mroKind = mroKind; + this.metaclassName = metaclassName; + return this; + } + + withDocstring(docstring: string): this { + this.docstring = docstring; + return this; + } + + build(): PyTypeRegistry { + return new (PyTypeRegistry as unknown as { + new (builder: PyTypeRegistryBuilder): PyTypeRegistry; + })(this); + } +} diff --git a/parser/src/analysis-types/python/index.ts b/parser/src/analysis-types/python/index.ts new file mode 100644 index 000000000..86cf5e075 --- /dev/null +++ b/parser/src/analysis-types/python/index.ts @@ -0,0 +1,19 @@ +export { PyBindingRegistry, PyBindingRegistryBuilder } from '@/analysis-types/python/PyBindingRegistry'; +export { PyCallSiteRegistry, PyCallSiteRegistryBuilder } from '@/analysis-types/python/PyCallSiteRegistry'; +export { PyExpressionRegistry, PyExpressionRegistryBuilder } from '@/analysis-types/python/PyExpressionRegistry'; +export { PyBlockRegistry, PyBlockRegistryBuilder } from '@/analysis-types/python/PyBlockRegistry'; +export { PyCommentRegistry } from '@/analysis-types/python/PyCommentRegistry'; +export { PyDecoratorArgumentRegistry } from '@/analysis-types/python/PyDecoratorArgumentRegistry'; +export { PyDecoratorRegistry, PyDecoratorRegistryBuilder } from '@/analysis-types/python/PyDecoratorRegistry'; +export { PyFieldPositionRegistry } from '@/analysis-types/python/PyFieldPositionRegistry'; +export { PyFieldRegistry, PyFieldRegistryBuilder } from '@/analysis-types/python/PyFieldRegistry'; +export { PyImportRegistry, PyImportRegistryBuilder } from '@/analysis-types/python/PyImportRegistry'; +export { PyMethodParameterRegistry, PyMethodParameterRegistryBuilder } from '@/analysis-types/python/PyMethodParameterRegistry'; +export { PyMethodRegistry, PyMethodRegistryBuilder } from '@/analysis-types/python/PyMethodRegistry'; +export { PyModuleRegistry, PyModuleRegistryBuilder } from '@/analysis-types/python/PyModuleRegistry'; +export { PyScopeRegistry, PyScopeRegistryBuilder } from '@/analysis-types/python/PyScopeRegistry'; +export { PyTypeBaseRegistry, PyTypeBaseRegistryBuilder } from '@/analysis-types/python/PyTypeBaseRegistry'; +export { PyTypeReferenceRegistry, PyTypeReferenceRegistryBuilder } from '@/analysis-types/python/PyTypeReferenceRegistry'; +export { PyTypeRegistry, PyTypeRegistryBuilder } from '@/analysis-types/python/PyTypeRegistry'; +export { PyParseGapRegistry } from '@/analysis-types/python/PyParseGapRegistry'; +export { PyTypeParameterRegistry } from '@/analysis-types/python/PyTypeParameterRegistry'; diff --git a/parser/src/analysis-types/services/ServiceDescriptor.ts b/parser/src/analysis-types/services/ServiceDescriptor.ts new file mode 100644 index 000000000..b4e28f65a --- /dev/null +++ b/parser/src/analysis-types/services/ServiceDescriptor.ts @@ -0,0 +1,257 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One row per `META-INF/services/` provider-configuration file, + * and the root of the service-loader key chain. + * + * The file NAME is the fact this row exists to carry: `ServiceLoader` locates a + * provider-configuration file by the binary name of the service it configures, + * so `META-INF/services/org.acme.Codec` says "these are the providers of + * `org.acme.Codec`" without any of that appearing in Java source. Every + * `ServiceProvider` row chains off this hash, because a provider class name on + * its own does not say WHAT it provides — the same class may be named in two + * files for two different services. + * + * `providerCount` is what lets a consumer tell an empty descriptor from an + * unanalysed one, and the distinction is load-bearing here: a file holding + * nothing but an Apache licence header is a real, correctly-parsed descriptor + * that declares zero providers, and it must not read as a parse failure. On one + * corpus 2,839 of 3,736 lines across 272 such files were comment lines, so an + * extractor that reported line counts instead of provider counts would report + * mostly licence text. + * + * ## CSV Export Format + * + * Column order: + * 1. serviceInterface, serviceInterfaceBinaryName, simpleName, packageName + * 2. isNestedServiceName, isWellFormedServiceName + * 3. providerCount, wellFormedProviderCount, lineCount + * 4. relativePath, filePath, baseMservPath + * 5. serviceVersionLinkHash + * 6. serviceDescriptorUniqueHash (LAST) + */ +export class ServiceDescriptor implements EntityIdentifiable { + private serviceInterface: string; + private serviceInterfaceBinaryName: string; + private simpleName: string; + private packageName: string; + private isNestedServiceName: boolean; + private isWellFormedServiceName: boolean; + private providerCount: number = 0; + private wellFormedProviderCount: number = 0; + private lineCount: number = 0; + private relativePath: string; + private filePath: string; + private baseMservPath: string; + private serviceVersionLinkHash: string; + private serviceDescriptorUniqueHash: string = ''; + + private constructor(builder: ServiceDescriptorBuilder) { + this.serviceInterface = builder.serviceInterface; + this.serviceInterfaceBinaryName = builder.serviceInterfaceBinaryName; + this.simpleName = builder.simpleName; + this.packageName = builder.packageName; + this.isNestedServiceName = builder.isNestedServiceName; + this.isWellFormedServiceName = builder.isWellFormedServiceName; + this.providerCount = builder.providerCount; + this.wellFormedProviderCount = builder.wellFormedProviderCount; + this.lineCount = builder.lineCount; + this.relativePath = builder.relativePath; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + serviceInterfaceBinaryName: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): ServiceDescriptorBuilder { + return new ServiceDescriptorBuilder( + serviceInterfaceBinaryName, + filePath, + baseMservPath, + serviceVersionLinkHash + ); + } + + getServiceInterface(): string { return this.serviceInterface; } + getServiceInterfaceBinaryName(): string { return this.serviceInterfaceBinaryName; } + getSimpleName(): string { return this.simpleName; } + getPackageName(): string { return this.packageName; } + getIsNestedServiceName(): boolean { return this.isNestedServiceName; } + getIsWellFormedServiceName(): boolean { return this.isWellFormedServiceName; } + getProviderCount(): number { return this.providerCount; } + getWellFormedProviderCount(): number { return this.wellFormedProviderCount; } + getLineCount(): number { return this.lineCount; } + getRelativePath(): string { return this.relativePath; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + getServiceDescriptorUniqueHash(): string { return this.serviceDescriptorUniqueHash; } + + getHash(): string { + return this.serviceDescriptorUniqueHash; + } + + /** + * Keyed on the file, not on the service name. + * + * Two modules may each ship a `META-INF/services/org.acme.Codec`, and both are + * real and separately citable; keying on the service name would collapse them + * into one row and lose half the providers in the corpus. + */ + generateHash(): void { + const content = + this.filePath + + '||' + this.baseMservPath + + '||' + this.serviceVersionLinkHash; + + this.serviceDescriptorUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.SERVICE_DESCRIPTOR, + content + ); + } + + /** + * Records the provider tallies after the provider rows have been built. + * + * Safe to set post-construction, and deliberately does NOT regenerate the + * hash: the key is derived from the file's identity alone, so the counts can + * never move it. The alternative — building the descriptor last — is what + * forces a second hash derivation for the providers to chain off, and two + * places deriving one key is how the two drift apart. + */ + setProviderCounts(providerCount: number, wellFormedProviderCount: number): void { + this.providerCount = providerCount; + this.wellFormedProviderCount = wellFormedProviderCount; + } + + getEntryCombined(): string { + return `service_descriptor[service=${this.serviceInterface}, providers=${this.providerCount}, file=${this.relativePath}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.serviceInterface), + EntityUtils.escapeTsv(this.serviceInterfaceBinaryName), + EntityUtils.escapeTsv(this.simpleName), + EntityUtils.escapeTsv(this.packageName), + this.isNestedServiceName.toString(), + this.isWellFormedServiceName.toString(), + this.providerCount.toString(), + this.wellFormedProviderCount.toString(), + this.lineCount.toString(), + EntityUtils.escapeTsv(this.relativePath), + this.filePath, + this.baseMservPath, + this.serviceVersionLinkHash, + this.serviceDescriptorUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'serviceInterface', + 'serviceInterfaceBinaryName', + 'simpleName', + 'packageName', + 'isNestedServiceName', + 'isWellFormedServiceName', + 'providerCount', + 'wellFormedProviderCount', + 'lineCount', + 'relativePath', + 'filePath', + 'baseMservPath', + 'serviceVersionLinkHash', + 'serviceDescriptorUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for ServiceDescriptor + */ +class ServiceDescriptorBuilder { + serviceInterface: string; + serviceInterfaceBinaryName: string; + simpleName: string = ''; + packageName: string = ''; + isNestedServiceName: boolean = false; + isWellFormedServiceName: boolean = false; + providerCount: number = 0; + wellFormedProviderCount: number = 0; + lineCount: number = 0; + relativePath: string = ''; + filePath: string; + baseMservPath: string; + serviceVersionLinkHash: string; + + constructor( + serviceInterfaceBinaryName: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ) { + this.serviceInterfaceBinaryName = serviceInterfaceBinaryName; + this.serviceInterface = serviceInterfaceBinaryName; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withServiceInterface(serviceInterface: string): ServiceDescriptorBuilder { + this.serviceInterface = serviceInterface; + return this; + } + + withSimpleName(simpleName: string): ServiceDescriptorBuilder { + this.simpleName = simpleName; + return this; + } + + withPackageName(packageName: string): ServiceDescriptorBuilder { + this.packageName = packageName; + return this; + } + + withIsNestedServiceName(isNested: boolean): ServiceDescriptorBuilder { + this.isNestedServiceName = isNested; + return this; + } + + withIsWellFormedServiceName(isWellFormed: boolean): ServiceDescriptorBuilder { + this.isWellFormedServiceName = isWellFormed; + return this; + } + + withProviderCount(providerCount: number): ServiceDescriptorBuilder { + this.providerCount = providerCount; + return this; + } + + withWellFormedProviderCount(count: number): ServiceDescriptorBuilder { + this.wellFormedProviderCount = count; + return this; + } + + withLineCount(lineCount: number): ServiceDescriptorBuilder { + this.lineCount = lineCount; + return this; + } + + withRelativePath(relativePath: string): ServiceDescriptorBuilder { + this.relativePath = relativePath; + return this; + } + + build(): ServiceDescriptor { + return new (ServiceDescriptor as any)(this); + } +} diff --git a/parser/src/analysis-types/services/ServiceProvider.ts b/parser/src/analysis-types/services/ServiceProvider.ts new file mode 100644 index 000000000..73b358dbc --- /dev/null +++ b/parser/src/analysis-types/services/ServiceProvider.ts @@ -0,0 +1,311 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One row per provider class named on a line of a `META-INF/services` file. + * + * This is the written-down half of reflection. `ServiceLoader.load(Codec.class)` + * is opaque because the LOOKUP is dynamic, but the class it instantiates is a + * literal in a file in the repository, so the instantiation is statically + * knowable. The platform also calls the provider's no-arg constructor and its + * interface methods, so without these rows a provider reads as a dead root. + * + * ## Why the name appears three times + * + * `providerClassBinaryName` is the token exactly as written — the only column a + * consumer can quote back to the user. `providerClass` is the same name with + * nested-type separators normalised to dots, which is the form every other + * relation in this schema uses for a qualified name, so it is the column a join + * can actually use. `simpleName` is the innermost segment. + * + * They differ, and the difference is the whole reason this belongs in the parser + * rather than in each consumer's own `split('.')`: `org.acme.Outer$Inner` names + * a nested class whose dotted form is `org.acme.Outer.Inner`, its enclosing type + * is `org.acme.Outer`, its package is `org.acme` and its simple name is `Inner`. + * A consumer splitting on `.` gets the package wrong and the simple name right + * by accident; one splitting on `$` unconditionally turns the synthetic name + * `Outer$1` into the non-name `Outer.1`. + * + * ## Malformed names are emitted, not dropped + * + * `isWellFormedName` is false when the token is not a legal binary name. + * Dropping those rows would be the wrong call twice over: a consumer that wants + * the instantiation set can filter on the flag, and a consumer that wants to + * report a broken configuration needs the row to exist. The population is real + * — Arquillian writes `!org.jboss.arquillian.container.impl.ContainerExtension` + * in a services file to mean "suppress this extension", which is not a class + * name at all and would make `ServiceLoader` throw if the JDK were reading it. + * + * ## CSV Export Format + * + * Column order: + * 1. providerClass, providerClassBinaryName, simpleName, packageName, + * enclosingTypeName + * 2. isNestedName, isWellFormedName, isDuplicateInFile, hasInlineComment + * 3. position, startLine, endLine, startCol, endCol + * 4. serviceDescriptorLinkHash + * 5. filePath, baseMservPath, serviceVersionLinkHash + * 6. serviceProviderUniqueHash (LAST) + */ +export class ServiceProvider implements EntityIdentifiable { + private providerClass: string; + private providerClassBinaryName: string; + private simpleName: string; + private packageName: string; + private enclosingTypeName: string; + private isNestedName: boolean; + private isWellFormedName: boolean; + private isDuplicateInFile: boolean; + private hasInlineComment: boolean; + private position: number; + private startLine: number; + private endLine: number; + private startCol: number; + private endCol: number; + private serviceDescriptorLinkHash: string; + private filePath: string; + private baseMservPath: string; + private serviceVersionLinkHash: string; + private serviceProviderUniqueHash: string = ''; + + private constructor(builder: ServiceProviderBuilder) { + this.providerClass = builder.providerClass; + this.providerClassBinaryName = builder.providerClassBinaryName; + this.simpleName = builder.simpleName; + this.packageName = builder.packageName; + this.enclosingTypeName = builder.enclosingTypeName; + this.isNestedName = builder.isNestedName; + this.isWellFormedName = builder.isWellFormedName; + this.isDuplicateInFile = builder.isDuplicateInFile; + this.hasInlineComment = builder.hasInlineComment; + this.position = builder.position; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startCol = builder.startCol; + this.endCol = builder.endCol; + this.serviceDescriptorLinkHash = builder.serviceDescriptorLinkHash; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + providerClassBinaryName: string, + position: number, + startLine: number, + startCol: number, + endCol: number, + serviceDescriptorLinkHash: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): ServiceProviderBuilder { + return new ServiceProviderBuilder( + providerClassBinaryName, + position, + startLine, + startCol, + endCol, + serviceDescriptorLinkHash, + filePath, + baseMservPath, + serviceVersionLinkHash + ); + } + + getProviderClass(): string { return this.providerClass; } + getProviderClassBinaryName(): string { return this.providerClassBinaryName; } + getSimpleName(): string { return this.simpleName; } + getPackageName(): string { return this.packageName; } + getEnclosingTypeName(): string { return this.enclosingTypeName; } + getIsNestedName(): boolean { return this.isNestedName; } + getIsWellFormedName(): boolean { return this.isWellFormedName; } + getIsDuplicateInFile(): boolean { return this.isDuplicateInFile; } + getHasInlineComment(): boolean { return this.hasInlineComment; } + getPosition(): number { return this.position; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getStartCol(): number { return this.startCol; } + getEndCol(): number { return this.endCol; } + getServiceDescriptorLinkHash(): string { return this.serviceDescriptorLinkHash; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + getServiceProviderUniqueHash(): string { return this.serviceProviderUniqueHash; } + + getHash(): string { + return this.serviceProviderUniqueHash; + } + + /** + * Chains off the descriptor hash and mixes in the LINE, not the name. + * + * `ServiceLoader` ignores a class named twice in the same file, but both + * occurrences are separately citable and a key built from the name alone would + * collide between them — turning a duplicate that a consumer should be able to + * report into a row that silently overwrites its twin. + */ + generateHash(): void { + const content = + this.serviceDescriptorLinkHash + + '||' + this.providerClassBinaryName + + '||' + this.startLine + + '||' + this.startCol; + + this.serviceProviderUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.SERVICE_PROVIDER, + content + ); + } + + getEntryCombined(): string { + return `service_provider[class=${this.providerClass}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.providerClass), + EntityUtils.escapeTsv(this.providerClassBinaryName), + EntityUtils.escapeTsv(this.simpleName), + EntityUtils.escapeTsv(this.packageName), + EntityUtils.escapeTsv(this.enclosingTypeName), + this.isNestedName.toString(), + this.isWellFormedName.toString(), + this.isDuplicateInFile.toString(), + this.hasInlineComment.toString(), + this.position.toString(), + this.startLine.toString(), + this.endLine.toString(), + this.startCol.toString(), + this.endCol.toString(), + this.serviceDescriptorLinkHash, + this.filePath, + this.baseMservPath, + this.serviceVersionLinkHash, + this.serviceProviderUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'providerClass', + 'providerClassBinaryName', + 'simpleName', + 'packageName', + 'enclosingTypeName', + 'isNestedName', + 'isWellFormedName', + 'isDuplicateInFile', + 'hasInlineComment', + 'position', + 'startLine', + 'endLine', + 'startCol', + 'endCol', + 'serviceDescriptorLinkHash', + 'filePath', + 'baseMservPath', + 'serviceVersionLinkHash', + 'serviceProviderUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for ServiceProvider + */ +class ServiceProviderBuilder { + providerClass: string; + providerClassBinaryName: string; + simpleName: string = ''; + packageName: string = ''; + enclosingTypeName: string = ''; + isNestedName: boolean = false; + isWellFormedName: boolean = false; + isDuplicateInFile: boolean = false; + hasInlineComment: boolean = false; + position: number; + startLine: number; + endLine: number; + startCol: number; + endCol: number; + serviceDescriptorLinkHash: string; + filePath: string; + baseMservPath: string; + serviceVersionLinkHash: string; + + constructor( + providerClassBinaryName: string, + position: number, + startLine: number, + startCol: number, + endCol: number, + serviceDescriptorLinkHash: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ) { + this.providerClassBinaryName = providerClassBinaryName; + this.providerClass = providerClassBinaryName; + this.position = position; + this.startLine = startLine; + // A provider name is always one line: `ServiceLoader` has no continuation + // syntax. `endLine` is carried anyway so the column shape matches every + // other positioned relation and a consumer's span query needs no special + // case for this table. + this.endLine = startLine; + this.startCol = startCol; + this.endCol = endCol; + this.serviceDescriptorLinkHash = serviceDescriptorLinkHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withProviderClass(providerClass: string): ServiceProviderBuilder { + this.providerClass = providerClass; + return this; + } + + withSimpleName(simpleName: string): ServiceProviderBuilder { + this.simpleName = simpleName; + return this; + } + + withPackageName(packageName: string): ServiceProviderBuilder { + this.packageName = packageName; + return this; + } + + withEnclosingTypeName(enclosingTypeName: string): ServiceProviderBuilder { + this.enclosingTypeName = enclosingTypeName; + return this; + } + + withIsNestedName(isNestedName: boolean): ServiceProviderBuilder { + this.isNestedName = isNestedName; + return this; + } + + withIsWellFormedName(isWellFormedName: boolean): ServiceProviderBuilder { + this.isWellFormedName = isWellFormedName; + return this; + } + + withIsDuplicateInFile(isDuplicateInFile: boolean): ServiceProviderBuilder { + this.isDuplicateInFile = isDuplicateInFile; + return this; + } + + withHasInlineComment(hasInlineComment: boolean): ServiceProviderBuilder { + this.hasInlineComment = hasInlineComment; + return this; + } + + build(): ServiceProvider { + return new (ServiceProvider as any)(this); + } +} diff --git a/parser/src/analysis-types/typescript/TsBlockRegistry.ts b/parser/src/analysis-types/typescript/TsBlockRegistry.ts new file mode 100644 index 000000000..742e69e9c --- /dev/null +++ b/parser/src/analysis-types/typescript/TsBlockRegistry.ts @@ -0,0 +1,170 @@ +import { ABSENT, commaSet, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsBlockKind } from '@/enums/typescript/blocks'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A statement block — schema §4.16, 21 columns. Positions 0–17 mirror + * `java_block` 0–17. + * + * Blocks earn their relation twice here. Caller attribution, as in Java — and + * as the **lexical scope of a `let`/`const`**, which is what lets `ts_variable` + * exist without a `ts_scope` relation. OQ-6 measured that: 34,798 identifier + * references, and the `ts_block -> ts_method -> ts_type -> ts_module` chain + * reaches every one of them with zero unreachable. That chain is only unbroken + * if these rows exist, so emitting them is what makes "no `ts_scope`" true + * rather than merely asserted. + * + * {@link caughtExceptionTypes} is near-always `""` and that is not a gap: a + * TypeScript `catch` binding is `unknown` (or `any`) and cannot be typed, so + * unlike Java there is nothing to record. The column is kept for parity, not + * for information. + */ +export class TsBlockRegistry implements EntityIdentifiable { + static readonly ARITY = 21; + + readonly blockKind: TsBlockKind; + readonly order: number; + readonly filePath: string; + readonly startLine: number; + readonly endLine: number; + readonly startColumn: number; + readonly endColumn: number; + readonly nestingDepth: number; + readonly tsTypeLinkHash: string; + readonly methodOwnerHash: string; + readonly parentContainerHash: string; + readonly tryStatementHash: string; + readonly resourceCount: number; + readonly caughtExceptionTypes: ReadonlySet; + readonly ownerTypeName: string; + readonly ownerQualifiedName: string; + readonly ownerMethodName: string; + private conditionExpressionLinkHash = ABSENT; + readonly tsModuleLinkHash: string; + readonly serviceVersionLinkHash: string; + private tsBlockUniqueHash = ABSENT; + + constructor(props: { + blockKind: TsBlockKind; + order: number; + filePath: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + nestingDepth: number; + tsTypeLinkHash: string; + methodOwnerHash: string; + parentContainerHash: string; + tryStatementHash: string; + resourceCount: number; + caughtExceptionTypes: ReadonlySet; + ownerTypeName: string; + ownerQualifiedName: string; + ownerMethodName: string; + tsModuleLinkHash: string; + serviceVersionLinkHash: string; + }) { + this.blockKind = props.blockKind; + this.order = props.order; + this.filePath = props.filePath; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.startColumn = props.startColumn; + this.endColumn = props.endColumn; + this.nestingDepth = props.nestingDepth; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.methodOwnerHash = props.methodOwnerHash; + this.parentContainerHash = props.parentContainerHash; + this.tryStatementHash = props.tryStatementHash; + this.resourceCount = props.resourceCount; + this.caughtExceptionTypes = props.caughtExceptionTypes; + this.ownerTypeName = props.ownerTypeName; + this.ownerQualifiedName = props.ownerQualifiedName; + this.ownerMethodName = props.ownerMethodName; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_BLOCK_md5(methodOwnerHash ‖ parentContainerHash ‖ blockKind ‖ order ‖ startLine ‖ startColumn)` */ + generateHash(): void { + this.tsBlockUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_BLOCK, + keyOf( + this.methodOwnerHash, + this.parentContainerHash, + this.blockKind, + this.order, + this.startLine, + this.startColumn + ) + ); + } + + getHash(): string { + return this.tsBlockUniqueHash; + } + + /** + * The guard expression, so the engine can narrow on it. + * + * `typeof x === "string"`, `x instanceof C` and a type-predicate call (440 + * predicates measured) all reach the receiver through this FK. Back-patched + * because expressions are extracted after the block tree exists. + */ + setConditionExpressionLinkHash(hash: string): void { + this.conditionExpressionLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_block[kind=${this.blockKind}, order=${this.order}, line=${this.startLine}, hash=${this.tsBlockUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.blockKind, + num(this.order), + text(this.filePath), + num(this.startLine), + num(this.endLine), + num(this.startColumn), + num(this.endColumn), + num(this.nestingDepth), + this.tsTypeLinkHash, + this.methodOwnerHash, + this.parentContainerHash, + this.tryStatementHash, + num(this.resourceCount), + commaSet(this.caughtExceptionTypes), + text(this.ownerTypeName), + text(this.ownerQualifiedName), + text(this.ownerMethodName), + this.conditionExpressionLinkHash, + this.tsModuleLinkHash, + this.serviceVersionLinkHash, + this.tsBlockUniqueHash, + ], + TsBlockRegistry.ARITY, + 'ts_block' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'blockKind', 'order', 'filePath', 'startLine', 'endLine', 'startColumn', 'endColumn', + 'nestingDepth', 'tsTypeLinkHash', 'methodOwnerHash', 'parentContainerHash', + 'tryStatementHash', 'resourceCount', 'caughtExceptionTypes', 'ownerTypeName', + 'ownerQualifiedName', 'ownerMethodName', 'conditionExpressionLinkHash', + 'tsModuleLinkHash', 'serviceVersionLinkHash', 'tsBlockUniqueHash', + ], + TsBlockRegistry.ARITY, + 'ts_block' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsCallSiteRegistry.ts b/parser/src/analysis-types/typescript/TsCallSiteRegistry.ts new file mode 100644 index 000000000..9b42f6eff --- /dev/null +++ b/parser/src/analysis-types/typescript/TsCallSiteRegistry.ts @@ -0,0 +1,251 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, optionalNum, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsCallKind, + TsReceiverKind, + TsResolutionEvidence, + TsResolvedTargetKind, +} from '@/enums/typescript/call-sites'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A call site — schema §4.15, 25 columns. 1:1 with its CALL / NEW / + * TAGGED_TEMPLATE expression, so the key is a pure chain off `ts_expression`. + * + * ## The flagship gate lives on this relation + * + * 9,627 of 9,627 measured call sites have a `getResolvedSignature` answer, so + * the closed-world ceiling here is a real 100% and every link is checkable + * against the reference implementation. Python's 51% receiver-typing gap has no + * analogue. + * + * ## The parser fills columns 12–18 only where resolution is SYNTACTICALLY decidable + * + * A call to an imported name with a single signature; a call on a receiver whose + * declared type is a locally declared class with one method of that name; a + * `super.m()`. Everything else is left `""` / `UNRESOLVED` for the engine, and + * that is a deliberate asymmetry rather than modesty: a parser-filled value that + * disagrees with `getResolvedSignature` is a hard failure, while an unfilled one + * becomes a measured rate. The parser never guesses, and the guess it declines + * to make turns into a number rather than a silence. + * + * {@link resolvedSignatureLinkHash} points at ONE SIGNATURE, never at a name. + * 77.6% of overloaded calls resolve to a non-first declaration, so a + * name-shaped answer is wrong four times in five. + * + * {@link isTypeOnlyTarget} must **always** be `false`. A `true` row means a + * type-only construct reached the call graph, and the gate fails on it by name. + */ +export class TsCallSiteRegistry implements EntityIdentifiable { + static readonly ARITY = 25; + + readonly callKind: TsCallKind; + readonly calleeName: string; + readonly receiverKind: TsReceiverKind; + readonly receiverExpressionLinkHash: string; + /** The receiver's DECLARED type name, filled by the resolution pass. */ + private receiverTypeName: string; + readonly tsExpressionLinkHash: string; + readonly tsModuleLinkHash: string; + readonly callerMethodLinkHash: string; + readonly callerTypeLinkHash: string; + readonly argumentCount: number; + readonly spreadArgumentIndex: number | undefined; + readonly typeArgumentCount: number; + + private resolvedSignatureLinkHash = ABSENT; + private resolvedGroupKey = ABSENT; + private resolvedTargetKind: TsResolvedTargetKind = TsResolvedTargetKind.UNRESOLVED; + private resolvedOverloadIndex: number | undefined = undefined; + private overloadCandidateCount = 0; + private isOverloadResolved = false; + private resolutionEvidence: TsResolutionEvidence = TsResolutionEvidence.NONE; + private isAmbientTarget = false; + + readonly isTypeOnlyTarget: boolean; + readonly startLine: number; + readonly startColumn: number; + readonly serviceVersionLinkHash: string; + private tsCallSiteUniqueHash = ABSENT; + + constructor(props: { + callKind: TsCallKind; + calleeName: string; + receiverKind: TsReceiverKind; + receiverExpressionLinkHash: string; + receiverTypeName: string; + tsExpressionLinkHash: string; + tsModuleLinkHash: string; + callerMethodLinkHash: string; + callerTypeLinkHash: string; + argumentCount: number; + spreadArgumentIndex: number | undefined; + typeArgumentCount: number; + isTypeOnlyTarget: boolean; + startLine: number; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.callKind = props.callKind; + this.calleeName = props.calleeName; + this.receiverKind = props.receiverKind; + this.receiverExpressionLinkHash = props.receiverExpressionLinkHash; + this.receiverTypeName = props.receiverTypeName; + this.tsExpressionLinkHash = props.tsExpressionLinkHash; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.callerMethodLinkHash = props.callerMethodLinkHash; + this.callerTypeLinkHash = props.callerTypeLinkHash; + this.argumentCount = props.argumentCount; + this.spreadArgumentIndex = props.spreadArgumentIndex; + this.typeArgumentCount = props.typeArgumentCount; + this.isTypeOnlyTarget = props.isTypeOnlyTarget; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_CALL_SITE_md5(tsExpressionLinkHash)` — a pure chain; a call site IS an expression. */ + generateHash(): void { + this.tsCallSiteUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_CALL_SITE, + keyOf(this.tsExpressionLinkHash) + ); + } + + getHash(): string { + return this.tsCallSiteUniqueHash; + } + + /** + * Records a target the parser can defend from syntax. + * + * `overloadCandidateCount` is the size of the set that was CONSIDERED, so a + * count of 1 with a filled target means there was nothing to choose between — + * and a count above 1 with `isOverloadResolved = false` means the parser saw + * a real overload set and declined to pick, which is the honest outcome when + * choosing needs argument types. + */ + setResolution(props: { + resolvedSignatureLinkHash: string; + resolvedGroupKey: string; + resolvedTargetKind: TsResolvedTargetKind; + resolvedOverloadIndex: number | undefined; + overloadCandidateCount: number; + isOverloadResolved: boolean; + resolutionEvidence: TsResolutionEvidence; + isAmbientTarget: boolean; + }): void { + this.resolvedSignatureLinkHash = props.resolvedSignatureLinkHash; + this.resolvedGroupKey = props.resolvedGroupKey; + this.resolvedTargetKind = props.resolvedTargetKind; + this.resolvedOverloadIndex = props.resolvedOverloadIndex; + this.overloadCandidateCount = props.overloadCandidateCount; + this.isOverloadResolved = props.isOverloadResolved; + this.resolutionEvidence = props.resolutionEvidence; + this.isAmbientTarget = props.isAmbientTarget; + } + + /** + * Records that the target is knowably outside this analysis. + * + * Distinct from leaving the row `UNRESOLVED`: `LIB_SIGNATURE` says the call + * has a target and it lives in `lib_ts_*`, which the closed-world gate counts + * as CLOSED. `UNRESOLVED` says the parser does not know, which it does not. + */ + setExternalTarget(kind: TsResolvedTargetKind, evidence: TsResolutionEvidence): void { + this.resolvedTargetKind = kind; + this.resolutionEvidence = evidence; + this.isAmbientTarget = true; + } + + /** + * The receiver's DECLARED type name. + * + * Declared, never inferred: this is the annotation a declaration site wrote, + * which is the whole mechanism Path 1 runs on and the reason 85.3% annotation + * coverage makes this schema Java-shaped. An inferred value here would be the + * checker's answer, and the parser does not have one. + */ + setReceiverTypeName(name: string): void { + this.receiverTypeName = name; + } + + getResolvedTargetKind(): TsResolvedTargetKind { + return this.resolvedTargetKind; + } + + getResolvedSignatureLinkHash(): string { + return this.resolvedSignatureLinkHash; + } + + getResolutionEvidence(): TsResolutionEvidence { + return this.resolutionEvidence; + } + + /** + * The size of the overload set that was CONSIDERED. + * + * Above 1 with an empty target means a real set was seen and none was picked — + * an honest outcome that a rule can tell apart from "nothing was found". + */ + getOverloadCandidateCount(): number { + return this.overloadCandidateCount; + } + + getEntryCombined(): string { + return `ts_call_site[kind=${this.callKind}, callee=${this.calleeName}, receiver=${this.receiverKind}, target=${this.resolvedTargetKind}, hash=${this.tsCallSiteUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.callKind, + text(this.calleeName), + this.receiverKind, + this.receiverExpressionLinkHash, + text(this.receiverTypeName), + this.tsExpressionLinkHash, + this.tsModuleLinkHash, + this.callerMethodLinkHash, + this.callerTypeLinkHash, + num(this.argumentCount), + optionalNum(this.spreadArgumentIndex), + num(this.typeArgumentCount), + this.resolvedSignatureLinkHash, + this.resolvedGroupKey, + this.resolvedTargetKind, + optionalNum(this.resolvedOverloadIndex), + num(this.overloadCandidateCount), + bool(this.isOverloadResolved), + this.resolutionEvidence, + bool(this.isTypeOnlyTarget), + bool(this.isAmbientTarget), + num(this.startLine), + num(this.startColumn), + this.serviceVersionLinkHash, + this.tsCallSiteUniqueHash, + ], + TsCallSiteRegistry.ARITY, + 'ts_call_site' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'callKind', 'calleeName', 'receiverKind', 'receiverExpressionLinkHash', + 'receiverTypeName', 'tsExpressionLinkHash', 'tsModuleLinkHash', 'callerMethodLinkHash', + 'callerTypeLinkHash', 'argumentCount', 'spreadArgumentIndex', 'typeArgumentCount', + 'resolvedSignatureLinkHash', 'resolvedGroupKey', 'resolvedTargetKind', + 'resolvedOverloadIndex', 'overloadCandidateCount', 'isOverloadResolved', + 'resolutionEvidence', 'isTypeOnlyTarget', 'isAmbientTarget', 'startLine', 'startColumn', + 'serviceVersionLinkHash', 'tsCallSiteUniqueHash', + ], + TsCallSiteRegistry.ARITY, + 'ts_call_site' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsCommentRegistry.ts b/parser/src/analysis-types/typescript/TsCommentRegistry.ts new file mode 100644 index 000000000..a4a1bc689 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsCommentRegistry.ts @@ -0,0 +1,125 @@ +import { ABSENT, commaSet, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsCommentKind, TsDirectiveKind } from '@/enums/typescript/comments'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A comment, JSDoc block, triple-slash directive or ts-directive — + * schema §4.17, 14 columns. Positions 0–9 mirror `java_comment`. + * + * ## Two of the five kinds are not commentary + * + * A `/// ` is a MODULE EDGE — in ambient code it is often the only + * edge a file has — and a `@ts-ignore` SUPPRESSES A DIAGNOSTIC. Both change what + * the program means, so both are recorded with a `directiveKind` rather than as + * prose. A fact base that treats them as text loses a dependency and a + * suppression. + * + * ## JSDoc carries semantics even in `.ts` + * + * `@deprecated`, `@internal` and `@template` are read by tooling and by people, + * and `@ts-expect-error` is read by the compiler. `jsDocTags` is a comma-set of + * the tag names present so a rule can filter without re-parsing the text. + */ +export class TsCommentRegistry implements EntityIdentifiable { + static readonly ARITY = 14; + + readonly commentKind: TsCommentKind; + readonly commentText: string; + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + readonly endColumn: number; + readonly ownerHash: string; + readonly commentIndex: number; + readonly filePath: string; + readonly tsModuleLinkHash: string; + readonly jsDocTags: ReadonlySet; + readonly directiveKind: TsDirectiveKind | ''; + readonly serviceVersionLinkHash: string; + private tsCommentUniqueHash = ABSENT; + + constructor(props: { + commentKind: TsCommentKind; + commentText: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + ownerHash: string; + commentIndex: number; + filePath: string; + tsModuleLinkHash: string; + jsDocTags: ReadonlySet; + directiveKind: TsDirectiveKind | ''; + serviceVersionLinkHash: string; + }) { + this.commentKind = props.commentKind; + this.commentText = props.commentText; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.endLine = props.endLine; + this.endColumn = props.endColumn; + this.ownerHash = props.ownerHash; + this.commentIndex = props.commentIndex; + this.filePath = props.filePath; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.jsDocTags = props.jsDocTags; + this.directiveKind = props.directiveKind; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_COMMENT_md5(filePath ‖ startLine ‖ startColumn ‖ commentIndex)` */ + generateHash(): void { + this.tsCommentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_COMMENT, + keyOf(this.filePath, this.startLine, this.startColumn, this.commentIndex) + ); + } + + getHash(): string { + return this.tsCommentUniqueHash; + } + + getEntryCombined(): string { + return `ts_comment[kind=${this.commentKind}, line=${this.startLine}, directive=${this.directiveKind}, hash=${this.tsCommentUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.commentKind, + text(this.commentText), + num(this.startLine), + num(this.startColumn), + num(this.endLine), + num(this.endColumn), + this.ownerHash, + num(this.commentIndex), + text(this.filePath), + this.tsModuleLinkHash, + commaSet(this.jsDocTags), + this.directiveKind, + this.serviceVersionLinkHash, + this.tsCommentUniqueHash, + ], + TsCommentRegistry.ARITY, + 'ts_comment' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'commentKind', 'commentText', 'startLine', 'startColumn', 'endLine', 'endColumn', + 'ownerHash', 'commentIndex', 'filePath', 'tsModuleLinkHash', 'jsDocTags', + 'directiveKind', 'serviceVersionLinkHash', 'tsCommentUniqueHash', + ], + TsCommentRegistry.ARITY, + 'ts_comment' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsDecoratorArgumentRegistry.ts b/parser/src/analysis-types/typescript/TsDecoratorArgumentRegistry.ts new file mode 100644 index 000000000..b83d13334 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsDecoratorArgumentRegistry.ts @@ -0,0 +1,117 @@ +import { ABSENT, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsDecoratorArgumentValueType } from '@/enums/typescript/decorators'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A decorator argument — schema §4.19, 13 columns. Positions 0–10 mirror + * `java_annotation_argument` 0–10. + * + * This is where framework routes and DI tokens live: `@Get("/users/:id")`, + * `@Inject(UserRepository)`, `@Column({ type: "varchar" })`. It is the direct + * analogue of the Java relation that CWE detection already keys on for + * `@RequestMapping` and `@RequestParam`, which is why {@link valueType} carries + * `CLASS_REFERENCE` as a distinct member rather than folding it into + * `IDENTIFIER`. + */ +export class TsDecoratorArgumentRegistry implements EntityIdentifiable { + static readonly ARITY = 13; + + readonly argumentName: string; + readonly argumentValue: string; + readonly valueType: TsDecoratorArgumentValueType; + readonly position: number; + readonly parentDecoratorHash: string; + private referencedTypeHash = ABSENT; + readonly nestedObjectHash: string; + readonly arrayIndex: number; + readonly startLine: number; + readonly endLine: number; + readonly tsExpressionLinkHash: string; + readonly serviceVersionLinkHash: string; + private tsDecoratorArgumentUniqueHash = ABSENT; + + constructor(props: { + argumentName: string; + argumentValue: string; + valueType: TsDecoratorArgumentValueType; + position: number; + parentDecoratorHash: string; + nestedObjectHash: string; + arrayIndex: number; + startLine: number; + endLine: number; + tsExpressionLinkHash: string; + serviceVersionLinkHash: string; + }) { + this.argumentName = props.argumentName; + this.argumentValue = props.argumentValue; + this.valueType = props.valueType; + this.position = props.position; + this.parentDecoratorHash = props.parentDecoratorHash; + this.nestedObjectHash = props.nestedObjectHash; + this.arrayIndex = props.arrayIndex; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.tsExpressionLinkHash = props.tsExpressionLinkHash; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_DECORATOR_ARGUMENT_md5(parentDecoratorHash ‖ position ‖ argumentName ‖ arrayIndex)` */ + generateHash(): void { + this.tsDecoratorArgumentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECORATOR_ARGUMENT, + keyOf(this.parentDecoratorHash, this.position, this.argumentName, this.arrayIndex) + ); + } + + getHash(): string { + return this.tsDecoratorArgumentUniqueHash; + } + + /** The class named as a DI token — the pattern this relation exists to capture. */ + setReferencedTypeHash(hash: string): void { + this.referencedTypeHash = hash; + } + + getEntryCombined(): string { + return `ts_decorator_argument[name=${this.argumentName}, value=${this.argumentValue}, type=${this.valueType}, hash=${this.tsDecoratorArgumentUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.argumentName), + text(this.argumentValue), + this.valueType, + num(this.position), + this.parentDecoratorHash, + this.referencedTypeHash, + this.nestedObjectHash, + num(this.arrayIndex), + num(this.startLine), + num(this.endLine), + this.tsExpressionLinkHash, + this.serviceVersionLinkHash, + this.tsDecoratorArgumentUniqueHash, + ], + TsDecoratorArgumentRegistry.ARITY, + 'ts_decorator_argument' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'argumentName', 'argumentValue', 'valueType', 'position', 'parentDecoratorHash', + 'referencedTypeHash', 'nestedObjectHash', 'arrayIndex', 'startLine', 'endLine', + 'tsExpressionLinkHash', 'serviceVersionLinkHash', 'tsDecoratorArgumentUniqueHash', + ], + TsDecoratorArgumentRegistry.ARITY, + 'ts_decorator_argument' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsDecoratorRegistry.ts b/parser/src/analysis-types/typescript/TsDecoratorRegistry.ts new file mode 100644 index 000000000..5a32d86d9 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsDecoratorRegistry.ts @@ -0,0 +1,172 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsDecoratorContext, + TsDecoratorKind, + TsDecoratorSemantics, + TsDecoratorSystem, +} from '@/enums/typescript/decorators'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A decorator — schema §4.18, 21 columns. Positions 0–12 mirror + * `java_annotation` 0–12, so the `annotation_on` projection is shared. + * + * ## It is not an annotation, and the difference is the whole relation + * + * A Java annotation is inert metadata. A decorator is an EXPRESSION THAT RUNS at + * class-definition time and may REPLACE the entity it decorates. That is why it + * carries {@link tsExpressionLinkHash} into the call graph and why + * `@Component({...})` is a call site like any other. + * + * ## `decoratorSystem` — and why it may never be a run-wide constant + * + * TypeScript has TWO decorator systems and they are not interchangeable: + * standard TC39 (TS 5.0) and legacy `experimentalDecorators`. They differ in + * evaluation ORDER, in what the decorator function RECEIVES, and in whether + * parameter decorators are legal at all. Without this column a fact base mixes + * two evaluation semantics under one relation and no rule can tell them apart. + * + * The value comes from the tsconfig that actually GOVERNS the file, resolved per + * file. In this repository's own fixture corpus that is load-bearing: + * `annotations/legacy/` compiles under its own config with + * `experimentalDecorators: true`, and its facts legitimately differ from the + * standard-decorator fixtures three directories up. A parser assuming one system + * per run is wrong for exactly the repositories that matter — the ones migrating + * between the two. + */ +export class TsDecoratorRegistry implements EntityIdentifiable { + static readonly ARITY = 21; + + readonly decoratorName: string; + readonly kind: TsDecoratorKind; + readonly context: TsDecoratorContext; + readonly ownerHash: string; + readonly tsTypeLinkHash: string; + /** Parity slot with `java_annotation` 5. */ + private readonly typeParameterHash = ABSENT; + /** Parity slot with `java_annotation` 6 — TypeScript decorators do not nest. */ + private readonly parentDecoratorHash = ABSENT; + private readonly depth = 0; + readonly position: number; + readonly startLine: number; + readonly endLine: number; + /** Parity slot with `java_annotation` 11 — no TypeScript analogue of a meta-annotation. */ + private readonly isMetaDecorator = false; + readonly argumentCount: number; + readonly decoratorSystem: TsDecoratorSystem; + readonly decoratorSemantics: TsDecoratorSemantics; + readonly tsExpressionLinkHash: string; + private resolvedDecoratorMethodLinkHash = ABSENT; + readonly tsModuleLinkHash: string; + readonly startColumn: number; + readonly serviceVersionLinkHash: string; + private tsDecoratorUniqueHash = ABSENT; + + constructor(props: { + decoratorName: string; + kind: TsDecoratorKind; + context: TsDecoratorContext; + ownerHash: string; + tsTypeLinkHash: string; + position: number; + startLine: number; + endLine: number; + argumentCount: number; + decoratorSystem: TsDecoratorSystem; + decoratorSemantics: TsDecoratorSemantics; + tsExpressionLinkHash: string; + tsModuleLinkHash: string; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.decoratorName = props.decoratorName; + this.kind = props.kind; + this.context = props.context; + this.ownerHash = props.ownerHash; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.position = props.position; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.argumentCount = props.argumentCount; + this.decoratorSystem = props.decoratorSystem; + this.decoratorSemantics = props.decoratorSemantics; + this.tsExpressionLinkHash = props.tsExpressionLinkHash; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_DECORATOR_md5(ownerHash ‖ decoratorName ‖ position ‖ startLine ‖ startColumn)` + * + * `position` is in the key because stacking is legal and ORDER MATTERS — + * standard decorators apply bottom-up — so `@a @b` and `@b @a` are different + * facts about the same owner. + */ + generateHash(): void { + this.tsDecoratorUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECORATOR, + keyOf(this.ownerHash, this.decoratorName, this.position, this.startLine, this.startColumn) + ); + } + + getHash(): string { + return this.tsDecoratorUniqueHash; + } + + setResolvedDecoratorMethodLinkHash(hash: string): void { + this.resolvedDecoratorMethodLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_decorator[name=${this.decoratorName}, kind=${this.kind}, context=${this.context}, system=${this.decoratorSystem}, hash=${this.tsDecoratorUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.decoratorName), + this.kind, + this.context, + this.ownerHash, + this.tsTypeLinkHash, + this.typeParameterHash, + this.parentDecoratorHash, + num(this.depth), + num(this.position), + num(this.startLine), + num(this.endLine), + bool(this.isMetaDecorator), + num(this.argumentCount), + this.decoratorSystem, + this.decoratorSemantics, + this.tsExpressionLinkHash, + this.resolvedDecoratorMethodLinkHash, + this.tsModuleLinkHash, + num(this.startColumn), + this.serviceVersionLinkHash, + this.tsDecoratorUniqueHash, + ], + TsDecoratorRegistry.ARITY, + 'ts_decorator' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'decoratorName', 'kind', 'context', 'ownerHash', 'tsTypeLinkHash', 'typeParameterHash', + 'parentDecoratorHash', 'depth', 'position', 'startLine', 'endLine', 'isMetaDecorator', + 'argumentCount', 'decoratorSystem', 'decoratorSemantics', 'tsExpressionLinkHash', + 'resolvedDecoratorMethodLinkHash', 'tsModuleLinkHash', 'startColumn', + 'serviceVersionLinkHash', 'tsDecoratorUniqueHash', + ], + TsDecoratorRegistry.ARITY, + 'ts_decorator' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsEnumMemberRegistry.ts b/parser/src/analysis-types/typescript/TsEnumMemberRegistry.ts new file mode 100644 index 000000000..024c3b716 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsEnumMemberRegistry.ts @@ -0,0 +1,142 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsEnumMemberValueKind } from '@/enums/typescript/enum-members'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * An enum member — schema §4.10, 18 columns. Positions 0–11 mirror + * `java_enum_constant` 0–11. + * + * TypeScript enums differ from Java's in two ways that need columns, and both + * change what a reference to a member MEANS: + * + * **A member may be COMPUTED.** `Runtime = compute()` has no statically known + * value, so `constantValue` is `""` and no rule may treat it as a constant. + * + * **A `const enum` member is INLINED at use sites.** A reference to one has no + * runtime member to link to — the compiler substitutes the literal — so + * `isConstEnumMember` is what stops a rule looking for a member access that the + * emit does not contain. + * + * `hasBody` is a parity slot, always `false`: a Java enum constant may carry a + * class body and a TypeScript member may not. + */ +export class TsEnumMemberRegistry implements EntityIdentifiable { + static readonly ARITY = 18; + + readonly name: string; + readonly qualifiedName: string; + readonly ordinal: number; + readonly initializerText: string; + readonly hasInitializer: boolean; + /** Parity slot with `java_enum_constant` 5. TypeScript members carry no body. */ + private readonly hasBody = false; + readonly filePath: string; + readonly startLine: number; + readonly endLine: number; + readonly tsTypeLinkHash: string; + readonly ownerTypeName: string; + readonly ownerQualifiedName: string; + readonly valueKind: TsEnumMemberValueKind; + readonly constantValue: string; + readonly isConstEnumMember: boolean; + private tsExpressionLinkHash = ABSENT; + readonly serviceVersionLinkHash: string; + private tsEnumMemberUniqueHash = ABSENT; + + constructor(props: { + name: string; + qualifiedName: string; + ordinal: number; + initializerText: string; + filePath: string; + startLine: number; + endLine: number; + tsTypeLinkHash: string; + ownerTypeName: string; + ownerQualifiedName: string; + valueKind: TsEnumMemberValueKind; + constantValue: string; + isConstEnumMember: boolean; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.qualifiedName = props.qualifiedName; + this.ordinal = props.ordinal; + this.initializerText = props.initializerText; + this.hasInitializer = props.initializerText !== ''; + this.filePath = props.filePath; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.ownerTypeName = props.ownerTypeName; + this.ownerQualifiedName = props.ownerQualifiedName; + this.valueKind = props.valueKind; + this.constantValue = props.constantValue; + this.isConstEnumMember = props.isConstEnumMember; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_ENUM_MEMBER_md5(tsTypeLinkHash ‖ name ‖ ordinal)` — chained off the enum. */ + generateHash(): void { + this.tsEnumMemberUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_ENUM_MEMBER, + keyOf(this.tsTypeLinkHash, this.name, this.ordinal) + ); + } + + getHash(): string { + return this.tsEnumMemberUniqueHash; + } + + setTsExpressionLinkHash(hash: string): void { + this.tsExpressionLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_enum_member[name=${this.name}, ordinal=${this.ordinal}, kind=${this.valueKind}, value=${this.constantValue}, hash=${this.tsEnumMemberUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.qualifiedName), + num(this.ordinal), + text(this.initializerText), + bool(this.hasInitializer), + bool(this.hasBody), + text(this.filePath), + num(this.startLine), + num(this.endLine), + this.tsTypeLinkHash, + text(this.ownerTypeName), + text(this.ownerQualifiedName), + this.valueKind, + text(this.constantValue), + bool(this.isConstEnumMember), + this.tsExpressionLinkHash, + this.serviceVersionLinkHash, + this.tsEnumMemberUniqueHash, + ], + TsEnumMemberRegistry.ARITY, + 'ts_enum_member' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', 'qualifiedName', 'ordinal', 'initializerText', 'hasInitializer', 'hasBody', + 'filePath', 'startLine', 'endLine', 'tsTypeLinkHash', 'ownerTypeName', + 'ownerQualifiedName', 'valueKind', 'constantValue', 'isConstEnumMember', + 'tsExpressionLinkHash', 'serviceVersionLinkHash', 'tsEnumMemberUniqueHash', + ], + TsEnumMemberRegistry.ARITY, + 'ts_enum_member' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsExportRegistry.ts b/parser/src/analysis-types/typescript/TsExportRegistry.ts new file mode 100644 index 000000000..ab2f7f522 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsExportRegistry.ts @@ -0,0 +1,182 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsExportedEntityKind, TsExportKind } from '@/enums/typescript/exports'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * An export or re-export — schema §4.13, 21 columns. **No Java analogue.** + * + * Java visibility is a modifier and there is no re-export, so there is nothing + * to port. TypeScript needs the relation because **a re-export chain is the only + * path from an importer to the real declaration**, and the measured corpus has + * 1,251 export declarations, 86 `export *` and 74 `import x = require()`. + * + * Without it, Path 2 stops at the barrel: `import { Thing } from "./index"` + * resolves to a module that declares nothing and merely forwards. + * + * ## `EXPORT_STAR` is a real soundness surface + * + * It exports a set this row cannot name — the engine expands it by joining the + * source module's exports. That is the same shape as Python's `import *`, at 86 + * sites rather than 22, so it is not an edge case here. + */ +export class TsExportRegistry implements EntityIdentifiable { + static readonly ARITY = 21; + + readonly exportedName: string; + readonly localName: string; + readonly exportKind: TsExportKind; + readonly isTypeOnly: boolean; + readonly isDefault: boolean; + readonly isReExport: boolean; + readonly sourceSpecifier: string; + readonly tsModuleLinkHash: string; + private resolvedSourceModuleLinkHash = ABSENT; + readonly exportedEntityKind: TsExportedEntityKind; + private exportedEntityLinkHash = ABSENT; + private exportedGroupKey = ABSENT; + readonly position: number; + readonly startLine: number; + readonly endLine: number; + readonly startColumn: number; + readonly isAmbient: boolean; + private tsExpressionLinkHash = ABSENT; + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private tsExportUniqueHash = ABSENT; + + constructor(props: { + exportedName: string; + localName: string; + exportKind: TsExportKind; + isTypeOnly: boolean; + isDefault: boolean; + isReExport: boolean; + sourceSpecifier: string; + tsModuleLinkHash: string; + exportedEntityKind: TsExportedEntityKind; + position: number; + startLine: number; + endLine: number; + startColumn: number; + isAmbient: boolean; + serviceVersionLinkHash: string; + }) { + this.exportedName = props.exportedName; + this.localName = props.localName; + this.exportKind = props.exportKind; + this.isTypeOnly = props.isTypeOnly; + this.isDefault = props.isDefault; + this.isReExport = props.isReExport; + this.sourceSpecifier = props.sourceSpecifier; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.exportedEntityKind = props.exportedEntityKind; + this.position = props.position; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.startColumn = props.startColumn; + this.isAmbient = props.isAmbient; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_EXPORT_md5(tsModuleLinkHash ‖ exportedName ‖ exportKind ‖ sourceSpecifier ‖ startLine ‖ position)` + * + * `exportedName` is `""` for `export *`, which is why `sourceSpecifier` and + * `position` are both in the key: a module may re-export from several sources + * on one line. + */ + generateHash(): void { + this.tsExportUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_EXPORT, + keyOf( + this.tsModuleLinkHash, + this.exportedName, + this.exportKind, + this.sourceSpecifier, + this.startLine, + this.position + ) + ); + } + + getHash(): string { + return this.tsExportUniqueHash; + } + + /** Back-patched by the module-graph pass: the source module may be parsed later. */ + setResolvedSourceModuleLinkHash(hash: string): void { + this.resolvedSourceModuleLinkHash = hash; + } + + getResolvedSourceModuleLinkHash(): string { + return this.resolvedSourceModuleLinkHash; + } + + /** + * The declaration this export exposes, and its merge group. + * + * The group key matters more than the entity hash: an exported interface with + * three declarations has three rows and one group, and an importer reaching it + * needs the group or it sees a third of the members. + */ + setExportedEntity(entityLinkHash: string, groupKey: string): void { + this.exportedEntityLinkHash = entityLinkHash; + this.exportedGroupKey = groupKey; + } + + setTsExpressionLinkHash(hash: string): void { + this.tsExpressionLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_export[name=${this.exportedName}, kind=${this.exportKind}, from=${this.sourceSpecifier}, hash=${this.tsExportUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.exportedName), + text(this.localName), + this.exportKind, + bool(this.isTypeOnly), + bool(this.isDefault), + bool(this.isReExport), + text(this.sourceSpecifier), + this.tsModuleLinkHash, + this.resolvedSourceModuleLinkHash, + this.exportedEntityKind, + this.exportedEntityLinkHash, + this.exportedGroupKey, + num(this.position), + num(this.startLine), + num(this.endLine), + num(this.startColumn), + bool(this.isAmbient), + this.tsExpressionLinkHash, + bool(this.isExternal), + this.serviceVersionLinkHash, + this.tsExportUniqueHash, + ], + TsExportRegistry.ARITY, + 'ts_export' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'exportedName', 'localName', 'exportKind', 'isTypeOnly', 'isDefault', 'isReExport', + 'sourceSpecifier', 'tsModuleLinkHash', 'resolvedSourceModuleLinkHash', + 'exportedEntityKind', 'exportedEntityLinkHash', 'exportedGroupKey', 'position', + 'startLine', 'endLine', 'startColumn', 'isAmbient', 'tsExpressionLinkHash', 'isExternal', + 'serviceVersionLinkHash', 'tsExportUniqueHash', + ], + TsExportRegistry.ARITY, + 'ts_export' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsExpressionRegistry.ts b/parser/src/analysis-types/typescript/TsExpressionRegistry.ts new file mode 100644 index 000000000..fafe44dad --- /dev/null +++ b/parser/src/analysis-types/typescript/TsExpressionRegistry.ts @@ -0,0 +1,274 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsEdgeRole, + TsExpressionKind, + TsExpressionOwnerKind, + TsReferencedEntityKind, + TsRootContext, +} from '@/enums/typescript/expressions'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * An expression AST node — schema §4.14, 34 columns. The spine of call resolution. + * + * Positions 0–24 are byte-for-byte `java_expression` 0–24, so `expr_kind`, + * `expr_child`, `expr_owner` and the whole call-resolution projection port as + * renames. The appended columns carry what TypeScript adds: optional chaining, + * `as`/`satisfies`, spread, and per-reference resolution. + * + * ## Three columns that exist to stop a specific wrong answer + * + * {@link assertedTypeReferenceLinkHash} is the ONLY expression-to-type edge in + * the schema, and it is deliberately a **type** FK: `x as Foo` mentions a type + * from a value position, and if that edge landed in the call graph every + * assertion would become a phantom call target. Because it points into + * `ts_type_reference`, no call-graph rule can cross it. + * + * {@link isSpread} marks where positional argument flow is **provably** + * imprecise. `f(...args)` does not have knowable argument positions, and a + * fact base that silently renumbers the remaining arguments is wrong in a way + * nothing downstream can detect. Marking it converts a silent error into a + * known limit. + * + * {@link isTypeOnlyReachable} should always be `false`. It is a tripwire for + * §3.3, not a feature: a `true` row means a type-only construct produced an + * expression, and the gate fails on it by name. + */ +export class TsExpressionRegistry implements EntityIdentifiable { + static readonly ARITY = 34; + + readonly kind: TsExpressionKind; + readonly edgeRole: TsEdgeRole; + readonly rootContext: TsRootContext; + readonly expressionOwnerKind: TsExpressionOwnerKind; + readonly tsTypeLinkHash: string; + readonly expressionOwnerHash: string; + readonly parentExpressionHash: string; + readonly position: number; + readonly depth: number; + readonly literalType: string; + readonly literalValue: string; + /** Parity slot with `java_expression` 11; TypeScript has no method reference `::`. */ + private readonly methodReferenceKind = ABSENT; + readonly unaryFixity: string; + readonly operatorString: string; + private referencedEntityKind: TsReferencedEntityKind = TsReferencedEntityKind.UNKNOWN; + private referencedEntityHash = ABSENT; + private anonymousDeclarationHash = ABSENT; + private potentialQualifiedName = ABSENT; + private isAmbiguous = false; + readonly returnStatementIndex: number; + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + readonly endColumn: number; + readonly tsModuleLinkHash: string; + readonly isOptionalChain: boolean; + readonly isNonNullAsserted: boolean; + private assertedTypeReferenceLinkHash = ABSENT; + readonly isSpread: boolean; + readonly argumentCount: number; + readonly typeArgumentCount: number; + readonly isTypeOnlyReachable: boolean; + readonly serviceVersionLinkHash: string; + private tsExpressionUniqueHash = ABSENT; + + constructor(props: { + kind: TsExpressionKind; + edgeRole: TsEdgeRole; + rootContext: TsRootContext; + expressionOwnerKind: TsExpressionOwnerKind; + tsTypeLinkHash: string; + expressionOwnerHash: string; + parentExpressionHash: string; + position: number; + depth: number; + literalType: string; + literalValue: string; + unaryFixity: string; + operatorString: string; + returnStatementIndex: number; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + tsModuleLinkHash: string; + isOptionalChain: boolean; + isNonNullAsserted: boolean; + isSpread: boolean; + argumentCount: number; + typeArgumentCount: number; + isTypeOnlyReachable: boolean; + serviceVersionLinkHash: string; + }) { + this.kind = props.kind; + this.edgeRole = props.edgeRole; + this.rootContext = props.rootContext; + this.expressionOwnerKind = props.expressionOwnerKind; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.expressionOwnerHash = props.expressionOwnerHash; + this.parentExpressionHash = props.parentExpressionHash; + this.position = props.position; + this.depth = props.depth; + this.literalType = props.literalType; + this.literalValue = props.literalValue; + this.unaryFixity = props.unaryFixity; + this.operatorString = props.operatorString; + this.returnStatementIndex = props.returnStatementIndex; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.endLine = props.endLine; + this.endColumn = props.endColumn; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.isOptionalChain = props.isOptionalChain; + this.isNonNullAsserted = props.isNonNullAsserted; + this.isSpread = props.isSpread; + this.argumentCount = props.argumentCount; + this.typeArgumentCount = props.typeArgumentCount; + this.isTypeOnlyReachable = props.isTypeOnlyReachable; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_EXPRESSION_md5(tsModuleLinkHash ‖ expressionOwnerHash ‖ parentExpressionHash ‖ edgeRole ‖ position ‖ startLine ‖ startColumn)` + * + * `edgeRole` is in the key alongside `position` because two children of one + * parent can share an index in different roles — a binary node's left operand + * and right operand are both position 0 of their own role. + */ + generateHash(): void { + this.tsExpressionUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_EXPRESSION, + keyOf( + this.tsModuleLinkHash, + this.expressionOwnerHash, + this.parentExpressionHash, + this.edgeRole, + this.position, + this.startLine, + this.startColumn + ) + ); + } + + getHash(): string { + return this.tsExpressionUniqueHash; + } + + /** + * Records what this identifier reference resolved to. + * + * The oracle checks this against `getSymbolAtLocation`, so an unfilled row is + * a measured gap and a wrongly filled one is a hard failure. Those are not the + * same cost, which is why the parser fills it only from syntax it can defend. + */ + setReference(kind: TsReferencedEntityKind, hash: string, potentialQualifiedName: string): void { + this.referencedEntityKind = kind; + this.referencedEntityHash = hash; + this.potentialQualifiedName = potentialQualifiedName; + } + + getReferencedEntityHash(): string { + return this.referencedEntityHash; + } + + getReferencedEntityKind(): TsReferencedEntityKind { + return this.referencedEntityKind; + } + + /** Two candidate declarations matched and syntax cannot choose. Reported, never guessed. */ + markAmbiguous(): void { + this.isAmbiguous = true; + } + + /** + * The DECLARATION this expression introduces — c16, polymorphic on {@link kind}. + * + * `ts_type` for a `CLASS_EXPRESSION`; `ts_method` for an `ARROW_FUNCTION` or a + * `FUNCTION_EXPRESSION`. Widened from Java's `anonymousTypeHash`, which covered + * only the class case: an arrow had a `ts_method` row and an expression row + * with no FK between them, so an IIFE's target was reachable only by matching + * positions — and a position match is exactly the kind of join that breaks + * silently when two nodes share an offset. + */ + setAnonymousDeclarationHash(hash: string): void { + this.anonymousDeclarationHash = hash; + } + + getAnonymousDeclarationHash(): string { + return this.anonymousDeclarationHash; + } + + setAssertedTypeReferenceLinkHash(hash: string): void { + this.assertedTypeReferenceLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_expression[kind=${this.kind}, role=${this.edgeRole}, pos=${this.position}, line=${this.startLine}, hash=${this.tsExpressionUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.kind, + this.edgeRole, + this.rootContext, + this.expressionOwnerKind, + this.tsTypeLinkHash, + this.expressionOwnerHash, + this.parentExpressionHash, + num(this.position), + num(this.depth), + this.literalType, + text(this.literalValue), + this.methodReferenceKind, + this.unaryFixity, + text(this.operatorString), + this.referencedEntityKind, + this.referencedEntityHash, + this.anonymousDeclarationHash, + text(this.potentialQualifiedName), + bool(this.isAmbiguous), + num(this.returnStatementIndex), + num(this.startLine), + num(this.startColumn), + num(this.endLine), + num(this.endColumn), + this.tsModuleLinkHash, + bool(this.isOptionalChain), + bool(this.isNonNullAsserted), + this.assertedTypeReferenceLinkHash, + bool(this.isSpread), + num(this.argumentCount), + num(this.typeArgumentCount), + bool(this.isTypeOnlyReachable), + this.serviceVersionLinkHash, + this.tsExpressionUniqueHash, + ], + TsExpressionRegistry.ARITY, + 'ts_expression' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'kind', 'edgeRole', 'rootContext', 'expressionOwnerKind', 'tsTypeLinkHash', + 'expressionOwnerHash', 'parentExpressionHash', 'position', 'depth', 'literalType', + 'literalValue', 'methodReferenceKind', 'unaryFixity', 'operatorString', + 'referencedEntityKind', 'referencedEntityHash', 'anonymousDeclarationHash', + 'potentialQualifiedName', 'isAmbiguous', 'returnStatementIndex', 'startLine', + 'startColumn', 'endLine', 'endColumn', 'tsModuleLinkHash', 'isOptionalChain', + 'isNonNullAsserted', 'assertedTypeReferenceLinkHash', 'isSpread', 'argumentCount', + 'typeArgumentCount', 'isTypeOnlyReachable', 'serviceVersionLinkHash', + 'tsExpressionUniqueHash', + ], + TsExpressionRegistry.ARITY, + 'ts_expression' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsFieldPositionRegistry.ts b/parser/src/analysis-types/typescript/TsFieldPositionRegistry.ts new file mode 100644 index 000000000..3edbf3030 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsFieldPositionRegistry.ts @@ -0,0 +1,73 @@ +import { ABSENT, joinHeader, joinRow, keyOf, num } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A field's declaration order within its type — schema §4.9, 3 columns. + * Parity with `java_field_position`, unchanged. + * + * Three columns is the whole relation, and it is load-bearing for one reason + * TypeScript has and Java does not: a class's field order determines a + * PARAMETER-PROPERTY constructor's positional shape. + * + * ```ts + * class Service { + * constructor( + * private readonly repo: Repo, // position 0 + * private readonly log: Logger, // position 1 + * ) { } + * } + * new Service(repo, log); // argument 0 -> repo, 1 -> log + * ``` + * + * Both fields are declared by parameters, so their ORDER is the constructor's + * signature. Recovering it from `startLine` would work until two are written on + * one line. + */ +export class TsFieldPositionRegistry implements EntityIdentifiable { + static readonly ARITY = 3; + + readonly tsFieldLinkHash: string; + readonly position: number; + private tsFieldPositionUniqueHash = ABSENT; + + constructor(props: { tsFieldLinkHash: string; position: number }) { + this.tsFieldLinkHash = props.tsFieldLinkHash; + this.position = props.position; + this.generateHash(); + } + + /** **PK** `TS_FIELD_POSITION_md5(tsFieldLinkHash ‖ position)` — a pure chain off the field. */ + generateHash(): void { + this.tsFieldPositionUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_FIELD_POSITION, + keyOf(this.tsFieldLinkHash, this.position) + ); + } + + getHash(): string { + return this.tsFieldPositionUniqueHash; + } + + getEntryCombined(): string { + return `ts_field_position[field=${this.tsFieldLinkHash}, position=${this.position}]`; + } + + toCsv(): string { + return joinRow( + [this.tsFieldLinkHash, num(this.position), this.tsFieldPositionUniqueHash], + TsFieldPositionRegistry.ARITY, + 'ts_field_position' + ); + } + + getCsvHeader(): string { + return joinHeader( + ['tsFieldLinkHash', 'position', 'tsFieldPositionUniqueHash'], + TsFieldPositionRegistry.ARITY, + 'ts_field_position' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsFieldRegistry.ts b/parser/src/analysis-types/typescript/TsFieldRegistry.ts new file mode 100644 index 000000000..8db64a187 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsFieldRegistry.ts @@ -0,0 +1,209 @@ +import { ABSENT, bool, commaSet, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsFieldAccess, TsFieldModifier, TsMemberKind } from '@/enums/typescript/fields'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A member with a value shape — schema §4.8, 29 columns. + * + * Class properties, interface property signatures, index signatures, + * auto-accessors, parameter properties, and object-literal properties. + * Positions 0–12 mirror `java_field` 0–12. + * + * ## The key chains off the OWNER HASH, exactly as `FieldRegistry` does + * + * `FieldRegistry.ts` builds its hash from `typeRegistryLinkHash` and never from + * a re-derived qualified name, and that discipline is inherited here for a + * sharper reason than in Java: with declaration merging, two declarations of one + * interface share a qualified name **by design**, so a qualified-name key would + * collide on the exact construct this schema exists to model. + * + * {@link memberGroupKey} is the separate, deliberately non-unique key that says + * "this member and that one are the same member of a merged owner" — so a + * property declared in a module augmentation joins the same member as one + * declared in the original interface. + * + * {@link isOptional} is not cosmetic: an absent optional member does not break + * assignability, so §3.2's structural satisfaction depends on this column to + * avoid rejecting a class that legitimately satisfies an interface. + */ +export class TsFieldRegistry implements EntityIdentifiable { + static readonly ARITY = 29; + + readonly name: string; + readonly fieldTypeName: string; + readonly fieldBaseType: string; + readonly potentialQualifiedName: string; + readonly isAmbiguous: boolean; + readonly filePath: string; + readonly startLine: number; + readonly endLine: number; + readonly tsTypeLinkHash: string; + readonly ownerTypeName: string; + readonly ownerQualifiedName: string; + readonly fieldAccess: TsFieldAccess; + readonly fieldModifiers: ReadonlySet; + readonly memberKind: TsMemberKind; + readonly tsModuleLinkHash: string; + readonly isOptional: boolean; + readonly hasDefiniteAssignment: boolean; + readonly isReadonly: boolean; + readonly isStatic: boolean; + readonly indexKeyTypeName: string; + readonly isTypeOnly: boolean; + private typeReferenceLinkHash = ABSENT; + private initializerExpressionLinkHash = ABSENT; + private originParameterLinkHash = ABSENT; + readonly memberGroupKey: string; + readonly startColumn: number; + readonly endColumn: number; + readonly serviceVersionLinkHash: string; + private tsFieldUniqueHash = ABSENT; + + constructor(props: { + name: string; + fieldTypeName: string; + fieldBaseType: string; + potentialQualifiedName: string; + isAmbiguous: boolean; + filePath: string; + startLine: number; + endLine: number; + tsTypeLinkHash: string; + ownerTypeName: string; + ownerQualifiedName: string; + fieldAccess: TsFieldAccess; + fieldModifiers: ReadonlySet; + memberKind: TsMemberKind; + tsModuleLinkHash: string; + isOptional: boolean; + hasDefiniteAssignment: boolean; + isReadonly: boolean; + isStatic: boolean; + indexKeyTypeName: string; + isTypeOnly: boolean; + memberGroupKey: string; + startColumn: number; + endColumn: number; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.fieldTypeName = props.fieldTypeName; + this.fieldBaseType = props.fieldBaseType; + this.potentialQualifiedName = props.potentialQualifiedName; + this.isAmbiguous = props.isAmbiguous; + this.filePath = props.filePath; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.ownerTypeName = props.ownerTypeName; + this.ownerQualifiedName = props.ownerQualifiedName; + this.fieldAccess = props.fieldAccess; + this.fieldModifiers = props.fieldModifiers; + this.memberKind = props.memberKind; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.isOptional = props.isOptional; + this.hasDefiniteAssignment = props.hasDefiniteAssignment; + this.isReadonly = props.isReadonly; + this.isStatic = props.isStatic; + this.indexKeyTypeName = props.indexKeyTypeName; + this.isTypeOnly = props.isTypeOnly; + this.memberGroupKey = props.memberGroupKey; + this.startColumn = props.startColumn; + this.endColumn = props.endColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_FIELD_md5(filePath ‖ tsTypeLinkHash ‖ name ‖ fieldTypeName ‖ startLine ‖ startColumn)` */ + generateHash(): void { + this.tsFieldUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_FIELD, + keyOf( + this.filePath, + this.tsTypeLinkHash, + this.name, + this.fieldTypeName, + this.startLine, + this.startColumn + ) + ); + } + + getHash(): string { + return this.tsFieldUniqueHash; + } + + setTypeReferenceLinkHash(hash: string): void { + this.typeReferenceLinkHash = hash; + } + + setInitializerExpressionLinkHash(hash: string): void { + this.initializerExpressionLinkHash = hash; + } + + /** Set when this field was declared by a `constructor(private x: T)` parameter. */ + setOriginParameterLinkHash(hash: string): void { + this.originParameterLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_field[name=${this.name}, type=${this.fieldTypeName}, kind=${this.memberKind}, hash=${this.tsFieldUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.fieldTypeName), + text(this.fieldBaseType), + text(this.potentialQualifiedName), + bool(this.isAmbiguous), + text(this.filePath), + num(this.startLine), + num(this.endLine), + this.tsTypeLinkHash, + text(this.ownerTypeName), + text(this.ownerQualifiedName), + this.fieldAccess, + commaSet(this.fieldModifiers), + this.memberKind, + this.tsModuleLinkHash, + bool(this.isOptional), + bool(this.hasDefiniteAssignment), + bool(this.isReadonly), + bool(this.isStatic), + text(this.indexKeyTypeName), + bool(this.isTypeOnly), + this.typeReferenceLinkHash, + this.initializerExpressionLinkHash, + this.originParameterLinkHash, + this.memberGroupKey, + num(this.startColumn), + num(this.endColumn), + this.serviceVersionLinkHash, + this.tsFieldUniqueHash, + ], + TsFieldRegistry.ARITY, + 'ts_field' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', 'fieldTypeName', 'fieldBaseType', 'potentialQualifiedName', 'isAmbiguous', + 'filePath', 'startLine', 'endLine', 'tsTypeLinkHash', 'ownerTypeName', + 'ownerQualifiedName', 'fieldAccess', 'fieldModifier', 'memberKind', 'tsModuleLinkHash', + 'isOptional', 'hasDefiniteAssignment', 'isReadonly', 'isStatic', 'indexKeyTypeName', + 'isTypeOnly', 'typeReferenceLinkHash', 'initializerExpressionLinkHash', + 'originParameterLinkHash', 'memberGroupKey', 'startColumn', 'endColumn', + 'serviceVersionLinkHash', 'tsFieldUniqueHash', + ], + TsFieldRegistry.ARITY, + 'ts_field' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsImportRegistry.ts b/parser/src/analysis-types/typescript/TsImportRegistry.ts new file mode 100644 index 000000000..d64aa07ee --- /dev/null +++ b/parser/src/analysis-types/typescript/TsImportRegistry.ts @@ -0,0 +1,217 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsImportKind, TsImportResolutionKind } from '@/enums/typescript/imports'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One bound import name — schema §4.12, 27 columns. + * + * Positions 0–8 mirror `java_import` 0–8 with two slots repurposed: Java's + * `isStatic` becomes `isTypeOnly` and its `isOnDemand` becomes `isWildcard`, so + * the `import_wildcard` projection keeps its name across both languages (§2). + * + * **One declaration with N named specifiers emits N ROWS.** Each binds a + * distinct name and each may be individually type-only — `import { a, type B }` + * is one declaration and two facts with different runtime existence. + * + * ## Resolution here is parser-legal + * + * {@link resolvedFilePath} is filled by `ts.resolveModuleName`, which was + * verified to need **no Program**: it is a pure function of the specifier, the + * compiler options and the file system, and it returns `undefined` for an + * unresolvable specifier rather than guessing. That is what makes this a tier-2 + * column rather than engine work, and it matters beyond imports — §3.1's merge + * key for a module augmentation uses the RESOLVED target module. + * + * {@link isExternalTarget} is an honest negative about **this analysis**, not a + * claim about the outside world: it says the specifier did not resolve to a + * `ts_module` row here, which is exactly the set the engine closes from + * `lib_ts_*`. + */ +export class TsImportRegistry implements EntityIdentifiable { + static readonly ARITY = 27; + + readonly importKind: TsImportKind; + readonly importedPath: string; + readonly moduleOrEntityName: string; + readonly simpleName: string; + readonly filePath: string; + readonly lineNumber: number; + readonly isTypeOnly: boolean; + readonly isWildcard: boolean; + /** Parity slot with `java_import` 8; TypeScript has no analogue of JEP 476. */ + private readonly isModuleImport = false; + readonly originalName: string; + readonly aliasName: string; + readonly isDefaultImport: boolean; + readonly isSideEffectOnly: boolean; + readonly tsModuleLinkHash: string; + private resolvedModuleLinkHash = ABSENT; + readonly resolvedFilePath: string; + private resolutionKind: TsImportResolutionKind; + readonly resolvedExtension: string; + readonly isExternalTarget: boolean; + readonly packageName: string; + readonly specifierHasExtension: boolean; + readonly importClauseIndex: number; + private tsExpressionLinkHash = ABSENT; + readonly startColumn: number; + private readonly isExternal = false; + readonly serviceVersionLinkHash: string; + private tsImportUniqueHash = ABSENT; + + constructor(props: { + importKind: TsImportKind; + importedPath: string; + moduleOrEntityName: string; + simpleName: string; + filePath: string; + lineNumber: number; + isTypeOnly: boolean; + isWildcard: boolean; + originalName: string; + aliasName: string; + isDefaultImport: boolean; + isSideEffectOnly: boolean; + tsModuleLinkHash: string; + resolvedFilePath: string; + resolutionKind: TsImportResolutionKind; + resolvedExtension: string; + isExternalTarget: boolean; + packageName: string; + specifierHasExtension: boolean; + importClauseIndex: number; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.importKind = props.importKind; + this.importedPath = props.importedPath; + this.moduleOrEntityName = props.moduleOrEntityName; + this.simpleName = props.simpleName; + this.filePath = props.filePath; + this.lineNumber = props.lineNumber; + this.isTypeOnly = props.isTypeOnly; + this.isWildcard = props.isWildcard; + this.originalName = props.originalName; + this.aliasName = props.aliasName; + this.isDefaultImport = props.isDefaultImport; + this.isSideEffectOnly = props.isSideEffectOnly; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.resolvedFilePath = props.resolvedFilePath; + this.resolutionKind = props.resolutionKind; + this.resolvedExtension = props.resolvedExtension; + this.isExternalTarget = props.isExternalTarget; + this.packageName = props.packageName; + this.specifierHasExtension = props.specifierHasExtension; + this.importClauseIndex = props.importClauseIndex; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_IMPORT_md5(tsModuleLinkHash ‖ importedPath ‖ importKind ‖ simpleName ‖ lineNumber ‖ importClauseIndex)` */ + generateHash(): void { + this.tsImportUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_IMPORT, + keyOf( + this.tsModuleLinkHash, + this.importedPath, + this.importKind, + this.simpleName, + this.lineNumber, + this.importClauseIndex + ) + ); + } + + getHash(): string { + return this.tsImportUniqueHash; + } + + /** Back-patched by the cross-module pass: the target module may be parsed after this one. */ + setResolvedModuleLinkHash(hash: string): void { + this.resolvedModuleLinkHash = hash; + } + + getResolvedModuleLinkHash(): string { + return this.resolvedModuleLinkHash; + } + + /** + * The specifier named an AMBIENT MODULE declared in this analysis. + * + * `ts.resolveModuleName` returns nothing for `declare module "x"` because + * there is no file — so `resolvedFilePath` stays empty and correct, while + * `resolvedModuleLinkHash` and this kind carry the answer. Distinguishing the + * two is the point: an empty path with an AMBIENT_MODULE kind is a resolved + * import, and an empty path with an UNRESOLVED kind is not. + */ + setAmbientModuleResolution(): void { + this.resolutionKind = TsImportResolutionKind.AMBIENT_MODULE; + } + + getResolutionKind(): TsImportResolutionKind { + return this.resolutionKind; + } + + setTsExpressionLinkHash(hash: string): void { + this.tsExpressionLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_import[kind=${this.importKind}, path=${this.importedPath}, name=${this.simpleName}, hash=${this.tsImportUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.importKind, + text(this.importedPath), + text(this.moduleOrEntityName), + text(this.simpleName), + text(this.filePath), + num(this.lineNumber), + bool(this.isTypeOnly), + bool(this.isWildcard), + bool(this.isModuleImport), + text(this.originalName), + text(this.aliasName), + bool(this.isDefaultImport), + bool(this.isSideEffectOnly), + this.tsModuleLinkHash, + this.resolvedModuleLinkHash, + text(this.resolvedFilePath), + this.resolutionKind, + this.resolvedExtension, + bool(this.isExternalTarget), + text(this.packageName), + bool(this.specifierHasExtension), + num(this.importClauseIndex), + this.tsExpressionLinkHash, + num(this.startColumn), + bool(this.isExternal), + this.serviceVersionLinkHash, + this.tsImportUniqueHash, + ], + TsImportRegistry.ARITY, + 'ts_import' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'importKind', 'importedPath', 'moduleOrEntityName', 'simpleName', 'filePath', + 'lineNumber', 'isTypeOnly', 'isWildcard', 'isModuleImport', 'originalName', 'aliasName', + 'isDefaultImport', 'isSideEffectOnly', 'tsModuleLinkHash', 'resolvedModuleLinkHash', + 'resolvedFilePath', 'resolutionKind', 'resolvedExtension', 'isExternalTarget', + 'packageName', 'specifierHasExtension', 'importClauseIndex', 'tsExpressionLinkHash', + 'startColumn', 'isExternal', 'serviceVersionLinkHash', 'tsImportUniqueHash', + ], + TsImportRegistry.ARITY, + 'ts_import' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsMethodParameterRegistry.ts b/parser/src/analysis-types/typescript/TsMethodParameterRegistry.ts new file mode 100644 index 000000000..70db683b0 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsMethodParameterRegistry.ts @@ -0,0 +1,215 @@ +import { ABSENT, bool, commaSet, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsDefaultValueKind, + TsParamKind, + TsParameterPropertyModifier, +} from '@/enums/typescript/method-parameters'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; +import { TsBindingSourceKind } from '@/enums/typescript/variables/TsBindingSourceKind'; + +/** + * A formal parameter — schema §4.7, 27 columns. Positions 0–11 mirror + * `java_method_parameter` 0–11. + * + * ## This relation is why TypeScript needs no binder to be useful + * + * **85.3%** of project parameters and **99.998%** of ambient parameters carry a + * type annotation. That is the exact inverse of Python, where 68.2% had none and + * argument flow had to carry the whole load of receiver typing. Here the + * declared type is written at the declaration site, so Path 1 — receiver + * expression to declaration to annotation to type — is a syntax-directed walk, + * which is why this schema is Java-shaped rather than Python-shaped. + * + * Two columns are load-bearing beyond their apparent size: + * + * - {@link isOptional} changes **arity matching**. A call with two arguments can + * legally target a three-parameter signature whose third is optional, so + * overload selection that compares counts without this column selects wrongly. + * - {@link isParameterProperty}: `constructor(private x: T)` means one parameter + * also DECLARES A FIELD. That has no Java or Python analogue. It is recorded + * as a cross-FK to the field row ({@link declaredFieldLinkHash}), never as a + * duplicated row, so the field is counted once in the type's shape. + */ +export class TsMethodParameterRegistry implements EntityIdentifiable { + static readonly ARITY = 29; + + readonly paramName: string; + readonly position: number; + readonly tsMethodLinkHash: string; + readonly parameterBaseType: string; + readonly parameterTypeName: string; + readonly potentialQualifiedName: string; + readonly isAmbiguous: boolean; + /** Parity slot with `java_method_parameter` 7; TypeScript has no `final`. */ + private readonly isFinal = false; + readonly isVarArgs: boolean; + readonly isReceiverParameter: boolean; + readonly startLine: number; + readonly endLine: number; + readonly paramKind: TsParamKind; + readonly isOptional: boolean; + readonly hasDefault: boolean; + readonly defaultValueText: string; + readonly defaultValueKind: TsDefaultValueKind; + readonly isParameterProperty: boolean; + readonly parameterPropertyModifiers: ReadonlySet; + private declaredFieldLinkHash = ABSENT; + readonly bindingPatternText: string; + readonly bindingSourceKind: TsBindingSourceKind; + readonly bindingSource: string; + private typeReferenceLinkHash = ABSENT; + private tsExpressionLinkHash = ABSENT; + readonly decoratorCount: number; + readonly startColumn: number; + readonly serviceVersionLinkHash: string; + private tsMethodParameterUniqueHash = ABSENT; + + constructor(props: { + paramName: string; + position: number; + tsMethodLinkHash: string; + parameterBaseType: string; + parameterTypeName: string; + potentialQualifiedName: string; + isAmbiguous: boolean; + isVarArgs: boolean; + isReceiverParameter: boolean; + startLine: number; + endLine: number; + paramKind: TsParamKind; + isOptional: boolean; + hasDefault: boolean; + defaultValueText: string; + defaultValueKind: TsDefaultValueKind; + isParameterProperty: boolean; + parameterPropertyModifiers: ReadonlySet; + bindingPatternText: string; + bindingSourceKind?: TsBindingSourceKind; + bindingSource?: string; + decoratorCount: number; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.paramName = props.paramName; + this.position = props.position; + this.tsMethodLinkHash = props.tsMethodLinkHash; + this.parameterBaseType = props.parameterBaseType; + this.parameterTypeName = props.parameterTypeName; + this.potentialQualifiedName = props.potentialQualifiedName; + this.isAmbiguous = props.isAmbiguous; + this.isVarArgs = props.isVarArgs; + this.isReceiverParameter = props.isReceiverParameter; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.paramKind = props.paramKind; + this.isOptional = props.isOptional; + this.hasDefault = props.hasDefault; + this.defaultValueText = props.defaultValueText; + this.defaultValueKind = props.defaultValueKind; + this.isParameterProperty = props.isParameterProperty; + this.parameterPropertyModifiers = props.parameterPropertyModifiers; + this.bindingPatternText = props.bindingPatternText; + this.bindingSourceKind = props.bindingSourceKind ?? TsBindingSourceKind.NONE; + this.bindingSource = props.bindingSource ?? ''; + this.decoratorCount = props.decoratorCount; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_METHOD_PARAMETER_md5(tsMethodLinkHash ‖ position ‖ paramName ‖ paramKind)` + * + * Chained off the method hash. `paramName` is `""` for a destructured + * parameter, which is why `position` and `paramKind` are both in the key — + * `function f({a}, [b])` has two nameless parameters that differ only in kind. + */ + generateHash(): void { + this.tsMethodParameterUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_METHOD_PARAMETER, + keyOf(this.tsMethodLinkHash, this.position, this.paramName, this.paramKind) + ); + } + + getHash(): string { + return this.tsMethodParameterUniqueHash; + } + + /** The field a parameter property declares. A cross-FK, never a duplicated row. */ + setDeclaredFieldLinkHash(hash: string): void { + this.declaredFieldLinkHash = hash; + } + + getTypeReferenceLinkHash(): string { + return this.typeReferenceLinkHash; + } + + setTypeReferenceLinkHash(hash: string): void { + this.typeReferenceLinkHash = hash; + } + + setTsExpressionLinkHash(hash: string): void { + this.tsExpressionLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_method_parameter[name=${this.paramName}, pos=${this.position}, kind=${this.paramKind}, hash=${this.tsMethodParameterUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.paramName), + num(this.position), + this.tsMethodLinkHash, + text(this.parameterBaseType), + text(this.parameterTypeName), + text(this.potentialQualifiedName), + bool(this.isAmbiguous), + bool(this.isFinal), + bool(this.isVarArgs), + bool(this.isReceiverParameter), + num(this.startLine), + num(this.endLine), + this.paramKind, + bool(this.isOptional), + bool(this.hasDefault), + text(this.defaultValueText), + this.defaultValueKind, + bool(this.isParameterProperty), + commaSet(this.parameterPropertyModifiers), + this.declaredFieldLinkHash, + text(this.bindingPatternText), + this.bindingSourceKind, + text(this.bindingSource), + this.typeReferenceLinkHash, + this.tsExpressionLinkHash, + num(this.decoratorCount), + num(this.startColumn), + this.serviceVersionLinkHash, + this.tsMethodParameterUniqueHash, + ], + TsMethodParameterRegistry.ARITY, + 'ts_method_parameter' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'paramName', 'position', 'tsMethodLinkHash', 'parameterBaseType', 'parameterTypeName', + 'potentialQualifiedName', 'isAmbiguous', 'isFinal', 'isVarArgs', 'isReceiverParameter', + 'startLine', 'endLine', 'paramKind', 'isOptional', 'hasDefault', 'defaultValueText', + 'defaultValueKind', 'isParameterProperty', 'parameterPropertyModifier', + 'declaredFieldLinkHash', 'bindingPatternText', 'bindingSourceKind', 'bindingSource', 'typeReferenceLinkHash', + 'tsExpressionLinkHash', 'decoratorCount', 'startColumn', 'serviceVersionLinkHash', + 'tsMethodParameterUniqueHash', + ], + TsMethodParameterRegistry.ARITY, + 'ts_method_parameter' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsMethodRegistry.ts b/parser/src/analysis-types/typescript/TsMethodRegistry.ts new file mode 100644 index 000000000..0528ac493 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsMethodRegistry.ts @@ -0,0 +1,302 @@ +import { ABSENT, bool, commaSet, joinHeader, joinRow, keyOf, num, optionalNum, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsBodyPresence, + TsMethodAccess, + TsMethodKind, + TsMethodModifier, + TsSignatureRole, +} from '@/enums/typescript/methods'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Every function-shaped declaration — schema §4.6, 43 columns. + * + * Functions, methods, constructors, accessors, arrows, function expressions, + * class static blocks, the synthetic `` initializer, **and every + * bodiless signature**: method signatures, call and construct signatures, and + * overload signatures. Positions 0–20 are byte-for-byte `java_method` 0–20. + * + * ## Two measurements shape this relation + * + * **11,599 overload signatures**, and **77.6% of overloaded calls resolve to a + * non-first declaration.** A call site must therefore be able to name ONE + * signature, which is why {@link signatureRole} is a column rather than a flag + * and why the primary key is per signature. A parser that resolves a call to a + * name and takes the first declaration is wrong on four overloaded calls in + * five — that is not a rounding error, it is the majority case. + * + * **703 arrow functions, 161 of them resolved call targets.** Arrows are rows + * here, not expression detail, and {@link startColumn} is in the key because + * `const [a, b] = [() => 1, () => 2]` yields two arrows sharing name, signature + * and line. That is the lambda hazard Python paid for, in a language with 703 + * of them. + * + * ## `bodyPresence` is the column that stops a `.d.ts` line becoming an implementation + * + * 44.3% of resolved call targets are bodiless. Reading one of those as the code + * that runs attributes behaviour to a declaration file, and nothing downstream + * can detect the mistake once made. + */ +export class TsMethodRegistry implements EntityIdentifiable { + static readonly ARITY = 43; + + readonly name: string; + readonly signature: string; + readonly detailedSignature: string; + readonly qualifiedName: string; + readonly filePath: string; + readonly startLine: number; + readonly endLine: number; + tsTypeLinkHash: string; + readonly ownerTypeName: string; + readonly ownerQualifiedName: string; + readonly methodAccess: TsMethodAccess; + readonly methodModifiers: ReadonlySet; + readonly returnTypeName: string; + readonly isVarArgs: boolean; + readonly hasReceiverParameter: boolean; + /** Parity slot with `java_method` 15, unused in TypeScript. */ + private readonly defaultValueExpression = ABSENT; + readonly methodKind: TsMethodKind; + readonly parameterCount: number; + readonly hasTypeParameters: boolean; + readonly throwsExceptions: ReadonlySet; + readonly enclosingMemberLinkHash: string; + + readonly tsModuleLinkHash: string; + readonly declarationGroupKey: string; + readonly mergeScopeKey: string; + readonly escapedName: string; + private signatureRole: TsSignatureRole; + private overloadIndex: number; + readonly bodyPresence: TsBodyPresence; + readonly isTypeOnly: boolean; + readonly isAsync: boolean; + readonly isGenerator: boolean; + readonly isAbstract: boolean; + readonly isStatic: boolean; + readonly optionalParameterCount: number; + readonly restParameterIndex: number | undefined; + readonly typeParameterCount: number; + readonly thisParameterTypeName: string; + private returnTypeReferenceLinkHash = ABSENT; + readonly isTypePredicateReturn: boolean; + readonly startColumn: number; + readonly endColumn: number; + readonly serviceVersionLinkHash: string; + private tsMethodUniqueHash = ABSENT; + + constructor(props: { + name: string; + signature: string; + detailedSignature: string; + qualifiedName: string; + filePath: string; + startLine: number; + endLine: number; + tsTypeLinkHash: string; + ownerTypeName: string; + ownerQualifiedName: string; + methodAccess: TsMethodAccess; + methodModifiers: ReadonlySet; + returnTypeName: string; + isVarArgs: boolean; + hasReceiverParameter: boolean; + methodKind: TsMethodKind; + parameterCount: number; + hasTypeParameters: boolean; + throwsExceptions: ReadonlySet; + enclosingMemberLinkHash: string; + tsModuleLinkHash: string; + declarationGroupKey: string; + mergeScopeKey: string; + escapedName: string; + signatureRole: TsSignatureRole; + overloadIndex: number; + bodyPresence: TsBodyPresence; + isTypeOnly: boolean; + isAsync: boolean; + isGenerator: boolean; + isAbstract: boolean; + isStatic: boolean; + optionalParameterCount: number; + restParameterIndex: number | undefined; + typeParameterCount: number; + thisParameterTypeName: string; + isTypePredicateReturn: boolean; + startColumn: number; + endColumn: number; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.signature = props.signature; + this.detailedSignature = props.detailedSignature; + this.qualifiedName = props.qualifiedName; + this.filePath = props.filePath; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.ownerTypeName = props.ownerTypeName; + this.ownerQualifiedName = props.ownerQualifiedName; + this.methodAccess = props.methodAccess; + this.methodModifiers = props.methodModifiers; + this.returnTypeName = props.returnTypeName; + this.isVarArgs = props.isVarArgs; + this.hasReceiverParameter = props.hasReceiverParameter; + this.methodKind = props.methodKind; + this.parameterCount = props.parameterCount; + this.hasTypeParameters = props.hasTypeParameters; + this.throwsExceptions = props.throwsExceptions; + this.enclosingMemberLinkHash = props.enclosingMemberLinkHash; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.declarationGroupKey = props.declarationGroupKey; + this.mergeScopeKey = props.mergeScopeKey; + this.escapedName = props.escapedName; + this.signatureRole = props.signatureRole; + this.overloadIndex = props.overloadIndex; + this.bodyPresence = props.bodyPresence; + this.isTypeOnly = props.isTypeOnly; + this.isAsync = props.isAsync; + this.isGenerator = props.isGenerator; + this.isAbstract = props.isAbstract; + this.isStatic = props.isStatic; + this.optionalParameterCount = props.optionalParameterCount; + this.restParameterIndex = props.restParameterIndex; + this.typeParameterCount = props.typeParameterCount; + this.thisParameterTypeName = props.thisParameterTypeName; + this.isTypePredicateReturn = props.isTypePredicateReturn; + this.startColumn = props.startColumn; + this.endColumn = props.endColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_METHOD_md5(tsModuleLinkHash ‖ tsTypeLinkHash ‖ qualifiedName ‖ signature ‖ startLine ‖ startColumn)` + * + * Deliberately does NOT include `signatureRole` or `overloadIndex`: both are + * assigned after the whole overload set is seen, and a key that moved when + * they were filled in would invalidate every child parameter row. + */ + generateHash(): void { + this.tsMethodUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_METHOD, + keyOf( + this.tsModuleLinkHash, + this.tsTypeLinkHash, + this.qualifiedName, + this.signature, + this.startLine, + this.startColumn + ) + ); + } + + getHash(): string { + return this.tsMethodUniqueHash; + } + + /** + * Assigns overload identity once the whole set is visible. + * + * Cannot be a constructor argument: whether a declaration is `SOLE` or an + * `OVERLOAD_SIGNATURE` is only knowable after its siblings have been seen, + * and the sibling may come later in the file. Safe to back-patch because + * neither column is in the primary key. + */ + setOverloadIdentity(role: TsSignatureRole, index: number): void { + this.signatureRole = role; + this.overloadIndex = index; + } + + getSignatureRole(): TsSignatureRole { + return this.signatureRole; + } + + /** An object-literal member's owner is known only once the literal is emitted. */ + setTsTypeLinkHash(hash: string): void { + this.tsTypeLinkHash = hash; + } + + setReturnTypeReferenceLinkHash(hash: string): void { + this.returnTypeReferenceLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_method[name=${this.name}, sig=${this.signature}, role=${this.signatureRole}, body=${this.bodyPresence}, hash=${this.tsMethodUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.signature), + text(this.detailedSignature), + text(this.qualifiedName), + text(this.filePath), + num(this.startLine), + num(this.endLine), + this.tsTypeLinkHash, + text(this.ownerTypeName), + text(this.ownerQualifiedName), + this.methodAccess, + commaSet(this.methodModifiers), + text(this.returnTypeName), + bool(this.isVarArgs), + bool(this.hasReceiverParameter), + this.defaultValueExpression, + this.methodKind, + num(this.parameterCount), + bool(this.hasTypeParameters), + commaSet(this.throwsExceptions), + this.enclosingMemberLinkHash, + this.tsModuleLinkHash, + this.declarationGroupKey, + text(this.mergeScopeKey), + text(this.escapedName), + this.signatureRole, + num(this.overloadIndex), + this.bodyPresence, + bool(this.isTypeOnly), + bool(this.isAsync), + bool(this.isGenerator), + bool(this.isAbstract), + bool(this.isStatic), + num(this.optionalParameterCount), + optionalNum(this.restParameterIndex), + num(this.typeParameterCount), + text(this.thisParameterTypeName), + this.returnTypeReferenceLinkHash, + bool(this.isTypePredicateReturn), + num(this.startColumn), + num(this.endColumn), + this.serviceVersionLinkHash, + this.tsMethodUniqueHash, + ], + TsMethodRegistry.ARITY, + 'ts_method' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', 'signature', 'detailedSignature', 'qualifiedName', 'filePath', 'startLine', + 'endLine', 'tsTypeLinkHash', 'ownerTypeName', 'ownerQualifiedName', 'methodAccess', + 'methodModifier', 'returnTypeName', 'isVarArgs', 'hasReceiverParameter', + 'defaultValueExpression', 'methodKind', 'parameterCount', 'hasTypeParameters', + 'throwsExceptions', 'enclosingMemberLinkHash', 'tsModuleLinkHash', + 'declarationGroupKey', 'mergeScopeKey', 'escapedName', 'signatureRole', + 'overloadIndex', 'bodyPresence', 'isTypeOnly', 'isAsync', 'isGenerator', 'isAbstract', + 'isStatic', 'optionalParameterCount', 'restParameterIndex', 'typeParameterCount', + 'thisParameterTypeName', 'returnTypeReferenceLinkHash', 'isTypePredicateReturn', + 'startColumn', 'endColumn', 'serviceVersionLinkHash', 'tsMethodUniqueHash', + ], + TsMethodRegistry.ARITY, + 'ts_method' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsModuleRegistry.ts b/parser/src/analysis-types/typescript/TsModuleRegistry.ts new file mode 100644 index 000000000..09e704b04 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsModuleRegistry.ts @@ -0,0 +1,233 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsEmissionRegime, + TsModuleKind, + TsModuleResolutionMode, + TsScriptKind, +} from '@/enums/typescript/modules'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A TypeScript module — schema §4.1, 28 columns. + * + * One row per `.ts`/`.tsx`/`.d.ts` file, **and** one per + * `declare module "x" { … }`, **and** one per `declare global { … }`. Those last + * two are not decoration: an ambient module declaration is an independently + * importable namespace and a merge scope of its own, one file may hold many + * (173 measured), which is why `declaredSpecifier` and `startLine` are in the + * primary key. + * + * There is no Java analogue. Java's package is implicit in a type's qualified + * name; a TypeScript module is simultaneously the unit of import resolution, + * the symbol MERGE TABLE that §3.1 keys off, and the boundary between module + * scope and global scope. + * + * ## `emissionRegime` is in the key; `targetTsVersion` is not + * + * Both record "which compiler", and they are deliberately separate. The regime + * is coarse (`ts6-inproc`) and sits in the PRIMARY KEY, so it propagates into + * every child hash and a 6.x fact base can never be silently mixed with a + * future 7.x one. The exact version (`6.0.3`) is provenance only: putting it in + * the key would invalidate the entire fact base on a patch bump, for a change + * that alters nothing about the facts. + */ +export class TsModuleRegistry implements EntityIdentifiable { + static readonly ARITY = 28; + + readonly name: string; + readonly qualifiedName: string; + readonly fileName: string; + readonly filePath: string; + readonly baseMservPath: string; + readonly moduleKind: TsModuleKind; + readonly scriptKind: TsScriptKind; + readonly declaredSpecifier: string; + readonly isDeclarationFile: boolean; + readonly isExternalModule: boolean; + readonly isAmbient: boolean; + readonly packageName: string; + readonly mergeTableKey: string; + readonly moduleResolutionMode: TsModuleResolutionMode; + readonly tsConfigPath: string; + readonly targetTsVersion: string; + readonly emissionRegime: TsEmissionRegime; + readonly startLine: number; + readonly endLine: number; + readonly hasTopLevelAwait: boolean; + readonly hasJsxContent: boolean; + + /** Back-patched: the module row is minted before its `` initializer exists. */ + private moduleInitMethodLinkHash = ABSENT; + private exportAssignmentLinkHash = ABSENT; + private defaultExportLinkHash = ABSENT; + + /** Parity slot, always `false` on parser output so `lib_ts_module` is byte-identical. */ + private readonly isExternal = false; + /** + * `strictBindCallApply` as the CHECKER resolves it, not as the config states it. + * + * `lib.es5.d.ts` declares `call`, `apply` and `bind` twice -- on `Function`, + * and again on `CallableFunction extends Function` with precise generic + * signatures. Which one a call resolves to is decided by this flag, so a + * consumer without it has two correct-looking candidates and no way to choose. + * + * RESOLVED is the whole point. `ts.parseJsonConfigFileContent` leaves this + * `undefined` when only `strict` is set -- the checker applies + * `strictBindCallApply ?? strict ?? false` itself -- so emitting the parsed + * option would not answer the question. Reading the config file would also + * leave a consumer to reimplement the implication and follow `extends`, + * which the parser has already done. + */ + readonly strictBindCallApply: boolean; + + readonly serviceVersionLinkHash: string; + private tsModuleUniqueHash = ABSENT; + + constructor(props: { + name: string; + qualifiedName: string; + fileName: string; + filePath: string; + baseMservPath: string; + moduleKind: TsModuleKind; + scriptKind: TsScriptKind; + declaredSpecifier: string; + isDeclarationFile: boolean; + isExternalModule: boolean; + isAmbient: boolean; + packageName: string; + mergeTableKey: string; + moduleResolutionMode: TsModuleResolutionMode; + tsConfigPath: string; + targetTsVersion: string; + emissionRegime: TsEmissionRegime; + startLine: number; + endLine: number; + hasTopLevelAwait: boolean; + hasJsxContent: boolean; + strictBindCallApply: boolean; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.qualifiedName = props.qualifiedName; + this.fileName = props.fileName; + this.filePath = props.filePath; + this.baseMservPath = props.baseMservPath; + this.moduleKind = props.moduleKind; + this.scriptKind = props.scriptKind; + this.declaredSpecifier = props.declaredSpecifier; + this.isDeclarationFile = props.isDeclarationFile; + this.isExternalModule = props.isExternalModule; + this.isAmbient = props.isAmbient; + this.packageName = props.packageName; + this.mergeTableKey = props.mergeTableKey; + this.moduleResolutionMode = props.moduleResolutionMode; + this.tsConfigPath = props.tsConfigPath; + this.targetTsVersion = props.targetTsVersion; + this.emissionRegime = props.emissionRegime; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.hasTopLevelAwait = props.hasTopLevelAwait; + this.hasJsxContent = props.hasJsxContent; + this.strictBindCallApply = props.strictBindCallApply; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_MODULE_md5(filePath ‖ baseMservPath ‖ declaredSpecifier ‖ startLine ‖ emissionRegime ‖ serviceVersionLinkHash)` + * + * `declaredSpecifier` and `startLine` are both present because one file can + * hold many ambient module declarations, and a key without them would collapse + * them into one row. + */ + generateHash(): void { + this.tsModuleUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_MODULE, + keyOf( + this.filePath, + this.baseMservPath, + this.declaredSpecifier, + this.startLine, + this.emissionRegime, + this.serviceVersionLinkHash + ) + ); + } + + getHash(): string { + return this.tsModuleUniqueHash; + } + + setModuleInitMethodLinkHash(hash: string): void { + this.moduleInitMethodLinkHash = hash; + } + + setExportAssignmentLinkHash(hash: string): void { + this.exportAssignmentLinkHash = hash; + } + + setDefaultExportLinkHash(hash: string): void { + this.defaultExportLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_module[name=${this.name}, kind=${this.moduleKind}, specifier=${this.declaredSpecifier}, hash=${this.tsModuleUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.qualifiedName), + text(this.fileName), + text(this.filePath), + text(this.baseMservPath), + this.moduleKind, + this.scriptKind, + text(this.declaredSpecifier), + bool(this.isDeclarationFile), + bool(this.isExternalModule), + bool(this.isAmbient), + text(this.packageName), + text(this.mergeTableKey), + this.moduleResolutionMode, + text(this.tsConfigPath), + this.targetTsVersion, + this.emissionRegime, + num(this.startLine), + num(this.endLine), + bool(this.hasTopLevelAwait), + bool(this.hasJsxContent), + this.moduleInitMethodLinkHash, + this.exportAssignmentLinkHash, + this.defaultExportLinkHash, + bool(this.isExternal), + this.serviceVersionLinkHash, + this.tsModuleUniqueHash, + bool(this.strictBindCallApply), + ], + TsModuleRegistry.ARITY, + 'ts_module' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', 'qualifiedName', 'fileName', 'filePath', 'baseMservPath', 'moduleKind', + 'scriptKind', 'declaredSpecifier', 'isDeclarationFile', 'isExternalModule', + 'isAmbient', 'packageName', 'mergeTableKey', 'moduleResolutionMode', 'tsConfigPath', + 'targetTsVersion', 'emissionRegime', 'startLine', 'endLine', 'hasTopLevelAwait', + 'hasJsxContent', 'moduleInitMethodLinkHash', 'exportAssignmentLinkHash', + 'defaultExportLinkHash', 'isExternal', 'serviceVersionLinkHash', 'tsModuleUniqueHash', + 'strictBindCallApply', + ], + TsModuleRegistry.ARITY, + 'ts_module' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsParseGapRegistry.ts b/parser/src/analysis-types/typescript/TsParseGapRegistry.ts new file mode 100644 index 000000000..ee7375930 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsParseGapRegistry.ts @@ -0,0 +1,104 @@ +import { ABSENT, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsParseGapKind } from '@/enums/typescript/parse-gaps'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One construct that could not be represented — schema §4.20, 10 columns. + * + * ## The relation exists BECAUSE it should always be empty + * + * Measured zero rows over 25.9 MB of real TypeScript with `ts.createSourceFile`. + * That is the argument for emitting it, not against: an always-empty relation + * that suddenly has rows is a SIGNAL, while a missing relation is a silence. On + * the day a file starts failing to parse, one of those shows up as data and the + * other as slightly fewer facts than yesterday. + * + * It RECORDS the gap and never rewrites source. Positions stay measured, so a + * consumer can go and look at the construct rather than trusting a summary. + */ +export class TsParseGapRegistry implements EntityIdentifiable { + static readonly ARITY = 10; + + readonly gapKind: TsParseGapKind; + readonly diagnosticCode: string; + readonly message: string; + readonly filePath: string; + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + readonly tsModuleLinkHash: string; + readonly serviceVersionLinkHash: string; + private tsParseGapUniqueHash = ABSENT; + + constructor(props: { + gapKind: TsParseGapKind; + diagnosticCode: string; + message: string; + filePath: string; + startLine: number; + startColumn: number; + endLine: number; + tsModuleLinkHash: string; + serviceVersionLinkHash: string; + }) { + this.gapKind = props.gapKind; + this.diagnosticCode = props.diagnosticCode; + this.message = props.message; + this.filePath = props.filePath; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.endLine = props.endLine; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_PARSE_GAP_md5(filePath ‖ gapKind ‖ startLine ‖ startColumn ‖ diagnosticCode)` */ + generateHash(): void { + this.tsParseGapUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_PARSE_GAP, + keyOf(this.filePath, this.gapKind, this.startLine, this.startColumn, this.diagnosticCode) + ); + } + + getHash(): string { + return this.tsParseGapUniqueHash; + } + + getEntryCombined(): string { + return `ts_parse_gap[kind=${this.gapKind}, code=${this.diagnosticCode}, file=${this.filePath}, line=${this.startLine}]`; + } + + toCsv(): string { + return joinRow( + [ + this.gapKind, + this.diagnosticCode, + text(this.message), + text(this.filePath), + num(this.startLine), + num(this.startColumn), + num(this.endLine), + this.tsModuleLinkHash, + this.serviceVersionLinkHash, + this.tsParseGapUniqueHash, + ], + TsParseGapRegistry.ARITY, + 'ts_parse_gap' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'gapKind', 'diagnosticCode', 'message', 'filePath', 'startLine', 'startColumn', + 'endLine', 'tsModuleLinkHash', 'serviceVersionLinkHash', 'tsParseGapUniqueHash', + ], + TsParseGapRegistry.ARITY, + 'ts_parse_gap' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsTypeHeritageRegistry.ts b/parser/src/analysis-types/typescript/TsTypeHeritageRegistry.ts new file mode 100644 index 000000000..1197ee459 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsTypeHeritageRegistry.ts @@ -0,0 +1,174 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TsClauseToken, TsHeritageKind } from '@/enums/typescript/heritage'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * One `extends` / `implements` clause entry — schema §4.3, 20 columns. + * + * ## This relation records SYNTAX. It is not a subtyping edge. + * + * That distinction does not exist in Java, where both clauses are authoritative, + * and getting it wrong here is the single most consequential porting error + * available. Measured: **60.4% of classes declare no `implements` at all**, + * **21.5%** of assignable (class, interface) pairs appear in no syntax anywhere, + * and **15.4%** are mutually assignable — which is not identity. + * + * {@link inheritsMembers} is the column that keeps it honest: `true` for + * `extends`, which really does inherit members, and `false` for `implements`, + * which is a compile-time assertion inheriting nothing. Path 4 of the resolution + * layer walks supertype members through this relation, and walking an + * `IMPLEMENTS_CLAUSE` row there is correct in Java and **wrong** here. + * + * For actual subtyping the engine derives `ts_type_satisfies` and the oracle + * adjudicates it with `isTypeAssignableTo`. The parser has no checker and must + * not pretend: a parser-emitted satisfaction row would be a guess dressed as a + * fact. + * + * Every entry also mints a `ts_type_reference` twin ({@link tsTypeReferenceLinkHash}), + * so heritage names resolve through the existing name-to-type machinery with no + * new rules. + */ +export class TsTypeHeritageRegistry implements EntityIdentifiable { + static readonly ARITY = 20; + + readonly heritageKind: TsHeritageKind; + readonly clauseToken: TsClauseToken; + readonly position: number; + readonly heritageText: string; + readonly heritageSimpleName: string; + readonly heritageQualifiedPath: string; + readonly typeArgumentCount: number; + readonly inheritsMembers: boolean; + readonly tsTypeLinkHash: string; + readonly tsModuleLinkHash: string; + private tsExpressionLinkHash = ABSENT; + private tsTypeReferenceLinkHash = ABSENT; + private resolvedTypeLinkHash = ABSENT; + private resolvedGroupKey = ABSENT; + private isResolvedLocally = false; + readonly isDynamic: boolean; + readonly startLine: number; + readonly startColumn: number; + readonly serviceVersionLinkHash: string; + private tsTypeHeritageUniqueHash = ABSENT; + + constructor(props: { + heritageKind: TsHeritageKind; + clauseToken: TsClauseToken; + position: number; + heritageText: string; + heritageSimpleName: string; + heritageQualifiedPath: string; + typeArgumentCount: number; + inheritsMembers: boolean; + tsTypeLinkHash: string; + tsModuleLinkHash: string; + isDynamic: boolean; + startLine: number; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.heritageKind = props.heritageKind; + this.clauseToken = props.clauseToken; + this.position = props.position; + this.heritageText = props.heritageText; + this.heritageSimpleName = props.heritageSimpleName; + this.heritageQualifiedPath = props.heritageQualifiedPath; + this.typeArgumentCount = props.typeArgumentCount; + this.inheritsMembers = props.inheritsMembers; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.isDynamic = props.isDynamic; + this.startLine = props.startLine; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_TYPE_HERITAGE_md5(tsTypeLinkHash ‖ clauseToken ‖ position ‖ heritageText ‖ startLine)` + * + * `clauseToken` is in the key rather than derived from `heritageKind`, because + * `class C extends B implements B` is legal and the two rows must not collide. + */ + generateHash(): void { + this.tsTypeHeritageUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_TYPE_HERITAGE, + keyOf( + this.tsTypeLinkHash, + this.clauseToken, + this.position, + this.heritageText, + this.startLine + ) + ); + } + + getHash(): string { + return this.tsTypeHeritageUniqueHash; + } + + setTsExpressionLinkHash(hash: string): void { + this.tsExpressionLinkHash = hash; + } + + setTsTypeReferenceLinkHash(hash: string): void { + this.tsTypeReferenceLinkHash = hash; + } + + setResolution(resolvedTypeLinkHash: string, resolvedGroupKey: string): void { + this.resolvedTypeLinkHash = resolvedTypeLinkHash; + this.resolvedGroupKey = resolvedGroupKey; + this.isResolvedLocally = resolvedTypeLinkHash !== ABSENT; + } + + getEntryCombined(): string { + return `ts_type_heritage[kind=${this.heritageKind}, text=${this.heritageText}, inherits=${this.inheritsMembers}, hash=${this.tsTypeHeritageUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.heritageKind, + this.clauseToken, + num(this.position), + text(this.heritageText), + text(this.heritageSimpleName), + text(this.heritageQualifiedPath), + num(this.typeArgumentCount), + bool(this.inheritsMembers), + this.tsTypeLinkHash, + this.tsModuleLinkHash, + this.tsExpressionLinkHash, + this.tsTypeReferenceLinkHash, + this.resolvedTypeLinkHash, + this.resolvedGroupKey, + bool(this.isResolvedLocally), + bool(this.isDynamic), + num(this.startLine), + num(this.startColumn), + this.serviceVersionLinkHash, + this.tsTypeHeritageUniqueHash, + ], + TsTypeHeritageRegistry.ARITY, + 'ts_type_heritage' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'heritageKind', 'clauseToken', 'position', 'heritageText', 'heritageSimpleName', + 'heritageQualifiedPath', 'typeArgumentCount', 'inheritsMembers', 'tsTypeLinkHash', + 'tsModuleLinkHash', 'tsExpressionLinkHash', 'tsTypeReferenceLinkHash', + 'resolvedTypeLinkHash', 'resolvedGroupKey', 'isResolvedLocally', 'isDynamic', + 'startLine', 'startColumn', 'serviceVersionLinkHash', 'tsTypeHeritageUniqueHash', + ], + TsTypeHeritageRegistry.ARITY, + 'ts_type_heritage' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsTypeParameterRegistry.ts b/parser/src/analysis-types/typescript/TsTypeParameterRegistry.ts new file mode 100644 index 000000000..3370ec19a --- /dev/null +++ b/parser/src/analysis-types/typescript/TsTypeParameterRegistry.ts @@ -0,0 +1,181 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsTypeParameterOwnerKind, + TsVarianceAnnotation, +} from '@/enums/typescript/type-parameters'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A generic type parameter — schema §4.4, 20 columns. + * + * Positions 0–6 mirror `java_type_parameter` 0–6. + * + * ## One relation where Java has two + * + * Java splits `java_type_parameter` from `java_method_type_parameter` because + * types and methods are its only owners. TypeScript attaches type parameters to + * **nine** kinds of declaration — including mapped types (`[K in keyof T]`) and + * conditional types (`infer U`), which have no Java analogue at all — so N + * relations would multiply without adding information. {@link ownerKind} plus the + * polymorphic {@link ownerLinkHash} carries it instead. + * + * The cost is exact and worth stating: the `method_type_parameter` projection + * ports as a rename plus a filter on `ownerKind`. Nothing else changes. + * + * ## Bounds stay separated even though the rows do not + * + * The BOUND is a `ts_type_reference` row, and its `context` distinguishes + * `TYPE_PARAM_BOUND` (a class, interface or type-alias parameter) from + * `METHOD_TYPE_PARAM_BOUND` (a function, method or signature parameter) exactly + * as Java does. So "every bound on a method type parameter" is still one + * predicate, and the Java query translates directly. + * + * ## Measured, so none of these columns is speculative + * + * 42,032 type parameters in one ecosystem corpus: 7,697 constrained, 2,014 + * defaulted, **562 variance-annotated** (`in`/`out`, TS 4.7) and **87 `const`** + * (TS 5.0). Both of the last two occur in the wild, so both get a column. + */ +export class TsTypeParameterRegistry implements EntityIdentifiable { + static readonly ARITY = 20; + + readonly paramName: string; + readonly position: number; + readonly ownerTypeName: string; + readonly ownerQualifiedName: string; + readonly filePath: string; + readonly startLine: number; + readonly tsTypeLinkHash: string; + readonly ownerKind: TsTypeParameterOwnerKind; + readonly ownerLinkHash: string; + private constraintReferenceLinkHash = ABSENT; + readonly constraintText: string; + private defaultReferenceLinkHash = ABSENT; + readonly defaultText: string; + readonly varianceAnnotation: TsVarianceAnnotation | ''; + readonly isConst: boolean; + readonly hasConstraint: boolean; + readonly hasDefault: boolean; + readonly startColumn: number; + readonly serviceVersionLinkHash: string; + private tsTypeParameterUniqueHash = ABSENT; + + constructor(props: { + paramName: string; + position: number; + ownerTypeName: string; + ownerQualifiedName: string; + filePath: string; + startLine: number; + tsTypeLinkHash: string; + ownerKind: TsTypeParameterOwnerKind; + ownerLinkHash: string; + constraintText: string; + defaultText: string; + varianceAnnotation: TsVarianceAnnotation | ''; + isConst: boolean; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.paramName = props.paramName; + this.position = props.position; + this.ownerTypeName = props.ownerTypeName; + this.ownerQualifiedName = props.ownerQualifiedName; + this.filePath = props.filePath; + this.startLine = props.startLine; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.ownerKind = props.ownerKind; + this.ownerLinkHash = props.ownerLinkHash; + this.constraintText = props.constraintText; + this.defaultText = props.defaultText; + this.varianceAnnotation = props.varianceAnnotation; + this.isConst = props.isConst; + // Derived from the TEXT rather than accepted as an argument, so the boolean + // and the text can never disagree — a caller that set one and forgot the + // other would produce a row claiming a constraint with nothing to join to. + this.hasConstraint = props.constraintText !== ''; + this.hasDefault = props.defaultText !== ''; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_TYPE_PARAMETER_md5(ownerLinkHash ‖ position ‖ paramName)` + * + * Chains off the owner. `position` and `paramName` are both present because a + * mapped type and its enclosing alias can declare parameters of the same name + * at different positions, and `infer U` can appear more than once in one + * conditional. + */ + generateHash(): void { + this.tsTypeParameterUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_TYPE_PARAMETER, + keyOf(this.ownerLinkHash, this.position, this.paramName) + ); + } + + getHash(): string { + return this.tsTypeParameterUniqueHash; + } + + /** The bound's `ts_type_reference` root — `TYPE_PARAM_BOUND` or `METHOD_TYPE_PARAM_BOUND`. */ + setConstraintReferenceLinkHash(hash: string): void { + this.constraintReferenceLinkHash = hash; + } + + /** The default's `ts_type_reference` root — `TYPE_PARAM_DEFAULT`. */ + setDefaultReferenceLinkHash(hash: string): void { + this.defaultReferenceLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_type_parameter[name=${this.paramName}, pos=${this.position}, owner=${this.ownerKind}, constraint=${this.constraintText}, hash=${this.tsTypeParameterUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.paramName), + num(this.position), + text(this.ownerTypeName), + text(this.ownerQualifiedName), + text(this.filePath), + num(this.startLine), + this.tsTypeLinkHash, + this.ownerKind, + this.ownerLinkHash, + this.constraintReferenceLinkHash, + text(this.constraintText), + this.defaultReferenceLinkHash, + text(this.defaultText), + this.varianceAnnotation, + bool(this.isConst), + bool(this.hasConstraint), + bool(this.hasDefault), + num(this.startColumn), + this.serviceVersionLinkHash, + this.tsTypeParameterUniqueHash, + ], + TsTypeParameterRegistry.ARITY, + 'ts_type_parameter' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'paramName', 'position', 'ownerTypeName', 'ownerQualifiedName', 'filePath', 'startLine', + 'tsTypeLinkHash', 'ownerKind', 'ownerLinkHash', 'constraintReferenceLinkHash', + 'constraintText', 'defaultReferenceLinkHash', 'defaultText', 'varianceAnnotation', + 'isConst', 'hasConstraint', 'hasDefault', 'startColumn', 'serviceVersionLinkHash', + 'tsTypeParameterUniqueHash', + ], + TsTypeParameterRegistry.ARITY, + 'ts_type_parameter' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsTypeReferenceRegistry.ts b/parser/src/analysis-types/typescript/TsTypeReferenceRegistry.ts new file mode 100644 index 000000000..847229052 --- /dev/null +++ b/parser/src/analysis-types/typescript/TsTypeReferenceRegistry.ts @@ -0,0 +1,237 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsReferenceOwnerKind, + TsTypeRefContext, + TsTypeRefKind, +} from '@/enums/typescript/type-references'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * The TYPE-NODE TREE — schema §4.5, 30 columns. + * + * Positions 0–16 mirror `java_type_reference` 0–16, so the whole name-to-type + * resolution layer ports as a relation rename. + * + * ## This relation is a containment guarantee, not just a table + * + * Every type-level construct lives here and **nowhere else**: conditional, + * mapped, template-literal, `infer`, `keyof`, `typeof`, indexed access — 10,436 + * such nodes measured, none with a Java or Python analogue. Because there is no + * other relation for them to be in, no call-graph rule can reach them. §3.3 is + * enforced structurally rather than by a filter someone has to remember. + * + * ## A union is N ROWS, never one row with a list + * + * `A | B | C` is one parent row with `childCount = 3` plus three child rows + * carrying `position` and `parentReferenceHash` — the same tree Java already + * uses for `Map`. The measurement killed the comma-set alternative three + * times over: max arity is **208**, members are arbitrary nested type nodes + * rather than names, and 45 nodes nest. + * + * {@link position} is **source** order and {@link childCount} is **source** + * arity. The checker normalises `boolean` into `true | false` and reorders by + * type id, so an oracle comparing member-wise against the checker would fail on + * correct output. Type-node trees are what is compared; `typeToString` is for + * reporting only. + */ +export class TsTypeReferenceRegistry implements EntityIdentifiable { + static readonly ARITY = 31; + + readonly kind: TsTypeRefKind; + readonly context: TsTypeRefContext; + readonly tsTypeLinkHash: string; + private typeParameterLinkHash = ABSENT; + private referencedTypeLinkHash = ABSENT; + readonly parentReferenceHash: string; + readonly position: number; + readonly depth: number; + readonly typeName: string; + readonly completeTypeName: string; + readonly entityName: string; + readonly typeVariableName: string; + readonly arrayDimensions: string; + readonly wildcardVariance: string; + readonly startLine: number; + readonly endLine: number; + readonly typeReferenceOwnerHash: string; + readonly referenceOwnerKind: TsReferenceOwnerKind; + readonly tsModuleLinkHash: string; + readonly childCount: number; + readonly isTypeOnlyPosition: boolean; + private resolvedGroupKey = ABSENT; + private isResolvedLocally = false; + readonly importSpecifier: string; + readonly isOptionalElement: boolean; + readonly isRestElement: boolean; + readonly literalValue: string; + readonly isTruncated: boolean; + readonly startColumn: number; + readonly serviceVersionLinkHash: string; + private tsTypeReferenceUniqueHash = ABSENT; + + constructor(props: { + kind: TsTypeRefKind; + context: TsTypeRefContext; + tsTypeLinkHash: string; + parentReferenceHash: string; + position: number; + depth: number; + typeName: string; + completeTypeName: string; + entityName?: string; + typeVariableName: string; + arrayDimensions: string; + wildcardVariance: string; + startLine: number; + endLine: number; + typeReferenceOwnerHash: string; + referenceOwnerKind: TsReferenceOwnerKind; + tsModuleLinkHash: string; + childCount: number; + isTypeOnlyPosition: boolean; + importSpecifier: string; + isOptionalElement: boolean; + isRestElement: boolean; + literalValue: string; + isTruncated: boolean; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.kind = props.kind; + this.context = props.context; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.parentReferenceHash = props.parentReferenceHash; + this.position = props.position; + this.depth = props.depth; + this.typeName = props.typeName; + this.completeTypeName = props.completeTypeName; + this.entityName = props.entityName ?? ''; + this.typeVariableName = props.typeVariableName; + this.arrayDimensions = props.arrayDimensions; + this.wildcardVariance = props.wildcardVariance; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.typeReferenceOwnerHash = props.typeReferenceOwnerHash; + this.referenceOwnerKind = props.referenceOwnerKind; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.childCount = props.childCount; + this.isTypeOnlyPosition = props.isTypeOnlyPosition; + this.importSpecifier = props.importSpecifier; + this.isOptionalElement = props.isOptionalElement; + this.isRestElement = props.isRestElement; + this.literalValue = props.literalValue; + this.isTruncated = props.isTruncated; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_TYPE_REFERENCE_md5(typeReferenceOwnerHash ‖ context ‖ parentReferenceHash ‖ position ‖ depth ‖ completeTypeName ‖ startLine ‖ startColumn)` + * + * Invariant 7 depends on `depth = 0` being exactly the rows with an empty + * `parentReferenceHash`, so the root of every tree is minted with both. + */ + generateHash(): void { + this.tsTypeReferenceUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_TYPE_REFERENCE, + keyOf( + this.typeReferenceOwnerHash, + this.context, + this.parentReferenceHash, + this.position, + this.depth, + this.completeTypeName, + this.startLine, + this.startColumn + ) + ); + } + + getHash(): string { + return this.tsTypeReferenceUniqueHash; + } + + setTypeParameterLinkHash(hash: string): void { + this.typeParameterLinkHash = hash; + } + + /** + * Records a resolution the PARSER could make from syntax alone. + * + * Left empty otherwise. An unfilled FK is a measured gap the engine closes; + * a filled wrong one is a fact nothing downstream can question. + */ + setResolution(referencedTypeLinkHash: string, resolvedGroupKey: string): void { + this.referencedTypeLinkHash = referencedTypeLinkHash; + this.resolvedGroupKey = resolvedGroupKey; + this.isResolvedLocally = referencedTypeLinkHash !== ABSENT; + } + + getResolvedGroupKey(): string { + return this.resolvedGroupKey; + } + + getEntryCombined(): string { + return `ts_type_reference[kind=${this.kind}, ctx=${this.context}, name=${this.completeTypeName}, depth=${this.depth}, hash=${this.tsTypeReferenceUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + this.kind, + this.context, + this.tsTypeLinkHash, + this.typeParameterLinkHash, + this.referencedTypeLinkHash, + this.parentReferenceHash, + num(this.position), + num(this.depth), + text(this.typeName), + text(this.completeTypeName), + text(this.entityName), + text(this.typeVariableName), + text(this.arrayDimensions), + this.wildcardVariance, + num(this.startLine), + num(this.endLine), + this.typeReferenceOwnerHash, + this.referenceOwnerKind, + this.tsModuleLinkHash, + num(this.childCount), + bool(this.isTypeOnlyPosition), + this.resolvedGroupKey, + bool(this.isResolvedLocally), + text(this.importSpecifier), + bool(this.isOptionalElement), + bool(this.isRestElement), + text(this.literalValue), + bool(this.isTruncated), + num(this.startColumn), + this.serviceVersionLinkHash, + this.tsTypeReferenceUniqueHash, + ], + TsTypeReferenceRegistry.ARITY, + 'ts_type_reference' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'kind', 'context', 'tsTypeLinkHash', 'typeParameterLinkHash', 'referencedTypeLinkHash', + 'parentReferenceHash', 'position', 'depth', 'typeName', 'completeTypeName', 'entityName', + 'typeVariableName', 'arrayDimensions', 'wildcardVariance', 'startLine', 'endLine', + 'typeReferenceOwnerHash', 'referenceOwnerKind', 'tsModuleLinkHash', 'childCount', + 'isTypeOnlyPosition', 'resolvedGroupKey', 'isResolvedLocally', 'importSpecifier', + 'isOptionalElement', 'isRestElement', 'literalValue', 'isTruncated', 'startColumn', + 'serviceVersionLinkHash', 'tsTypeReferenceUniqueHash', + ], + TsTypeReferenceRegistry.ARITY, + 'ts_type_reference' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsTypeRegistry.ts b/parser/src/analysis-types/typescript/TsTypeRegistry.ts new file mode 100644 index 000000000..8d60650dd --- /dev/null +++ b/parser/src/analysis-types/typescript/TsTypeRegistry.ts @@ -0,0 +1,257 @@ +import { ABSENT, bool, commaSet, joinHeader, joinRow, keyOf, num, text } from './ts-row'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsDeclarationSpace, + TsTypeAccess, + TsTypeCategory, + TsTypeModifier, + TsTypePlacement, +} from '@/enums/typescript/types'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A type DECLARATION — schema §4.2, 33 columns. + * + * Class, interface, enum, type alias, namespace, or class expression. Columns + * 0–11 mirror `java_type` 0–11 so `type_decl` / `type_lines` port as a rename. + * + * **Anonymous structural types are not here.** 21,956 function types and 5,015 + * type literals were measured in the ecosystem corpus; none has a name, a + * declaration or a merge identity, and they live in the `ts_type_reference` + * tree instead. Keeping this relation to declarations is what keeps it key-able + * at all. + * + * ## One row per DECLARATION SITE, not per type + * + * This is the §3.1 decision and it is stated here because every consumer trips + * on it: `name -> single entity` is false in TypeScript. 1,986 symbols in the + * measured corpus have more than one declaration and one has 43, merging + * crosses declaration kinds (class+interface, function+namespace) and crosses + * files. + * + * So the primary key is the SITE — always unique, always parser-derivable, never + * needing cross-file knowledge — and {@link declarationGroupKey} is the merged + * entity's identity. That key is deliberately **not unique**: N declarations of + * one type produce N rows carrying the same group key, and the engine forms the + * merged type by grouping on it. A rule that assumes one row per name is wrong + * by construction, not merely imprecise. + * + * There is deliberately no `isPrimaryDeclaration` and no `declarationIndex`: + * choosing "the" declaration requires seeing all of them, which is cross-file, + * which a single-file extraction may not do. A column here would be a lie. + */ +export class TsTypeRegistry implements EntityIdentifiable { + static readonly ARITY = 33; + + readonly name: string; + readonly qualifiedName: string; + readonly fileName: string; + readonly typeCategory: TsTypeCategory; + readonly typeAccess: TsTypeAccess; + readonly typeModifiers: ReadonlySet; + readonly typePlacement: TsTypePlacement; + readonly filePath: string; + readonly baseMservPath: string; + readonly startLine: number; + readonly endLine: number; + private readonly isExternal = false; + + readonly tsModuleLinkHash: string; + readonly enclosingTypeLinkHash: string; + readonly enclosingMethodLinkHash: string; + readonly declarationGroupKey: string; + readonly mergeScopeKey: string; + readonly escapedName: string; + readonly declarationSpaces: ReadonlySet; + readonly isAmbientDeclaration: boolean; + readonly isTypeOnly: boolean; + readonly typeParameterCount: number; + readonly heritageCount: number; + + /** Own declared members. Back-patched once members are extracted. */ + private memberCount = 0; + private requiredMemberCount = 0; + private shapeDigest = ABSENT; + private aliasTargetReferenceLinkHash = ABSENT; + + readonly isExported: boolean; + readonly hasIndexSignature: boolean; + readonly startColumn: number; + readonly endColumn: number; + readonly serviceVersionLinkHash: string; + private tsTypeUniqueHash = ABSENT; + + constructor(props: { + name: string; + qualifiedName: string; + fileName: string; + typeCategory: TsTypeCategory; + typeAccess: TsTypeAccess; + typeModifiers: ReadonlySet; + typePlacement: TsTypePlacement; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + tsModuleLinkHash: string; + enclosingTypeLinkHash: string; + enclosingMethodLinkHash: string; + declarationGroupKey: string; + mergeScopeKey: string; + escapedName: string; + declarationSpaces: ReadonlySet; + isAmbientDeclaration: boolean; + isTypeOnly: boolean; + typeParameterCount: number; + heritageCount: number; + isExported: boolean; + hasIndexSignature: boolean; + startColumn: number; + endColumn: number; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.qualifiedName = props.qualifiedName; + this.fileName = props.fileName; + this.typeCategory = props.typeCategory; + this.typeAccess = props.typeAccess; + this.typeModifiers = props.typeModifiers; + this.typePlacement = props.typePlacement; + this.filePath = props.filePath; + this.baseMservPath = props.baseMservPath; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.enclosingTypeLinkHash = props.enclosingTypeLinkHash; + this.enclosingMethodLinkHash = props.enclosingMethodLinkHash; + this.declarationGroupKey = props.declarationGroupKey; + this.mergeScopeKey = props.mergeScopeKey; + this.escapedName = props.escapedName; + this.declarationSpaces = props.declarationSpaces; + this.isAmbientDeclaration = props.isAmbientDeclaration; + this.isTypeOnly = props.isTypeOnly; + this.typeParameterCount = props.typeParameterCount; + this.heritageCount = props.heritageCount; + this.isExported = props.isExported; + this.hasIndexSignature = props.hasIndexSignature; + this.startColumn = props.startColumn; + this.endColumn = props.endColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** + * **PK** `TS_TYPE_md5(tsModuleLinkHash ‖ escapedName ‖ mergeScopeKey ‖ startLine ‖ startColumn)` + * + * Chained off the module hash, never off a re-derived qualified name — and + * here that is not a stylistic preference. Two declarations of one merged + * interface share a qualified name BY DESIGN, so a qualified-name key would + * collide on precisely the construct this relation exists to represent. + * + * `startColumn` is present because a namespace-merged declaration and a class + * expression can share a line. + */ + generateHash(): void { + this.tsTypeUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_TYPE, + keyOf( + this.tsModuleLinkHash, + this.escapedName, + this.mergeScopeKey, + this.startLine, + this.startColumn + ) + ); + } + + getHash(): string { + return this.tsTypeUniqueHash; + } + + /** + * Records the member shape once members are known. + * + * `requiredMemberCount` is separate from `memberCount` because an absent + * optional member does not break assignability, so it is the number + * structural-satisfaction pruning actually uses (§3.2). + * + * `shapeDigest` is **tier 3**: a pruning aid with no semantic claim. Equal + * digests make two types candidates for satisfaction; they are never a + * satisfaction fact. `ts_type_satisfies` is the engine's, adjudicated by the + * oracle's `isTypeAssignableTo`, and the parser must not pretend otherwise. + */ + setShape(memberCount: number, requiredMemberCount: number, shapeDigest: string): void { + this.memberCount = memberCount; + this.requiredMemberCount = requiredMemberCount; + this.shapeDigest = shapeDigest; + } + + setAliasTargetReferenceLinkHash(hash: string): void { + this.aliasTargetReferenceLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_type[name=${this.name}, category=${this.typeCategory}, group=${this.declarationGroupKey}, hash=${this.tsTypeUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.qualifiedName), + text(this.fileName), + this.typeCategory, + this.typeAccess, + commaSet(this.typeModifiers), + this.typePlacement, + text(this.filePath), + text(this.baseMservPath), + num(this.startLine), + num(this.endLine), + bool(this.isExternal), + this.tsModuleLinkHash, + this.enclosingTypeLinkHash, + this.enclosingMethodLinkHash, + this.declarationGroupKey, + text(this.mergeScopeKey), + text(this.escapedName), + commaSet(this.declarationSpaces), + bool(this.isAmbientDeclaration), + bool(this.isTypeOnly), + num(this.typeParameterCount), + num(this.heritageCount), + num(this.memberCount), + num(this.requiredMemberCount), + this.shapeDigest, + this.aliasTargetReferenceLinkHash, + bool(this.isExported), + bool(this.hasIndexSignature), + num(this.startColumn), + num(this.endColumn), + this.serviceVersionLinkHash, + this.tsTypeUniqueHash, + ], + TsTypeRegistry.ARITY, + 'ts_type' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', 'qualifiedName', 'fileName', 'typeCategory', 'typeAccess', 'typeModifier', + 'typePlacement', 'filePath', 'baseMservPath', 'startLine', 'endLine', 'isExternal', + 'tsModuleLinkHash', 'enclosingTypeLinkHash', 'enclosingMethodLinkHash', + 'declarationGroupKey', 'mergeScopeKey', 'escapedName', 'declarationSpaces', + 'isAmbientDeclaration', 'isTypeOnly', 'typeParameterCount', 'heritageCount', + 'memberCount', 'requiredMemberCount', 'shapeDigest', 'aliasTargetReferenceLinkHash', + 'isExported', 'hasIndexSignature', 'startColumn', 'endColumn', + 'serviceVersionLinkHash', 'tsTypeUniqueHash', + ], + TsTypeRegistry.ARITY, + 'ts_type' + ); + } +} diff --git a/parser/src/analysis-types/typescript/TsVariableRegistry.ts b/parser/src/analysis-types/typescript/TsVariableRegistry.ts new file mode 100644 index 000000000..92156b42e --- /dev/null +++ b/parser/src/analysis-types/typescript/TsVariableRegistry.ts @@ -0,0 +1,231 @@ +import { ABSENT, bool, joinHeader, joinRow, keyOf, num, text } from './ts-row'; +import { TsBindingSourceKind } from '@/enums/typescript/variables/TsBindingSourceKind'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TsVariableDeclarationKind, + TsVariableInitializerKind, + TsVariableScopeKind, +} from '@/enums/typescript/variables'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A variable declaration at module, function, block or global scope — + * schema §4.11, 29 columns. Positions 0–8 mirror `java_local_variable` 0–8. + * + * Widened beyond Java's "local" because a module-level `const` is a first-class + * declaration here: it merges (a module-scope variable can merge with a + * namespace of the same name, which is why it carries a + * {@link declarationGroupKey}), it is exported, and it is imported by name. + * + * ## `boundFunctionLinkHash` is what makes `const f = () => {}; f()` resolvable + * + * 161 measured call targets are arrow functions, and every one of them is + * reached through the variable that binds it — the arrow has no name of its own + * for a call site to match. Java never needed this link and Python folded the + * equivalent into `py_binding`. + * + * ## Why there is no `ts_scope` relation + * + * Measured, not assumed (OQ-6): 34,798 identifier references resolve to a + * declaration and the `ts_block -> ts_method -> ts_type -> ts_module` chain + * reaches **every one**, with zero unreachable. A scope table would carry no + * information the FKs do not already carry. {@link tsBlockLinkHash} is the + * lexical link that makes that true for `let`/`const`. + */ +export class TsVariableRegistry implements EntityIdentifiable { + static readonly ARITY = 31; + + readonly name: string; + readonly variableTypeName: string; + readonly variableBaseType: string; + readonly potentialQualifiedName: string; + readonly isAmbiguous: boolean; + readonly filePath: string; + readonly startLine: number; + readonly endLine: number; + readonly scopeKind: TsVariableScopeKind; + readonly scopeDepth: number; + readonly isConst: boolean; + readonly isTypeInferred: boolean; + readonly tsTypeLinkHash: string; + readonly tsMethodLinkHash: string; + readonly tsModuleLinkHash: string; + readonly tsBlockLinkHash: string; + readonly declarationKind: TsVariableDeclarationKind; + readonly hasInitializer: boolean; + readonly initializerKind: TsVariableInitializerKind; + private boundFunctionLinkHash = ABSENT; + private typeReferenceLinkHash = ABSENT; + private initializerExpressionLinkHash = ABSENT; + readonly isExported: boolean; + readonly isAmbientDeclare: boolean; + readonly isDestructuring: boolean; + readonly bindingSourceKind: TsBindingSourceKind; + readonly bindingSource: string; + readonly declarationGroupKey: string; + readonly startColumn: number; + readonly serviceVersionLinkHash: string; + private tsVariableUniqueHash = ABSENT; + + constructor(props: { + name: string; + variableTypeName: string; + variableBaseType: string; + potentialQualifiedName: string; + isAmbiguous: boolean; + filePath: string; + startLine: number; + endLine: number; + scopeKind: TsVariableScopeKind; + scopeDepth: number; + isConst: boolean; + isTypeInferred: boolean; + tsTypeLinkHash: string; + tsMethodLinkHash: string; + tsModuleLinkHash: string; + tsBlockLinkHash: string; + declarationKind: TsVariableDeclarationKind; + hasInitializer: boolean; + initializerKind: TsVariableInitializerKind; + isExported: boolean; + isAmbientDeclare: boolean; + isDestructuring: boolean; + bindingSourceKind?: TsBindingSourceKind; + bindingSource?: string; + declarationGroupKey: string; + startColumn: number; + serviceVersionLinkHash: string; + }) { + this.name = props.name; + this.variableTypeName = props.variableTypeName; + this.variableBaseType = props.variableBaseType; + this.potentialQualifiedName = props.potentialQualifiedName; + this.isAmbiguous = props.isAmbiguous; + this.filePath = props.filePath; + this.startLine = props.startLine; + this.endLine = props.endLine; + this.scopeKind = props.scopeKind; + this.scopeDepth = props.scopeDepth; + this.isConst = props.isConst; + this.isTypeInferred = props.isTypeInferred; + this.tsTypeLinkHash = props.tsTypeLinkHash; + this.tsMethodLinkHash = props.tsMethodLinkHash; + this.tsModuleLinkHash = props.tsModuleLinkHash; + this.tsBlockLinkHash = props.tsBlockLinkHash; + this.declarationKind = props.declarationKind; + this.hasInitializer = props.hasInitializer; + this.initializerKind = props.initializerKind; + this.isExported = props.isExported; + this.isAmbientDeclare = props.isAmbientDeclare; + this.isDestructuring = props.isDestructuring; + this.bindingSourceKind = props.bindingSourceKind ?? TsBindingSourceKind.NONE; + this.bindingSource = props.bindingSource ?? ''; + this.declarationGroupKey = props.declarationGroupKey; + this.startColumn = props.startColumn; + this.serviceVersionLinkHash = props.serviceVersionLinkHash; + this.generateHash(); + } + + /** **PK** `TS_VARIABLE_md5(tsModuleLinkHash ‖ tsMethodLinkHash ‖ tsBlockLinkHash ‖ name ‖ startLine ‖ startColumn)` */ + generateHash(): void { + this.tsVariableUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_VARIABLE, + keyOf( + this.tsModuleLinkHash, + this.tsMethodLinkHash, + this.tsBlockLinkHash, + this.name, + this.startLine, + this.startColumn + ) + ); + } + + getHash(): string { + return this.tsVariableUniqueHash; + } + + /** The arrow or function expression this variable binds. See the class note. */ + setBoundFunctionLinkHash(hash: string): void { + this.boundFunctionLinkHash = hash; + } + + getBoundFunctionLinkHash(): string { + return this.boundFunctionLinkHash; + } + + setTypeReferenceLinkHash(hash: string): void { + this.typeReferenceLinkHash = hash; + } + + getTypeReferenceLinkHash(): string { + return this.typeReferenceLinkHash; + } + + setInitializerExpressionLinkHash(hash: string): void { + this.initializerExpressionLinkHash = hash; + } + + getEntryCombined(): string { + return `ts_variable[name=${this.name}, kind=${this.declarationKind}, scope=${this.scopeKind}, hash=${this.tsVariableUniqueHash}]`; + } + + toCsv(): string { + return joinRow( + [ + text(this.name), + text(this.variableTypeName), + text(this.variableBaseType), + text(this.potentialQualifiedName), + bool(this.isAmbiguous), + text(this.filePath), + num(this.startLine), + num(this.endLine), + this.scopeKind, + num(this.scopeDepth), + bool(this.isConst), + bool(this.isTypeInferred), + this.tsTypeLinkHash, + this.tsMethodLinkHash, + this.tsModuleLinkHash, + this.tsBlockLinkHash, + this.declarationKind, + bool(this.hasInitializer), + this.initializerKind, + this.boundFunctionLinkHash, + this.typeReferenceLinkHash, + this.initializerExpressionLinkHash, + bool(this.isExported), + bool(this.isAmbientDeclare), + bool(this.isDestructuring), + this.bindingSourceKind, + text(this.bindingSource), + this.declarationGroupKey, + num(this.startColumn), + this.serviceVersionLinkHash, + this.tsVariableUniqueHash, + ], + TsVariableRegistry.ARITY, + 'ts_variable' + ); + } + + getCsvHeader(): string { + return joinHeader( + [ + 'name', 'variableTypeName', 'variableBaseType', 'potentialQualifiedName', 'isAmbiguous', + 'filePath', 'startLine', 'endLine', 'scopeKind', 'scopeDepth', 'isConst', + 'isTypeInferred', 'tsTypeLinkHash', 'tsMethodLinkHash', 'tsModuleLinkHash', + 'tsBlockLinkHash', 'declarationKind', 'hasInitializer', 'initializerKind', + 'boundFunctionLinkHash', 'typeReferenceLinkHash', 'initializerExpressionLinkHash', + 'isExported', 'isAmbientDeclare', 'isDestructuring', 'bindingSourceKind', 'bindingSource', + 'declarationGroupKey', + 'startColumn', 'serviceVersionLinkHash', 'tsVariableUniqueHash', + ], + TsVariableRegistry.ARITY, + 'ts_variable' + ); + } +} diff --git a/parser/src/analysis-types/typescript/index.ts b/parser/src/analysis-types/typescript/index.ts new file mode 100644 index 000000000..99a7713de --- /dev/null +++ b/parser/src/analysis-types/typescript/index.ts @@ -0,0 +1,21 @@ +export * from './ts-row'; +export * from './TsBlockRegistry'; +export * from './TsCallSiteRegistry'; +export * from './TsCommentRegistry'; +export * from './TsDecoratorArgumentRegistry'; +export * from './TsDecoratorRegistry'; +export * from './TsEnumMemberRegistry'; +export * from './TsExportRegistry'; +export * from './TsExpressionRegistry'; +export * from './TsFieldPositionRegistry'; +export * from './TsFieldRegistry'; +export * from './TsImportRegistry'; +export * from './TsMethodParameterRegistry'; +export * from './TsMethodRegistry'; +export * from './TsModuleRegistry'; +export * from './TsParseGapRegistry'; +export * from './TsTypeHeritageRegistry'; +export * from './TsTypeParameterRegistry'; +export * from './TsTypeReferenceRegistry'; +export * from './TsTypeRegistry'; +export * from './TsVariableRegistry'; diff --git a/parser/src/analysis-types/typescript/ts-row.ts b/parser/src/analysis-types/typescript/ts-row.ts new file mode 100644 index 000000000..f4d6dde88 --- /dev/null +++ b/parser/src/analysis-types/typescript/ts-row.ts @@ -0,0 +1,79 @@ +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Shared row plumbing for the TypeScript fact relations. + * + * ## Why arity is asserted at runtime and not merely reviewed + * + * `decls_base_ts.dl` carries positional `c0..cN` and nothing else, so a column + * dropped or transposed in a `toCsv()` produces a file that loads cleanly into + * Souffle and means something different. There is no type error, no parse + * error, and no failing join — every later column is shifted by one and the + * relation still has rows. That is the exact failure mode `gen_decls.py --check` + * exists to prevent on the schema side; this is its counterpart on the emit + * side. + * + * So every relation states its arity as a constant taken from the schema, and + * every row is built through {@link joinRow}, which refuses to emit a row of the + * wrong width. The check costs one comparison per row and turns a silent + * corruption into a loud stop. + */ +export function joinRow(columns: readonly string[], expectedArity: number, relation: string): string { + if (columns.length !== expectedArity) { + throw new Error( + `${relation}: emitted ${columns.length} columns, schema declares ${expectedArity}. ` + + 'Column ORDER is the contract and a shifted column loads without error — ' + + 'fix the toCsv(), never the arity constant.' + ); + } + return columns.join('\t'); +} + +/** Header row, held to the same arity as the data rows for the same reason. */ +export function joinHeader(names: readonly string[], expectedArity: number, relation: string): string { + return joinRow(names, expectedArity, relation); +} + +/** Souffle has no nulls: `""` is the legal "absent" value everywhere in this schema. */ +export const ABSENT = ''; + +/** A boolean column. Written as the literal `true`/`false` the `.dl` compares against. */ +export function bool(value: boolean): string { + return value ? 'true' : 'false'; +} + +/** A numeric column. */ +export function num(value: number): string { + return String(value); +} + +/** + * An optional numeric column: `""` when there is no number, NOT `-1` and NOT `0`. + * + * `restParameterIndex` and `spreadArgumentIndex` are both legitimately `0`, so a + * sentinel would be indistinguishable from the first position. + */ +export function optionalNum(value: number | undefined): string { + return value === undefined ? ABSENT : String(value); +} + +/** Free text bound for TSV: newlines and tabs escaped, quotes doubled. */ +export function text(value: string): string { + return EntityUtils.escapeTsv(value); +} + +/** A comma-set column, sorted so the value is independent of source order. */ +export function commaSet(values: Iterable): string { + return Array.from(new Set(values)).sort().join(','); +} + +/** + * The `||` join used by every primary key in this schema. + * + * Components go through {@link String} untouched: they are hashes, names, + * numbers and enum values, and escaping them would make two different keys + * collide in the escaping rather than in the content. + */ +export function keyOf(...components: (string | number)[]): string { + return components.map(String).join('||'); +} diff --git a/parser/src/analysis-types/xml/XmlAttribute.ts b/parser/src/analysis-types/xml/XmlAttribute.ts new file mode 100644 index 000000000..ab0d4aacb --- /dev/null +++ b/parser/src/analysis-types/xml/XmlAttribute.ts @@ -0,0 +1,164 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a single attribute on an XML element. + * + * Captures every attribute found on every element in an XML document, + * linked back to the owning element via parentElementHash. + * + * ## CSV Export Format + * + * Column order: + * 1. name, value, namespace + * 2. filePath, baseMservPath, startLine, endLine + * 3. parentElementHash, serviceVersionLinkHash + * 4. xmlAttributeUniqueHash (LAST) + */ +export class XmlAttribute implements EntityIdentifiable { + private name: string; + private value: string; + private namespace: string; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private parentElementHash: string; + private serviceVersionLinkHash: string; + private xmlAttributeUniqueHash: string = ''; + + private constructor(builder: XmlAttributeBuilder) { + this.name = builder.name; + this.value = builder.value; + this.namespace = builder.namespace; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.parentElementHash = builder.parentElementHash; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + name: string, + value: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + parentElementHash: string, + serviceVersionLinkHash: string + ): XmlAttributeBuilder { + return new XmlAttributeBuilder( + name, value, filePath, baseMservPath, + startLine, endLine, parentElementHash, serviceVersionLinkHash + ); + } + + getName(): string { return this.name; } + getValue(): string { return this.value; } + getNamespace(): string { return this.namespace; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getParentElementHash(): string { return this.parentElementHash; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + getHash(): string { + return this.xmlAttributeUniqueHash; + } + + generateHash(): void { + const content = + this.name + + '||' + this.value + + '||' + this.parentElementHash + + '||' + this.filePath + + '||' + this.startLine + + '||' + this.serviceVersionLinkHash; + + this.xmlAttributeUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.XML_ATTRIBUTE, + content + ); + } + + getEntryCombined(): string { + return `xml_attribute[name=${this.name}, value=${this.value}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + this.name, + EntityUtils.escapeTsv(this.value), + this.namespace, + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.parentElementHash, + this.serviceVersionLinkHash, + this.xmlAttributeUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'name', + 'value', + 'namespace', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'parentElementHash', + 'serviceVersionLinkHash', + 'xmlAttributeUniqueHash', + ].join('\t'); + } +} + +class XmlAttributeBuilder { + name: string; + value: string; + namespace: string = ''; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + parentElementHash: string; + serviceVersionLinkHash: string; + + constructor( + name: string, + value: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + parentElementHash: string, + serviceVersionLinkHash: string + ) { + this.name = name; + this.value = value; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.parentElementHash = parentElementHash; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withNamespace(namespace: string): XmlAttributeBuilder { + this.namespace = namespace; + return this; + } + + build(): XmlAttribute { + return new (XmlAttribute as any)(this); + } +} diff --git a/parser/src/analysis-types/xml/XmlElement.ts b/parser/src/analysis-types/xml/XmlElement.ts new file mode 100644 index 000000000..e1bb6714d --- /dev/null +++ b/parser/src/analysis-types/xml/XmlElement.ts @@ -0,0 +1,224 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a single XML element node extracted from an XML document. + * + * Captures the full structural information of each element: + * - Tag name and namespace + * - XPath location within the document + * - Nesting depth + * - Direct text content (leaf nodes) + * - Parent element linkage via hash + * + * ## CSV Export Format + * + * Column order: + * 1. tagName, namespace, namespacePrefix, xPath, depth, textContent + * 2. isSelfClosing, childCount + * 3. filePath, baseMservPath, startLine, endLine + * 4. parentElementHash, serviceVersionLinkHash + * 5. xmlElementUniqueHash (LAST) + */ +export class XmlElement implements EntityIdentifiable { + private tagName: string; + private namespace: string; + private namespacePrefix: string; + private xPath: string; + private depth: number; + private textContent: string; + private isSelfClosing: boolean; + private childCount: number; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private parentElementHash: string; + private serviceVersionLinkHash: string; + private xmlElementUniqueHash: string = ''; + + private constructor(builder: XmlElementBuilder) { + this.tagName = builder.tagName; + this.namespace = builder.namespace; + this.namespacePrefix = builder.namespacePrefix; + this.xPath = builder.xPath; + this.depth = builder.depth; + this.textContent = builder.textContent; + this.isSelfClosing = builder.isSelfClosing; + this.childCount = builder.childCount; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.parentElementHash = builder.parentElementHash; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + tagName: string, + xPath: string, + depth: number, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ): XmlElementBuilder { + return new XmlElementBuilder( + tagName, xPath, depth, filePath, baseMservPath, + startLine, endLine, serviceVersionLinkHash + ); + } + + getTagName(): string { return this.tagName; } + getNamespace(): string { return this.namespace; } + getNamespacePrefix(): string { return this.namespacePrefix; } + getXPath(): string { return this.xPath; } + getDepth(): number { return this.depth; } + getTextContent(): string { return this.textContent; } + getIsSelfClosing(): boolean { return this.isSelfClosing; } + getChildCount(): number { return this.childCount; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getParentElementHash(): string { return this.parentElementHash; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + getHash(): string { + return this.xmlElementUniqueHash; + } + + generateHash(): void { + const content = + this.tagName + + '||' + this.xPath + + '||' + this.filePath + + '||' + this.baseMservPath + + '||' + this.startLine + + '||' + this.serviceVersionLinkHash; + + this.xmlElementUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.XML_ELEMENT, + content + ); + } + + getEntryCombined(): string { + return `xml_element[tag=${this.tagName}, xPath=${this.xPath}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + this.tagName, + this.namespace, + this.namespacePrefix, + this.xPath, + this.depth.toString(), + EntityUtils.escapeTsv(this.textContent), + this.isSelfClosing.toString(), + this.childCount.toString(), + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.parentElementHash, + this.serviceVersionLinkHash, + this.xmlElementUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'tagName', + 'namespace', + 'namespacePrefix', + 'xPath', + 'depth', + 'textContent', + 'isSelfClosing', + 'childCount', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'parentElementHash', + 'serviceVersionLinkHash', + 'xmlElementUniqueHash', + ].join('\t'); + } +} + +class XmlElementBuilder { + tagName: string; + namespace: string = ''; + namespacePrefix: string = ''; + xPath: string; + depth: number; + textContent: string = ''; + isSelfClosing: boolean = false; + childCount: number = 0; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + parentElementHash: string = ''; + serviceVersionLinkHash: string; + + constructor( + tagName: string, + xPath: string, + depth: number, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ) { + this.tagName = tagName; + this.xPath = xPath; + this.depth = depth; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withNamespace(namespace: string): XmlElementBuilder { + this.namespace = namespace; + return this; + } + + withNamespacePrefix(prefix: string): XmlElementBuilder { + this.namespacePrefix = prefix; + return this; + } + + withTextContent(text: string): XmlElementBuilder { + this.textContent = text; + return this; + } + + withIsSelfClosing(isSelfClosing: boolean): XmlElementBuilder { + this.isSelfClosing = isSelfClosing; + return this; + } + + withChildCount(count: number): XmlElementBuilder { + this.childCount = count; + return this; + } + + withParentElementHash(hash: string): XmlElementBuilder { + this.parentElementHash = hash; + return this; + } + + build(): XmlElement { + return new (XmlElement as any)(this); + } +} diff --git a/parser/src/analysis-types/xml/XmlValueReference.ts b/parser/src/analysis-types/xml/XmlValueReference.ts new file mode 100644 index 000000000..32f172beb --- /dev/null +++ b/parser/src/analysis-types/xml/XmlValueReference.ts @@ -0,0 +1,201 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { XmlValueReferenceType } from '@/enums/xml/XmlValueReferenceType'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a ${...} or #{...} value reference found inside XML text content + * or attribute values. + * + * Captures property placeholders, environment variable references, SpEL expressions, + * and placeholders with default values. + * + * ## CSV Export Format + * + * Column order: + * 1. referenceExpression, referenceType, defaultValue, rawValue + * 2. ownerElementHash, ownerAttributeName, depth + * 3. filePath, baseMservPath, startLine, endLine + * 4. serviceVersionLinkHash + * 5. xmlValueReferenceUniqueHash (LAST) + */ +export class XmlValueReference implements EntityIdentifiable { + private referenceExpression: string; + private referenceType: XmlValueReferenceType; + private defaultValue: string; + private rawValue: string; + private ownerElementHash: string; + private ownerAttributeName: string; + private depth: number; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private serviceVersionLinkHash: string; + private xmlValueReferenceUniqueHash: string = ''; + + private constructor(builder: XmlValueReferenceBuilder) { + this.referenceExpression = builder.referenceExpression; + this.referenceType = builder.referenceType; + this.defaultValue = builder.defaultValue; + this.rawValue = builder.rawValue; + this.ownerElementHash = builder.ownerElementHash; + this.ownerAttributeName = builder.ownerAttributeName; + this.depth = builder.depth; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + referenceExpression: string, + referenceType: XmlValueReferenceType, + rawValue: string, + ownerElementHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ): XmlValueReferenceBuilder { + return new XmlValueReferenceBuilder( + referenceExpression, referenceType, rawValue, ownerElementHash, + filePath, baseMservPath, startLine, endLine, serviceVersionLinkHash + ); + } + + getReferenceExpression(): string { return this.referenceExpression; } + getReferenceType(): XmlValueReferenceType { return this.referenceType; } + getDefaultValue(): string { return this.defaultValue; } + getRawValue(): string { return this.rawValue; } + getOwnerElementHash(): string { return this.ownerElementHash; } + getOwnerAttributeName(): string { return this.ownerAttributeName; } + getDepth(): number { return this.depth; } + getFilePath(): string { return this.filePath; } + getBaseMservPath(): string { return this.baseMservPath; } + getStartLine(): number { return this.startLine; } + getEndLine(): number { return this.endLine; } + getServiceVersionLinkHash(): string { return this.serviceVersionLinkHash; } + + getHash(): string { + return this.xmlValueReferenceUniqueHash; + } + + generateHash(): void { + const content = + this.referenceExpression + + '||' + this.referenceType + + '||' + this.rawValue + + '||' + this.ownerElementHash + + '||' + this.ownerAttributeName + + '||' + this.depth + + '||' + this.filePath + + '||' + this.startLine + + '||' + this.serviceVersionLinkHash; + + this.xmlValueReferenceUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.XML_VALUE_REFERENCE, + content + ); + } + + getEntryCombined(): string { + return `xml_value_ref[expr=${this.referenceExpression}, type=${this.referenceType}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.referenceExpression), + this.referenceType, + EntityUtils.escapeTsv(this.defaultValue), + EntityUtils.escapeTsv(this.rawValue), + this.ownerElementHash, + this.ownerAttributeName, + this.depth.toString(), + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.serviceVersionLinkHash, + this.xmlValueReferenceUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'referenceExpression', + 'referenceType', + 'defaultValue', + 'rawValue', + 'ownerElementHash', + 'ownerAttributeName', + 'depth', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'serviceVersionLinkHash', + 'xmlValueReferenceUniqueHash', + ].join('\t'); + } +} + +class XmlValueReferenceBuilder { + referenceExpression: string; + referenceType: XmlValueReferenceType; + defaultValue: string = ''; + rawValue: string; + ownerElementHash: string; + ownerAttributeName: string = ''; + depth: number = 0; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + serviceVersionLinkHash: string; + + constructor( + referenceExpression: string, + referenceType: XmlValueReferenceType, + rawValue: string, + ownerElementHash: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ) { + this.referenceExpression = referenceExpression; + this.referenceType = referenceType; + this.rawValue = rawValue; + this.ownerElementHash = ownerElementHash; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withDefaultValue(defaultValue: string): XmlValueReferenceBuilder { + this.defaultValue = defaultValue; + return this; + } + + withOwnerAttributeName(name: string): XmlValueReferenceBuilder { + this.ownerAttributeName = name; + return this; + } + + withDepth(depth: number): XmlValueReferenceBuilder { + this.depth = depth; + return this; + } + + build(): XmlValueReference { + return new (XmlValueReference as any)(this); + } +} diff --git a/parser/src/analysis-types/xml/index.ts b/parser/src/analysis-types/xml/index.ts new file mode 100644 index 000000000..d38bfe75a --- /dev/null +++ b/parser/src/analysis-types/xml/index.ts @@ -0,0 +1,3 @@ +export { XmlElement } from '@/analysis-types/xml/XmlElement'; +export { XmlAttribute } from '@/analysis-types/xml/XmlAttribute'; +export { XmlValueReference } from '@/analysis-types/xml/XmlValueReference'; diff --git a/parser/src/analysis-types/yaml/YamlProperty.ts b/parser/src/analysis-types/yaml/YamlProperty.ts new file mode 100644 index 000000000..8e0863a7d --- /dev/null +++ b/parser/src/analysis-types/yaml/YamlProperty.ts @@ -0,0 +1,311 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { YamlValueType } from '@/enums/yaml/YamlValueType'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a flattened key-value pair extracted from a YAML document. + * + * YAML nested structure is flattened into dot-separated paths: + * ```yaml + * server: + * port: 8080 # key="server.port", value="8080" + * hosts: + * - localhost # key="server.hosts[0]", value="localhost" + * - remote # key="server.hosts[1]", value="remote" + * ``` + * + * ## CSV Export Format + * + * Column order: + * 1. key, value, valueType, depth, isListItem, listIndex + * 2. anchorName, isAlias, documentIndex + * 3. filePath, baseMservPath, startLine, endLine + * 4. parentPropertyHash, serviceVersionLinkHash + * 5. yamlPropertyUniqueHash (LAST) + */ +export class YamlProperty implements EntityIdentifiable { + private key: string; + private value: string; + private valueType: YamlValueType; + private depth: number; + private isListItem: boolean; + private listIndex: number; + private anchorName: string; + private isAlias: boolean; + private documentIndex: number; + private filePath: string; + private baseMservPath: string; + private startLine: number; + private endLine: number; + private parentPropertyHash: string; + private serviceVersionLinkHash: string; + private yamlPropertyUniqueHash: string = ''; + + private constructor(builder: YamlPropertyBuilder) { + this.key = builder.key; + this.value = builder.value; + this.valueType = builder.valueType; + this.depth = builder.depth; + this.isListItem = builder.isListItem; + this.listIndex = builder.listIndex; + this.anchorName = builder.anchorName; + this.isAlias = builder.isAlias; + this.documentIndex = builder.documentIndex; + this.filePath = builder.filePath; + this.baseMservPath = builder.baseMservPath; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.parentPropertyHash = builder.parentPropertyHash; + this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + + this.generateHash(); + } + + static builder( + key: string, + value: string, + valueType: YamlValueType, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ): YamlPropertyBuilder { + return new YamlPropertyBuilder( + key, + value, + valueType, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionLinkHash + ); + } + + getKey(): string { + return this.key; + } + + getValue(): string { + return this.value; + } + + getValueType(): YamlValueType { + return this.valueType; + } + + getDepth(): number { + return this.depth; + } + + getIsListItem(): boolean { + return this.isListItem; + } + + getListIndex(): number { + return this.listIndex; + } + + getAnchorName(): string { + return this.anchorName; + } + + getIsAlias(): boolean { + return this.isAlias; + } + + getDocumentIndex(): number { + return this.documentIndex; + } + + getFilePath(): string { + return this.filePath; + } + + getBaseMservPath(): string { + return this.baseMservPath; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getParentPropertyHash(): string { + return this.parentPropertyHash; + } + + getServiceVersionLinkHash(): string { + return this.serviceVersionLinkHash; + } + + getYamlPropertyUniqueHash(): string { + return this.yamlPropertyUniqueHash; + } + + getHash(): string { + return this.yamlPropertyUniqueHash; + } + + /** + * Adjusts line numbers by adding an offset. Used when parsing chunked YAML + * files where each chunk's lines are relative to the chunk start. + * Regenerates the hash since startLine is part of it. + */ + adjustLineNumbers(lineOffset: number): void { + this.startLine += lineOffset; + this.endLine += lineOffset; + this.generateHash(); + } + + generateHash(): void { + const content = + this.key + + '||' + + this.filePath + + '||' + + this.baseMservPath + + '||' + + this.startLine + + '||' + + this.documentIndex + + '||' + + this.serviceVersionLinkHash; + + this.yamlPropertyUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.YAML_PROPERTY, + content + ); + } + + getEntryCombined(): string { + return `yaml_property[key=${this.key}, type=${this.valueType}, line=${this.startLine}, file=${this.filePath}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.key), + EntityUtils.escapeTsv(this.value), + this.valueType, + this.depth.toString(), + this.isListItem.toString(), + this.listIndex.toString(), + this.anchorName, + this.isAlias.toString(), + this.documentIndex.toString(), + this.filePath, + this.baseMservPath, + this.startLine.toString(), + this.endLine.toString(), + this.parentPropertyHash, + this.serviceVersionLinkHash, + this.yamlPropertyUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'key', + 'value', + 'valueType', + 'depth', + 'isListItem', + 'listIndex', + 'anchorName', + 'isAlias', + 'documentIndex', + 'filePath', + 'baseMservPath', + 'startLine', + 'endLine', + 'parentPropertyHash', + 'serviceVersionLinkHash', + 'yamlPropertyUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for YamlProperty + */ +class YamlPropertyBuilder { + key: string; + value: string; + valueType: YamlValueType; + depth: number = 0; + isListItem: boolean = false; + listIndex: number = -1; + anchorName: string = ''; + isAlias: boolean = false; + documentIndex: number = 0; + filePath: string; + baseMservPath: string; + startLine: number; + endLine: number; + parentPropertyHash: string = ''; + serviceVersionLinkHash: string; + + constructor( + key: string, + value: string, + valueType: YamlValueType, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ) { + this.key = key; + this.value = value; + this.valueType = valueType; + this.filePath = filePath; + this.baseMservPath = baseMservPath; + this.startLine = startLine; + this.endLine = endLine; + this.serviceVersionLinkHash = serviceVersionLinkHash; + } + + withDepth(depth: number): YamlPropertyBuilder { + this.depth = depth; + return this; + } + + withIsListItem(isListItem: boolean): YamlPropertyBuilder { + this.isListItem = isListItem; + return this; + } + + withListIndex(listIndex: number): YamlPropertyBuilder { + this.listIndex = listIndex; + return this; + } + + withAnchorName(anchorName: string): YamlPropertyBuilder { + this.anchorName = anchorName; + return this; + } + + withIsAlias(isAlias: boolean): YamlPropertyBuilder { + this.isAlias = isAlias; + return this; + } + + withDocumentIndex(documentIndex: number): YamlPropertyBuilder { + this.documentIndex = documentIndex; + return this; + } + + withParentPropertyHash(hash: string): YamlPropertyBuilder { + this.parentPropertyHash = hash; + return this; + } + + build(): YamlProperty { + return new (YamlProperty as any)(this); + } +} diff --git a/parser/src/analysis-types/yaml/YamlValueSegment.ts b/parser/src/analysis-types/yaml/YamlValueSegment.ts new file mode 100644 index 000000000..144312112 --- /dev/null +++ b/parser/src/analysis-types/yaml/YamlValueSegment.ts @@ -0,0 +1,267 @@ +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { YamlValueSegmentType } from '@/enums/yaml/YamlValueSegmentType'; +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Represents a single segment within a YAML property value expression. + * + * YAML values are decomposed into segments to capture the structure of + * references, defaults, and literal text — identical to properties parsing. + * + * ## Nesting via depth + parentSegmentLinkHash + * + * For `${PRIMARY:${SECONDARY:fallback}}`: + * - depth=0: PRIMARY (ENV_VARIABLE) + * - depth=1: SECONDARY (ENV_WITH_DEFAULT, defaultValue="fallback"), parent # PRIMARY + * + * ## Mixed Example + * + * For `jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${db.name}`: + * - pos=0, depth=0: LITERAL "jdbc:postgresql://" + * - pos=1, depth=0: ENV_WITH_DEFAULT "DB_HOST", default="localhost" + * - pos=2, depth=0: LITERAL ":" + * - pos=3, depth=0: ENV_WITH_DEFAULT "DB_PORT", default="5432" + * - pos=4, depth=0: LITERAL "/" + * - pos=5, depth=0: PROPERTY_REFERENCE "db.name" + * + * ## CSV Export Format + * + * Column order: + * 1. segmentValue, segmentType, defaultValue, position, depth + * 2. parentSegmentLinkHash, yamlPropertyLinkHash + * 3. startLine, endLine, startCol, endCol + * 4. yamlValueSegmentUniqueHash (LAST) + */ +export class YamlValueSegment implements EntityIdentifiable { + private segmentValue: string; + private segmentType: YamlValueSegmentType; + private defaultValue: string; + private position: number; + private depth: number; + private parentSegmentLinkHash: string; + private yamlPropertyLinkHash: string; + private startLine: number; + private endLine: number; + private startCol: number; + private endCol: number; + private yamlValueSegmentUniqueHash: string = ''; + + private constructor(builder: YamlValueSegmentBuilder) { + this.segmentValue = builder.segmentValue; + this.segmentType = builder.segmentType; + this.defaultValue = builder.defaultValue; + this.position = builder.position; + this.depth = builder.depth; + this.parentSegmentLinkHash = builder.parentSegmentLinkHash; + this.yamlPropertyLinkHash = builder.yamlPropertyLinkHash; + this.startLine = builder.startLine; + this.endLine = builder.endLine; + this.startCol = builder.startCol; + this.endCol = builder.endCol; + + this.generateHash(); + } + + static builder( + segmentValue: string, + segmentType: YamlValueSegmentType, + position: number, + depth: number, + yamlPropertyLinkHash: string, + startLine: number, + endLine: number, + startCol: number, + endCol: number + ): YamlValueSegmentBuilder { + return new YamlValueSegmentBuilder( + segmentValue, + segmentType, + position, + depth, + yamlPropertyLinkHash, + startLine, + endLine, + startCol, + endCol + ); + } + + getSegmentValue(): string { + return this.segmentValue; + } + + getSegmentType(): YamlValueSegmentType { + return this.segmentType; + } + + getDefaultValue(): string { + return this.defaultValue; + } + + getPosition(): number { + return this.position; + } + + getDepth(): number { + return this.depth; + } + + getParentSegmentLinkHash(): string { + return this.parentSegmentLinkHash; + } + + getYamlPropertyLinkHash(): string { + return this.yamlPropertyLinkHash; + } + + getStartLine(): number { + return this.startLine; + } + + getEndLine(): number { + return this.endLine; + } + + getStartCol(): number { + return this.startCol; + } + + getEndCol(): number { + return this.endCol; + } + + getYamlValueSegmentUniqueHash(): string { + return this.yamlValueSegmentUniqueHash; + } + + getHash(): string { + return this.yamlValueSegmentUniqueHash; + } + + /** + * Adjusts line numbers by adding an offset. Used when parsing chunked YAML + * files where each chunk's lines are relative to the chunk start. + * Regenerates the hash since startLine is part of it. + */ + adjustLineNumbers(lineOffset: number): void { + this.startLine += lineOffset; + this.endLine += lineOffset; + this.generateHash(); + } + + generateHash(): void { + const content = + this.yamlPropertyLinkHash + + '||' + + this.segmentValue + + '||' + + this.segmentType + + '||' + + this.position + + '||' + + this.depth + + '||' + + this.parentSegmentLinkHash + + '||' + + this.startLine + + '||' + + this.startCol; + + this.yamlValueSegmentUniqueHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.YAML_VALUE_SEGMENT, + content + ); + } + + getEntryCombined(): string { + return `yaml_value_segment[value=${this.segmentValue}, type=${this.segmentType}, pos=${this.position}, depth=${this.depth}, line=${this.startLine}]`; + } + + toCsv(): string { + return [ + EntityUtils.escapeTsv(this.segmentValue), + this.segmentType, + EntityUtils.escapeTsv(this.defaultValue), + this.position.toString(), + this.depth.toString(), + this.parentSegmentLinkHash, + this.yamlPropertyLinkHash, + this.startLine.toString(), + this.endLine.toString(), + this.startCol.toString(), + this.endCol.toString(), + this.yamlValueSegmentUniqueHash, + ].join('\t'); + } + + getCsvHeader(): string { + return [ + 'segmentValue', + 'segmentType', + 'defaultValue', + 'position', + 'depth', + 'parentSegmentLinkHash', + 'yamlPropertyLinkHash', + 'startLine', + 'endLine', + 'startCol', + 'endCol', + 'yamlValueSegmentUniqueHash', + ].join('\t'); + } +} + +/** + * Builder for YamlValueSegment + */ +class YamlValueSegmentBuilder { + segmentValue: string; + segmentType: YamlValueSegmentType; + defaultValue: string = ''; + position: number; + depth: number; + parentSegmentLinkHash: string = ''; + yamlPropertyLinkHash: string; + startLine: number; + endLine: number; + startCol: number; + endCol: number; + + constructor( + segmentValue: string, + segmentType: YamlValueSegmentType, + position: number, + depth: number, + yamlPropertyLinkHash: string, + startLine: number, + endLine: number, + startCol: number, + endCol: number + ) { + this.segmentValue = segmentValue; + this.segmentType = segmentType; + this.position = position; + this.depth = depth; + this.yamlPropertyLinkHash = yamlPropertyLinkHash; + this.startLine = startLine; + this.endLine = endLine; + this.startCol = startCol; + this.endCol = endCol; + } + + withDefaultValue(defaultValue: string): YamlValueSegmentBuilder { + this.defaultValue = defaultValue; + return this; + } + + withParentSegmentLinkHash(hash: string): YamlValueSegmentBuilder { + this.parentSegmentLinkHash = hash; + return this; + } + + build(): YamlValueSegment { + return new (YamlValueSegment as any)(this); + } +} diff --git a/parser/src/analysis-types/yaml/index.ts b/parser/src/analysis-types/yaml/index.ts new file mode 100644 index 000000000..0be1aab13 --- /dev/null +++ b/parser/src/analysis-types/yaml/index.ts @@ -0,0 +1,2 @@ +export { YamlProperty } from '@/analysis-types/yaml/YamlProperty'; +export { YamlValueSegment } from '@/analysis-types/yaml/YamlValueSegment'; diff --git a/parser/src/constants/consts.ts b/parser/src/constants/consts.ts new file mode 100644 index 000000000..f9d4edbf8 --- /dev/null +++ b/parser/src/constants/consts.ts @@ -0,0 +1,164 @@ +import * as path from 'path'; + +/** + * Hash algorithm used for generating entity hashes + */ +export const HASH_ALGO = 'md5'; + +/** + * Directories to exclude when scanning projects or source files + */ +export const EXCLUDED_DIRS = new Set([ + 'node_modules', + '.git', + '.idea', + '.vscode', + 'dist', + 'build', + 'target', + 'out', + '__pycache__', + '.pytest_cache', + 'venv', + 'env', +]); + +/** + * A directory that holds TESTS rather than source, matched by NAME during the Java source walk. + * + * The list is short on purpose, because a Java package is a language-level namespace and pruning + * one by name deletes real code. Two patterns were here and are gone: + * + * `spec` — `java.security.spec`, `javax.crypto.spec`, `javax.xml.crypto.dsig.spec`. Matching it + * removed 73 source files, and every key and algorithm specification the platform + * declares, from the platform library IR — silently, with the run reporting no skipped + * files. A client calling `new SecretKeySpec(...)` then resolved to nothing. + * `it` — an integration-test folder in some layouts, and the top-level package of every Italian + * open-source library there is (`it.unimi.dsi.fastutil`, in particular). One directory + * named `it` at the root of a sources jar drops the whole library. + * + * What is left cannot be a Java package: `test-`, `__tests__`, `integration-tests` and `e2e` + * contain characters no Java identifier allows, or are conventions no library ships as a package. + * `test` / `tests` stay, and are the two this exclusion was asked for. + */ +export const JAVA_TEST_DIR = /^tests?$|^__tests__$|^test-|^integration-tests?$|^e2e$/i; + +export function isJavaTestDir(name: string): boolean { + return JAVA_TEST_DIR.test(name); +} + +/** + * Analysis output configuration. + * + * Default location for extracted CSV facts when no explicit `outputDir` + * (library `outputDir` / positional arg) is supplied. Anchored to the package + * root — `/analysis-results` — so it lives *outside* `src/` and is + * independent of the current working directory. This file resolves to + * `dist/constants/consts.js` (built) or `src/constants/consts.ts` (tsx), both + * two levels below the package root. + */ +export const PACKAGE_ROOT = path.resolve(__dirname, '..', '..'); +export const ANALYSIS_OUTPUT_DIR = path.join(PACKAGE_ROOT, 'analysis-results'); +export const OUTPUT_TYPE_REGISTRY_CSV_FILENAME = 'all-types.csv'; +export const OUTPUT_TYPE_PARAMETER_CSV_FILENAME = 'all-type-parameters.csv'; +export const OUTPUT_TYPE_REFERENCE_CSV_FILENAME = 'all-type-references.csv'; +export const OUTPUT_TYPE_ANNOTATION_CSV_FILENAME = 'all-annotations.csv'; +export const OUTPUT_ANNOTATION_ARGUMENT_CSV_FILENAME = 'all-annotation-arguments.csv'; +export const OUTPUT_METHOD_REGISTRY_CSV_FILENAME = 'all-methods.csv'; +export const OUTPUT_METHOD_PARAMETER_CSV_FILENAME = 'all-method-parameters.csv'; +export const OUTPUT_METHOD_TYPE_PARAMETER_CSV_FILENAME = 'all-method-type-parameters.csv'; +export const OUTPUT_ENUM_CONSTANT_CSV_FILENAME = 'all-enum-constants.csv'; +export const OUTPUT_ENUM_CONSTANT_ARGUMENT_CSV_FILENAME = 'all-enum-constant-arguments.csv'; +export const OUTPUT_FIELD_REGISTRY_CSV_FILENAME = 'all-fields.csv'; +export const OUTPUT_FIELD_POSITION_CSV_FILENAME = 'all-field-positions.csv'; +export const OUTPUT_IMPORT_REGISTRY_CSV_FILENAME = 'all-imports.csv'; +export const OUTPUT_MODULE_REGISTRY_CSV_FILENAME = 'all-modules.csv'; +export const OUTPUT_MODULE_DIRECTIVE_CSV_FILENAME = 'all-module-directives.csv'; +export const OUTPUT_EXPRESSION_REFERENCE_CSV_FILENAME = 'all-expressions.csv'; +export const OUTPUT_LOCAL_VARIABLE_REGISTRY_CSV_FILENAME = 'all-local-variables.csv'; +export const OUTPUT_BLOCK_REGISTRY_CSV_FILENAME = 'all-blocks.csv'; +export const OUTPUT_COMMENT_REGISTRY_CSV_FILENAME = 'all-comments.csv'; +export const OUTPUT_PROPERTY_KEY_CSV_FILENAME = 'all-property-keys.csv'; +export const OUTPUT_PROPERTY_VALUE_SEGMENT_CSV_FILENAME = 'all-property-value-segments.csv'; +export const OUTPUT_SKIPPED_JAVA_FILES_CSV_FILENAME = 'skipped-java-files.csv'; +export const OUTPUT_SKIPPED_XML_FILES_CSV_FILENAME = 'skipped-xml-files.csv'; +export const OUTPUT_SKIPPED_PROPERTIES_FILES_CSV_FILENAME = 'skipped-properties-files.csv'; +export const OUTPUT_SERVICE_DESCRIPTOR_CSV_FILENAME = 'all-service-descriptors.csv'; +export const OUTPUT_SERVICE_PROVIDER_CSV_FILENAME = 'all-service-providers.csv'; +export const OUTPUT_SKIPPED_SERVICES_FILES_CSV_FILENAME = 'skipped-services-files.csv'; +export const OUTPUT_SKIPPED_YAML_FILES_CSV_FILENAME = 'skipped-yaml-files.csv'; +export const OUTPUT_SKIPPED_GRADLE_FILES_CSV_FILENAME = 'skipped-gradle-files.csv'; +export const OUTPUT_XML_ELEMENT_CSV_FILENAME = 'all-xml-elements.csv'; +export const OUTPUT_XML_ATTRIBUTE_CSV_FILENAME = 'all-xml-attributes.csv'; +export const OUTPUT_XML_VALUE_REFERENCE_CSV_FILENAME = 'all-xml-value-references.csv'; +export const OUTPUT_YAML_PROPERTY_CSV_FILENAME = 'all-yaml-properties.csv'; +export const OUTPUT_YAML_VALUE_SEGMENT_CSV_FILENAME = 'all-yaml-value-segments.csv'; +export const OUTPUT_GRADLE_BLOCK_CSV_FILENAME = 'all-gradle-blocks.csv'; +export const OUTPUT_GRADLE_DECLARATION_CSV_FILENAME = 'all-gradle-declarations.csv'; +export const OUTPUT_GRADLE_VALUE_REFERENCE_CSV_FILENAME = 'all-gradle-value-references.csv'; +export const OUTPUT_GRADLE_SCRIPT_CSV_FILENAME = 'all-gradle-scripts.csv'; +export const OUTPUT_GRADLE_DEPENDENCY_COORDINATE_CSV_FILENAME = 'all-gradle-dependency-coordinates.csv'; +export const OUTPUT_GRADLE_CATALOG_ENTRY_CSV_FILENAME = 'all-gradle-catalog-entries.csv'; +export const OUTPUT_GRADLE_COMMENT_CSV_FILENAME = 'all-gradle-comments.csv'; +export const OUTPUT_GRADLE_PARSE_GAP_CSV_FILENAME = 'all-gradle-parse-gaps.csv'; + +/** + * Java entity type identifiers used for extractor registration + */ +export const JAVA_ENTITY_TYPES = { + TYPE_REGISTRY: 'TypeRegistry', + METHOD_REGISTRY: 'MethodRegistry', + FIELD_REGISTRY: 'FieldRegistry', + TYPE_PARAMETER: 'TypeParameter', + METHOD_TYPE_PARAMETER: 'MethodTypeParameter', + METHOD_PARAMETER: 'MethodParameter', + TYPE_REFERENCE: 'TypeReference', + TYPE_ANNOTATION: 'TypeAnnotation', + ANNOTATION_ARGUMENT: 'AnnotationArgument', + IMPORT_REGISTRY: 'ImportRegistry', +} as const; + +/** + * Files exceeding this line count are skipped to avoid stack/memory exhaustion. + */ +export const LARGE_FILE_LINE_THRESHOLD = 55_000; + +/** + * File extension constants + */ +export const FILE_EXTENSIONS = { + JAVA: '.java', + PYTHON: '.py', + PYTHON_STUB: '.pyi', + TYPESCRIPT: '.ts', + JAVASCRIPT: '.js', + PROPERTIES: '.properties', + XML: '.xml', + YAML: '.yml', + YAML_LONG: '.yaml', + GRADLE: '.gradle', + GRADLE_KTS: '.gradle.kts', + KOTLIN_SCRIPT: '.kts', + TOML: '.toml', +} as const; + +/** + * The conventional location of a Gradle version catalog. Gradle also accepts + * catalogs declared explicitly in settings via + * `versionCatalogs { create("libs") { from(files("...")) } }`; those are picked + * up from the settings declaration rather than by path. + */ +export const GRADLE_DEFAULT_VERSION_CATALOG = 'gradle/libs.versions.toml'; + +/** + * The directory pair that makes a file a `ServiceLoader` provider-configuration + * file: a direct child of `services`, itself a direct child of `META-INF`. + * + * Matched case-SENSITIVELY, and the case is not a style choice. The JVM looks + * up the resource path `META-INF/services/` literally, so a + * directory named `meta-inf` is never read by `ServiceLoader` — treating it as + * one would report an instantiation that cannot happen. Only direct children + * count: the format has no notion of a nested provider-configuration file. + */ +export const META_INF_DIR = 'META-INF'; +export const SERVICES_DIR = 'services'; diff --git a/parser/src/constants/entity-constants.ts b/parser/src/constants/entity-constants.ts new file mode 100644 index 000000000..c503b8917 --- /dev/null +++ b/parser/src/constants/entity-constants.ts @@ -0,0 +1,144 @@ +export const ENTITY_IDENTIFIERS = { + TYPE_REGISTRY: 'TYPE_REGISTRY', + SERVICE_VERSION: 'SERVICE_VERSION', + TYPE_PARAMETER: 'TYPE_PARAMETER', + TYPE_REFERENCE: 'TYPE_REFERENCE', + TYPE_ANNOTATION: 'TYPE_ANNOTATION', + ANNOTATION_ARGUMENT_REFERENCE: 'ANNOTATION_ARGUMENT_REFERENCE', + METHOD_REGISTRY: 'METHOD_REGISTRY', + METHOD_PARAMETER: 'METHOD_PARAMETER', + METHOD_TYPE_PARAMETER: 'METHOD_TYPE_PARAMETER', + IMPORT_REGISTRY: 'IMPORT_REGISTRY', + MODULE_REGISTRY: 'MODULE_REGISTRY', + MODULE_DIRECTIVE: 'MODULE_DIRECTIVE', + ENUM_CONSTANT: 'ENUM_CONSTANT', + ENUM_CONSTANT_ARGUMENT_REFERENCE: 'ENUM_CONSTANT_ARGUMENT_REFERENCE', + FIELD_REGISTRY: 'FIELD_REGISTRY', + EXPRESSION_REFERENCE: 'EXPRESSION_REFERENCE', + LOCAL_VARIABLE_REGISTRY: 'LOCAL_VARIABLE_REGISTRY', + BLOCK_REGISTRY: 'BLOCK_REGISTRY', + COMMENT_REGISTRY: 'COMMENT_REGISTRY', + PROPERTY_KEY: 'PROPERTY_KEY', + PROPERTY_VALUE_SEGMENT: 'PROPERTY_VALUE_SEGMENT', + SKIPPED_FILE: 'SKIPPED_FILE', + XML_ELEMENT: 'XML_ELEMENT', + XML_ATTRIBUTE: 'XML_ATTRIBUTE', + XML_VALUE_REFERENCE: 'XML_VALUE_REFERENCE', + YAML_PROPERTY: 'YAML_PROPERTY', + YAML_VALUE_SEGMENT: 'YAML_VALUE_SEGMENT', + + // ------------------------------------------------- META-INF/services (Java) + // SERVICE_DESCRIPTOR is the root: it is keyed on the FILE, because the same + // service interface is configured by a separate file in every module that + // ships providers for it, and a key derived from the service name would + // collapse those into one row. SERVICE_PROVIDER chains off it — a provider + // class name alone does not say which service it provides. + SERVICE_DESCRIPTOR: 'SERVICE_DESCRIPTOR', + SERVICE_PROVIDER: 'SERVICE_PROVIDER', + + // ---------------------------------------------------------------- Gradle + // GRADLE_SCRIPT is the root of the chain. Every other Gradle key mixes in + // its parent's hash rather than re-deriving one from a qualified name, + // because a Gradle name collides constantly: every subproject has a + // `dependencies` block, every one of those has an `implementation`, and two + // `mavenCentral()` calls differ only by which repositories block they sit in. + GRADLE_SCRIPT: 'GRADLE_SCRIPT', + GRADLE_BLOCK: 'GRADLE_BLOCK', + GRADLE_DECLARATION: 'GRADLE_DECLARATION', + GRADLE_VALUE_REFERENCE: 'GRADLE_VALUE_REFERENCE', + /** A 1:1 chain off GRADLE_DECLARATION — a coordinate IS a parsed dependency. */ + GRADLE_DEPENDENCY_COORDINATE: 'GRADLE_DEPENDENCY_COORDINATE', + GRADLE_CATALOG_ENTRY: 'GRADLE_CATALOG_ENTRY', + GRADLE_COMMENT: 'GRADLE_COMMENT', + GRADLE_PARSE_GAP: 'GRADLE_PARSE_GAP', + + // ---------------------------------------------------------------- Python + // One prefix per PK. Every child key chains off its parent's hash, so these + // prefixes also document the FK chain: PY_MODULE is the root, PY_SCOPE is + // recursive through itself, and PY_CALL_SITE is a pure 1:1 chain off + // PY_EXPRESSION. Schema v6 section 1. + PY_MODULE: 'PY_MODULE', + PY_SCOPE: 'PY_SCOPE', + PY_BINDING: 'PY_BINDING', + PY_TYPE: 'PY_TYPE', + PY_TYPE_BASE: 'PY_TYPE_BASE', + PY_TYPE_REFERENCE: 'PY_TYPE_REFERENCE', + PY_METHOD: 'PY_METHOD', + PY_METHOD_PARAMETER: 'PY_METHOD_PARAMETER', + PY_FIELD: 'PY_FIELD', + PY_FIELD_WRITE: 'PY_FIELD_WRITE', + PY_DECORATOR: 'PY_DECORATOR', + PY_DECORATOR_ARGUMENT: 'PY_DECORATOR_ARGUMENT', + PY_IMPORT: 'PY_IMPORT', + PY_EXPRESSION: 'PY_EXPRESSION', + PY_CALL_SITE: 'PY_CALL_SITE', + PY_TYPE_INFERENCE: 'PY_TYPE_INFERENCE', + PY_COMMENT: 'PY_COMMENT', + PY_BLOCK: 'PY_BLOCK', + PY_PARSE_GAP: 'PY_PARSE_GAP', + PY_TYPE_PARAMETER: 'PY_TYPE_PARAMETER', + + // ------------------------------------------------------------ TypeScript + // One prefix per PK, and the list doubles as the FK chain: TS_MODULE is the + // root and every child key chains off its parent's hash (schema section 1), + // never off a re-derived qualified name. That discipline is not stylistic + // here — with declaration merging a qualified name collides BY DESIGN + // (1,986 multi-declaration symbols measured, max 43 for one name). + TS_MODULE: 'TS_MODULE', + TS_TYPE: 'TS_TYPE', + TS_TYPE_HERITAGE: 'TS_TYPE_HERITAGE', + TS_TYPE_PARAMETER: 'TS_TYPE_PARAMETER', + TS_TYPE_REFERENCE: 'TS_TYPE_REFERENCE', + TS_METHOD: 'TS_METHOD', + TS_METHOD_PARAMETER: 'TS_METHOD_PARAMETER', + TS_FIELD: 'TS_FIELD', + TS_FIELD_POSITION: 'TS_FIELD_POSITION', + TS_ENUM_MEMBER: 'TS_ENUM_MEMBER', + TS_VARIABLE: 'TS_VARIABLE', + TS_IMPORT: 'TS_IMPORT', + TS_EXPORT: 'TS_EXPORT', + TS_EXPRESSION: 'TS_EXPRESSION', + /** A pure 1:1 chain off TS_EXPRESSION — a call site IS an expression. */ + TS_CALL_SITE: 'TS_CALL_SITE', + TS_BLOCK: 'TS_BLOCK', + TS_COMMENT: 'TS_COMMENT', + TS_DECORATOR: 'TS_DECORATOR', + TS_DECORATOR_ARGUMENT: 'TS_DECORATOR_ARGUMENT', + TS_PARSE_GAP: 'TS_PARSE_GAP', + TS_TYPE_SATISFIES: 'TS_TYPE_SATISFIES', + /** + * Not a fact-relation prefix: the group key is deliberately NOT UNIQUE, so it + * is never a PK. It gets its own prefix so a group key can never be mistaken + * for an entity hash in a join. + */ + TS_DECLARATION_GROUP: 'TS_DECLARATION_GROUP', + + // ------------------------------------------------------------ JavaScript + // JavaScript is its own front end (schema Q1), so it gets its own prefixes + // rather than sharing TypeScript's. The two languages share `ts.createSourceFile` + // and nothing else: 83.6% of JavaScript module edges are expression-borne and + // 0.165% of its parameters carry a syntactic type annotation, so the relations + // are shaped by a binder rather than by declared types. + // + // JS_MODULE is the root of every FK chain and every child key chains off its + // parent's hash, never off a re-derived qualified name — `module.exports = + // function () {}` gives a callable whose only name is its file's, and two such + // files in one directory would collide on any name-derived key. + JS_MODULE: 'JS_MODULE', + JS_SCOPE: 'JS_SCOPE', + JS_TYPE: 'JS_TYPE', + JS_TYPE_HERITAGE: 'JS_TYPE_HERITAGE', + JS_TYPE_REFERENCE: 'JS_TYPE_REFERENCE', + JS_METHOD: 'JS_METHOD', + JS_METHOD_PARAMETER: 'JS_METHOD_PARAMETER', + JS_FIELD: 'JS_FIELD', + JS_VARIABLE: 'JS_VARIABLE', + JS_IMPORT: 'JS_IMPORT', + JS_EXPORT: 'JS_EXPORT', + JS_EXPRESSION: 'JS_EXPRESSION', + /** A pure 1:1 chain off JS_EXPRESSION — a call site IS an expression. */ + JS_CALL_SITE: 'JS_CALL_SITE', + JS_BLOCK: 'JS_BLOCK', + JS_COMMENT: 'JS_COMMENT', + JS_PARSE_GAP: 'JS_PARSE_GAP', +} as const; \ No newline at end of file diff --git a/parser/src/constants/index.ts b/parser/src/constants/index.ts new file mode 100644 index 000000000..fb5479e95 --- /dev/null +++ b/parser/src/constants/index.ts @@ -0,0 +1,2 @@ +export { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +export { HASH_ALGO } from '@/constants/consts'; diff --git a/parser/src/constants/javascript-constants.ts b/parser/src/constants/javascript-constants.ts new file mode 100644 index 000000000..d1dd18afc --- /dev/null +++ b/parser/src/constants/javascript-constants.ts @@ -0,0 +1,192 @@ +/** + * JavaScript-specific parser constants. + * + * The parse layer is `ts.createSourceFile` with a JavaScript `ScriptKind`, the + * same one the TypeScript front end uses, so nothing here is about tree-sitter's + * 32,767-character buffer. What *is* JavaScript-specific lives here: the depth + * cap the corpus forced up from TypeScript's, the synthetic names, and the + * governing-config defaults that decide whether a `.js` file is CommonJS or ESM. + */ + +/** + * The exact compiler this analysis parses with. Recorded in every `js_module` + * row as `targetTsVersion`, and paired with invariant #10 of the schema: a + * golden file emitted by a different compiler fails the gate rather than being + * reconciled. + * + * Deliberately NOT in any primary key — see {@link JS_EMISSION_REGIME}. + */ +export const JS_TARGET_VERSION = '6.0.3'; + +/** + * The regime token stamped into `js_module`'s PRIMARY KEY. + * + * Coarse, for the same reason `ts_module`'s is: the module hash chains into + * every child key, so a patch bump in a key would invalidate the whole fact + * base for a change that alters no fact. What must be distinguishable from + * inside the table is the regime — a 6.x in-process parse versus a future + * out-of-process one — not the patch level. + */ +export const JS_EMISSION_REGIME = 'js-ts6-inproc'; + +/** + * Maximum expression-tree depth recorded in `js_expression`. + * + * **32, not TypeScript's effective ceiling of 20.** The schema measured a + * maximum AST depth of 67 and a p99 of 26 over 2,738 real JavaScript files, so + * a cap of 20 truncates code that actually exists — promise chains and + * two large library trees both reach past it. Deeper nodes are dropped with the parent + * marked `isTruncated`, so a lost subtree is visible rather than silent. + */ +export const JS_EXPRESSION_MAX_DEPTH = 32; + +/** Same cap for the JSDoc type tree, for the same reason. */ +export const JS_TYPE_REFERENCE_MAX_DEPTH = 32; + +/** `js_expression.text` is truncated here; the schema states 512. */ +export const JS_EXPRESSION_TEXT_LIMIT = 512; + +/** `js_comment.text` is truncated here; the schema states 2,048. */ +export const JS_COMMENT_TEXT_LIMIT = 2_048; + +/** + * Synthetic method minted per module so top-level executable code always has an + * owner. + * + * In JavaScript this carries more weight than in TypeScript: a CommonJS file's + * top level is genuinely a function body at runtime (Node wraps it), and the + * 83.6% of module edges that are expression-borne all hang off this row. + */ +export const JS_MODULE_INITIALIZER_NAME = ''; + +/** `js_method.name` for the unnamed function-shaped declarations. */ +export const JS_ANONYMOUS_METHOD_NAMES = { + CONSTRUCTOR: '', + ARROW: '', + FUNCTION_EXPRESSION: '', + STATIC_BLOCK: '', +} as const; + +/** `js_type.name` for a class with no name of its own. */ +export const JS_ANONYMOUS_TYPE_NAME = ''; + +/** The exported name a `module.exports = X` edge carries. */ +export const JS_DEFAULT_EXPORT_NAME = 'default'; + +/** + * File extensions the analyzer treats as JavaScript source. + * + * `.mjs` and `.cjs` are here because they OVERRIDE the governing + * `package.json` outright — they are not spelling variants, they are the only + * two ways a file states its own module system. + * + * ## Flow declaration files are NOT spelled here, deliberately + * + * Flow is out of scope, and the ruling is that a rejection must be RECORDED + * rather than absent: a declined file emits one `js_module` row with + * `sourceProvenance = FLOW_REJECTED` so a consumer can count what was refused. + * + * That only works if the file is discovered, and `.js.flow` was first added to + * THIS LIST — which fixed one spelling and left the class: `.cjs.flow` and + * `.mjs.flow` are equally legal and were still invisible, because the list held + * a literal string rather than describing the shape. + * + * Flow's convention is `..flow`, so the rule is "a JavaScript + * extension, optionally followed by `.flow`" and it lives in + * `jsExtensionOf` — one place that knows `path.extname` returns only the last + * part. This list stays what its name says: the JavaScript extensions. + */ +export const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] as const; + +/** + * Directories that contain JavaScript but are never the project under analysis. + * + * `node_modules` and `bower_components` are excluded by NAME, not as a scale + * optimisation: those files are real source, and analysing them as PROJECT code + * stages third-party declarations into the wrong provenance bucket. The rest are + * build *output* directories — the artefact, never the input. + * + * ## What is deliberately NOT here, and why it was removed + * + * `vendor` and `min` were on this list for one commit, and the day-one gate + * caught it: excluding them by directory name made + * `sourceProvenance = BUNDLED` **unemittable**, because a bundle that + * is never walked cannot be classified. The schema's rule is *classified, never + * counted*, and gate 7.3.5 asserts such a file contributes zero rows to any + * denominator — which is only checkable if the file is in the fact base saying + * what it is. A silent directory skip and a classified row look identical in a + * row count and are opposite claims. + * + * `vendor/` is also frequently hand-vendored real source rather than output, so + * the name is not evidence of anything. Detection belongs in + * `provenanceOf` — a `.min.js`-shaped name or a line no human writes — where the + * answer lands in a column. + */ +export const JS_SKIP_DIRECTORIES = [ + 'node_modules', 'bower_components', '.git', 'dist', 'build', 'out', 'coverage', + '.next', '.nuxt', '.turbo', '.cache', '.yarn', +] as const; + +/** + * A line longer than this is ONE of the two signals that mark a file as + * bundled output — never the only one (ruled 2026-09-13). + * + * Bundled JavaScript is real and valid and will dominate any row count in a + * corpus that does not exclude it, while teaching nothing. It is LABELLED + * (`sourceProvenance = BUNDLED`) and emitted in full; denominators exclude it + * by column. + * + * ## What the length signal is worth, measured + * + * On the development corpus (js-corpus, 4,529 files): 19 files labelled + * BUNDLED, 17 by name and 3 by length — and every length catch that was a + * real bundle was ALSO caught by name. The one file caught by length alone + * was a 399-line hand-written CommonJS file with a 7,286-character regex + * literal, which this comment used to say could not happen. **Zero unique + * true positives, one false positive.** It stays because that is a statement + * about a corpus, not the language: a bundler-emitted `dist/index.js` with no + * name marker is a real shape that is simply not in that corpus — and it will + * carry a `sourceMappingURL` footer or a runtime preamble. So the rule is + * length AND a content signal; a single long LITERAL is excused, because + * source can have one long line without being generated. + */ +export const JS_BUNDLED_LINE_LENGTH_THRESHOLD = 5_000; + +/** + * A `.min.js`-style name is bundled output whatever its line lengths are. + * + * DROP MODULE-FORMAT MARKERS, KEEP BUILD-PRODUCT MARKERS (ruled 2026-09-13). + * `min` and `bundle` name a build product — a tool writes `foo.min.js`. + * `esm` and `umd` name a MODULE FORMAT, how the code is packaged for a + * loader — a person writes `index.esm.js` and `Foo.umd.js`. Measured by + * js-corpus: `.min.` 17 hits, all real; `.umd.` 2, both hand-written (39 + * lines, eight imports, longest line 81, PROJECT under a neutral name); + * `.esm.` zero. Where a name signal and a content signal disagree the content + * wins, because including a bundle inflates a denominator detectably while + * deleting real source leaves nothing to notice. + */ +export const JS_MINIFIED_NAME_PATTERN = /\.(min|bundle)\.[cm]?jsx?$/i; + +/** CSV file names for the JavaScript fact tables. */ +export const JAVASCRIPT_CSV_FILES = { + MODULES: 'all-javascript-modules.csv', + SCOPES: 'all-javascript-scopes.csv', + TYPES: 'all-javascript-types.csv', + TYPE_HERITAGES: 'all-javascript-type-heritages.csv', + TYPE_REFERENCES: 'all-javascript-type-references.csv', + METHODS: 'all-javascript-methods.csv', + METHOD_PARAMETERS: 'all-javascript-method-parameters.csv', + FIELDS: 'all-javascript-fields.csv', + VARIABLES: 'all-javascript-variables.csv', + IMPORTS: 'all-javascript-imports.csv', + EXPORTS: 'all-javascript-exports.csv', + EXPRESSIONS: 'all-javascript-expressions.csv', + CALL_SITES: 'all-javascript-call-sites.csv', + BLOCKS: 'all-javascript-blocks.csv', + COMMENTS: 'all-javascript-comments.csv', + PARSE_GAPS: 'all-javascript-parse-gaps.csv', + SKIPPED_FILES: 'skipped-javascript-files.csv', +} as const; + +/** Chunk size for CSV writes, matching the Java, Python and TypeScript analyzers. */ +export const JS_CSV_CHUNK_SIZE = 50_000; diff --git a/parser/src/constants/python-constants.ts b/parser/src/constants/python-constants.ts new file mode 100644 index 000000000..bfe16060d --- /dev/null +++ b/parser/src/constants/python-constants.ts @@ -0,0 +1,236 @@ +/** + * Python-specific parser constants. + */ + +/** + * tree-sitter's hard ceiling on a single parse buffer: **32,767 characters** + * (2^15 - 1), not "about 30KB". + * + * Two properties of this limit are easy to get wrong and both matter: + * + * 1. **It counts characters, not bytes.** 29k characters of CJK is 62KB of + * UTF-8 and parses fine; 33k characters of ASCII is 33KB and does not. A + * byte-based guard is wrong in both directions. + * 2. **The callback's returned chunk carries the same ceiling.** Streaming does + * not lift the limit, it only lets you stay under it — so returning a 65536- + * byte chunk from the callback throws exactly as a direct parse would. + * + * Three of five stdlib packages fail to parse without the callback path, so it + * is the common path for real code, not an edge case. + */ +import { FILE_EXTENSIONS } from '@/constants/consts'; + +export const TREE_SITTER_MAX_PARSE_CHARS = 32_767; + +/** + * Character count above which `PythonParser` switches to callback parsing. + * + * Deliberately below {@link TREE_SITTER_MAX_PARSE_CHARS} rather than equal to + * it, mirroring `java-parser.ts`: the margin costs nothing and avoids sitting + * exactly on a boundary whose accounting is not ours to verify. + */ +export const PYTHON_CALLBACK_PARSE_THRESHOLD = 30_000; + +/** + * Chunk size returned by the streaming parse callback. Well under the ceiling + * described above, and identical to the Java parser's chunking. + */ +export const PYTHON_PARSE_CHUNK_SIZE = 8_192; + +/** + * The exact interpreter patch this analysis targets. Recorded in every + * `py_module` row for reproducibility, and paired with invariant #10. + */ +export const PYTHON_TARGET_VERSION = '3.10.4'; + +/** + * CPython's synthetic iterator parameter, present in exactly one binding of + * every comprehension and generator-expression scope on the `PY3_0_11` target. + * + * It is a genuine `symtable.Symbol`, so it is emitted as a real `py_binding` + * row and asserted positively — never whitelisted, because a whitelist also + * passes when the binding is missing. + */ +export const PYTHON_SYNTHETIC_ITERATOR = '.0'; + +/** symtable's own name for the module scope. */ +/** + * The base name of a package's initialiser, without extension. + * + * A module is never NAMED for this file: `pkg/__init__.py` is the module `pkg`, + * not `pkg.__init__`. Both are checked when asking whether a directory is a + * package, because a stub-only distribution ships `__init__.pyi` and no `.py` + * at all. Treating only `.py` as the marker makes a stub package invisible, and + * every module under it is then named as though it sat at the top level, so + * `collections/abc.pyi` and `abc.pyi` collapse onto the same name. + */ +export const PYTHON_PACKAGE_INIT_STEM = '__init__'; + +/** Every file name that marks a directory as a regular package. */ +export const PYTHON_PACKAGE_INIT_FILENAMES: readonly string[] = [ + `${PYTHON_PACKAGE_INIT_STEM}${FILE_EXTENSIONS.PYTHON}`, + `${PYTHON_PACKAGE_INIT_STEM}${FILE_EXTENSIONS.PYTHON_STUB}`, +]; + +/** + * Whether a file name is a package initialiser, in either dialect of the tree. + * + * The extension decides nothing about a module's identity: a stub tree must be + * named exactly as the source tree in the same position would be, or a library + * parsed separately cannot be linked to by name. + */ +export function isPythonPackageInitFileName(fileName: string): boolean { + return PYTHON_PACKAGE_INIT_FILENAMES.includes(fileName); +} + +export const PYTHON_MODULE_SCOPE_NAME = 'top'; + +/** The `` marker CPython inserts into `__qualname__` inside functions. */ +export const PYTHON_LOCALS_MARKER = ''; + +/** Synthetic method minted per module so module-level code always has an owner. */ +export const PYTHON_MODULE_INITIALIZER_NAME = ''; + +/** Synthetic method minted per class body, the `` analogue. */ +export const PYTHON_CLASS_INITIALIZER_NAME = ''; + +/** symtable's name for every lambda scope. Two on one line are distinguished only by column. */ +export const PYTHON_LAMBDA_SCOPE_NAME = 'lambda'; + +/** + * CPython's `__qualname__` for a lambda, and therefore `py_method.name`. + * + * Deliberately different from {@link PYTHON_LAMBDA_SCOPE_NAME}: symtable calls + * the scope `lambda`, while the function object's qualname is ``. Each + * column follows its own source of truth rather than being forced to agree. + */ +export const PYTHON_LAMBDA_METHOD_NAME = ''; + +/** CSV file names for the Python fact tables. */ +export const PYTHON_CSV_FILES = { + MODULES: 'all-python-modules.csv', + SCOPES: 'all-python-scopes.csv', + BINDINGS: 'all-python-bindings.csv', + TYPES: 'all-python-types.csv', + TYPE_BASES: 'all-python-type-bases.csv', + METHODS: 'all-python-methods.csv', + METHOD_PARAMETERS: 'all-python-method-parameters.csv', + IMPORTS: 'all-python-imports.csv', + EXPRESSIONS: 'all-python-expressions.csv', + CALL_SITES: 'all-python-call-sites.csv', + TYPE_REFERENCES: 'all-python-type-references.csv', + FIELDS: 'all-python-fields.csv', + FIELD_POSITIONS: 'all-python-field-positions.csv', + BLOCKS: 'all-python-blocks.csv', + COMMENTS: 'all-python-comments.csv', + PARSE_GAPS: 'all-python-parse-gaps.csv', + TYPE_PARAMETERS: 'all-python-type-parameters.csv', + DECORATORS: 'all-python-decorators.csv', + DECORATOR_ARGUMENTS: 'all-python-decorator-arguments.csv', + SKIPPED_FILES: 'skipped-python-files.csv', +} as const; + +/** + * Builtin scalar type names, for classifying an inferred type without resolving + * it (`py_expression.inferredTypeKind`, schema v7 §2.15 c34). + * + * These are the names that cannot be a user class, because a module cannot + * shadow them in a way the parser could see without resolution. + */ +export const PYTHON_BUILTIN_SCALAR_TYPES: ReadonlySet = new Set([ + 'int', + 'float', + 'complex', + 'bool', + 'str', + 'bytes', + 'bytearray', + 'memoryview', +]); + +/** Builtin container type names. */ +export const PYTHON_BUILTIN_COLLECTION_TYPES: ReadonlySet = new Set([ + 'list', + 'dict', + 'set', + 'frozenset', + 'tuple', + 'List', + 'Dict', + 'Set', + 'FrozenSet', + 'Tuple', + 'Sequence', + 'Mapping', + 'MutableMapping', + 'Iterable', + 'Iterator', +]); + +/** + * The public method set of each builtin container and string type, plus the + * `collections` types that behave like them. + * + * Generated from CPython 3.10.4 itself (`dir(list)` and friends), which is why it + * is ground truth rather than a hand-written guess. It exists so an attribute + * whose type is known to be a builtin can resolve a call ON that attribute: + * `self._buf = bytearray()` followed by `self._buf.extend(d)` reaches + * `bytearray.extend`. + * + * The membership test is the point. Without it, `self._items = []` followed by + * `self._items.frobnicate()` would be reported as a builtin call — wrong, and + * worse, it would HIDE a real bug in the analysed code. With it, an unknown name + * on a known type stays `UNRESOLVED`, which is the honest answer. + */ +export const PYTHON_BUILTIN_TYPE_METHODS: ReadonlyMap> = new Map([ + ['list', new Set(['append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort'])], + ['dict', new Set(['clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values'])], + ['set', new Set(['add', 'clear', 'copy', 'difference', 'difference_update', 'discard', 'intersection', 'intersection_update', 'isdisjoint', 'issubset', 'issuperset', 'pop', 'remove', 'symmetric_difference', 'symmetric_difference_update', 'union', 'update'])], + ['frozenset', new Set(['copy', 'difference', 'intersection', 'isdisjoint', 'issubset', 'issuperset', 'symmetric_difference', 'union'])], + ['tuple', new Set(['count', 'index'])], + ['str', new Set(['capitalize', 'casefold', 'center', 'count', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'format_map', 'index', 'isalnum', 'isalpha', 'isascii', 'isdecimal', 'isdigit', 'isidentifier', 'islower', 'isnumeric', 'isprintable', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'removeprefix', 'removesuffix', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill'])], + ['bytes', new Set(['capitalize', 'center', 'count', 'decode', 'endswith', 'expandtabs', 'find', 'fromhex', 'hex', 'index', 'isalnum', 'isalpha', 'isascii', 'isdigit', 'islower', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'removeprefix', 'removesuffix', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill'])], + ['bytearray', new Set(['append', 'capitalize', 'center', 'clear', 'copy', 'count', 'decode', 'endswith', 'expandtabs', 'extend', 'find', 'fromhex', 'hex', 'index', 'insert', 'isalnum', 'isalpha', 'isascii', 'isdigit', 'islower', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'pop', 'remove', 'removeprefix', 'removesuffix', 'replace', 'reverse', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill'])], + ['deque', new Set(['append', 'appendleft', 'clear', 'copy', 'count', 'extend', 'extendleft', 'index', 'insert', 'maxlen', 'pop', 'popleft', 'remove', 'reverse', 'rotate'])], + ['defaultdict', new Set(['clear', 'copy', 'default_factory', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values'])], + ['OrderedDict', new Set(['clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'move_to_end', 'pop', 'popitem', 'setdefault', 'update', 'values'])], + ['Counter', new Set(['clear', 'copy', 'elements', 'fromkeys', 'get', 'items', 'keys', 'most_common', 'pop', 'popitem', 'setdefault', 'subtract', 'total', 'update', 'values'])], +]); + +/** + * Every name in CPython's `builtins` module, generated from the pinned 3.10 + * interpreter rather than hand-listed. + * + * Used to separate a reference to a builtin from a reference to a module-level + * global. CPython's own symtable cannot make that distinction, because + * LOAD_GLOBAL checks module globals first and builtins second and the decision + * is a runtime one, so both come back as "global". That is why py_binding + * carries its own BUILTIN kind: without it a consumer cannot tell `len` from a + * global someone defined, and the two need different handling when resolving a + * call. + */ +export const PYTHON_BUILTIN_NAMES: ReadonlySet = new Set([ + 'ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', 'BlockingIOError', + 'BrokenPipeError', 'BufferError', 'BytesWarning', 'ChildProcessError', + 'ConnectionAbortedError', 'ConnectionError', 'ConnectionRefusedError', + 'ConnectionResetError', 'DeprecationWarning', 'EOFError', 'Ellipsis', 'EncodingWarning', + 'EnvironmentError', 'Exception', 'False', 'FileExistsError', 'FileNotFoundError', + 'FloatingPointError', 'FutureWarning', 'GeneratorExit', 'IOError', 'ImportError', + 'ImportWarning', 'IndentationError', 'IndexError', 'InterruptedError', 'IsADirectoryError', + 'KeyError', 'KeyboardInterrupt', 'LookupError', 'MemoryError', 'ModuleNotFoundError', + 'NameError', 'None', 'NotADirectoryError', 'NotImplemented', 'NotImplementedError', + 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'PermissionError', + 'ProcessLookupError', 'RecursionError', 'ReferenceError', 'ResourceWarning', 'RuntimeError', + 'RuntimeWarning', 'StopAsyncIteration', 'StopIteration', 'SyntaxError', 'SyntaxWarning', + 'SystemError', 'SystemExit', 'TabError', 'TimeoutError', 'True', 'TypeError', + 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', 'UnicodeError', + 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', 'ValueError', 'Warning', + 'ZeroDivisionError', 'abs', 'aiter', 'all', 'anext', 'any', 'ascii', 'bin', 'bool', + 'breakpoint', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'compile', 'complex', + 'copyright', 'credits', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'exec', + 'exit', 'filter', 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', + 'help', 'hex', 'id', 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'license', + 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', + 'pow', 'print', 'property', 'quit', 'range', 'repr', 'reversed', 'round', 'set', 'setattr', + 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip', +]); diff --git a/parser/src/constants/typescript-constants.ts b/parser/src/constants/typescript-constants.ts new file mode 100644 index 000000000..460035399 --- /dev/null +++ b/parser/src/constants/typescript-constants.ts @@ -0,0 +1,118 @@ +/** + * TypeScript-specific parser constants. + * + * The parse layer is `ts.createSourceFile` (OQ-1), so the 32,767-character + * tree-sitter buffer limit that shaped `python-constants.ts` and + * `java-parser.ts` does **not** apply here and there is deliberately no + * chunking threshold in this file. That was the deciding argument: 4.7% of real + * TypeScript files exceed the tree-sitter ceiling, `lib.dom.d.ts` is 2.3 MB, and + * those are exactly the `.d.ts` files carrying 52% of the call graph's leaves. + */ + +/** + * The exact compiler this analysis parses with. Recorded in every `ts_module` + * row as `targetTsVersion` and paired with invariant #9. + * + * Deliberately NOT in any primary key — see {@link TS_EMISSION_REGIME}. + */ +export const TS_TARGET_VERSION = '6.0.3'; + +/** + * The regime token stamped into `ts_module`'s PRIMARY KEY. + * + * Coarse by ruling (OQ-4): `6.0.3` in a key would invalidate every hash in the + * fact base on a patch bump, because the module hash chains into every child + * key. What has to be distinguishable from inside the fact table is the regime + * — a 6.x in-process parse versus a future 7.x out-of-process one — not the + * patch level. + */ +export const TS_EMISSION_REGIME = 'ts6-inproc'; + +/** + * Maximum type-node nesting depth recorded in `ts_type_reference`. + * + * **32, not 20 and not 5.** Java and Python cap at 5, which truncates real + * `.d.ts`: the measured maximum over 1,113 declaration files is 19. A cap of 20 + * admits everything observed and sits one node from truncating on the next + * a deeply-generic library, so the ruling took 32. The extra headroom costs nothing — + * no node in 25.9 MB of TypeScript reaches depth 20, so no extra row is emitted + * — and `isTruncated` stays, because a cap that can never fire is a cap nobody + * maintains. + */ +export const TS_TYPE_REFERENCE_MAX_DEPTH = 32; + +/** + * Maximum expression-tree depth recorded in `ts_expression`. + * + * Same cap, same reasoning. Deeper nodes are dropped with the parent marked, so + * a lost subtree is visible rather than silent. + */ +export const TS_EXPRESSION_MAX_DEPTH = 32; + +/** Synthetic method minted per module so top-level executable code always has an owner. */ +export const TS_MODULE_INITIALIZER_NAME = ''; + +/** `ts_method.name` for the unnamed function-shaped declarations. */ +export const TS_ANONYMOUS_METHOD_NAMES = { + CONSTRUCTOR: '', + ARROW: '', + FUNCTION_EXPRESSION: '', + CALL_SIGNATURE: '', + CONSTRUCT_SIGNATURE: '', + STATIC_BLOCK: '', + INDEX_SIGNATURE: '', + /** + * A `(a: T) => R` written in TYPE position. + * + * It gets a `ts_method` row because it is a real call target: a variable + * annotated with a function type resolves its calls to THIS signature, not to + * whatever arrow was assigned to it. 21,956 function types were measured in + * the ecosystem corpus, and the ones in project code are the reason + * `const f: (x: T) => R = (x) => …; f(x)` has a declared target at all. + */ + FUNCTION_TYPE: '', + CONSTRUCTOR_TYPE: '', +} as const; + +/** + * The binder's own name for a default export, in both `escapedName` and `name`. + * This is TypeScript's `InternalSymbolName.Default`, not an invention. + */ +export const TS_DEFAULT_EXPORT_NAME = 'default'; + +/** File extensions the analyzer treats as TypeScript source. */ +export const TS_SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'] as const; + +/** Directories that contain TypeScript but are never the project under analysis. */ +export const TS_SKIP_DIRECTORIES = [ + 'node_modules', '.git', 'dist', 'build', 'out', 'coverage', + '.next', '.nuxt', '.turbo', '.cache', '.yarn', 'bower_components', +] as const; + +/** CSV file names for the TypeScript fact tables. */ +export const TYPESCRIPT_CSV_FILES = { + MODULES: 'all-typescript-modules.csv', + TYPES: 'all-typescript-types.csv', + TYPE_HERITAGES: 'all-typescript-type-heritages.csv', + TYPE_PARAMETERS: 'all-typescript-type-parameters.csv', + TYPE_REFERENCES: 'all-typescript-type-references.csv', + METHODS: 'all-typescript-methods.csv', + METHOD_PARAMETERS: 'all-typescript-method-parameters.csv', + FIELDS: 'all-typescript-fields.csv', + VARIABLES: 'all-typescript-variables.csv', + IMPORTS: 'all-typescript-imports.csv', + EXPRESSIONS: 'all-typescript-expressions.csv', + CALL_SITES: 'all-typescript-call-sites.csv', + BLOCKS: 'all-typescript-blocks.csv', + DECORATORS: 'all-typescript-decorators.csv', + DECORATOR_ARGUMENTS: 'all-typescript-decorator-arguments.csv', + EXPORTS: 'all-typescript-exports.csv', + ENUM_MEMBERS: 'all-typescript-enum-members.csv', + FIELD_POSITIONS: 'all-typescript-field-positions.csv', + COMMENTS: 'all-typescript-comments.csv', + PARSE_GAPS: 'all-typescript-parse-gaps.csv', + SKIPPED_FILES: 'skipped-typescript-files.csv', +} as const; + +/** Chunk size for CSV writes, matching the Java and Python analyzers. */ +export const TS_CSV_CHUNK_SIZE = 50_000; diff --git a/parser/src/enums/SkippedFileReason.ts b/parser/src/enums/SkippedFileReason.ts new file mode 100644 index 000000000..416630880 --- /dev/null +++ b/parser/src/enums/SkippedFileReason.ts @@ -0,0 +1,44 @@ +/** + * Reason why a source file was skipped during analysis. Generic enum that applies to all things. + * + * ## Examples + * + * - `EMPTY_CONTENT` — file exists but has no meaningful content + * - `READ_ERROR` — file could not be read (permissions, encoding, etc.) + * - `PY2_CONSTRUCT_DETECTED` — Python 2 source, which is out of scope and must be + * rejected explicitly rather than misinterpreted + */ +export enum SkippedFileReason { + /** File was empty or contained only whitespace */ + EMPTY_CONTENT = 'EMPTY_CONTENT', + + /** File could not be read due to an I/O or encoding error */ + READ_ERROR = 'READ_ERROR', + + /** File exceeded LARGE_FILE_LINE_THRESHOLD and was skipped */ + FILE_TOO_LARGE = 'FILE_TOO_LARGE', + + /** + * A Python-2-only construct was detected, so the file was rejected wholesale. + * + * This reason exists because `tree-sitter-python@0.21.0` parses Python 2 + * **without erroring** — it carries first-class `print_statement`, + * `exec_statement` and `chevron` nodes, so `print "x"` yields a clean tree + * with `hasError === false`. Emitting facts from it would silently apply + * Python 3 scoping semantics to Python 2 source. Rejection is therefore + * explicit, and the offending construct and span are recorded rather than + * merely absent. + */ + PY2_CONSTRUCT_DETECTED = 'PY2_CONSTRUCT_DETECTED', + + /** + * The extractor threw while processing a file that read and parsed fine. + * + * Distinct from `READ_ERROR` on purpose, and the distinction is not cosmetic: + * `READ_ERROR` says the environment failed, which is nobody's bug, while this + * says the PARSER failed, which is always a bug. Filing the second as the first + * is how a crash in every file of a corpus can produce an empty relation and a + * run that still reports success. + */ + EXTRACTION_ERROR = 'EXTRACTION_ERROR', +} diff --git a/parser/src/enums/gradle/blocks/GradleBlockType.ts b/parser/src/enums/gradle/blocks/GradleBlockType.ts new file mode 100644 index 000000000..f1c6f6fcd --- /dev/null +++ b/parser/src/enums/gradle/blocks/GradleBlockType.ts @@ -0,0 +1,178 @@ +/** + * Identifies the type of block/closure in a Gradle build file. + * + * Follows the same pattern as Java's BlockKind — each control flow construct + * gets its own enum value so blocks can be queried directly and expressions + * captured. DSL-specific blocks that don't have distinct field semantics + * use `DSL_BLOCK` with the identity in the `blockName` field. + * + * ## Block Categories + * + * - **Gradle DSL** – buildscript, plugins, dependencies, repositories, etc. + * - **Scope Modifiers** – allprojects, subprojects + * - **Exception Handling** – try, catch, finally + * - **Loops** – for, for-each/for-in, while, do-while + * - **Conditionals** – if, else-if, else, switch/case, when (Kotlin) + * - **DSL Catch-all** – any other named block (including .each { }, .forEach { }, closures) + * + * ## Examples + * + * ```groovy + * // === Gradle DSL Blocks === + * + * // BUILDSCRIPT + * buildscript { repositories { } dependencies { } } + * + * // PLUGINS + * plugins { id 'java' } + * + * // DEPENDENCIES + * dependencies { implementation 'com.google.guava:guava:32.1.3-jre' } + * + * // DSL_BLOCK (blockName = "maven") + * repositories { maven { url 'https://...' } } + * + * // === Control Flow === + * + * // IF (expression = "project.hasProperty('ci')") + * if (project.hasProperty('ci')) { ... } + * + * // FOR (expression = "int i = 0; i < 10; i++") + * for (int i = 0; i < 10; i++) { ... } + * + * // FOR_EACH (expression = "dep in configurations.implementation") + * for (dep in configurations.implementation) { ... } + * + * // WHILE (expression = "retryCount > 0") + * while (retryCount > 0) { ... } + * + * // TRY + * try { riskyOperation() } catch (Exception e) { handleError(e) } + * + * // SWITCH_CASE (Groovy) + * switch (env) { case 'prod': ... } + * + * // WHEN (Kotlin) + * when (env) { "prod" -> ... } + * + * // DSL_BLOCK (blockName = "each") — closures handled same as DSL blocks + * configurations.each { ... } + * ``` + * + * ## Ownership Model + * + * Blocks form a tree via `parentBlockHash`. Each block can contain: + * - Child blocks (nested control flow, DSL sub-blocks) + * - Declarations (linked via parentBlockHash) + * - Value references (linked via ownerBlockHash) + * + * ## Example Hierarchy + * + * ```groovy + * dependencies { // DEPENDENCIES block + * if (project.hasProperty('ci')) { // IF block (parent: DEPENDENCIES) + * for (dep in ciDeps) { // FOR_EACH block (parent: IF) + * implementation dep // DEPENDENCY decl (parent: FOR_EACH) + * } + * } else { // ELSE block (parent: IF) + * implementation 'com.x:y:1.0' // DEPENDENCY decl (parent: ELSE) + * } + * } + * ``` + */ +export enum GradleBlockType { + // === Gradle DSL Blocks === + /** buildscript { } */ + BUILDSCRIPT = 'BUILDSCRIPT', + + /** plugins { } — children use special id/version syntax */ + PLUGINS = 'PLUGINS', + + /** dependencies { } — children are dependency declarations */ + DEPENDENCIES = 'DEPENDENCIES', + + /** repositories { } — children are repository declarations */ + REPOSITORIES = 'REPOSITORIES', + + /** configurations { } — children are configuration declarations */ + CONFIGURATIONS = 'CONFIGURATIONS', + + /** ext { } / extra { } — children are property definitions */ + EXT = 'EXT', + + // === Scope Modifiers === + /** allprojects { } — scope modifier */ + ALLPROJECTS = 'ALLPROJECTS', + + /** subprojects { } — scope modifier */ + SUBPROJECTS = 'SUBPROJECTS', + + // === Tasks === + /** Task definition or configuration block (blockName = task name) */ + TASK = 'TASK', + + // === Exception Handling === + /** try { } block body */ + TRY = 'TRY', + + /** catch (ExceptionType e) { } block */ + CATCH = 'CATCH', + + /** finally { } block */ + FINALLY = 'FINALLY', + + // === Loops === + /** Traditional for loop: for (int i = 0; i < 10; i++) { } */ + FOR = 'FOR', + + /** + * For-each / for-in loop. + * Groovy: for (item in list) { } or for (String item : list) { } + * Kotlin: for (item in list) { } + */ + FOR_EACH = 'FOR_EACH', + + /** while (condition) { } */ + WHILE = 'WHILE', + + /** do { } while (condition) */ + DO_WHILE = 'DO_WHILE', + + // === Conditionals === + /** if (condition) { } — the "then" branch */ + IF = 'IF', + + /** + * else if (condition) { } — chained conditional. + * In Groovy, `else if` is syntactically an else containing an if. + * We flatten it to ELSE_IF for consistency with Java's BlockKind. + */ + ELSE_IF = 'ELSE_IF', + + /** else { } — standalone else block */ + ELSE = 'ELSE', + + /** switch (x) { } — the enclosing switch statement (expression = selector) */ + SWITCH = 'SWITCH', + + /** Individual case within a switch: case 'a': ... (blockName = case label) */ + SWITCH_CASE = 'SWITCH_CASE', + + /** Kotlin when expression: when (x) { "a" -> ... } */ + WHEN = 'WHEN', + + // === Synchronization === + /** synchronized (lock) { } */ + SYNCHRONIZED = 'SYNCHRONIZED', + + // === DSL Catch-all === + /** + * Any other named block. blockName carries the identity: + * maven, credentials, pom, java, application, constraints, + * resolutionStrategy, publishing, sourceSets, signing, + * pluginManagement, afterEvaluate, initscript, testLogging, + * manifest, exclusiveContent, dependencySubstitution, + * each, forEach, collect, closures/lambdas, etc. + */ + DSL_BLOCK = 'DSL_BLOCK', +} diff --git a/parser/src/enums/gradle/blocks/index.ts b/parser/src/enums/gradle/blocks/index.ts new file mode 100644 index 000000000..72c263aca --- /dev/null +++ b/parser/src/enums/gradle/blocks/index.ts @@ -0,0 +1 @@ +export { GradleBlockType } from '@/enums/gradle/blocks/GradleBlockType'; diff --git a/parser/src/enums/gradle/catalog/GradleCatalogEntryKind.ts b/parser/src/enums/gradle/catalog/GradleCatalogEntryKind.ts new file mode 100644 index 000000000..1db864826 --- /dev/null +++ b/parser/src/enums/gradle/catalog/GradleCatalogEntryKind.ts @@ -0,0 +1,38 @@ +/** + * Which of a version catalog's four tables an entry came from. + * + * `gradle/libs.versions.toml` is where a modern build actually keeps its + * coordinates. Without it, `implementation libs.spring.boot.starter.web` is a + * dependency on nothing resolvable — the group, the artifact and the version + * all live in the catalog, and the build file names only an alias. + * + * ## Examples + * + * ```toml + * [versions] + * spring = "6.1.3" # VERSION + * + * [libraries] + * spring-core = { module = "org.springframework:spring-core", version.ref = "spring" } + * # LIBRARY + * [bundles] + * spring = ["spring-core", "spring-beans"] # BUNDLE + * + * [plugins] + * boot = { id = "org.springframework.boot", version = "3.2.2" } + * # PLUGIN + * ``` + */ +export enum GradleCatalogEntryKind { + /** [versions] — a named version string other entries reference. */ + VERSION = 'VERSION', + + /** [libraries] — a dependency coordinate. */ + LIBRARY = 'LIBRARY', + + /** [bundles] — a named list of library aliases. */ + BUNDLE = 'BUNDLE', + + /** [plugins] — a plugin id plus version. */ + PLUGIN = 'PLUGIN', +} diff --git a/parser/src/enums/gradle/catalog/GradleCatalogNotation.ts b/parser/src/enums/gradle/catalog/GradleCatalogNotation.ts new file mode 100644 index 000000000..c42cef150 --- /dev/null +++ b/parser/src/enums/gradle/catalog/GradleCatalogNotation.ts @@ -0,0 +1,61 @@ +/** + * How a catalog entry spells its coordinate. + * + * The same library can be written five ways in one file. Recording which one + * was used keeps the emitted group/artifact/version columns honest: a + * SHORTHAND_STRING carries its version inline, a VERSION_REF does not, and a + * VERSION_RICH may constrain rather than pin. Treating all three as "version" + * turns a range into a pin. + * + * ## Examples + * + * ```toml + * a = "com.example:lib:1.0" # SHORTHAND_STRING + * b = { module = "com.example:lib", version = "1.0" } # MODULE_LITERAL + * c = { module = "com.example:lib", version.ref = "x" } # MODULE_VERSION_REF + * d = { group = "com.example", name = "lib", version = "1.0" } # GROUP_NAME_LITERAL + * e = { group = "com.example", name = "lib", version.ref = "x" } # GROUP_NAME_VERSION_REF + * f = { module = "com.example:lib" } # MODULE_NO_VERSION + * g = { module = "com.example:lib", version = { strictly = "[1.0, 2.0[" } } # VERSION_RICH + * ``` + */ +export enum GradleCatalogNotation { + /** alias = "group:artifact:version" */ + SHORTHAND_STRING = 'SHORTHAND_STRING', + + /** { module = "group:artifact", version = "1.0" } */ + MODULE_LITERAL = 'MODULE_LITERAL', + + /** { module = "group:artifact", version.ref = "alias" } */ + MODULE_VERSION_REF = 'MODULE_VERSION_REF', + + /** { module = "group:artifact" } — version supplied by a BOM or platform. */ + MODULE_NO_VERSION = 'MODULE_NO_VERSION', + + /** { group = "g", name = "a", version = "1.0" } */ + GROUP_NAME_LITERAL = 'GROUP_NAME_LITERAL', + + /** { group = "g", name = "a", version.ref = "alias" } */ + GROUP_NAME_VERSION_REF = 'GROUP_NAME_VERSION_REF', + + /** { group = "g", name = "a" } — version supplied elsewhere. */ + GROUP_NAME_NO_VERSION = 'GROUP_NAME_NO_VERSION', + + /** version = { strictly | require | prefer | reject | rejectAll } */ + VERSION_RICH = 'VERSION_RICH', + + /** A plain [versions] entry: alias = "1.0". */ + VERSION_LITERAL = 'VERSION_LITERAL', + + /** A [bundles] entry: alias = ["a", "b"]. */ + BUNDLE_LIST = 'BUNDLE_LIST', + + /** { id = "plugin.id", version = "1.0" } */ + PLUGIN_ID_LITERAL = 'PLUGIN_ID_LITERAL', + + /** { id = "plugin.id", version.ref = "alias" } */ + PLUGIN_ID_VERSION_REF = 'PLUGIN_ID_VERSION_REF', + + /** alias = "plugin.id:1.0" */ + PLUGIN_SHORTHAND = 'PLUGIN_SHORTHAND', +} diff --git a/parser/src/enums/gradle/catalog/index.ts b/parser/src/enums/gradle/catalog/index.ts new file mode 100644 index 000000000..db0ebed42 --- /dev/null +++ b/parser/src/enums/gradle/catalog/index.ts @@ -0,0 +1,2 @@ +export { GradleCatalogEntryKind } from '@/enums/gradle/catalog/GradleCatalogEntryKind'; +export { GradleCatalogNotation } from '@/enums/gradle/catalog/GradleCatalogNotation'; diff --git a/parser/src/enums/gradle/comments/GradleCommentKind.ts b/parser/src/enums/gradle/comments/GradleCommentKind.ts new file mode 100644 index 000000000..44eba2a7e --- /dev/null +++ b/parser/src/enums/gradle/comments/GradleCommentKind.ts @@ -0,0 +1,30 @@ +/** + * Comment form in a Gradle script. + * + * Mirrors Java's CommentKind. Build files carry a disproportionate amount of + * their meaning in comments — a pinned version almost always has a "// pinned + * because …" beside it, and a commented-out dependency is a fact about what the + * build deliberately does NOT have. + * + * ## Examples + * + * ```groovy + * // line comment LINE + * /* block comment *\/ BLOCK + * /** GroovyDoc *\/ GROOVYDOC + * #!/usr/bin/env groovy SHEBANG + * ``` + */ +export enum GradleCommentKind { + /** // ... */ + LINE = 'LINE', + + /** \/* ... *\/ */ + BLOCK = 'BLOCK', + + /** \/** ... *\/ */ + GROOVYDOC = 'GROOVYDOC', + + /** #! on line 1 of an executable script. */ + SHEBANG = 'SHEBANG', +} diff --git a/parser/src/enums/gradle/comments/index.ts b/parser/src/enums/gradle/comments/index.ts new file mode 100644 index 000000000..4ec7bef69 --- /dev/null +++ b/parser/src/enums/gradle/comments/index.ts @@ -0,0 +1 @@ +export { GradleCommentKind } from '@/enums/gradle/comments/GradleCommentKind'; diff --git a/parser/src/enums/gradle/declarations/GradleDeclarationType.ts b/parser/src/enums/gradle/declarations/GradleDeclarationType.ts new file mode 100644 index 000000000..e79ed794a --- /dev/null +++ b/parser/src/enums/gradle/declarations/GradleDeclarationType.ts @@ -0,0 +1,75 @@ +/** + * Categorizes declarations/statements within Gradle blocks. + * + * Only 8 values — each represents a **primary query target** with distinct + * field semantics. Everything else is `STATEMENT`; the block hierarchy + * provides context. + * + * ## Examples + * + * ```groovy + * // DEPENDENCY — notation: STRING_NOTATION, qualifier: implementation + * implementation 'com.google.guava:guava:32.1.3-jre' + * + * // PLUGIN — notation: PLUGINS_BLOCK_ID + * plugins { id 'org.springframework.boot' version '3.1.5' } + * + * // REPOSITORY — notation: MAVEN_CENTRAL + * repositories { mavenCentral() } + * + * // PROPERTY — qualifier: EXT_BLOCK + * ext { guavaVersion = '32.1.3-jre' } + * + * // TASK — notation: TASK_KEYWORD + * task hello { doLast { println 'Hello' } } + * + * // CONFIGURATION + * configurations { smokeTest.extendsFrom testImplementation } + * + * // INCLUDE — qualifier: include + * include ':core', ':modules:auth' + * + * // STATEMENT — catch-all for everything else + * exclude group: 'org.unwanted' + * ``` + */ +export enum GradleDeclarationType { + /** All dependency declarations — regular, constraint, classpath, default, platform. + * Block hierarchy distinguishes context (inside buildscript/constraints/etc.). + * notation: GradleDependencyNotation, qualifier: config name */ + DEPENDENCY = 'DEPENDENCY', + + /** All plugin applications — plugins block, apply plugin, apply from. + * notation: GradlePluginSyntax */ + PLUGIN = 'PLUGIN', + + /** Repository declarations. + * notation: GradleRepositoryType */ + REPOSITORY = 'REPOSITORY', + + /** Property/variable assignments — project props, ext, local vars, rootProject.name. + * qualifier: GradlePropertyScope */ + PROPERTY = 'PROPERTY', + + /** Task definitions. + * notation: GradleTaskStyle, qualifier: task type (Copy, Exec, etc.) */ + TASK = 'TASK', + + /** Custom configuration declarations. + * name: config name, value: extendsFrom */ + CONFIGURATION = 'CONFIGURATION', + + /** Settings includes — include, includeBuild. + * name: path, qualifier: "include" or "includeBuild" */ + INCLUDE = 'INCLUDE', + + /** Dependency exclusions — exclude group:/module:, and the transitive/ + * changing/force flags that ride alongside them. + * name: excluded coordinate, qualifier: owning configuration or dependency */ + EXCLUDE = 'EXCLUDE', + + /** Everything else: method calls, artifacts, resolution rules, + * imports, return, throw, class defs, etc. + * name: statement kind or method name, value: arguments/expression */ + STATEMENT = 'STATEMENT', +} diff --git a/parser/src/enums/gradle/declarations/GradleDependencyNotation.ts b/parser/src/enums/gradle/declarations/GradleDependencyNotation.ts new file mode 100644 index 000000000..3b35a9ad4 --- /dev/null +++ b/parser/src/enums/gradle/declarations/GradleDependencyNotation.ts @@ -0,0 +1,87 @@ +/** + * How a dependency coordinate is expressed in Gradle DSL. + * + * ## Examples + * + * ```groovy + * // STRING_NOTATION + * implementation 'com.google.guava:guava:32.1.3-jre' + * + * // MAP_NOTATION + * implementation group: 'com.google.guava', name: 'guava', version: '32.1.3-jre' + * + * // PROJECT + * implementation project(':core') + * + * // PLATFORM + * implementation platform('org.springframework.boot:spring-boot-dependencies:3.1.5') + * + * // VERSION_CATALOG_ACCESSOR + * implementation libs.spring.boot.starter.web + * ``` + */ +export enum GradleDependencyNotation { + /** 'group:artifact:version' */ + STRING_NOTATION = 'STRING_NOTATION', + + /** 'group:artifact:version:classifier' */ + STRING_WITH_CLASSIFIER = 'STRING_WITH_CLASSIFIER', + + /** 'group:artifact:version@ext' or 'group:artifact:version:classifier@ext' */ + STRING_WITH_EXTENSION = 'STRING_WITH_EXTENSION', + + /** group: 'x', name: 'y', version: 'z' */ + MAP_NOTATION = 'MAP_NOTATION', + + /** project(':path') */ + PROJECT = 'PROJECT', + + /** files('x.jar') or files('a.jar', 'b.jar') */ + FILES = 'FILES', + + /** fileTree('dir') or fileTree(dir: 'x', include: '*.jar') */ + FILE_TREE = 'FILE_TREE', + + /** platform('group:artifact:version') */ + PLATFORM = 'PLATFORM', + + /** enforcedPlatform('group:artifact:version') */ + ENFORCED_PLATFORM = 'ENFORCED_PLATFORM', + + /** testFixtures(project(':x')) */ + TEST_FIXTURES = 'TEST_FIXTURES', + + /** gradleApi() */ + GRADLE_API = 'GRADLE_API', + + /** gradleTestKit() */ + GRADLE_TEST_KIT = 'GRADLE_TEST_KIT', + + /** localGroovy() */ + LOCAL_GROOVY = 'LOCAL_GROOVY', + + /** add("configuration", "group:artifact:version") */ + ADD_METHOD = 'ADD_METHOD', + + /** dependencies.create("group:artifact:version") */ + CREATE_METHOD = 'CREATE_METHOD', + + /** libs.spring.boot.starter (version catalog accessor) */ + VERSION_CATALOG_ACCESSOR = 'VERSION_CATALOG_ACCESSOR', + + /** libs.bundles.spring — a whole bundle of coordinates at once */ + VERSION_CATALOG_BUNDLE = 'VERSION_CATALOG_BUNDLE', + + /** implementation depString — the coordinate is behind a variable */ + VARIABLE_REFERENCE = 'VARIABLE_REFERENCE', + + /** implementation "com.example:lib:${springVersion}" — resolves once the ref does */ + INTERPOLATED_STRING = 'INTERPOLATED_STRING', + + /** + * The argument is a dependency but its shape matched none of the above. + * Deliberately NOT folded into STRING_NOTATION: a consumer that splits on + * ':' would otherwise be handed something that was never a coordinate. + */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/gradle/declarations/GradlePluginSyntax.ts b/parser/src/enums/gradle/declarations/GradlePluginSyntax.ts new file mode 100644 index 000000000..3161395e8 --- /dev/null +++ b/parser/src/enums/gradle/declarations/GradlePluginSyntax.ts @@ -0,0 +1,56 @@ +/** + * How a plugin is applied in a Gradle build file. + * + * ## Examples + * + * ```groovy + * // PLUGINS_BLOCK_CORE — core plugin by name, no id() call + * plugins { java } + * + * // PLUGINS_BLOCK_ID + * plugins { id 'org.springframework.boot' version '3.1.5' } + * + * // APPLY_PLUGIN_STRING + * apply plugin: 'java' + * + * // APPLY_PLUGIN_CLASS + * apply plugin: MyPlugin + * + * // APPLY_FROM_LOCAL + * apply from: 'gradle/dependencies.gradle' + * + * // APPLY_FROM_REMOTE + * apply from: 'https://raw.githubusercontent.com/.../init.gradle' + * + * // BUILDSCRIPT_CLASSPATH — legacy + * buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.1.0' } } + * + * // PLUGIN_ALIAS — version catalog + * plugins { alias(libs.plugins.spring.boot) } + * ``` + */ +export enum GradlePluginSyntax { + /** plugins { java } — core plugin by name, no id() call */ + PLUGINS_BLOCK_CORE = 'PLUGINS_BLOCK_CORE', + + /** plugins { id 'x' version 'y' } or plugins { id("x") version "y" } */ + PLUGINS_BLOCK_ID = 'PLUGINS_BLOCK_ID', + + /** apply plugin: 'x' */ + APPLY_PLUGIN_STRING = 'APPLY_PLUGIN_STRING', + + /** apply plugin: MyPlugin (class reference) */ + APPLY_PLUGIN_CLASS = 'APPLY_PLUGIN_CLASS', + + /** apply from: 'local/path.gradle' */ + APPLY_FROM_LOCAL = 'APPLY_FROM_LOCAL', + + /** apply from: 'https://...' */ + APPLY_FROM_REMOTE = 'APPLY_FROM_REMOTE', + + /** buildscript { dependencies { classpath 'x' } } — legacy */ + BUILDSCRIPT_CLASSPATH = 'BUILDSCRIPT_CLASSPATH', + + /** alias(libs.plugins.x) — version catalog */ + PLUGIN_ALIAS = 'PLUGIN_ALIAS', +} diff --git a/parser/src/enums/gradle/declarations/GradlePropertyScope.ts b/parser/src/enums/gradle/declarations/GradlePropertyScope.ts new file mode 100644 index 000000000..1aa1f1e42 --- /dev/null +++ b/parser/src/enums/gradle/declarations/GradlePropertyScope.ts @@ -0,0 +1,65 @@ +/** + * Scope/origin of a property or variable assignment in a Gradle build file. + * + * ## Examples + * + * ```groovy + * // PROJECT — direct project property + * group = 'com.example' + * version = '1.0.0' + * sourceCompatibility = '17' + * + * // EXT_BLOCK — inside ext { } + * ext { guavaVersion = '32.1.3-jre' } + * + * // EXT_SINGLE — ext.x = 'y' shorthand + * ext.springVersion = '6.0.0' + * + * // EXT_SET — programmatic set + * project.ext.set('nexusUrl', 'https://...') + * + * // EXTRA_DELEGATION (Kotlin DSL) + * val springVersion by extra("6.0.0") + * + * // PROJECT_DELEGATION (Kotlin DSL) + * val nexusUrl: String by project + * + * // BUILDSCRIPT_EXT + * buildscript { ext { kotlinVersion = '1.9.10' } } + * + * // LOCAL_VARIABLE + * def jacksonVersion = '2.15.3' // Groovy + * val jacksonVersion = "2.15.3" // Kotlin + * + * // EXT_MAP_ENTRY — nested map inside ext + * ext { versions = [ awsSdk: '2.21.29', jackson: '2.15.3' ] } + * ``` + */ +export enum GradlePropertyScope { + /** group = 'x', version = 'y', sourceCompatibility = '17' */ + PROJECT = 'PROJECT', + + /** ext { x = 'y' } */ + EXT_BLOCK = 'EXT_BLOCK', + + /** ext.x = 'y' */ + EXT_SINGLE = 'EXT_SINGLE', + + /** project.ext.set('x', 'y') */ + EXT_SET = 'EXT_SET', + + /** val x by extra("y") — Kotlin DSL */ + EXTRA_DELEGATION = 'EXTRA_DELEGATION', + + /** val x: String by project — Kotlin DSL */ + PROJECT_DELEGATION = 'PROJECT_DELEGATION', + + /** buildscript { ext { x = 'y' } } */ + BUILDSCRIPT_EXT = 'BUILDSCRIPT_EXT', + + /** def x = 'y' (Groovy) or val x = "y" (Kotlin) */ + LOCAL_VARIABLE = 'LOCAL_VARIABLE', + + /** Nested map in ext: versions = [ awsSdk: '2.21.29' ] */ + EXT_MAP_ENTRY = 'EXT_MAP_ENTRY', +} diff --git a/parser/src/enums/gradle/declarations/GradleRepositoryType.ts b/parser/src/enums/gradle/declarations/GradleRepositoryType.ts new file mode 100644 index 000000000..d6249aa40 --- /dev/null +++ b/parser/src/enums/gradle/declarations/GradleRepositoryType.ts @@ -0,0 +1,34 @@ +/** + * Type of repository declared in a Gradle build file. + * + * ## Examples + * + * ```groovy + * repositories { + * mavenCentral() // MAVEN_CENTRAL + * mavenLocal() // MAVEN_LOCAL + * google() // GOOGLE + * gradlePluginPortal() // GRADLE_PLUGIN_PORTAL + * jcenter() // JCENTER (deprecated) + * maven { url '...' } // MAVEN_CUSTOM + * ivy { url '...' } // IVY + * flatDir { dirs 'libs' } // FLAT_DIR + * } + * ``` + */ +export enum GradleRepositoryType { + MAVEN_CENTRAL = 'MAVEN_CENTRAL', + MAVEN_LOCAL = 'MAVEN_LOCAL', + GOOGLE = 'GOOGLE', + GRADLE_PLUGIN_PORTAL = 'GRADLE_PLUGIN_PORTAL', + JCENTER = 'JCENTER', + MAVEN_CUSTOM = 'MAVEN_CUSTOM', + IVY = 'IVY', + FLAT_DIR = 'FLAT_DIR', + /** mavenCentral { } / maven { } with an artifact-only content filter */ + MAVEN_CONTENT_FILTERED = 'MAVEN_CONTENT_FILTERED', + /** exclusiveContent { forRepository { ... } } */ + EXCLUSIVE_CONTENT = 'EXCLUSIVE_CONTENT', + /** A repository added by a method this parser cannot resolve statically. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/gradle/declarations/GradleTaskStyle.ts b/parser/src/enums/gradle/declarations/GradleTaskStyle.ts new file mode 100644 index 000000000..5d377ee8f --- /dev/null +++ b/parser/src/enums/gradle/declarations/GradleTaskStyle.ts @@ -0,0 +1,63 @@ +/** + * How a task is defined or configured in a Gradle build file. + * + * ## Examples + * + * ```groovy + * // TASK_KEYWORD + * task hello { doLast { println 'Hello' } } + * + * // TASK_KEYWORD_TYPED + * task compileExtra(type: JavaCompile) { source = ... } + * + * // TASKS_REGISTER + * tasks.register('hello') { doLast { println 'Hello' } } + * + * // TASKS_REGISTER_TYPED + * tasks.register('copyDocs', Copy) { from 'docs' } + * tasks.register("copyDocs") { from("docs") } + * + * // TASKS_NAMED + * tasks.named('test') { useJUnitPlatform() } + * + * // TASKS_NAMED_TYPED (Kotlin DSL) + * tasks.named("test") { useJUnitPlatform() } + * + * // DIRECT_CONFIGURE + * compileJava { options.encoding = 'UTF-8' } + * + * // TASK_RULE + * tasks.addRule("Pattern: deploy") { ... } + * + * // LOOP_CREATION + * ['dev','prod'].each { env -> task "deploy${env}" { ... } } + * ``` + */ +export enum GradleTaskStyle { + /** task hello { } */ + TASK_KEYWORD = 'TASK_KEYWORD', + + /** task compileExtra(type: JavaCompile) { } */ + TASK_KEYWORD_TYPED = 'TASK_KEYWORD_TYPED', + + /** tasks.register('hello') { } */ + TASKS_REGISTER = 'TASKS_REGISTER', + + /** tasks.register('copyDocs', Copy) { } or tasks.register("x") { } */ + TASKS_REGISTER_TYPED = 'TASKS_REGISTER_TYPED', + + /** tasks.named('test') { } */ + TASKS_NAMED = 'TASKS_NAMED', + + /** tasks.named('test') { } (Kotlin DSL) */ + TASKS_NAMED_TYPED = 'TASKS_NAMED_TYPED', + + /** compileJava { } — direct name as method call */ + DIRECT_CONFIGURE = 'DIRECT_CONFIGURE', + + /** tasks.addRule("Pattern: ...") { } */ + TASK_RULE = 'TASK_RULE', + + /** ['dev','prod'].each { task "deploy${it}" { } } */ + LOOP_CREATION = 'LOOP_CREATION', +} diff --git a/parser/src/enums/gradle/declarations/index.ts b/parser/src/enums/gradle/declarations/index.ts new file mode 100644 index 000000000..f7e0b68b7 --- /dev/null +++ b/parser/src/enums/gradle/declarations/index.ts @@ -0,0 +1,6 @@ +export { GradleDeclarationType } from '@/enums/gradle/declarations/GradleDeclarationType'; +export { GradleDependencyNotation } from '@/enums/gradle/declarations/GradleDependencyNotation'; +export { GradlePluginSyntax } from '@/enums/gradle/declarations/GradlePluginSyntax'; +export { GradleRepositoryType } from '@/enums/gradle/declarations/GradleRepositoryType'; +export { GradleTaskStyle } from '@/enums/gradle/declarations/GradleTaskStyle'; +export { GradlePropertyScope } from '@/enums/gradle/declarations/GradlePropertyScope'; diff --git a/parser/src/enums/gradle/dependencies/GradleVersionSource.ts b/parser/src/enums/gradle/dependencies/GradleVersionSource.ts new file mode 100644 index 000000000..0b135c8ec --- /dev/null +++ b/parser/src/enums/gradle/dependencies/GradleVersionSource.ts @@ -0,0 +1,43 @@ +/** + * Where a dependency's version came from — or that it has none. + * + * A coordinate row carries a `version` column. Two very different situations + * produce an empty one: the build genuinely omits the version because a BOM + * supplies it, and the parser could not read the version because it is behind + * an accessor it did not resolve. A downstream "which builds pin log4j" query + * must not treat those the same. + * + * ## Examples + * + * ```groovy + * implementation 'com.example:lib:1.0' LITERAL + * implementation "com.example:lib:${springVersion}" INTERPOLATED + * implementation libs.spring.core CATALOG + * implementation "com.example:lib:$version" PROPERTY + * implementation platform('com:bom:1.0') — the BOM itself is LITERAL + * implementation 'com.example:lib' ABSENT (BOM-managed) + * implementation depCoordinate VARIABLE + * ``` + */ +export enum GradleVersionSource { + /** Written out in the coordinate as a plain string. */ + LITERAL = 'LITERAL', + + /** Supplied by a GString/template interpolation the reference relation carries. */ + INTERPOLATED = 'INTERPOLATED', + + /** Supplied by a version catalog entry. */ + CATALOG = 'CATALOG', + + /** Supplied by a project/ext property resolved within the corpus. */ + PROPERTY = 'PROPERTY', + + /** The coordinate deliberately has no version — a platform or BOM supplies it. */ + ABSENT = 'ABSENT', + + /** The whole coordinate sits behind a variable; nothing can be split out. */ + VARIABLE = 'VARIABLE', + + /** A version is present in some form this parser could not classify. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/gradle/dependencies/index.ts b/parser/src/enums/gradle/dependencies/index.ts new file mode 100644 index 000000000..0185e14ac --- /dev/null +++ b/parser/src/enums/gradle/dependencies/index.ts @@ -0,0 +1 @@ +export { GradleVersionSource } from '@/enums/gradle/dependencies/GradleVersionSource'; diff --git a/parser/src/enums/gradle/files/GradleDSLDialect.ts b/parser/src/enums/gradle/files/GradleDSLDialect.ts new file mode 100644 index 000000000..0688e9738 --- /dev/null +++ b/parser/src/enums/gradle/files/GradleDSLDialect.ts @@ -0,0 +1,15 @@ +/** + * DSL dialect used in a Gradle build file. + */ +export enum GradleDSLDialect { + GROOVY = 'GROOVY', + KOTLIN = 'KOTLIN', + /** + * A version catalog. Not a DSL at all — `gradle/libs.versions.toml` is TOML, + * read by a hand written reader rather than by the Groovy grammar. It gets + * its own value because defaulting it to GROOVY told every consumer that a + * declarative data file had been parsed by a programming-language grammar, + * which is both false and the kind of thing a reader would build on. + */ + TOML = 'TOML', +} diff --git a/parser/src/enums/gradle/files/GradleParseStatus.ts b/parser/src/enums/gradle/files/GradleParseStatus.ts new file mode 100644 index 000000000..bff957699 --- /dev/null +++ b/parser/src/enums/gradle/files/GradleParseStatus.ts @@ -0,0 +1,18 @@ +/** + * How completely a Gradle script was parsed. + * + * Recorded per script so a consumer can tell an empty result from an + * unanalysed one. A build file that produced no dependency rows because it + * declares none, and one that produced none because the parse collapsed at + * line 3, are different facts and must not read the same downstream. + */ +export enum GradleParseStatus { + /** Parsed with no ERROR/MISSING node anywhere in the tree. */ + OK = 'OK', + + /** Parsed, but at least one region is recorded in the parse-gap relation. */ + PARTIAL = 'PARTIAL', + + /** The parser threw. No blocks or declarations were emitted for this file. */ + FAILED = 'FAILED', +} diff --git a/parser/src/enums/gradle/files/GradleScriptKind.ts b/parser/src/enums/gradle/files/GradleScriptKind.ts new file mode 100644 index 000000000..4cf23093f --- /dev/null +++ b/parser/src/enums/gradle/files/GradleScriptKind.ts @@ -0,0 +1,55 @@ +/** + * The role a Gradle file plays in a build. + * + * A build's files are not interchangeable: `settings.gradle` declares which + * projects exist, a root `build.gradle` configures every one of them, a leaf + * `build.gradle` configures exactly one, and a script plugin configures + * whichever script applied it. A dependency found in the first three is a fact + * about a known project; the same dependency in a script plugin is a fact about + * an unknown set of projects until the `apply from:` edges are followed. + * + * Collapsing these into "a .gradle file" is what makes a dependency query + * return the right coordinate against the wrong project. + * + * ## Examples + * + * ``` + * settings.gradle → SETTINGS + * settings.gradle.kts → SETTINGS + * build.gradle (root) → ROOT_BUILD + * core/build.gradle → PROJECT_BUILD + * buildSrc/build.gradle → BUILD_SRC_BUILD + * buildSrc/settings.gradle → BUILD_SRC_SETTINGS + * gradle/dependencies.gradle → SCRIPT_PLUGIN + * init.gradle / *.init.gradle → INIT + * gradle/libs.versions.toml → VERSION_CATALOG + * ``` + */ +export enum GradleScriptKind { + /** settings.gradle[.kts] — declares the project graph. */ + SETTINGS = 'SETTINGS', + + /** build.gradle[.kts] sitting beside the settings file. */ + ROOT_BUILD = 'ROOT_BUILD', + + /** build.gradle[.kts] for a subproject. */ + PROJECT_BUILD = 'PROJECT_BUILD', + + /** buildSrc/build.gradle[.kts] — builds the build, not the product. */ + BUILD_SRC_BUILD = 'BUILD_SRC_BUILD', + + /** buildSrc/settings.gradle[.kts]. */ + BUILD_SRC_SETTINGS = 'BUILD_SRC_SETTINGS', + + /** An included build's settings/build (composite builds via includeBuild). */ + INCLUDED_BUILD = 'INCLUDED_BUILD', + + /** init.gradle[.kts] or *.init.gradle[.kts] — applied before any project. */ + INIT = 'INIT', + + /** Any other .gradle[.kts] file — reached only through `apply from:`. */ + SCRIPT_PLUGIN = 'SCRIPT_PLUGIN', + + /** gradle/libs.versions.toml or any TOML declared as a version catalog. */ + VERSION_CATALOG = 'VERSION_CATALOG', +} diff --git a/parser/src/enums/gradle/files/index.ts b/parser/src/enums/gradle/files/index.ts new file mode 100644 index 000000000..2981a6130 --- /dev/null +++ b/parser/src/enums/gradle/files/index.ts @@ -0,0 +1,3 @@ +export { GradleDSLDialect } from '@/enums/gradle/files/GradleDSLDialect'; +export { GradleScriptKind } from '@/enums/gradle/files/GradleScriptKind'; +export { GradleParseStatus } from '@/enums/gradle/files/GradleParseStatus'; diff --git a/parser/src/enums/gradle/index.ts b/parser/src/enums/gradle/index.ts new file mode 100644 index 000000000..cb1e3eaea --- /dev/null +++ b/parser/src/enums/gradle/index.ts @@ -0,0 +1,32 @@ +// Blocks +export { GradleBlockType } from '@/enums/gradle/blocks/GradleBlockType'; + +// Declarations +export { GradleDeclarationType } from '@/enums/gradle/declarations/GradleDeclarationType'; +export { GradleDependencyNotation } from '@/enums/gradle/declarations/GradleDependencyNotation'; +export { GradlePluginSyntax } from '@/enums/gradle/declarations/GradlePluginSyntax'; +export { GradleRepositoryType } from '@/enums/gradle/declarations/GradleRepositoryType'; +export { GradleTaskStyle } from '@/enums/gradle/declarations/GradleTaskStyle'; +export { GradlePropertyScope } from '@/enums/gradle/declarations/GradlePropertyScope'; + +// Dependencies +export { GradleVersionSource } from '@/enums/gradle/dependencies/GradleVersionSource'; + +// Value References +export { GradleValueReferenceType } from '@/enums/gradle/value-references/GradleValueReferenceType'; +export { GradleReferenceResolution } from '@/enums/gradle/value-references/GradleReferenceResolution'; + +// Version Catalog +export { GradleCatalogEntryKind } from '@/enums/gradle/catalog/GradleCatalogEntryKind'; +export { GradleCatalogNotation } from '@/enums/gradle/catalog/GradleCatalogNotation'; + +// Comments +export { GradleCommentKind } from '@/enums/gradle/comments/GradleCommentKind'; + +// Parse Gaps +export { GradleParseGapReason } from '@/enums/gradle/parse-gaps/GradleParseGapReason'; + +// Files +export { GradleDSLDialect } from '@/enums/gradle/files/GradleDSLDialect'; +export { GradleScriptKind } from '@/enums/gradle/files/GradleScriptKind'; +export { GradleParseStatus } from '@/enums/gradle/files/GradleParseStatus'; diff --git a/parser/src/enums/gradle/parse-gaps/GradleParseGapReason.ts b/parser/src/enums/gradle/parse-gaps/GradleParseGapReason.ts new file mode 100644 index 000000000..98803f2c5 --- /dev/null +++ b/parser/src/enums/gradle/parse-gaps/GradleParseGapReason.ts @@ -0,0 +1,49 @@ +/** + * Why a region of a Gradle script is not fully represented in the emitted rows. + * + * The Gradle front end is the one place in this parser where the tree comes + * from a grammar that does not match the language: tree-sitter-groovy parses + * Groovy, and it is asked to parse Kotlin DSL as well as Groovy constructs it + * has no rule for. The extractor rewrites the source before parsing to get a + * usable tree, and some of those rewrites delete tokens. + * + * Every such region gets a row here. That is the whole point: a downstream + * query can then distinguish "this build declares no dependency in that block" + * from "that block was rewritten and the parser could not see it". Without this + * relation the two are the same empty result, and the second one is a lie. + * + * ERROR_NODE and MISSING_NODE come from tree-sitter itself. Everything else is + * a rewrite this extractor performed, recorded against the ORIGINAL source + * offsets so a consumer can go look at the real text. + */ +export enum GradleParseGapReason { + /** tree-sitter produced an ERROR node: the region did not parse. */ + ERROR_NODE = 'ERROR_NODE', + + /** tree-sitter inserted a MISSING node to recover: a token was absent. */ + MISSING_NODE = 'MISSING_NODE', + + /** `{ dep -> ... }` → `{ ... }`. The closure's parameter names are gone. */ + DROPPED_CLOSURE_PARAMETERS = 'DROPPED_CLOSURE_PARAMETERS', + + /** `x as String?` → `x`. The cast target type is gone. */ + DROPPED_TYPE_CAST = 'DROPPED_TYPE_CAST', + + /** `Foo::class.java` → `Foo`. The class-literal form is gone. */ + DROPPED_CLASS_REFERENCE = 'DROPPED_CLASS_REFERENCE', + + /** `register("x")` → `register("x")`. The type argument is gone. */ + DROPPED_TYPE_ARGUMENTS = 'DROPPED_TYPE_ARGUMENTS', + + /** `a ?: b` → `a || b`. Parsed as a boolean OR, which it is not. */ + REWRITTEN_ELVIS = 'REWRITTEN_ELVIS', + + /** A non-ASCII character was replaced with `_` so the grammar would accept it. */ + REPLACED_NON_ASCII = 'REPLACED_NON_ASCII', + + /** The file exceeded the line threshold and was never parsed. */ + FILE_TOO_LARGE = 'FILE_TOO_LARGE', + + /** The parser threw on this file; nothing downstream of this point was seen. */ + PARSE_FAILED = 'PARSE_FAILED', +} diff --git a/parser/src/enums/gradle/parse-gaps/index.ts b/parser/src/enums/gradle/parse-gaps/index.ts new file mode 100644 index 000000000..c74a167bf --- /dev/null +++ b/parser/src/enums/gradle/parse-gaps/index.ts @@ -0,0 +1 @@ +export { GradleParseGapReason } from '@/enums/gradle/parse-gaps/GradleParseGapReason'; diff --git a/parser/src/enums/gradle/value-references/GradleReferenceResolution.ts b/parser/src/enums/gradle/value-references/GradleReferenceResolution.ts new file mode 100644 index 000000000..63073987e --- /dev/null +++ b/parser/src/enums/gradle/value-references/GradleReferenceResolution.ts @@ -0,0 +1,55 @@ +/** + * What a value reference was resolved against — or why it was not. + * + * This column exists so that coverage can be measured honestly. A raw + * "resolved / total" ratio over Gradle references measures how many + * environment variables a build reads, not how good the parser is. + * + * Split the unresolved side and the number becomes meaningful: + * + * - `UNRESOLVED_IN_CORPUS` is the parser's gap. A property by that name IS + * declared somewhere in the analysed files and the link was still not made. + * - `EXTERNAL` is not. `System.getenv('CI')` has no declaration to point at, + * and never will; counting it as a miss penalises the parser for the build + * reading its environment. + * + * Absence stays absence: a reference that cannot be decided is left + * `UNRESOLVED_IN_CORPUS` or `EXTERNAL` with an empty target hash rather than + * pointed at a plausible guess. + */ +export enum GradleReferenceResolution { + /** Matched a PROPERTY declaration in the same script. */ + LOCAL_PROPERTY = 'LOCAL_PROPERTY', + + /** Matched a PROPERTY declared in an `ext { }` / `extra` block. */ + EXT_PROPERTY = 'EXT_PROPERTY', + + /** Matched a PROPERTY in another script in the same build (root, applied-from). */ + CROSS_SCRIPT_PROPERTY = 'CROSS_SCRIPT_PROPERTY', + + /** Matched a [versions] entry in a version catalog. */ + CATALOG_VERSION = 'CATALOG_VERSION', + + /** Matched a [libraries] entry in a version catalog. */ + CATALOG_LIBRARY = 'CATALOG_LIBRARY', + + /** Matched a [bundles] entry in a version catalog. */ + CATALOG_BUNDLE = 'CATALOG_BUNDLE', + + /** Matched a [plugins] entry in a version catalog. */ + CATALOG_PLUGIN = 'CATALOG_PLUGIN', + + /** + * A declaration by that name exists in the analysed corpus and the link was + * still not made. This is the parser's gap, and the only bucket that should + * shrink as the parser improves. + */ + UNRESOLVED_IN_CORPUS = 'UNRESOLVED_IN_CORPUS', + + /** + * Nothing by that name exists in the corpus. Environment variables, system + * properties, `-P` command-line properties, and coordinates supplied by a + * plugin at execution time all land here. Excluded from coverage. + */ + EXTERNAL = 'EXTERNAL', +} diff --git a/parser/src/enums/gradle/value-references/GradleValueReferenceType.ts b/parser/src/enums/gradle/value-references/GradleValueReferenceType.ts new file mode 100644 index 000000000..4a98b0f56 --- /dev/null +++ b/parser/src/enums/gradle/value-references/GradleValueReferenceType.ts @@ -0,0 +1,114 @@ +/** + * Categorizes how a value references something external in Gradle DSL. + * + * Classification is purely structural — we detect the syntax pattern, + * not the runtime semantics. + * + * ## Examples + * + * ```groovy + * // GSTRING_INTERPOLATION + * implementation "com.google.guava:guava:${guavaVersion}" + * + * // GSTRING_SIMPLE + * implementation "com.google.guava:guava:$guavaVersion" + * + * // LAZY_GSTRING + * version = "${-> rootProject.version}" + * + * // EXT_PROPERTY_ACCESS + * implementation "com.amazonaws:aws-java-sdk:${versions.awsSdk}" + * + * // SYSTEM_PROPERTY + * def javaVer = System.getProperty('java.version') + * + * // ENV_VARIABLE + * def ci = System.getenv('CI') + * + * // ENV_VARIABLE_SHORT + * def token = System.env.GITHUB_TOKEN + * + * // FIND_PROPERTY + * def user = project.findProperty('nexusUser') ?: 'default' + * + * // GRADLE_PROPERTY_PROVIDER + * def prop = providers.gradleProperty('myProp') + * + * // FILE_READ + * version = file('VERSION').text.trim() + * ``` + */ +export enum GradleValueReferenceType { + // ── GString interpolation ── + /** "${varName}" — full interpolation syntax */ + GSTRING_INTERPOLATION = 'GSTRING_INTERPOLATION', + + /** "$varName" — simple dollar-prefix */ + GSTRING_SIMPLE = 'GSTRING_SIMPLE', + + /** "${-> expr}" — lazy/closure-based GString */ + LAZY_GSTRING = 'LAZY_GSTRING', + + // ── Ext / nested property access ── + /** ext.varName or versions.awsSdk */ + EXT_PROPERTY_ACCESS = 'EXT_PROPERTY_ACCESS', + + // ── System / environment reads ── + /** System.getProperty('x') */ + SYSTEM_PROPERTY = 'SYSTEM_PROPERTY', + + /** System.getenv('x') */ + ENV_VARIABLE = 'ENV_VARIABLE', + + /** System.env.X */ + ENV_VARIABLE_SHORT = 'ENV_VARIABLE_SHORT', + + // ── Project property reads ── + /** findProperty('x') or project.findProperty('x') */ + FIND_PROPERTY = 'FIND_PROPERTY', + + /** project.property('x') — throws if missing */ + PROJECT_PROPERTY = 'PROJECT_PROPERTY', + + /** project.hasProperty('x') — boolean check */ + HAS_PROPERTY = 'HAS_PROPERTY', + + // ── Provider API ── + /** providers.gradleProperty('x') */ + GRADLE_PROPERTY_PROVIDER = 'GRADLE_PROPERTY_PROVIDER', + + /** providers.systemProperty('x') */ + SYSTEM_PROPERTY_PROVIDER = 'SYSTEM_PROPERTY_PROVIDER', + + /** providers.environmentVariable('x') */ + ENV_VARIABLE_PROVIDER = 'ENV_VARIABLE_PROVIDER', + + // ── Kotlin delegation ── + /** val x: String by project */ + PROJECT_DELEGATION = 'PROJECT_DELEGATION', + + /** val x by extra("y") */ + EXTRA_DELEGATION = 'EXTRA_DELEGATION', + + // ── Other ── + /** file('VERSION').text.trim() or file('x').readText() */ + FILE_READ = 'FILE_READ', + + /** requested.version (inside eachPlugin / eachDependency blocks) */ + REQUESTED_VERSION = 'REQUESTED_VERSION', + + /** libs.spring.boot.starter / libs.versions.spring — version catalog accessor */ + VERSION_CATALOG_ACCESSOR = 'VERSION_CATALOG_ACCESSOR', + + /** libs.bundles.spring — version catalog bundle accessor */ + VERSION_CATALOG_BUNDLE = 'VERSION_CATALOG_BUNDLE', + + /** libs.plugins.spring.boot — version catalog plugin accessor */ + VERSION_CATALOG_PLUGIN = 'VERSION_CATALOG_PLUGIN', + + /** rootProject.someProperty / rootProject.ext.someProperty */ + ROOT_PROJECT_PROPERTY = 'ROOT_PROJECT_PROPERTY', + + /** Variable reference that couldn't be classified further */ + VARIABLE_REFERENCE = 'VARIABLE_REFERENCE', +} diff --git a/parser/src/enums/gradle/value-references/index.ts b/parser/src/enums/gradle/value-references/index.ts new file mode 100644 index 000000000..3eb249523 --- /dev/null +++ b/parser/src/enums/gradle/value-references/index.ts @@ -0,0 +1,2 @@ +export { GradleValueReferenceType } from '@/enums/gradle/value-references/GradleValueReferenceType'; +export { GradleReferenceResolution } from '@/enums/gradle/value-references/GradleReferenceResolution'; diff --git a/parser/src/enums/index.ts b/parser/src/enums/index.ts new file mode 100644 index 000000000..69a3f6ffe --- /dev/null +++ b/parser/src/enums/index.ts @@ -0,0 +1,13 @@ +export * from '@/enums/java/types'; +export * from '@/enums/java/type-references'; +export * from '@/enums/java/annotations'; +export * from '@/enums/java/imports'; +export * from '@/enums/java/fields'; +export * from '@/enums/java/expressions'; +export * from '@/enums/java/local-variables'; +export * from '@/enums/java/blocks'; +export * from '@/enums/java/scopes'; +export * from '@/enums/python'; +export { SkippedFileReason } from '@/enums/SkippedFileReason'; +export * from '@/enums/xml'; +export * from '@/enums/yaml'; diff --git a/parser/src/enums/java/annotations/AnnotationContext.ts b/parser/src/enums/java/annotations/AnnotationContext.ts new file mode 100644 index 000000000..e1923df94 --- /dev/null +++ b/parser/src/enums/java/annotations/AnnotationContext.ts @@ -0,0 +1,243 @@ +/** + * Classification of where annotations appear in Java source code. + * + * Java allows annotations in numerous syntactic locations, each serving + * different purposes in the language. This enum categorizes annotations + * by their declaration context to enable context-specific analysis. + * + * ## Annotation Target Contexts + * + * Java supports annotations in 12 distinct contexts, each with specific + * semantic meaning and application: + * + * ### Declaration Contexts + * - **TYPE_DECLARATION**: Classes, interfaces, enums + * - **FIELD_DECLARATION**: Class or instance fields + * - **METHOD_DECLARATION**: Method definitions + * - **PARAMETER_DECLARATION**: Method and constructor parameters + * - **CONSTRUCTOR_DECLARATION**: Constructor definitions + * - **ANNOTATION_TYPE_DECLARATION**: Meta-annotations on annotation definitions + * - **ENUM_CONSTANT**: Individual enum values + * - **RECORD_COMPONENT**: Record component parameters (Java 14+) + * - **PACKAGE_DECLARATION**: Package-level annotations + * + * ### Type System Contexts + * - **TYPE_PARAMETER**: Generic type parameter declarations + * - **TYPE_USE**: Any use of a type in code (Java 8+) + * - **LOCAL_VARIABLE**: Local variable declarations within methods + * + * ## Usage in Analysis + * + * The annotation context enables: + * - Target analysis: Find annotations by where they're applied + * - Convention checking: Validate annotation usage patterns + * - Migration detection: Track annotation placement across versions + * - Framework analysis: Identify dependency injection, validation, mapping patterns + * - Compliance checking: Ensure annotations used in correct contexts + * + * ## Java @Target Relationship + * + * While Java's `@Target` meta-annotation restricts where annotations can be used, + * AnnotationContext captures where they're actually used in source code, enabling + * validation and pattern analysis. + */ +export enum AnnotationContext { + /** + * Annotation on a type declaration (class, interface, enum). + * + * Examples: + * ```java + * @Entity + * @Table(name = "users") + * public class User { } + * + * @Service + * public class UserService { } + * + * @FunctionalInterface + * public interface Processor { } + * ``` + */ + TYPE_DECLARATION = 'TYPE_DECLARATION', + + /** + * Annotation on a field declaration. + * + * Examples: + * ```java + * @Id + * @GeneratedValue(strategy = GenerationType.IDENTITY) + * private Long id; + * + * @Autowired + * private UserRepository userRepository; + * + * @Column(name = "email_address") + * private String email; + * ``` + */ + FIELD_DECLARATION = 'FIELD_DECLARATION', + + /** + * Annotation on a method declaration. + * + * Examples: + * ```java + * @Override + * public String toString() { } + * + * @GetMapping("/users/{id}") + * public User getUser(@PathVariable Long id) { } + * + * @Transactional(readOnly = true) + * public List findAll() { } + * ``` + */ + METHOD_DECLARATION = 'METHOD_DECLARATION', + + /** + * Annotation on a method or constructor parameter. + * + * Examples: + * ```java + * public void setUser(@NotNull User user) { } + * + * public User getUser(@PathVariable("id") Long userId) { } + * + * public UserService(@Autowired UserRepository repo) { } + * ``` + */ + PARAMETER_DECLARATION = 'PARAMETER_DECLARATION', + + /** + * Annotation on a constructor declaration. + * + * Examples: + * ```java + * @Autowired + * public UserService(UserRepository repo) { } + * + * @JsonCreator + * public User(@JsonProperty("name") String name) { } + * ``` + */ + CONSTRUCTOR_DECLARATION = 'CONSTRUCTOR_DECLARATION', + + /** + * Annotation on a local variable within a method body. + * + * Examples: + * ```java + * public void process() { + * @SuppressWarnings("unchecked") + * List items = (List) obj; + * } + * ``` + */ + LOCAL_VARIABLE = 'LOCAL_VARIABLE', + + /** + * Annotation on a generic type parameter declaration. + * + * Examples: + * ```java + * public class Container<@NonNull T> { } + * + * public <@Nullable E> void process(E element) { } + * ``` + */ + TYPE_PARAMETER = 'TYPE_PARAMETER', + + /** + * Annotation on type usage (Java 8+ type annotations). + * + * Examples: + * ```java + * List<@NonNull String> items; + * + * Map<@NonEmpty String, @Positive Integer> scores; + * + * @NonNull String getValue() { } + * + * void process() throws @Critical IOException { } + * ``` + */ + TYPE_USE = 'TYPE_USE', + + /** + * Annotation on a package declaration (in package-info.java). + * + * Examples: + * ```java + * @NonNullApi + * @NonNullFields + * package com.example.api; + * ``` + */ + PACKAGE_DECLARATION = 'PACKAGE_DECLARATION', + + /** + * Meta-annotation on an annotation type declaration. + * + * Examples: + * ```java + * @Target(ElementType.TYPE) + * @Retention(RetentionPolicy.RUNTIME) + * public @interface MyAnnotation { } + * ``` + */ + ANNOTATION_TYPE_DECLARATION = 'ANNOTATION_TYPE_DECLARATION', + + /** + * Annotation on an enum constant. + * + * Examples: + * ```java + * public enum Status { + * @JsonProperty("active") + * ACTIVE, + * + * @JsonProperty("inactive") + * INACTIVE + * } + * ``` + */ + ENUM_CONSTANT = 'ENUM_CONSTANT', + + /** + * Annotation on a record component (Java 14+). + * + * Examples: + * ```java + * public record User( + * @NotNull String name, + * @Email String email + * ) { } + * ``` + */ + RECORD_COMPONENT = 'RECORD_COMPONENT', + + /** + * Annotation on a class-level type parameter bound. + * + * Examples: + * ```java + * public class Container { } + * + * public class Box> { } + * ``` + */ + TYPE_PARAM_BOUND = 'TYPE_PARAM_BOUND', + + /** + * Annotation on a method-level type parameter bound. + * + * Examples: + * ```java + * public T process(T value) { } + * + * public > void sort(List items) { } + * ``` + */ + METHOD_TYPE_PARAM_BOUND = 'METHOD_TYPE_PARAM_BOUND', +} diff --git a/parser/src/enums/java/annotations/AnnotationKind.ts b/parser/src/enums/java/annotations/AnnotationKind.ts new file mode 100644 index 000000000..a1c407fc6 --- /dev/null +++ b/parser/src/enums/java/annotations/AnnotationKind.ts @@ -0,0 +1,99 @@ +/** + * Classification of annotation structural patterns in Java. + * + * Annotations in Java can take various syntactic forms depending on their + * argument structure. This enum categorizes annotations by their argument + * pattern to enable structural analysis and querying. + * + * ## Annotation Syntax Patterns + * + * Java supports five distinct annotation argument patterns: + * + * 1. **MARKER**: No arguments + * - Simplest form, used for boolean flags or presence indicators + * - Example: `@Override`, `@Deprecated`, `@FunctionalInterface` + * + * 2. **SINGLE_VALUE**: Shorthand single argument (implicit "value") + * - Uses implicit "value" attribute name + * - Example: `@Timeout(1000)`, `@SuppressWarnings("unchecked")` + * + * 3. **ARRAY_VALUE**: Shorthand array argument + * - Array literal using braces without explicit attribute name + * - Example: `@Target({ElementType.TYPE, ElementType.METHOD})` + * + * 4. **NAMED_ARGUMENTS**: Explicit name=value pairs + * - One or more explicitly named arguments + * - Example: `@Column(name = "user_id", nullable = false)` + * - Example: `@RequestMapping(method = RequestMethod.GET, path = "/users")` + * + * 5. **NESTED**: Contains nested annotation(s) as arguments + * - Arguments include other annotations + * - Example: `@JoinColumn(foreignKey = @ForeignKey(name = "fk_user"))` + * - Example: `@JsonFormat(with = @JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY)` + * + * ## Usage in Analysis + * + * The annotation kind enables: + * - Pattern-based filtering: Find all marker annotations vs. configured ones + * - Complexity analysis: Identify heavily parameterized annotations + * - Refactoring detection: Track changes from marker to configured annotations + * - Convention analysis: Discover common annotation patterns in codebase + * + * ## Relationship to Arguments + * + * While AnnotationKind categorizes the structural pattern, individual arguments + * are tracked separately as AnnotationArgumentReference entities for detailed + * argument-level analysis. + */ +export enum AnnotationKind { + /** + * Marker annotation with no arguments. + * + * Examples: + * - `@Override` + * - `@Deprecated` + * - `@FunctionalInterface` + * - `@PostConstruct` + */ + MARKER = 'MARKER', + + /** + * Single value annotation using implicit "value" attribute. + * + * Examples: + * - `@Timeout(1000)` + * - `@SuppressWarnings("unchecked")` + * - `@RequestMapping("/api/users")` + * - `@Retention(RetentionPolicy.RUNTIME)` + */ + SINGLE_VALUE = 'SINGLE_VALUE', + + /** + * Array value annotation with implicit "value" attribute. + * + * Examples: + * - `@Target({ElementType.TYPE, ElementType.METHOD})` + * - `@SuppressWarnings({"unchecked", "deprecation"})` + */ + ARRAY_VALUE = 'ARRAY_VALUE', + + /** + * Named arguments annotation with explicit attribute names. + * + * Examples: + * - `@Column(name = "user_id", nullable = false)` + * - `@Table(name = "users", schema = "public")` + * - `@RequestMapping(method = RequestMethod.GET, path = "/users")` + */ + NAMED_ARGUMENTS = 'NAMED_ARGUMENTS', + + /** + * Nested annotation containing other annotations as arguments. + * + * Examples: + * - `@JoinColumn(foreignKey = @ForeignKey(name = "fk_user"))` + * - `@JsonFormat(with = @JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY)` + * - `@Repeatable(@Schedules)` + */ + NESTED = 'NESTED', +} diff --git a/parser/src/enums/java/annotations/ArgumentValueType.ts b/parser/src/enums/java/annotations/ArgumentValueType.ts new file mode 100644 index 000000000..20d84f338 --- /dev/null +++ b/parser/src/enums/java/annotations/ArgumentValueType.ts @@ -0,0 +1,215 @@ +/** + * Classification of annotation argument value types in Java. + * + * Annotation arguments in Java can accept various value types, each with + * specific semantic meaning and linking requirements. This enum categorizes + * argument values to enable type-specific analysis and relationship tracking. + * + * ## Argument Value Categories + * + * Java annotation arguments support eight distinct value types: + * + * ### Reference Types + * - **CLASS_REFERENCE**: Class literals (`.class` syntax) + * - **ENUM_CONSTANT**: Enum values + * - **NESTED_ANNOTATION**: Other annotations as values + * + * ### Literal Types + * - **STRING_LITERAL**: String constants + * - **NUMBER_LITERAL**: Integer and floating-point numbers + * - **BOOLEAN_LITERAL**: `true` or `false` + * - **NULL**: Null literal + * + * ### Fallback + * - **UNKNOWN**: Unclassified or complex expressions + * + * ## Array Handling + * + * Arrays are NOT represented as a single ARRAY type. Instead, each array element + * is expanded into its own AnnotationArgumentReference with: + * - `valueType`: The type of the individual element (STRING_LITERAL, ENUM_CONSTANT, etc.) + * - `arrayIndex`: Position within the array (0, 1, 2, ...) + * + * Example: `@Target({TYPE, METHOD})` creates two separate argument references: + * - argumentName="value", argumentValue="TYPE", valueType=ENUM_CONSTANT, arrayIndex=0 + * - argumentName="value", argumentValue="METHOD", valueType=ENUM_CONSTANT, arrayIndex=1 + * + * ## Usage in Analysis + * + * Argument value types enable: + * - **Type linking**: Connect CLASS_REFERENCE to TypeRegistry entities + * - **Annotation graphs**: Link NESTED_ANNOTATION to child annotations + * - **Configuration mining**: Extract STRING_LITERAL and NUMBER_LITERAL patterns + * - **Dependency analysis**: Track class references in annotations + * - **Validation**: Ensure type-appropriate argument values + * + * ## Relationship to AnnotationArgumentReference + * + * Each AnnotationArgumentReference entity has a valueType field that uses + * this enum to classify its value, enabling type-specific queries and linking. + */ +export enum ArgumentValueType { + /** + * Class reference using `.class` literal syntax. + * + * Links to TypeRegistry entities for dependency analysis. + * + * Examples: + * ```java + * @EntityListeners(AuditListener.class) + * @JsonDeserialize(using = CustomDeserializer.class) + * @ManyToOne(targetEntity = Customer.class) + * ``` + * + * Pattern: `ClassName.class` or `package.ClassName.class` + */ + CLASS_REFERENCE = 'CLASS_REFERENCE', + + /** + * String literal value. + * + * Common for configuration, names, paths, and messages. + * + * Examples: + * ```java + * @Column(name = "user_id") + * @RequestMapping(path = "/api/users") + * @Table(name = "users", schema = "public") + * @Value("${app.config.timeout}") + * ``` + * + * Pattern: `"string content"` (with escaping support) + */ + STRING_LITERAL = 'STRING_LITERAL', + + /** + * Numeric literal (integer or floating-point). + * + * Used for configuration values, limits, and constants. + * + * Examples: + * ```java + * @Timeout(1000) + * @Size(min = 1, max = 255) + * @Column(length = 50, precision = 2) + * @CacheEvict(maxEntries = 100) + * ``` + * + * Pattern: `123`, `3.14`, `0xFF`, `1_000_000`, `-1`, `999L` + */ + NUMBER_LITERAL = 'NUMBER_LITERAL', + + /** + * Character literal value. + * + * Single character constants enclosed in single quotes. + * + * Examples: + * ```java + * @CharValue('x') + * @Delimiter(',') + * @Separator('\t') + * ``` + * + * Pattern: `'c'`, `'\n'`, `'\u0041'` + */ + CHAR_LITERAL = 'CHAR_LITERAL', + + /** + * Boolean literal (`true` or `false`). + * + * Used for feature flags and configuration switches. + * + * Examples: + * ```java + * @Column(nullable = false) + * @JsonProperty(required = true) + * @Transactional(readOnly = true) + * @Cacheable(sync = false) + * ``` + * + * Pattern: `true` or `false` + */ + BOOLEAN_LITERAL = 'BOOLEAN_LITERAL', + + /** + * Enum constant or static final field reference. + * + * Common for type-safe configuration using enum values or compile-time constants. + * Cannot distinguish between enum constants and static final fields without type resolution. + * + * Examples: + * ```java + * @Retention(RetentionPolicy.RUNTIME) + * @Target(ElementType.TYPE) + * @RequestMapping(method = RequestMethod.GET) + * @Timeout(Constants.DEFAULT_TIMEOUT) + * @Message(ErrorMessages.CONNECTION_FAILED) + * ``` + * + * Pattern: `Type.CONSTANT` or `CONSTANT` (when statically imported) + */ + ENUM_CONSTANT = 'ENUM_CONSTANT', + + /** + * Compile-time constant expression. + * + * Arithmetic, bitwise, or string concatenation expressions evaluated at compile time. + * + * Examples: + * ```java + * @Timeout(60 * 1000) + * @BitMask(1 << 3) + * @Value(2 + 3 * 4) + * @Message("Error: " + "Connection failed") + * @NegativeValue(-1) + * @BitwiseNot(~0) + * ``` + * + * Pattern: Binary ops (`+`, `-`, `*`, `/`, `<<`, `>>`, `|`, `&`, `^`) or unary ops (`-`, `+`, `~`, `!`) + */ + CONSTANT_EXPRESSION = 'CONSTANT_EXPRESSION', + + /** + * Nested annotation as argument value. + * + * Links to child TypeAnnotation entities for hierarchical analysis. + * + * Examples: + * ```java + * @JoinColumn(foreignKey = @ForeignKey(name = "fk_user")) + * @JsonFormat(with = @JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + * @SecondaryTable(pkJoinColumns = @PrimaryKeyJoinColumn(name = "user_id")) + * ``` + * + * Pattern: Full annotation syntax `@AnnotationName(...)` + */ + NESTED_ANNOTATION = 'NESTED_ANNOTATION', + + /** + * Null literal value. + * + * Rarely used but syntactically valid in some contexts. + * + * Examples: + * ```java + * @JsonProperty(defaultValue = null) + * ``` + * + * Pattern: `null` + */ + NULL = 'NULL', + + /** + * Unknown or unclassified value type. + * + * Fallback for complex expressions or unrecognized patterns. + * May indicate need for parser enhancement. + * + * Examples: + * - Complex constant expressions + * - Method calls (generally not allowed) + * - Unrecognized syntax patterns + */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/java/annotations/index.ts b/parser/src/enums/java/annotations/index.ts new file mode 100644 index 000000000..bae005e8c --- /dev/null +++ b/parser/src/enums/java/annotations/index.ts @@ -0,0 +1,3 @@ +export { AnnotationContext } from '@/enums/java/annotations/AnnotationContext'; +export { AnnotationKind } from '@/enums/java/annotations/AnnotationKind'; +export { ArgumentValueType } from '@/enums/java/annotations/ArgumentValueType'; diff --git a/parser/src/enums/java/blocks/BlockKind.ts b/parser/src/enums/java/blocks/BlockKind.ts new file mode 100644 index 000000000..2204e4a1e --- /dev/null +++ b/parser/src/enums/java/blocks/BlockKind.ts @@ -0,0 +1,196 @@ +/** + * Identifies the type of code block that can contain expressions and local variables. + * + * This enumeration describes block-level constructs that create a scope or + * logical grouping for the code inside them. + * + * ## Block Categories + * + * - **Exception Handling** – try, catch, finally, try-with-resources + * - **Loops** – for, enhanced for, while, do-while + * - **Conditionals** – if, else-if, else, switch case + * - **Synchronization** – synchronized block + * + * ## Examples by Block Kind + * + * ```java + * // === Exception Handling === + * + * // TRY + * try { + * riskyOperation(); + * } + * + * // TRY_WITH_RESOURCES (Java 7+) + * try (FileReader reader = new FileReader("file.txt")) { + * reader.read(); + * } + * + * // CATCH + * try { ... } catch (IOException e) { + * handleError(e); + * } + * + * // FINALLY + * try { ... } finally { + * cleanup(); + * } + * + * // === Loops === + * + * // FOR + * for (int i = 0; i < 10; i++) { + * process(i); + * } + * + * // ENHANCED_FOR (Java 5+) + * for (String item : items) { + * process(item); + * } + * + * // WHILE + * while (condition) { + * doWork(); + * } + * + * // DO_WHILE + * do { + * doWork(); + * } while (condition); + * + * // === Conditionals === + * + * // IF + * if (condition) { + * handleTrue(); + * } + * + * // ELSE_IF (tree-sitter: if_statement in alternative position) + * if (c1) { + * // IF block + * } else if (c2) { + * // ELSE_IF block + * } + * + * // ELSE (tree-sitter: block in alternative position) + * if (condition) { + * // IF block + * } else { + * // ELSE block + * } + * + * // SWITCH_CASE (traditional) + * switch (value) { + * case 1: + * handle1(); + * break; + * default: + * handleDefault(); + * } + * + * // SWITCH_EXPRESSION_CASE (Java 14+) + * int result = switch (day) { + * case MONDAY -> 1; + * case TUESDAY -> 2; + * default -> 0; + * }; + * + * // === Synchronization === + * + * // SYNCHRONIZED + * synchronized (lock) { + * sharedResource.update(); + * } + * ``` + * + * ## Ownership Model + * + * Blocks form a hierarchy where each block can contain: + * - Local variables (linked via parentExpressionLinkHash) + * - Expression statements (linked via expressionOwnerHash) + * - Nested blocks (linked via parentContainerHash) + * + * ## Example Hierarchy + * + * ```java + * void process() { + * try { // TRY block + * for (int i = 0; i < 10; i++) { // FOR block (parent: TRY) + * if (condition) { // IF block (parent: FOR) + * doSomething(); // owned by IF block + * } + * } + * } catch (Exception e) { // CATCH block (tryStatementHash links to TRY) + * handleError(e); // owned by CATCH block + * } finally { // FINALLY block (tryStatementHash links to TRY) + * cleanup(); // owned by FINALLY block + * } + * } + * ``` + * + * ## Key Fields in BlockRegistry + * + * - `parentContainerHash` – Links to containing block or lambda expression + * - `tryStatementHash` – Groups TRY + CATCH + FINALLY blocks together + * - `methodOwnerHash` – Always points to the containing method + */ +export enum BlockKind { + // === Exception Handling === + /** Standard try block body. */ + TRY = 'TRY', + + /** Try-with-resources block (Java 7+). Resources are auto-closed. */ + TRY_WITH_RESOURCES = 'TRY_WITH_RESOURCES', + + /** Catch clause block. Contains exception handling code. */ + CATCH = 'CATCH', + + /** Finally clause block. Always executes after try/catch. */ + FINALLY = 'FINALLY', + + // === Loops === + /** Standard for loop block. */ + FOR = 'FOR', + + /** Enhanced for loop / for-each block (Java 5+). */ + ENHANCED_FOR = 'ENHANCED_FOR', + + /** While loop block. */ + WHILE = 'WHILE', + + /** Do-while loop block. */ + DO_WHILE = 'DO_WHILE', + + // === Conditionals === + /** If statement block (the "then" branch). */ + IF = 'IF', + + /** + * Else-if block. In Java, `else if` is syntactically an else containing an if, + * but tree-sitter represents it as an if_statement directly in the alternative. + * + * ```java + * if (c1) { + * // IF block + * } else if (c2) { + * // ELSE_IF block (not ELSE + nested IF) + * } else { + * // ELSE block + * } + * ``` + */ + ELSE_IF = 'ELSE_IF', + + /** Standalone else block (when alternative is a block, not another if_statement). */ + ELSE = 'ELSE', + + /** Switch case block (traditional or arrow). */ + SWITCH_CASE = 'SWITCH_CASE', + + /** Switch expression case (Java 14+). */ + SWITCH_EXPRESSION_CASE = 'SWITCH_EXPRESSION_CASE', + + // === Synchronization === + /** Synchronized block. */ + SYNCHRONIZED = 'SYNCHRONIZED', +} diff --git a/parser/src/enums/java/blocks/index.ts b/parser/src/enums/java/blocks/index.ts new file mode 100644 index 000000000..905d8ce04 --- /dev/null +++ b/parser/src/enums/java/blocks/index.ts @@ -0,0 +1 @@ +export { BlockKind } from '@/enums/java/blocks/BlockKind'; diff --git a/parser/src/enums/java/comments/CommentKind.ts b/parser/src/enums/java/comments/CommentKind.ts new file mode 100644 index 000000000..cc10e736b --- /dev/null +++ b/parser/src/enums/java/comments/CommentKind.ts @@ -0,0 +1,78 @@ +/** + * Describes the kind of comment in Java source code. + * + * This enumeration classifies comments by their syntactic form, which + * determines their purpose and rendering behavior in documentation tools. + * + * ## Comment Kinds + * + * - **LINE_COMMENT** – Single-line `//` comments + * - **BLOCK_COMMENT** – Multi-line `/* ... *​/` comments + * - **JAVADOC** – Documentation `/** ... *​/` comments (rendered by javadoc tool) + * + * ## Examples + * + * ```java + * // LINE_COMMENT - single-line comment starting with // + * // This is a line comment + * private int count = 0; + * + * // Multiple line comments before the same entity get grouped + * // with ascending commentIndex (0, 1, 2, ...) + * // Each is a separate LINE_COMMENT with the same ownerHash + * private String name = "default"; + * + * // BLOCK_COMMENT - multi-line comment delimited by /* ... *​/ + * /* This is a block comment + * spanning multiple lines *​/ + * public void process() { } + * + * // JAVADOC - documentation comment delimited by /** ... *​/ + * // Distinguished from BLOCK_COMMENT by the leading /** (double asterisk) + * /** + * * Calculates the total price including tax. + * * + * * @param price the base price + * * @param taxRate the tax rate as a decimal + * * @return the total price + * *​/ + * public double calculateTotal(double price, double taxRate) { + * return price * (1 + taxRate); + * } + * ``` + * + * ## End-of-Line Comments + * + * ```java + * int x = 42; // LINE_COMMENT on same line as declaration + * // linked to the entity on the same line (the field/variable) + * ``` + * + * ## Association Model + * + * Each comment is linked to an owner entity via `ownerHash`: + * - Comments before a type declaration → linked to TYPE_REGISTRY + * - Comments before a method → linked to METHOD_REGISTRY + * - Comments before a field → linked to FIELD_REGISTRY + * - Comments before a local variable → linked to LOCAL_VARIABLE_REGISTRY + * - Comments before an annotation → linked to TYPE_ANNOTATION + * - Comments before an expression statement → linked to EXPRESSION_REFERENCE + * + * ## Tree-sitter AST Representation + * + * In the tree-sitter Java grammar: + * - `//` comments produce `line_comment` nodes + * - `/* ... *​/` and `/** ... *​/` both produce `block_comment` nodes + * - Javadoc is distinguished from block comments by checking if text starts with `/**` + * - Comments are sibling nodes of declarations in `class_body`, `block`, and `program` + */ +export enum CommentKind { + /** Single-line comment: `// ...` */ + LINE_COMMENT = 'LINE_COMMENT', + + /** Multi-line block comment: `/* ... *​/` */ + BLOCK_COMMENT = 'BLOCK_COMMENT', + + /** Javadoc documentation comment: `/** ... *​/` */ + JAVADOC = 'JAVADOC', +} diff --git a/parser/src/enums/java/comments/index.ts b/parser/src/enums/java/comments/index.ts new file mode 100644 index 000000000..3ed161782 --- /dev/null +++ b/parser/src/enums/java/comments/index.ts @@ -0,0 +1 @@ +export { CommentKind } from '@/enums/java/comments/CommentKind'; diff --git a/parser/src/enums/java/expressions/EdgeRole.ts b/parser/src/enums/java/expressions/EdgeRole.ts new file mode 100644 index 000000000..d573ed4ec --- /dev/null +++ b/parser/src/enums/java/expressions/EdgeRole.ts @@ -0,0 +1,533 @@ +/** + * Describes the relationship of an expression to its parent expression. + * + * This enumeration describes how a child expression relates to its parent + * in the expression tree. Root expressions use ROOT. + * + * ## Role Categories + * + * - **ROOT** – Top-level expression (no parent) + * - **Operands** – Binary, unary, ternary operand positions + * - **Invocation** – Method receiver, arguments + * - **Field/Array Access** – Qualifier, array index + * - **Array Creation** – Element, dimension positions + * - **Type Operations** – Cast operand, instanceof operand + * - **Functional** – Lambda body + * - **Switch** – Selector, case labels, guards, results + * - **String Template** – Template literal text, embedded expressions (Java 21+) + * - **Structural** – Parenthesized inner + * - **Assignment** – Target and value + * + * ## Classification Examples + * + * Each example shows: [ROOT: ExpressionKind] then the tree structure. + * + * **Tree Direction:** ROOT is always the OUTERMOST expression. + * Trees go from outer (top) to inner (bottom/leaves). + * - `(a + b) * c` → ROOT is `*` (outermost), `a + b` is nested inside + * - `obj.method()` → ROOT is method call (outermost), `obj` is nested inside + * + * ```java + * // ───────────────────────────────────────────────────────────────── + * // ROOT - Top-level expression (no parent) + * // ───────────────────────────────────────────────────────────────── + * private int x = 42; + * // [ROOT: LITERAL] + * // Tree: LITERAL (edgeRole: ROOT) + * + * // ───────────────────────────────────────────────────────────────── + * // LEFT_OPERAND, RIGHT_OPERAND - Binary expression operands + * // ───────────────────────────────────────────────────────────────── + * private int sum = a + b; + * // [ROOT: BINARY_EXPRESSION] + * // Tree: + * // BINARY_EXPRESSION (edgeRole: ROOT, operator: +) + * // ├── IDENTIFIER_REFERENCE 'a' (edgeRole: LEFT_OPERAND) + * // └── IDENTIFIER_REFERENCE 'b' (edgeRole: RIGHT_OPERAND) + * + * // ───────────────────────────────────────────────────────────────── + * // TERNARY_CONDITION, TERNARY_TRUE, TERNARY_FALSE + * // ───────────────────────────────────────────────────────────────── + * private int val = cond ? trueVal : falseVal; + * // [ROOT: TERNARY_EXPRESSION] + * // Tree: + * // TERNARY_EXPRESSION (edgeRole: ROOT) + * // ├── IDENTIFIER_REFERENCE 'cond' (edgeRole: TERNARY_CONDITION) + * // ├── IDENTIFIER_REFERENCE 'trueVal' (edgeRole: TERNARY_TRUE) + * // └── IDENTIFIER_REFERENCE 'falseVal' (edgeRole: TERNARY_FALSE) + * + * // ───────────────────────────────────────────────────────────────── + * // RECEIVER, ARGUMENT - Method invocation parts + * // ───────────────────────────────────────────────────────────────── + * private String result = obj.method(arg1, arg2); + * // [ROOT: METHOD_INVOCATION] + * // Tree: + * // METHOD_INVOCATION 'method' (edgeRole: ROOT) + * // ├── IDENTIFIER_REFERENCE 'obj' (edgeRole: RECEIVER) + * // ├── IDENTIFIER_REFERENCE 'arg1' (edgeRole: ARGUMENT, position: 0) + * // └── IDENTIFIER_REFERENCE 'arg2' (edgeRole: ARGUMENT, position: 1) + * + * // ───────────────────────────────────────────────────────────────── + * // RECEIVER for chained calls - outermost is ROOT + * // ───────────────────────────────────────────────────────────────── + * private String s = getData().trim().toLowerCase(); + * // [ROOT: METHOD_INVOCATION 'toLowerCase'] + * // Tree: + * // METHOD_INVOCATION 'toLowerCase' (edgeRole: ROOT) + * // └── METHOD_INVOCATION 'trim' (edgeRole: RECEIVER) + * // └── METHOD_INVOCATION 'getData' (edgeRole: RECEIVER) + * + * // ───────────────────────────────────────────────────────────────── + * // METHOD_NAME - Method name identifier (internal, not typically visible) + * // ───────────────────────────────────────────────────────────────── + * // Note: METHOD_NAME is used internally to mark the method name identifier + * // within a method invocation. It is extracted as a child of METHOD_INVOCATION. + * // Example: obj.calculate(x) → "calculate" is METHOD_NAME + * + * // ───────────────────────────────────────────────────────────────── + * // FIELD_NAME - Field name identifier (internal, not typically visible) + * // ───────────────────────────────────────────────────────────────── + * // Note: FIELD_NAME is used internally to mark the field name identifier + * // within a field access expression. It is extracted as a child of FIELD_ACCESS. + * // Example: obj.status → "status" is FIELD_NAME + * + * // ───────────────────────────────────────────────────────────────── + * // QUALIFIER - Qualifier expression in field access + * // ───────────────────────────────────────────────────────────────── + * private int len = getData().items.length; + * // [ROOT: FIELD_ACCESS 'length'] + * // Tree: + * // FIELD_ACCESS 'length' (edgeRole: ROOT) + * // └── FIELD_ACCESS 'items' (edgeRole: QUALIFIER) + * // └── METHOD_INVOCATION 'getData' (edgeRole: QUALIFIER) + * + * // ───────────────────────────────────────────────────────────────── + * // ARRAY_INDEX - Index expression in array access + * // ───────────────────────────────────────────────────────────────── + * private int first = array[0]; + * // [ROOT: ARRAY_ACCESS] + * // Tree: + * // ARRAY_ACCESS (edgeRole: ROOT) + * // ├── IDENTIFIER_REFERENCE 'array' (edgeRole: QUALIFIER) + * // └── LITERAL '0' (edgeRole: ARRAY_INDEX) + * + * // ───────────────────────────────────────────────────────────────── + * // ARRAY_ELEMENT - Elements in array initializer + * // ───────────────────────────────────────────────────────────────── + * private int[] arr = {1, 2, 3}; + * // [ROOT: ARRAY_INITIALIZER] + * // Tree: + * // ARRAY_INITIALIZER (edgeRole: ROOT) + * // ├── LITERAL '1' (edgeRole: ARRAY_ELEMENT, position: 0) + * // ├── LITERAL '2' (edgeRole: ARRAY_ELEMENT, position: 1) + * // └── LITERAL '3' (edgeRole: ARRAY_ELEMENT, position: 2) + * + * // ───────────────────────────────────────────────────────────────── + * // ARRAY_DIMENSION - Size expression in array creation + * // ───────────────────────────────────────────────────────────────── + * private int[] arr = new int[size]; + * // [ROOT: ARRAY_CREATION] + * // Tree: + * // ARRAY_CREATION (edgeRole: ROOT, elementType: int) + * // └── IDENTIFIER_REFERENCE 'size' (edgeRole: ARRAY_DIMENSION, position: 0) + * + * // Multi-dimensional array + * private int[][] matrix = new int[rows][cols]; + * // [ROOT: ARRAY_CREATION] + * // Tree: + * // ARRAY_CREATION (edgeRole: ROOT, elementType: int, dimensions: 2) + * // ├── IDENTIFIER_REFERENCE 'rows' (edgeRole: ARRAY_DIMENSION, position: 0) + * // └── IDENTIFIER_REFERENCE 'cols' (edgeRole: ARRAY_DIMENSION, position: 1) + * + * // ───────────────────────────────────────────────────────────────── + * // UNARY_OPERAND - Operand of unary expression + * // ───────────────────────────────────────────────────────────────── + * private int neg = -value; + * // [ROOT: UNARY_EXPRESSION] + * // Tree: + * // UNARY_EXPRESSION (edgeRole: ROOT, operator: -, fixity: PREFIX) + * // └── IDENTIFIER_REFERENCE 'value' (edgeRole: UNARY_OPERAND) + * + * // ───────────────────────────────────────────────────────────────── + * // CAST_OPERAND - Expression being cast + * // ───────────────────────────────────────────────────────────────── + * private long big = (long) smallInt; + * // [ROOT: CAST_EXPRESSION] + * // Tree: + * // CAST_EXPRESSION (edgeRole: ROOT, castType: long) + * // └── IDENTIFIER_REFERENCE 'smallInt' (edgeRole: CAST_OPERAND) + * + * // ───────────────────────────────────────────────────────────────── + * // INSTANCEOF_OPERAND - Expression being tested + * // ───────────────────────────────────────────────────────────────── + * private boolean isStr = obj instanceof String; + * // [ROOT: INSTANCEOF_EXPRESSION] + * // Tree: + * // INSTANCEOF_EXPRESSION (edgeRole: ROOT, testType: String) + * // └── IDENTIFIER_REFERENCE 'obj' (edgeRole: INSTANCEOF_OPERAND) + * + * // ───────────────────────────────────────────────────────────────── + * // PATTERN_VARIABLE - Pattern variable in instanceof pattern (Java 16+) + * // ───────────────────────────────────────────────────────────────── + * String result = (obj instanceof String s) ? s.toUpperCase() : "default"; + * // [ROOT: TERNARY_EXPRESSION] + * // Tree: + * // TERNARY_EXPRESSION (edgeRole: ROOT) + * // ├── PARENTHESIZED (edgeRole: TERNARY_CONDITION) + * // │ └── INSTANCEOF_PATTERN (edgeRole: PARENTHESIZED_INNER, testType: String) + * // │ ├── IDENTIFIER_REFERENCE 'obj' (edgeRole: INSTANCEOF_OPERAND) + * // │ └── IDENTIFIER_REFERENCE 's' (edgeRole: PATTERN_VARIABLE) + * // ├── METHOD_INVOCATION 'toUpperCase' (edgeRole: TERNARY_TRUE) + * // │ └── IDENTIFIER_REFERENCE 's' (edgeRole: RECEIVER) + * // └── LITERAL '"default"' (edgeRole: TERNARY_FALSE) + * // + * // Note: Pattern variable 's' appears twice - as PATTERN_VARIABLE (declaration) + * // and as RECEIVER (usage). Both reference the same scoped variable. + * + * Integer doubled = numObj instanceof Integer i ? i * 2 : 0; + * // [ROOT: TERNARY_EXPRESSION] + * // Tree: + * // TERNARY_EXPRESSION (edgeRole: ROOT) + * // ├── INSTANCEOF_PATTERN (edgeRole: TERNARY_CONDITION, testType: Integer) + * // │ ├── IDENTIFIER_REFERENCE 'numObj' (edgeRole: INSTANCEOF_OPERAND) + * // │ └── IDENTIFIER_REFERENCE 'i' (edgeRole: PATTERN_VARIABLE) + * // ├── BINARY_EXPRESSION (edgeRole: TERNARY_TRUE, operator: *) + * // │ ├── IDENTIFIER_REFERENCE 'i' (edgeRole: LEFT_OPERAND) + * // │ └── LITERAL '2' (edgeRole: RIGHT_OPERAND) + * // └── LITERAL '0' (edgeRole: TERNARY_FALSE) + * + * // ───────────────────────────────────────────────────────────────── + * // LAMBDA_PARAMETER & LAMBDA_BODY - Lambda expression components + * // ───────────────────────────────────────────────────────────────── + * private Function f = x -> x * 2; + * // [ROOT: LAMBDA_EXPRESSION] - The lambda itself is assigned to field 'f' + * // Tree: + * // LAMBDA_EXPRESSION (edgeRole: ROOT, operator: "x") + * // ├── IDENTIFIER_REFERENCE 'x' (edgeRole: LAMBDA_PARAMETER, referencedEntityKind: LAMBDA_PARAMETER) + * // └── BINARY_EXPRESSION (edgeRole: LAMBDA_BODY, operator: *) + * // ├── IDENTIFIER_REFERENCE 'x' (edgeRole: LEFT_OPERAND) + * // └── LITERAL '2' (edgeRole: RIGHT_OPERAND) + * // + * // Multi-parameter lambda: + * private BiFunction add = (a, b) -> a + b; + * // Tree: + * // LAMBDA_EXPRESSION (edgeRole: ROOT, operator: "a,b") + * // ├── IDENTIFIER_REFERENCE 'a' (edgeRole: LAMBDA_PARAMETER, position: 0) + * // ├── IDENTIFIER_REFERENCE 'b' (edgeRole: LAMBDA_PARAMETER, position: 1) + * // └── BINARY_EXPRESSION (edgeRole: LAMBDA_BODY, operator: +) + * // ├── IDENTIFIER_REFERENCE 'a' (edgeRole: LEFT_OPERAND) + * // └── IDENTIFIER_REFERENCE 'b' (edgeRole: RIGHT_OPERAND) + * + * // ───────────────────────────────────────────────────────────────── + * // Anonymous class assigned to field (class body is separate TypeRegistry entry) + * // ───────────────────────────────────────────────────────────────── + * private Runnable r = new Runnable() { + * private int count = 0; + * public void run() { System.out.println(count++); } + * }; + * // [ROOT: ANONYMOUS_CLASS_CREATION] - The anonymous class is assigned to field 'r' + * // Tree: + * // ANONYMOUS_CLASS_CREATION (edgeRole: ROOT) + * // - implementedTypeHash → TypeRegistry (Runnable) + * // - anonymousTypeHash → TypeRegistry (MyClass$1) + * // + * // The anonymous class body is extracted separately: + * // - TypeRegistry entry for "MyClass$1" (isAnonymous: true) + * // - FieldRegistry entry for "count" (enclosingType: MyClass$1) + * // - MethodRegistry entry for "run" (enclosingType: MyClass$1) + * // - Field initializer "0" has its own ExpressionReference tree + * + * // ───────────────────────────────────────────────────────────────── + * // SWITCH_SELECTOR, SWITCH_CASE_RESULT - Switch expression parts + * // ───────────────────────────────────────────────────────────────── + * private String day = switch(getStatus()) { + * case 1 -> "Monday"; + * default -> "Other"; + * }; + * // [ROOT: SWITCH_EXPRESSION] + * // Tree: + * // SWITCH_EXPRESSION (edgeRole: ROOT) + * // ├── METHOD_INVOCATION 'getStatus' (edgeRole: SWITCH_SELECTOR) + * // ├── LITERAL '"Monday"' (edgeRole: SWITCH_CASE_RESULT) + * // └── LITERAL '"Other"' (edgeRole: SWITCH_CASE_RESULT) + * + * // ───────────────────────────────────────────────────────────────── + * // SWITCH_TYPE_PATTERN, SWITCH_GUARD - Pattern matching in switch (Java 17+/21+) + * // ───────────────────────────────────────────────────────────────── + * private String guardedPattern = switch(obj) { + * case String s when s.length() > 5 -> "long string"; + * case String s when s.isEmpty() -> "empty"; + * case String s -> "short string"; + * default -> "other"; + * }; + * // [ROOT: SWITCH_EXPRESSION] + * // Tree: + * // SWITCH_EXPRESSION (edgeRole: ROOT) + * // ├── IDENTIFIER_REFERENCE 'obj' (edgeRole: SWITCH_SELECTOR) + * // │ + * // │ // Case 0: case String s when s.length() > 5 -> "long string" + * // ├── IDENTIFIER_REFERENCE 's' (edgeRole: SWITCH_TYPE_PATTERN, position: 0) + * // ├── BINARY_EXPRESSION '>' (edgeRole: SWITCH_GUARD, position: 0) + * // │ ├── METHOD_INVOCATION 'length' (edgeRole: LEFT_OPERAND) + * // │ │ └── IDENTIFIER_REFERENCE 's' (edgeRole: RECEIVER) + * // │ └── LITERAL '5' (edgeRole: RIGHT_OPERAND) + * // ├── LITERAL '"long string"' (edgeRole: SWITCH_CASE_RESULT, position: 0) + * // │ + * // │ // Case 1: case String s when s.isEmpty() -> "empty" + * // ├── IDENTIFIER_REFERENCE 's' (edgeRole: SWITCH_TYPE_PATTERN, position: 1) + * // ├── METHOD_INVOCATION 'isEmpty' (edgeRole: SWITCH_GUARD, position: 1) + * // │ └── IDENTIFIER_REFERENCE 's' (edgeRole: RECEIVER) + * // ├── LITERAL '"empty"' (edgeRole: SWITCH_CASE_RESULT, position: 1) + * // │ + * // │ // Case 2: case String s -> "short string" (no guard) + * // ├── IDENTIFIER_REFERENCE 's' (edgeRole: SWITCH_TYPE_PATTERN, position: 2) + * // ├── LITERAL '"short string"' (edgeRole: SWITCH_CASE_RESULT, position: 2) + * // │ + * // │ // Case 3: default -> "other" + * // └── LITERAL '"other"' (edgeRole: SWITCH_CASE_RESULT, position: 3) + * // + * // Note: The type 'String' is extracted as a TYPE_REFERENCE linked to the expression. + * // The pattern variable 's' is extracted as SWITCH_TYPE_PATTERN. + * // Position links SWITCH_TYPE_PATTERN, SWITCH_GUARD, and SWITCH_CASE_RESULT for each case. + * + * // ───────────────────────────────────────────────────────────────── + * // SWITCH_CASE_LABEL - Constant/enum expression in case label + * // ───────────────────────────────────────────────────────────────── + * private int val = switch(status) { + * case ACTIVE -> 1; // ACTIVE is SWITCH_CASE_LABEL + * case 1, 2, 3 -> 0; // Each constant is SWITCH_CASE_LABEL + * default -> -1; + * }; + * // [ROOT: SWITCH_EXPRESSION] + * // Tree: + * // SWITCH_EXPRESSION (edgeRole: ROOT) + * // ├── IDENTIFIER_REFERENCE 'status' (edgeRole: SWITCH_SELECTOR) + * // ├── IDENTIFIER_REFERENCE 'ACTIVE' (edgeRole: SWITCH_CASE_LABEL) + * // ├── LITERAL '1' (edgeRole: SWITCH_CASE_LABEL, position: 0) + * // ├── LITERAL '2' (edgeRole: SWITCH_CASE_LABEL, position: 1) + * // ├── LITERAL '3' (edgeRole: SWITCH_CASE_LABEL, position: 2) + * // ├── LITERAL '1' (edgeRole: SWITCH_CASE_RESULT) // for ACTIVE case + * // ├── LITERAL '0' (edgeRole: SWITCH_CASE_RESULT) // for 1,2,3 case + * // └── UNARY_EXPRESSION '-1' (edgeRole: SWITCH_CASE_RESULT) // for default + * + * // ───────────────────────────────────────────────────────────────── + * // ASSIGNMENT_TARGET, ASSIGNMENT_VALUE - Assignment as expression + * // ───────────────────────────────────────────────────────────────── + * // Note: More common in method bodies. In field init, the assigned + * // variable becomes a side effect of initializing the field. + * private int result = (x = 5); // x gets 5, result also gets 5 + * // [ROOT: ASSIGNMENT_EXPRESSION] + * // Tree: + * // ASSIGNMENT_EXPRESSION (edgeRole: ROOT, operator: =) + * // ├── IDENTIFIER_REFERENCE 'x' (edgeRole: ASSIGNMENT_TARGET) + * // └── LITERAL '5' (edgeRole: ASSIGNMENT_VALUE) + * // + * // Note: Field 'result' is the OWNER, not in expression tree. + * // The tree represents only the initializer expression (x = 5). + * + * // ───────────────────────────────────────────────────────────────── + * // ENCLOSING_INSTANCE - Enclosing instance in qualified class creation + * // ───────────────────────────────────────────────────────────────── + * private Inner obj = outer.new Inner(arg); + * // [ROOT: OBJECT_CREATION] + * // Tree: + * // OBJECT_CREATION 'Inner' (edgeRole: ROOT) + * // ├── IDENTIFIER_REFERENCE 'outer' (edgeRole: ENCLOSING_INSTANCE) + * // └── IDENTIFIER_REFERENCE 'arg' (edgeRole: ARGUMENT, position: 0) + * + * // ───────────────────────────────────────────────────────────────── + * // PARENTHESIZED_INNER - Inner expression of parentheses + * // ───────────────────────────────────────────────────────────────── + * private int grouped = (a + b) * c; + * // [ROOT: BINARY_EXPRESSION] + * // Tree: + * // BINARY_EXPRESSION (edgeRole: ROOT, operator: *) + * // ├── PARENTHESIZED (edgeRole: LEFT_OPERAND) + * // │ └── BINARY_EXPRESSION (edgeRole: PARENTHESIZED_INNER, operator: +) + * // │ ├── IDENTIFIER_REFERENCE 'a' (edgeRole: LEFT_OPERAND) + * // │ └── IDENTIFIER_REFERENCE 'b' (edgeRole: RIGHT_OPERAND) + * // └── IDENTIFIER_REFERENCE 'c' (edgeRole: RIGHT_OPERAND) + * + * // ───────────────────────────────────────────────────────────────── + * // TEMPLATE_LITERAL, TEMPLATE_EMBEDDED - String templates (Java 21+) + * // ───────────────────────────────────────────────────────────────── + * private String greeting = STR."Hello, \{name}! You are \{age} years old."; + * // [ROOT: STRING_TEMPLATE] + * // Tree: + * // STRING_TEMPLATE (edgeRole: ROOT, operator: STR) + * // ├── LITERAL "Hello, \{name}! You are \{age} years old." (edgeRole: TEMPLATE_LITERAL) + * // ├── IDENTIFIER_REFERENCE 'name' (edgeRole: TEMPLATE_EMBEDDED, position: 0) + * // └── IDENTIFIER_REFERENCE 'age' (edgeRole: TEMPLATE_EMBEDDED, position: 1) + * + * // Complex expressions in templates + * private String info = STR."Sum: \{a + b}, Upper: \{name.toUpperCase()}"; + * // [ROOT: STRING_TEMPLATE] + * // Tree: + * // STRING_TEMPLATE (edgeRole: ROOT, operator: STR) + * // ├── LITERAL "Sum: \{a + b}, Upper: \{name.toUpperCase()}" (edgeRole: TEMPLATE_LITERAL) + * // ├── BINARY_EXPRESSION '+' (edgeRole: TEMPLATE_EMBEDDED, position: 0) + * // │ ├── IDENTIFIER_REFERENCE 'a' (edgeRole: LEFT_OPERAND) + * // │ └── IDENTIFIER_REFERENCE 'b' (edgeRole: RIGHT_OPERAND) + * // └── METHOD_INVOCATION 'toUpperCase' (edgeRole: TEMPLATE_EMBEDDED, position: 1) + * // └── IDENTIFIER_REFERENCE 'name' (edgeRole: RECEIVER) + * + * // Nested string templates + * private String nested = STR."Outer: \{STR."Inner: \{value}"}"; + * // [ROOT: STRING_TEMPLATE] + * // Tree: + * // STRING_TEMPLATE (edgeRole: ROOT, operator: STR) + * // ├── LITERAL "Outer: \{STR."Inner: \{value}"}" (edgeRole: TEMPLATE_LITERAL) + * // └── STRING_TEMPLATE (edgeRole: TEMPLATE_EMBEDDED, position: 0, operator: STR) + * // ├── LITERAL "Inner: \{value}" (edgeRole: TEMPLATE_LITERAL) + * // └── IDENTIFIER_REFERENCE 'value' (edgeRole: TEMPLATE_EMBEDDED, position: 0) + * // + * // Note: The processor (STR, FMT, RAW) is stored in operatorString field. + * // Position field orders embedded expressions within the template. + * // TEMPLATE_LITERAL captures full template text for reconstruction. + * + * // ───────────────────────────────────────────────────────────────── + * // TYPE_ARGUMENT - Explicit type argument in generic method invocation + * // ───────────────────────────────────────────────────────────────── + * private List empty = Collections.emptyList(); + * // [ROOT: METHOD_INVOCATION 'emptyList'] + * // Tree: + * // METHOD_INVOCATION 'emptyList' (edgeRole: ROOT) + * // ├── IDENTIFIER_REFERENCE 'Collections' (edgeRole: RECEIVER) + * // └── IDENTIFIER_REFERENCE 'String' (edgeRole: TYPE_ARGUMENT, position: 0) + * + * // ───────────────────────────────────────────────────────────────── + * // RECORD_PATTERN_BINDING - Pattern variable in record deconstruction + * // ───────────────────────────────────────────────────────────────── + * // if (obj instanceof Person(String name, int age)) { ... } + * // [ROOT: RECORD_PATTERN] + * // Tree: + * // RECORD_PATTERN (edgeRole: ROOT or INSTANCEOF_OPERAND) + * // ├── IDENTIFIER_REFERENCE 'name' (edgeRole: RECORD_PATTERN_BINDING, position: 0) + * // └── IDENTIFIER_REFERENCE 'age' (edgeRole: RECORD_PATTERN_BINDING, position: 1) + * ``` + * + * ## Key Invariants + * + * - **Root expressions** (`parentExpressionHash == null`): edgeRole = ROOT + * - **Child expressions** (`parentExpressionHash != null`): edgeRole describes relationship + * - **Position field**: Used with ARGUMENT, ARRAY_ELEMENT, ARRAY_DIMENSION, TEMPLATE_EMBEDDED for ordering + */ +export enum EdgeRole { + /** Top-level expression with no parent. */ + ROOT = 'ROOT', + + // === Binary/Ternary Operands === + /** Left operand of binary expression. */ + LEFT_OPERAND = 'LEFT_OPERAND', + + /** Right operand of binary expression. */ + RIGHT_OPERAND = 'RIGHT_OPERAND', + + /** Condition part of ternary expression. */ + TERNARY_CONDITION = 'TERNARY_CONDITION', + + /** True branch of ternary expression. */ + TERNARY_TRUE = 'TERNARY_TRUE', + + /** False branch of ternary expression. */ + TERNARY_FALSE = 'TERNARY_FALSE', + + // === Method/Constructor Calls === + /** Receiver expression (obj in obj.method()). */ + RECEIVER = 'RECEIVER', + + /** Method name identifier in method invocation (method in obj.method()). */ + METHOD_NAME = 'METHOD_NAME', + + /** Argument in method/constructor call. Use position field for index. */ + ARGUMENT = 'ARGUMENT', + + // === Field Access === + /** Qualifier expression in field access (obj in obj.field). */ + QUALIFIER = 'QUALIFIER', + + /** Field name identifier in field access (field in obj.field). */ + FIELD_NAME = 'FIELD_NAME', + + // === Array === + /** Index expression in array access (i in arr[i]). */ + ARRAY_INDEX = 'ARRAY_INDEX', + + /** Element in array initializer. Use position field for index. */ + ARRAY_ELEMENT = 'ARRAY_ELEMENT', + + /** Size expression in array creation (n in new int[n]). */ + ARRAY_DIMENSION = 'ARRAY_DIMENSION', + + // === Unary === + /** Operand of unary expression (-x, !x, ++x). */ + UNARY_OPERAND = 'UNARY_OPERAND', + + // === Type Operations === + /** Expression being cast ((Type) EXPR). */ + CAST_OPERAND = 'CAST_OPERAND', + + /** Expression being tested (EXPR instanceof Type). */ + INSTANCEOF_OPERAND = 'INSTANCEOF_OPERAND', + + /** Pattern variable in instanceof pattern (obj instanceof Type VAR). Java 16+. */ + PATTERN_VARIABLE = 'PATTERN_VARIABLE', + + // === Functional === + /** Parameter declaration in lambda (x -> ..., (a, b) -> ...). */ + LAMBDA_PARAMETER = 'LAMBDA_PARAMETER', + + /** Body expression of lambda. */ + LAMBDA_BODY = 'LAMBDA_BODY', + + // === Switch Expression === + /** Selector expression in switch (switch(EXPR)). */ + SWITCH_SELECTOR = 'SWITCH_SELECTOR', + + /** Result expression in switch case (case X -> RESULT). */ + SWITCH_CASE_RESULT = 'SWITCH_CASE_RESULT', + + /** Guard expression in pattern case (case X when EXPR). Java 21+. */ + SWITCH_GUARD = 'SWITCH_GUARD', + + /** Constant expression in case label (case EXPR). */ + SWITCH_CASE_LABEL = 'SWITCH_CASE_LABEL', + + /** + * Type pattern in switch case (case String s -> ...). Java 17+. + * Contains the pattern variable name. The type is extracted as a type reference. + * Example: case String s when s.length() > 5 -> "long"; + * ^^^^^^^^ type pattern with variable 's' + */ + SWITCH_TYPE_PATTERN = 'SWITCH_TYPE_PATTERN', + + // === Structural === + /** Inner expression of parentheses ((INNER)). */ + PARENTHESIZED_INNER = 'PARENTHESIZED_INNER', + + // === Assignment === + /** Left side of assignment (x in x = y). */ + ASSIGNMENT_TARGET = 'ASSIGNMENT_TARGET', + + /** Right side of assignment (y in x = y). */ + ASSIGNMENT_VALUE = 'ASSIGNMENT_VALUE', + + // === Object Creation === + /** Enclosing instance in qualified class creation (outer in outer.new Inner()). */ + ENCLOSING_INSTANCE = 'ENCLOSING_INSTANCE', + + // === Generic Type Arguments === + /** Type argument in generic method call or parameterized type (String in obj.method()). Use position field for index. */ + TYPE_ARGUMENT = 'TYPE_ARGUMENT', + + // === String Template (Java 21+) === + /** Full template literal text in string template (STR."Hello \{name}!" -> "Hello \{name}!"). */ + TEMPLATE_LITERAL = 'TEMPLATE_LITERAL', + + /** Embedded expression in string template (\{expr} in STR."text \{expr} more"). Use position for index. */ + TEMPLATE_EMBEDDED = 'TEMPLATE_EMBEDDED', + + // === Record Pattern (Java 21+) === + /** Pattern variable binding in record pattern (n in Person(String n, int a)). Use position for index. */ + RECORD_PATTERN_BINDING = 'RECORD_PATTERN_BINDING', +} diff --git a/parser/src/enums/java/expressions/ExpressionKind.ts b/parser/src/enums/java/expressions/ExpressionKind.ts new file mode 100644 index 000000000..836756f6b --- /dev/null +++ b/parser/src/enums/java/expressions/ExpressionKind.ts @@ -0,0 +1,268 @@ +/** + * Classifies the type of expression in Java source code. + * + * This enumeration distinguishes between different expression forms, + * enabling precise parsing and representation of Java expressions + * within the dependency analysis framework. + * + * ## Expression Categories + * + * - **Literals** – Constant values (`42`, `"hello"`, `true`, `String.class`) + * - **References** – Variable/field/type access (`foo`, `this`, `obj.field`) + * - **Creation** – Object/array instantiation (`new Foo()`, `new int[10]`) + * - **Invocation** – Method/constructor calls (`obj.method()`, `this()`) + * - **Operators** – Binary, unary, ternary operations (`a + b`, `-x`, `a ? b : c`) + * - **Type Operations** – Cast, instanceof (`(Type) x`, `x instanceof T`) + * - **Functional** – Lambda, method reference (`x -> x * 2`, `String::length`) + * + * ## Classification Examples + * + * ```java + * // LITERAL - Constant values + * private int count = 42; // LITERAL (INTEGER) + * private String name = "John"; // LITERAL (STRING) + * private boolean active = true; // LITERAL (BOOLEAN) + * private Object nothing = null; // LITERAL (NULL) + * + * // CLASS_LITERAL - Class object references + * private Class type = String.class; // CLASS_LITERAL + * private Class intType = int.class; // CLASS_LITERAL + * + * // IDENTIFIER_REFERENCE - Simple name (no dots) + * private int b = a; // IDENTIFIER_REFERENCE (a) + * private int timeout = DEFAULT_TIMEOUT; // IDENTIFIER_REFERENCE + * + * // FIELD_ACCESS - Dotted path (Type.field or obj.field) + * private double pi = Math.PI; // FIELD_ACCESS (Math=TYPE, PI=FIELD) + * private int max = Integer.MAX_VALUE; // FIELD_ACCESS (Integer=TYPE, MAX_VALUE=FIELD) + * private Status s = Status.ACTIVE; // FIELD_ACCESS (Status=TYPE, ACTIVE=FIELD) + * private String name = user.name; // FIELD_ACCESS (user=FIELD, name=FIELD) + * + * // THIS_REFERENCE / SUPER_REFERENCE + * private Service self = this; // THIS_REFERENCE + * private Parent p = super; // SUPER_REFERENCE + * + * // Qualified this/super (OuterClass.this) - represented as FIELD_ACCESS tree: + * // FIELD_ACCESS (memberName="this", memberKind=THIS) + * // └─ IDENTIFIER_REFERENCE (edgeRole=QUALIFIER, "OuterClass", isTypeReference=true) + * private Inner i = OuterClass.this.new Inner(); // FIELD_ACCESS + IDENTIFIER_REFERENCE + * + * // OBJECT_CREATION - new ClassName() + * private List list = new ArrayList<>(); // OBJECT_CREATION + * private User user = new User("John", 30); // OBJECT_CREATION + * + * // ANONYMOUS_CLASS_CREATION - new Type() { ... } + * private Runnable r = new Runnable() { // ANONYMOUS_CLASS_CREATION + * public void run() { } + * }; + * + * // ARRAY_CREATION - new Type[size] or new Type[]{...} + * private int[] arr = new int[10]; // ARRAY_CREATION + * private int[] init = new int[]{1, 2, 3}; // ARRAY_CREATION (hasArrayInitializer=true) + * + * // ARRAY_INITIALIZER - Standalone {1, 2, 3} + * private int[] values = {1, 2, 3}; // ARRAY_INITIALIZER + * + * // METHOD_INVOCATION - obj.method() or method() + * private String result = getData(); // METHOD_INVOCATION + * private int abs = Math.abs(-5); // METHOD_INVOCATION + * private User user = userService.getCurrentUser(); // METHOD_INVOCATION + * + * // CONSTRUCTOR_INVOCATION - this() or super() in constructor body + * public MyClass() { this(0); } // CONSTRUCTOR_INVOCATION (this()) + * public MyClass(int x) { super(x); } // CONSTRUCTOR_INVOCATION (super()) + * public Child() { super(compute()); } // CONSTRUCTOR_INVOCATION with arg expression + * + * // LAMBDA_EXPRESSION - x -> expr or (x, y) -> { ... } + * private Function f = x -> x * 2; // LAMBDA_EXPRESSION + * private Consumer c = s -> { System.out.println(s); }; + * + * // METHOD_REFERENCE - Type::method or obj::method + * private Function len = String::length; // METHOD_REFERENCE + * private Supplier> s = ArrayList::new; // METHOD_REFERENCE (CONSTRUCTOR) + * + * // BINARY_EXPRESSION - a op b + * private int sum = a + b; // BINARY_EXPRESSION (operator: +) + * private boolean valid = x > 0 && y < 10; // BINARY_EXPRESSION (operator: &&) + * + * // UNARY_EXPRESSION - op a or a op + * private int neg = -value; // UNARY_EXPRESSION (operator: -) + * private boolean not = !flag; // UNARY_EXPRESSION (operator: !) + * + * // TERNARY_EXPRESSION - cond ? a : b + * private int val = isDefault ? 5 : custom; // TERNARY_EXPRESSION + * + * // ASSIGNMENT_EXPRESSION - x = y (when used as expression) + * private int chain = x = y = 5; // ASSIGNMENT_EXPRESSION + * + * // COMPOUND_ASSIGNMENT - x op= y + * // (rare in field initializers, common in method bodies) + * + * // CAST_EXPRESSION - (Type) expr + * private long big = (long) smallInt; // CAST_EXPRESSION + * + * // INSTANCEOF_EXPRESSION - expr instanceof Type + * private boolean isString = obj instanceof String; // INSTANCEOF_EXPRESSION + * + * // INSTANCEOF_PATTERN - expr instanceof Type var (Java 16+) + * // Used in method body context with pattern matching + * + * // ARRAY_ACCESS - arr[index] + * private int first = array[0]; // ARRAY_ACCESS + * + * // SWITCH_EXPRESSION - switch(x) { case 1 -> val; } (Java 14+) + * private String day = switch(num) { + * case 1 -> "Mon"; + * default -> "Other"; + * }; + * + * // BREAK_STATEMENT - break; or break label; in switch case or loop + * switch (status) { + * case ACTIVE: + * handle(); + * break; // BREAK_STATEMENT (no label) + * break outer; // BREAK_STATEMENT (literalValue = "outer") + * } + * + * // CONTINUE_STATEMENT - continue; or continue label; in loop + * for (String s : items) { + * if (s == null) continue; // CONTINUE_STATEMENT (no label) + * if (skip) continue outer; // CONTINUE_STATEMENT (literalValue = "outer") + * } + * + * // PARENTHESIZED - (expr) - preserves source fidelity + * private int grouped = (a + b) * c; // PARENTHESIZED wrapping BINARY_EXPRESSION + * + * // RECORD_PATTERN - Record deconstruction pattern (Java 21+) + * // Used in instanceof and switch to destructure record components + * // if (obj instanceof Person(String name, int age)) { ... } + * // case Point(int x, int y) -> x + y; + * + * // STRING_TEMPLATE - Template expression (Java 21+ preview) + * private String greeting = STR."Hello, \{name}! You are \{age}."; // STRING_TEMPLATE + * private String query = STR.""" + * SELECT * FROM users + * WHERE id = \{userId} + * """; // STRING_TEMPLATE (text block form) + * ``` + * + * ## Implementation Guidelines + * + * - **Tree Structure:** Complex expressions create parent-child relationships + * - **Kind Selection:** Use most specific kind (e.g., CLASS_LITERAL over LITERAL for String.class) + * - **Operator Storage:** BINARY/UNARY/COMPOUND store operator in separate field + * - **Method Name:** METHOD_INVOCATION stores methodName in separate field + */ +export enum ExpressionKind { + // === Literals === + /** Primitive, string, or null literal (42, "hello", true, null). Use literalType for subtype. */ + LITERAL = 'LITERAL', + + /** Class literal reference (String.class, int.class). Links to TypeRegistry. */ + CLASS_LITERAL = 'CLASS_LITERAL', + + // === References === + /** Simple name without dots (foo, bar, count). Resolved at extraction to field/type. */ + IDENTIFIER_REFERENCE = 'IDENTIFIER_REFERENCE', + + /** Field/member access (obj.field, this.field, Math.PI, Type.CONST). Qualifier extracted as child. */ + FIELD_ACCESS = 'FIELD_ACCESS', + + /** The 'this' keyword alone. */ + THIS_REFERENCE = 'THIS_REFERENCE', + + /** The 'super' keyword alone. */ + SUPER_REFERENCE = 'SUPER_REFERENCE', + + // NOTE: Qualified this/super (OuterClass.this, OuterClass.super) are NOT separate kinds. + // They are represented as FIELD_ACCESS with: + // - memberName = "this" or "super" + // - memberKind = THIS or SUPER + // - Child IDENTIFIER_REFERENCE with edgeRole=QUALIFIER containing the outer class name + // + // Example: OuterClass.this.new Inner() + // FIELD_ACCESS (edgeRole=ENCLOSING_INSTANCE, memberName="this", memberKind=THIS) + // └─ IDENTIFIER_REFERENCE (edgeRole=QUALIFIER, memberName="OuterClass", isTypeReference=true) + + // === Object/Array Creation === + /** Object instantiation (new ClassName(), new ClassName(args)). */ + OBJECT_CREATION = 'OBJECT_CREATION', + + /** Anonymous class creation (new Interface() { ... }). */ + ANONYMOUS_CLASS_CREATION = 'ANONYMOUS_CLASS_CREATION', + + /** Array creation (new int[10], new String[]{...}). */ + ARRAY_CREATION = 'ARRAY_CREATION', + + /** Standalone array initializer ({1, 2, 3}). */ + ARRAY_INITIALIZER = 'ARRAY_INITIALIZER', + + // === Invocations === + /** Method invocation (obj.method(), method(), Type.staticMethod()). */ + METHOD_INVOCATION = 'METHOD_INVOCATION', + + /** Explicit constructor invocation in constructor body (this(), super()). */ + CONSTRUCTOR_INVOCATION = 'CONSTRUCTOR_INVOCATION', + + // === Functional === + /** Lambda expression (x -> x * 2, (a, b) -> a + b). */ + LAMBDA_EXPRESSION = 'LAMBDA_EXPRESSION', + + /** Method reference (String::length, ArrayList::new, obj::method). */ + METHOD_REFERENCE = 'METHOD_REFERENCE', + + // === Operators === + /** Binary operation (a + b, a && b, a == b). */ + BINARY_EXPRESSION = 'BINARY_EXPRESSION', + + /** Unary operation (-a, !a, ++a, a++). */ + UNARY_EXPRESSION = 'UNARY_EXPRESSION', + + /** Ternary conditional (cond ? trueExpr : falseExpr). */ + TERNARY_EXPRESSION = 'TERNARY_EXPRESSION', + + /** Assignment as expression (x = y when used in larger expression). */ + ASSIGNMENT_EXPRESSION = 'ASSIGNMENT_EXPRESSION', + + /** Compound assignment (x += 5, x *= 2). */ + COMPOUND_ASSIGNMENT = 'COMPOUND_ASSIGNMENT', + + // === Type Operations === + /** Cast expression ((Type) expr). */ + CAST_EXPRESSION = 'CAST_EXPRESSION', + + /** instanceof check (obj instanceof Type). */ + INSTANCEOF_EXPRESSION = 'INSTANCEOF_EXPRESSION', + + /** instanceof with pattern variable (obj instanceof String s). Java 16+. */ + INSTANCEOF_PATTERN = 'INSTANCEOF_PATTERN', + + // === Access === + /** Array element access (array[index]). */ + ARRAY_ACCESS = 'ARRAY_ACCESS', + + // === Control Flow as Expression === + /** Switch expression (switch(x) { case 1 -> "one"; }). Java 14+. */ + SWITCH_EXPRESSION = 'SWITCH_EXPRESSION', + + /** Break statement in a switch case or loop (break; or break label;). */ + BREAK_STATEMENT = 'BREAK_STATEMENT', + + /** Continue statement in a loop (continue; or continue label;). */ + CONTINUE_STATEMENT = 'CONTINUE_STATEMENT', + + // === Structural === + /** Parenthesized expression ((expr)). Preserves source fidelity. */ + PARENTHESIZED = 'PARENTHESIZED', + + // === Advanced Patterns (Java 21+) === + /** Record pattern (Java 21+). */ + RECORD_PATTERN = 'RECORD_PATTERN', + + /** String template (Java 21+ preview). */ + STRING_TEMPLATE = 'STRING_TEMPLATE', + + // === Fallback === + /** Unknown or unrecognized expression type. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/java/expressions/ExpressionOwnerKind.ts b/parser/src/enums/java/expressions/ExpressionOwnerKind.ts new file mode 100644 index 000000000..fb64303ed --- /dev/null +++ b/parser/src/enums/java/expressions/ExpressionOwnerKind.ts @@ -0,0 +1,215 @@ +/** + * Identifies the type of syntactic construct that owns an expression tree. + * + * This enumeration describes the actual owning entity of an expression, + * not the relationship between expressions (that's EdgeRole). + * + * ## Owner Categories + * + * - **Declarations** – Field, local variable + * - **Statements** – Return, throw, assert, expression statement + * - **Control Flow** – If, while, for, switch, etc. + * - **Blocks** – Static/instance initializer blocks + * - **Other** – Annotation argument, enum constant argument + * + * ## Classification Examples + * + * ```java + * public class Example { + * // FIELD - expression owned by field declaration + * private int count = 42; // ownerKind: FIELD + * private String name = getName(); // ownerKind: FIELD + * + * // STATIC_INIT_BLOCK - expression in static block + * static { + * DEFAULT = compute(); // ownerKind: STATIC_INIT_BLOCK + * } + * + * // INSTANCE_INIT_BLOCK - expression in instance block + * { + * instanceId = generate(); // ownerKind: INSTANCE_INIT_BLOCK + * } + * + * public void method() { + * // LOCAL_VARIABLE - expression owned by local var declaration + * int x = a + b; // ownerKind: LOCAL_VARIABLE + * + * // RETURN_STATEMENT - expression in return + * return x * 2; // ownerKind: RETURN_STATEMENT + * + * // THROW_STATEMENT - expression in throw + * throw new RuntimeException(); // ownerKind: THROW_STATEMENT + * + * // BREAK_STATEMENT - break in switch case or loop + * switch (x) { + * case 1: + * doSomething(); + * break; // ownerKind: BREAK_STATEMENT + * } + * for (int i = 0; i < 10; i++) { + * if (i == 5) break outer; // ownerKind: BREAK_STATEMENT + * } + * + * // CONTINUE_STATEMENT - continue in a loop + * for (String s : items) { + * if (s == null) continue; // ownerKind: CONTINUE_STATEMENT + * if (skip) continue outer; // ownerKind: CONTINUE_STATEMENT + * } + * + * // ASSERT_STATEMENT - expression in assert + * assert x > 0 : "must be positive"; // ownerKind: ASSERT_STATEMENT + * + * // EXPRESSION_STATEMENT - standalone expression + * System.out.println("hello"); // ownerKind: EXPRESSION_STATEMENT + * + * // IF_STATEMENT - condition expression + * if (isValid) { } // ownerKind: IF_STATEMENT + * + * // WHILE_STATEMENT - condition expression + * while (running) { } // ownerKind: WHILE_STATEMENT + * + * // DO_WHILE_STATEMENT - condition expression + * do { + * process(); + * } while (hasMore()); // ownerKind: DO_WHILE_STATEMENT + * + * // FOR_STATEMENT - init/condition/update expressions + * for (int i = 0; i < 10; i++) { } // ownerKind: FOR_STATEMENT + * + * // ENHANCED_FOR_STATEMENT - iterable expression + * for (String s : items) { } // ownerKind: ENHANCED_FOR_STATEMENT + * + * // SWITCH_STATEMENT - selector expression + * switch (status) { } // ownerKind: SWITCH_STATEMENT + * + * // SWITCH_EXPRESSION - switch as expression + * String s = switch(x) { ... }; // ownerKind: SWITCH_EXPRESSION + * + * // SYNCHRONIZED_STATEMENT - lock expression + * synchronized (lock) { } // ownerKind: SYNCHRONIZED_STATEMENT + * + * // TRY_STATEMENT - resource expression in try-with-resources + * try (var r = getResource()) { // getResource(): ownerKind: TRY_STATEMENT + * // TRY_BLOCK - expression in try block body + * process(r); // ownerKind: TRY_BLOCK + * } catch (Exception e) { + * // CATCH_BLOCK - expression in catch block body + * log(e); // ownerKind: CATCH_BLOCK + * } finally { + * // FINALLY_BLOCK - expression in finally block body + * cleanup(); // ownerKind: FINALLY_BLOCK + * } + * + * // Regular try (no resources) - only has TRY_BLOCK, CATCH_BLOCK, FINALLY_BLOCK + * try { + * riskyOperation(); // ownerKind: TRY_BLOCK + * } catch (Exception e) { + * handleError(e); // ownerKind: CATCH_BLOCK + * } + * } + * } + * + * // ANNOTATION_ARGUMENT - expression in annotation + * @MyAnnotation(value = 42) // ownerKind: ANNOTATION_ARGUMENT + * class Annotated { } + * + * // ENUM_CONSTANT_ARGUMENT - expression in enum constant + * enum Status { + * ACTIVE(1, "Active"); // ownerKind: ENUM_CONSTANT_ARGUMENT + * } + * + * // RECORD_COMPONENT - expression in record component (Java 16+) + * record Config( + * int timeout, // No initializer, no expression + * String name + * ) { } + * // Note: Record components with default values or compact constructor + * // assignments may produce expressions with ownerKind: RECORD_COMPONENT + * ``` + * + * ## Key Invariants + * + * - All expressions in a tree share the same ownerKind and ownerHash + * - ownerHash links to the actual entity (FieldRegistry hash, etc.) + */ +export enum ExpressionOwnerKind { + // === Declarations === + /** Expression owned by field declaration. */ + FIELD = 'FIELD', + + /** Expression owned by local variable declaration. */ + LOCAL_VARIABLE = 'LOCAL_VARIABLE', + + // === Statements === + /** Expression in return statement. */ + RETURN_STATEMENT = 'RETURN_STATEMENT', + + /** Expression in throw statement. */ + THROW_STATEMENT = 'THROW_STATEMENT', + + /** Break statement in a switch case or loop. */ + BREAK_STATEMENT = 'BREAK_STATEMENT', + + /** Continue statement in a loop. */ + CONTINUE_STATEMENT = 'CONTINUE_STATEMENT', + + /** Expression in assert statement. */ + ASSERT_STATEMENT = 'ASSERT_STATEMENT', + + /** Standalone expression as statement (e.g., method call). */ + EXPRESSION_STATEMENT = 'EXPRESSION_STATEMENT', + + // === Control Flow === + /** Expression in if statement condition. */ + IF_STATEMENT = 'IF_STATEMENT', + + /** Expression in while loop. */ + WHILE_STATEMENT = 'WHILE_STATEMENT', + + /** Expression in do-while loop. */ + DO_WHILE_STATEMENT = 'DO_WHILE_STATEMENT', + + /** Expression in for loop (init, condition, or update). */ + FOR_STATEMENT = 'FOR_STATEMENT', + + /** Expression in enhanced for loop (iterable). */ + ENHANCED_FOR_STATEMENT = 'ENHANCED_FOR_STATEMENT', + + /** Expression in switch statement selector. */ + SWITCH_STATEMENT = 'SWITCH_STATEMENT', + + /** Expression in switch expression (Java 14+). */ + SWITCH_EXPRESSION = 'SWITCH_EXPRESSION', + + /** Expression in synchronized statement lock. */ + SYNCHRONIZED_STATEMENT = 'SYNCHRONIZED_STATEMENT', + + /** Expression in try-with-resources. */ + TRY_STATEMENT = 'TRY_STATEMENT', + + /** Expression in try block body. */ + TRY_BLOCK = 'TRY_BLOCK', + + /** Expression in catch block body. */ + CATCH_BLOCK = 'CATCH_BLOCK', + + /** Expression in finally block body. */ + FINALLY_BLOCK = 'FINALLY_BLOCK', + + // === Blocks === + /** Expression in static initializer block. */ + STATIC_INIT_BLOCK = 'STATIC_INIT_BLOCK', + + /** Expression in instance initializer block. */ + INSTANCE_INIT_BLOCK = 'INSTANCE_INIT_BLOCK', + + // === Other === + /** Expression in annotation argument. */ + ANNOTATION_ARGUMENT = 'ANNOTATION_ARGUMENT', + + /** Expression in enum constant argument. */ + ENUM_CONSTANT_ARGUMENT = 'ENUM_CONSTANT_ARGUMENT', + + /** Expression in record component (Java 16+). */ + RECORD_COMPONENT = 'RECORD_COMPONENT', +} diff --git a/parser/src/enums/java/expressions/LiteralType.ts b/parser/src/enums/java/expressions/LiteralType.ts new file mode 100644 index 000000000..3c7f8527b --- /dev/null +++ b/parser/src/enums/java/expressions/LiteralType.ts @@ -0,0 +1,100 @@ +/** + * Classifies the type of literal value in a LITERAL expression. + * + * This enumeration provides fine-grained classification of literal values, + * enabling precise representation and analysis of constant expressions. + * + * ## Literal Categories + * + * - **Numeric** – Integer, long, float, double + * - **Boolean** – true, false + * - **Character** – Single character ('a', '\n') + * - **String** – Regular strings and text blocks + * - **Null** – null literal + * + * ## Classification Examples + * + * ```java + * public class LiteralExamples { + * // INTEGER - int literals (decimal, hex, octal, binary) + * private int decimal = 42; // literalType: INTEGER + * private int hex = 0x2A; // literalType: INTEGER + * private int octal = 052; // literalType: INTEGER + * private int binary = 0b101010; // literalType: INTEGER + * private int underscore = 1_000_000; // literalType: INTEGER + * + * // LONG - long literals (suffix L or l) + * private long bigNum = 42L; // literalType: LONG + * private long timestamp = 1234567890L; // literalType: LONG + * + * // FLOAT - float literals (suffix f or F) + * private float pi = 3.14f; // literalType: FLOAT + * private float scientific = 1.5e-4f; // literalType: FLOAT + * + * // DOUBLE - double literals (default for decimals, suffix d or D) + * private double precise = 3.14159265; // literalType: DOUBLE + * private double explicit = 3.14d; // literalType: DOUBLE + * private double sci = 1.5e10; // literalType: DOUBLE + * + * // BOOLEAN - true/false + * private boolean active = true; // literalType: BOOLEAN + * private boolean disabled = false; // literalType: BOOLEAN + * + * // CHARACTER - single character in single quotes + * private char letter = 'a'; // literalType: CHARACTER + * private char newline = '\n'; // literalType: CHARACTER + * private char unicode = '\u0041'; // literalType: CHARACTER + * private char tab = '\t'; // literalType: CHARACTER + * + * // STRING - double-quoted strings + * private String name = "John"; // literalType: STRING + * private String empty = ""; // literalType: STRING + * private String escaped = "Hello\nWorld"; // literalType: STRING + * + * // TEXT_BLOCK - triple-quoted strings (Java 15+) + * private String json = """ + * { + * "name": "John" + * } + * """; // literalType: TEXT_BLOCK + * + * // NULL - null literal + * private Object nothing = null; // literalType: NULL + * private String unset = null; // literalType: NULL + * } + * ``` + * + * ## Notes + * + * - **Class literals** (String.class) use ExpressionKind.CLASS_LITERAL, not LITERAL + * - **resolvedValue** field stores the actual parsed value as string + * - **rawText** preserves original format (0x2A vs 42) + */ +export enum LiteralType { + /** Integer literal (42, 0x2A, 0b101010, 1_000). */ + INTEGER = 'INTEGER', + + /** Long literal with L suffix (42L, 1234567890L). */ + LONG = 'LONG', + + /** Float literal with f/F suffix (3.14f, 1.5e-4F). */ + FLOAT = 'FLOAT', + + /** Double literal (3.14, 3.14d, 1.5e10). Default for decimal literals. */ + DOUBLE = 'DOUBLE', + + /** Boolean literal (true, false). */ + BOOLEAN = 'BOOLEAN', + + /** Character literal in single quotes ('a', '\n', '\u0041'). */ + CHARACTER = 'CHARACTER', + + /** String literal in double quotes ("hello", "line\nbreak"). */ + STRING = 'STRING', + + /** Text block literal with triple quotes (Java 15+). */ + TEXT_BLOCK = 'TEXT_BLOCK', + + /** Null literal (null). */ + NULL = 'NULL', +} diff --git a/parser/src/enums/java/expressions/MethodReferenceKind.ts b/parser/src/enums/java/expressions/MethodReferenceKind.ts new file mode 100644 index 000000000..220b3d73b --- /dev/null +++ b/parser/src/enums/java/expressions/MethodReferenceKind.ts @@ -0,0 +1,99 @@ +/** + * Classifies the type of method reference expression. + * + * This enumeration distinguishes between different forms of method references + * (Type::method syntax), enabling precise analysis of functional interface usage. + * + * ## Method Reference Categories + * + * - **QUALIFIED_METHOD** – Reference to a method via qualifier (Type::method, obj::method) + * - **CONSTRUCTOR** – Reference to constructor (ArrayList::new) + * - **ARRAY_CONSTRUCTOR** – Reference to array constructor (int[]::new) + * - **SUPER** – Reference to superclass method (super::method, Child.super::method) + * + * ## Why QUALIFIED_METHOD combines STATIC, BOUND, and UNBOUND + * + * Without full type resolution (which tree-sitter doesn't provide), we cannot + * reliably distinguish between: + * - **STATIC vs UNBOUND**: Both use `Type::method` syntax. Distinguishing requires + * knowing if the method is static or instance. + * - **BOUND vs UNBOUND**: `prefix::length` vs `String::length`. Distinguishing + * requires knowing if the qualifier is a variable or a type name. + * + * Downstream consumers with type resolution can further classify if needed. + * + * ## Classification Examples + * + * ```java + * public class MethodReferenceExamples { + * private String prefix = "Hello"; + * + * // QUALIFIED_METHOD - Reference to method via qualifier + * private Function abs = Math::abs; + * // ^^^^^^^^ + * // methodReferenceKind: QUALIFIED_METHOD + * // (could be static or unbound - we can't tell) + * + * private Supplier len = prefix::length; + * // ^^^^^^^^^^^^^^ + * // methodReferenceKind: QUALIFIED_METHOD + * // (could be bound instance method) + * + * private Function strLen = String::length; + * // ^^^^^^^^^^^^^^ + * // methodReferenceKind: QUALIFIED_METHOD + * // (could be unbound instance method) + * + * // CONSTRUCTOR - Reference to constructor + * private Supplier> listFactory = ArrayList::new; + * // ^^^^^^^^^^^^^ + * // methodReferenceKind: CONSTRUCTOR + * + * private Function userFactory = User::new; + * // ^^^^^^^^ + * // methodReferenceKind: CONSTRUCTOR + * + * // ARRAY_CONSTRUCTOR - Reference to array constructor + * private IntFunction intArrayFactory = int[]::new; + * // ^^^^^^^^^^ + * // methodReferenceKind: ARRAY_CONSTRUCTOR + * + * private IntFunction strArrayFactory = String[]::new; + * // ^^^^^^^^^^^^^ + * // methodReferenceKind: ARRAY_CONSTRUCTOR + * + * // SUPER - Reference to superclass method + * class Child extends Parent { + * private Runnable r = super::parentMethod; + * // ^^^^^^^^^^^^^^^^^^ + * // methodReferenceKind: SUPER + * + * class Inner { + * // Qualified super from inner class + * private Runnable r2 = Child.super::parentMethod; + * // ^^^^^^^^^^^^^^^^^^^^^^^^^ + * // methodReferenceKind: SUPER + * } + * } + * } + * ``` + * + * ## Linking + * + * - QUALIFIED_METHOD, SUPER → link to MethodRegistry via invokedMethodHash + * - CONSTRUCTOR → link to MethodRegistry (constructor) via invokedConstructorHash + * - CONSTRUCTOR, ARRAY_CONSTRUCTOR → link to TypeRegistry via createdTypeHash + */ +export enum MethodReferenceKind { + /** Reference to method via qualifier (Math::abs, str::length, String::length). */ + QUALIFIED_METHOD = 'QUALIFIED_METHOD', + + /** Reference to constructor (ArrayList::new, User::new). */ + CONSTRUCTOR = 'CONSTRUCTOR', + + /** Reference to array constructor (int[]::new, String[]::new). */ + ARRAY_CONSTRUCTOR = 'ARRAY_CONSTRUCTOR', + + /** Reference to superclass method (super::method, Child.super::method). */ + SUPER = 'SUPER', +} diff --git a/parser/src/enums/java/expressions/ReferencedEntityKind.ts b/parser/src/enums/java/expressions/ReferencedEntityKind.ts new file mode 100644 index 000000000..789299142 --- /dev/null +++ b/parser/src/enums/java/expressions/ReferencedEntityKind.ts @@ -0,0 +1,152 @@ +/** + * Identifies the type of entity being referenced by an expression. + * + * This enumeration classifies what kind of entity an expression refers to, + * enabling proper linking to the appropriate registry (TypeRegistry, + * FieldRegistry, MethodRegistry, etc.). + * + * ## Entity Categories + * + * - **TYPE** – Class, interface, enum type reference + * - **FIELD** – Instance or static field reference + * - **METHOD** – Method being invoked + * - **CONSTRUCTOR** – Constructor being called (new expressions) + * - **ENUM_CONSTANT** – Enum constant value + * - **LOCAL_VARIABLE** – Local variable reference + * - **PARAMETER** – Method parameter reference + * - **THIS** – Qualified this (OuterClass.this) or this() constructor invocation + * - **SUPER** – Qualified super or super() constructor invocation + * - **PATTERN_BINDING** – Pattern variable declaration in instanceof/switch patterns (Java 16+) + * - **PATTERN_BINDING_VARIABLE** – Reference to a pattern binding variable (usage site) + * - **LAMBDA_PARAMETER** – Lambda parameter declaration (x -> ..., (a, b) -> ...) + * - **UNKNOWN** – Cannot determine at extraction time + * + * ## Classification Examples + * + * ```java + * public class Example { + * private static final int DEFAULT = 100; + * private int count; + * + * // TYPE - Reference to a type + * private Class type = String.class; // referencedEntityKind: TYPE (for String) + * private int max = Integer.MAX_VALUE; // Integer is TYPE + * private List list = Collections.emptyList(); // Collections is TYPE + * + * // FIELD - Reference to a field + * private int timeout = DEFAULT; // referencedEntityKind: FIELD + * private int copy = count; // referencedEntityKind: FIELD + * private double pi = Math.PI; // referencedEntityKind: FIELD (static field) + * + * // METHOD - Method being invoked + * private String data = getData(); // referencedEntityKind: METHOD + * private int abs = Math.abs(-5); // referencedEntityKind: METHOD + * + * // CONSTRUCTOR - Constructor being called + * private User user = new User("John"); // referencedEntityKind: CONSTRUCTOR + * private List items = new ArrayList<>(); // referencedEntityKind: CONSTRUCTOR + * + * // ENUM_CONSTANT - Enum constant reference + * private Status status = Status.ACTIVE; // referencedEntityKind: ENUM_CONSTANT + * private Priority p = Priority.HIGH; // referencedEntityKind: ENUM_CONSTANT + * + * // LOCAL_VARIABLE - Local variable reference (method body context) + * // void method() { + * // int x = 5; + * // int y = x + 1; // x is LOCAL_VARIABLE + * // } + * + * // PARAMETER - Method parameter reference (method body context) + * // int add(List list, int b) { + * // return list.stream().sum() + b; // list is PARAMETER, b is PARAMETER + * // } + * // void process(String input) { + * // System.out.println(input); // input is PARAMETER + * // } + * + * // THIS - Qualified this or this() constructor invocation + * // class Outer { + * // class Inner { + * // Object o = Outer.this; // referencedEntityKind: THIS (qualified this) + * // Inner() { this(0); } // referencedEntityKind: THIS (constructor invocation) + * // } + * // } + * + * // SUPER - Qualified super or super() constructor invocation + * // class Child extends Parent { + * // Child() { super(); } // referencedEntityKind: SUPER + * // Child(int x) { super(x, null); } // referencedEntityKind: SUPER (with args) + * // } + * + * // PATTERN_BINDING - Pattern variable declaration in instanceof/switch patterns (Java 16+) + * // obj instanceof Person(String name, int age) // name, age declarations are PATTERN_BINDING + * // case Point(int x, int y) -> ... // x, y declarations are PATTERN_BINDING + * + * // PATTERN_BINDING_VARIABLE - Usage of a pattern binding variable (Java 16+) + * // (obj instanceof String s) ? s.toUpperCase() : "default" // s in s.toUpperCase() is PATTERN_BINDING_VARIABLE + * // obj instanceof List list ? list : null // list after ? is PATTERN_BINDING_VARIABLE + * + * // LAMBDA_PARAMETER - Lambda parameter declaration (Java 8+) + * // Function f = s -> s.length(); // s is LAMBDA_PARAMETER + * // BiFunction add = (a, b) -> a + b; // a, b are LAMBDA_PARAMETER + * // Consumer c = (String str) -> System.out.println(str); // str is LAMBDA_PARAMETER + * // Note: The parameter declaration itself is LAMBDA_PARAMETER. + * // Usage of the parameter inside the lambda body requires scope analysis (Datalog). + * + * // UNKNOWN - Truly cannot determine (rare for field initializers) + * // This should be rare since field initializers have well-defined scope + * } + * ``` + * + * ## Resolution Strategy + * + * For **field initializers**, resolution happens at extraction time: + * 1. Check if identifier exists as field in current class → FIELD + * 2. Check if it's a static import → FIELD or METHOD + * 3. Check if it's a type name (capitalized, in imports) → TYPE + * 4. Otherwise → UNKNOWN (should be rare) + * + * For **method bodies**, resolution happens at extraction time: + * 1. Check if identifier matches a method parameter name → PARAMETER + * 2. Otherwise → falls back to naming convention (LOCAL_VARIABLE requires scope analysis) + */ +export enum ReferencedEntityKind { + /** Reference to a type (class, interface, enum). */ + TYPE = 'TYPE', + + /** Reference to a field (instance or static). */ + FIELD = 'FIELD', + + /** Reference to a method being called. */ + METHOD = 'METHOD', + + /** Reference to a constructor being called. */ + CONSTRUCTOR = 'CONSTRUCTOR', + + /** Reference to an enum constant. */ + ENUM_CONSTANT = 'ENUM_CONSTANT', + + /** Reference to a local variable (method body context). */ + LOCAL_VARIABLE = 'LOCAL_VARIABLE', + + /** Reference to a method parameter (method body context). */ + PARAMETER = 'PARAMETER', + + /** Reference to 'this' in a qualified this expression (e.g., OuterClass.this) or this() constructor invocation. */ + THIS = 'THIS', + + /** Reference to 'super' in a qualified super expression or super() constructor invocation. */ + SUPER = 'SUPER', + + /** Pattern variable binding in instanceof/switch patterns (Java 16+) - the declaration site. */ + PATTERN_BINDING = 'PATTERN_BINDING', + + /** Reference to a pattern binding variable (usage site, e.g., `s` in `s.toUpperCase()` after `instanceof String s`). */ + PATTERN_BINDING_VARIABLE = 'PATTERN_BINDING_VARIABLE', + + /** Lambda parameter declaration (x -> ..., (a, b) -> ...). */ + LAMBDA_PARAMETER = 'LAMBDA_PARAMETER', + + /** Cannot determine the entity type at extraction time. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/java/expressions/RootContext.ts b/parser/src/enums/java/expressions/RootContext.ts new file mode 100644 index 000000000..a9f6a9c8f --- /dev/null +++ b/parser/src/enums/java/expressions/RootContext.ts @@ -0,0 +1,257 @@ +/** + * Identifies where an expression tree is rooted in the source code. + * + * This enumeration describes the attachment point for the entire expression tree, + * not the relationship between parent-child expressions (that's EdgeRole). + * + * ## Context Categories + * + * - **Declarations** – Field initializers, local variables + * - **Statements** – Return, throw, assert + * - **Control Flow** – If, while, for, switch conditions + * - **Exception Handling** – Try, catch, finally blocks + * - **Blocks** – Static/instance initializers + * + * ## Classification Examples + * + * ```java + * public class Example { + * // FIELD_INITIALIZER - expression initializes a field + * private int count = 42; // rootContext: FIELD_INITIALIZER + * private String name = getName(); // rootContext: FIELD_INITIALIZER + * private List items = new ArrayList<>(); // rootContext: FIELD_INITIALIZER + * + * // STATIC_INITIALIZER - expression in static block + * static { + * DEFAULT_VALUE = computeDefault(); // rootContext: STATIC_INITIALIZER + * } + * + * // INSTANCE_INITIALIZER - expression in instance block + * { + * instanceId = generateId(); // rootContext: INSTANCE_INITIALIZER + * } + * + * public void method() { + * // LOCAL_VAR_INITIALIZER - expression initializes local variable + * int x = a + b; // rootContext: LOCAL_VAR_INITIALIZER + * + * // RETURN_VALUE - expression in return statement + * return x * 2; // rootContext: RETURN_VALUE + * + * // THROW_VALUE - expression in throw statement + * throw new RuntimeException("error"); // rootContext: THROW_VALUE + * + * // IF_CONDITION - expression in if condition + * if (x > 0) { } // rootContext: IF_CONDITION + * + * // WHILE_CONDITION - expression in while condition + * while (running) { } // rootContext: WHILE_CONDITION + * + * // FOR_INIT/CONDITION/UPDATE - expressions in for loop + * for (int i = 0; i < 10; i++) { } // three different contexts + * + * // DO_WHILE_CONDITION - expression in do-while condition + * do { + * process(); + * } while (hasMore()); // rootContext: DO_WHILE_CONDITION + * + * // ASSERT_CONDITION / ASSERT_MESSAGE - expressions in assert statement + * assert x > 0; // rootContext: ASSERT_CONDITION + * assert x > 0 : "x must be positive"; // condition: ASSERT_CONDITION, message: ASSERT_MESSAGE + * + * // EXPRESSION_STATEMENT - standalone expression as statement + * doSomething(); // rootContext: EXPRESSION_STATEMENT + * counter++; // rootContext: EXPRESSION_STATEMENT + * list.add(item); // rootContext: EXPRESSION_STATEMENT + * + * // SYNCHRONIZED_LOCK - expression in synchronized block + * synchronized (lock) { // rootContext: SYNCHRONIZED_LOCK + * sharedResource.update(); + * } + * } + * + * // EXPLICIT_CONSTRUCTOR_INVOCATION - this() or super() in constructor + * public MyClass() { + * super(compute()); // rootContext: EXPLICIT_CONSTRUCTOR_INVOCATION + * } + * + * public MyClass(int x) { + * this(); // rootContext: EXPLICIT_CONSTRUCTOR_INVOCATION + * + * // ENHANCED_FOR_ITERABLE - iterable in enhanced for + * for (String s : items) { } // rootContext: ENHANCED_FOR_ITERABLE + * + * // SWITCH_SELECTOR - expression being switched on + * switch (status) { // rootContext: SWITCH_SELECTOR + * // SWITCH_CASE_LABEL - case label constants in statement switch + * case ACTIVE: // rootContext: SWITCH_CASE_LABEL + * case PENDING: // rootContext: SWITCH_CASE_LABEL + * handleActive(); + * break; // rootContext: BREAK_STATEMENT + * default: // rootContext: SWITCH_CASE_LABEL (synthetic "default") + * handleOther(); + * } + * + * // BREAK_STATEMENT - break; or break label; in switch case or loop + * for (int i = 0; i < 10; i++) { + * if (found) break outer; // rootContext: BREAK_STATEMENT (literalValue = "outer") + * } + * + * // CONTINUE_STATEMENT - continue; or continue label; in a loop + * for (String s : items) { + * if (s == null) continue; // rootContext: CONTINUE_STATEMENT + * if (skip) continue outer; // rootContext: CONTINUE_STATEMENT (literalValue = "outer") + * } + * + * // YIELD_VALUE - expression in yield statement (switch expression) + * String label = switch (status) { + * case ACTIVE -> "active"; + * default -> { + * String s = compute(); + * yield s.toUpperCase(); // rootContext: YIELD_VALUE + * } + * }; + * + * // TRY_RESOURCE - resource expression in try-with-resources + * try (var r = getResource()) { // getResource(): rootContext: TRY_RESOURCE + * // TRY_BLOCK - expression in try block body + * process(r); // rootContext: TRY_BLOCK + * } catch (Exception e) { + * // CATCH_BLOCK - expression in catch block body + * log(e); // rootContext: CATCH_BLOCK + * } finally { + * // FINALLY_BLOCK - expression in finally block body + * cleanup(); // rootContext: FINALLY_BLOCK + * } + * + * // Regular try (no resources) - only has TRY_BLOCK, CATCH_BLOCK, FINALLY_BLOCK + * try { + * riskyOperation(); // rootContext: TRY_BLOCK + * } catch (Exception e) { + * handleError(e); // rootContext: CATCH_BLOCK + * } + * } + * } + * + * // ANNOTATION_VALUE - expression in annotation argument + * @MyAnnotation(value = 42) // rootContext: ANNOTATION_VALUE + * public class Annotated { } + * + * // ANNOTATION_DEFAULT - default value in annotation method + * @interface Config { + * int timeout() default 30; // rootContext: ANNOTATION_DEFAULT + * String name() default "default"; // rootContext: ANNOTATION_DEFAULT + * } + * + * // ENUM_CONSTANT_ARGUMENT - expression in enum constant constructor argument + * enum Status { + * ACTIVE(1, "Active"), // 1 and "Active": rootContext: ENUM_CONSTANT_ARGUMENT + * INACTIVE(0, "Inactive"), // 0 and "Inactive": rootContext: ENUM_CONSTANT_ARGUMENT + * PENDING(computeCode(), "Pending"); // computeCode() and "Pending": rootContext: ENUM_CONSTANT_ARGUMENT + * + * Status(int code, String label) { ... } + * } + * + * enum ComplexEnum { + * VALUE(new ArrayList<>(), true ? "yes" : "no"); // new ArrayList<>() and ternary: rootContext: ENUM_CONSTANT_ARGUMENT + * } + * ``` + * + * ## Key Invariant + * + * - **Root expressions** have a meaningful rootContext + * - **Child expressions** inherit rootContext from their root ancestor + * - All expressions in a tree share the same rootContext + */ +export enum RootContext { + // === Declarations === + /** Expression initializes a field (private int x = EXPR). */ + FIELD_INITIALIZER = 'FIELD_INITIALIZER', + + /** Expression initializes a local variable. */ + LOCAL_VAR_INITIALIZER = 'LOCAL_VAR_INITIALIZER', + + /** Expression in return statement. */ + RETURN_VALUE = 'RETURN_VALUE', + + /** Expression in throw statement. */ + THROW_VALUE = 'THROW_VALUE', + + /** Expression in assert condition. */ + ASSERT_CONDITION = 'ASSERT_CONDITION', + + /** Expression in assert message. */ + ASSERT_MESSAGE = 'ASSERT_MESSAGE', + + /** Expression in if/else-if condition. */ + IF_CONDITION = 'IF_CONDITION', + + /** Expression in while loop condition. */ + WHILE_CONDITION = 'WHILE_CONDITION', + + /** Expression in do-while condition. */ + DO_WHILE_CONDITION = 'DO_WHILE_CONDITION', + + /** Expression in for loop initialization. */ + FOR_INIT = 'FOR_INIT', + + /** Expression in for loop condition. */ + FOR_CONDITION = 'FOR_CONDITION', + + /** Expression in for loop update. */ + FOR_UPDATE = 'FOR_UPDATE', + + /** Iterable expression in enhanced for loop. */ + ENHANCED_FOR_ITERABLE = 'ENHANCED_FOR_ITERABLE', + + /** Expression being switched on. */ + SWITCH_SELECTOR = 'SWITCH_SELECTOR', + + /** Case label constant in a statement switch (case 1, 9: or case "hello" ->). */ + SWITCH_CASE_LABEL = 'SWITCH_CASE_LABEL', + + /** Expression in synchronized block. */ + SYNCHRONIZED_LOCK = 'SYNCHRONIZED_LOCK', + + /** Resource expression in try-with-resources. */ + TRY_RESOURCE = 'TRY_RESOURCE', + + /** Expression in try block body. */ + TRY_BLOCK = 'TRY_BLOCK', + + /** Expression in catch block body. */ + CATCH_BLOCK = 'CATCH_BLOCK', + + /** Expression in finally block body. */ + FINALLY_BLOCK = 'FINALLY_BLOCK', + + /** Expression in annotation argument. */ + ANNOTATION_VALUE = 'ANNOTATION_VALUE', + + /** Expression in static initializer block. */ + STATIC_INITIALIZER = 'STATIC_INITIALIZER', + + /** Expression in instance initializer block. */ + INSTANCE_INITIALIZER = 'INSTANCE_INITIALIZER', + + /** Expression in yield statement (switch expression). */ + YIELD_VALUE = 'YIELD_VALUE', + + /** Standalone expression as statement (method call, assignment). */ + EXPRESSION_STATEMENT = 'EXPRESSION_STATEMENT', + + /** Expression in explicit constructor invocation (this(), super()). */ + EXPLICIT_CONSTRUCTOR_INVOCATION = 'EXPLICIT_CONSTRUCTOR_INVOCATION', + + /** Default value expression in annotation method declaration. */ + ANNOTATION_DEFAULT = 'ANNOTATION_DEFAULT', + + /** Expression in enum constant argument. */ + ENUM_CONSTANT_ARGUMENT = 'ENUM_CONSTANT_ARGUMENT', + + /** Break statement (break; or break label;) in switch case or loop. */ + BREAK_STATEMENT = 'BREAK_STATEMENT', + + /** Continue statement (continue; or continue label;) in a loop. */ + CONTINUE_STATEMENT = 'CONTINUE_STATEMENT', +} diff --git a/parser/src/enums/java/expressions/UnaryFixity.ts b/parser/src/enums/java/expressions/UnaryFixity.ts new file mode 100644 index 000000000..78571e3be --- /dev/null +++ b/parser/src/enums/java/expressions/UnaryFixity.ts @@ -0,0 +1,51 @@ +/** + * Distinguishes prefix from postfix unary operators. + * + * This is semantically important for increment/decrement operators: + * - **PREFIX** (++i): Increment first, then use the value + * - **POSTFIX** (i++): Use the value first, then increment + * + * Other unary operators (-x, !x, ~x, +x) are always prefix. + * + * ## Classification Examples + * + * ```java + * public class UnaryExamples { + * private int count = 0; + * + * // PREFIX - increment/decrement before use + * private int preIncrement = ++count; // unaryFixity: PREFIX + * private int preDecrement = --count; // unaryFixity: PREFIX + * + * // POSTFIX - increment/decrement after use + * private int postIncrement = count++; // unaryFixity: POSTFIX + * private int postDecrement = count--; // unaryFixity: POSTFIX + * + * // PREFIX only operators (no postfix form) + * private int negative = -value; // unaryFixity: PREFIX + * private int positive = +value; // unaryFixity: PREFIX + * private boolean negation = !flag; // unaryFixity: PREFIX + * private int bitNot = ~bits; // unaryFixity: PREFIX + * } + * ``` + * + * ## Semantic Difference + * + * ```java + * int i = 5; + * int a = ++i; // a = 6, i = 6 (increment then assign) + * int b = i++; // b = 6, i = 7 (assign then increment) + * ``` + * + * ## Usage + * + * This field is only relevant for UNARY_EXPRESSION kind. + * Use in conjunction with the `operator` field. + */ +export enum UnaryFixity { + /** Prefix operator (++i, --i, -x, !x, ~x, +x). Operator before operand. */ + PREFIX = 'PREFIX', + + /** Postfix operator (i++, i--). Operator after operand. */ + POSTFIX = 'POSTFIX', +} diff --git a/parser/src/enums/java/expressions/index.ts b/parser/src/enums/java/expressions/index.ts new file mode 100644 index 000000000..6daac8b38 --- /dev/null +++ b/parser/src/enums/java/expressions/index.ts @@ -0,0 +1,8 @@ +export { ExpressionKind } from '@/enums/java/expressions/ExpressionKind'; +export { RootContext } from '@/enums/java/expressions/RootContext'; +export { EdgeRole } from '@/enums/java/expressions/EdgeRole'; +export { ExpressionOwnerKind } from '@/enums/java/expressions/ExpressionOwnerKind'; +export { LiteralType } from '@/enums/java/expressions/LiteralType'; +export { ReferencedEntityKind } from '@/enums/java/expressions/ReferencedEntityKind'; +export { MethodReferenceKind } from '@/enums/java/expressions/MethodReferenceKind'; +export { UnaryFixity } from '@/enums/java/expressions/UnaryFixity'; diff --git a/parser/src/enums/java/fields/FieldModifier.ts b/parser/src/enums/java/fields/FieldModifier.ts new file mode 100644 index 000000000..5331b9e54 --- /dev/null +++ b/parser/src/enums/java/fields/FieldModifier.ts @@ -0,0 +1,68 @@ +/** + * Field-specific modifiers in Java. + * + * These modifiers apply specifically to field declarations and control + * various aspects of field behavior including memory visibility, serialization, + * and mutability. + * + * ## Modifier Descriptions + * + * - **STATIC** - Field belongs to the class rather than instances + * - **FINAL** - Field value cannot be changed after initialization + * - **VOLATILE** - Field reads/writes go directly to main memory (thread visibility) + * - **TRANSIENT** - Field is excluded from serialization + * + * ## Modifier Combinations + * + * Some combinations are legal, others are not: + * + * ### Legal Combinations: + * ```java + * private static final String CONSTANT = "value"; // static + final + * private static volatile boolean flag; // static + volatile + * private static transient Logger logger; // static + transient + * private final transient InputStream stream; // final + transient + * private volatile transient int cached; // volatile + transient + * ``` + * + * ### Illegal Combinations: + * ```java + * private final volatile int x; // ILLEGAL: final + volatile + * ``` + * + * ## Examples + * + * ```java + * // STATIC - Class-level field + * private static int instanceCount; + * public static final String VERSION = "1.0"; + * + * // FINAL - Immutable after initialization + * private final String id; + * private final List items; + * + * // VOLATILE - Memory visibility for concurrent access + * private volatile boolean running; + * private volatile int counter; + * + * // TRANSIENT - Excluded from serialization + * private transient Connection connection; + * private transient Thread workerThread; + * ``` + * + * @remarks These modifiers are combined with access modifiers (public, private, etc.) + * to fully describe field visibility and behavior. + */ +export enum FieldModifier { + /** Field belongs to the class, not instances. */ + STATIC = 'STATIC', + + /** Field value cannot be changed after initialization. */ + FINAL = 'FINAL', + + /** Field reads/writes bypass CPU cache for thread visibility. */ + VOLATILE = 'VOLATILE', + + /** Field is excluded from Java serialization. */ + TRANSIENT = 'TRANSIENT', +} diff --git a/parser/src/enums/java/fields/index.ts b/parser/src/enums/java/fields/index.ts new file mode 100644 index 000000000..cda89a548 --- /dev/null +++ b/parser/src/enums/java/fields/index.ts @@ -0,0 +1 @@ +export { FieldModifier } from '@/enums/java/fields/FieldModifier'; diff --git a/parser/src/enums/java/imports/ImportKind.ts b/parser/src/enums/java/imports/ImportKind.ts new file mode 100644 index 000000000..324412663 --- /dev/null +++ b/parser/src/enums/java/imports/ImportKind.ts @@ -0,0 +1,78 @@ +/** + * Import Kind Classification (Java 23+) + * + * Categorizes import declarations based on their type and behavior. + * Each import statement is assigned exactly ONE ImportKind. + * + * Values: + * - SINGLE_TYPE: Single type import (import java.util.List;) + * - TYPE_ON_DEMAND: Wildcard type import (import java.util.*;) + * - SINGLE_STATIC: Single static member import (import static java.lang.Math.PI;) + * - STATIC_ON_DEMAND: Wildcard static import (import static java.lang.Math.*;) + * - MODULE: Module import - Java 23+ JEP 476 (import module java.base;) + * + * Grammar (JLS): + * ``` + * ImportDeclaration: + * SingleTypeImportDeclaration + * TypeImportOnDemandDeclaration + * SingleStaticImportDeclaration + * StaticImportOnDemandDeclaration + * ModuleImportDeclaration + * + * SingleTypeImportDeclaration: + * import TypeName ; + * + * TypeImportOnDemandDeclaration: + * import PackageOrTypeName . * ; + * + * SingleStaticImportDeclaration: + * import static TypeName . Identifier ; + * + * StaticImportOnDemandDeclaration: + * import static TypeName . * ; + * + * ModuleImportDeclaration: + * import module ModuleName ; + * ``` + * + * Examples: + * ```java + * // SINGLE_TYPE - imports a specific type + * import java.util.List; + * import com.example.MyClass; + * + * // TYPE_ON_DEMAND - imports all public types from a package + * import java.util.*; + * import com.example.models.*; + * + * // SINGLE_STATIC - imports a specific static member + * import static java.lang.Math.PI; + * import static java.lang.Math.max; + * + * // STATIC_ON_DEMAND - imports all static members from a type + * import static java.lang.Math.*; + * import static org.junit.Assert.*; + * + * // MODULE (Java 23+) - imports all public types from all packages exported by a module + * import module java.base; + * import module java.sql; + * import module com.example.mymodule; + * ``` + * + * Determination Logic: + * 1. Check for "module" keyword → MODULE + * 2. Check for "static" keyword: + * - With "*" → STATIC_ON_DEMAND + * - Without "*" → SINGLE_STATIC + * 3. Non-static imports: + * - With "*" → TYPE_ON_DEMAND + * - Without "*" → SINGLE_TYPE + */ +export enum ImportKind { + SINGLE_TYPE = 'SINGLE_TYPE', + TYPE_ON_DEMAND = 'TYPE_ON_DEMAND', + SINGLE_STATIC = 'SINGLE_STATIC', + STATIC_ON_DEMAND = 'STATIC_ON_DEMAND', + MODULE = 'MODULE', +} diff --git a/parser/src/enums/java/imports/index.ts b/parser/src/enums/java/imports/index.ts new file mode 100644 index 000000000..f70c63521 --- /dev/null +++ b/parser/src/enums/java/imports/index.ts @@ -0,0 +1 @@ +export { ImportKind } from '@/enums/java/imports/ImportKind'; diff --git a/parser/src/enums/java/local-variables/LocalVariableScopeKind.ts b/parser/src/enums/java/local-variables/LocalVariableScopeKind.ts new file mode 100644 index 000000000..6b7394589 --- /dev/null +++ b/parser/src/enums/java/local-variables/LocalVariableScopeKind.ts @@ -0,0 +1,261 @@ +/** + * Identifies the syntactic scope where a local variable is declared. + * + * This enumeration classifies the declaration context of local variables, + * which is essential for: + * - Scope analysis and variable resolution + * - Understanding variable lifetime + * - Linking variables to their enclosing construct + * + * ## Scope Categories + * + * - **Method Scopes** – Variables in regular methods, constructors + * - **Lambda Scopes** – Variables inside lambda expressions (can be nested) + * - **Block Scopes** – Variables in initializer blocks + * - **Loop Scopes** – Variables in for/enhanced-for loop declarations + * - **Exception Scopes** – Variables in try-with-resources, catch clauses + * - **Special Scopes** – Anonymous class methods, record methods, enum methods + * + * ## Classification Examples + * + * ```java + * public class Example { + * // STATIC_INITIALIZER - in static block + * static { + * int staticBlockVar = 10; // scopeKind: STATIC_INITIALIZER + * } + * + * // INSTANCE_INITIALIZER - in instance block + * { + * int instanceBlockVar = 20; // scopeKind: INSTANCE_INITIALIZER + * } + * + * // CONSTRUCTOR_BODY - in constructor + * public Example() { + * int constructorVar = 30; // scopeKind: CONSTRUCTOR_BODY + * } + * + * public void method() { + * // METHOD_BODY - regular method + * int methodVar = 40; // scopeKind: METHOD_BODY + * + * // LAMBDA_BODY - inside lambda + * Supplier s = () -> { + * int lambdaVar = 50; // scopeKind: LAMBDA_BODY, scopeDepth: 1 + * + * // Nested lambda + * return (() -> { + * int nestedLambdaVar = 60; // scopeKind: LAMBDA_BODY, scopeDepth: 2 + * return nestedLambdaVar; + * }).get(); + * }; + * + * // FOR_LOOP - in for loop declaration + * for (int i = 0; i < 10; i++) { // i: scopeKind: FOR_LOOP + * int loopBodyVar = 70; // scopeKind: FOR_BLOCK + * } + * + * // ENHANCED_FOR_LOOP - in enhanced for loop + * for (String item : items) { // item: scopeKind: ENHANCED_FOR_LOOP + * int eachVar = 80; // scopeKind: FOR_BLOCK + * } + * + * // TRY_WITH_RESOURCES - resource variable + * try (var reader = new BufferedReader(...)) { // reader: scopeKind: TRY_WITH_RESOURCES + * int tryVar = 90; // scopeKind: TRY_BLOCK (inside try body) + * } catch (IOException e) { // e: scopeKind: CATCH_CLAUSE + * int catchVar = 100; // scopeKind: CATCH_BLOCK (inside catch body) + * } finally { + * int finallyVar = 101; // scopeKind: FINALLY_BLOCK (inside finally body) + * } + * + * // === Control Flow Block Scopes === + * + * // FOR_BLOCK - variable inside for loop body + * for (int i = 0; i < 10; i++) { // i: scopeKind: FOR_LOOP + * int loopBodyVar = 70; // scopeKind: FOR_BLOCK + * } + * + * // WHILE_BLOCK - variable inside while loop body + * while (running) { + * int whileVar = 71; // scopeKind: WHILE_BLOCK + * } + * + * // DO_WHILE_BLOCK - variable inside do-while loop body + * do { + * int doWhileVar = 72; // scopeKind: DO_WHILE_BLOCK + * } while (hasMore()); + * + * // IF_BLOCK / ELSE_IF_BLOCK / ELSE_BLOCK + * if (condition) { + * int ifVar = 73; // scopeKind: IF_BLOCK + * } else if (other) { + * int elseIfVar = 74; // scopeKind: ELSE_IF_BLOCK + * } else { + * int elseVar = 75; // scopeKind: ELSE_BLOCK + * } + * + * // SWITCH_BLOCK - variable inside switch case + * switch (status) { + * case ACTIVE: + * int switchVar = 76; // scopeKind: SWITCH_BLOCK + * break; + * } + * + * // SYNCHRONIZED_BLOCK - variable inside synchronized block + * synchronized (lock) { + * int syncVar = 77; // scopeKind: SYNCHRONIZED_BLOCK + * } + * } + * + * // ANONYMOUS_CLASS_METHOD - in anonymous class method + * Runnable r = new Runnable() { + * public void run() { + * int anonMethodVar = 110; // scopeKind: ANONYMOUS_CLASS_METHOD + * } + * }; + * } + * + * // RECORD_METHOD - in record method + * record Point(int x, int y) { + * public double distance() { + * double dx = x; // scopeKind: RECORD_METHOD + * return dx; + * } + * } + * + * // ENUM_METHOD - in enum constant anonymous body or enum method + * enum Status { + * ACTIVE { + * public String describe() { + * String desc = "Active"; // scopeKind: ENUM_CONSTANT_METHOD + * return desc; + * } + * }; + * public String describe() { + * String base = "Status"; // scopeKind: ENUM_METHOD + * return base; + * } + * } + * + * // === Pattern Scopes (Java 16+) === + * + * // INSTANCEOF_PATTERN - pattern binding in instanceof + * if (obj instanceof String s) { // s: scopeKind: INSTANCEOF_PATTERN + * System.out.println(s.toUpperCase()); + * } + * + * // SWITCH_PATTERN - pattern binding in switch case + * switch (obj) { + * case String s when s.length() > 5: // s: scopeKind: SWITCH_PATTERN + * System.out.println(s); + * break; + * default: + * break; + * } + * + * // RECORD_PATTERN - pattern binding in record deconstruction (Java 21+) + * if (obj instanceof Person(String name, int age)) { // name, age: scopeKind: RECORD_PATTERN + * System.out.println(name + " is " + age); + * } + * ``` + * + * ## Scope Depth + * + * For nested lambdas, `scopeDepth` indicates nesting level: + * - 0 = method body (not in a lambda) + * - 1 = first lambda level + * - 2 = nested lambda inside another lambda + * - etc. + */ +export enum LocalVariableScopeKind { + // === Method Scopes === + /** Variable in regular method body. */ + METHOD_BODY = 'METHOD_BODY', + + /** Variable in constructor body. */ + CONSTRUCTOR_BODY = 'CONSTRUCTOR_BODY', + + // === Lambda Scopes === + /** Variable inside lambda expression body. */ + LAMBDA_BODY = 'LAMBDA_BODY', + + // === Block Scopes === + /** Variable in static initializer block. */ + STATIC_INITIALIZER = 'STATIC_INITIALIZER', + + /** Variable in instance initializer block. */ + INSTANCE_INITIALIZER = 'INSTANCE_INITIALIZER', + + // === Loop Scopes === + /** Variable declared in for loop initialization (int i = 0). */ + FOR_LOOP = 'FOR_LOOP', + + /** Variable declared in enhanced for loop (String item : items). */ + ENHANCED_FOR_LOOP = 'ENHANCED_FOR_LOOP', + + // === Exception Binding Scopes === + /** Resource variable in try-with-resources header. */ + TRY_WITH_RESOURCES = 'TRY_WITH_RESOURCES', + + /** Exception variable in catch clause header. */ + CATCH_CLAUSE = 'CATCH_CLAUSE', + + // === Exception Block Body Scopes === + /** Variable inside try block body (regular or with-resources). */ + TRY_BLOCK = 'TRY_BLOCK', + + /** Variable inside catch block body. */ + CATCH_BLOCK = 'CATCH_BLOCK', + + /** Variable inside finally block body. */ + FINALLY_BLOCK = 'FINALLY_BLOCK', + + // === Control Flow Block Scopes === + /** Variable inside for loop body. */ + FOR_BLOCK = 'FOR_BLOCK', + + /** Variable inside while loop body. */ + WHILE_BLOCK = 'WHILE_BLOCK', + + /** Variable inside do-while loop body. */ + DO_WHILE_BLOCK = 'DO_WHILE_BLOCK', + + /** Variable inside if block body. */ + IF_BLOCK = 'IF_BLOCK', + + /** Variable inside else-if block body. */ + ELSE_IF_BLOCK = 'ELSE_IF_BLOCK', + + /** Variable inside else block body. */ + ELSE_BLOCK = 'ELSE_BLOCK', + + /** Variable inside switch case/default block. */ + SWITCH_BLOCK = 'SWITCH_BLOCK', + + /** Variable inside synchronized block. */ + SYNCHRONIZED_BLOCK = 'SYNCHRONIZED_BLOCK', + + // === Special Method Scopes === + /** Variable in anonymous class method body. */ + ANONYMOUS_CLASS_METHOD = 'ANONYMOUS_CLASS_METHOD', + + /** Variable in record class method body. */ + RECORD_METHOD = 'RECORD_METHOD', + + /** Variable in enum class method body. */ + ENUM_METHOD = 'ENUM_METHOD', + + /** Variable in enum constant's anonymous class method. */ + ENUM_CONSTANT_METHOD = 'ENUM_CONSTANT_METHOD', + + // === Pattern Scopes === + /** Pattern binding variable in instanceof pattern. */ + INSTANCEOF_PATTERN = 'INSTANCEOF_PATTERN', + + /** Pattern binding variable in switch case pattern. */ + SWITCH_PATTERN = 'SWITCH_PATTERN', + + /** Pattern binding variable in record pattern deconstruction. */ + RECORD_PATTERN = 'RECORD_PATTERN', +} diff --git a/parser/src/enums/java/local-variables/index.ts b/parser/src/enums/java/local-variables/index.ts new file mode 100644 index 000000000..5b7bc9ce8 --- /dev/null +++ b/parser/src/enums/java/local-variables/index.ts @@ -0,0 +1 @@ +export { LocalVariableScopeKind } from '@/enums/java/local-variables/LocalVariableScopeKind'; diff --git a/parser/src/enums/java/methods/MethodAccess.ts b/parser/src/enums/java/methods/MethodAccess.ts new file mode 100644 index 000000000..5f31ba563 --- /dev/null +++ b/parser/src/enums/java/methods/MethodAccess.ts @@ -0,0 +1,51 @@ +/** + * ### Supported Method Access Levels: + * - **PUBLIC** - Method is accessible from any package (`public` modifier) + * - **PROTECTED** - Method is accessible within package and subclasses (`protected` modifier) + * - **PRIVATE** - Method is accessible only within enclosing class (`private` modifier) + * - **PACKAGE** - Method is accessible only within same package (no explicit modifier) + * + * ### Java Language Rules Applied: + * - Interface methods default to `public` (implicit) + * - Annotation elements are always `public` (implicit) + * - Enum constructors default to `private` (public/protected are illegal) + * - Class methods default to package-private (no modifier) + * - Private interface methods are allowed in Java 9+ + * + * ### Examples: + * ```java + * // Class methods + * public class UserService { + * public void save(User user) {} // PUBLIC - External API + * protected void validate(User user) {} // PROTECTED - Subclass access + * private void encrypt(String data) {} // PRIVATE - Internal only + * void log(String message) {} // PACKAGE - Package internal + * } + * + * // Interface methods (Java 9+) + * public interface PaymentGateway { + * void process(Payment p); // PUBLIC (implicit) + * default void log(String msg) {} // PUBLIC (implicit) + * static void validate(Payment p) {} // PUBLIC (implicit) + * private void helper() {} // PRIVATE (Java 9+) + * } + * + * // Annotation elements (always public) + * public @interface RequestMapping { + * String value(); // PUBLIC (implicit) + * String method() default "GET"; // PUBLIC (implicit) + * } + * + * // Enum constructors (always private) + * public enum Status { + * ACTIVE("active"), INACTIVE("inactive"); + * Status(String code) {} // PRIVATE (implicit, public/protected illegal) + * } + * ``` + */ +export enum MethodAccess { + PUBLIC = 'PUBLIC', + PRIVATE = 'PRIVATE', + PROTECTED = 'PROTECTED', + PACKAGE = 'PACKAGE', +} diff --git a/parser/src/enums/java/methods/MethodKind.ts b/parser/src/enums/java/methods/MethodKind.ts new file mode 100644 index 000000000..dc2a3c280 --- /dev/null +++ b/parser/src/enums/java/methods/MethodKind.ts @@ -0,0 +1,162 @@ +/** + * Method Kind Classification + * + * Categorizes methods based on their type and behavior. Each method is assigned exactly ONE MethodKind. + * + * ## Method Categories + * + * - **Instance** – Regular methods requiring an object instance + * - **Static** – Class-level methods not tied to an instance + * - **Abstract** – Methods without implementation (must be overridden) + * - **Default** – Interface methods with default implementation (Java 8+) + * - **Constructors** – Regular constructors and record compact constructors + * - **Initializers** – Static and instance initialization blocks + * - **Special** – Annotation elements and enum constant methods + * + * ## Classification Examples + * + * ```java + * public class UserService { + * // STATIC_INITIALIZER - static initialization block + * static { + * System.loadLibrary("native"); // methodKind: STATIC_INITIALIZER + * } + * + * // INSTANCE_INITIALIZER - instance initialization block + * { + * System.out.println("instance created"); // methodKind: INSTANCE_INITIALIZER + * } + * + * // CONSTRUCTOR - regular constructor + * public UserService(UserRepository repo) { // methodKind: CONSTRUCTOR + * this.repo = repo; + * } + * + * // INSTANCE_METHOD - regular instance method + * public User findById(Long id) { // methodKind: INSTANCE_METHOD + * return repo.findById(id); + * } + * + * // STATIC_METHOD - static utility method + * public static UserService create() { // methodKind: STATIC_METHOD + * return new UserService(new UserRepository()); + * } + * } + * + * // ABSTRACT_METHOD - no method body + * public abstract class BaseProcessor { + * public abstract void process(); // methodKind: ABSTRACT_METHOD + * } + * + * // DEFAULT_METHOD - interface method with implementation (Java 8+) + * public interface Logger { + * void log(String msg); // methodKind: ABSTRACT_METHOD (no body) + * default void info(String msg) { // methodKind: DEFAULT_METHOD + * log("INFO: " + msg); + * } + * static Logger create() { // methodKind: STATIC_METHOD + * return msg -> System.out.println(msg); + * } + * } + * + * // ANNOTATION_ELEMENT - methods in @interface declarations + * public @interface Config { + * String name(); // methodKind: ANNOTATION_ELEMENT + * int timeout() default 30; // methodKind: ANNOTATION_ELEMENT + * } + * + * // RECORD_ACCESSOR / RECORD_EQUALS / RECORD_HASH_CODE / RECORD_TO_STRING + * // Members JLS 8.10.3 declares implicitly on a record. They have no declaration node in the + * // source, so they are synthesised from the record header. + * public record Point(int x, int y) {} + * // ^ ^ implicit: x() and y() -> RECORD_ACCESSOR + * // implicit: equals(Object) -> RECORD_EQUALS + * // implicit: hashCode() -> RECORD_HASH_CODE + * // implicit: toString() -> RECORD_TO_STRING + * + * // These four kinds mark IMPLICIT members only. A record may declare any of them itself, in + * // which case javac does not declare it implicitly and nothing is synthesised - the declared + * // member is extracted from its own node and keeps INSTANCE_METHOD, exactly as before: + * public record Custom(int x, int y) { + * @Override public int x() { return x < 0 ? 0 : x; } // methodKind: INSTANCE_METHOD + * // y() is still implicit // methodKind: RECORD_ACCESSOR + * } + * // So methodKind answers "did the language declare this, or did a person?" - which is the + * // distinction a consumer cannot recover once the member is in the fact set. + * + * // ENUM_VALUES / ENUM_VALUE_OF + * // JLS 8.9.3 declares both on every enum. Unlike a record's members these cannot be written by + * // hand - declaring values() or valueOf(String) in an enum body is a compile error - so they + * // are always implicit and always present. + * public enum Color { RED, GREEN } + * // implicit: public static Color[] values() -> ENUM_VALUES + * // implicit: public static Color valueOf(String) -> ENUM_VALUE_OF + * + * // DEFAULT_CONSTRUCTOR + * // JLS 8.8.9 (classes) and 8.9.2 (enums): a type that declares NO constructor gets one + * // implicitly. Its access is the class's own access, except on an enum, where it is private. + * public class PlainClass { } // implicit: public PlainClass() -> DEFAULT_CONSTRUCTOR + * public enum Color { RED } // implicit: private Color() -> DEFAULT_CONSTRUCTOR + * + * // COMPACT_CONSTRUCTOR - record compact constructor (Java 16+) + * public record User(String name, int age) { + * public User { // methodKind: COMPACT_CONSTRUCTOR (no param list) + * if (age < 0) throw new IllegalArgumentException(); + * } + * } + * + * // CONSTRUCTOR for records - explicit canonical constructor + * public record Point(int x, int y) { + * public Point(int x, int y) { // methodKind: CONSTRUCTOR (explicit canonical) + * this.x = x; + * this.y = y; + * } + * } + * + * // ENUM_CONSTANT_METHOD - method in enum constant anonymous body + * public enum Status { + * ACTIVE { + * @Override + * public String describe() { // methodKind: ENUM_CONSTANT_METHOD + * return "Active"; + * } + * }; + * public abstract String describe(); // methodKind: ABSTRACT_METHOD + * } + * ``` + * + * ## Determination Logic (Priority Order) + * + * 1. Special AST node types (constructors, initializers, annotation elements) + * 2. Abstract (no method body, excluding native methods) + * 3. Default modifier (interface default methods) + * 4. Static modifier + * 5. Fallback to INSTANCE_METHOD + * + * ## Edge Cases + * + * - **Native methods:** No body but NOT abstract → STATIC_METHOD or INSTANCE_METHOD + * - **Interface methods:** No body → ABSTRACT_METHOD (unless default or static) + * - **Interface private methods (Java 9+):** INSTANCE_METHOD or STATIC_METHOD + * - **Enum constructors:** Always CONSTRUCTOR (implicitly private) + * - **Record constructors:** Explicit canonical = CONSTRUCTOR, compact = COMPACT_CONSTRUCTOR + */ +export enum MethodKind { + INSTANCE_METHOD = 'INSTANCE_METHOD', + STATIC_METHOD = 'STATIC_METHOD', + ABSTRACT_METHOD = 'ABSTRACT_METHOD', + DEFAULT_METHOD = 'DEFAULT_METHOD', + CONSTRUCTOR = 'CONSTRUCTOR', + COMPACT_CONSTRUCTOR = 'COMPACT_CONSTRUCTOR', + STATIC_INITIALIZER = 'STATIC_INITIALIZER', + INSTANCE_INITIALIZER = 'INSTANCE_INITIALIZER', + ANNOTATION_ELEMENT = 'ANNOTATION_ELEMENT', + ENUM_CONSTANT_METHOD = 'ENUM_CONSTANT_METHOD', + RECORD_ACCESSOR = 'RECORD_ACCESSOR', + RECORD_EQUALS = 'RECORD_EQUALS', + RECORD_HASH_CODE = 'RECORD_HASH_CODE', + RECORD_TO_STRING = 'RECORD_TO_STRING', + ENUM_VALUES = 'ENUM_VALUES', + ENUM_VALUE_OF = 'ENUM_VALUE_OF', + DEFAULT_CONSTRUCTOR = 'DEFAULT_CONSTRUCTOR', +} diff --git a/parser/src/enums/java/methods/MethodModifier.ts b/parser/src/enums/java/methods/MethodModifier.ts new file mode 100644 index 000000000..d0b291739 --- /dev/null +++ b/parser/src/enums/java/methods/MethodModifier.ts @@ -0,0 +1,68 @@ +/** + * ### Supported Method Modifiers: + * - **STATIC_MODIFIER** - Method belongs to the class, not instances (`static`) + * - **ABSTRACT_MODIFIER** - Method has no implementation (`abstract`) + * - **FINAL_MODIFIER** - Method cannot be overridden in subclasses (`final`) + * - **SYNCHRONIZED_MODIFIER** - Method has synchronized access (`synchronized`) + * - **NATIVE_MODIFIER** - Method implemented in native code (`native`) + * - **STRICTFP_MODIFIER** - Method uses strict floating-point semantics (`strictfp`) + * - **DEFAULT_MODIFIER** - Interface default method with implementation (`default`, Java 8+) + * + * ### Key Differences from TypeModifier: + * - **Method-only:** SYNCHRONIZED, NATIVE, DEFAULT + * - **Type-only:** SEALED, NON_SEALED, TRANSIENT + * - **Shared:** STATIC, ABSTRACT, FINAL, STRICTFP + * + * ### Usage Notes: + * - Methods can have multiple modifiers (stored as comma-separated string in MethodRegistry) + * - Some combinations are illegal (e.g., `abstract final`, `abstract native`) + * - Access modifiers (public, private, etc.) are stored separately in MethodAccess enum + * - Interface methods have implicit modifiers (abstract for methods without bodies, public for all) + * + * ### Examples: + * ```java + * // STATIC_MODIFIER + * public static void main(String[] args) {} + * public static final int calculate(int x) {} // STATIC + FINAL + * + * // ABSTRACT_MODIFIER (explicit in abstract classes) + * public abstract void draw(); + * abstract void process(); // In abstract classes - explicit abstract keyword + * + * // FINAL_MODIFIER + * public final void validate() {} // Cannot be overridden + * protected final String format() {} + * + * // SYNCHRONIZED_MODIFIER + * public synchronized void increment() {} + * private synchronized static void reset() {} // SYNCHRONIZED + STATIC + * + * // NATIVE_MODIFIER + * public native void nativeOperation(); + * private static native String getNativeVersion(); // NATIVE + STATIC + * + * // STRICTFP_MODIFIER + * public strictfp double calculate(double x) {} + * private strictfp static void compute() {} // STRICTFP + STATIC + * + * // DEFAULT_MODIFIER (Java 8+ interface default methods) + * public interface Logger { + * default void log(String msg) { // DEFAULT + * System.out.println(msg); + * } + * } + * + * // Multiple modifiers combined + * public static final synchronized void criticalSection() {} + * // Stored as: "STATIC_MODIFIER,FINAL_MODIFIER,SYNCHRONIZED_MODIFIER" + * ``` + */ +export enum MethodModifier { + STATIC_MODIFIER = 'STATIC_MODIFIER', + ABSTRACT_MODIFIER = 'ABSTRACT_MODIFIER', + FINAL_MODIFIER = 'FINAL_MODIFIER', + SYNCHRONIZED_MODIFIER = 'SYNCHRONIZED_MODIFIER', + NATIVE_MODIFIER = 'NATIVE_MODIFIER', + STRICTFP_MODIFIER = 'STRICTFP_MODIFIER', + DEFAULT_MODIFIER = 'DEFAULT_MODIFIER', +} diff --git a/parser/src/enums/java/methods/index.ts b/parser/src/enums/java/methods/index.ts new file mode 100644 index 000000000..d03781546 --- /dev/null +++ b/parser/src/enums/java/methods/index.ts @@ -0,0 +1,3 @@ +export { MethodAccess } from '@/enums/java/methods/MethodAccess'; +export { MethodModifier } from '@/enums/java/methods/MethodModifier'; +export { MethodKind } from '@/enums/java/methods/MethodKind'; diff --git a/parser/src/enums/java/modules/ModuleDirectiveKind.ts b/parser/src/enums/java/modules/ModuleDirectiveKind.ts new file mode 100644 index 000000000..5af83df60 --- /dev/null +++ b/parser/src/enums/java/modules/ModuleDirectiveKind.ts @@ -0,0 +1,38 @@ +/** + * The five directive forms a module declaration may contain (JLS 7.7). + * + * ```java + * module com.example.app { + * requires java.sql; // REQUIRES + * requires transitive java.logging; // REQUIRES + TRANSITIVE + * requires static java.compiler; // REQUIRES + STATIC + * exports com.example.api; // EXPORTS, no target + * exports com.example.internal to com.example.client; // EXPORTS, one target + * opens com.example.model; // OPENS + * uses com.example.spi.Service; // USES + * provides com.example.spi.Service + * with com.example.impl.ServiceImpl; // PROVIDES, one target + * } + * ``` + * + * ## Subject and target + * + * Every directive names one subject; some also name a list of targets. The two are kept in + * separate columns rather than one joined string, so a directive with N targets becomes N rows + * that differ only in `targetName` and `position` — which is the shape a join wants. + * + * | kind | subjectName | targetName | + * |---|---|---| + * | REQUIRES | the required module | *(empty)* | + * | EXPORTS | the exported package | each module in `to`, or empty when unqualified | + * | OPENS | the opened package | each module in `to`, or empty when unqualified | + * | USES | the service type consumed | *(empty)* | + * | PROVIDES | the service type implemented | each implementation in `with` | + */ +export enum ModuleDirectiveKind { + REQUIRES = 'REQUIRES', + EXPORTS = 'EXPORTS', + OPENS = 'OPENS', + USES = 'USES', + PROVIDES = 'PROVIDES', +} diff --git a/parser/src/enums/java/modules/ModuleDirectiveModifier.ts b/parser/src/enums/java/modules/ModuleDirectiveModifier.ts new file mode 100644 index 000000000..999e9d565 --- /dev/null +++ b/parser/src/enums/java/modules/ModuleDirectiveModifier.ts @@ -0,0 +1,11 @@ +/** + * Modifiers a `requires` directive may carry (JLS 7.7.1). No other directive kind takes one. + * + * - **TRANSITIVE** — any module reading this one also reads the required module. Dropping it + * would understate the module graph: dependents inherit the dependency implicitly. + * - **STATIC** — the dependency is mandatory at compile time and optional at run time. + */ +export enum ModuleDirectiveModifier { + TRANSITIVE = 'TRANSITIVE', + STATIC = 'STATIC', +} diff --git a/parser/src/enums/java/modules/index.ts b/parser/src/enums/java/modules/index.ts new file mode 100644 index 000000000..e04dc5d7a --- /dev/null +++ b/parser/src/enums/java/modules/index.ts @@ -0,0 +1,2 @@ +export { ModuleDirectiveKind } from '@/enums/java/modules/ModuleDirectiveKind'; +export { ModuleDirectiveModifier } from '@/enums/java/modules/ModuleDirectiveModifier'; diff --git a/parser/src/enums/java/scopes/ScopeType.ts b/parser/src/enums/java/scopes/ScopeType.ts new file mode 100644 index 000000000..3dd4d98c4 --- /dev/null +++ b/parser/src/enums/java/scopes/ScopeType.ts @@ -0,0 +1,191 @@ +/** + * Identifies the type of scope in the AST hierarchy for tracking context. + * + * This enumeration classifies the different scope boundaries that affect + * how variables and expressions are linked to their parent containers. + * + * ## Scope Hierarchy + * + * Scopes form a hierarchy where: + * - Methods/constructors are the top-level scope for code + * - Lambdas create new scope boundaries (reset block context) + * - Blocks (try/catch/if/for) create nested scopes within methods/lambdas + * - Static/instance initializers are type-level scopes + * + * ## Key Concept: Scope Boundary + * + * A **scope boundary** (like LAMBDA) resets the block context. This means + * variables inside a lambda link to the lambda, not to an outer try/catch block. + * + * ## Examples + * + * ```java + * public class Example { + * // STATIC_INITIALIZER scope + * static { + * int x = 10; // Links to static initializer + * } + * + * // INSTANCE_INITIALIZER scope + * { + * int y = 20; // Links to instance initializer + * } + * + * // METHOD scope + * public void process() { + * int a = 1; // Links to method + * + * // BLOCK scope (try) + * try { + * int b = 2; // Links to try block + * + * // LAMBDA scope - creates new boundary + * Supplier s = () -> { + * int c = 3; // Links to lambda (NOT try block!) + * + * // BLOCK scope (nested try inside lambda) + * try { + * int d = 4; // Links to nested try block + * } catch (Exception e) { + * // BLOCK scope (catch) + * int f = 5; // Links to catch block + * } + * + * return c; + * }; + * + * } catch (Exception ex) { + * // BLOCK scope (catch) + * int g = 6; // Links to catch block + * + * // LAMBDA scope - creates new boundary + * Runnable r = () -> { + * int h = 7; // Links to lambda (NOT catch block!) + * }; + * } + * } + * } + * ``` + * + * ## Scope Stack Example + * + * For the variable `d` in the example above, the scope stack would be: + * ``` + * [0] METHOD (process) + * [1] BLOCK (try) + * [2] LAMBDA (supplier) + * [3] BLOCK (nested try) <-- d links here + * ``` + * + * ## Usage with ScopeContext + * + * ```typescript + * const context = new ScopeContext(); + * context.enterMethod(methodHash, LocalVariableScopeKind.METHOD_BODY); + * context.enterBlock(tryBlockHash, BlockKind.TRY, LocalVariableScopeKind.TRY_BLOCK); + * context.enterLambda(lambdaHash); // Resets block context! + * + * // Inside lambda: + * const ownerHash = context.getCurrentOwnerHash(); // Returns lambdaHash + * ``` + */ +export enum ScopeType { + /** + * Method or constructor body scope. + * + * This is the top-level scope for executable code. Variables declared + * directly in a method body link to the method. + * + * ```java + * public void example() { + * int x = 10; // ScopeType: METHOD + * } + * + * public Example() { + * int y = 20; // ScopeType: METHOD (constructor) + * } + * ``` + */ + METHOD = 'METHOD', + + /** + * Lambda expression body scope. + * + * Creates a **scope boundary** - resets block context. Variables inside + * a lambda link to the lambda expression, not to any outer block. + * + * ```java + * try { + * Supplier s = () -> { + * int x = 10; // ScopeType: LAMBDA (NOT linked to try block) + * return x; + * }; + * } catch (Exception e) { } + * ``` + */ + LAMBDA = 'LAMBDA', + + /** + * Block construct scope (try, catch, finally, if, for, while, etc.) + * + * Blocks create nested scopes within methods or lambdas. Variables + * declared in a block link to that block's hash. + * + * ```java + * try { + * int x = 10; // ScopeType: BLOCK (try) + * } catch (Exception e) { + * int y = 20; // ScopeType: BLOCK (catch) + * } finally { + * int z = 30; // ScopeType: BLOCK (finally) + * } + * + * if (condition) { + * int a = 1; // ScopeType: BLOCK (if) + * } + * + * for (int i = 0; i < 10; i++) { + * int b = 2; // ScopeType: BLOCK (for) + * } + * ``` + */ + BLOCK = 'BLOCK', + + /** + * Static initializer block scope. + * + * Variables declared in a static initializer block link to the type. + * + * ```java + * public class Example { + * static { + * int x = 10; // ScopeType: STATIC_INITIALIZER + * + * try { + * int y = 20; // ScopeType: BLOCK (nested in static init) + * } catch (Exception e) { } + * } + * } + * ``` + */ + STATIC_INITIALIZER = 'STATIC_INITIALIZER', + + /** + * Instance initializer block scope. + * + * Variables declared in an instance initializer block link to the type. + * + * ```java + * public class Example { + * { + * int x = 10; // ScopeType: INSTANCE_INITIALIZER + * + * Runnable r = () -> { + * int y = 20; // ScopeType: LAMBDA (nested in instance init) + * }; + * } + * } + * ``` + */ + INSTANCE_INITIALIZER = 'INSTANCE_INITIALIZER', +} diff --git a/parser/src/enums/java/scopes/index.ts b/parser/src/enums/java/scopes/index.ts new file mode 100644 index 000000000..d4fd40efd --- /dev/null +++ b/parser/src/enums/java/scopes/index.ts @@ -0,0 +1 @@ +export { ScopeType } from '@/enums/java/scopes/ScopeType'; diff --git a/parser/src/enums/java/type-references/ReferenceOwnerKind.ts b/parser/src/enums/java/type-references/ReferenceOwnerKind.ts new file mode 100644 index 000000000..ef6acf365 --- /dev/null +++ b/parser/src/enums/java/type-references/ReferenceOwnerKind.ts @@ -0,0 +1,183 @@ +/** + * Classifies the ownership context of type references within Java source code structures. + * + * This enumeration identifies the specific syntactic location where a type reference + * appears, enabling precise traceability from type usage back to the exact code element + * that declares or uses the type. This supports detailed dependency analysis and impact + * assessment across different levels of code organization. + * + * ## Ownership Contexts + * + * - **TYPE** – Type-level declarations (extends, implements, permits, type bounds) + * - **FIELD** – Field type declarations within classes + * - **METHOD** – Method return types and throws clause declarations + * - **METHOD_PARAM** – Method and constructor parameter type declarations + * - **ANNOTATION_ARGUMENT** – Type references in annotation argument values + * - **LOCAL_VARIABLE** – Local variable type declarations within method bodies + * - **EXPRESSION** – Type references in expressions (casts, instanceof, new) + * - **ANNOTATION** – The annotation type itself (e.g., @Override → Override, @RequestMapping → RequestMapping) + * + * ## Ownership Examples + * + * ```java + * // TYPE - Class-level type relationships + * public class UserService // T extends BaseEntity: TYPE ownership + * extends AbstractService // AbstractService: TYPE ownership + * implements CrudRepository // CrudRepository: TYPE ownership + * permits StandardUserService, AdminUserService { // Permitted types: TYPE ownership + * + * // FIELD - Field type declarations + * private UserRepository repository; // UserRepository: FIELD ownership + * private List roles; // List: FIELD ownership + * private final Map perms; // Map: FIELD ownership + * + * // METHOD - Method return types and throws clauses + * public Optional findById(Long id) // Optional: METHOD ownership + * throws UserNotFoundException { // UserNotFoundException: METHOD ownership + * + * // LOCAL_VARIABLE - Local variable declarations + * UserQuery query = new UserQuery(); // UserQuery: LOCAL_VARIABLE ownership + * List filters = getFilters(); // List: LOCAL_VARIABLE ownership + * + * // EXPRESSION - Type references in expressions + * if (user instanceof AdminUser) { // AdminUser: EXPRESSION ownership + * return (AdminUser) user; // AdminUser: EXPRESSION ownership + * } + * return new Optional<>(user); // Optional: EXPRESSION ownership + * } + * + * // METHOD_PARAM - Parameter type declarations + * public void saveUser(User user, // User: METHOD_PARAM ownership + * List roles, // List: METHOD_PARAM ownership + * UserOptions options) { // UserOptions: METHOD_PARAM ownership + * // method body + * } + * + * // Constructor parameters also use METHOD_PARAM + * public UserService(UserRepository repo, // UserRepository: METHOD_PARAM ownership + * UserValidator validator) { // UserValidator: METHOD_PARAM ownership + * this.repository = repo; + * } + * } + * + * // ANNOTATION_ARGUMENT - Type references in annotation argument values + * @Entity(name = "users") // No type reference (string literal) + * @Table( + * indexes = @Index(columnList = "email") // No type reference (string literal) + * ) + * @JsonDeserialize(using = UserDeserializer.class) // UserDeserializer: ANNOTATION_ARGUMENT ownership + * class User { + * @Column(targetClass = String.class) // String: ANNOTATION_ARGUMENT ownership + * private String email; + * + * @Enumerated(EnumType.STRING) // EnumType: ANNOTATION_ARGUMENT ownership (enum class) + * private UserRole role; + * + * @Config( + * timeout = Constants.DEFAULT_TIMEOUT, // Constants: ANNOTATION_ARGUMENT ownership + * handler = ErrorHandler.class, // ErrorHandler: ANNOTATION_ARGUMENT ownership + * retries = Config.MAX_RETRIES * 2 // Config: ANNOTATION_ARGUMENT ownership + * ) + * void processUser() {} + * } + * + * // TYPE_PARAMETER - Type parameter bounds (the types that constrain the parameter) + * class Container { // Number: TYPE_PARAMETER ownership (bound on T) + * // Type parameters with bounds + * } + * + * class Processor & Serializable> { + * // Comparable: TYPE_PARAMETER ownership (first bound) + * // Serializable: TYPE_PARAMETER ownership (second bound) + * } + * + * // Note: For annotations ON type parameters, type references in those annotation arguments + * // use ANNOTATION_ARGUMENT ownership (not TYPE_PARAMETER), to maintain direct linkability: + * class ValidatedContainer<@Validated(validator = SizeValidator.class) U> { + * // SizeValidator: ANNOTATION_ARGUMENT ownership (class literal in annotation argument) + * // Context: TYPE_PARAMETER_ANNOTATION (distinguishes it from regular annotation arguments) + * // Linkage: TypeReference → AnnotationArgument → Annotation → TypeParameter + * } + * + * // ANNOTATION - The annotation type itself + * @Entity // Entity: ANNOTATION ownership → TYPE_ANNOTATION_* + * @RequestMapping("/api/users") // RequestMapping: ANNOTATION ownership → TYPE_ANNOTATION_* + * @javax.annotation.Nullable // Nullable: ANNOTATION ownership (qualified name) + * class AnnotatedService { } + * ``` + * + * ## Ownership Linking + * + * - **TYPE:** Links to TypeRegistry hash of the declaring class + * - **FIELD:** Links to FieldRegistry hash of the specific field + * - **METHOD:** Links to MethodRegistry hash of the specific method + * - **METHOD_PARAM:** Links to MethodParameterRegistry hash of the specific parameter + * - **ANNOTATION_ARGUMENT:** Links to AnnotationArgumentReference hash of the specific argument + * - Used for class literals in all annotation arguments, including those on type parameters + * - For type parameter annotations, the context is TYPE_PARAMETER_ANNOTATION (not just ANNOTATION_PARAM) + * - **TYPE_PARAMETER:** Links to TypeParameter hash (for type parameter bounds like `T extends Number`) + * - **LOCAL_VARIABLE:** Links to LocalVariableRegistry hash + * - **EXPRESSION:** Links to ExpressionReference hash + * - **ANNOTATION:** Links to TypeAnnotation hash (the annotation usage entity itself) + * + * ## Analysis Capabilities + * + * ```sql + * -- Find all usages of CustomUser type + * SELECT tr.*, rk.owner_kind, rk.owner_link_hash + * FROM java_type_reference tr + * WHERE tr.referenced_type_registry_hash = 'custom_user_hash'; + * + * -- Results enable precise impact analysis: + * -- - TYPE: CustomUser used in class inheritance + * -- - FIELD: CustomUser used as field type in specific fields + * -- - METHOD: CustomUser used as return type in specific methods + * -- - METHOD_PARAM: CustomUser used as parameter in specific methods + * -- - LOCAL_VARIABLE: CustomUser used in local variables + * -- - EXPRESSION: CustomUser used in casts/instanceof checks + * -- - ANNOTATION: CustomUser used as annotation type (@CustomUser) + * ``` + * + * ## Implementation Guidelines + * + * - **Granularity:** Each ownership kind links to the most specific registry entity + * - **Traceability:** Enables navigation from type usage to exact source location + * - **Extensibility:** New ownership contexts can be added without breaking existing data + * - **Consistency:** Same type reference pattern across all ownership contexts + * + * @remarks Used in conjunction with TypeRefKind and TypeRefContext + * to provide complete classification of type reference location and structure. + * + * @see TypeRefKind + * @see TypeRefContext + * + * @todo Add FieldRegistry, MethodRegistry, ConstructorRegistry, MethodParameterRegistry + */ +export enum ReferenceOwnerKind { + /** Type-level declarations (extends, implements, permits, type parameter bounds). */ + TYPE = 'TYPE', + + /** Field type declarations within classes, interfaces, enums, or records. */ + FIELD = 'FIELD', + + /** Method return types and throws clause type declarations. */ + METHOD = 'METHOD', + + /** Method and constructor parameter type declarations. */ + METHOD_PARAM = 'METHOD_PARAM', + + /** Annotation argument type references (e.g., @Anno(value = SomeClass.class)). */ + ANNOTATION_ARGUMENT = 'ANNOTATION_ARGUMENT', + + /** Annotations on type parameter declarations (e.g., class Box<@NonNull T>). */ + TYPE_PARAMETER = 'TYPE_PARAMETER', + + /** Local variable type declarations within method or constructor bodies. */ + LOCAL_VARIABLE = 'LOCAL_VARIABLE', + + /** Type references in expressions (casts, instanceof, object creation). */ + EXPRESSION = 'EXPRESSION', + + /** Annotation type reference (the annotation itself, e.g., @RequestMapping). Owner is the TypeAnnotation hash. */ + ANNOTATION = 'ANNOTATION', +} diff --git a/parser/src/enums/java/type-references/TypeRefContext.ts b/parser/src/enums/java/type-references/TypeRefContext.ts new file mode 100644 index 000000000..c530664da --- /dev/null +++ b/parser/src/enums/java/type-references/TypeRefContext.ts @@ -0,0 +1,422 @@ +/** + * Identifies WHERE a type reference appears in Java source code. + * + * This enum answers the question: "In what syntactic location is this type being used?" + * It classifies the specific code context where each type reference occurs, enabling + * precise tracking of how types are used throughout your codebase. + * + * ## Reference Contexts + * + * - **TYPE_PARAM_BOUND** – Type bound in a class/interface generic parameter declaration (`class Box`) + * - **METHOD_TYPE_PARAM_BOUND** – Type bound in a method generic parameter declaration (` void process(T item)`) + * - **SUPER_TYPE** – Superclass or extended type in a class or record declaration + * - **IMPLEMENTS_INTERFACE** – Implemented interface in a class or record declaration + * - **PERMITS** – Permitted subtype in a sealed class or interface declaration + * - **FIELD_TYPE** – Declared type of a field + * - **METHOD_RETURN** – Declared return type of a method or constructor + * - **METHOD_PARAM** – Declared type of a formal method or constructor parameter + * - **ANNOTATION_PARAM** – Declared type of an annotation element value + * - **THROWS_CLAUSE** – Declared exception type in a method or constructor throws clause + * - **LOCAL_VARIABLE** – Declared type of a local variable within a block or method scope + * - **CAST_EXPRESSION** – Target type used in an explicit cast expression + * - **METHOD_TYPE_ARGUMENT** – Explicit type argument in a generic method invocation (`Collections.emptyList()`) + * - **INSTANCEOF_TYPE** – Type tested in an instanceof expression (`obj instanceof String`) + * - **OBJECT_CREATION_TYPE** – Type instantiated in a new expression (`new ArrayList()`) + * - **ARRAY_CREATION_TYPE** – Element type in an array creation expression (`new int[3]`, `new String[] { }`) + * - **RECORD_PATTERN_TYPE** – Record type in a record pattern (`Person` in `obj instanceof Person(String name, int age)`) + * - **PATTERN_BINDING_TYPE** – Declared type of a pattern binding variable (`String` in `Person(String name, int age)`) + * - **LAMBDA_PARAMETER_TYPE** – Declared type of a lambda parameter (`String` in `(String s) -> s.length()`) + * + * ## Implementation Assumptions + * + * - **Position:** When multiple references occur in the same context (e.g., `extends A & B`), + * the position field preserves their source ordering + * - **Hierarchy:** Generic arguments form a parent–child tree (e.g., `List` + * → `List`(parent) → `String`(child)) + * - **Linking:** Each context links to a specific registry type via its corresponding + * hash identifier (e.g., field, method, or type) + * - **Wildcards:** Represented using WildcardVariance with nested child nodes for their bounds + * + * ## Comprehensive Example + * + * ```java + * // Complex class demonstrating all major contexts + * @Entity(name = "users", targetEntity = User.class) // ANNOTATION_PARAM: User.class + * public sealed class UserService // TYPE_PARAM_BOUND: BaseEntity, Auditable + * extends AbstractService // SUPER_TYPE: AbstractService + * implements CrudService, Serializable // IMPLEMENTS_INTERFACE: CrudService, Serializable + * permits StandardUserService, AdminUserService { // PERMITS: StandardUserService, AdminUserService + * + * // Field type references + * private Repository repository; // FIELD_TYPE: Repository + * private final List validationRules; // FIELD_TYPE: List + * private Map metrics; // FIELD_TYPE: Map + * + * // Method with multiple contexts + * public Optional findById(Long id, Class type) // METHOD_RETURN: Optional + * // METHOD_PARAM: Long, Class + * throws ServiceException, ValidationException { // THROWS_CLAUSE: ServiceException, ValidationException + * + * // Local variable context + * String cacheKey = generateKey(id); // LOCAL_VARIABLE: String + * List results = new ArrayList<>(); // LOCAL_VARIABLE: List + * + * // Cast expression context + * return Optional.of((T) repository.findById(id)); // CAST_EXPRESSION: (T) + * + * // Method type argument context (explicit type arguments in method calls) + * List empty = Collections.emptyList(); // METHOD_TYPE_ARGUMENT: String + * Map map = ImmutableMap.of(); // METHOD_TYPE_ARGUMENT: String, Integer + * } + * } + * + * // This single class creates 20+ TypeReference entries: + * // - 2 TYPE_PARAM_BOUND (BaseEntity, Auditable) + * // - 1 SUPER_TYPE (AbstractService with child T) + * // - 3 IMPLEMENTS_INTERFACE (CrudService with children, Serializable) + * // - 2 PERMITS (StandardUserService, AdminUserService) + * // - 1 ANNOTATION_PARAM (User.class) + * // - 6 FIELD_TYPE (Repository, List, Map with children) + * // - 1 METHOD_RETURN (Optional with child T) + * // - 2 METHOD_PARAM (Long, Class with child T) + * // - 2 THROWS_CLAUSE (ServiceException, ValidationException) + * // - 2 LOCAL_VARIABLE (String, List with child T) + * // - 1 CAST_EXPRESSION ((T)) + * // - 3 METHOD_TYPE_ARGUMENT (String, String, Integer from explicit generic method calls) + * ``` + * + * ## Type Parameter Annotation Example (Java 8+) + * + * ```java + * // Annotations on type parameters (Java 8+ feature) + * public class Container<@NonNull T, // T: type parameter, @NonNull: annotation (no type refs) + * @Validated(validator = SizeValidator.class) U> { // U: type parameter, @Validated: annotation + * + * public <@Valid(groups = {Quick.class, Full.class}) E> // E: type parameter, @Valid: annotation + * void process(E element) { } + * } + * ``` + * + * **What gets extracted:** + * + * - 3 TypeParameter entries: T, U, E + * - 3 TypeAnnotation entries: @NonNull (on T), @Validated (on U), @Valid (on E) + * - 3 AnnotationArgumentReference entries: validator, groups[0], groups[1] + * - 3 TypeReference entries with **TYPE_PARAMETER_ANNOTATION** context: + * - `SizeValidator` - type used in U's @Validated annotation (context: TYPE_PARAMETER_ANNOTATION, owner: ANNOTATION_ARGUMENT) + * - `Quick` - type used in E's @Valid annotation array (context: TYPE_PARAMETER_ANNOTATION, owner: ANNOTATION_ARGUMENT) + * - `Full` - type used in E's @Valid annotation array (context: TYPE_PARAMETER_ANNOTATION, owner: ANNOTATION_ARGUMENT) + * + * **Key distinction:** TYPE_PARAMETER_ANNOTATION is the **context for type references** that appear + * in annotation arguments on type parameters. The type parameters themselves (T, U, E) and their + * annotations (@NonNull, @Validated, @Valid) are separate entities. + * ``` + * + * ## METHOD_TYPE_ARGUMENT Example + * + * ```java + * // Explicit type arguments in generic method invocations + * List empty = Collections.emptyList(); + * Map map = Collections.emptyMap(); + * List> nested = Collections.>singletonList(Collections.emptyList()); + * ``` + * + * **What gets extracted:** + * + * - `Collections.emptyList()` → 1 TypeReference: String (context: METHOD_TYPE_ARGUMENT, owner: EXPRESSION) + * - `Collections.emptyMap()` → 2 TypeReferences: String (pos 0), Integer (pos 1) + * - `Collections.>singletonList()` → TypeReference tree: List (depth 0) → String (depth 1) + * + * **Key points:** + * - Owner is the ExpressionReference (the method invocation) + * - Position tracks order of multiple type arguments + * - Complex type arguments create parent-child hierarchies + * + * ## INSTANCEOF_TYPE Example + * + * ```java + * // Simple instanceof + * boolean isString = obj instanceof String; + * boolean isNumber = obj instanceof Number; + * + * // Interface types + * boolean isSerializable = obj instanceof Serializable; + * + * // Array types + * boolean isIntArray = obj instanceof int[]; + * boolean isStringArray = obj instanceof String[][]; + * + * // In expressions + * String result = obj instanceof String ? ((String) obj).toUpperCase() : "default"; + * boolean combined = obj != null && obj instanceof Map; + * ``` + * + * **What gets extracted:** + * + * - `obj instanceof String` → 1 TypeReference: String (context: INSTANCEOF_TYPE, owner: EXPRESSION) + * - `obj instanceof int[]` → 1 TypeReference: int[] (kind: ARRAY) + * - `obj instanceof String[][]` → 1 TypeReference: String[][] (kind: ARRAY, nested) + * + * **Key points:** + * - Owner is the ExpressionReference (the instanceof expression) + * - The operand (`obj`) is extracted as a child expression with edgeRole: INSTANCEOF_OPERAND + * - Array types preserve dimensionality + * - Works in complex expressions (ternary, binary, etc.) + * + * ## OBJECT_CREATION_TYPE Example + * + * ```java + * // Simple object creation + * Object obj = new Object(); + * String str = new String("hello"); + * + * // Generic types with explicit type arguments + * ArrayList list = new ArrayList(); + * HashMap map = new HashMap(); + * + * // Diamond operator (type inferred) + * ArrayList inferred = new ArrayList<>(); + * + * // Nested generics + * HashMap>> complex = new HashMap>>(); + * + * // Fully qualified type names + * java.util.Date date = new java.util.Date(); + * + * // Qualified inner class creation (outer.new Inner) + * Outer outer = new Outer(); + * Outer.Inner inner = outer.new Inner(); + * + * // Generic constructor type arguments (rare) + * GenericCtor gc = new GenericCtor("test"); + * ``` + * + * **What gets extracted:** + * + * - `new Object()` → 1 TypeReference: Object (context: OBJECT_CREATION_TYPE, owner: EXPRESSION) + * - `new ArrayList()` → TypeReference tree: ArrayList (depth 0) → String (depth 1) + * - `new ArrayList<>()` → 1 TypeReference: ArrayList (generic_type with empty type_arguments) + * - `new HashMap()` → TypeReference tree: HashMap → String (pos 0), Integer (pos 1) + * - `outer.new Inner()` → 1 TypeReference: Inner; enclosing instance `outer` is ENCLOSING_INSTANCE child + * - `new GenericCtor()` → 2 TypeReferences: String (METHOD_TYPE_ARGUMENT), GenericCtor (OBJECT_CREATION_TYPE) + * + * **Key points:** + * - Owner is the ExpressionReference (the object creation expression) + * - Constructor arguments are extracted as child expressions with edgeRole: ARGUMENT + * - For qualified inner class creation, the enclosing instance is edgeRole: ENCLOSING_INSTANCE + * - Diamond operator creates a generic_type with empty type_arguments + * - Generic constructor type arguments use METHOD_TYPE_ARGUMENT context (reused) + * + * ## ARRAY_CREATION_TYPE Example + * + * ```java + * // Primitive arrays with size + * int[] ints = new int[3]; + * double[] doubles = new double[10]; + * + * // Reference arrays with size + * String[] strings = new String[5]; + * Object[] objects = new Object[10]; + * + * // Arrays with initializer (explicit new) + * int[] withInit = new int[] { 1, 2, 3 }; + * String[] strInit = new String[] { "a", "b" }; + * + * // Multi-dimensional arrays + * int[][] matrix = new int[2][3]; + * int[][] jagged = new int[2][]; + * int[][] withInit2D = new int[][] { {1, 2}, {3, 4} }; + * + * // Standalone array initializer (no explicit new) + * int[] implicit = { 1, 2, 3 }; + * ``` + * + * **What gets extracted:** + * + * - `new int[3]` → 1 TypeReference: int (PRIMITIVE, context: ARRAY_CREATION_TYPE) + * - `new String[5]` → 1 TypeReference: String (CLASS, context: ARRAY_CREATION_TYPE) + * - `new int[] { 1, 2, 3 }` → TypeReference for int; initializer elements are ARRAY_ELEMENT children + * - `new int[2][3]` → TypeReference for int; size expressions are ARRAY_DIMENSION children + * - `{ 1, 2, 3 }` (standalone) → ARRAY_INITIALIZER expression; no type reference (type from field declaration) + * + * **Key points:** + * - Owner is the ExpressionReference (the array creation expression) + * - The element type (int, String, etc.) is extracted, NOT the array type + * - Size expressions are extracted as children with edgeRole: ARRAY_DIMENSION + * - Initializer elements are extracted as children with edgeRole: ARRAY_ELEMENT + * - Standalone array initializers ({ 1, 2, 3 }) are ARRAY_INITIALIZER expressions, not ARRAY_CREATION + * + * ## SWITCH_TYPE_PATTERN Example (Java 17+) + * + * ```java + * // Type patterns in switch expressions + * String result = switch(obj) { + * case String s when s.length() > 5 -> "long string"; + * case String s when s.isEmpty() -> "empty"; + * case String s -> "short string"; + * case List list -> "list: " + list.size(); + * case Integer i -> "int: " + i; + * default -> "other"; + * }; + * ``` + * + * **What gets extracted:** + * + * - `case String s` → TypeReference: String (context: SWITCH_TYPE_PATTERN, owner: EXPRESSION) + * - `case List list` → TypeReference tree: List (depth 0) → String (depth 1) + * - `case Integer i` → TypeReference: Integer (context: SWITCH_TYPE_PATTERN, owner: EXPRESSION) + * + * **Key points:** + * - Owner is the ExpressionReference (the switch expression) + * - Pattern variable ('s', 'list', 'i') is extracted as SWITCH_TYPE_PATTERN expression child + * - Guard expressions ('s.length() > 5', 's.isEmpty()') are SWITCH_GUARD expression children + * - Generic type patterns create parent-child hierarchies like other contexts + * - Position links SWITCH_TYPE_PATTERN, SWITCH_GUARD, and SWITCH_CASE_RESULT for each case arm + * + * ## RECORD_PATTERN_TYPE and PATTERN_BINDING_TYPE Example (Java 16+) + * + * ```java + * // Record patterns in instanceof expressions + * if (obj instanceof Person(String name, int age)) { + * System.out.println(name + " is " + age); + * } + * + * // Nested record patterns + * if (obj instanceof Employee(String name, int id, Department(String deptName, String code))) { + * System.out.println(name + " in " + deptName); + * } + * ``` + * + * **What gets extracted:** + * + * - `Person(String name, int age)`: + * - Person → TypeReference (context: RECORD_PATTERN_TYPE, owner: EXPRESSION) + * - String → TypeReference (context: PATTERN_BINDING_TYPE, owner: EXPRESSION) + * - int → TypeReference (context: PATTERN_BINDING_TYPE, owner: EXPRESSION) + * + * - `Employee(String name, int id, Department(String deptName, String code))`: + * - Employee → TypeReference (context: RECORD_PATTERN_TYPE) + * - String (for name) → TypeReference (context: PATTERN_BINDING_TYPE) + * - int → TypeReference (context: PATTERN_BINDING_TYPE) + * - Department → TypeReference (context: RECORD_PATTERN_TYPE) - nested + * - String (for deptName) → TypeReference (context: PATTERN_BINDING_TYPE) + * - String (for code) → TypeReference (context: PATTERN_BINDING_TYPE) + * + * **Key points:** + * - RECORD_PATTERN_TYPE is for the record type being matched (Person, Department) + * - PATTERN_BINDING_TYPE is for the declared type of pattern binding variables + * - Owner is the ExpressionReference (the record pattern expression) + * - Pattern binding variables (name, age, etc.) are extracted as IDENTIFIER_REFERENCE with PATTERN_BINDING entity kind + * + * ## LAMBDA_PARAMETER_TYPE Example (Java 8+) + * + * ```java + * // Lambda with typed parameters + * Function lengthFn = (String s) -> s.length(); + * BiFunction adder = (Integer a, Integer b) -> a + b; + * + * // Lambda with generic typed parameters + * Function, Integer> sizeFunc = (List items) -> items.size(); + * + * // Lambda with array typed parameter + * Function joiner = (String[] arr) -> String.join(",", arr); + * ``` + * + * **What gets extracted:** + * + * - `(String s) -> s.length()`: + * - String → TypeReference (context: LAMBDA_PARAMETER_TYPE, owner: EXPRESSION) + * + * - `(Integer a, Integer b) -> a + b`: + * - Integer (for a) → TypeReference (context: LAMBDA_PARAMETER_TYPE, position: 0) + * - Integer (for b) → TypeReference (context: LAMBDA_PARAMETER_TYPE, position: 1) + * + * - `(List items) -> items.size()`: + * - List → TypeReference (context: LAMBDA_PARAMETER_TYPE, kind: PARAMETERIZED) + * - String → TypeReference (depth: 1, parent: List) + * + * **Key points:** + * - Owner is the ExpressionReference (the lambda expression) + * - Only explicitly typed parameters create type references (inferred params like `(x, y) -> x + y` do not) + * - Generic parameter types create parent-child hierarchies + * - Lambda parameter names are extracted as IDENTIFIER_REFERENCE with LAMBDA_PARAMETER entity kind + * + * ## Context-Specific Behavior + * + * - **Multiple Bounds:** `T extends A & B` creates 2 entries with same typeParameterLinkHash, positions 0,1 + * - **Generic Nesting:** `List>` creates parent-child tree: List → Map → K,V + * - **Wildcard Bounds:** `? extends T` creates WILDCARD entry with EXTENDS variance + child for T + * - **Method Linking:** All METHOD_RETURN/METHOD_PARAM entries share same methodRegistryLinkHash + * - **Position Ordering:** Preserves source order for implements, permits, method parameters + * - **Method Type Arguments:** `Collections.emptyMap()` creates 2 METHOD_TYPE_ARGUMENT entries at positions 0,1 + */ +export enum TypeRefContext { + /** Type bound in a class/interface generic parameter declaration (e.g., class Box). */ + TYPE_PARAM_BOUND = 'TYPE_PARAM_BOUND', + + /** Type bound in a method generic parameter declaration (e.g., void process(T item)). */ + METHOD_TYPE_PARAM_BOUND = 'METHOD_TYPE_PARAM_BOUND', + + /** Superclass or extended type in a class or record declaration. */ + SUPER_TYPE = 'SUPER_TYPE', + + /** Implemented interface in a class or record declaration. */ + IMPLEMENTS_INTERFACE = 'IMPLEMENTS_INTERFACE', + + /** Permitted subtype in a sealed class or interface declaration. */ + PERMITS = 'PERMITS', + + /** Declared type of a class or instance field. */ + FIELD_TYPE = 'FIELD_TYPE', + + /** Declared return type of a method or constructor. */ + METHOD_RETURN = 'METHOD_RETURN', + + /** Declared type of a formal parameter in a method or constructor. */ + METHOD_PARAM = 'METHOD_PARAM', + + /** Declared type of a parameter in an annotation element. */ + ANNOTATION_PARAM = 'ANNOTATION_PARAM', + + /** Type referenced in an annotation applied to a type parameter (e.g., class Box<@Valid(validator = SizeValidator.class) T>). */ + TYPE_PARAMETER_ANNOTATION = 'TYPE_PARAMETER_ANNOTATION', + + /** Declared type in a throws clause of a method or constructor. */ + THROWS_CLAUSE = 'THROWS_CLAUSE', + + /** Declared type of a local variable within a block or method scope. */ + LOCAL_VARIABLE = 'LOCAL_VARIABLE', + + /** Target type used in an explicit cast expression. */ + CAST_EXPRESSION = 'CAST_EXPRESSION', + + /** Type argument in a generic method invocation (e.g., Collections.emptyList()). */ + METHOD_TYPE_ARGUMENT = 'METHOD_TYPE_ARGUMENT', + + /** Type tested in an instanceof expression (e.g., obj instanceof String, obj instanceof Map). */ + INSTANCEOF_TYPE = 'INSTANCEOF_TYPE', + + /** Type instantiated in an object creation expression (e.g., new ArrayList(), new HashMap()). */ + OBJECT_CREATION_TYPE = 'OBJECT_CREATION_TYPE', + + /** Element type in an array creation expression (e.g., int in new int[3], String in new String[] { ... }). */ + ARRAY_CREATION_TYPE = 'ARRAY_CREATION_TYPE', + + /** Type qualifier in a method reference expression (e.g., String in String::valueOf, String[] in String[]::new, List in List::new). */ + METHOD_REFERENCE_QUALIFIER = 'METHOD_REFERENCE_QUALIFIER', + + /** Type pattern in switch case (e.g., String in case String s -> ...). Java 17+. */ + SWITCH_TYPE_PATTERN = 'SWITCH_TYPE_PATTERN', + + /** Declared type of a pattern binding variable in a record pattern (e.g., String in Person(String name, int age)). Java 16+. */ + PATTERN_BINDING_TYPE = 'PATTERN_BINDING_TYPE', + + /** Record type being matched in a record pattern (e.g., Person in obj instanceof Person(String name, int age)). Java 16+. */ + RECORD_PATTERN_TYPE = 'RECORD_PATTERN_TYPE', + + /** Declared type of a lambda parameter (e.g., String in (String s) -> s.length()). Java 8+. */ + LAMBDA_PARAMETER_TYPE = 'LAMBDA_PARAMETER_TYPE', + + /** The annotation type itself (e.g., RequestMapping in @RequestMapping("/api"), Override in @Override). Links annotation usage to its type definition. */ + ANNOTATION_TYPE = 'ANNOTATION_TYPE', +} diff --git a/parser/src/enums/java/type-references/TypeRefKind.ts b/parser/src/enums/java/type-references/TypeRefKind.ts new file mode 100644 index 000000000..718582779 --- /dev/null +++ b/parser/src/enums/java/type-references/TypeRefKind.ts @@ -0,0 +1,97 @@ +/** + * Classifies the structural form of a type reference in Java source code. + * + * Provides information about what the structural form the type has. + * + * This enumeration distinguishes between different syntactic forms that type + * references can take, enabling precise parsing and representation of Java's + * type system within the dependency analysis framework. + * + * ## Type Reference Forms + * + * - **CLASS** – Simple class or interface reference (`String`, `List`) + * - **PARAMETERIZED** – Generic type with arguments (`List`, `Map`) + * - **WILDCARD** – Wildcard type argument (`?`, `? extends T`, `? super T`) + * - **TYPE_VARIABLE** – Reference to a type parameter (`T`, `K`, `V`) + * - **ARRAY** – Array type reference (`String[]`, `int[][]`, `T[]`) + * - **PRIMITIVE** – Primitive type reference (`int`, `boolean`, `char`) + * + * ## Classification Examples + * + * ```java + * // CLASS - Simple type references + * String name; // CLASS: String + * List list; // CLASS: List (raw type) + * CustomService service; // CLASS: CustomService + * + * // PARAMETERIZED - Generic types with arguments + * List names; // PARAMETERIZED: List + * // └─ Child: CLASS: String + * Map counts; // PARAMETERIZED: Map + * // ├─ Child: CLASS: String + * // └─ Child: CLASS: Integer + * + * // WILDCARD - Wildcard type arguments + * List unknown; // PARAMETERIZED: List + * // └─ Child: WILDCARD: ? (UNBOUNDED) + * List nums; // PARAMETERIZED: List + * // └─ Child: WILDCARD: ? extends + * // └─ Child: CLASS: Number + * + * // TYPE_VARIABLE - References to type parameters + * class Container { + * private T value; // TYPE_VARIABLE: T + * List items; // PARAMETERIZED: List + * // └─ Child: TYPE_VARIABLE: T + * } + * + * // TYPE_VARIABLE as bound - with variance + * public void method(U item) { } + * // TYPE_VARIABLE: T with EXTENDS variance (in METHOD_TYPE_PARAM_BOUND context) + * + * // ARRAY - Array type references + * String[] names; // ARRAY: String[] (1 dimension) + * int[][] matrix; // ARRAY: int[][] (2 dimensions) + * T[] genericArray; // ARRAY: T[] (generic array) + * + * // PRIMITIVE - Primitive type references + * int count; // PRIMITIVE: int + * boolean flag; // PRIMITIVE: boolean + * char letter; // PRIMITIVE: char + * + * // COMPLEX COMBINATIONS + * Map[]> complex; + * // PARAMETERIZED: Map[]> + * // ├─ Child: CLASS: String + * // └─ Child: ARRAY: List[] + * // └─ Child: PARAMETERIZED: List + * // └─ Child: WILDCARD: ? extends + * // └─ Child: TYPE_VARIABLE: T + * ``` + * + * ## Implementation Guidelines + * + * - **Hierarchy:** PARAMETERIZED types create parent-child relationships for their arguments + * - **Nesting:** Complex types can nest multiple kinds (ARRAY of PARAMETERIZED with WILDCARD) + * - **Context Independence:** Same kind can appear in different TypeRefContext values + * - **Primitive Handling:** Only use PRIMITIVE for actual primitive types, not their wrapper classes + */ +export enum TypeRefKind { + /** Simple class or interface reference (String, List, CustomType). */ + CLASS = 'CLASS', + + /** Generic type with type arguments (List, Map). */ + PARAMETERIZED = 'PARAMETERIZED', + + /** Wildcard type argument (?, ? extends T, ? super T). */ + WILDCARD = 'WILDCARD', + + /** Reference to a declared type parameter (T, K, V). May have wildcardVariance when used as a bound. */ + TYPE_VARIABLE = 'TYPE_VARIABLE', + + /** Array type reference (String[], int[][], T[]). */ + ARRAY = 'ARRAY', + + /** Primitive type reference (int, boolean, char, double). */ + PRIMITIVE = 'PRIMITIVE', +} diff --git a/parser/src/enums/java/type-references/WildcardVariance.ts b/parser/src/enums/java/type-references/WildcardVariance.ts new file mode 100644 index 000000000..fcb0ce33d --- /dev/null +++ b/parser/src/enums/java/type-references/WildcardVariance.ts @@ -0,0 +1,58 @@ +/** + * Specifies the variance constraint for bounded type references. + * + * This enumeration captures the bound relationships supported by Java's generic + * type system, enabling precise representation of variance relationships in: + * - **Wildcard type arguments** (`? extends T`, `? super T`) + * - **Type parameter bounds** (`` in method/class declarations) + * + * ## Variance Forms + * + * - **UNBOUNDED** – No constraint, accepts any type (`List`) + * - **EXTENDS** – Upper bound constraint (`? extends Number`, ``) + * - **SUPER** – Lower bound constraint (`? super Integer`) + * + * ## Examples + * + * ```java + * // WILDCARD with UNBOUNDED - can read Object, cannot write safely + * List unknownList = Arrays.asList("hello", 123, true); + * + * // WILDCARD with EXTENDS - upper bounded wildcard + * List numbers = Arrays.asList(1, 2.5, 3L); + * + * // WILDCARD with SUPER - lower bounded wildcard + * List integers = new ArrayList(); + * + * // TYPE_VARIABLE with EXTENDS - type parameter bound + * public Map transform(List items) { } + * // Here T is extracted as TYPE_VARIABLE with EXTENDS variance + * + * // Complex nesting with type variables + * Map> complexMap; + * ``` + * + * ## Implementation Notes + * + * - **Bound Representation:** The bound type (if any) creates a child TypeReference entry + * - **Nesting Support:** Wildcards can contain other wildcards or type variables + * - **Context Independence:** Same variance rules apply across all TypeRefContext values + * + * @remarks Applicable when TypeRefKind is WILDCARD or when a TYPE_VARIABLE/CLASS + * appears in a TYPE_PARAM_BOUND or METHOD_TYPE_PARAM_BOUND context. + * For bounded types, the bound appears as a child entry with + * parentRefHash pointing to the parent entry. + * + * @see TypeRefKind + * @see TypeRefContext + */ +export enum WildcardVariance { + /** Unbounded wildcard with no constraints (?). */ + UNBOUNDED = 'UNBOUNDED', + + /** Upper-bounded wildcard (? extends Type). */ + EXTENDS = 'EXTENDS', + + /** Lower-bounded wildcard (? super Type). */ + SUPER = 'SUPER', +} diff --git a/parser/src/enums/java/type-references/index.ts b/parser/src/enums/java/type-references/index.ts new file mode 100644 index 000000000..94bdca848 --- /dev/null +++ b/parser/src/enums/java/type-references/index.ts @@ -0,0 +1,4 @@ +export { TypeRefKind } from '@/enums/java/type-references/TypeRefKind'; +export { TypeRefContext } from '@/enums/java/type-references/TypeRefContext'; +export { ReferenceOwnerKind } from '@/enums/java/type-references/ReferenceOwnerKind'; +export { WildcardVariance } from '@/enums/java/type-references/WildcardVariance'; diff --git a/parser/src/enums/java/types/TypeAccess.ts b/parser/src/enums/java/types/TypeAccess.ts new file mode 100644 index 000000000..4e82d90a8 --- /dev/null +++ b/parser/src/enums/java/types/TypeAccess.ts @@ -0,0 +1,50 @@ +/** + * ### Supported Access Levels: + * - **PUBLIC_ACCESS** - Type is accessible from any package (`public` modifier) + * - **PROTECTED_ACCESS** - Type is accessible within package and subclasses (`protected` modifier) + * - **PRIVATE_ACCESS** - Type is accessible only within enclosing class (`private` modifier) + * - **PACKAGE_ACCESS** - Type is accessible only within same package (no explicit modifier) + * + * ### Java Language Rules Applied: + * - Top-level types can only be `public` or package-private (default) + * - Nested types can have any access modifier (`public`, `protected`, `private`, or package-private) + * - Local types (inside methods) cannot have explicit access modifiers + * - When no explicit modifier is present, package-private access is assumed + * + * ### Examples: + * ```java + * // PUBLIC_ACCESS - Accessible from any microservice + * public class PaymentProcessor {} + * public interface PaymentGateway {} + * public enum PaymentStatus {} + * public record PaymentRequest() {} + * + * // PACKAGE_ACCESS (default) - Internal to microservice + * class InternalPaymentValidator {} + * interface InternalAuditLogger {} + * enum InternalErrorCode {} + * record InternalTransactionData() {} + * + * // Nested types with various access levels + * public class PaymentService { + * public static class PublicPaymentResult {} // PUBLIC_ACCESS - External API + * protected class ProtectedPaymentCache {} // PROTECTED_ACCESS - Subclass access + * private enum PrivatePaymentState {} // PRIVATE_ACCESS - Internal only + * static record InternalPaymentLog() {} // PACKAGE_ACCESS - Service internal + * } + * + * // Local types (treated as package access for analysis) + * public class PaymentController { + * void processPayment() { + * class LocalPaymentValidator {} // Analyzed as PACKAGE_ACCESS + * record LocalPaymentData() {} // Analyzed as PACKAGE_ACCESS + * } + * } + * ``` + */ +export enum TypeAccess { + PUBLIC_ACCESS = 'PUBLIC_ACCESS', + PROTECTED_ACCESS = 'PROTECTED_ACCESS', + PRIVATE_ACCESS = 'PRIVATE_ACCESS', + PACKAGE_ACCESS = 'PACKAGE_ACCESS', +} diff --git a/parser/src/enums/java/types/TypeCategory.ts b/parser/src/enums/java/types/TypeCategory.ts new file mode 100644 index 000000000..460614fb6 --- /dev/null +++ b/parser/src/enums/java/types/TypeCategory.ts @@ -0,0 +1,56 @@ +/** + * ### Supported Type Categories: + * - **CLASS_TYPE** - Regular class that can be instantiated and extended + * - **INTERFACE_TYPE** - Contract definition that must be implemented + * - **ENUM_TYPE** - Fixed set of constants with type safety (Java 5+) + * - **RECORD_TYPE** - Immutable data carrier with auto-generated methods (Java 14+) + * - **ANNOTATION_TYPE** - Reserved for future use or general annotation classification + * - **ANNOTATION_INTERFACE_TYPE** - Annotation type declarations using @interface syntax + * + * ### Examples by Type Category: + * ```java + * // CLASS_TYPE - Regular instantiable classes + * public class PaymentService {} + * public abstract class BasePaymentProcessor {} + * public final class PaymentValidator {} + * + * // INTERFACE_TYPE - Contract definitions + * public interface PaymentGateway { + * PaymentResult process(PaymentRequest request); + * } + * + * // ENUM_TYPE - Type-safe constants + * public enum PaymentStatus { + * PENDING, PROCESSING, COMPLETED, FAILED; + * } + * + * // RECORD_TYPE - Immutable data carriers (Java 14+) + * public record PaymentRequest( + * String merchantId, + * BigDecimal amount, + * Currency currency + * ) {} + * + * // ANNOTATION_INTERFACE_TYPE - Annotation type declarations (@interface) + * // These are interfaces that extend java.lang.annotation.Annotation + * @Target(ElementType.METHOD) + * @Retention(RetentionPolicy.RUNTIME) + * public @interface PaymentEndpoint { + * String value() default ""; + * } + * ``` + * + * ### Note on ANNOTATION_INTERFACE_TYPE: + * In Java, `@interface` declarations are syntactic sugar. At the bytecode level: + * - They ARE interfaces that implicitly extend java.lang.annotation.Annotation + * - The @interface keyword is syntactic sugar for this special interface type + * - ANNOTATION_INTERFACE_TYPE distinguishes these from regular INTERFACE_TYPE + */ +export enum TypeCategory { + CLASS_TYPE = 'CLASS_TYPE', + INTERFACE_TYPE = 'INTERFACE_TYPE', + ENUM_TYPE = 'ENUM_TYPE', + RECORD_TYPE = 'RECORD_TYPE', + ANNOTATION_TYPE = 'ANNOTATION_TYPE', + ANNOTATION_INTERFACE_TYPE = 'ANNOTATION_INTERFACE_TYPE', +} diff --git a/parser/src/enums/java/types/TypeModifier.ts b/parser/src/enums/java/types/TypeModifier.ts new file mode 100644 index 000000000..b7aaf2207 --- /dev/null +++ b/parser/src/enums/java/types/TypeModifier.ts @@ -0,0 +1,59 @@ +/** + * ### Supported Type Modifiers: + * - **STATIC_MODIFIER** - Type doesn't require outer class instance (nested classes only) + * - **ABSTRACT_MODIFIER** - Type cannot be instantiated directly, must be subclassed + * - **FINAL_MODIFIER** - Type cannot be extended/subclassed + * - **STRICTFP_MODIFIER** - Floating-point calculations use strict IEEE 754 semantics + * - **SEALED_MODIFIER** - Type restricts which classes can extend it (Java 17+) + * - **NON_SEALED_MODIFIER** - Sealed subclass that allows further extension (Java 17+) + * - **DEPRECATED_MODIFIER** - Type is marked for removal or discouraged use + * + * ### Examples by Modifier Type: + * ```java + * // STATIC_MODIFIER - Nested class without outer instance dependency + * public class PaymentService { + * public static class PaymentResult {} // Can be instantiated independently + * } + * + * // ABSTRACT_MODIFIER - Cannot be instantiated directly + * public abstract class BasePaymentProcessor { + * public abstract void process(); // Must be implemented by subclasses + * } + * + * // FINAL_MODIFIER - Cannot be extended + * public final class ImmutablePaymentData { // Prevents subclassing for security + * // Implementation details + * } + * + * // SEALED_MODIFIER - Controlled inheritance (Java 17+) + * public sealed class PaymentMethod + * permits CreditCard, DebitCard, DigitalWallet { + * // Only specified classes can extend this + * } + * + * // NON_SEALED_MODIFIER - Allows further extension in sealed hierarchy + * public non-sealed class DigitalWallet extends PaymentMethod { + * // Can be extended by other classes + * } + * + * // DEPRECATED_MODIFIER - Marked for removal + * @Deprecated(since = "2.0", forRemoval = true) + * public class LegacyPaymentProcessor { // Should be migrated away from + * // Legacy implementation + * } + * + * // STRICTFP_MODIFIER - Strict floating-point semantics + * public strictfp class FinancialCalculator { // Ensures consistent math across platforms + * public double calculateInterest() { ... } + * } + * ``` + */ +export enum TypeModifier { + ABSTRACT_MODIFIER = 'ABSTRACT_MODIFIER', + FINAL_MODIFIER = 'FINAL_MODIFIER', + STRICTFP_MODIFIER = 'STRICTFP_MODIFIER', + STATIC_MODIFIER = 'STATIC_MODIFIER', + SEALED_MODIFIER = 'SEALED_MODIFIER', + NON_SEALED_MODIFIER = 'NON_SEALED_MODIFIER', + DEPRECATED_MODIFIER = 'DEPRECATED_MODIFIER', +} diff --git a/parser/src/enums/java/types/TypePlacement.ts b/parser/src/enums/java/types/TypePlacement.ts new file mode 100644 index 000000000..003b7dca9 --- /dev/null +++ b/parser/src/enums/java/types/TypePlacement.ts @@ -0,0 +1,60 @@ +/** + * ### Supported Type Placements: + * - **TOP_LEVEL_PLACEMENT** - Type declared at package level (most common, widest visibility) + * - **STATIC_NESTED_PLACEMENT** - Static nested type within another class (no outer instance dependency) + * - **INNER_PLACEMENT** - Non-static inner class with access to outer instance members + * - **LOCAL_PLACEMENT** - Type declared within a method, constructor, or initializer block + * - **ANONYMOUS_PLACEMENT** - Anonymous class implementation (often lambda alternatives) + * + * ### Examples by Placement: + * ```java + * // TOP_LEVEL_PLACEMENT - Package-level types + * public class PaymentService {} + * interface PaymentGateway {} + * enum PaymentStatus {} + * record PaymentRequest() {} + * + * // STATIC_NESTED_PLACEMENT - No outer instance dependency + * public class PaymentProcessor { + * public static class PaymentResult {} // Explicit static + * public interface PaymentCallback {} // Implicitly static + * public enum PaymentMethod {} // Implicitly static + * public record PaymentData() {} // Implicitly static + * public @interface PaymentAnnotation {} // Implicitly static + * } + * + * // INNER_PLACEMENT - Has access to outer instance + * public class PaymentService { + * private String serviceId; + * + * public class PaymentValidator { // Non-static inner class + * public boolean validate() { + * return serviceId != null; // Can access outer fields + * } + * } + * } + * + * // LOCAL_PLACEMENT - Method/block scoped + * public class PaymentController { + * public void processPayment() { + * class LocalValidator {} // Local class + * record LocalPaymentData() {} // Local record + * } + * } + * + * // ANONYMOUS_PLACEMENT - Anonymous implementations + * public class PaymentService { + * PaymentCallback callback = new PaymentCallback() { // Anonymous class + * @Override + * public void onComplete() { ... } + * }; + * } + * ``` + */ +export enum TypePlacement { + TOP_LEVEL_PLACEMENT = 'TOP_LEVEL_PLACEMENT', + STATIC_NESTED_PLACEMENT = 'STATIC_NESTED_PLACEMENT', + INNER_PLACEMENT = 'INNER_PLACEMENT', + LOCAL_PLACEMENT = 'LOCAL_PLACEMENT', + ANONYMOUS_PLACEMENT = 'ANONYMOUS_PLACEMENT', +} diff --git a/parser/src/enums/java/types/index.ts b/parser/src/enums/java/types/index.ts new file mode 100644 index 000000000..97503ebd2 --- /dev/null +++ b/parser/src/enums/java/types/index.ts @@ -0,0 +1,4 @@ +export { TypeAccess } from '@/enums/java/types/TypeAccess'; +export { TypeCategory } from '@/enums/java/types/TypeCategory'; +export { TypeModifier } from '@/enums/java/types/TypeModifier'; +export { TypePlacement } from '@/enums/java/types/TypePlacement'; diff --git a/parser/src/enums/javascript/blocks/JsBlockKind.ts b/parser/src/enums/javascript/blocks/JsBlockKind.ts new file mode 100644 index 000000000..a57bc3474 --- /dev/null +++ b/parser/src/enums/javascript/blocks/JsBlockKind.ts @@ -0,0 +1,66 @@ +/** + * A lexical block's form. Schema §3.12 c0. + * + * ## A block is syntax; a scope is binding + * + * They are separate relations because they are not in 1:1 correspondence. A bare + * `{}` containing only `var` declarations is a block that opens **no scope**; a + * function's parameter list and body are one scope spanning two syntactic + * regions. `js_block.opensScope` and `scopeLinkHash` carry the join, and a `""` + * there is a real answer rather than a missing one. + * + * ## Every block form gets a row, including the ones that emit nothing else + * + * TypeScript's enum audit found `NAMESPACE_BODY` and `MODULE_BODY` producing + * **no block row at all**, on two separate early-return paths, and nothing else + * caught it because no row was misplaced — there simply were none. `LABELED` is + * the same lesson in the other direction: `outer: for (…)` emitted the `FOR` and + * **dropped the label**, so a `continue outer` had no target to join to. + */ +export enum JsBlockKind { + /** A function, arrow, method or accessor body. */ + FUNCTION_BODY = 'FUNCTION_BODY', + + /** A bare `{ … }`. */ + BLOCK = 'BLOCK', + + IF = 'IF', + ELSE = 'ELSE', + FOR = 'FOR', + FOR_IN = 'FOR_IN', + FOR_OF = 'FOR_OF', + WHILE = 'WHILE', + DO = 'DO', + TRY = 'TRY', + CATCH = 'CATCH', + FINALLY = 'FINALLY', + SWITCH = 'SWITCH', + + /** + * One `case`/`default` clause. + * + * A clause is a block for structure and **not** a scope: every clause in one + * `switch` shares one scope, so `case 1: let x = 1; case 2: x;` refers to one + * binding. Emitting a scope per clause would make the second reference + * unresolved. + */ + SWITCH_CASE = 'SWITCH_CASE', + + /** + * `outer: for (…)`. + * + * The value TypeScript's audit found unemitted while the loop emitted fine. + * `js_block.label` is why that cannot happen here: without it a + * `break outer` names a target nothing in the fact base identifies. + */ + LABELED = 'LABELED', + + /** A class body. */ + CLASS_BODY = 'CLASS_BODY', + + /** `static { … }`. */ + CLASS_STATIC_BLOCK = 'CLASS_STATIC_BLOCK', + + /** The file's top level, so top-level statements have a block to belong to. */ + MODULE_BODY = 'MODULE_BODY', +} diff --git a/parser/src/enums/javascript/blocks/index.ts b/parser/src/enums/javascript/blocks/index.ts new file mode 100644 index 000000000..c4df4a46a --- /dev/null +++ b/parser/src/enums/javascript/blocks/index.ts @@ -0,0 +1 @@ +export * from './JsBlockKind'; diff --git a/parser/src/enums/javascript/call-sites/JsCallKind.ts b/parser/src/enums/javascript/call-sites/JsCallKind.ts new file mode 100644 index 000000000..00106c4b2 --- /dev/null +++ b/parser/src/enums/javascript/call-sites/JsCallKind.ts @@ -0,0 +1,168 @@ +/** + * Every way JavaScript invokes something. Schema §3.11 c0 and §2.4. + * + * ## Enumerated against the corpus before the relation was declared + * + * §5 of `BUILDING-A-PARSER.md`: *every language has more ways to invoke than + * "call a method", and each one an engine cannot distinguish is a class of edge + * it will get wrong.* The counts below are measured over 2,738 files, and they + * are here because the shape of the distribution is itself a finding: method + * calls and plain function calls are 86 of every 100 sites, and everything + * interesting is in the remaining 14. + * + * ## `require()` is NOT here + * + * It is a **module edge**, by ruling. 9,055 of them were measured, and counting + * them as unresolved call sites is what made the raw resolution figure look + * worse than it is — they were listed as declines in a table whose denominator + * they did not belong in. A `require` produces a `js_import` row and a + * `js_expression` row, and no `js_call_site` row at all. + * + * ## Five values are RESERVED with a zero-row assertion + * + * Each is a fact about a value's **runtime identity**, not about the syntax in + * front of you, and `INDEX_CALL` is the precedent: guessing is wrong more often + * than it is right. The enum-emission audit carries them on an explicit + * allowlist, each asserted to have zero rows, so the day one is switched on it + * shows up as a named gate failure rather than as new rows nobody noticed. + */ +export enum JsCallKind { + /** `obj.m()`. **46,726 measured** — the most common thing in the language. */ + METHOD_CALL = 'METHOD_CALL', + + /** `fn()`. 39,334. A bare identifier callee, so the binder decides the target. */ + FUNCTION_CALL = 'FUNCTION_CALL', + + /** `new F()`. 7,964. */ + CONSTRUCTOR_CALL = 'CONSTRUCTOR_CALL', + + /** + * `obj[expr]()`. 663. + * + * The name is **not fixed by syntax**, so `calleeName` is `""`. That is not a + * gap: the row is *complete because it says so*, and the IR-completeness gate + * treats it as an honest terminal rather than a miss. + */ + COMPUTED_CALL = 'COMPUTED_CALL', + + /** + * `f.call(receiver, …)`. 541. + * + * **The receiver is argument 0.** An engine reading the syntactic receiver + * gets `Function.prototype.call` as the target and the real receiver not at + * all, which is why `receiverPosition` exists as a column. + */ + FUNCTION_CALL_CALL = 'FUNCTION_CALL_CALL', + + /** `f.apply(receiver, args)`. 318. Same receiver displacement, spread arguments. */ + FUNCTION_CALL_APPLY = 'FUNCTION_CALL_APPLY', + + /** + * `f.bind(receiver, …)`. 189. + * + * **Produces a function; does not invoke one.** Emitted as a call site because + * `.bind` itself is called, and distinguished because an engine that treats it + * as an invocation of `f` reports an edge that does not happen at this point in + * the program. `js_method.thisBinding = BOUND` is the other half. + */ + FUNCTION_CALL_BIND = 'FUNCTION_CALL_BIND', + + /** `super()`. 517. */ + SUPER_CALL = 'SUPER_CALL', + + /** + * `(function () { … })()`. 113. + * + * The pre-ES6 module pattern, and the reason the expression walker must + * descend through parentheses: the call, the parenthesis and the function all + * begin at the same offset, and a subtree rooted at the non-emitting + * parenthesis dies before its children are enqueued — which cost TypeScript + * 1,808 expressions. + */ + IIFE_CALL = 'IIFE_CALL', + + /** `obj?.m()`. 28. Differs from `METHOD_CALL` in reachability, not in target. */ + OPTIONAL_CALL = 'OPTIONAL_CALL', + + /** ``tag`…` ``. 20. Invokes `tag` with the template's pieces. */ + TAGGED_TEMPLATE_CALL = 'TAGGED_TEMPLATE_CALL', + + /** + * `eval(…)` or `new Function(…)`. 2 measured. + * + * The target is **unknowable**, and the row says so. Emitted rather than + * dropped: a fact base that omits it asserts the program has no dynamic code, + * which is a stronger claim than admitting one call cannot be followed. + * `isDynamicCode` is the column a consumer filters on. + */ + DYNAMIC_CODE_CALL = 'DYNAMIC_CODE_CALL', + + /** + * `import('m')`. + * + * **Also a module edge.** It produces a `js_import` row with + * `importForm = DYNAMIC_IMPORT` *and* a call-site row, because unlike + * `require` it is a genuine expression returning a promise — the call happens + * and its result flows somewhere. + */ + DYNAMIC_IMPORT_CALL = 'DYNAMIC_IMPORT_CALL', + + // ----------------------------------------------------------------- reserved + + /** + * **RESERVED — zero rows.** A call through an index signature. + * + * Inherited from TypeScript, where it is described as "a resolution outcome + * about the receiver's TYPE, not readable from syntax". `ops[name](a, b)` is a + * `COMPUTED_CALL`; whether it resolves *through an index signature* is a fact + * about the receiver's type and nothing the parser can see. + */ + INDEX_CALL = 'INDEX_CALL', + + /** + * **RESERVED — zero rows.** A property read that invokes a getter. + * + * 1,225 getters are *declared* in the corpus and **0** invocations are + * emittable, because `obj.x` invokes a function only if `x` is an accessor on + * whatever `obj` turns out to be — a fact about the object, not the + * expression. `js_field.accessorPairKind` records the declaration side, which + * is the half syntax can answer. + */ + GETTER_INVOCATION = 'GETTER_INVOCATION', + + /** **RESERVED — zero rows.** `obj.x = v` invoking a setter. Same argument. */ + SETTER_INVOCATION = 'SETTER_INVOCATION', + + /** + * **RESERVED — zero rows.** A property access that hits a `Proxy` trap. + * + * *Any* property access on a proxy may invoke a function. Whether a given + * object is a proxy is a runtime fact, so emitting this would mean guessing on + * every member access in the program. + */ + PROXY_TRAP_CALL = 'PROXY_TRAP_CALL', + + /** + * **RESERVED — zero rows.** `.next()` resuming a suspended generator frame. + * + * Syntactically an ordinary `METHOD_CALL`. That it resumes a frame rather than + * entering one depends on what the receiver is, and the control flow it + * implies is nothing like a call. + */ + GENERATOR_RESUME = 'GENERATOR_RESUME', +} + +/** + * The reserved values, as data. + * + * Exported so the enum-emission audit's allowlist is read from here rather than + * re-typed in the gate. A list maintained in two places drifts, and the + * direction it drifts is always "the gate stops checking something". + */ +export const RESERVED_CALL_KINDS: readonly JsCallKind[] = [ + JsCallKind.INDEX_CALL, + JsCallKind.GETTER_INVOCATION, + JsCallKind.SETTER_INVOCATION, + JsCallKind.PROXY_TRAP_CALL, + JsCallKind.GENERATOR_RESUME, +]; diff --git a/parser/src/enums/javascript/call-sites/JsCallResolutionOutcome.ts b/parser/src/enums/javascript/call-sites/JsCallResolutionOutcome.ts new file mode 100644 index 000000000..e856963c3 --- /dev/null +++ b/parser/src/enums/javascript/call-sites/JsCallResolutionOutcome.ts @@ -0,0 +1,77 @@ +/** + * What the engine will have to do with this call site. Schema §3.11 c13. + * + * ## Mirrors the oracle's partition so a gate can compare like with like + * + * The oracle emits a three-way partition — `RESOLVED`, `SYNTHESIZED`, + * `ANY_SIGNATURE` — and only the first two may authorise an expectation. This + * column is the parser's side of that comparison. Without it a gate would be + * comparing "the parser emitted a row" against "tsc resolved a signature", + * which are different questions, and the difference would read as a defect + * population that does not exist. + * + * ## The one value that is not about this project at all + * + * `AMBIENT_BUILTIN_TARGET` says the target is in the `lib_*` population and + * therefore **not in this project**. 15.3-24.4% of oracle declines are this, and the + * schema's own correction is worth repeating: an earlier draft said JavaScript + * has no large environmental class, on the strength of `node_modules` mattering + * only 1.5%. That measurement was right and the conclusion was too broad. Node + * builtins with no ambient declarations are a second, larger class — and no + * amount of installing dependencies in the analysed repo fixes it. + */ +export enum JsCallResolutionOutcome { + /** + * The receiver's declaration is in this file and the parser named it. + * + * The only outcome where a same-file one-hop link is legitimately populated. + */ + SAME_FILE_RESOLVED = 'SAME_FILE_RESOLVED', + + /** + * The receiver came through an import, and the import row carries + * `resolvedFilePath`. + * + * **Complete, not resolved.** The three things §0 of `BUILDING-A-PARSER.md` + * says an engine needs — the name as written, the importing module, and the + * resolved path — are all present, and the parser stops there on purpose. + */ + IMPORT_HOP_AVAILABLE = 'IMPORT_HOP_AVAILABLE', + + /** + * The target is an ambient or platform declaration: `lib.*.d.ts`, a Node + * builtin. + * + * Not in this project and not stageable from it. + * + * ## A RANGE, because it is not a language constant + * + * The schema measured 24.4% of oracle declines as this class; js-corpus + * re-derived the partition on a different corpus and got 15.3%. Neither is + * wrong. The figure tracks **how much CommonJS a corpus holds** — the schema's + * was three CommonJS-heavy packages at 84.3% CommonJS, js-corpus's is 55% and includes + * three ESM packages that never require anything. + * + * Recorded as a range because a single number here reads as a property of + * JavaScript, and the next person to quote it will be quoting a property of + * somebody's package selection. The three-class partition itself DOES + * reproduce, and that is the durable finding: environmental-and-fixable, + * environmental-but-unfixable, and language-intrinsic are real and distinct. + */ + AMBIENT_BUILTIN_TARGET = 'AMBIENT_BUILTIN_TARGET', + + /** + * The receiver has no type from any channel. + * + * Language-intrinsic rather than environmental — the file is there and carries + * no types. 8.5% of declines are the narrower version of this where the module + * resolved and turned out to be untyped JavaScript. + */ + RECEIVER_UNTYPED = 'RECEIVER_UNTYPED', + + /** `obj[expr]()` — the name is not fixed by syntax. Complete *because* it says so. */ + COMPUTED_NAME = 'COMPUTED_NAME', + + /** `eval`, `new Function` — the target is unknowable. Also complete by saying so. */ + DYNAMIC_CODE = 'DYNAMIC_CODE', +} diff --git a/parser/src/enums/javascript/call-sites/JsReceiverPosition.ts b/parser/src/enums/javascript/call-sites/JsReceiverPosition.ts new file mode 100644 index 000000000..990803eb5 --- /dev/null +++ b/parser/src/enums/javascript/call-sites/JsReceiverPosition.ts @@ -0,0 +1,30 @@ +/** + * Where the receiver actually is. Schema §3.11 c4. + * + * ## This column exists for 1,048 call sites and it is not a rounding error + * + * `f.call(obj, a)` and `f.apply(obj, args)` move the receiver into an + * **argument**. An engine reading the syntactic receiver of `f.call(obj, a)` + * gets `f` — or worse, resolves `.call` and gets `Function.prototype.call` — and + * the real receiver `obj` is not consulted at all. That is a wrong edge, not a + * missing one. + * + * `receiverExpressionLinkHash` points at the **real** receiver wherever it sits, + * and this column says where that was, so a consumer never has to re-derive it + * from the call kind. + */ +export enum JsReceiverPosition { + /** `obj.m()` — the receiver is the member expression's object, as written. */ + SYNTACTIC = 'SYNTACTIC', + + /** + * `f.call(obj, …)` / `f.apply(obj, args)` — the receiver is argument 0. + * + * 859 sites. `.bind` is the third member of this family and is counted with + * it, though it produces a function rather than invoking one. + */ + FIRST_ARGUMENT = 'FIRST_ARGUMENT', + + /** `fn()` — no receiver. `this` is `undefined` in strict mode, global in sloppy. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/javascript/call-sites/JsReceiverTypeSource.ts b/parser/src/enums/javascript/call-sites/JsReceiverTypeSource.ts new file mode 100644 index 000000000..116e128cc --- /dev/null +++ b/parser/src/enums/javascript/call-sites/JsReceiverTypeSource.ts @@ -0,0 +1,64 @@ +/** + * How much the engine has to work with for this receiver. Schema §3.11 c10. + * + * ## The 52.6% ceiling, made explicit per call site instead of inferred later + * + * The oracle — tsc with `checkJs` — decides only 52.6% of call sites, because + * JavaScript types are inferred rather than declared. The parser will not name + * the target for roughly half of all calls, and pretending otherwise produces a + * confidently wrong fact base. + * + * What the parser *can* always say is **which channel, if any, carries type + * information about this receiver**. That turns "unresolved" from one + * undifferentiated bucket into five actionable ones, and it is stated per row so + * a consumer never has to reconstruct it by joining four relations. + */ +export enum JsReceiverTypeSource { + /** Nothing. A local with no annotation, no import, and no class in sight. */ + NONE = 'NONE', + + /** + * A JSDoc `@param`/`@type` names the receiver's type. + * + * 37.9% of parameters carry one, and this is the only declared-type channel + * the language has — syntactic annotations measure 0 in the replication corpus, and the 64 + * of those are Flow. + */ + JSDOC = 'JSDOC', + + /** + * The receiver is a name bound to a `require()` or an `import`. + * + * **The largest single lever.** `const x = require('y'); x.foo()` is 34.4% of + * all oracle declines — 15,759 sites — and every one of them is + * *reconstructable* rather than unresolvable: the import row carries + * `resolvedFilePath`, so the engine has the file even though the parser has + * no type. `importLinkHash` is the hop. + */ + IMPORT_ALIAS = 'IMPORT_ALIAS', + + /** + * The receiver is a `new`-ed class or constructor function declared in **this + * file**. + * + * The one case the parser can nearly finish, and the reason `resolutionOutcome + * = SAME_FILE_RESOLVED` exists. + */ + LOCAL_CLASS = 'LOCAL_CLASS', + + /** + * The receiver came from a Node builtin — `path`, `fs`, `events`, `util`. + * + * **24.4% of all oracle declines**, and the correction that mattered most in + * the schema's measurement: the environmental class TypeScript had (missing + * `node_modules`) is genuinely small here at 1.5%, and a *different, larger* + * one takes its place. Forcing `types: ["node"]` moved one web framework from 12.9% to + * 21.9% resolved. + * + * It is not fixable by installing anything in the repo under analysis. It is + * the `lib_*` population — the JavaScript spelling of + * `ts_call_site.resolvedTargetKind = LIB_SIGNATURE`, which TypeScript measured + * at 42.3% of call targets. + */ + NODE_BUILTIN = 'NODE_BUILTIN', +} diff --git a/parser/src/enums/javascript/call-sites/index.ts b/parser/src/enums/javascript/call-sites/index.ts new file mode 100644 index 000000000..647c0beed --- /dev/null +++ b/parser/src/enums/javascript/call-sites/index.ts @@ -0,0 +1,4 @@ +export * from './JsCallKind'; +export * from './JsCallResolutionOutcome'; +export * from './JsReceiverPosition'; +export * from './JsReceiverTypeSource'; diff --git a/parser/src/enums/javascript/comments/JsCommentAttachmentKind.ts b/parser/src/enums/javascript/comments/JsCommentAttachmentKind.ts new file mode 100644 index 000000000..70c35144f --- /dev/null +++ b/parser/src/enums/javascript/comments/JsCommentAttachmentKind.ts @@ -0,0 +1,20 @@ +/** + * What a comment documents. Schema §3.15 c7. + * + * Attachment is by **start offset**, because that is what a trivia scan knows + * about the node it precedes — and recorded first-wins, since several nodes + * begin at one offset (a declaration and its own name) and the **outermost** is + * the one a preceding comment documents. + */ +export enum JsCommentAttachmentKind { + METHOD = 'METHOD', + TYPE = 'TYPE', + FIELD = 'FIELD', + VARIABLE = 'VARIABLE', + + /** A file-level comment: a licence header, a `@flow` pragma, a shebang. */ + MODULE = 'MODULE', + + /** Attached to nothing. A comment between statements, or one inside a body. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/javascript/comments/JsCommentKind.ts b/parser/src/enums/javascript/comments/JsCommentKind.ts new file mode 100644 index 000000000..fd0c2f1da --- /dev/null +++ b/parser/src/enums/javascript/comments/JsCommentKind.ts @@ -0,0 +1,38 @@ +/** + * What kind of comment. Schema §3.15 c0. + * + * ## In this language a comment can be a declaration + * + * That is the reason this relation is not decoration. 1,825 `@typedef` and 103 + * `@callback` tags declare **types with no declaration syntax anywhere**, so a + * `js_type` row can have a `startLine` inside a comment and + * `evidenceKind = COMMENT_ONLY`. `js_comment.declaresType` is the corroborating + * column, and the gate asserts the two relations agree: every `COMMENT_ONLY` + * type points at a comment whose `declaresType` is true. + * + * Comments are **trivia** — not in the AST — so no tree walk reaches them and + * the scan is separate by necessity. + */ +export enum JsCommentKind { + /** `// …` */ + LINE = 'LINE', + + /** `/* … *\/` with no JSDoc marker. */ + BLOCK = 'BLOCK', + + /** + * `/** … *\/`. + * + * **A type annotation, not a comment.** The compiler parses `@param`, + * `@returns`, `@type`, `@typedef`, `@template`, `@extends` and `@implements` + * into `node.jsDoc` and *uses* them for inference under `checkJs`. 37.9% of + * parameters get their declared type from one, against effectively none from syntax. + */ + JSDOC = 'JSDOC', + + /** `'use strict'`, `@flow`, `// @ts-check`, a source-map URL. */ + DIRECTIVE = 'DIRECTIVE', + + /** `#!/usr/bin/env node`. Legal only on line 1, and not a comment to the grammar. */ + SHEBANG = 'SHEBANG', +} diff --git a/parser/src/enums/javascript/comments/JsDirectiveKind.ts b/parser/src/enums/javascript/comments/JsDirectiveKind.ts new file mode 100644 index 000000000..41f297607 --- /dev/null +++ b/parser/src/enums/javascript/comments/JsDirectiveKind.ts @@ -0,0 +1,31 @@ +/** + * A directive a comment carries, when it carries one. Schema §3.15 c6. + * + * `FLOW_PRAGMA` is the one with downstream consequences: it corroborates + * `declaredTypeSource = SYNTACTIC_FLOW`, and without it a Flow annotation in + * the AST is indistinguishable from a TypeScript one to a later reader — which + * matters because `ts.createSourceFile` parses the overlapping grammar happily + * and **mis-parses the rest silently**. + */ +export enum JsDirectiveKind { + /** `'use strict'`. Decides whether an undeclared assignment binds or throws. */ + USE_STRICT = 'USE_STRICT', + + /** `@flow`. 6 files measured, and the reason `SYNTACTIC_FLOW` exists. */ + FLOW_PRAGMA = 'FLOW_PRAGMA', + + /** `// @ts-check`. Asks the compiler to typecheck this JavaScript file. */ + TS_CHECK = 'TS_CHECK', + + /** `// @ts-nocheck`. */ + TS_NOCHECK = 'TS_NOCHECK', + + /** `/* eslint … *\/`. */ + ESLINT = 'ESLINT', + + /** `//# sourceMappingURL=…`. Corroborating evidence that a file is generated. */ + SOURCE_MAP = 'SOURCE_MAP', + + /** Not a directive. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/javascript/comments/index.ts b/parser/src/enums/javascript/comments/index.ts new file mode 100644 index 000000000..0ac0a6967 --- /dev/null +++ b/parser/src/enums/javascript/comments/index.ts @@ -0,0 +1,3 @@ +export * from './JsCommentAttachmentKind'; +export * from './JsCommentKind'; +export * from './JsDirectiveKind'; diff --git a/parser/src/enums/javascript/common/JsDeclaredTypeSource.ts b/parser/src/enums/javascript/common/JsDeclaredTypeSource.ts new file mode 100644 index 000000000..fe2b2fd45 --- /dev/null +++ b/parser/src/enums/javascript/common/JsDeclaredTypeSource.ts @@ -0,0 +1,55 @@ +/** + * Which channel declared this position's type, if any. Schema §2.3. + * + * On `js_method` c19, `js_method_parameter` c4, `js_field` c8 and + * `js_variable` c9 — every typed position carries it. + * + * ## The measurement that makes this the most important column in the schema + * + * | channel | share of parameters | + * |---|---| + * | nothing | 62.1% | + * | **JSDoc** | **37.9%** | + * | syntactic annotation | **0%** in the replication corpus — see below | + * + * TypeScript's schema is Java-shaped because 85.3% of its parameters are + * annotated; declared-type receiver typing is its primary resolution mechanism. + * Here that mechanism is **absent from the syntax**. JSDoc is not a comment + * feature in this language — it is the type annotation, the compiler parses it + * into `node.jsDoc` and uses it for inference under `checkJs`, and a schema that + * treats it as trivia has no type channel at all. + */ +export enum JsDeclaredTypeSource { + /** No declared type. 62.1% of parameters, and the normal case. */ + NONE = 'NONE', + + /** + * A JSDoc tag: `@param {string} x`, `@type {Foo}`, `@returns {Promise}`. + * + * 14,307 `@param`, 8,869 `@type`, 6,073 `@returns` measured. The tree it + * describes lives in `js_type_reference`, because `Array>` is three nodes and not a string. + */ + JSDOC = 'JSDOC', + + /** + * A **Flow** annotation in the source syntax. + * + * **The measurement behind this value has been retracted.** 64 syntactic + * annotations were originally reported, all of them Flow; the replication + * corpus measures **0**, because all 64 were in one package that corpus does + * not contain. + * + * The value is kept rather than removed, on `js-oracle`'s ruling: the + * held-back corpus (a Flow-typed framework, Flow throughout) was chosen specifically to + * test it. If that holdout shows the column is wrong, that is the holdout doing its + * job — which is the only thing a holdout is for. `ts.createSourceFile` parses Flow into real `.type` nodes where + * the two grammars overlap and **mis-parses silently where they do not**, so + * the annotation is present in the AST and means something slightly different + * from what a TypeScript reader would assume. + * + * Recording it is what keeps a later reader from treating a Flow annotation as + * a TypeScript one. `js_module.hasFlowPragma` is the corroborating evidence. + */ + SYNTACTIC_FLOW = 'SYNTACTIC_FLOW', +} diff --git a/parser/src/enums/javascript/common/index.ts b/parser/src/enums/javascript/common/index.ts new file mode 100644 index 000000000..affa1ca05 --- /dev/null +++ b/parser/src/enums/javascript/common/index.ts @@ -0,0 +1 @@ +export * from './JsDeclaredTypeSource'; diff --git a/parser/src/enums/javascript/exports/JsExportForm.ts b/parser/src/enums/javascript/exports/JsExportForm.ts new file mode 100644 index 000000000..4ba3c573f --- /dev/null +++ b/parser/src/enums/javascript/exports/JsExportForm.ts @@ -0,0 +1,47 @@ +/** How a module edge OUT was written. Schema §3.9 c3. */ +export enum JsExportForm { + /** + * `module.exports = X`. **2,102 measured** — the most common export in + * JavaScript. + * + * An **export expressed as an assignment**, and a *replacing* one: it discards + * whatever `module.exports` held before, which is what + * `overwritesPreviousExport` records. + */ + MODULE_EXPORTS_ASSIGNMENT = 'MODULE_EXPORTS_ASSIGNMENT', + + /** `module.exports.foo = …`. 380 measured. Adds one name, replaces nothing. */ + MODULE_EXPORTS_MEMBER = 'MODULE_EXPORTS_MEMBER', + + /** + * `exports.foo = …`. 118 measured. + * + * The same edge as `MODULE_EXPORTS_MEMBER` through a different alias, and kept + * separate because the two stop being equivalent the moment a + * `module.exports = {}` runs: `exports` still points at the old object, so a + * later `exports.x = 1` exports nothing at all. + */ + EXPORTS_MEMBER = 'EXPORTS_MEMBER', + + /** + * `Object.defineProperty(exports, 'x', { get() { … } })`. + * + * What transpilers emit, and the only export form that is lazy — the value is + * computed on first read. + */ + OBJECT_DEFINE_PROPERTY = 'OBJECT_DEFINE_PROPERTY', + + /** `export const x = 1`, `export { a as b }`. A declaration. */ + EXPORT_DECLARATION = 'EXPORT_DECLARATION', + + /** `export default X`. */ + EXPORT_DEFAULT = 'EXPORT_DEFAULT', + + /** + * `export * from './y'`, and `export * as ns from './y'`. An import and an + * export in one statement; the second spelling is the same edge under one + * exported name, and `exportedName` is what tells them apart — `*` for the + * bare form, the name for the namespaced one. + */ + EXPORT_ALL = 'EXPORT_ALL', +} diff --git a/parser/src/enums/javascript/exports/JsExportTargetKind.ts b/parser/src/enums/javascript/exports/JsExportTargetKind.ts new file mode 100644 index 000000000..2ec68e932 --- /dev/null +++ b/parser/src/enums/javascript/exports/JsExportTargetKind.ts @@ -0,0 +1,30 @@ +/** + * Which relation `targetLinkHash` points into. Schema §3.9 c10. + * + * A polymorphic FK, discriminated by a sibling column — the same shape + * `js_type_reference.ownerKind` and `js_comment.attachedToKind` use. The + * alternative, five nullable FK columns, costs five columns to say what one + * says, and a reader then has to check all five to find the one populated. + */ +export enum JsExportTargetKind { + /** FK→`js_method`. `module.exports = function f() {}`. */ + METHOD = 'METHOD', + + /** FK→`js_type`. `module.exports = class Foo {}`. */ + TYPE = 'TYPE', + + /** FK→`js_variable`. `module.exports = localName`. */ + VARIABLE = 'VARIABLE', + + /** FK→`js_field`. `exports.x = …` where `x` is also a declared member. */ + FIELD = 'FIELD', + + /** + * FK→`js_expression`. The value is not a declaration at all. + * + * `module.exports = compute()` or `module.exports = a || b`. There is nothing + * to point at but the expression, and pointing at the expression is a complete + * answer — the engine can walk it. + */ + EXPRESSION_VALUE = 'EXPRESSION_VALUE', +} diff --git a/parser/src/enums/javascript/exports/JsExportedValueKind.ts b/parser/src/enums/javascript/exports/JsExportedValueKind.ts new file mode 100644 index 000000000..515c3f041 --- /dev/null +++ b/parser/src/enums/javascript/exports/JsExportedValueKind.ts @@ -0,0 +1,38 @@ +/** + * What is on the right-hand side. Schema §3.9 c4. + * + * The column answers the question an **importer** actually asks: given + * `const R = require('./router')`, what is `R`? A function, a class, an object + * of functions, or another module entirely — and those are four different things + * to do next. + * + * `OBJECT_LITERAL` at 335 measured is the pre-ES6 namespace, and the reason + * object literals reach the fact base as expressions with + * `js_variable.initializerKind = OBJECT_LITERAL` rather than as `js_type` rows. + */ +export enum JsExportedValueKind { + /** `module.exports = function () {}`. 24 measured. The module IS callable. */ + FUNCTION = 'FUNCTION', + + /** `module.exports = class { … }`. The module is a constructor. */ + CLASS = 'CLASS', + + /** `module.exports = { a, b }`. 335 measured. The pre-ES6 namespace. */ + OBJECT_LITERAL = 'OBJECT_LITERAL', + + /** + * `module.exports = require('./y')`. **81 measured.** + * + * A module edge that is **simultaneously an import and an export**, and the + * one construct that needs two rows in two relations for one line of source. + * `isReExport`, `reExportSpecifier` and `reExportImportLinkHash` are the + * columns that keep the pair joinable. + */ + REQUIRE_REEXPORT = 'REQUIRE_REEXPORT', + + /** `module.exports = Foo`, where `Foo` is a local name. The common case. */ + IDENTIFIER = 'IDENTIFIER', + + /** Anything else: a call result, a member access, a literal, an operator. */ + OTHER = 'OTHER', +} diff --git a/parser/src/enums/javascript/exports/index.ts b/parser/src/enums/javascript/exports/index.ts new file mode 100644 index 000000000..5bacb1d26 --- /dev/null +++ b/parser/src/enums/javascript/exports/index.ts @@ -0,0 +1,3 @@ +export * from './JsExportForm'; +export * from './JsExportTargetKind'; +export * from './JsExportedValueKind'; diff --git a/parser/src/enums/javascript/expressions/JsBindingResolution.ts b/parser/src/enums/javascript/expressions/JsBindingResolution.ts new file mode 100644 index 000000000..7dd463f9a --- /dev/null +++ b/parser/src/enums/javascript/expressions/JsBindingResolution.ts @@ -0,0 +1,74 @@ +/** + * Which scope a name came from. Schema §3.10 c18. + * + * ## The binder's output, and the engine's input + * + * `resolvedBindingLinkHash` says *which* `js_variable` row a name refers to. + * This says *where it was found*, and the two are not the same information. A + * name resolved in the current scope and the same name resolved three closures + * up point at one row and are very different facts about the code: the second + * means the value **outlives its frame**, which is what makes a callback able to + * see a loop variable. + * + * ## Both of the unresolved values are honest, and they are not the same + * + * `GLOBAL_BUILTIN` says the target is in the `lib_*` population — expected, and + * 24.4% of oracle declines are exactly this. `UNRESOLVED_FREE` says the parser + * genuinely does not know. Conflating them would hide a real gap inside a + * category that is expected to be large, which is the §7 failure: *classify + * environmental before reporting.* + */ +export enum JsBindingResolution { + /** Declared in the same scope as the reference. */ + LOCAL = 'LOCAL', + + /** + * Declared in an enclosing **function** scope. A closure capture. + * + * The value outlives the frame it was declared in, which is the fact that + * distinguishes this from `LOCAL` and the reason the two are separate values + * rather than one "found lexically". + */ + CLOSURE = 'CLOSURE', + + /** Declared at the file's top level. */ + MODULE = 'MODULE', + + /** + * Bound by an `import` or a `require`. + * + * The hop that matters: 34.4% of all oracle declines are calls through one of + * these, and every one is reconstructable because the import row carries + * `resolvedFilePath`. + */ + IMPORTED = 'IMPORTED', + + /** + * A platform name with no declaration in this file: `console`, `Array`, + * `process`, `Buffer`. + * + * Not a failure. The target is in the `lib_*` population, which TypeScript + * measured at 42.3% of its call targets and which no amount of installing + * dependencies in the analysed repo brings into scope. + */ + GLOBAL_BUILTIN = 'GLOBAL_BUILTIN', + + /** + * `#brand in obj` — a class-private name in a reference position. + * + * Schema §3.10.1. A private name is a slot on a class, not a binding in any + * scope chain, so every scope value is dishonest for it and `UNRESOLVED_FREE` + * claims a binder failure that did not happen. The binder resolved it: to the + * class whose body encloses the reference. `this.#x` is unaffected — that is + * a property access whose member name is private. + */ + CLASS_PRIVATE = 'CLASS_PRIVATE', + + /** + * Not bound anywhere the parser can see, and not a known builtin. + * + * The honest "I do not know". Kept apart from `GLOBAL_BUILTIN` so a rising + * count here is visible rather than absorbed into an expected category. + */ + UNRESOLVED_FREE = 'UNRESOLVED_FREE', +} diff --git a/parser/src/enums/javascript/expressions/JsEdgeRole.ts b/parser/src/enums/javascript/expressions/JsEdgeRole.ts new file mode 100644 index 000000000..62e3517fe --- /dev/null +++ b/parser/src/enums/javascript/expressions/JsEdgeRole.ts @@ -0,0 +1,98 @@ +/** + * What role a child expression plays in its parent. Schema §3.10 c7. + * + * ## The column that makes a wrapper node useful + * + * A wrapper with unlabelled children is only half the fix. `x += 1` needs its + * target distinguishable from its value, or an engine cannot tell + * `a = b` from `b = a` — and the flat-emission workaround that pairs on + * `(scope, line)` gets it wrong in the direction that **invents** value flow. + * + * `RECEIVER` is the JavaScript-specific one worth noting: for + * `f.call(obj, a)` it labels `obj`, which sits in **argument** position. The + * call site's `receiverPosition = FIRST_ARGUMENT` says so, and this role is how + * the expression tree agrees with it. + */ +export enum JsEdgeRole { + /** The left side of an assignment. */ + ASSIGNMENT_TARGET = 'ASSIGNMENT_TARGET', + + /** The right side of an assignment, or a declaration's initializer. */ + ASSIGNMENT_VALUE = 'ASSIGNMENT_VALUE', + + /** The thing being called: `f` in `f(x)`, `obj.m` in `obj.m(x)`. */ + CALLEE = 'CALLEE', + + /** A positional argument. */ + ARGUMENT = 'ARGUMENT', + + /** + * The receiver, wherever it sits. + * + * `obj` in `obj.m()` and `obj` in `f.call(obj, …)` both get this role, which + * is the point — a consumer reads the role rather than re-deriving the + * position from the call kind. + */ + RECEIVER = 'RECEIVER', + + /** An `if`, `while`, ternary or `&&`/`||` condition. */ + CONDITION = 'CONDITION', + + /** An array element, or a sequence operand. */ + ELEMENT = 'ELEMENT', + + /** An object-literal property's value. */ + PROPERTY_VALUE = 'PROPERTY_VALUE', + + /** An object-literal property's key, emitted as its own row. */ + PROPERTY_KEY = 'PROPERTY_KEY', + + /** + * A JSX element nested inside another element's markup. + * + * ## Its own role, because a JSX child is not an array element + * + * `ELEMENT` means an array element or a sequence operand, and reusing it here + * would tell a consumer that `
` inside `
` is positional data + * rather than composition. It is the composition: the set of JSX_CHILD edges + * out of a component's markup IS the component graph, which is the single + * question anyone asks of a component codebase. + * + * The edge was missing entirely. Nested elements were descended THROUGH — so + * their attributes and the calls inside those attributes were emitted — and + * never emitted themselves, and their children were attached to the OUTERMOST + * element's row. So `
` produced + * one row for the `div`, none for the other three, and a flat attribute list + * that made the nesting unrecoverable. 4,944 elements over the corpus. + */ + JSX_CHILD = 'JSX_CHILD', + + /** + * The tag of a JSX COMPONENT element — `Foo` in ``, `widgets.panel` + * in ``. + * + * Schema §2.5a: an ordinary child expression reading a binding, with + * `referencedName`, `bindingResolution` and `resolvedBindingLinkHash` as any + * identifier read has, so the 833 component references that pointed at + * nothing point at their declarations. A dotted tag is a property-access + * subtree and needs no special case. Emitted ONCE, from the opening tag — + * the closing `` repeats the same reference and is not a second one. + * An intrinsic element has no edge of this role. + */ + JSX_TAG_NAME = 'JSX_TAG_NAME', + + /** The operand of `...`. */ + SPREAD_OPERAND = 'SPREAD_OPERAND', + + /** A `${…}` inside a template literal. */ + TEMPLATE_SUBSTITUTION = 'TEMPLATE_SUBSTITUTION', + + /** A unary or binary operand that is none of the above. */ + OPERAND = 'OPERAND', + + /** The object of a member access: `obj` in `obj.prop` used as a value. */ + ACCESS_TARGET = 'ACCESS_TARGET', + + /** The computed key of `obj[expr]`. */ + COMPUTED_KEY = 'COMPUTED_KEY', +} diff --git a/parser/src/enums/javascript/expressions/JsExpressionKind.ts b/parser/src/enums/javascript/expressions/JsExpressionKind.ts new file mode 100644 index 000000000..eb7a3c1f6 --- /dev/null +++ b/parser/src/enums/javascript/expressions/JsExpressionKind.ts @@ -0,0 +1,188 @@ +/** + * What an expression node is. Schema §3.10 c0 — the spine's vocabulary. + * + * ## The operator is a COLUMN, not a kind + * + * `+=`, `-=`, `**=`, `??=`, `||=` and `&&=` are **one** `ASSIGNMENT` kind with + * `operatorString` distinguishing them. Java established this and TypeScript + * follows it, and the reason is in §3 of `BUILDING-A-PARSER.md`: Python emitted + * `x += 1` as two depth-0 roots with no wrapper and no parent, so 1,276 + * statements became unpairable. + * + * The failure mode is worse than lost rows. The proposed engine-side workaround + * was to join target and value on `(scope, line, rootContext)`; on + * `a += 1; b += 2` that yields four pairs, two of them wrong — `a` paired with + * `2`. Flat output does not merely lose structure, it **invites a fix that + * manufactures wrong edges**. + * + * So the rule for every construct here: one wrapper node, children parented to + * it with an `edgeRole`, and the variant in a column. The five-minute checklist + * asks whether it survives *two on one line*, and that is why. + * + * ## Reached by an ALLOWLIST of expression positions, never a generic walk + * + * §6: a generic tree walk puts JSDoc type names into the expression relation, + * and type-only constructs then reach the call graph. + */ +export enum JsExpressionKind { + // --------------------------------------------------------------- references + /** A bare name: `foo`. The binder decides what it refers to. */ + IDENTIFIER = 'IDENTIFIER', + + /** `this`. 33,189 measured, and what it refers to depends on the call form. */ + THIS = 'THIS', + + /** `super`. */ + SUPER = 'SUPER', + + /** + * `new.target` and `import.meta` — a MetaProperty, a keyword pair that + * refers to a runtime slot and never to a binding. + * + * Ruled 2026-09-13 (#175): the last named expression residue, 5 sites on + * the development corpus, and `import.meta.url` is the ESM idiom that + * `createRequire` is fed with. `referencedName` is the pair as written; + * `bindingResolution` is empty because no scope resolves it. + */ + META_PROPERTY = 'META_PROPERTY', + + /** A literal: string, number, template, regex, `null`, `true`, bigint. */ + LITERAL = 'LITERAL', + + // ------------------------------------------------------------------- access + /** + * `obj.prop`. The name is fixed by syntax, so `name` is populated. + */ + PROPERTY_ACCESS = 'PROPERTY_ACCESS', + + /** + * `obj[expr]`. **715 computed member names measured.** + * + * `name` is `""` and `isComputedName` is true. A row that says the name is + * unknown is complete; a row that guesses a name is worse than either. + */ + ELEMENT_ACCESS = 'ELEMENT_ACCESS', + + /** `obj?.prop` / `obj?.[expr]`. Differs in reachability, not in target. */ + OPTIONAL_ACCESS = 'OPTIONAL_ACCESS', + + // -------------------------------------------------------------------- calls + /** `fn(…)`, `obj.m(…)`. The 1:1 partner of a `js_call_site` row. */ + CALL = 'CALL', + + /** `new F(…)`. */ + NEW = 'NEW', + + /** ``tag`…` ``. Invokes `tag`; also a call site. */ + TAGGED_TEMPLATE = 'TAGGED_TEMPLATE', + + /** + * `require('x')` or `import('x')`. + * + * `isModuleEdge` is true and **no `js_call_site` row is minted for a + * `require`** — it is a module edge by ruling, and counting it as an + * unresolved call is what made the raw resolution figure look worse than it + * was. `import('x')` gets both, because its result genuinely flows somewhere. + */ + MODULE_EDGE_CALL = 'MODULE_EDGE_CALL', + + // --------------------------------------------------------------- operations + /** + * `=`, `+=`, `??=`, `||=` — **every** assignment form. + * + * One kind. The target is parented under `ASSIGNMENT_TARGET` and the value + * under `ASSIGNMENT_VALUE`, and `operatorString` says which operator. This is + * also the kind that carries `isDeclarationBearing`, because + * `Foo.prototype.bar = function () {}` is an assignment that declares a method. + */ + ASSIGNMENT = 'ASSIGNMENT', + + /** A binary operator: `+`, `===`, `instanceof`, `in`, `&&`, `??`. */ + BINARY = 'BINARY', + + /** A prefix or postfix unary: `!`, `-`, `typeof`, `void`, `delete`, `++`, `--`. */ + UNARY = 'UNARY', + + /** `c ? a : b`. */ + CONDITIONAL = 'CONDITIONAL', + + /** `(a, b, c)` — the comma operator. Rare, and it evaluates all of them. */ + SEQUENCE = 'SEQUENCE', + + // ----------------------------------------------------------------- literals + /** + * `{ a: 1, m() {} }`. + * + * A **value**, never a `js_type`. Treating every object literal as a type is + * how a JavaScript fact base acquires 50,000 meaningless types — and the + * pre-ES6 module pattern makes it tempting, because the literal really is + * playing the role a class would. + */ + OBJECT_LITERAL = 'OBJECT_LITERAL', + + /** `[1, 2, …rest]`. */ + ARRAY_LITERAL = 'ARRAY_LITERAL', + + /** A property key inside an object literal, emitted as its own row. */ + PROPERTY_KEY = 'PROPERTY_KEY', + + /** `...x` in a call, an array, or an object literal. */ + SPREAD = 'SPREAD', + + /** `` `a${b}c` `` — the substitutions are parented under it. */ + TEMPLATE = 'TEMPLATE', + + // --------------------------------------------------------------- functions + /** + * A function expression, arrow, or class expression **in expression + * position**. + * + * The row exists so the expression tree is not broken by a callable sitting in + * it, and `declarationLinkHash` points at the `js_method`/`js_type` row it + * introduces. The body's contents belong to that method's own rows — which is + * the boundary §6 says the worklist stops at and must be descended + * **explicitly**, because `return function () { … }` once emitted the function + * and nothing inside it. + */ + FUNCTION_EXPRESSION = 'FUNCTION_EXPRESSION', + + /** `await x`. */ + AWAIT = 'AWAIT', + + /** `yield x` / `yield* xs`. 248 measured. */ + YIELD = 'YIELD', + + // --------------------------------------------------------------------- JSX + /** + * A JSX element or fragment. + * + * Emitted because JSX in a plain `.js` file parses — `ScriptKind.JS` already + * carries `languageVariant = JSX`. The **brace** inside one is the trap: + * `{t(msg)}` produces no row of its own, so a subtree rooted at it dies before + * its children are enqueued, and that cost TypeScript **4,488 of admin-ui's + * 14,335 call sites**. Unwrapped at the root, in one place. + */ + JSX_ELEMENT = 'JSX_ELEMENT', + + /** + * A JSX element whose tag REFERENCES NOTHING — `
`, ``, + * ``, and a fragment `<>…`. + * + * Schema §2.5a. Two kinds rather than a flag because the two differ in + * whether they reference anything at all, which is structural: a component + * element carries a `JSX_TAG_NAME` child that reads a binding; an intrinsic + * one carries none, because the factory receives the STRING `"div"` and + * minting a reference row for it would put a name into the binding graph + * that no binding can satisfy. A fragment has no tag and so no reference + * either — the property that defines this kind — and is recorded here. + * + * Intrinsic iff a simple identifier that is either not a valid ECMAScript + * identifier (`Foo-Bar`: a name no `const` can bind) or begins with a + * lowercase letter — a character that CHANGES under `toUpperCase`, so `_` + * and `$` are not lowercase and `<_Private>` is a reference. + */ + JSX_INTRINSIC_ELEMENT = 'JSX_INTRINSIC_ELEMENT', + + /** A JSX attribute's value. */ + JSX_ATTRIBUTE_VALUE = 'JSX_ATTRIBUTE_VALUE', +} diff --git a/parser/src/enums/javascript/expressions/JsLiteralKind.ts b/parser/src/enums/javascript/expressions/JsLiteralKind.ts new file mode 100644 index 000000000..85543109d --- /dev/null +++ b/parser/src/enums/javascript/expressions/JsLiteralKind.ts @@ -0,0 +1,33 @@ +/** + * What kind of literal, when the expression is one. Schema §3.10 c20. + * + * `NONE` for every expression that is not a literal, so the column is total + * rather than optional — a `""` would be indistinguishable from "the extractor + * did not look". + */ +export enum JsLiteralKind { + STRING = 'STRING', + NUMBER = 'NUMBER', + + /** A template with no substitutions, which is a string constant. */ + TEMPLATE = 'TEMPLATE', + + REGEX = 'REGEX', + NULL = 'NULL', + + /** + * `undefined`. + * + * Not a literal in the grammar — it is an identifier that resolves to a + * global — and recorded as one here because every consumer wants it to be. The + * `IDENTIFIER` row it also produces carries + * `bindingResolution = GLOBAL_BUILTIN`, so nothing is lost. + */ + UNDEFINED = 'UNDEFINED', + + BOOLEAN = 'BOOLEAN', + BIGINT = 'BIGINT', + + /** Not a literal. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/javascript/expressions/JsReferenceKind.ts b/parser/src/enums/javascript/expressions/JsReferenceKind.ts new file mode 100644 index 000000000..80c472336 --- /dev/null +++ b/parser/src/enums/javascript/expressions/JsReferenceKind.ts @@ -0,0 +1,22 @@ +/** + * What is being done to a name. Schema §3.10 c16. + * + * `READ_WRITE` exists for `x += 1` and `x++`, which do both — and collapsing it + * into `WRITE` loses the read that a data-flow analysis needs, while collapsing + * it into `READ` loses the write. `TYPEOF` is separate because + * `typeof undeclared` is the one reference form that **does not throw** on an + * unbound name, so an unresolved `TYPEOF` is not evidence of a missing binding. + */ +export enum JsReferenceKind { + READ = 'READ', + WRITE = 'WRITE', + + /** `x += 1`, `x++`. Both, and both are needed. */ + READ_WRITE = 'READ_WRITE', + + /** `delete obj.x`. Removes the property rather than reading or writing it. */ + DELETE = 'DELETE', + + /** `typeof x`. The only form that tolerates an unbound name. */ + TYPEOF = 'TYPEOF', +} diff --git a/parser/src/enums/javascript/expressions/JsRootContext.ts b/parser/src/enums/javascript/expressions/JsRootContext.ts new file mode 100644 index 000000000..958a406f7 --- /dev/null +++ b/parser/src/enums/javascript/expressions/JsRootContext.ts @@ -0,0 +1,55 @@ +/** + * The statement form a depth-0 expression tree hangs under. Schema §3.10 c9. + * + * A consumer reading an expression row needs to know whether it is a value being + * returned, a condition being tested, or a statement being executed for its + * effect, and that is a property of the **statement**, not of the expression. + * Without it, `f()` as a discarded call and `f()` as a returned value are + * identical rows. + */ +export enum JsRootContext { + /** A statement evaluated for its effect: `f();`, `x = 1;`. */ + EXPRESSION_STATEMENT = 'EXPRESSION_STATEMENT', + + /** A `var`/`let`/`const` initializer. */ + VARIABLE_INITIALIZER = 'VARIABLE_INITIALIZER', + + /** A class field's initializer. */ + FIELD_INITIALIZER = 'FIELD_INITIALIZER', + + /** A parameter's default value, evaluated at call time in the function's scope. */ + PARAMETER_DEFAULT = 'PARAMETER_DEFAULT', + + RETURN = 'RETURN', + THROW = 'THROW', + + /** An `if`, `while`, `do` or `switch` test. */ + CONDITION = 'CONDITION', + + /** A `for` initializer, condition or incrementor. */ + FOR_HEADER = 'FOR_HEADER', + + /** The iterated expression of `for…in` / `for…of`. */ + ITERABLE = 'ITERABLE', + + /** A `case` clause's test expression. */ + SWITCH_CASE_TEST = 'SWITCH_CASE_TEST', + + /** A `class X extends ` clause — which may be a call. */ + HERITAGE = 'HERITAGE', + + /** An `export default ` or a `module.exports = ` value. */ + EXPORT_VALUE = 'EXPORT_VALUE', + + /** A computed member or property name. */ + COMPUTED_NAME = 'COMPUTED_NAME', + + /** A `with ()` head. */ + WITH_TARGET = 'WITH_TARGET', + + /** A JSX expression container: `{…}` in markup. */ + JSX_EXPRESSION = 'JSX_EXPRESSION', + + /** A `@typedef`/`@param` default or value position. Type-only; never a call target. */ + JSDOC = 'JSDOC', +} diff --git a/parser/src/enums/javascript/expressions/index.ts b/parser/src/enums/javascript/expressions/index.ts new file mode 100644 index 000000000..bafd06340 --- /dev/null +++ b/parser/src/enums/javascript/expressions/index.ts @@ -0,0 +1,6 @@ +export * from './JsBindingResolution'; +export * from './JsEdgeRole'; +export * from './JsExpressionKind'; +export * from './JsLiteralKind'; +export * from './JsReferenceKind'; +export * from './JsRootContext'; diff --git a/parser/src/enums/javascript/fields/JsAccessorPairKind.ts b/parser/src/enums/javascript/fields/JsAccessorPairKind.ts new file mode 100644 index 000000000..43a6386e4 --- /dev/null +++ b/parser/src/enums/javascript/fields/JsAccessorPairKind.ts @@ -0,0 +1,31 @@ +/** + * Whether reading or writing this name invokes a function. Schema §3.6 c12. + * + * ## The declaration side of a reserved call kind + * + * 1,225 getters and 137 setters were measured. Each one means that somewhere, + * `obj.x` is **a function call written as a property read**. + * + * The parser can emit the declaration — a `get x() {}` is right there in the + * syntax — and it cannot emit the invocation, because whether a given `obj.x` + * hits an accessor depends on what `obj` turns out to be at runtime. So + * `GETTER_INVOCATION` and `SETTER_INVOCATION` are reserved call kinds with a + * zero-row assertion, and this column is the half syntax can answer. + * + * That split is the point: an engine that wants to model accessor invocation has + * everything it needs on the declaration side and is told explicitly that the + * call side was not guessed. + */ +export enum JsAccessorPairKind { + /** An ordinary data property. A read is a read. */ + NONE = 'NONE', + + /** `get x()` with no setter. Writing it is a silent no-op in sloppy mode. */ + GETTER_ONLY = 'GETTER_ONLY', + + /** `set x(v)` with no getter. Reading it yields `undefined`. */ + SETTER_ONLY = 'SETTER_ONLY', + + /** Both. `getterMethodLinkHash` and `setterMethodLinkHash` are both populated. */ + GETTER_SETTER = 'GETTER_SETTER', +} diff --git a/parser/src/enums/javascript/fields/JsFieldDeclarationForm.ts b/parser/src/enums/javascript/fields/JsFieldDeclarationForm.ts new file mode 100644 index 000000000..066708626 --- /dev/null +++ b/parser/src/enums/javascript/fields/JsFieldDeclarationForm.ts @@ -0,0 +1,39 @@ +/** + * How a member was declared. Schema §3.6 c3, and **in the primary key**. + * + * ## Why it is in the key + * + * `this.x = 1` in a constructor and `Foo.prototype.x = 1` at module level are + * **two declarations of one member**, at different lines, and both are real — + * one sets an own property per instance and the other sets a shared prototype + * property. Keying without the form would merge them; keying with it keeps both, + * and the engine can decide which it cares about. + */ +export enum JsFieldDeclarationForm { + /** `class Foo { x = 1 }`. 614 measured. */ + CLASS_FIELD = 'CLASS_FIELD', + + /** `Foo.prototype.x = 1`. 233 measured. A shared property on the prototype. */ + PROTOTYPE_ASSIGNMENT = 'PROTOTYPE_ASSIGNMENT', + + /** `Foo.x = 1`. A static member, installed by assignment. */ + STATIC_ASSIGNMENT = 'STATIC_ASSIGNMENT', + + /** + * `Object.defineProperty(Foo.prototype, 'x', { … })`. 76 measured. + * + * The only form that can declare a member non-writable or non-enumerable, and + * the source of `isReadonly` — `writable: false` is a fact readable from the + * descriptor literal and from nowhere else. + */ + OBJECT_DEFINE_PROPERTY = 'OBJECT_DEFINE_PROPERTY', + + /** + * `this.x = 1` inside a constructor or a constructor function. + * + * The dominant way pre-ES6 code declares instance state, and the reason the + * field extractor has to look **inside a function body** for declarations + * rather than only at a class body's members. + */ + CONSTRUCTOR_THIS_ASSIGNMENT = 'CONSTRUCTOR_THIS_ASSIGNMENT', +} diff --git a/parser/src/enums/javascript/fields/index.ts b/parser/src/enums/javascript/fields/index.ts new file mode 100644 index 000000000..ae25ba9b6 --- /dev/null +++ b/parser/src/enums/javascript/fields/index.ts @@ -0,0 +1,2 @@ +export * from './JsAccessorPairKind'; +export * from './JsFieldDeclarationForm'; diff --git a/parser/src/enums/javascript/heritage/JsHeritageForm.ts b/parser/src/enums/javascript/heritage/JsHeritageForm.ts new file mode 100644 index 000000000..3be4b83d4 --- /dev/null +++ b/parser/src/enums/javascript/heritage/JsHeritageForm.ts @@ -0,0 +1,55 @@ +/** + * How an inheritance edge was written. Schema §3.3 c2. + * + * ## In JavaScript an `extends` edge can be a function call + * + * That sentence is the reason `js_type_heritage` is a relation separate from + * `js_type`, and it is §3 of `BUILDING-A-PARSER.md` in its purest form: *the + * parts get emitted, the structure does not.* `util.inherits(Child, Parent)` + * emits trivially as a call site with two identifier arguments. What it **is** + * is an inheritance edge, and an extractor that only sees the call emits no + * inheritance at all. + * + * Get this wrong and the engine sees **no inheritance in any pre-ES6 + * codebase** — not a degraded answer, an absent one. + * + * ## Do not size this work from the corpus counts + * + * `util.inherits` 2, `Object.create(B.prototype)` 4, + * `Object.assign(X.prototype, …)` 2. Those numbers are a property of a corpus + * where the runtime's standard library has been modernised, not of the language. A 2015-era corpus + * inverts them, and the schema says so explicitly rather than letting the counts + * argue for skipping the work. + */ +export enum JsHeritageForm { + /** `class Child extends Parent`. The only form syntax states directly. */ + EXTENDS_CLAUSE = 'EXTENDS_CLAUSE', + + /** + * `util.inherits(Child, Parent)`. + * + * An **extends edge expressed as a call**. Node's own pre-ES6 idiom, and + * `require('util').inherits` reached through an alias is the normal spelling, + * so recognition cannot depend on the receiver being literally named `util`. + */ + UTIL_INHERITS = 'UTIL_INHERITS', + + /** + * `Child.prototype = Object.create(Parent.prototype)`. + * + * The hand-rolled version of the same thing, and the one that appears without + * any library dependency. Two statements usually follow it — + * `Child.prototype.constructor = Child` and the members — and only this one + * is the edge. + */ + OBJECT_CREATE_PROTOTYPE = 'OBJECT_CREATE_PROTOTYPE', + + /** + * `Child.prototype = new Parent()` or `Child.prototype = Parent.prototype`. + * + * The oldest and most broken form — it runs the parent constructor at + * definition time, or shares one prototype object between two types. Still an + * inheritance edge, and still what a great deal of shipped code does. + */ + PROTOTYPE_ASSIGNMENT = 'PROTOTYPE_ASSIGNMENT', +} diff --git a/parser/src/enums/javascript/heritage/index.ts b/parser/src/enums/javascript/heritage/index.ts new file mode 100644 index 000000000..a4953bdfc --- /dev/null +++ b/parser/src/enums/javascript/heritage/index.ts @@ -0,0 +1 @@ +export * from './JsHeritageForm'; diff --git a/parser/src/enums/javascript/imports/JsEdgeBearer.ts b/parser/src/enums/javascript/imports/JsEdgeBearer.ts new file mode 100644 index 000000000..b1ec2d8a6 --- /dev/null +++ b/parser/src/enums/javascript/imports/JsEdgeBearer.ts @@ -0,0 +1,45 @@ +/** + * Whether this module edge was written as a declaration or as an expression. + * Schema §3.8 c2 and §3.9 c2. + * + * ## The finding that made JavaScript its own front end + * + * **11,655 of 13,936 module edges — 83.6% — are expression-borne.** + * `require('./x')` is a call. `module.exports = X` is an assignment. No + * TypeScript relation expects this, because every TypeScript module edge is a + * declaration at the top of a file, and routing expression-minted rows into + * `ts_import` would have inverted the build order in §1 of + * `BUILDING-A-PARSER.md` for the whole front end. + * + * So `js_import` and `js_export` are minted in a **second pass, from + * `js_expression` rows**, and `sourceExpressionLinkHash` points back at the + * expression each was minted from — which is what makes the second pass + * auditable rather than asserted. Gate 7.3.1: every expression with + * `isModuleEdge` is pointed at by **exactly one** import or export row. That is + * what stops the second pass double-minting, which would **double** the edge + * count rather than collide. + * + * ## It is a partition, not a flag + * + * Per-file: **2,300 files wholly `EXPRESSION`, 431 wholly `DECLARATION`, 0 + * mixed.** Bimodal, not averaged — so a consumer can treat the value as a + * property of the file, and a file that *is* mixed is worth looking at. + */ +export enum JsEdgeBearer { + /** `import … from 'x'`, `export …`. A declaration, always at the top level. */ + DECLARATION = 'DECLARATION', + + /** `require('x')`, `import('x')`, `module.exports = …`. An expression. 83.6%. */ + EXPRESSION = 'EXPRESSION', + + /** + * A JSDoc `import("./x").Y` — JavaScript's `import type`, in a comment. + * + * Schema §3.8.1. Type-only, no runtime behaviour, no expression. The + * partition statistics above — 83.6%, the bimodality, the 0-mixed count — + * are over RUNTIME edges (`DECLARATION` and `EXPRESSION`) and exclude this + * value; and the module-edge gate pairs a COMMENT row through + * `js_type_reference.importLinkHash` rather than through an expression. + */ + COMMENT = 'COMMENT', +} diff --git a/parser/src/enums/javascript/imports/JsImportBindingForm.ts b/parser/src/enums/javascript/imports/JsImportBindingForm.ts new file mode 100644 index 000000000..aaa459c03 --- /dev/null +++ b/parser/src/enums/javascript/imports/JsImportBindingForm.ts @@ -0,0 +1,42 @@ +/** What the edge binds locally. Schema §3.8 c6. */ +export enum JsImportBindingForm { + /** `const x = require('y')` or `import * as x from 'y'`. The whole module object. */ + NAMESPACE = 'NAMESPACE', + + /** `import { a } from 'y'`. One named export. */ + NAMED = 'NAMED', + + /** `import x from 'y'`. The default export. */ + DEFAULT = 'DEFAULT', + + /** + * `const { a, b } = require('y')`. + * + * Produces **one row per bound name**, all sharing a specifier and a line — + * which is why `startColumn` is in `js_import`'s primary key. Without it the + * rows collide **by doubling, not by erroring**, and the edge count is quietly + * wrong. + */ + DESTRUCTURED = 'DESTRUCTURED', + + /** + * `require('./polyfill')` or `import './polyfill'` with nothing bound. + * + * Binds no name and is still a real module edge — the module runs and its side + * effects happen. TypeScript had this defect: an empty import recorded no + * module edge, so a package imported only for its ambient declarations could + * not be staged. + */ + SIDE_EFFECT_ONLY = 'SIDE_EFFECT_ONLY', + + /** + * An import type binds NOTHING — `import("./x").Y` in a comment names a + * type and introduces no local name. + * + * Schema §3.8.1, corrected from a proposal of `NAMED`: `NAMED` with an + * empty `localName` asserts a binding that does not exist, and + * `SIDE_EFFECT_ONLY` asserts a runtime effect a type reference has not. A + * forced value reads as data; this one says what is true. + */ + NO_LOCAL_BINDING = 'NO_LOCAL_BINDING', +} diff --git a/parser/src/enums/javascript/imports/JsImportForm.ts b/parser/src/enums/javascript/imports/JsImportForm.ts new file mode 100644 index 000000000..4c7827fc2 --- /dev/null +++ b/parser/src/enums/javascript/imports/JsImportForm.ts @@ -0,0 +1,47 @@ +/** How a module edge IN was written. Schema §3.8 c3. */ +export enum JsImportForm { + /** + * `require('x')`. 9,055 measured, and **1,227 of them are not top-level**. + * + * That 13.6% is why the import extractor reads the expression worklist rather + * than `sourceFile.statements`: a scan of the statement list, which is what + * every TypeScript module-edge extractor does because every TypeScript module + * edge is a top-level declaration, misses one require in seven. 1,048 sit in a + * function body and 179 in a block. + */ + REQUIRE_CALL = 'REQUIRE_CALL', + + /** `import … from 'x'`. A declaration, and only legal at the top level. */ + IMPORT_DECLARATION = 'IMPORT_DECLARATION', + + /** `import('x')`. An expression returning a promise, legal anywhere, in both systems. */ + DYNAMIC_IMPORT = 'DYNAMIC_IMPORT', + + /** + * `const require = createRequire(import.meta.url)` and the requires through it. + * + * How an ES module reaches CommonJS. Worth its own value because the edge is + * spelled as an ordinary call on a local name, so an extractor matching on the + * identifier `require` alone sees a call to an unknown function. + */ + CREATE_REQUIRE = 'CREATE_REQUIRE', + + /** + * `import x = require('y')`. + * + * TypeScript syntax that `ts.createSourceFile` will parse out of a `.js` file + * if it encounters it. Declared so that a file carrying it produces a module + * edge rather than a parse gap. + */ + IMPORT_EQUALS = 'IMPORT_EQUALS', + + /** + * `/** @type {import("./x").Y} *\/` — an `ImportTypeNode` in a JSDoc type. + * + * Schema §3.8.1. None of the five above: `DYNAMIC_IMPORT` is a call + * expression and this is a type node. Minted so that a typedef whose file + * the engine cannot locate is not the incomplete row §0.2 forbids — the + * name present, the hop absent. `isTypeOnly = true` on every such row. + */ + JSDOC_IMPORT_TYPE = 'JSDOC_IMPORT_TYPE', +} diff --git a/parser/src/enums/javascript/imports/JsImportResolutionOutcome.ts b/parser/src/enums/javascript/imports/JsImportResolutionOutcome.ts new file mode 100644 index 000000000..09d982217 --- /dev/null +++ b/parser/src/enums/javascript/imports/JsImportResolutionOutcome.ts @@ -0,0 +1,44 @@ +/** + * What happened when the specifier was resolved. Schema §3.8 c10. + * + * Resolution here is `ts.resolveModuleName`, a **pure function** of a specifier, + * options and a host. It needs no Program, no typecheck and no installed + * `node_modules` for its answer to be honest: an unresolvable specifier returns + * `undefined`, which is a correct answer and not a missing one. + * + * ## Environmental unresolution is named, not hidden and not counted as a gap + * + * 3.2% of edges do not resolve, and the reasons are not interchangeable. §7 of + * `BUILDING-A-PARSER.md`: missing `node_modules` accounted for 10,068 of zod's + * 10,162 incomplete hand-offs, and *reporting those as parser gaps is wrong; + * hiding them is also wrong.* Naming them is the third option. + */ +export enum JsImportResolutionOutcome { + /** Resolved to a file inside the analysed project. The engine can follow it. */ + RESOLVED_PROJECT = 'RESOLVED_PROJECT', + + /** Resolved into `node_modules`. Real, and outside the project's provenance. */ + RESOLVED_EXTERNAL = 'RESOLVED_EXTERNAL', + + /** + * A Node builtin: `path`, `fs`, `events`, `util`. + * + * **Not a failure.** Calls through one of these are 15.3-24.4% of all oracle + * declines, and the target lives in the `lib_*` population rather than + * anywhere in the repository — which no amount of installing dependencies + * changes. + */ + RESOLVED_BUILTIN = 'RESOLVED_BUILTIN', + + /** + * A package that is not installed, or a relative path that does not exist. + * + * Environmental for the first and a genuine defect for the second: 0 relative + * paths failed to resolve in the measured corpus, so a non-zero count here is + * worth investigating rather than classifying. + */ + UNRESOLVED_MISSING = 'UNRESOLVED_MISSING', + + /** `require(variable)`. Unresolvable by construction, and never guessed. */ + UNRESOLVED_NON_LITERAL = 'UNRESOLVED_NON_LITERAL', +} diff --git a/parser/src/enums/javascript/imports/JsSpecifierKind.ts b/parser/src/enums/javascript/imports/JsSpecifierKind.ts new file mode 100644 index 000000000..d14f4f970 --- /dev/null +++ b/parser/src/enums/javascript/imports/JsSpecifierKind.ts @@ -0,0 +1,28 @@ +/** + * What the specifier syntactically is. Schema §3.8 c1. + * + * `NON_LITERAL` is the value that matters: **17 measured**, each one a module + * edge that is **unresolvable by construction**. `require(name)` where `name` is + * a variable cannot be resolved by any amount of static analysis, and the honest + * row says so — `specifierKind = NON_LITERAL`, `resolvedFilePath = ""`, + * `resolutionOutcome = UNRESOLVED_NON_LITERAL`. + * + * The alternative is to guess, and a guessed module edge is worse than an absent + * one because nothing downstream can tell it from a real one. + */ +export enum JsSpecifierKind { + /** `require('./x')`. The normal case, and the only resolvable one. */ + STRING_LITERAL = 'STRING_LITERAL', + + /** `require(name)`, `require(cond ? a : b)`. 17 measured. Unresolvable, and said so. */ + NON_LITERAL = 'NON_LITERAL', + + /** + * ``require(`./locales/${lang}`)``. + * + * Separate from `NON_LITERAL` because the *shape* is known even though the + * value is not — a consumer can see the directory being indexed into, which is + * enough to stage a whole subtree. A plain `NON_LITERAL` offers nothing. + */ + TEMPLATE = 'TEMPLATE', +} diff --git a/parser/src/enums/javascript/imports/index.ts b/parser/src/enums/javascript/imports/index.ts new file mode 100644 index 000000000..5538ff9e7 --- /dev/null +++ b/parser/src/enums/javascript/imports/index.ts @@ -0,0 +1,5 @@ +export * from './JsEdgeBearer'; +export * from './JsImportBindingForm'; +export * from './JsImportForm'; +export * from './JsImportResolutionOutcome'; +export * from './JsSpecifierKind'; diff --git a/parser/src/enums/javascript/index.ts b/parser/src/enums/javascript/index.ts new file mode 100644 index 000000000..e96bc0dca --- /dev/null +++ b/parser/src/enums/javascript/index.ts @@ -0,0 +1,17 @@ +export * from './blocks'; +export * from './call-sites'; +export * from './comments'; +export * from './common'; +export * from './exports'; +export * from './expressions'; +export * from './fields'; +export * from './heritage'; +export * from './imports'; +export * from './method-parameters'; +export * from './methods'; +export * from './modules'; +export * from './parse-gaps'; +export * from './scopes'; +export * from './type-references'; +export * from './types'; +export * from './variables'; diff --git a/parser/src/enums/javascript/method-parameters/JsParameterBindingForm.ts b/parser/src/enums/javascript/method-parameters/JsParameterBindingForm.ts new file mode 100644 index 000000000..d174560a4 --- /dev/null +++ b/parser/src/enums/javascript/method-parameters/JsParameterBindingForm.ts @@ -0,0 +1,40 @@ +/** + * The syntax that binds a parameter. Schema §3.5 c10. + * + * ## One parameter row, N variable rows + * + * A destructured parameter is **one** `js_method_parameter` row with + * `patternBindingCount > 0`, plus N `js_variable` rows for the names it binds, + * each with `bindingRegime = PARAMETER`. + * + * Both alternatives are worse and both are tempting: + * + * - **N parameter rows** breaks `position`. `function f({ a, b }, c)` has `c` at + * position 1; emitting `a` and `b` as parameters 0 and 1 puts `c` at 2, and + * every arity-based join is then off by one. + * - **One row with no binding information** loses every name, so a call through + * a destructured parameter resolves to nothing. + * + * 6,909 destructuring patterns were measured, so this is not an edge case — it + * is how modern JavaScript writes an options object. + */ +export enum JsParameterBindingForm { + /** `function f(x)`. */ + IDENTIFIER = 'IDENTIFIER', + + /** `function f({ a, b: c })`. Binds by property name, with renaming. */ + OBJECT_PATTERN = 'OBJECT_PATTERN', + + /** `function f([a, , b])`. Binds by position, with holes. */ + ARRAY_PATTERN = 'ARRAY_PATTERN', + + /** + * `function f(x = 1)`. + * + * The default expression is evaluated in the function's **own** scope at call + * time, which is what makes `function f(a, b = a)` work — and is the one place + * the scope builder deliberately diverges from Python's binder, where defaults + * are evaluated in the enclosing scope. + */ + ASSIGNMENT_PATTERN = 'ASSIGNMENT_PATTERN', +} diff --git a/parser/src/enums/javascript/method-parameters/index.ts b/parser/src/enums/javascript/method-parameters/index.ts new file mode 100644 index 000000000..680386cba --- /dev/null +++ b/parser/src/enums/javascript/method-parameters/index.ts @@ -0,0 +1 @@ +export * from './JsParameterBindingForm'; diff --git a/parser/src/enums/javascript/methods/JsBodyPresence.ts b/parser/src/enums/javascript/methods/JsBodyPresence.ts new file mode 100644 index 000000000..dd4986114 --- /dev/null +++ b/parser/src/enums/javascript/methods/JsBodyPresence.ts @@ -0,0 +1,26 @@ +/** + * Whether and how this callable has a body. Schema §3.4 c21. + * + * `EXPRESSION_BODY` is the value that earns the enum. A concise arrow — + * `x => x * 2` — has a body that is an **expression**, not a block, so its + * implicit return has no `return` statement to find. An extractor looking for + * `ReturnStatement` nodes finds none and reports a function that returns + * nothing, which is wrong for every point-free callback in the corpus. + */ +export enum JsBodyPresence { + /** A `{ … }` block. */ + HAS_BODY = 'HAS_BODY', + + /** A concise arrow: `x => expr`. The expression IS the return value. */ + EXPRESSION_BODY = 'EXPRESSION_BODY', + + /** + * No body at all. + * + * An overload signature has no JavaScript equivalent, so in practice this is + * an abstract-shaped member in a `@typedef`, or a parse gap. Kept because an + * always-empty value that is *written down* reads as a decision, and a missing + * one reads as a bug. + */ + NO_BODY = 'NO_BODY', +} diff --git a/parser/src/enums/javascript/methods/JsHoisting.ts b/parser/src/enums/javascript/methods/JsHoisting.ts new file mode 100644 index 000000000..7033d0c77 --- /dev/null +++ b/parser/src/enums/javascript/methods/JsHoisting.ts @@ -0,0 +1,57 @@ +/** + * Whether this callable's name exists before its declaration runs. + * Schema §3.4 c10. + * + * ## The same syntax category, two behaviours, decided by position + * + * ```js + * f(); // works — the declaration hoisted entirely + * function f() {} + * + * g(); // TypeError: g is not a function + * var g = function () {}; // the VAR hoisted; the function did not + * ``` + * + * 5,271 function declarations and 4,325 function expressions were measured. An + * engine that collapses them reports a call to `undefined` as a call to a + * function, or reports a working call as unreachable — and both are wrong in the + * direction that looks plausible. + */ +export enum JsHoisting { + /** + * A function declaration. Name **and body** available from the top of the + * enclosing scope. + * + * Which scope depends on strict mode when the declaration sits in a block: + * strict makes it block-scoped, and sloppy mode's Annex B semantics also bind + * the name in the enclosing function scope. `js_variable`'s two scope columns + * carry the answer. + */ + HOISTED_FULLY = 'HOISTED_FULLY', + + /** + * A function expression or arrow. Nothing hoists. + * + * The *binding* it is assigned to may hoist — a `var` does — but it holds + * `undefined` until the assignment runs, which is a different fact and a + * different column. + */ + NOT_HOISTED = 'NOT_HOISTED', + + /** + * A class method, or a callable bound by `let`/`const`/`class`. + * + * The name exists in its scope but touching it before the declaration throws. + * Distinct from `NOT_HOISTED`, where touching it early yields `undefined`: + * one is a crash and one is a silently wrong value. + */ + TDZ = 'TDZ', + + /** + * Hoisting is not a question for this row. + * + * The `` initializer, a static block, a constructor — none of them + * have a name a scope could hold. + */ + NOT_APPLICABLE = 'NOT_APPLICABLE', +} diff --git a/parser/src/enums/javascript/methods/JsMethodDeclarationForm.ts b/parser/src/enums/javascript/methods/JsMethodDeclarationForm.ts new file mode 100644 index 000000000..3dbae27fb --- /dev/null +++ b/parser/src/enums/javascript/methods/JsMethodDeclarationForm.ts @@ -0,0 +1,56 @@ +/** + * How a callable was declared — as syntax, or as an assignment. Schema §3.4 c9. + * + * ## Members declared by assignment are declarations, not expressions + * + * `Foo.prototype.bar = function () {}` is a **method declaration written as an + * assignment**. By ruling, these mint real `js_method` rows, and they are + * **also** expressions — the assignment really happens at a particular point in + * the program — so the row is minted in both relations and + * `js_method.sourceExpressionLinkHash` ties them. + * + * Both halves matter. Emitting only the expression is the §3 defect class: the + * parts emit trivially and the structure is entirely absent. Emitting only the + * declaration loses the fact that it executes, which in a conditional or an IIFE + * is the whole point. + * + * Gate 7.3.7 asserts the round trip: every row with a form other than + * `SYNTACTIC` has a `sourceExpressionLinkHash` that resolves, and that + * expression has `isDeclarationBearing = true`. + */ +export enum JsMethodDeclarationForm { + /** Written as a function, method, arrow or accessor. 6,586 class members. */ + SYNTACTIC = 'SYNTACTIC', + + /** `Foo.prototype.bar = function () {}`. 361 measured. An instance method. */ + PROTOTYPE_ASSIGNMENT = 'PROTOTYPE_ASSIGNMENT', + + /** + * `Foo.staticM = function () {}`. **521 measured — more common than the + * prototype form.** + * + * Worth knowing, because the prototype idiom is the famous one and the static + * one is what real code does more of. + */ + STATIC_ASSIGNMENT = 'STATIC_ASSIGNMENT', + + /** + * `Foo.prototype = { m() {}, n() {} }`. 15 measured. + * + * Replaces the whole prototype object, so it also **discards** anything + * previously on it — including `constructor`. Each member of the literal is + * its own method row. + */ + PROTOTYPE_OBJECT_LITERAL = 'PROTOTYPE_OBJECT_LITERAL', + + /** + * `Object.defineProperty(Foo.prototype, 'x', { get() {} })`. 76 measured. + * + * The only form that can declare a non-enumerable or non-writable member, and + * the only one where a getter and a setter arrive in one statement. + */ + OBJECT_DEFINE_PROPERTY = 'OBJECT_DEFINE_PROPERTY', + + /** `Object.assign(Foo.prototype, { … })`. 2 measured. A bulk prototype install. */ + OBJECT_ASSIGN_PROTOTYPE = 'OBJECT_ASSIGN_PROTOTYPE', +} diff --git a/parser/src/enums/javascript/methods/JsMethodKind.ts b/parser/src/enums/javascript/methods/JsMethodKind.ts new file mode 100644 index 000000000..c019e0b7a --- /dev/null +++ b/parser/src/enums/javascript/methods/JsMethodKind.ts @@ -0,0 +1,58 @@ +/** + * What kind of callable a `js_method` row describes. Schema §3.4 c8. + * + * Every callable gets a row: function declarations, function expressions, + * arrows, class methods, accessors, prototype-assigned methods, and the + * synthetic `` initializer. + */ +export enum JsMethodKind { + /** `function f() {}` as a statement. 5,271 measured, and all of them hoist. */ + FUNCTION_DECLARATION = 'FUNCTION_DECLARATION', + + /** + * `const f = function () {}`, or a function passed as an argument. + * + * 4,325 measured, and **none of them hoist**. Same syntax category as the + * declaration, opposite behaviour, decided entirely by position — which is why + * `hoisting` is a column. + */ + FUNCTION_EXPRESSION = 'FUNCTION_EXPRESSION', + + /** + * `() => {}`. + * + * 9,391 measured, many sharing a line, which is why `startColumn` is in + * `js_method`'s primary key. Binds neither `this` nor `arguments`. + */ + ARROW = 'ARROW', + + /** A method in a class body, or one assigned to a prototype. */ + CLASS_METHOD = 'CLASS_METHOD', + + /** `constructor() {}`. */ + CONSTRUCTOR = 'CONSTRUCTOR', + + /** + * `get x() {}`. 1,225 measured. + * + * A **property read that invokes a function**. The declaration is emittable + * and the invocation is not — `GETTER_INVOCATION` is reserved with a zero-row + * assertion, because whether `obj.x` invokes anything is a fact about `obj`. + */ + GETTER = 'GETTER', + + /** `set x(v) {}`. 137 measured. Same asymmetry. */ + SETTER = 'SETTER', + + /** + * The synthetic `` method that owns top-level executable code. + * + * Carries more weight here than in TypeScript: a CommonJS file's top level + * genuinely **is** a function body at runtime, because Node wraps it, and the + * 83.6% of module edges that are expression-borne all hang off this row. + */ + MODULE_INITIALIZER = 'MODULE_INITIALIZER', + + /** `static { … }` in a class body. */ + STATIC_BLOCK = 'STATIC_BLOCK', +} diff --git a/parser/src/enums/javascript/methods/JsThisBinding.ts b/parser/src/enums/javascript/methods/JsThisBinding.ts new file mode 100644 index 000000000..d036fad1a --- /dev/null +++ b/parser/src/enums/javascript/methods/JsThisBinding.ts @@ -0,0 +1,58 @@ +/** + * What `this` is inside this callable. Schema §3.4 c17. + * + * ## 33,189 `this` references, and the call form decides what they mean + * + * This is the column with no analogue in any other front end in the repository, + * and it is not a convenience. In Java and Python the receiver of a method is + * fixed at the declaration. In JavaScript: + * + * ```js + * const m = obj.method; m(); // `this` is undefined / global + * obj.method(); // `this` is obj + * obj.method.call(other); // `this` is other + * const bound = obj.method.bind(obj); // `this` is obj, permanently + * arr.map(x => this.f(x)); // `this` is the ENCLOSING function's + * ``` + * + * Every line invokes the same function body and `this` differs. An engine that + * treats every callable as rebinding `this` gets the arrow case wrong, and an + * engine that treats none of them as rebinding gets the other four wrong. + */ +export enum JsThisBinding { + /** + * An arrow function. `this` comes from **where the arrow was written**. + * + * The whole of lexical `this`, and what makes `this.f()` work inside a + * callback without `.bind(this)`. `js_scope.bindsThis` is false for `ARROW` for + * the same reason. + */ + LEXICAL = 'LEXICAL', + + /** + * An ordinary `function`. `this` is decided **at the call site**. + * + * The default, and the source of every "cannot read property of undefined" + * that comes from passing a method as a callback. + */ + DYNAMIC = 'DYNAMIC', + + /** + * The result of `.bind(receiver)`. `this` is fixed and cannot be changed — + * not by `.call`, not by `.apply`. + * + * 189 `.bind` sites measured. The other half of `FUNCTION_CALL_BIND`, which + * records the call that produces the bound function. + */ + BOUND = 'BOUND', + + /** + * `this` is not meaningful here. + * + * A module-level function in an ES module, where `this` is `undefined` + * outright, and the `` initializer, where in CommonJS it is + * `module.exports` and in ESM it is `undefined` — two different values, which + * is why the honest answer is to decline rather than pick one. + */ + NONE = 'NONE', +} diff --git a/parser/src/enums/javascript/methods/index.ts b/parser/src/enums/javascript/methods/index.ts new file mode 100644 index 000000000..9fa2a6408 --- /dev/null +++ b/parser/src/enums/javascript/methods/index.ts @@ -0,0 +1,5 @@ +export * from './JsBodyPresence'; +export * from './JsHoisting'; +export * from './JsMethodDeclarationForm'; +export * from './JsMethodKind'; +export * from './JsThisBinding'; diff --git a/parser/src/enums/javascript/modules/JsContradictionKind.ts b/parser/src/enums/javascript/modules/JsContradictionKind.ts new file mode 100644 index 000000000..d65a8fb2e --- /dev/null +++ b/parser/src/enums/javascript/modules/JsContradictionKind.ts @@ -0,0 +1,46 @@ +/** + * How a file contradicts its governing config, when it does. Schema §3.1 c11. + * + * ## The Q3 ruling, as a vocabulary + * + * `BUILDING-JAVASCRIPT.md` §3 poses this as an open question: a file using + * `import` under `"type": "commonjs"` cannot run, so is it a `SkippedFileReason`, + * a flagged row, or a normal row? The ruling is **emit normally and flag it**, + * and the measurement is why: 170 of 2,738 files — 6.2% — contradict their + * config, and **all 170 are bundler input**, where `package.json` never governs + * anything because a bundler reads the file before Node ever would. + * + * Skipping 6.2% of a real corpus to enforce a runtime rule that does not apply + * to it would be the analyzer inventing a constraint. + * + * ## The asymmetry is the interesting part + * + * `ESM_SYNTAX_UNDER_COMMONJS` is 170 measured occurrences. `REQUIRE_UNDER_ESM` + * is **0** — and that is the direction that really is a runtime crash, since + * `require` is simply not defined in an ES module. The harmless direction is + * common and the fatal one is absent, which is what a corpus of bundler-fed + * source should look like. A future corpus where that inverts is telling you + * something, and it can only be seen because the two have separate values. + */ +export enum JsContradictionKind { + /** The file agrees with its governing config. */ + NONE = 'NONE', + + /** + * Top-level `import`/`export` in a CommonJS-governed file. 170 measured. + * + * Cannot run under Node as-is; runs fine through any bundler. + */ + ESM_SYNTAX_UNDER_COMMONJS = 'ESM_SYNTAX_UNDER_COMMONJS', + + /** + * `require(...)` in an ESM-governed file. **0 measured.** + * + * The direction that genuinely throws at runtime. Kept separate precisely so + * its absence is a reportable fact rather than a value nobody thought of. + */ + REQUIRE_UNDER_ESM = 'REQUIRE_UNDER_ESM', + + /** Both, in one file. */ + MIXED = 'MIXED', +} diff --git a/parser/src/enums/javascript/modules/JsModuleKind.ts b/parser/src/enums/javascript/modules/JsModuleKind.ts new file mode 100644 index 000000000..61040e973 --- /dev/null +++ b/parser/src/enums/javascript/modules/JsModuleKind.ts @@ -0,0 +1,47 @@ +/** + * What kind of module a `js_module` row describes. Schema §3.1 c5. + * + * Short by design. JavaScript has no `declare module`, no `declare global` and + * no declaration files, so the six-value `TsModuleKind` collapses to three — + * and the `js_module` relation is **one row per file, always**, where + * `ts_module` is one row per file plus one per ambient block. + * + * The distinction that survives the collapse is TypeScript's most important + * one, because it decides the same thing here: does the file have a top-level + * `import`/`export`? A `SOURCE_MODULE` has module scope and is always strict; a + * `SCRIPT_GLOBAL` shares the global object and is sloppy unless it says + * `'use strict'`. That is not bookkeeping — in sloppy mode `x = 1` with no + * declaration creates a global binding, and in strict mode it throws. + * + * ```js + * // router.js — `module.exports = Router` → SCRIPT_GLOBAL + * // CommonJS is not an ES module: no top-level import/export syntax. + * // client.mjs — `export function get() {}` → SOURCE_MODULE + * // package.json imported under resolveJsonModule → JSON_MODULE + * ``` + * + * Note what this enum is NOT: it is not `moduleSystem`. A CommonJS file and an + * ESM file can both be `SOURCE_MODULE` — `moduleSystem` records what the + * governing config says, and this records what the file's own syntax does. + * Keeping them apart is what lets `contradictsGoverningConfig` exist. + */ +export enum JsModuleKind { + /** + * A file with a top-level `import` or `export`. + * + * Module scope, and implicitly strict whatever the governing config says. + */ + SOURCE_MODULE = 'SOURCE_MODULE', + + /** + * A file with no top-level `import` or `export`. + * + * Every CommonJS file is this, which is 84.3% of the measured corpus. Its top + * level is a function body at runtime — Node wraps it — which is why the + * synthetic `` initializer is not a convenience here. + */ + SCRIPT_GLOBAL = 'SCRIPT_GLOBAL', + + /** A `.json` file reached by an import. Declarations, no executable code. */ + JSON_MODULE = 'JSON_MODULE', +} diff --git a/parser/src/enums/javascript/modules/JsModuleSystem.ts b/parser/src/enums/javascript/modules/JsModuleSystem.ts new file mode 100644 index 000000000..5ed7a12fa --- /dev/null +++ b/parser/src/enums/javascript/modules/JsModuleSystem.ts @@ -0,0 +1,28 @@ +/** + * Whether this file is CommonJS or an ES module. Schema §3.1 c7, **in the + * primary key**. + * + * ## Why a two-value enum sits in a primary key + * + * `import` in a file governed by `"type": "commonjs"` is a syntax error at + * runtime. `require` in a file governed by `"type": "module"` is `undefined`. + * They are not two spellings of one program — they are two different programs, + * and the source bytes are identical either way. + * + * So the deciding input is a `package.json` **the file does not contain**, and + * 91.4% of measured files are decided by *default* rather than by declaration. + * Editing a `"type"` field three directories up genuinely changes this file's + * facts. Keying on it means the before and after are two distinguishable fact + * sets rather than one silently overwriting the other. + * + * `governingPackageJsonPath` is deliberately **not** in the key: it is the + * evidence for this conclusion, and evidence moving without the conclusion + * moving must not cascade every child hash. + */ +export enum JsModuleSystem { + /** `require`/`module.exports`. The default when nothing says otherwise. */ + COMMONJS = 'COMMONJS', + + /** `import`/`export`. Always strict mode, with a real temporal dead zone. */ + ESM = 'ESM', +} diff --git a/parser/src/enums/javascript/modules/JsModuleSystemSource.ts b/parser/src/enums/javascript/modules/JsModuleSystemSource.ts new file mode 100644 index 000000000..928134620 --- /dev/null +++ b/parser/src/enums/javascript/modules/JsModuleSystemSource.ts @@ -0,0 +1,53 @@ +/** + * **How** `moduleSystem` was decided. Schema §3.1 c8. + * + * ## The column that stops a default from looking like a declaration + * + * 91.4% of the measured corpus gets its module system by default: 76.0% have a + * `package.json` with no `"type"` field, and 15.4% have no `package.json` at + * all. Without this column those files are indistinguishable from the 8.6% that + * genuinely declare CommonJS — and they are not the same claim. One is "this + * project says CommonJS", the other is "nobody said anything and the spec's + * default is CommonJS". + * + * That difference decides how much weight a consumer can put on a + * `contradictsGoverningConfig` flag. ESM syntax under a *declared* CommonJS + * config is a project contradicting itself; ESM syntax under an *absent* + * `package.json` is a file nobody ever configured, which is what all 170 + * measured contradictions turned out to be. + * + * ## Precedence, highest first + * + * `.mjs`/`.cjs` override the governing `package.json` **outright** — they are + * not hints. Everything below them is the nearest-ancestor lookup. + */ +export enum JsModuleSystemSource { + /** `.mjs`. ESM, whatever any `package.json` says. */ + EXT_MJS = 'EXT_MJS', + + /** `.cjs`. CommonJS, whatever any `package.json` says. */ + EXT_CJS = 'EXT_CJS', + + /** The nearest-ancestor `package.json` declares `"type": "module"`. */ + PKG_TYPE_MODULE = 'PKG_TYPE_MODULE', + + /** The nearest-ancestor `package.json` declares `"type": "commonjs"`. */ + PKG_TYPE_COMMONJS = 'PKG_TYPE_COMMONJS', + + /** + * A `package.json` governs the file and has no `"type"` field. 76.0%. + * + * The spec's default is CommonJS, so this is a real answer — but it is an + * answer nobody wrote down, which is the whole reason this enum exists. + */ + PKG_TYPE_ABSENT_DEFAULT = 'PKG_TYPE_ABSENT_DEFAULT', + + /** + * No `package.json` anywhere up the tree. 15.4%. + * + * Common in loose script directories and in anything analysed outside the + * package that ships it. The governing file is frequently not in the + * repository at all, and this value says so rather than inventing one. + */ + NO_PACKAGE_JSON_DEFAULT = 'NO_PACKAGE_JSON_DEFAULT', +} diff --git a/parser/src/enums/javascript/modules/JsScriptKind.ts b/parser/src/enums/javascript/modules/JsScriptKind.ts new file mode 100644 index 000000000..67906fadb --- /dev/null +++ b/parser/src/enums/javascript/modules/JsScriptKind.ts @@ -0,0 +1,29 @@ +/** + * The `ts.ScriptKind` this file was parsed with. Schema §3.1 c6. + * + * ## Recorded as provenance, and never read from a config + * + * `ts-fact-extractor.ts` records that "`ts.ScriptKind` decides whether `<` opens + * JSX, so it cannot be guessed", and that is true for TypeScript: `.ts` and + * `.tsx` genuinely parse differently, because in `.ts` a `` is a type + * assertion. + * + * **For JavaScript it is not.** The schema measured **0 of 2,942 `.js` files** + * parsing differently under `ScriptKind.JS` versus `ScriptKind.JSX`, because + * `ScriptKind.JS` already carries `languageVariant = JSX` inside the compiler — + * there is no type-assertion syntax for `<` to be ambiguous with. So the + * script-kind decision that §3 of `BUILDING-JAVASCRIPT.md` warned about does not + * exist for this language, and the column is here to say which value was + * actually passed rather than to carry a decision. + * + * That is worth having written down: an always-`JS` column reads as a bug to + * the next person otherwise, and the reason it is safe is a measurement, not an + * assumption. + */ +export enum JsScriptKind { + /** `.js`, `.mjs`, `.cjs` — and JSX inside them parses anyway. */ + JS = 'JS', + + /** `.jsx`, where the extension states the intent. */ + JSX = 'JSX', +} diff --git a/parser/src/enums/javascript/modules/JsSourceProvenance.ts b/parser/src/enums/javascript/modules/JsSourceProvenance.ts new file mode 100644 index 000000000..0ee3c4f3e --- /dev/null +++ b/parser/src/enums/javascript/modules/JsSourceProvenance.ts @@ -0,0 +1,78 @@ +/** + * Where this file's bytes came from, for the purpose of counting. Schema §3.1 c24. + * + * ## Bundled output is classified, never counted + * + * A bundler's output is real, valid JavaScript. It is also a single generated + * artefact that can carry more expressions than the entire source tree it was + * built from, and every one of them teaches nothing about the language: the + * identifiers are mangled, the module structure is a numeric map, and the + * inheritance is whatever the transpiler emitted. 43 such files appeared in a + * 2,738-file corpus and were excluded by rule. + * + * The rule has to be a *column*, not a silent skip. Gate 7.3.5 asserts that a + * file whose provenance is not `PROJECT` contributes zero rows to any coverage + * denominator — which is only checkable if the file is in the fact base saying + * what it is. §9 of `BUILDING-A-PARSER.md` is the precedent: when the analyzer + * drops something for a structural reason it must say so, because on one large framework checkout a + * nested config silently excluded 1,270 of 1,821 files and nothing counted them. + */ +export enum JsSourceProvenance { + /** Hand-written source. The only value that contributes to a denominator. */ + PROJECT = 'PROJECT', + + /** + * Detected bundler or minifier output: a `.min.js`-shaped name, or a line + * past the length threshold no hand-written source reaches. + * + * A LABEL, not a rejection (§3.1.1): the file emits in full, and the column + * is how a consumer filters. Its facts are right — just not project source — + * and dropping them at emit time would be the parser deciding what a + * consumer wants. Only `FLOW_REJECTED` withholds rows. The old name, + * `BUNDLED_EXCLUDED`, read as if both did the same thing. + */ + BUNDLED = 'BUNDLED', + + /** + * A generated single-file artefact that is not minified — a concatenated + * build, a compiled template set. Readable, still not source. + */ + GENERATED_MONOLITH = 'GENERATED_MONOLITH', + + /** + * A file carrying Flow syntax. **Out of scope, as a recorded rejection rather + * than an absence.** + * + * ## Why this is a provenance and not a skip + * + * Flow is not JavaScript. `ts.createSourceFile` under `ScriptKind.JS` accepts + * Flow's grammar where it overlaps TypeScript's and mis-parses it where it + * diverges — silently, producing a tree that looks complete. One cause, and it + * surfaced as three separate-looking defects: + * + * - 3,793 parameters carried `declaredTypeSource = SYNTACTIC_FLOW`, 6.5% of + * all shipped-source parameters, indistinguishable in the fact base from + * TypeScript annotations; + * - `declare function flushSync(fn: () => R): R;` minted a `js_method` with + * `bodyPresence = NO_BODY` for a TYPE-ONLY overload declaration, putting + * three method rows where JavaScript has one function and breaking gate 4; + * - error recovery over a Flow cast emitted the same diagnostic repeatedly. + * + * And of 248 `@flow` files, **zero parsed cleanly** — every one carried at + * least one parse gap, averaging 24, accounting for 5,962 of the corpus's + * 5,964 `PARSE_ERROR` gaps. + * + * ## The rejection is RECORDED, which is the whole design + * + * Exactly one `js_module` row is emitted, with this provenance and + * `hasFlowPragma = true`, and nothing anywhere else. A silent skip would be + * §9's nested-config failure — 1,270 of 1,821 files excluded with nothing counting + * them. A consumer can ask how much of a tree was declined and get a number. + * + * It rides on `sourceProvenance` rather than `SkippedFileReason` deliberately: + * that enum is shared by five front ends and Flow is a JavaScript-local + * problem. Unlike `BUNDLED`, which labels a file whose facts are correct, + * this WITHHOLDS: the facts would be wrong rather than unwanted (§3.1.1). + */ + FLOW_REJECTED = 'FLOW_REJECTED', +} diff --git a/parser/src/enums/javascript/modules/index.ts b/parser/src/enums/javascript/modules/index.ts new file mode 100644 index 000000000..57211160f --- /dev/null +++ b/parser/src/enums/javascript/modules/index.ts @@ -0,0 +1,6 @@ +export * from './JsContradictionKind'; +export * from './JsModuleKind'; +export * from './JsModuleSystem'; +export * from './JsModuleSystemSource'; +export * from './JsScriptKind'; +export * from './JsSourceProvenance'; diff --git a/parser/src/enums/javascript/parse-gaps/JsParseGapKind.ts b/parser/src/enums/javascript/parse-gaps/JsParseGapKind.ts new file mode 100644 index 000000000..9fe2a7c3c --- /dev/null +++ b/parser/src/enums/javascript/parse-gaps/JsParseGapKind.ts @@ -0,0 +1,59 @@ +/** + * What the parser could not do, recorded as **data rather than a log line**. + * Schema §3.16 c0. + * + * ## Why this is a relation and not a warning + * + * §9 of `BUILDING-A-PARSER.md`: *if the analyzer drops something for a + * structural reason, it must say so.* On one large framework checkout a nested config silently + * excluded 1,270 of 1,821 files and **nothing counted them** — the run reported + * success with a fact base missing two thirds of the project. A log line is not + * a count, and a count that is not in the fact base cannot be joined against the + * rows that are. + */ +export enum JsParseGapKind { + /** + * `ts.createSourceFile` reported a syntactic diagnostic. + * + * Should be rare. Emitted anyway, because an always-empty relation that + * suddenly has rows is a signal and a missing relation is a silence. + */ + PARSE_ERROR = 'PARSE_ERROR', + + /** + * The depth cap was reached and a subtree was dropped. + * + * The cap is **32**, not TypeScript's effective 20: the corpus has a maximum + * AST depth of 67 and a p99 of 26, so a cap of 20 truncates code that exists. + * The parent is marked `isTruncated`, so a lost subtree is visible rather than + * silent — and the cap stays, because a cap that can never fire is a cap + * nobody maintains. + */ + DEPTH_CAP_REACHED = 'DEPTH_CAP_REACHED', + + /** `require(variable)`. The edge is real and the target is unknowable. */ + NON_LITERAL_SPECIFIER = 'NON_LITERAL_SPECIFIER', + + /** + * A JSDoc type expression the parser could not decompose. + * + * JSDoc type syntax is **not standardised** — Closure, TypeScript and + * jsdoc.app all differ — so this is expected to be non-empty, and the row + * preserves the text rather than dropping the tag or guessing at it. + */ + UNKNOWN_JSDOC_SYNTAX = 'UNKNOWN_JSDOC_SYNTAX', + + /** A `with` body, where no name is statically resolvable. */ + WITH_STATEMENT_SCOPE = 'WITH_STATEMENT_SCOPE', + + /** `eval` or `new Function`. The call is emitted; the target cannot be. */ + DYNAMIC_CODE = 'DYNAMIC_CODE', + + /** + * Flow syntax the TypeScript grammar does not share. + * + * `ts.createSourceFile` mis-parses these **silently**, so the gap row is the + * only place the disagreement becomes visible. + */ + FLOW_SYNTAX = 'FLOW_SYNTAX', +} diff --git a/parser/src/enums/javascript/parse-gaps/index.ts b/parser/src/enums/javascript/parse-gaps/index.ts new file mode 100644 index 000000000..37e977376 --- /dev/null +++ b/parser/src/enums/javascript/parse-gaps/index.ts @@ -0,0 +1 @@ +export * from './JsParseGapKind'; diff --git a/parser/src/enums/javascript/scopes/JsScopeKind.ts b/parser/src/enums/javascript/scopes/JsScopeKind.ts new file mode 100644 index 000000000..35f631629 --- /dev/null +++ b/parser/src/enums/javascript/scopes/JsScopeKind.ts @@ -0,0 +1,125 @@ +/** + * What kind of scope a `js_scope` row describes. Schema §3.13 c0. + * + * ## This relation is why JavaScript is a Python port and not a TypeScript one + * + * There is **no `ts_*` analogue at all.** The TypeScript spine has no scope + * relation because declared types carry resolution: 85.3% of its parameters are + * annotated, so a receiver's type is readable at the declaration site and a + * binder pass is not needed to be useful. In JavaScript effectively no parameters + * carry a syntactic annotation, and the oracle itself decides only 52.6% of call + * sites. The binder *is* the resolution mechanism here, so the scope tree is a + * first-class relation rather than an internal detail. + * + * ## The distinction that carries the most weight + * + * `isFunctionScope` — true for `FUNCTION`, `ARROW`, `MODULE` and `GLOBAL` — + * is what makes hoisting representable. `var` hoists to the nearest function + * scope; `let` stops at the nearest block. Those are two different scopes for + * the same declaration, which is why `js_variable` has **two** scope columns and + * not one. + * + * ## Blocks and scopes are not the same thing + * + * `js_block` is syntax; `js_scope` is binding. A bare `{}` containing only `var` + * declarations opens no scope at all, and a function's parameter list and body + * are one scope spanning two syntactic regions. Keeping them in separate + * relations is what lets `js_block.opensScope` be false without losing the block. + */ +export enum JsScopeKind { + /** + * The ambient scope holding the platform's own bindings. + * + * One row per module, and the only kind whose `parentScopeLinkHash` is `""`. + * It is per-module in the fact base because every row carries an + * `ownerModuleLinkHash` and the parser emits per-file facts — it cannot mint a + * row shared across files without doing cross-file work. + * + * **The row means the global scope as observed from this module.** Proving + * that the N rows across N modules are one runtime scope is a join, and joins + * are the engine's. Ruled rather than assumed: the alternative — no `GLOBAL` + * row — leaves two columns pointing at nothing, and a dangling FK to save one + * row per file is a bad trade. + * + * It is not decoration. Two things need a scope above the module's: + * `bindingResolution = GLOBAL_BUILTIN`, for a name like `console` that + * resolves to nothing in any lexical scope; and `GLOBAL_IMPLICIT`, the + * sloppy-mode `x = 1` with no declaration, which creates a binding **visible + * to other files**. Parenting that at the module scope would assert it is + * module-local, which is the one thing it is not. + */ + GLOBAL = 'GLOBAL', + + /** + * The file's own top-level scope. One per module, always. + * + * A function scope for `var` purposes under both module systems, and for + * different reasons: an ES module's top level genuinely is its own scope, and + * a CommonJS file's top level is a **function body** at runtime because Node + * wraps it in one. So a top-level `var` in a `.cjs` file is module-local + * rather than global, and this is the scope it lands in. + */ + MODULE = 'MODULE', + + /** + * A `function` — declaration, expression, method, constructor or accessor. + * + * Binds `this` dynamically and binds `arguments`. Both are what an arrow does + * not do, and that difference is the reason `ARROW` is a separate kind rather + * than a flag. + */ + FUNCTION = 'FUNCTION', + + /** + * An arrow function. + * + * A function scope for `var`, and **not** a `this` scope: an arrow inherits + * `this` and `arguments` from wherever it was written. That is what makes + * `this` usable inside a callback without `.bind(this)`, and it is why + * `bindsThis` is a column — an engine that treats every callable as rebinding + * `this` gets 33,189 measured `this` references wrong in the direction that + * looks plausible. + */ + ARROW = 'ARROW', + + /** + * A block: `{ … }`, a loop body, a `switch` body. + * + * Holds `let`, `const` and `class` bindings, and holds no `var` binding ever. + * A loop head is part of its body's scope for `let i`, which is what makes + * each iteration's `i` a distinct binding. + */ + BLOCK = 'BLOCK', + + /** + * A `catch (e) { … }` clause. + * + * Its own kind because the parameter is bound by a form that is neither a + * declaration nor a function parameter — hence `CATCH_PARAMETER` as its own + * binding regime. + */ + CATCH = 'CATCH', + + /** + * A class body. + * + * Two things make it a scope: the class's own name is bound inside it (so a + * method can refer to the class even when the binding outside was + * reassigned), and a class body is **always strict** regardless of anything + * around it. + */ + CLASS = 'CLASS', + + /** A `static { … }` block. A function-like scope with its own `this`. */ + CLASS_STATIC_BLOCK = 'CLASS_STATIC_BLOCK', + + /** + * A `with (obj) { … }` body. + * + * Every name inside it is **statically unresolvable**, because whether an + * identifier is a property of `obj` is a runtime fact. The honest answer is to + * mark the scope — `hasWithStatement` — rather than emit confident bindings + * that may all be wrong. + */ + WITH = 'WITH', +} diff --git a/parser/src/enums/javascript/scopes/JsStrictModeSource.ts b/parser/src/enums/javascript/scopes/JsStrictModeSource.ts new file mode 100644 index 000000000..5d3b34270 --- /dev/null +++ b/parser/src/enums/javascript/scopes/JsStrictModeSource.ts @@ -0,0 +1,56 @@ +/** + * How a scope came to be strict, or why it is not. Schema §3.13 c7. + * + * ## Strict mode is not bookkeeping + * + * In sloppy mode `x = 1` with no declaration **creates a global binding**. In + * strict mode the same line **throws**. So the same source text is a binding in + * one file and an error in another, and the deciding input is either a directive + * inside the file or the module system decided by a `package.json` the file does + * not contain. + * + * That is precisely why `js_module.moduleSystem` is in the module's primary key: + * the two answers are two different programs, and `GLOBAL_IMPLICIT` exists in + * one of them and not the other. + * + * Recording the *source* rather than only the boolean matters for the same + * reason `moduleSystemSource` does. A strictness inherited from an ES module is + * a property of the whole file; one from a `'use strict'` directive at the top of + * one function is a property of that function; one from a class body applies to + * a region nobody wrote a directive for. A consumer asking "could this + * assignment have created a global?" needs to know which. + */ +export enum JsStrictModeSource { + /** + * The file is an ES module, so every scope in it is strict. + * + * Not overridable and not opt-out: there is no way to write a sloppy ES + * module. + */ + ESM_IMPLICIT = 'ESM_IMPLICIT', + + /** + * A `'use strict'` directive at the top of this scope or an enclosing one. + * + * The CommonJS route, and the only one available to a CommonJS file. + */ + USE_STRICT_DIRECTIVE = 'USE_STRICT_DIRECTIVE', + + /** + * A class body, which is strict whatever surrounds it. + * + * The one source that applies to a region with no directive in it and no + * module system behind it — a class in a sloppy CommonJS file has a strict + * body, and every method in it is strict too. + */ + CLASS_BODY_IMPLICIT = 'CLASS_BODY_IMPLICIT', + + /** + * Not strict. + * + * The default for CommonJS, which is 84.3% of the measured corpus. This is the + * value that makes `GLOBAL_IMPLICIT` possible, so it is the one a consumer + * checks before believing an undeclared assignment is a global. + */ + SLOPPY = 'SLOPPY', +} diff --git a/parser/src/enums/javascript/scopes/index.ts b/parser/src/enums/javascript/scopes/index.ts new file mode 100644 index 000000000..57f594ae9 --- /dev/null +++ b/parser/src/enums/javascript/scopes/index.ts @@ -0,0 +1,2 @@ +export * from './JsScopeKind'; +export * from './JsStrictModeSource'; diff --git a/parser/src/enums/javascript/type-references/JsTypeReferenceContextKind.ts b/parser/src/enums/javascript/type-references/JsTypeReferenceContextKind.ts new file mode 100644 index 000000000..731311319 --- /dev/null +++ b/parser/src/enums/javascript/type-references/JsTypeReferenceContextKind.ts @@ -0,0 +1,59 @@ +/** + * Which JSDoc tag this type tree hangs off. Schema §3.14 c7. + * + * `TEMPLATE` is the one that replaces a whole relation. `@template` appears + * 624 times, and the schema's ruling is that a separate `js_type_parameter` + * table for 624 comment-borne rows is not worth a table — so a type parameter + * is a `js_type_reference` row with this context. That is a deliberate departure + * from `ts_type_parameter` and it is recorded here so the absence reads as a + * decision. + */ +export enum JsTypeReferenceContextKind { + /** `@param {T} x`. 14,307 measured. */ + PARAM = 'PARAM', + + /** `@returns {T}`. 6,073 measured. */ + RETURN = 'RETURN', + + /** `@type {T}` on a variable. */ + VARIABLE = 'VARIABLE', + + /** `@type {T}` on a member. 8,869 `@type` tags in total. */ + FIELD = 'FIELD', + + /** `@typedef {T} Name` / `@callback Name`. The type being declared. */ + TYPEDEF = 'TYPEDEF', + + /** `@template T`. 624 measured, and the reason there is no separate relation. */ + TEMPLATE = 'TEMPLATE', + + /** `@this {T}` — the receiver's declared type, which nothing else can state. */ + THIS = 'THIS', + + /** `@extends {T}`. An inheritance edge asserted in a comment. */ + EXTENDS = 'EXTENDS', + + /** `@implements {T}`. JavaScript has no `implements`, so a comment is the only route. */ + IMPLEMENTS = 'IMPLEMENTS', + + /** + * An inline JSDoc cast: `/** @type {T} *\/ (expr)` — the PARENTHESISED form, + * which is the one the compiler treats as a type assertion. + * + * The position the vocabulary had no slot for. Every other context names a + * DECLARATION position; a cast types an expression, so there was nothing to + * emit for one — and 1,942 of 3,766 type-reference misses were exactly this. + * 29.3% of all `@type` tags in JSDoc-typed code are casts, against 0.2% on a + * variable declaration: in that style the cast is how a callback gets a type + * at all. Owned by the EXPRESSION the parentheses wrap (§3.14.2), since a + * parenthesis emits no row of its own. + */ + CAST = 'CAST', + + /** + * `@throws {T}` / `@exception {T}` on a callable. The closest JavaScript comes + * to a throws clause, and it is a comment with no enforcement. Owned by the + * METHOD it documents. + */ + THROWS = 'THROWS', +} diff --git a/parser/src/enums/javascript/type-references/JsTypeReferenceKind.ts b/parser/src/enums/javascript/type-references/JsTypeReferenceKind.ts new file mode 100644 index 000000000..55615cf62 --- /dev/null +++ b/parser/src/enums/javascript/type-references/JsTypeReferenceKind.ts @@ -0,0 +1,94 @@ +/** + * A node in a JSDoc type expression. Schema §3.14 c1. + * + * ## The JavaScript type system in its entirety, and it lives in comments + * + * `Array>` is **three rows**, not a string. The same + * parent-FK tree shape `ts_type_reference` uses, capped at depth 32, because a + * consumer that has to re-parse a string to find the generic argument is one + * that will get it wrong on the first nested union. + * + * ## `UNKNOWN_SYNTAX` is deliberate and expected to be non-empty + * + * JSDoc type syntax is **not standardised**. Closure, TypeScript and jsdoc.app + * all differ — on `!T`, on `Object`, on `function(this:T, …)`. A type + * expression the parser cannot decompose gets **one row with its text + * preserved**, rather than a guess or a dropped tag. Guessing would put a + * plausible wrong type into the fact base; dropping would lose the only + * declared-type channel the language has. + */ +export enum JsTypeReferenceKind { + /** `string`, `Foo`, `Bar.Baz`. A name. */ + NAMED = 'NAMED', + + /** `string|number`. Operands are children. */ + UNION = 'UNION', + + /** `A&B`. Rare in JSDoc and legal. */ + INTERSECTION = 'INTERSECTION', + + /** `string[]` or `Array` written as an array shorthand. */ + ARRAY = 'ARRAY', + + /** `Array`, `Object`, `Promise`. The name plus its arguments as children. */ + GENERIC_APPLICATION = 'GENERIC_APPLICATION', + + /** + * `function(string): number`. + * + * A callable **shape**, and therefore the row most likely to be mistaken for a + * call target. `isTypeOnly` is true — as it is for every row here — and the + * gate asserts no `js_call_site` resolves into this relation. + */ + FUNCTION_TYPE = 'FUNCTION_TYPE', + + /** `{a: string, b: number}` — an inline record shape. */ + OBJECT_TYPE = 'OBJECT_TYPE', + + /** A literal in type position: `'get'|'post'`, `42`. */ + TYPE_LITERAL = 'TYPE_LITERAL', + + /** `?T` — nullable, in Closure's spelling. */ + NULLABLE = 'NULLABLE', + + /** `!T` — explicitly non-nullable, in Closure's spelling. */ + NON_NULLABLE = 'NON_NULLABLE', + + /** `T=` or `[x]` — an optional parameter. */ + OPTIONAL = 'OPTIONAL', + + /** `...T` — a rest parameter's element type. */ + REST = 'REST', + + /** `*` or `any`. Declared, and declaring nothing. */ + ANY = 'ANY', + + /** + * `import("./x").Y` — JavaScript's `import type`, written in a comment. + * + * Schema §3.14.4. The qualifier (`Y`) is the row's `typeName`; the + * specifier is a `js_import` row with `isTypeOnly = true`, reached through + * `importLinkHash`, because a typedef whose file the engine cannot locate is + * the incomplete row §0.2 forbids. Type arguments are children. 3,718 of these + * sat under UNKNOWN_SYNTAX with their text intact and their hop absent. + */ + IMPORT_TYPE = 'IMPORT_TYPE', + + /** `[number, number]`. Elements are children, in order. */ + TUPLE = 'TUPLE', + + /** `T['key']`. The object type and the index type are the two children. */ + INDEXED_ACCESS = 'INDEXED_ACCESS', + + /** `typeof x` — the type of a value. The name is the expression as written. */ + TYPE_QUERY = 'TYPE_QUERY', + + /** + * `x is string` / `asserts x is T`. Arrives as the return of a + * FUNCTION_TYPE; the asserted type is the child, the name is the parameter. + */ + TYPE_PREDICATE = 'TYPE_PREDICATE', + + /** Text the parser could not decompose. Preserved verbatim, never guessed. */ + UNKNOWN_SYNTAX = 'UNKNOWN_SYNTAX', +} diff --git a/parser/src/enums/javascript/type-references/JsTypeReferenceOwnerKind.ts b/parser/src/enums/javascript/type-references/JsTypeReferenceOwnerKind.ts new file mode 100644 index 000000000..f125144fc --- /dev/null +++ b/parser/src/enums/javascript/type-references/JsTypeReferenceOwnerKind.ts @@ -0,0 +1,35 @@ +/** + * Which relation `ownerLinkHash` points into. Schema §3.14 c9. + * + * A polymorphic FK discriminated by a sibling column, the same shape + * `js_export.targetKind` uses — one column plus a discriminator rather than five + * mostly-empty FK columns a reader has to check in turn. + */ +export enum JsTypeReferenceOwnerKind { + METHOD = 'METHOD', + METHOD_PARAMETER = 'METHOD_PARAMETER', + FIELD = 'FIELD', + VARIABLE = 'VARIABLE', + TYPE = 'TYPE', + + /** + * A `js_expression` row. `ownerLinkHash` → `js_expression`. + * + * ## One value, because ownerKind names the RELATION + * + * Ruled by js-oracle after 396 `@type` positions were found with nowhere to + * be owned: 268 on an object-literal property (`{ /** @type {T} *\/ items: [] }` + * — §3.2 rules that an object literal is a value, so there is no js_field) and + * 128 on an exports-member assignment (`/** @type {T} *\/ exports.X = …`, which + * mints a module edge, not a type declaration). Both are expressions, and the + * construct distinction between them is already carried by the expression + * row's own `expressionKind`, so a second owner value would say what a join + * already says. + * + * Emitted only when no declaration path claimed the annotation: `this.x =` in + * a constructor is a FIELD's, `ret = …` is a VARIABLE's, `Foo.prototype.m =` + * is a FIELD's. This is the owner of last resort, not a second route to any + * of those. + */ + EXPRESSION = 'EXPRESSION', +} diff --git a/parser/src/enums/javascript/type-references/index.ts b/parser/src/enums/javascript/type-references/index.ts new file mode 100644 index 000000000..dc43100ad --- /dev/null +++ b/parser/src/enums/javascript/type-references/index.ts @@ -0,0 +1,3 @@ +export * from './JsTypeReferenceContextKind'; +export * from './JsTypeReferenceKind'; +export * from './JsTypeReferenceOwnerKind'; diff --git a/parser/src/enums/javascript/types/JsEvidenceKind.ts b/parser/src/enums/javascript/types/JsEvidenceKind.ts new file mode 100644 index 000000000..cb41a0bbf --- /dev/null +++ b/parser/src/enums/javascript/types/JsEvidenceKind.ts @@ -0,0 +1,20 @@ +/** + * What this row's existence rests on. Schema §3.2 c12. + * + * A two-value enum that exists because of one number: **1,825 `js_type` rows have + * no declaration syntax anywhere.** A `@typedef` is a type declared in a + * comment, and a consumer that assumes every type row corresponds to a + * `class` or `function` keyword somewhere will look for one and not find it. + * + * Gate: every row with `COMMENT_ONLY` has a `jsdocCommentLinkHash` pointing at a + * `js_comment` row whose `declaresType` is true. That is what makes the claim + * checkable rather than asserted — the comment relation and the type relation + * have to agree about which comments are declarations. + */ +export enum JsEvidenceKind { + /** A `class` or `function` keyword in the source. */ + SYNTAX = 'SYNTAX', + + /** A JSDoc `@typedef`/`@callback` and nothing else. 1,825 rows. */ + COMMENT_ONLY = 'COMMENT_ONLY', +} diff --git a/parser/src/enums/javascript/types/JsTypeCategory.ts b/parser/src/enums/javascript/types/JsTypeCategory.ts new file mode 100644 index 000000000..69d5fa558 --- /dev/null +++ b/parser/src/enums/javascript/types/JsTypeCategory.ts @@ -0,0 +1,56 @@ +/** + * What kind of type a `js_type` row describes. Schema §3.2 c8. + * + * ## Object literals are NOT here, and that is a deliberate omission + * + * An object literal is a **value**. Treating every one as a type is how a + * JavaScript fact base acquires 50,000 meaningless types, and it is a tempting + * mistake because the pre-ES6 module pattern really does use an object literal + * where a modern codebase would use a class. The literal still reaches the fact + * base — as a `js_expression`, with `js_variable.initializerKind = + * OBJECT_LITERAL` naming it — so a call through one of its properties is + * followable without a fictional type row. + */ +export enum JsTypeCategory { + /** `class Foo { … }` or a named `class` expression. 6,586 members measured. */ + CLASS = 'CLASS', + + /** + * A function used as a constructor: `function Foo() { this.x = 1 }` with + * prototype members hung off it. + * + * The pre-ES6 class, and the reason `js_method`/`js_field` accept rows minted + * from assignments. 361 prototype methods and 233 prototype properties were + * measured, and the runtime's standard library has been modernised since — a 2015-era corpus + * inverts those counts. + */ + CONSTRUCTOR_FUNCTION = 'CONSTRUCTOR_FUNCTION', + + /** + * `@typedef {{a: string}} Foo` — **a type whose only evidence is a comment.** + * + * 1,825 measured. `js_type` therefore has rows with `evidenceKind = + * COMMENT_ONLY` and a `startLine` inside a comment, and an FK from a + * `js_variable` to one of them is an ordinary FK. `isTypeOnly` is true and no + * call-graph rule may traverse it. + */ + JSDOC_TYPEDEF = 'JSDOC_TYPEDEF', + + /** + * `@callback Handler` — a function *shape* declared in a comment. 103 measured. + * + * Type-only, like `JSDOC_TYPEDEF`, and worth its own value because it names a + * callable and is therefore the one most likely to be mistaken for a call + * target. It is not one. + */ + JSDOC_CALLBACK = 'JSDOC_CALLBACK', + + /** + * An anonymous class expression: `module.exports = class { … }`. + * + * Its only name is its file's, which is why `js_type`'s key chains off + * `ownerModuleLinkHash` rather than re-deriving a qualified name — two such + * files in one directory would collide on any name-derived key. + */ + ANONYMOUS_CLASS = 'ANONYMOUS_CLASS', +} diff --git a/parser/src/enums/javascript/types/JsTypeDeclarationForm.ts b/parser/src/enums/javascript/types/JsTypeDeclarationForm.ts new file mode 100644 index 000000000..a98aab243 --- /dev/null +++ b/parser/src/enums/javascript/types/JsTypeDeclarationForm.ts @@ -0,0 +1,34 @@ +/** + * The syntax that declared this type. Schema §3.2 c9. + * + * Separate from {@link JsTypeCategory} because the two answer different + * questions: the category is *what kind of thing is this*, and the form is *how + * was it written*. A constructor function and a class are different categories; + * a class declaration and a class expression are one category written two ways, + * and the difference decides whether the name hoists. + */ +export enum JsTypeDeclarationForm { + /** `class Foo { … }` as a statement. The name is in a temporal dead zone. */ + CLASS_DECLARATION = 'CLASS_DECLARATION', + + /** + * `const Foo = class { … }`, or a class as an argument or an export value. + * + * Does not hoist at all, unlike a function expression's *declaration* + * counterpart — the distinction `js_method.hoisting` records. + */ + CLASS_EXPRESSION = 'CLASS_EXPRESSION', + + /** + * `function Foo() { this.x = 1 }` recognised as a constructor. + * + * Recognised by evidence, never by naming convention: a `new Foo()` somewhere, + * a `Foo.prototype.x = …` assignment, or `this.x = …` in the body. A + * capital-letter heuristic would classify every capitalised import as a + * constructor. + */ + PROTOTYPE_CONSTRUCTOR = 'PROTOTYPE_CONSTRUCTOR', + + /** `@typedef` or `@callback`. The declaration is a comment. */ + JSDOC_TYPEDEF = 'JSDOC_TYPEDEF', +} diff --git a/parser/src/enums/javascript/types/index.ts b/parser/src/enums/javascript/types/index.ts new file mode 100644 index 000000000..add4443cf --- /dev/null +++ b/parser/src/enums/javascript/types/index.ts @@ -0,0 +1,3 @@ +export * from './JsEvidenceKind'; +export * from './JsTypeCategory'; +export * from './JsTypeDeclarationForm'; diff --git a/parser/src/enums/javascript/variables/JsBindingRegime.ts b/parser/src/enums/javascript/variables/JsBindingRegime.ts new file mode 100644 index 000000000..3dbce4e6a --- /dev/null +++ b/parser/src/enums/javascript/variables/JsBindingRegime.ts @@ -0,0 +1,89 @@ +/** + * How a name is bound, and therefore where it is visible and when. + * Schema §3.7 c2, and `js_method_parameter` c12. + * + * ## Two regimes coexist in every real JavaScript file + * + * `var` (3,705 measured) is function-scoped and hoisted: the name exists from + * the first instruction of its function, holding `undefined`, whatever line it + * is written on. `let`/`const` (41,353) are block-scoped with a **temporal dead + * zone**: the name exists in its block but touching it before the declaration + * throws. + * + * This is Python's problem, not TypeScript's — `python-scope-builder.ts` is the + * model. And it is not expressible as a flag, because the two regimes put the + * *same declaration* in two different scopes, which is why `js_variable` carries + * `declarationScopeLinkHash` **and** `syntacticScopeLinkHash`. + * + * ## The pair that looks like one category and is two + * + * Function *declarations* (5,271) hoist entirely — name and body both, so a call + * above the declaration works. Function *expressions* (4,325) do not hoist at + * all. Same syntax category, opposite behaviour, decided by position. An engine + * that collapses them reports a call to an undefined value as a call to a + * function, or the reverse. + */ +export enum JsBindingRegime { + /** + * `var`. Function-scoped, hoisted, no dead zone. + * + * The one regime whose declaration scope and syntactic scope genuinely differ: + * a `var` inside a block is visible from the top of the enclosing *function*. + * Gate 7.3.3 asserts every binding with this regime has a declaration scope + * whose `isFunctionScope` is true — the hoisting model checked rather than + * assumed. + */ + VAR_FUNCTION_SCOPED_HOISTED = 'VAR_FUNCTION_SCOPED_HOISTED', + + /** `let`. Block-scoped, with a temporal dead zone. */ + LET_BLOCK_TDZ = 'LET_BLOCK_TDZ', + + /** `const`. Block-scoped, with a temporal dead zone, and not reassignable. */ + CONST_BLOCK_TDZ = 'CONST_BLOCK_TDZ', + + /** + * A `function f() {}` statement. + * + * Hoisted with its body, so `f()` above the declaration is a working call. + * Where it hoists *to* depends on strict mode when the declaration sits in a + * block: strict mode makes it block-scoped, and sloppy mode's Annex B + * semantics also bind the name in the enclosing function scope. + */ + FUNCTION_DECLARATION_HOISTED = 'FUNCTION_DECLARATION_HOISTED', + + /** + * A `class` declaration or a named class expression. + * + * Block-scoped with a dead zone, like `let` — a class is not hoisted, which + * surprises people often enough that it is worth a distinct value. + */ + CLASS_TDZ = 'CLASS_TDZ', + + /** + * A function parameter. + * + * Bound in the function's own scope, before the body runs. Also the regime on + * the N `js_variable` rows a destructured parameter binds: the parameter row + * keeps `position`, and the names it binds are variables. + */ + PARAMETER = 'PARAMETER', + + /** A `catch (e)` parameter. Bound in the catch clause's own scope. */ + CATCH_PARAMETER = 'CATCH_PARAMETER', + + /** A name bound by an `import` declaration. Module-scoped, and immutable. */ + IMPORT_BINDING = 'IMPORT_BINDING', + + /** + * A sloppy-mode assignment to a name nobody declared. + * + * `x = 1` at the top of a non-strict file creates a property on the global + * object. It is **a binding with no declaration**, and it is the reason the + * scope tree cannot be derived from declarations alone — which is the reason + * `js_scope` is a relation rather than a computed view. + * + * Only possible where `isStrictMode` is false, so a consumer can check the + * scope before believing it. + */ + GLOBAL_IMPLICIT = 'GLOBAL_IMPLICIT', +} diff --git a/parser/src/enums/javascript/variables/JsInitializerKind.ts b/parser/src/enums/javascript/variables/JsInitializerKind.ts new file mode 100644 index 000000000..6b368fb67 --- /dev/null +++ b/parser/src/enums/javascript/variables/JsInitializerKind.ts @@ -0,0 +1,63 @@ +/** + * What a binding was initialised with, when that changes what the name means. + * Schema §3.7 c13. + * + * ## `REQUIRE_CALL` is the single most load-bearing value in this schema + * + * `const x = require('y'); x.foo()` accounts for **15,759 of 45,804 oracle + * declines — 34.4%**, the largest single cause by a wide margin. The call + * `x.foo()` is unresolvable to the checker because `x` has no declared type, but + * it is perfectly *reconstructable* by an engine: `x` is an alias for a module, + * and the module is named by a `js_import` row that carries `resolvedFilePath`. + * + * This value is the hop that makes that reconstruction possible. Without it the + * engine sees a local constant holding an opaque value; with it, plus + * `importLinkHash`, it sees a module alias. Those three columns are the whole of + * §5's resolution story, and they are why the 52.6% ceiling does not cap the + * engine. + * + * The values are deliberately coarse. This column answers "does the name mean + * something other than a value?", not "what is the value" — the initializer + * expression is already a row, linked by `initializerExpressionLinkHash`. + */ +export enum JsInitializerKind { + /** No initializer. A `var` with no value, or a `let` declared and assigned later. */ + NONE = 'NONE', + + /** + * `require('x')`, or a destructured `const { a } = require('x')`. + * + * The name is a module alias. 34.4% of all oracle declines are calls through + * one of these. + */ + REQUIRE_CALL = 'REQUIRE_CALL', + + /** A name bound by an `import` declaration. Also a module alias, by the other route. */ + IMPORT_BINDING = 'IMPORT_BINDING', + + /** + * A function expression or arrow. + * + * The name is a call target declared without the `function f()` syntax, and + * it does **not** hoist — which is the distinction + * `FUNCTION_DECLARATION_HOISTED` exists against. + */ + FUNCTION = 'FUNCTION', + + /** A class expression. The name is a constructor. */ + CLASS = 'CLASS', + + /** + * An object literal. + * + * Recorded because the pre-ES6 module pattern is an object literal of + * functions, and a call through one of its properties is a real call edge that + * no `js_type` row describes. Object literals are deliberately NOT types — + * treating every one as a type is how a JavaScript fact base acquires 50,000 + * meaningless types. + */ + OBJECT_LITERAL = 'OBJECT_LITERAL', + + /** Anything else: a literal, a call, a member access, an operator. */ + OTHER = 'OTHER', +} diff --git a/parser/src/enums/javascript/variables/JsVariableBindingForm.ts b/parser/src/enums/javascript/variables/JsVariableBindingForm.ts new file mode 100644 index 000000000..5bc8a20c3 --- /dev/null +++ b/parser/src/enums/javascript/variables/JsVariableBindingForm.ts @@ -0,0 +1,34 @@ +/** + * The syntax that bound a name. Schema §3.7 c6 and §3.5 c10. + * + * ## Why a destructuring pattern is one row plus N, and not N rows + * + * 6,909 destructuring patterns were measured. A destructured *parameter* is + * **one** `js_method_parameter` row — because `position` has to stay meaningful, + * and `function f({ a, b }, c)` has `c` at position 1, not 2 — plus N + * `js_variable` rows for the names it actually binds. + * + * Emitting N parameter rows breaks `position`. Emitting one row with no binding + * information loses every name. That is §3 of `BUILDING-A-PARSER.md` exactly: + * *the parts get emitted, the structure does not.* The fix is the same one Java + * had first — one node for the construct, its parts parented to it, and the + * variant in a column. + */ +export enum JsVariableBindingForm { + /** A plain name: `const x = 1`. */ + IDENTIFIER = 'IDENTIFIER', + + /** `const { a, b: c } = o`. Binds by property name, with renaming. */ + OBJECT_PATTERN = 'OBJECT_PATTERN', + + /** `const [a, , b] = xs`. Binds by position, with holes. */ + ARRAY_PATTERN = 'ARRAY_PATTERN', + + /** + * `function f(x = 1)`, or `const { a = 1 } = o`. + * + * A parameter-only form in practice: the default makes the parameter optional + * and the expression runs at call time, in the function's own scope. + */ + ASSIGNMENT_PATTERN = 'ASSIGNMENT_PATTERN', +} diff --git a/parser/src/enums/javascript/variables/index.ts b/parser/src/enums/javascript/variables/index.ts new file mode 100644 index 000000000..429649ac7 --- /dev/null +++ b/parser/src/enums/javascript/variables/index.ts @@ -0,0 +1,3 @@ +export * from './JsBindingRegime'; +export * from './JsInitializerKind'; +export * from './JsVariableBindingForm'; diff --git a/parser/src/enums/properties/PropertyDelimiter.ts b/parser/src/enums/properties/PropertyDelimiter.ts new file mode 100644 index 000000000..459cfc681 --- /dev/null +++ b/parser/src/enums/properties/PropertyDelimiter.ts @@ -0,0 +1,32 @@ +/** + * Delimiter type used to separate key from value in a .properties file line. + * + * ## Examples + * + * ```properties + * # EQUALS delimiter + * app.name=Auth Service + * + * # COLON delimiter + * app.name: Auth Service + * + * # SPACE delimiter (first whitespace after key) + * app.name Auth Service + * + * # NONE - key with no value at all (no delimiter present) + * some.flag + * ``` + */ +export enum PropertyDelimiter { + /** Key-value separated by `=` (most common) */ + EQUALS = 'EQUALS', + + /** Key-value separated by `:` */ + COLON = 'COLON', + + /** Key-value separated by whitespace only (no `=` or `:`) */ + SPACE = 'SPACE', + + /** Key has no delimiter and no value (standalone key, value defaults to empty string) */ + NONE = 'NONE', +} diff --git a/parser/src/enums/properties/PropertyValueSegmentType.ts b/parser/src/enums/properties/PropertyValueSegmentType.ts new file mode 100644 index 000000000..045dfd125 --- /dev/null +++ b/parser/src/enums/properties/PropertyValueSegmentType.ts @@ -0,0 +1,60 @@ +/** + * Type of a value segment within a property value expression. + * + * A property value can contain multiple segments of different types. + * For example: `jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}` + * contains LITERAL, ENV_WITH_DEFAULT, LITERAL, ENV_WITH_DEFAULT segments. + * + * ## Segment Types + * + * ```properties + * # LITERAL - plain text + * app.name=Auth Service + * + * # ENV_VARIABLE - ${VAR} with no default, not a known property key + * db.url=${DATABASE_URL} + * + * # ENV_WITH_DEFAULT - ${VAR:default} with default, not a known property key + * db.host=${DB_HOST:localhost} + * + * # PROPERTY_REFERENCE - ${key} where key matches another property in the file + * app.url=${app.base-url}/health + * + * # PROPERTY_REF_WITH_DEFAULT - ${key:default} where key matches another property + * app.region=${app.default-region:us-east-1} + * + * # SPEL_EXPRESSION - #{...} Spring Expression Language + * app.home=#{systemProperties['user.home']} + * + * # RANDOM - ${random.*} Spring random value placeholders + * app.id=${random.uuid} + * + * # EMPTY - empty or absent value + * app.flag= + * ``` + */ +export enum PropertyValueSegmentType { + /** Plain text with no references */ + LITERAL = 'LITERAL', + + /** ${VAR} - environment variable reference with no default, not a known property key */ + ENV_VARIABLE = 'ENV_VARIABLE', + + /** ${VAR:default} - environment variable with a default value, not a known property key */ + ENV_WITH_DEFAULT = 'ENV_WITH_DEFAULT', + + /** ${prop.key} - references another property key defined in the same file */ + PROPERTY_REFERENCE = 'PROPERTY_REFERENCE', + + /** ${prop.key:default} - references another property key with a fallback default */ + PROPERTY_REF_WITH_DEFAULT = 'PROPERTY_REF_WITH_DEFAULT', + + /** #{...} - Spring Expression Language expression */ + SPEL_EXPRESSION = 'SPEL_EXPRESSION', + + /** ${random.*} - Spring Boot random value placeholder */ + RANDOM = 'RANDOM', + + /** Empty or absent value (key with no value, or key= with nothing after) */ + EMPTY = 'EMPTY', +} diff --git a/parser/src/enums/properties/index.ts b/parser/src/enums/properties/index.ts new file mode 100644 index 000000000..d08e5235c --- /dev/null +++ b/parser/src/enums/properties/index.ts @@ -0,0 +1,2 @@ +export { PropertyDelimiter } from '@/enums/properties/PropertyDelimiter'; +export { PropertyValueSegmentType } from '@/enums/properties/PropertyValueSegmentType'; diff --git a/parser/src/enums/python/bindings/PythonBindingKind.ts b/parser/src/enums/python/bindings/PythonBindingKind.ts new file mode 100644 index 000000000..5bbac7b50 --- /dev/null +++ b/parser/src/enums/python/bindings/PythonBindingKind.ts @@ -0,0 +1,72 @@ +/** + * How a name is bound in a scope — the resolved outcome of CPython's two-pass + * symbol-table analysis, one value per `(scope, name)` pair. + * + * This is Python's `local_variable` table *and* its global/nonlocal/free/import/ + * parameter table, unified. It is why 50.7% of method calls — those with a bare + * name as the receiver — become resolvable at all. + * + * ## The distinctions that carry weight + * + * ```python + * def make_counter(): + * count = 0 # CELL — bound here AND captured by a nested scope + * def bump(): + * nonlocal count # NONLOCAL / FREE — resolves to make_counter's binding + * count += 1 + * return bump + * + * def read(): + * return counter # GLOBAL_IMPLICIT — never bound here, so module-level + * + * def write(): + * global counter # GLOBAL_EXPLICIT — the `global` statement + * counter = 1 + * ``` + * + * `CELL` vs `LOCAL` is the difference between a variable that lives in a closure + * cell and one that lives in a frame slot; CPython computes it only after + * analysing every child scope, which is why this cannot be decided in one pass. + * + * Schema v6 §2.3 c2. + */ +export enum PythonBindingKind { + /** Bound in this scope and not captured by any nested scope. */ + LOCAL = 'LOCAL', + + /** Declared with a `global` statement in this scope. */ + GLOBAL_EXPLICIT = 'GLOBAL_EXPLICIT', + + /** Referenced but never bound here — resolves to the module namespace. */ + GLOBAL_IMPLICIT = 'GLOBAL_IMPLICIT', + + /** Declared with a `nonlocal` statement in this scope. */ + NONLOCAL = 'NONLOCAL', + + /** Free variable: referenced here, bound in an enclosing function scope. */ + FREE = 'FREE', + + /** Bound here **and** captured by a nested scope — lives in a closure cell. */ + CELL = 'CELL', + + /** A parameter of this function, lambda, or the synthetic `.0` iterator. */ + PARAMETER = 'PARAMETER', + + /** Bound by an `import` / `from ... import` statement. */ + IMPORTED = 'IMPORTED', + + /** Bound in a class body — becomes a class attribute, not a closure local. */ + CLASS_ATTRIBUTE = 'CLASS_ATTRIBUTE', + + /** Bound at module level: simultaneously local and global, as CPython models it. */ + MODULE_LEVEL = 'MODULE_LEVEL', + + /** Resolves to a builtin. */ + BUILTIN = 'BUILTIN', + + /** Carries an annotation but no value — `x: int` with no assignment. */ + ANNOTATED_ONLY = 'ANNOTATED_ONLY', + + /** Could not be classified. An honest negative, never a guess. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/python/bindings/PythonBindingOrigin.ts b/parser/src/enums/python/bindings/PythonBindingOrigin.ts new file mode 100644 index 000000000..c74858948 --- /dev/null +++ b/parser/src/enums/python/bindings/PythonBindingOrigin.ts @@ -0,0 +1,104 @@ +/** + * The **syntactic form** that created a binding, as distinct from the resolved + * scope outcome in `PythonBindingKind`. + * + * Two columns rather than one because they answer different questions: origin is + * what the source says, kind is what CPython decided. A name bound by several + * different forms in one scope gets `MULTIPLE`. + * + * ## Examples + * + * ```python + * x = 1 # ASSIGNMENT + * x += 1 # AUGMENTED_ASSIGNMENT + * x: int = 1 # ANNOTATED_ASSIGNMENT + * x: int # ANNOTATION_ONLY (still binds, on 3.10) + * for x in xs: ... # FOR_TARGET + * with open(p) as x: ... # WITH_TARGET + * except E as x: ... # EXCEPT_TARGET + * [x for x in xs] # COMPREHENSION_TARGET + * if (x := f()): ... # WALRUS + * a, *b = xs # TUPLE_UNPACK_TARGET / STAR_TARGET + * match p: + * case [x]: ... # MATCH_CAPTURE + * ``` + * + * Schema v6 §2.3 c3. + */ +export enum PythonBindingOrigin { + /** A plain `=` assignment. */ + ASSIGNMENT = 'ASSIGNMENT', + + /** An augmented assignment such as `x += 1`. Binds without referencing. */ + AUGMENTED_ASSIGNMENT = 'AUGMENTED_ASSIGNMENT', + + /** An annotated assignment with a value — `x: int = 1`. */ + ANNOTATED_ASSIGNMENT = 'ANNOTATED_ASSIGNMENT', + + /** A bare annotation — `x: int`. On 3.10 this still marks the name local. */ + ANNOTATION_ONLY = 'ANNOTATION_ONLY', + + /** A `def` / `async def` statement binding its own name. */ + FUNCTION_DEF = 'FUNCTION_DEF', + + /** A `class` statement binding its own name. */ + CLASS_DEF = 'CLASS_DEF', + + /** An `import` or `from ... import` statement. */ + IMPORT = 'IMPORT', + + /** A `for` loop target. */ + FOR_TARGET = 'FOR_TARGET', + + /** A `with ... as` target. */ + WITH_TARGET = 'WITH_TARGET', + + /** An `except ... as` target. Unbound and deleted at the end of the handler. */ + EXCEPT_TARGET = 'EXCEPT_TARGET', + + /** A comprehension's iteration target. */ + COMPREHENSION_TARGET = 'COMPREHENSION_TARGET', + + /** A `:=` assignment expression. Binds in the *enclosing* scope from a comprehension. */ + WALRUS = 'WALRUS', + + /** A `global` statement. */ + GLOBAL_STMT = 'GLOBAL_STMT', + + /** A `nonlocal` statement. */ + NONLOCAL_STMT = 'NONLOCAL_STMT', + + /** A function or lambda parameter, including the synthetic `.0`. */ + PARAMETER = 'PARAMETER', + + /** A `del` statement. Binds the name locally, then unbinds it. */ + DEL = 'DEL', + + /** A `match` / `case` capture pattern. */ + MATCH_CAPTURE = 'MATCH_CAPTURE', + + /** A starred target in an unpacking assignment — the `*b` in `a, *b = xs`. */ + STAR_TARGET = 'STAR_TARGET', + + /** A target inside a tuple or list unpacking. */ + TUPLE_UNPACK_TARGET = 'TUPLE_UNPACK_TARGET', + + /** A lambda parameter, where distinguishing it from a `def` parameter matters. */ + LAMBDA_PARAM = 'LAMBDA_PARAM', + + /** A PEP 695 `type` alias name (3.12). Deferred — declared for forward parity. */ + /** + * A PEP 695 type parameter: the `T` in `class C[T]`, `def f[T]` or `type A[T] = …`. + * + * Distinct from `PARAMETER`, which is a function parameter and binds a runtime value. A + * type parameter binds a `TypeVar` / `TypeVarTuple` / `ParamSpec` object in the annotation + * scope that wraps the class or function, and is visible to annotations and bases that a + * function parameter is not. + */ + TYPE_PARAM = 'TYPE_PARAM', + + TYPE_ALIAS = 'TYPE_ALIAS', + + /** The name is bound by more than one distinct form in this scope. */ + MULTIPLE = 'MULTIPLE', +} diff --git a/parser/src/enums/python/bindings/PythonBindingTargetKind.ts b/parser/src/enums/python/bindings/PythonBindingTargetKind.ts new file mode 100644 index 000000000..38f3bb387 --- /dev/null +++ b/parser/src/enums/python/bindings/PythonBindingTargetKind.ts @@ -0,0 +1,37 @@ +/** + * Discriminator for `py_binding.targetEntityHash`, which is polymorphic. + * + * `NONE` is the honest default: most bindings — a local integer, a loop + * variable — point at no declared entity, and inventing one would be a + * confident wrong answer of exactly the kind this schema is organised against. + * + * Schema v6 §2.3 c22. + */ +export enum PythonBindingTargetKind { + /** The name binds a `class` — target is a `py_type` row. */ + TYPE = 'TYPE', + + /** The name binds a `def` / `async def` — target is a `py_method` row. */ + METHOD = 'METHOD', + + /** The name was bound by an import — target is a `py_import` row. */ + IMPORT = 'IMPORT', + + /** The name is a parameter — target is a `py_method_parameter` row. */ + PARAMETER = 'PARAMETER', + + /** An ordinary variable with no declaration-site entity. */ + VARIABLE = 'VARIABLE', + + /** The name refers to a module. */ + MODULE = 'MODULE', + + /** The name is a `TypeVar`. */ + TYPE_VAR = 'TYPE_VAR', + + /** The name is a type alias. */ + TYPE_ALIAS = 'TYPE_ALIAS', + + /** No target entity — the default. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/python/bindings/index.ts b/parser/src/enums/python/bindings/index.ts new file mode 100644 index 000000000..ee11e5209 --- /dev/null +++ b/parser/src/enums/python/bindings/index.ts @@ -0,0 +1,3 @@ +export { PythonBindingKind } from '@/enums/python/bindings/PythonBindingKind'; +export { PythonBindingOrigin } from '@/enums/python/bindings/PythonBindingOrigin'; +export { PythonBindingTargetKind } from '@/enums/python/bindings/PythonBindingTargetKind'; diff --git a/parser/src/enums/python/blocks/PythonBlockKind.ts b/parser/src/enums/python/blocks/PythonBlockKind.ts new file mode 100644 index 000000000..74b7e19e1 --- /dev/null +++ b/parser/src/enums/python/blocks/PythonBlockKind.ts @@ -0,0 +1,37 @@ +/** + * The statement form a block belongs to. + * + * Mirrors `java_block`'s kind where the languages agree and adds what Python has + * that Java does not: `ELIF` is a distinct kind rather than a nested `IF`, + * because CPython's own grammar chains them; `EXCEPT_STAR` is PEP 654; + * `ASYNC_FOR` and `ASYNC_WITH` are separate because the suspension point matters + * to a data-flow rule; and `MATCH`/`CASE` have no Java equivalent at all. + * + * `COMPREHENSION_BODY` is listed and is where the 3.12 question bites: under + * PEP 709 a comprehension no longer has a scope of its own, so the block still + * exists syntactically while the scope beneath it does not. + * + * Schema v7 §2.18 c0. + */ +export enum PythonBlockKind { + IF = 'IF', + ELIF = 'ELIF', + ELSE = 'ELSE', + FOR = 'FOR', + ASYNC_FOR = 'ASYNC_FOR', + WHILE = 'WHILE', + TRY = 'TRY', + EXCEPT = 'EXCEPT', + /** `except*` — PEP 654 exception groups. */ + EXCEPT_STAR = 'EXCEPT_STAR', + FINALLY = 'FINALLY', + WITH = 'WITH', + ASYNC_WITH = 'ASYNC_WITH', + MATCH = 'MATCH', + CASE = 'CASE', + FUNCTION_BODY = 'FUNCTION_BODY', + CLASS_BODY = 'CLASS_BODY', + MODULE_BODY = 'MODULE_BODY', + LAMBDA_BODY = 'LAMBDA_BODY', + COMPREHENSION_BODY = 'COMPREHENSION_BODY', +} diff --git a/parser/src/enums/python/blocks/index.ts b/parser/src/enums/python/blocks/index.ts new file mode 100644 index 000000000..28564e125 --- /dev/null +++ b/parser/src/enums/python/blocks/index.ts @@ -0,0 +1 @@ +export { PythonBlockKind } from '@/enums/python/blocks/PythonBlockKind'; diff --git a/parser/src/enums/python/call-sites/PythonCallKind.ts b/parser/src/enums/python/call-sites/PythonCallKind.ts new file mode 100644 index 000000000..eb949de73 --- /dev/null +++ b/parser/src/enums/python/call-sites/PythonCallKind.ts @@ -0,0 +1,58 @@ +/** + * The shape of a call site. + * + * `py_call_site` is a **base** relation for Python where Java derives it in + * `call-site.dl`, because the call shape is not recoverable from one positional + * pattern: keyword arguments, `*`/`**` spreading, chained receivers, `super()`, + * and — the point — the receiver's *syntactic* shape, which is all we honestly + * know about a duck-typed receiver. + * + * ## `SUPER_CALL` is not virtual dispatch + * + * 98% of `super()` uses are `super().m()`. Python's `super()` performs an + * MRO-ordered lookup starting **after** the enclosing class, so the enclosing + * type is the slice point and `py_type_base.position` gives the order. Treating + * it as ordinary dispatch finds the wrong method in any diamond. + * + * Schema v6 §2.16 c0. + */ +export enum PythonCallKind { + /** `f(x)` — a bare name called. */ + SIMPLE_CALL = 'SIMPLE_CALL', + + /** `obj.m(x)`. */ + METHOD_CALL = 'METHOD_CALL', + + /** `a.b().c()` — the receiver is itself a call result. */ + CHAINED_CALL = 'CHAINED_CALL', + + /** `super().m()` — MRO-ordered, sliced after the enclosing class. */ + SUPER_CALL = 'SUPER_CALL', + + /** `self.m()`. */ + SELF_CALL = 'SELF_CALL', + + /** `cls.m()`. */ + CLS_CALL = 'CLS_CALL', + + /** `mod.f()` where the receiver is a known module. */ + MODULE_CALL = 'MODULE_CALL', + + /** `d[k]()` — the callee came out of a subscript. */ + SUBSCRIPT_CALL = 'SUBSCRIPT_CALL', + + /** The callee is computed, so no static target exists. */ + DYNAMIC_CALL = 'DYNAMIC_CALL', + + /** A call written as a decorator. */ + DECORATOR_CALL = 'DECORATOR_CALL', + + /** An instance called via `__call__`. */ + INSTANCE_CALL = 'INSTANCE_CALL', + + /** A builtin such as `len` or `isinstance`. */ + BUILTIN_CALL = 'BUILTIN_CALL', + + /** The callee could not be characterised. Honest, not lazy. */ + UNKNOWN_CALLEE_CALL = 'UNKNOWN_CALLEE_CALL', +} diff --git a/parser/src/enums/python/call-sites/PythonReceiverKind.ts b/parser/src/enums/python/call-sites/PythonReceiverKind.ts new file mode 100644 index 000000000..7b054fb1b --- /dev/null +++ b/parser/src/enums/python/call-sites/PythonReceiverKind.ts @@ -0,0 +1,37 @@ +/** + * The **syntactic shape** of a call's receiver. + * + * This is the schema's answer to duck typing: record the shape, and let the + * engine do the typing. The measured distribution is why the enum is shaped + * this way — of 39,000-odd attribute calls, the receiver is a bare `NAME` 50.7% + * of the time, `SELF` 19.6%, an `ATTRIBUTE` chain 18.8%, and a `CALL_RESULT` + * 7.3% (of which 28% are `super()`). + * + * Schema v6 §2.16 c4. + */ +export enum PythonReceiverKind { + /** No receiver — a bare call such as `f(x)`. */ + NONE = 'NONE', + /** The receiver parameter of an instance method. */ + SELF = 'SELF', + /** The receiver parameter of a classmethod. */ + CLS = 'CLS', + /** `super()`. */ + SUPER = 'SUPER', + /** A bare name — the highest-value case, resolvable through its binding. */ + NAME = 'NAME', + /** An attribute chain such as `self.repo`. */ + ATTRIBUTE = 'ATTRIBUTE', + /** The result of another call. */ + CALL_RESULT = 'CALL_RESULT', + /** A subscript such as `items[0]`. */ + SUBSCRIPT = 'SUBSCRIPT', + /** A literal, as in `"a,b".split(",")`. */ + LITERAL = 'LITERAL', + /** A known module. */ + MODULE = 'MODULE', + /** A known class, so the call is on the class rather than an instance. */ + TYPE = 'TYPE', + /** Anything else. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/python/call-sites/PythonResolvedCalleeKind.ts b/parser/src/enums/python/call-sites/PythonResolvedCalleeKind.ts new file mode 100644 index 000000000..0530198ca --- /dev/null +++ b/parser/src/enums/python/call-sites/PythonResolvedCalleeKind.ts @@ -0,0 +1,23 @@ +/** + * What the callee resolved to, parser-local best effort. + * + * `UNRESOLVED` is the **honest default**, and the engine overrides it with + * cross-module knowledge. The parser deliberately stops at the module boundary: + * resolution is decidable within one module and guesswork outside it. + * + * Schema v6 §2.16 c17. + */ +export enum PythonResolvedCalleeKind { + /** A method of a class in this module. */ + METHOD = 'METHOD', + /** A class — so the call constructs an instance. */ + TYPE = 'TYPE', + /** A module-level function in this module. */ + MODULE_FUNCTION = 'MODULE_FUNCTION', + /** A builtin. */ + BUILTIN = 'BUILTIN', + /** Bound by an import; the target is outside this module. */ + IMPORTED = 'IMPORTED', + /** Not resolved here. The default. */ + UNRESOLVED = 'UNRESOLVED', +} diff --git a/parser/src/enums/python/call-sites/index.ts b/parser/src/enums/python/call-sites/index.ts new file mode 100644 index 000000000..4e1265e68 --- /dev/null +++ b/parser/src/enums/python/call-sites/index.ts @@ -0,0 +1,3 @@ +export { PythonCallKind } from '@/enums/python/call-sites/PythonCallKind'; +export { PythonReceiverKind } from '@/enums/python/call-sites/PythonReceiverKind'; +export { PythonResolvedCalleeKind } from '@/enums/python/call-sites/PythonResolvedCalleeKind'; diff --git a/parser/src/enums/python/comments/PythonCommentKind.ts b/parser/src/enums/python/comments/PythonCommentKind.ts new file mode 100644 index 000000000..147fa6956 --- /dev/null +++ b/parser/src/enums/python/comments/PythonCommentKind.ts @@ -0,0 +1,36 @@ +/** + * What a comment IS, beyond being text a human wrote. + * + * Most of these are not commentary at all — they are directives the toolchain + * acts on, and a consumer that treats them as prose loses the instruction: + * + * - `ENCODING_COOKIE` decides how the bytes are decoded (PEP 263). + * - `TYPE_COMMENT` carries a real annotation; `ast.parse(type_comments=True)` + * parses it, and 163 files in the corpus depend on it. + * - `NOQA` suppresses a diagnostic, so a rule that reports the suppressed thing + * is arguing with an explicit decision. + * - `DOCSTRING_*` is a string EXPRESSION that also appears in `py_expression`. + * The duplication is intentional (§2.17) and a recall check must whitelist it. + * + * Schema v7 §2.17 c0. + */ +export enum PythonCommentKind { + LINE_COMMENT = 'LINE_COMMENT', + /** `#!/usr/bin/env python3` — first line only. */ + SHEBANG = 'SHEBANG', + /** PEP 263 `# -*- coding: utf-8 -*-`, first two lines only. */ + ENCODING_COOKIE = 'ENCODING_COOKIE', + /** PEP 484 `# type: List[int]` — a real annotation in a comment. */ + TYPE_COMMENT = 'TYPE_COMMENT', + /** `# noqa`, `# type: ignore`, `# pylint: disable=...`. */ + NOQA = 'NOQA', + /** `# pragma: no cover` and similar tool directives. */ + PRAGMA = 'PRAGMA', + DOCSTRING_MODULE = 'DOCSTRING_MODULE', + DOCSTRING_CLASS = 'DOCSTRING_CLASS', + DOCSTRING_FUNCTION = 'DOCSTRING_FUNCTION', + /** A string literal directly after an attribute assignment, by convention. */ + DOCSTRING_ATTRIBUTE = 'DOCSTRING_ATTRIBUTE', + /** Consecutive line comments merged into one run. */ + BLOCK_COMMENT_RUN = 'BLOCK_COMMENT_RUN', +} diff --git a/parser/src/enums/python/comments/index.ts b/parser/src/enums/python/comments/index.ts new file mode 100644 index 000000000..217619279 --- /dev/null +++ b/parser/src/enums/python/comments/index.ts @@ -0,0 +1 @@ +export { PythonCommentKind } from '@/enums/python/comments/PythonCommentKind'; diff --git a/parser/src/enums/python/decorators/PythonBuiltinDecoratorKind.ts b/parser/src/enums/python/decorators/PythonBuiltinDecoratorKind.ts new file mode 100644 index 000000000..27e0a4857 --- /dev/null +++ b/parser/src/enums/python/decorators/PythonBuiltinDecoratorKind.ts @@ -0,0 +1,27 @@ +/** + * Decorators whose effect on the language is defined rather than user code. + * + * These change what the decorated name MEANS, so a consumer cannot treat them as + * opaque. `@staticmethod` removes the receiver, which shifts every positional + * parameter by one; `@property` turns an attribute read into a call; + * `@contextmanager` turns a generator into a context manager. + * + * Schema v7 §2.12 c16. + */ +export enum PythonBuiltinDecoratorKind { + STATICMETHOD = 'STATICMETHOD', + CLASSMETHOD = 'CLASSMETHOD', + PROPERTY = 'PROPERTY', + SETTER = 'SETTER', + DELETER = 'DELETER', + ABSTRACTMETHOD = 'ABSTRACTMETHOD', + OVERLOAD = 'OVERLOAD', + FINAL = 'FINAL', + CACHED_PROPERTY = 'CACHED_PROPERTY', + LRU_CACHE = 'LRU_CACHE', + DATACLASS = 'DATACLASS', + CONTEXTMANAGER = 'CONTEXTMANAGER', + WRAPS = 'WRAPS', + /** Not a decorator the language defines. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/python/decorators/PythonDecoratorArgumentValueType.ts b/parser/src/enums/python/decorators/PythonDecoratorArgumentValueType.ts new file mode 100644 index 000000000..36b3dc462 --- /dev/null +++ b/parser/src/enums/python/decorators/PythonDecoratorArgumentValueType.ts @@ -0,0 +1,47 @@ +/** + * The shape of one decorator argument's value. + * + * Kept syntactic. `@app.route("/admin/", methods=["POST"])` yields a + * `STRING_LITERAL` and a `LIST`, and a security rule reads the route from the + * first and the verb from the second — neither needs a type inferred. + * + * Schema v7 §2.13 c2. + */ +export enum PythonDecoratorArgumentValueType { + STRING_LITERAL = 'STRING_LITERAL', + NUMBER_LITERAL = 'NUMBER_LITERAL', + BOOLEAN_LITERAL = 'BOOLEAN_LITERAL', + NONE_LITERAL = 'NONE_LITERAL', + LIST = 'LIST', + DICT = 'DICT', + TUPLE = 'TUPLE', + SET = 'SET', + /** + * A bare name that does NOT resolve to a class — a constant, a function, a + * variable. Kept distinct from {@link CLASS_REFERENCE} so a consumer can tell + * "we looked and it is not a class" from "we did not look". + */ + NAME_REFERENCE = 'NAME_REFERENCE', + ATTRIBUTE_REFERENCE = 'ATTRIBUTE_REFERENCE', + /** + * A name that RESOLVES to a class, mirroring Java's `CLASS_REFERENCE`. + * + * `@register(HandlerClass)` and `@field(default_factory=OrderedDict)` name a + * type, and `referencedTypeHash` carries the FK. Without the distinction a + * consumer cannot tell a class argument from any other identifier without + * re-resolving the name itself, which is the work this relation exists to + * have already done. + */ + CLASS_REFERENCE = 'CLASS_REFERENCE', + /** + * A dotted name whose base resolves to a class — `Color.RED`, `Mode.STRICT`. + * + * Java's `ENUM_CONSTANT`. Python has no separate enum syntax, so this is any + * attribute access on a resolved class, which is what an enum member is. + */ + ENUM_CONSTANT = 'ENUM_CONSTANT', + CALL = 'CALL', + LAMBDA = 'LAMBDA', + FSTRING = 'FSTRING', + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/python/decorators/PythonDecoratorContext.ts b/parser/src/enums/python/decorators/PythonDecoratorContext.ts new file mode 100644 index 000000000..c0de8cc3d --- /dev/null +++ b/parser/src/enums/python/decorators/PythonDecoratorContext.ts @@ -0,0 +1,17 @@ +/** + * What the decorator is attached to. + * + * Doubles as the discriminator for `ownerHash` (§2.12 c3), which points at a + * `py_type` for a class and a `py_method` for a function — the same polymorphic + * pattern `py_scope.ownerKind` uses. + * + * Schema v7 §2.12 c2. + */ +export enum PythonDecoratorContext { + /** On a `class`. */ + TYPE_DECLARATION = 'TYPE_DECLARATION', + /** On a `def` at class or module level. */ + METHOD_DECLARATION = 'METHOD_DECLARATION', + /** On a `def` inside another function — a closure, often a wrapper. */ + NESTED_FUNCTION_DECLARATION = 'NESTED_FUNCTION_DECLARATION', +} diff --git a/parser/src/enums/python/decorators/PythonDecoratorKind.ts b/parser/src/enums/python/decorators/PythonDecoratorKind.ts new file mode 100644 index 000000000..5af6c62fa --- /dev/null +++ b/parser/src/enums/python/decorators/PythonDecoratorKind.ts @@ -0,0 +1,27 @@ +/** + * The syntactic shape of a decorator expression. + * + * The distinction that earns its place is `BARE` versus `CALL`: `@property` is + * the decorator itself, while `@lru_cache(maxsize=None)` is a CALL whose RESULT + * decorates. Only the second has arguments, and the arguments are where + * framework semantics live — routes, permissions, cache sizes. + * + * `EXPRESSION` exists because PEP 614 (3.9) dropped the grammar restriction, so + * `@buttons[0].clicked.connect` is legal and names no single identifier. + * + * Schema v7 §2.12 c1. + */ +export enum PythonDecoratorKind { + /** `@property` — a plain name. */ + BARE = 'BARE', + /** `@lru_cache(maxsize=None)` — a call whose result decorates. */ + CALL = 'CALL', + /** `@app.route` — a dotted name, not called. */ + ATTRIBUTE = 'ATTRIBUTE', + /** `@app.route("/x")` — the common framework shape. */ + ATTRIBUTE_CALL = 'ATTRIBUTE_CALL', + /** `@registry["name"]`. */ + SUBSCRIPT = 'SUBSCRIPT', + /** Any other expression, legal since PEP 614. */ + EXPRESSION = 'EXPRESSION', +} diff --git a/parser/src/enums/python/decorators/index.ts b/parser/src/enums/python/decorators/index.ts new file mode 100644 index 000000000..bb807340f --- /dev/null +++ b/parser/src/enums/python/decorators/index.ts @@ -0,0 +1,4 @@ +export { PythonBuiltinDecoratorKind } from '@/enums/python/decorators/PythonBuiltinDecoratorKind'; +export { PythonDecoratorArgumentValueType } from '@/enums/python/decorators/PythonDecoratorArgumentValueType'; +export { PythonDecoratorContext } from '@/enums/python/decorators/PythonDecoratorContext'; +export { PythonDecoratorKind } from '@/enums/python/decorators/PythonDecoratorKind'; diff --git a/parser/src/enums/python/expressions/PythonComprehensionKind.ts b/parser/src/enums/python/expressions/PythonComprehensionKind.ts new file mode 100644 index 000000000..4ccbce46d --- /dev/null +++ b/parser/src/enums/python/expressions/PythonComprehensionKind.ts @@ -0,0 +1,22 @@ +/** + * Which comprehension form a node is, occupying Java's `methodReferenceKind` + * column position. + * + * The `ASYNC_*` variants are separate because an `async for` comprehension + * produces an async generator, so its results arrive by `await` rather than by + * iteration. + * + * Schema v6 §2.15 c11. + */ +export enum PythonComprehensionKind { + LIST = 'LIST', + SET = 'SET', + DICT = 'DICT', + GENERATOR = 'GENERATOR', + ASYNC_LIST = 'ASYNC_LIST', + ASYNC_SET = 'ASYNC_SET', + ASYNC_DICT = 'ASYNC_DICT', + ASYNC_GENERATOR = 'ASYNC_GENERATOR', + /** Not a comprehension. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/python/expressions/PythonEdgeRole.ts b/parser/src/enums/python/expressions/PythonEdgeRole.ts new file mode 100644 index 000000000..4677b8b03 --- /dev/null +++ b/parser/src/enums/python/expressions/PythonEdgeRole.ts @@ -0,0 +1,115 @@ +/** + * An expression's role in its parent — the edge label of the expression tree. + * + * ## Why `RECEIVER` and not `ATTRIBUTE_OBJECT` on calls + * + * `RECEIVER` is kept as the role for the object of a `CALL` specifically, so + * that `call-site.dl`'s existing `java_expression(_, "RECEIVER", …)` pattern + * ports unchanged. `ATTRIBUTE_OBJECT` is used for a plain attribute read that is + * not being called. The distinction is worth the extra value: 50.7% of attribute + * calls have a bare name as the receiver, and that is the single highest-value + * resolution path in the schema. + * + * Schema v6 §2.15 c1. + */ +export enum PythonEdgeRole { + /** The root of an expression tree — no parent expression. */ + ROOT = 'ROOT', + + /** The callee of a call: the `f` in `f(x)`. */ + CALLEE = 'CALLEE', + + /** The receiver of a call: the `obj` in `obj.m()`. */ + RECEIVER = 'RECEIVER', + + /** A positional argument. */ + ARGUMENT = 'ARGUMENT', + + /** + * An element of a list, set or tuple DISPLAY — `a` and `b` in `[a, b]`. + * + * Added because these previously wore {@link ARGUMENT}, which was the closest + * available value and still wrong: 17% of argument-role rows on a real corpus + * were collection elements, so any rule joining `edgeRole = ARGUMENT` to a call + * site picked them up unless it also tested the parent's kind. An element of a + * list passed to a call is not an argument of that call — `f([a, b])` has ONE + * argument. + * + * `position` is the element's index within its display. + */ + ELEMENT = 'ELEMENT', + + /** + * The key half of a dict entry — `k` in `{k: v}`. + * + * Split from {@link VALUE} because without it a dict's entries are not merely + * mislabelled, they are unpaired AND unordered: `{k: v, k2: v2}` emitted `k` + * and `k2` both at position 0 and `v` and `v2` both at position 1, so + * `position` — documented as the ordinal among siblings in the same edgeRole — + * identified nothing. With the roles split, `position` is the ENTRY index, so + * key and value of one entry share it and the pairing is recoverable. + */ + KEY = 'KEY', + + /** The value half of a dict entry. See {@link KEY}. */ + VALUE = 'VALUE', + + /** The value of a `k=v` argument; the name is in `argumentKeywordName`. */ + KEYWORD_ARGUMENT = 'KEYWORD_ARGUMENT', + + /** The `x` in `f(*x)`. */ + STAR_ARGUMENT = 'STAR_ARGUMENT', + + /** The `x` in `f(**x)`. */ + DOUBLE_STAR_ARGUMENT = 'DOUBLE_STAR_ARGUMENT', + + /** The object of an attribute read that is not being called. */ + ATTRIBUTE_OBJECT = 'ATTRIBUTE_OBJECT', + + SUBSCRIPT_OBJECT = 'SUBSCRIPT_OBJECT', + SUBSCRIPT_INDEX = 'SUBSCRIPT_INDEX', + SLICE_LOWER = 'SLICE_LOWER', + SLICE_UPPER = 'SLICE_UPPER', + SLICE_STEP = 'SLICE_STEP', + + ASSIGNMENT_TARGET = 'ASSIGNMENT_TARGET', + ASSIGNMENT_VALUE = 'ASSIGNMENT_VALUE', + + ANNOTATION = 'ANNOTATION', + DEFAULT_VALUE = 'DEFAULT_VALUE', + DECORATOR_EXPR = 'DECORATOR_EXPR', + BASE_CLASS = 'BASE_CLASS', + + CONDITION = 'CONDITION', + BODY = 'BODY', + ORELSE = 'ORELSE', + + OPERAND_LEFT = 'OPERAND_LEFT', + OPERAND_RIGHT = 'OPERAND_RIGHT', + UNARY_OPERAND = 'UNARY_OPERAND', + + COMPREHENSION_ELEMENT = 'COMPREHENSION_ELEMENT', + COMPREHENSION_ITERABLE = 'COMPREHENSION_ITERABLE', + COMPREHENSION_TARGET = 'COMPREHENSION_TARGET', + COMPREHENSION_CONDITION = 'COMPREHENSION_CONDITION', + + FSTRING_EXPRESSION = 'FSTRING_EXPRESSION', + + RETURN_VALUE = 'RETURN_VALUE', + YIELD_VALUE = 'YIELD_VALUE', + AWAIT_OPERAND = 'AWAIT_OPERAND', + + WITH_CONTEXT = 'WITH_CONTEXT', + WITH_TARGET = 'WITH_TARGET', + + EXCEPT_TYPE = 'EXCEPT_TYPE', + EXCEPT_TARGET = 'EXCEPT_TARGET', + + RAISE_EXC = 'RAISE_EXC', + RAISE_CAUSE = 'RAISE_CAUSE', + + LAMBDA_BODY = 'LAMBDA_BODY', + + MATCH_SUBJECT = 'MATCH_SUBJECT', + MATCH_PATTERN = 'MATCH_PATTERN', +} diff --git a/parser/src/enums/python/expressions/PythonExpressionKind.ts b/parser/src/enums/python/expressions/PythonExpressionKind.ts new file mode 100644 index 000000000..514badf5d --- /dev/null +++ b/parser/src/enums/python/expressions/PythonExpressionKind.ts @@ -0,0 +1,116 @@ +/** + * The kind of a Python expression node. + * + * ## There is deliberately no `OBJECT_CREATION` + * + * This is the one omission worth explaining, because Java has it and its + * absence is a modelling decision rather than an oversight. Python has no `new`: + * `User(1)` and `helper(1)` are the *same syntax*, and which one constructs an + * object depends on whether `User` happens to name a class — which is exactly + * the question the engine exists to answer. Splitting them in the parser would + * mean guessing, and guessing wrong produces a call graph that looks precise and + * is not. So every call is a `CALL`, and `py_call_site.resolvedCalleeKind` + * records what we could honestly determine. + * + * Schema v6 §2.15 c0, §4.5. + */ +/** + * RESERVED, and deliberately never emitted. Audited on a corpus exercising every + * construct: 32 of the 35 kinds below carry rows; these three do not, because the + * information they would carry is already on another column and a second + * representation could disagree with the first. + * + * STARRED / DOUBLE_STARRED `f(*a, **k)` emits the OPERAND with + * edgeRole=STAR_ARGUMENT / DOUBLE_STAR_ARGUMENT and + * isStarred=true. A wrapper row would duplicate the + * operand at an identical span. + * MATCH_PATTERN a pattern emits its own expression with + * edgeRole=MATCH_PATTERN. tree-sitter wraps every + * pattern element in its own `case_pattern`, so a row + * per wrapper duplicated its child at an identical + * span -- ten duplicates on one fixture. + * + * If you are auditing for declared-but-unemitted enum values, these three are the + * answer; the rest should all carry rows. + */ +export enum PythonExpressionKind { + /** Any call, including construction — see the note above. */ + CALL = 'CALL', + + /** `obj.attr`. */ + ATTRIBUTE_ACCESS = 'ATTRIBUTE_ACCESS', + + /** `obj[key]`. */ + SUBSCRIPT = 'SUBSCRIPT', + + /** `obj[a:b:c]`. */ + SLICE = 'SLICE', + + /** A bare name in load, store, or del position. */ + NAME_REFERENCE = 'NAME_REFERENCE', + + /** A literal — see `literalType` for which. */ + LITERAL = 'LITERAL', + + /** An f-string as a whole. */ + FSTRING = 'FSTRING', + + /** One `{...}` interpolation inside an f-string. */ + FSTRING_INTERPOLATION = 'FSTRING_INTERPOLATION', + + TUPLE = 'TUPLE', + LIST = 'LIST', + SET = 'SET', + DICT = 'DICT', + + LIST_COMPREHENSION = 'LIST_COMPREHENSION', + SET_COMPREHENSION = 'SET_COMPREHENSION', + DICT_COMPREHENSION = 'DICT_COMPREHENSION', + GENERATOR_EXPRESSION = 'GENERATOR_EXPRESSION', + + LAMBDA = 'LAMBDA', + + /** `a if cond else b`. */ + CONDITIONAL_EXPRESSION = 'CONDITIONAL_EXPRESSION', + + /** An arithmetic or bitwise operation. */ + BINARY_OPERATION = 'BINARY_OPERATION', + + /** `-x`, `not x`, `~x`. */ + UNARY_OPERATION = 'UNARY_OPERATION', + + /** `and` / `or`, which short-circuit and so do not always evaluate both sides. */ + BOOLEAN_OPERATION = 'BOOLEAN_OPERATION', + + /** `==`, `is not`, `not in`, and chained comparisons. */ + COMPARISON = 'COMPARISON', + + /** The walrus, `x := f()`. */ + ASSIGNMENT_EXPRESSION = 'ASSIGNMENT_EXPRESSION', + + /** `*x` in a call or literal. */ + STARRED = 'STARRED', + + /** `**x` in a call or literal. */ + DOUBLE_STARRED = 'DOUBLE_STARRED', + + AWAIT = 'AWAIT', + YIELD = 'YIELD', + YIELD_FROM = 'YIELD_FROM', + + ASSIGNMENT = 'ASSIGNMENT', + AUGMENTED_ASSIGNMENT = 'AUGMENTED_ASSIGNMENT', + ANNOTATED_ASSIGNMENT = 'ANNOTATED_ASSIGNMENT', + + /** A reference to the receiver parameter — usually but not always `self`. */ + SELF_REFERENCE = 'SELF_REFERENCE', + + /** A reference to a `classmethod`'s receiver — usually `cls`. */ + CLS_REFERENCE = 'CLS_REFERENCE', + + /** A `match` / `case` pattern. */ + MATCH_PATTERN = 'MATCH_PATTERN', + + /** `...`. */ + ELLIPSIS = 'ELLIPSIS', +} diff --git a/parser/src/enums/python/expressions/PythonExpressionOwnerKind.ts b/parser/src/enums/python/expressions/PythonExpressionOwnerKind.ts new file mode 100644 index 000000000..f8740cf6c --- /dev/null +++ b/parser/src/enums/python/expressions/PythonExpressionOwnerKind.ts @@ -0,0 +1,18 @@ +/** + * Discriminator for `py_expression.expressionOwnerHash`, which is polymorphic. + * + * Schema v6 §2.15 c3. + */ +export enum PythonExpressionOwnerKind { + MODULE = 'MODULE', + TYPE = 'TYPE', + METHOD = 'METHOD', + LAMBDA = 'LAMBDA', + BLOCK = 'BLOCK', + FIELD = 'FIELD', + BINDING = 'BINDING', + METHOD_PARAMETER = 'METHOD_PARAMETER', + DECORATOR = 'DECORATOR', + IMPORT = 'IMPORT', + COMPREHENSION_SCOPE = 'COMPREHENSION_SCOPE', +} diff --git a/parser/src/enums/python/expressions/PythonLiteralType.ts b/parser/src/enums/python/expressions/PythonLiteralType.ts new file mode 100644 index 000000000..59b2f4642 --- /dev/null +++ b/parser/src/enums/python/expressions/PythonLiteralType.ts @@ -0,0 +1,22 @@ +/** + * Which kind of literal a `LITERAL` expression is. + * + * `FSTRING` is distinguished from `STRING` because an f-string is not a + * constant: it contains expressions that are evaluated, so it can read names, + * make calls, and carry taint. + * + * Schema v6 §2.15 c9. + */ +export enum PythonLiteralType { + STRING = 'STRING', + BYTES = 'BYTES', + RAW_STRING = 'RAW_STRING', + /** An f-string: evaluated, not constant. */ + FSTRING = 'FSTRING', + INTEGER = 'INTEGER', + FLOAT = 'FLOAT', + COMPLEX = 'COMPLEX', + BOOLEAN = 'BOOLEAN', + NONE = 'NONE', + ELLIPSIS = 'ELLIPSIS', +} diff --git a/parser/src/enums/python/expressions/PythonNameContext.ts b/parser/src/enums/python/expressions/PythonNameContext.ts new file mode 100644 index 000000000..468375af2 --- /dev/null +++ b/parser/src/enums/python/expressions/PythonNameContext.ts @@ -0,0 +1,28 @@ +/** + * How a name is used at a given occurrence — mirrors `ast.Load` / `ast.Store` / + * `ast.Del` exactly. + * + * This is a real schema column (`py_expression.nameContext`), and it also drives + * pass 1 of symbol-table construction: the same identifier node contributes + * `USE` in a load position and `DEF_LOCAL` in a store position. + * + * ## Examples + * + * ```python + * y = x # `x` is LOAD, `y` is STORE + * del y # `y` is DEL + * self.a = 1 # `self` is LOAD; `a` is an attribute label, not a name at all + * ``` + * + * Schema v6 §2.15 c27. + */ +export enum PythonNameContext { + /** The name is read. */ + LOAD = 'LOAD', + + /** The name is written — an assignment, loop target, parameter, or capture. */ + STORE = 'STORE', + + /** The name is unbound by a `del` statement. */ + DEL = 'DEL', +} diff --git a/parser/src/enums/python/expressions/PythonReferencedEntityKind.ts b/parser/src/enums/python/expressions/PythonReferencedEntityKind.ts new file mode 100644 index 000000000..2344e0608 --- /dev/null +++ b/parser/src/enums/python/expressions/PythonReferencedEntityKind.ts @@ -0,0 +1,34 @@ +/** + * What a name reference resolves to, as far as the parser can honestly tell. + * + * `UNKNOWN` is a first-class answer, not a failure. The parser resolves within + * one module and stops where CPython stops being able to answer; claiming more + * would produce facts the oracle cannot check. + * + * Schema v6 §2.15 c14. + */ +export enum PythonReferencedEntityKind { + TYPE = 'TYPE', + METHOD = 'METHOD', + FIELD = 'FIELD', + ATTRIBUTE = 'ATTRIBUTE', + MODULE = 'MODULE', + IMPORT = 'IMPORT', + PARAMETER = 'PARAMETER', + LOCAL_VARIABLE = 'LOCAL_VARIABLE', + GLOBAL_VARIABLE = 'GLOBAL_VARIABLE', + NONLOCAL_VARIABLE = 'NONLOCAL_VARIABLE', + FREE_VARIABLE = 'FREE_VARIABLE', + BUILTIN = 'BUILTIN', + /** The receiver parameter of an instance method. */ + SELF = 'SELF', + /** The receiver parameter of a classmethod. */ + CLS = 'CLS', + /** `super` — an MRO-ordered lookup, not virtual dispatch. */ + SUPER = 'SUPER', + COMPREHENSION_VARIABLE = 'COMPREHENSION_VARIABLE', + EXCEPT_VARIABLE = 'EXCEPT_VARIABLE', + WALRUS_TARGET = 'WALRUS_TARGET', + /** Not resolvable within this module. The honest default. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/python/expressions/PythonRootContext.ts b/parser/src/enums/python/expressions/PythonRootContext.ts new file mode 100644 index 000000000..5405f57eb --- /dev/null +++ b/parser/src/enums/python/expressions/PythonRootContext.ts @@ -0,0 +1,96 @@ +/** + * The statement form an expression tree's root sits in. + * + * The Python-specific value that earns its place is + * `MODULE_LEVEL_STATEMENT`: module-level code runs at **import** time, so it is + * reachable from every importer, which makes it different in kind from a + * statement inside a function. + * + * Schema v6 §2.15 c2. + */ +export enum PythonRootContext { + /** A bare expression statement. */ + EXPRESSION_STATEMENT = 'EXPRESSION_STATEMENT', + + /** The right-hand side of an assignment. */ + ASSIGNMENT_VALUE = 'ASSIGNMENT_VALUE', + + /** An assignment target. */ + ASSIGNMENT_TARGET = 'ASSIGNMENT_TARGET', + + /** An augmented assignment, `x += 1`. */ + AUGMENTED_ASSIGNMENT = 'AUGMENTED_ASSIGNMENT', + + /** A variable annotation, `x: int`. */ + ANNOTATED_ASSIGNMENT = 'ANNOTATED_ASSIGNMENT', + + RETURN_VALUE = 'RETURN_VALUE', + YIELD_VALUE = 'YIELD_VALUE', + + IF_CONDITION = 'IF_CONDITION', + WHILE_CONDITION = 'WHILE_CONDITION', + ASSERT_CONDITION = 'ASSERT_CONDITION', + ASSERT_MESSAGE = 'ASSERT_MESSAGE', + + FOR_TARGET = 'FOR_TARGET', + FOR_ITERABLE = 'FOR_ITERABLE', + + /** + * `async for x in obj` — a DIFFERENT protocol from `for`, not a variant of it. + * + * `for` calls `obj.__iter__` / `__next__`; `async for` calls `obj.__aiter__` / + * `__anext__`. ast has two node types for exactly this reason, and the two forms shared + * `FOR_ITERABLE` here, so a consumer emitting the iteration-protocol edge had to choose + * between fabricating `__iter__` on every `async for` and emitting nothing at all. + * + * The enclosing function does NOT decide it — an `async def` contains plain `for` loops + * too — and neither does the block row: the iterable expression is owned by the METHOD, + * not by the `ASYNC_FOR` block, so there is no join that recovers it. + * + * Async is a distinct enum member here for the same reason it is one in + * `PythonBlockKind` (`ASYNC_FOR`), `PythonMethodKind` (`ASYNC_FUNCTION`) and + * `PythonComprehensionKind` (`ASYNC_LIST`): the kind says what the construct IS. + */ + ASYNC_FOR_TARGET = 'ASYNC_FOR_TARGET', + ASYNC_FOR_ITERABLE = 'ASYNC_FOR_ITERABLE', + + WITH_CONTEXT = 'WITH_CONTEXT', + WITH_TARGET = 'WITH_TARGET', + + /** + * `async with cm` — `__aenter__` / `__aexit__`, where `with` is `__enter__` / `__exit__`. + * + * The same distinction as `ASYNC_FOR_ITERABLE`, and it was missing for the same reason. + */ + ASYNC_WITH_CONTEXT = 'ASYNC_WITH_CONTEXT', + ASYNC_WITH_TARGET = 'ASYNC_WITH_TARGET', + + RAISE_VALUE = 'RAISE_VALUE', + EXCEPT_TYPE = 'EXCEPT_TYPE', + + DELETE_TARGET = 'DELETE_TARGET', + + DECORATOR = 'DECORATOR', + BASE_CLASS_LIST = 'BASE_CLASS_LIST', + DEFAULT_VALUE = 'DEFAULT_VALUE', + ANNOTATION = 'ANNOTATION', + + MATCH_SUBJECT = 'MATCH_SUBJECT', + CASE_PATTERN = 'CASE_PATTERN', + CASE_GUARD = 'CASE_GUARD', + + /** A statement at module level — executed at import time. */ + MODULE_LEVEL_STATEMENT = 'MODULE_LEVEL_STATEMENT', + + /** A statement in a class body — executed when the class is created. */ + CLASS_BODY_STATEMENT = 'CLASS_BODY_STATEMENT', + + /** The body expression of a lambda. */ + LAMBDA_BODY = 'LAMBDA_BODY', + + /** Inside a comprehension. */ + COMPREHENSION = 'COMPREHENSION', + + /** A print/format-style statement with no more specific context. */ + OTHER_STATEMENT = 'OTHER_STATEMENT', +} diff --git a/parser/src/enums/python/expressions/PythonUnaryFixity.ts b/parser/src/enums/python/expressions/PythonUnaryFixity.ts new file mode 100644 index 000000000..adf696612 --- /dev/null +++ b/parser/src/enums/python/expressions/PythonUnaryFixity.ts @@ -0,0 +1,13 @@ +/** + * Operator fixity for unary operations. + * + * Python has **no postfix operators** — there is no `x++` — so `PREFIX` and + * `NONE` are the only possibilities. The column exists for parity with Java, + * where postfix increment is real. + * + * Schema v6 §2.15 c12. + */ +export enum PythonUnaryFixity { + PREFIX = 'PREFIX', + NONE = 'NONE', +} diff --git a/parser/src/enums/python/expressions/index.ts b/parser/src/enums/python/expressions/index.ts new file mode 100644 index 000000000..ffa028478 --- /dev/null +++ b/parser/src/enums/python/expressions/index.ts @@ -0,0 +1,9 @@ +export { PythonComprehensionKind } from '@/enums/python/expressions/PythonComprehensionKind'; +export { PythonEdgeRole } from '@/enums/python/expressions/PythonEdgeRole'; +export { PythonExpressionKind } from '@/enums/python/expressions/PythonExpressionKind'; +export { PythonExpressionOwnerKind } from '@/enums/python/expressions/PythonExpressionOwnerKind'; +export { PythonLiteralType } from '@/enums/python/expressions/PythonLiteralType'; +export { PythonNameContext } from '@/enums/python/expressions/PythonNameContext'; +export { PythonReferencedEntityKind } from '@/enums/python/expressions/PythonReferencedEntityKind'; +export { PythonRootContext } from '@/enums/python/expressions/PythonRootContext'; +export { PythonUnaryFixity } from '@/enums/python/expressions/PythonUnaryFixity'; diff --git a/parser/src/enums/python/fields/PythonFieldModifier.ts b/parser/src/enums/python/fields/PythonFieldModifier.ts new file mode 100644 index 000000000..05e16798b --- /dev/null +++ b/parser/src/enums/python/fields/PythonFieldModifier.ts @@ -0,0 +1,33 @@ +/** + * Attribute modifiers, emitted as a comma-set. + * + * `CLASS_VAR` versus `INSTANCE_VAR` is the load-bearing distinction: a class + * attribute is shared by every instance, so a write through one instance is + * visible from all of them, while an instance attribute is not. + * + * `PROPERTY_BACKED` marks the case where a name is BOTH a field and a method: + * `@property def x` means `obj.x` is a call, and the engine needs to see both + * candidates rather than one. + * + * Schema v6 §2.9 c12. + */ +export enum PythonFieldModifier { + /** Declared in the class body — shared across instances. */ + CLASS_VAR = 'CLASS_VAR', + /** Assigned through the receiver — per instance. */ + INSTANCE_VAR = 'INSTANCE_VAR', + /** Declared in `__slots__`, so there is no instance `__dict__`. */ + SLOT = 'SLOT', + /** Annotated `Final`. */ + FINAL = 'FINAL', + /** Annotated `ClassVar[...]`, which makes the class/instance question explicit. */ + CLASSVAR_ANNOTATED = 'CLASSVAR_ANNOTATED', + /** A `@property` exists for this name, so a read is a call. */ + PROPERTY_BACKED = 'PROPERTY_BACKED', + /** A `dataclasses.field(...)` declaration. */ + DATACLASS_FIELD = 'DATACLASS_FIELD', + /** An enum member. */ + ENUM_MEMBER = 'ENUM_MEMBER', + /** Never written after initialisation, as far as the parser can see. */ + READ_ONLY = 'READ_ONLY', +} diff --git a/parser/src/enums/python/fields/PythonFieldOrigin.ts b/parser/src/enums/python/fields/PythonFieldOrigin.ts new file mode 100644 index 000000000..6d816d502 --- /dev/null +++ b/parser/src/enums/python/fields/PythonFieldOrigin.ts @@ -0,0 +1,45 @@ +/** + * How an attribute came to exist. + * + * Python has no field declarations, so an attribute is not one syntactic thing. + * This is the column that records which mechanism created it, and it is part of + * `py_field`'s identity — the same name arriving by two mechanisms is two facts, + * not one. + * + * ## Examples + * + * ```python + * class K: + * count = 0 # CLASS_BODY_ASSIGN + * name: str # CLASS_BODY_ANNOTATION_ONLY + * __slots__ = ("a", "b") # SLOTS_ENTRY, one per name + * + * def __init__(self): + * self.conn = None # SELF_ASSIGN + * self.hits += 1 # SELF_AUGASSIGN + * ``` + * + * Schema v6 §2.9 c13. + */ +export enum PythonFieldOrigin { + /** Assigned in the class body — a class attribute. */ + CLASS_BODY_ASSIGN = 'CLASS_BODY_ASSIGN', + /** Annotated in the class body with no value. */ + CLASS_BODY_ANNOTATION_ONLY = 'CLASS_BODY_ANNOTATION_ONLY', + /** `self.x = ...` inside a method — 7,124 measured, only 67% in `__init__`. */ + SELF_ASSIGN = 'SELF_ASSIGN', + /** `self.x += ...`, which both reads and writes. */ + SELF_AUGASSIGN = 'SELF_AUGASSIGN', + /** A name listed in `__slots__`. */ + SLOTS_ENTRY = 'SLOTS_ENTRY', + /** A `@dataclass` field — generated `__init__` takes these in order. */ + DATACLASS_FIELD = 'DATACLASS_FIELD', + /** A `NamedTuple` field. */ + NAMEDTUPLE_FIELD = 'NAMEDTUPLE_FIELD', + /** A `TypedDict` key. */ + TYPEDDICT_KEY = 'TYPEDDICT_KEY', + /** An `Enum` member. */ + ENUM_MEMBER = 'ENUM_MEMBER', + /** Created by `setattr` — the name may not be statically known. */ + SETATTR_DYNAMIC = 'SETATTR_DYNAMIC', +} diff --git a/parser/src/enums/python/fields/PythonInitializerKind.ts b/parser/src/enums/python/fields/PythonInitializerKind.ts new file mode 100644 index 000000000..d4ba6eb92 --- /dev/null +++ b/parser/src/enums/python/fields/PythonInitializerKind.ts @@ -0,0 +1,27 @@ +/** + * The shape of an attribute's first initialiser. + * + * Coarse on purpose: it says what KIND of thing the attribute was first set to, + * which is a syntactic fact, and stops short of claiming a type — that is + * `py_type_inference`'s job and carries a confidence level. + * + * Schema v6 §2.9 c23. + */ +export enum PythonInitializerKind { + /** No initialiser — an annotation with no value. */ + NONE = 'NONE', + /** A literal. */ + LITERAL = 'LITERAL', + /** A call — often a constructor, which is why it is worth distinguishing. */ + CALL = 'CALL', + /** A bare name. */ + NAME = 'NAME', + /** An attribute access. */ + ATTRIBUTE = 'ATTRIBUTE', + /** A lambda. */ + LAMBDA = 'LAMBDA', + /** A comprehension. */ + COMPREHENSION = 'COMPREHENSION', + /** Anything else. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/python/fields/index.ts b/parser/src/enums/python/fields/index.ts new file mode 100644 index 000000000..aa0dfcb31 --- /dev/null +++ b/parser/src/enums/python/fields/index.ts @@ -0,0 +1,3 @@ +export { PythonFieldModifier } from '@/enums/python/fields/PythonFieldModifier'; +export { PythonFieldOrigin } from '@/enums/python/fields/PythonFieldOrigin'; +export { PythonInitializerKind } from '@/enums/python/fields/PythonInitializerKind'; diff --git a/parser/src/enums/python/imports/PythonImportKind.ts b/parser/src/enums/python/imports/PythonImportKind.ts new file mode 100644 index 000000000..b14ddffea --- /dev/null +++ b/parser/src/enums/python/imports/PythonImportKind.ts @@ -0,0 +1,58 @@ +/** + * The form of an import statement. + * + * From-imports outnumber module-imports 6:1 in the measured corpus and **38% of + * from-imports are relative**, so relative resolution is mandatory rather than + * an edge case. + * + * ## One row per BOUND NAME + * + * This is the rule that decides row counts, and it is about what lands in the + * namespace rather than what is written: + * + * ```python + * import a.b.c # ONE row: binds only `a` + * import a.b as ab # ONE row: binds `ab` + * from m import x, y # TWO rows: binds `x` and `y` + * from . import sibling # ONE row, relativeLevel=1 + * from .mod import * # ONE row, RELATIVE_WILDCARD, binds nothing knowable + * ``` + * + * ## Naming + * + * `FROM_WILDCARD` / `RELATIVE_WILDCARD` follow Python's own term — the Python + * Language Reference calls `from x import *` a *wildcard* import — and the + * neutral Souffle projection is `import_wildcard`, which is what Java's + * `TYPE_ON_DEMAND` projects to as well. The fact layer follows the language; the + * projection layer is shared. + * + * Schema v6 §2.14 c0. + */ +export enum PythonImportKind { + /** `import os`. */ + MODULE_IMPORT = 'MODULE_IMPORT', + + /** `import numpy as np`. */ + MODULE_IMPORT_ALIAS = 'MODULE_IMPORT_ALIAS', + + /** `from m import x`. */ + FROM_MEMBER = 'FROM_MEMBER', + + /** `from m import x as y`. */ + FROM_MEMBER_ALIAS = 'FROM_MEMBER_ALIAS', + + /** `from m import *` — a soundness hole, marked rather than expanded. */ + FROM_WILDCARD = 'FROM_WILDCARD', + + /** `from .m import x` — 38% of from-imports. */ + RELATIVE_MEMBER = 'RELATIVE_MEMBER', + + /** `from .m import *`. */ + RELATIVE_WILDCARD = 'RELATIVE_WILDCARD', + + /** `from __future__ import annotations` — changes annotation semantics. */ + FUTURE = 'FUTURE', + + /** `importlib.import_module(...)` / `__import__(...)`. */ + DYNAMIC = 'DYNAMIC', +} diff --git a/parser/src/enums/python/imports/PythonImportTargetKind.ts b/parser/src/enums/python/imports/PythonImportTargetKind.ts new file mode 100644 index 000000000..901aa9371 --- /dev/null +++ b/parser/src/enums/python/imports/PythonImportTargetKind.ts @@ -0,0 +1,33 @@ +/** + * What an import resolved to, **within the repo only**. + * + * `UNRESOLVED` is an honest negative: it means the parser did not find the + * target in this analysis, not that the target does not exist. Deciding what is + * external is the engine's job — the parser has no site-packages walk to do — + * so `py_import.isExternalTarget` means precisely "did not resolve to a + * `py_module` here". + * + * Schema v6 §2.14 c13. + */ +export enum PythonImportTargetKind { + /** Resolved to a module in this analysis. */ + MODULE = 'MODULE', + + /** Resolved to a class. */ + TYPE = 'TYPE', + + /** Resolved to a function. */ + FUNCTION = 'FUNCTION', + + /** Resolved to a module-level variable. */ + VARIABLE = 'VARIABLE', + + /** Resolved to a package. */ + PACKAGE = 'PACKAGE', + + /** Not found in this analysis. The default, and an honest one. */ + UNRESOLVED = 'UNRESOLVED', + + /** More than one candidate, typically via a wildcard import. */ + AMBIGUOUS = 'AMBIGUOUS', +} diff --git a/parser/src/enums/python/imports/index.ts b/parser/src/enums/python/imports/index.ts new file mode 100644 index 000000000..6f2e71c73 --- /dev/null +++ b/parser/src/enums/python/imports/index.ts @@ -0,0 +1,2 @@ +export { PythonImportKind } from '@/enums/python/imports/PythonImportKind'; +export { PythonImportTargetKind } from '@/enums/python/imports/PythonImportTargetKind'; diff --git a/parser/src/enums/python/index.ts b/parser/src/enums/python/index.ts new file mode 100644 index 000000000..82ef8362c --- /dev/null +++ b/parser/src/enums/python/index.ts @@ -0,0 +1,16 @@ +export * from '@/enums/python/bindings'; +export * from '@/enums/python/call-sites'; +export * from '@/enums/python/blocks'; +export * from '@/enums/python/comments'; +export * from '@/enums/python/decorators'; +export * from '@/enums/python/expressions'; +export * from '@/enums/python/fields'; +export * from '@/enums/python/inference'; +export * from '@/enums/python/imports'; +export * from '@/enums/python/methods'; +export * from '@/enums/python/modules'; +export * from '@/enums/python/parse-gaps'; +export * from '@/enums/python/scopes'; +export * from '@/enums/python/type-references'; +export * from '@/enums/python/types'; +export * from '@/enums/python/type-parameters'; diff --git a/parser/src/enums/python/inference/PythonInferenceConfidence.ts b/parser/src/enums/python/inference/PythonInferenceConfidence.ts new file mode 100644 index 000000000..f82169726 --- /dev/null +++ b/parser/src/enums/python/inference/PythonInferenceConfidence.ts @@ -0,0 +1,21 @@ +/** + * How much weight an inferred type carries. + * + * `CERTAIN` means the syntax leaves no alternative: `[]` is a `list`, and no + * amount of surrounding code makes it something else. `PROBABLE` means the + * syntax names a type but the runtime can differ — an annotation is not + * enforced, so `x: int` may hold a `str` and Python will not complain. + * + * The distinction matters for a security rule: acting on `CERTAIN` is sound, + * while acting on `PROBABLE` is a heuristic and should be reported as one. + * + * Schema v7 §2.15 c36. + */ +export enum PythonInferenceConfidence { + /** The syntax admits no other type. */ + CERTAIN = 'CERTAIN', + /** The syntax names a type that the runtime is not obliged to honour. */ + PROBABLE = 'PROBABLE', + /** No inference was made. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/python/inference/PythonInferenceEvidence.ts b/parser/src/enums/python/inference/PythonInferenceEvidence.ts new file mode 100644 index 000000000..a99db101e --- /dev/null +++ b/parser/src/enums/python/inference/PythonInferenceEvidence.ts @@ -0,0 +1,32 @@ +/** + * WHY a type was inferred — the syntactic ground for the claim. + * + * This column is the tier boundary. The parser emits only evidence it can read + * off one node: a literal is its own type, an f-string is `str`, a comprehension + * is a `list`. Anything needing a second fact — that a name resolves to a class, + * that a call reaches a constructor, that an `isinstance` guard narrows a branch + * — is the ENGINE's inference, appended as derived rows and never stored here. + * + * Keeping the evidence explicit is what lets a consumer decide how much to trust + * a row instead of trusting all rows equally. + * + * Schema v7 §2.15 c35. + */ +export enum PythonInferenceEvidence { + /** A scalar literal — `3`, `"s"`, `True`. */ + LITERAL = 'LITERAL', + /** A collection display — `[]`, `{}`, `()`, `{1}`. */ + COLLECTION_LITERAL = 'COLLECTION_LITERAL', + /** An f-string, which is `str` regardless of what it interpolates. */ + FSTRING = 'FSTRING', + /** A comprehension, whose type follows the bracket. */ + COMPREHENSION = 'COMPREHENSION', + /** An annotation naming the type directly. */ + ANNOTATION = 'ANNOTATION', + /** `typing.cast(Foo, v)` — the programmer asserting the type. */ + CAST = 'CAST', + /** A parameter default, which types the parameter by construction. */ + DEFAULT_VALUE = 'DEFAULT_VALUE', + /** No evidence; `inferredTypeName` is empty. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/python/inference/PythonInferredTypeKind.ts b/parser/src/enums/python/inference/PythonInferredTypeKind.ts new file mode 100644 index 000000000..80c424887 --- /dev/null +++ b/parser/src/enums/python/inference/PythonInferredTypeKind.ts @@ -0,0 +1,24 @@ +/** + * What KIND of type an expression was inferred to have. + * + * Coarser than the type name on purpose. A rule that asks "is this a container?" + * should not have to know the difference between `list`, `set` and `dict`, and a + * rule that asks "is this a project class?" should not have to carry a list of + * builtin names. + * + * Schema v7 §2.15 c34. + */ +export enum PythonInferredTypeKind { + /** `int`, `str`, `float`, `bool`, `bytes`, `complex`. */ + BUILTIN_SCALAR = 'BUILTIN_SCALAR', + /** `list`, `dict`, `set`, `tuple`, `frozenset`. */ + BUILTIN_COLLECTION = 'BUILTIN_COLLECTION', + /** A class declared in the analysed code. */ + USER_CLASS = 'USER_CLASS', + /** `None` — its own kind, because `Optional` handling turns on it. */ + NONE_TYPE = 'NONE_TYPE', + /** A function, lambda or bound method. */ + CALLABLE = 'CALLABLE', + /** Nothing derivable. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/python/inference/index.ts b/parser/src/enums/python/inference/index.ts new file mode 100644 index 000000000..4e67dcd65 --- /dev/null +++ b/parser/src/enums/python/inference/index.ts @@ -0,0 +1,3 @@ +export { PythonInferenceConfidence } from '@/enums/python/inference/PythonInferenceConfidence'; +export { PythonInferenceEvidence } from '@/enums/python/inference/PythonInferenceEvidence'; +export { PythonInferredTypeKind } from '@/enums/python/inference/PythonInferredTypeKind'; diff --git a/parser/src/enums/python/methods/PythonDefaultValueKind.ts b/parser/src/enums/python/methods/PythonDefaultValueKind.ts new file mode 100644 index 000000000..3683c3d87 --- /dev/null +++ b/parser/src/enums/python/methods/PythonDefaultValueKind.ts @@ -0,0 +1,57 @@ +/** + * The shape of a parameter's default value. + * + * `isMutableDefault` is derived from this and is a standing finding: a list, + * dict, set or call default is evaluated **once at definition time** and shared + * across every call, which is one of Python's most common latent bugs. + * + * ```python + * def f(items=[]): ... # LIST — the same list on every call + * def g(items=None): ... # NONE_LITERAL — the correct idiom + * ``` + * + * Schema v6 §2.8 c15. + */ +export enum PythonDefaultValueKind { + /** No default. */ + NONE = 'NONE', + + /** The literal `None`. */ + NONE_LITERAL = 'NONE_LITERAL', + + /** A string literal. */ + STRING = 'STRING', + + /** An int, float or complex literal. */ + NUMBER = 'NUMBER', + + /** `True` or `False`. */ + BOOL = 'BOOL', + + /** A list display — mutable, shared across calls. */ + LIST = 'LIST', + + /** A dict display — mutable, shared across calls. */ + DICT = 'DICT', + + /** A set display — mutable, shared across calls. */ + SET = 'SET', + + /** A tuple display — immutable. */ + TUPLE = 'TUPLE', + + /** A call, evaluated once at definition time. */ + CALL = 'CALL', + + /** A bare name, read at definition time. */ + NAME = 'NAME', + + /** A lambda. */ + LAMBDA = 'LAMBDA', + + /** `...` — the stub idiom. */ + ELLIPSIS = 'ELLIPSIS', + + /** Any other expression. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/python/methods/PythonMethodAccess.ts b/parser/src/enums/python/methods/PythonMethodAccess.ts new file mode 100644 index 000000000..cdaa41d90 --- /dev/null +++ b/parser/src/enums/python/methods/PythonMethodAccess.ts @@ -0,0 +1,23 @@ +/** + * Method access level inferred from leading underscores. + * + * `DUNDER_ACCESS` is separate from `PRIVATE_ACCESS` because the two behave + * oppositely: `__x` is name-mangled and effectively private, while `__x__` is + * **not** mangled and is the public protocol surface the interpreter itself + * calls. + * + * Schema v6 §2.7 c10. + */ +export enum PythonMethodAccess { + /** No leading underscore. */ + PUBLIC_ACCESS = 'PUBLIC_ACCESS', + + /** One leading underscore. */ + PROTECTED_ACCESS = 'PROTECTED_ACCESS', + + /** Two leading underscores, not dunder — name-mangled. */ + PRIVATE_ACCESS = 'PRIVATE_ACCESS', + + /** A `__dunder__` name: not mangled, and called by the interpreter. */ + DUNDER_ACCESS = 'DUNDER_ACCESS', +} diff --git a/parser/src/enums/python/methods/PythonMethodKind.ts b/parser/src/enums/python/methods/PythonMethodKind.ts new file mode 100644 index 000000000..9cef47ac9 --- /dev/null +++ b/parser/src/enums/python/methods/PythonMethodKind.ts @@ -0,0 +1,89 @@ +/** + * What kind of callable a `def`, `async def`, or `lambda` produces. + * + * The two synthetic values are the load-bearing ones. Python allows executable + * code at module level and in class bodies, where Java does not, but the + * resolution layer requires every expression to reach *some* method or call + * attribution fails. So a synthetic `` and `` method are + * minted, exactly as the Java parser already mints `` / `` for + * initializer blocks. Measured: 3,006 executable module-level statements across + * 826 files. + * + * ## Examples + * + * ```python + * def helper(): ... # FUNCTION + * class K: + * def m(self): ... # INSTANCE_METHOD + * @staticmethod + * def s(): ... # STATIC_METHOD + * @classmethod + * def c(cls): ... # CLASS_METHOD + * @property + * def p(self): ... # PROPERTY_GETTER — turns a read into a call + * def __init__(self): ... # CONSTRUCTOR + * def __new__(cls): ... # ALLOCATOR + * @overload + * def f(x: int) -> int: ... # OVERLOAD_STUB — a declaration, never a target + * ``` + * + * Schema v6 §2.7 c16. + */ +export enum PythonMethodKind { + /** A module-level function. */ + FUNCTION = 'FUNCTION', + + /** A method taking an instance receiver. */ + INSTANCE_METHOD = 'INSTANCE_METHOD', + + /** A `@staticmethod` — no receiver, so argument positions do not shift. */ + STATIC_METHOD = 'STATIC_METHOD', + + /** A `@classmethod` — receiver is the class. */ + CLASS_METHOD = 'CLASS_METHOD', + + /** A `@property` getter: `obj.x` becomes a call, not an attribute read. */ + PROPERTY_GETTER = 'PROPERTY_GETTER', + + /** A `@x.setter`. */ + PROPERTY_SETTER = 'PROPERTY_SETTER', + + /** A `@x.deleter`. */ + PROPERTY_DELETER = 'PROPERTY_DELETER', + + /** `__init__`. */ + CONSTRUCTOR = 'CONSTRUCTOR', + + /** `__new__` — runs before `__init__` and can return another type entirely. */ + ALLOCATOR = 'ALLOCATOR', + + /** Any other dunder method. */ + DUNDER_METHOD = 'DUNDER_METHOD', + + /** Decorated `@abstractmethod` — the concrete target is in an implementor. */ + ABSTRACT_METHOD = 'ABSTRACT_METHOD', + + /** An `@overload` signature. `bodyIsStub` is true; never a call target. */ + OVERLOAD_STUB = 'OVERLOAD_STUB', + + /** A `lambda`. */ + LAMBDA = 'LAMBDA', + + /** A function defined inside another function — 5.3% of functions. */ + NESTED_FUNCTION = 'NESTED_FUNCTION', + + /** Contains `yield`, so calling it returns a generator rather than running it. */ + GENERATOR = 'GENERATOR', + + /** An `async def`. Calling it returns a coroutine. */ + ASYNC_FUNCTION = 'ASYNC_FUNCTION', + + /** An `async def` containing `yield`. */ + ASYNC_GENERATOR = 'ASYNC_GENERATOR', + + /** Synthetic `` initializer — owns all module-level code. */ + MODULE_INITIALIZER = 'MODULE_INITIALIZER', + + /** Synthetic `` initializer — owns all class-body code. */ + CLASS_INITIALIZER = 'CLASS_INITIALIZER', +} diff --git a/parser/src/enums/python/methods/PythonMethodModifier.ts b/parser/src/enums/python/methods/PythonMethodModifier.ts new file mode 100644 index 000000000..ec210ff8f --- /dev/null +++ b/parser/src/enums/python/methods/PythonMethodModifier.ts @@ -0,0 +1,47 @@ +/** + * Method modifiers, emitted as a comma-set. + * + * These are mostly decorator-derived, and decorators matter here in a way Java + * annotations do not: a Python decorator is a function application that + * **replaces** the decorated object. `@lru_cache def f()` means the name `f` now + * holds a `functools._lru_cache_wrapper`, not the function. + * + * Schema v6 §2.7 c11. + */ +export enum PythonMethodModifier { + /** `async def`. */ + ASYNC = 'ASYNC', + + /** Contains `yield` or `yield from`. */ + GENERATOR = 'GENERATOR', + + /** `@staticmethod`. */ + STATIC = 'STATIC', + + /** `@classmethod`. */ + CLASS = 'CLASS', + + /** `@property`. */ + PROPERTY = 'PROPERTY', + + /** `@x.setter`. */ + SETTER = 'SETTER', + + /** `@x.deleter`. */ + DELETER = 'DELETER', + + /** `@abstractmethod`. */ + ABSTRACT = 'ABSTRACT', + + /** `@overload`. */ + OVERLOAD = 'OVERLOAD', + + /** `@final`. */ + FINAL = 'FINAL', + + /** `@lru_cache` / `@cache` / `@cached_property`. */ + CACHED = 'CACHED', + + /** Minted by the parser rather than written in source. */ + SYNTHETIC = 'SYNTHETIC', +} diff --git a/parser/src/enums/python/methods/PythonParameterKind.ts b/parser/src/enums/python/methods/PythonParameterKind.ts new file mode 100644 index 000000000..87442433b --- /dev/null +++ b/parser/src/enums/python/methods/PythonParameterKind.ts @@ -0,0 +1,43 @@ +/** + * The five parameter kinds Python distinguishes, plus the two bare markers. + * + * This is required for argument→parameter flow, which is the **primary** typing + * mechanism given that 68.2% of parameters carry no annotation. Getting the kind + * wrong misaligns every positional argument after it. + * + * ## Examples + * + * ```python + * def f(a, /, b, *args, c, **kwargs): ... + * # ^ ^ ^ ^ ^ + * # | | | | VAR_KEYWORD + * # | | | KEYWORD_ONLY + * # | | VAR_POSITIONAL + * # | POSITIONAL_OR_KEYWORD + * # POSITIONAL_ONLY (the `/` itself is POSITIONAL_ONLY_MARKER) + * ``` + * + * Schema v6 §2.8 c12. + */ +export enum PythonParameterKind { + /** The default: bindable by position or by name. */ + POSITIONAL_OR_KEYWORD = 'POSITIONAL_OR_KEYWORD', + + /** Before `/` (PEP 570) — cannot be passed by name. */ + POSITIONAL_ONLY = 'POSITIONAL_ONLY', + + /** After `*` — must be passed by name. */ + KEYWORD_ONLY = 'KEYWORD_ONLY', + + /** `*args`. */ + VAR_POSITIONAL = 'VAR_POSITIONAL', + + /** `**kwargs`. */ + VAR_KEYWORD = 'VAR_KEYWORD', + + /** The bare `/` marker. Binds no name. */ + POSITIONAL_ONLY_MARKER = 'POSITIONAL_ONLY_MARKER', + + /** The bare `*` marker. Binds no name. */ + KEYWORD_ONLY_MARKER = 'KEYWORD_ONLY_MARKER', +} diff --git a/parser/src/enums/python/methods/index.ts b/parser/src/enums/python/methods/index.ts new file mode 100644 index 000000000..2790c2eb9 --- /dev/null +++ b/parser/src/enums/python/methods/index.ts @@ -0,0 +1,5 @@ +export { PythonDefaultValueKind } from '@/enums/python/methods/PythonDefaultValueKind'; +export { PythonMethodAccess } from '@/enums/python/methods/PythonMethodAccess'; +export { PythonMethodKind } from '@/enums/python/methods/PythonMethodKind'; +export { PythonMethodModifier } from '@/enums/python/methods/PythonMethodModifier'; +export { PythonParameterKind } from '@/enums/python/methods/PythonParameterKind'; diff --git a/parser/src/enums/python/modules/PythonDialect.ts b/parser/src/enums/python/modules/PythonDialect.ts new file mode 100644 index 000000000..2c94d1082 --- /dev/null +++ b/parser/src/enums/python/modules/PythonDialect.ts @@ -0,0 +1,34 @@ +/** + * The dialect detected for a file — a **detection / rejection** signal, not a + * semantics selector. + * + * Python 2 is out of scope (schema v6 §6). The hazard this enum exists to close + * is that `tree-sitter-python@0.21.0` parses Python 2 **without erroring**: it + * carries first-class `print_statement`, `exec_statement` and `chevron` nodes, + * so `print "x"` produces a clean tree with `hasError === false` and a full, + * confident, plausible-looking fact set whose scoping semantics we are not + * applying. Absence of support is not the same as rejection, so a Py2 file must + * be detected and rejected explicitly. + * + * ## Examples + * + * ```python + * print("x") # PY3 + * print "x" # PY2_DETECTED_REJECTED (print_statement) + * except E, e: # PY2_DETECTED_REJECTED (two-identifier except_clause) + * def f((a, b)): # PY2_DETECTED_REJECTED (tuple_pattern in parameters) + * x = `repr` # PY2_DETECTED_REJECTED (raw-source backtick scan) + * ``` + * + * Schema v6 §2.1 c9, §6.2. + */ +export enum PythonDialect { + /** Parsed as Python 3; facts are emitted. */ + PY3 = 'PY3', + + /** A Python-2-only construct was found. **No facts are emitted** for the module. */ + PY2_DETECTED_REJECTED = 'PY2_DETECTED_REJECTED', + + /** Dialect could not be established (unreadable or unparseable source). */ + PY_UNKNOWN = 'PY_UNKNOWN', +} diff --git a/parser/src/enums/python/modules/PythonEmissionRegime.ts b/parser/src/enums/python/modules/PythonEmissionRegime.ts new file mode 100644 index 000000000..3e9597a67 --- /dev/null +++ b/parser/src/enums/python/modules/PythonEmissionRegime.ts @@ -0,0 +1,39 @@ +/** + * What the **facts were emitted under** — a property of the analysis run, not + * of the file. + * + * This is the single column the engine branches on for comprehension scoping, + * and it is in `py_module`'s primary key so that two regimes can never collide + * even if both fact sets are loaded at once. + * + * ## Why this cannot be inferred + * + * The two regimes disagree **structurally** on the most common construct in the + * language, while agreeing **semantically**: + * + * ``` + * Construct PY3_0_11 PY3_12_PLUS + * listcomp own scope + synthetic `.0` no scope (PEP 709 inlined) + * setcomp own scope + `.0` no scope (inlined) + * dictcomp own scope + `.0` no scope (inlined) + * genexpr own scope + `.0` own scope + `.0` + * ``` + * + * In both regimes the comprehension target is isolated from the enclosing + * scope, so runtime semantics cannot tell you which regime produced a fact set, + * and a harness that infers the regime from its own output is confidently wrong + * on one of them. Hence the regime is an **input**, recorded in the fact table. + * + * `PY3_0_11` is the freeze target because it is the *richer* regime: emission is + * implemented there and gated off for 3.12, which is subtractive and keeps every + * golden file exercising the path. + * + * Schema v6 §2.1 c11, §4.4. + */ +export enum PythonEmissionRegime { + /** Python 3.0–3.11: list/set/dict comprehensions and genexprs all get scopes. */ + PY3_0_11 = 'PY3_0_11', + + /** Python 3.12+: PEP 709 inlines list/set/dict comprehensions; genexpr keeps its scope. */ + PY3_12_PLUS = 'PY3_12_PLUS', +} diff --git a/parser/src/enums/python/modules/PythonGrammarUsed.ts b/parser/src/enums/python/modules/PythonGrammarUsed.ts new file mode 100644 index 000000000..57d434c58 --- /dev/null +++ b/parser/src/enums/python/modules/PythonGrammarUsed.ts @@ -0,0 +1,19 @@ +/** + * Which front end produced the tree for a module, and whether it was complete. + * + * `TS_PYTHON3_PARTIAL` means at least one `py_parse_gap` row exists for the + * module — the gap is recorded, never repaired, and positions stay measured + * against the original source. + * + * Schema v6 §2.1 c12. + */ +export enum PythonGrammarUsed { + /** `tree-sitter-python@0.21.0`, clean parse. */ + TS_PYTHON3 = 'TS_PYTHON3', + + /** Parsed, but with at least one recorded `py_parse_gap` (ERROR / MISSING node). */ + TS_PYTHON3_PARTIAL = 'TS_PYTHON3_PARTIAL', + + /** No usable tree — the module was rejected or unreadable. */ + UNPARSED = 'UNPARSED', +} diff --git a/parser/src/enums/python/modules/PythonModuleKind.ts b/parser/src/enums/python/modules/PythonModuleKind.ts new file mode 100644 index 000000000..4809a1df0 --- /dev/null +++ b/parser/src/enums/python/modules/PythonModuleKind.ts @@ -0,0 +1,40 @@ +/** + * Classifies a Python module by the role its file plays in a package. + * + * Python's module is a first-class runtime namespace object and the unit of + * import resolution, so the kind is not cosmetic: `PACKAGE_INIT` participates + * in re-export resolution, and `STUB` bodies are declarations that must never + * be treated as call targets. + * + * ## Examples + * + * ``` + * app/web/views.py -> MODULE + * app/web/__init__.py -> PACKAGE_INIT + * app/web/ (no init) -> NAMESPACE_PACKAGE (PEP 420) + * scripts/migrate.py -> SCRIPT (no package, executable) + * stubs/views.pyi -> STUB + * tools/run.py -> MAIN_GUARD_SCRIPT (has `if __name__ == "__main__"`) + * ``` + * + * Schema v6 §2.1 c5. + */ +export enum PythonModuleKind { + /** An ordinary importable `.py` module inside a package. */ + MODULE = 'MODULE', + + /** An `__init__.py` — the package's own namespace, and its re-export surface. */ + PACKAGE_INIT = 'PACKAGE_INIT', + + /** A PEP 420 implicit namespace package directory with no `__init__.py`. */ + NAMESPACE_PACKAGE = 'NAMESPACE_PACKAGE', + + /** A top-level file outside any package — imported by nothing. */ + SCRIPT = 'SCRIPT', + + /** A `.pyi` type stub: signatures only, bodies are `...`. */ + STUB = 'STUB', + + /** A script carrying an `if __name__ == "__main__":` entry point. */ + MAIN_GUARD_SCRIPT = 'MAIN_GUARD_SCRIPT', +} diff --git a/parser/src/enums/python/modules/index.ts b/parser/src/enums/python/modules/index.ts new file mode 100644 index 000000000..2f89f0076 --- /dev/null +++ b/parser/src/enums/python/modules/index.ts @@ -0,0 +1,4 @@ +export { PythonDialect } from '@/enums/python/modules/PythonDialect'; +export { PythonEmissionRegime } from '@/enums/python/modules/PythonEmissionRegime'; +export { PythonGrammarUsed } from '@/enums/python/modules/PythonGrammarUsed'; +export { PythonModuleKind } from '@/enums/python/modules/PythonModuleKind'; diff --git a/parser/src/enums/python/parse-gaps/PythonParseGapDisposition.ts b/parser/src/enums/python/parse-gaps/PythonParseGapDisposition.ts new file mode 100644 index 000000000..831ba9600 --- /dev/null +++ b/parser/src/enums/python/parse-gaps/PythonParseGapDisposition.ts @@ -0,0 +1,19 @@ +/** + * How the gap presented itself — and the reason this column exists at all. + * + * `MISPARSED_SILENTLY` is the dangerous one and the one the whole relation is + * built around. An `ERROR_NODE` announces itself; a backtick expression parses + * into a plausible-but-wrong node and produces confident facts about code that + * does not mean what the tree says. A consumer must be able to tell "the parser + * knew it was lost" from "the parser did not notice". + * + * Schema v7 §2.19 c2. + */ +export enum PythonParseGapDisposition { + /** The grammar flagged it; nothing downstream trusts the region. */ + ERROR_NODE = 'ERROR_NODE', + /** The grammar produced a plausible but WRONG node — no error was raised. */ + MISPARSED_SILENTLY = 'MISPARSED_SILENTLY', + /** The construct was recognised and deliberately not emitted. */ + SKIPPED = 'SKIPPED', +} diff --git a/parser/src/enums/python/parse-gaps/PythonParseGapKind.ts b/parser/src/enums/python/parse-gaps/PythonParseGapKind.ts new file mode 100644 index 000000000..05e8d2ea6 --- /dev/null +++ b/parser/src/enums/python/parse-gaps/PythonParseGapKind.ts @@ -0,0 +1,40 @@ +/** + * What the grammar could not represent, or represented wrongly. + * + * This relation RECORDS a gap and never repairs it. That is the whole design: + * v3 had a source-rewriter and an edit-audit table so positions could be mapped + * back, and §6.2(a) cancelled it because tree-sitter already parses 99.59% of + * the CPython 2.7 stdlib unaided — putting every position in the fact table + * behind a mapping to recover 0.41% of files is the wrong trade. + * + * Schema v7 §2.19 c1. + */ +export enum PythonParseGapKind { + /** `exec tmpl % (a,)` — the only measured real grammar gap. */ + EXEC_COMPLEX_EXPR = 'EXEC_COMPLEX_EXPR', + /** A Python 2-only node type; the module is rejected wholesale (§6.2). */ + PY2_CONSTRUCT_DETECTED = 'PY2_CONSTRUCT_DETECTED', + /** tree-sitter flagged an ERROR node. */ + /** + * The grammar applied a SOFT KEYWORD where it should not have, producing a + * clean parse of a different statement. + * + * `type(obj).attr = value` -- the ordinary way to set an attribute on an + * object's class -- is read as a PEP 695 type alias, because the leading + * `type` is taken as the keyword and `(obj).attr` accepted as the alias name. + * The `call` node is then absent from the tree entirely, so the callee has no + * identifier node and NO py_call_site can be minted for it however the + * statement is walked. The target, the value and any nested calls ARE + * recoverable and are recovered; the outer call is not, and that is what this + * row records. + * + * Disposition is MISPARSED_SILENTLY, not ERROR_NODE: nothing in the tree is + * marked wrong, so without this row a consumer cannot tell a file where the + * idiom appears from one where it does not. + */ + SOFT_KEYWORD_MISPARSE = 'SOFT_KEYWORD_MISPARSE', + + ERROR_NODE = 'ERROR_NODE', + /** tree-sitter inserted a MISSING node to recover. */ + MISSING_NODE = 'MISSING_NODE', +} diff --git a/parser/src/enums/python/parse-gaps/index.ts b/parser/src/enums/python/parse-gaps/index.ts new file mode 100644 index 000000000..e4ef939f6 --- /dev/null +++ b/parser/src/enums/python/parse-gaps/index.ts @@ -0,0 +1,2 @@ +export { PythonParseGapDisposition } from '@/enums/python/parse-gaps/PythonParseGapDisposition'; +export { PythonParseGapKind } from '@/enums/python/parse-gaps/PythonParseGapKind'; diff --git a/parser/src/enums/python/scopes/PythonScopeKind.ts b/parser/src/enums/python/scopes/PythonScopeKind.ts new file mode 100644 index 000000000..a42a56141 --- /dev/null +++ b/parser/src/enums/python/scopes/PythonScopeKind.ts @@ -0,0 +1,85 @@ +/** + * The kind of a Python scope — a direct mirror of `symtable.SymbolTable`. + * + * This enum exists so the oracle can assert **set equality** with CPython + * rather than eyeballing structure; it is the reason precision and recall are + * well-defined for the scope relation at all. + * + * ## The non-obvious cases + * + * A scope is introduced by exactly eight syntactic forms. `def`, `async def`, + * `class` and `lambda` are expected. The other four are comprehensions, which + * are separate scopes on the `PY3_0_11` target: + * + * ```python + * x = "outer" + * squares = [x * x for x in values] # COMPREHENSION_LIST — its own scope; + * # the inner `x` never touches the outer one + * ``` + * + * `CLASS` is **not** an enclosing scope for name resolution: a class body's + * names are invisible to functions nested inside it, which is why the analysis + * pass passes a class body's bindings to its children differently from a + * function's. + * + * Schema v6 §2.2 c0. + */ +export enum PythonScopeKind { + /** The module's own top-level scope. One per module, the root of the forest. */ + MODULE = 'MODULE', + + /** A `class` body. Bindings here are attributes, not closure-visible locals. */ + CLASS = 'CLASS', + + /** A `def` or `async def` body. */ + FUNCTION = 'FUNCTION', + + /** A `lambda` body. Named `lambda` by symtable, so two on one line collide without a column. */ + LAMBDA = 'LAMBDA', + + /** A list comprehension — symtable name `listcomp`. */ + COMPREHENSION_LIST = 'COMPREHENSION_LIST', + + /** A set comprehension — symtable name `setcomp`. */ + COMPREHENSION_SET = 'COMPREHENSION_SET', + + /** A dict comprehension — symtable name `dictcomp`. */ + COMPREHENSION_DICT = 'COMPREHENSION_DICT', + + /** A generator expression — symtable name `genexpr`. Keeps its scope in every regime. */ + GENERATOR_EXPRESSION = 'GENERATOR_EXPRESSION', + + /** PEP 695 type-parameter scope (3.12). Deferred — declared for forward parity. */ + TYPE_PARAM = 'TYPE_PARAM', + + /** PEP 695 `type` alias scope (3.12). Deferred — declared for forward parity. */ + TYPE_ALIAS = 'TYPE_ALIAS', + + /** + * The scope CPython opens for a BOUNDED or CONSTRAINED type parameter (3.12+). + * + * `class C[T: int]` is three scopes deep, not two, and the middle one is a + * distinct block type in CPython's own symtable — `get_type()` returns + * `"TypeVar bound"`, not `"type parameter"`. Verified directly on 3.12.4: + * + * ``` + * class C[T] module -> type parameter -> class + * class C[T: int] module -> type parameter -> TypeVar bound -> class + * def f[T: (int, str)]() module -> type parameter -> TypeVar bound -> function + * ``` + * + * The bound gets its own scope because it is EVALUATED LAZILY and can refer to + * the type parameters around it, so it cannot share the wrapper's namespace. + * A constrained parameter — the tuple form — opens it too, so this is not the + * rare case it might look like: 18 occurrences in CPython's own PEP 695 tests. + * + * §2.2 anticipated TYPE_PARAM and TYPE_ALIAS but not this third scope. Merging + * it into TYPE_PARAM would report a two-level nesting as flat and lose the + * distinction between a name visible to the bound and one visible only to the + * body. + */ + TYPE_PARAM_BOUND = 'TYPE_PARAM_BOUND', + + /** PEP 649 deferred-annotation scope (3.14). Deferred — declared for forward parity. */ + ANNOTATION = 'ANNOTATION', +} diff --git a/parser/src/enums/python/scopes/PythonScopeOwnerKind.ts b/parser/src/enums/python/scopes/PythonScopeOwnerKind.ts new file mode 100644 index 000000000..89c8e4fbe --- /dev/null +++ b/parser/src/enums/python/scopes/PythonScopeOwnerKind.ts @@ -0,0 +1,25 @@ +/** + * Discriminator for `py_scope.ownerHash`, which is polymorphic. + * + * Invariant #1 (referential integrity) resolves a polymorphic FK in the + * relation selected by its discriminator, so this column decides which table + * `ownerHash` is looked up in. + * + * Schema v6 §2.2 c6. + */ +export enum PythonScopeOwnerKind { + /** Owner is a `py_module` row (module scope). */ + MODULE = 'MODULE', + + /** Owner is a `py_type` row (class body scope). */ + TYPE = 'TYPE', + + /** Owner is a `py_method` row (`def` / `async def`). */ + METHOD = 'METHOD', + + /** Owner is the synthetic `py_method` minted for a `lambda`. */ + LAMBDA = 'LAMBDA', + + /** Owner is a comprehension or generator expression. */ + COMPREHENSION = 'COMPREHENSION', +} diff --git a/parser/src/enums/python/scopes/SymbolBlockType.ts b/parser/src/enums/python/scopes/SymbolBlockType.ts new file mode 100644 index 000000000..0f49a6aa1 --- /dev/null +++ b/parser/src/enums/python/scopes/SymbolBlockType.ts @@ -0,0 +1,45 @@ +/** + * The block types CPython's symbol table distinguishes. + * + * Worth stating explicitly because the mapping is not one-to-one with the + * syntactic forms: **lambdas and all four comprehension forms are `FUNCTION` + * blocks**. symtable gives them no type of their own, which is why + * `SymbolTable.is_optimized()` returns true for a list comprehension, and why + * the analysis pass treats a comprehension's locals as capturable exactly like a + * function's. + * + * The `CLASS` / `FUNCTION` distinction is load-bearing in the other direction: a + * class body's bindings are **not** visible to functions nested inside it, so a + * class block contributes nothing to the `bound` set handed to its children. + */ +export enum SymbolBlockType { + /** The module block. Exactly one per file, the root of the scope forest. */ + MODULE = 'module', + + /** A class body. Does not provide closure cells to nested scopes. */ + CLASS = 'class', + + /** A `def`, `async def`, `lambda`, or any comprehension. */ + FUNCTION = 'function', + + // ── PEP 695 annotation scopes, 3.12+ ────────────────────────────────────── + // The strings are CPython's own `SymbolTable.get_type()` values, because that + // is what this enum mirrors and what the oracle compares against. + + /** + * The scope a type parameter list opens: `class C[T]` / `def f[U]` / `type A[W]`. + * + * It WRAPS the class or function scope rather than sitting inside it — symtable + * nests the `class` block within this one — and it is where the parameter names + * bind. With type parameters present a class's BASES and a function's DEFAULTS + * are evaluated here too, which is what `.generic_base` and `.defaults` are in + * CPython's own symbol list. + */ + TYPE_PARAM = 'type parameter', + + /** The value scope of `type A = …`, nested inside TYPE_PARAM when generic. */ + TYPE_ALIAS = 'type alias', + + /** The bound of one parameter: the `int` in `class C[T: int]`. */ + TYPE_PARAM_BOUND = 'TypeVar bound', +} diff --git a/parser/src/enums/python/scopes/index.ts b/parser/src/enums/python/scopes/index.ts new file mode 100644 index 000000000..65bfa78f2 --- /dev/null +++ b/parser/src/enums/python/scopes/index.ts @@ -0,0 +1,3 @@ +export { PythonScopeKind } from '@/enums/python/scopes/PythonScopeKind'; +export { PythonScopeOwnerKind } from '@/enums/python/scopes/PythonScopeOwnerKind'; +export { SymbolBlockType } from '@/enums/python/scopes/SymbolBlockType'; diff --git a/parser/src/enums/python/type-parameters/PythonTypeParameterKind.ts b/parser/src/enums/python/type-parameters/PythonTypeParameterKind.ts new file mode 100644 index 000000000..816fd6f97 --- /dev/null +++ b/parser/src/enums/python/type-parameters/PythonTypeParameterKind.ts @@ -0,0 +1,32 @@ +/** + * Which of PEP 695's three parameter forms this is. + * + * **NOT EMITTED — there is no column for it.** §2.20 declares fourteen columns + * and none carries a parameter kind, so `*Ts` and `**P` are currently + * INDISTINGUISHABLE in `py_type_parameter`: both appear as a plain name with an + * empty bound. That is a real loss — a TypeVarTuple stands for a sequence of + * types and a ParamSpec for a whole parameter list, so `Callable[P, R]` and + * `tuple[*Ts]` are different shapes that read identically in the facts. + * + * Kept rather than deleted because the distinction is real and the extractor + * already recovers it from source text; it needs a column to live in. Raised + * with A0 as a schema question. Until then this enum is deliberately unused, and + * saying so here is better than leaving a reader to wonder why nothing + * references it. + * + * tree-sitter does NOT distinguish them: `*Ts` and `**P` both parse to + * `splat_type` with an identifier under it, so the kind has to come from the + * source text. They are genuinely different things — a TypeVarTuple stands for a + * SEQUENCE of types and a ParamSpec for a whole parameter LIST — and collapsing + * them would make `Callable[P, R]` and `tuple[*Ts]` look like the same shape. + * + * Schema v7 §2.20. + */ +export enum PythonTypeParameterKind { + /** `T` — one type. */ + TYPE_VAR = 'TYPE_VAR', + /** `*Ts` — a variadic sequence of types (PEP 646). */ + TYPE_VAR_TUPLE = 'TYPE_VAR_TUPLE', + /** `**P` — a callable's whole parameter list (PEP 612). */ + PARAM_SPEC = 'PARAM_SPEC', +} diff --git a/parser/src/enums/python/type-parameters/PythonTypeParameterVariance.ts b/parser/src/enums/python/type-parameters/PythonTypeParameterVariance.ts new file mode 100644 index 000000000..4a098eb96 --- /dev/null +++ b/parser/src/enums/python/type-parameters/PythonTypeParameterVariance.ts @@ -0,0 +1,18 @@ +/** + * Variance of a type parameter. + * + * PEP 695 removed the explicit `covariant=True` spelling: variance is now + * INFERRED by the type checker from how the parameter is used. So a parser + * reading 3.12 syntax honestly reports `INFERRED` and not a guess — the other + * values exist for the legacy `TypeVar(..., covariant=True)` form, which is a + * runtime assignment rather than syntax and lands in `py_binding` instead. + * + * Schema v7 §2.20 c9. + */ +export enum PythonTypeParameterVariance { + /** PEP 695: the checker infers it, and the source does not say. */ + INFERRED = 'INFERRED', + INVARIANT = 'INVARIANT', + COVARIANT = 'COVARIANT', + CONTRAVARIANT = 'CONTRAVARIANT', +} diff --git a/parser/src/enums/python/type-parameters/index.ts b/parser/src/enums/python/type-parameters/index.ts new file mode 100644 index 000000000..efe6f9029 --- /dev/null +++ b/parser/src/enums/python/type-parameters/index.ts @@ -0,0 +1,2 @@ +export { PythonTypeParameterKind } from '@/enums/python/type-parameters/PythonTypeParameterKind'; +export { PythonTypeParameterVariance } from '@/enums/python/type-parameters/PythonTypeParameterVariance'; diff --git a/parser/src/enums/python/type-references/PythonTypeRefContext.ts b/parser/src/enums/python/type-references/PythonTypeRefContext.ts new file mode 100644 index 000000000..768ee630a --- /dev/null +++ b/parser/src/enums/python/type-references/PythonTypeRefContext.ts @@ -0,0 +1,48 @@ +/** + * Where a type reference appears — the question `py_expression.edgeRole` cannot + * answer. + * + * This is the column that makes a type reference queryable by role: "every type + * used as a method return", "every type in an isinstance guard". `edgeRole` only + * separates `ANNOTATION` from everything else, so without this the distinction + * between a parameter type, a return type and a narrowing guard is lost. + * + * `ISINSTANCE_TYPE` earns its place on measurement: 2,123 `isinstance` sites in + * the corpus, and they are the main type-narrowing lever the engine has. + * + * Schema v6 §2.6 c1. + */ +export enum PythonTypeRefContext { + /** A positional base in a class statement. */ + BASE_CLASS = 'BASE_CLASS', + /** The `metaclass=` keyword argument. */ + METACLASS = 'METACLASS', + /** A parameter annotation. */ + METHOD_PARAM = 'METHOD_PARAM', + /** A `->` return annotation. */ + METHOD_RETURN = 'METHOD_RETURN', + /** An attribute's declared type. */ + FIELD_TYPE = 'FIELD_TYPE', + /** A variable annotation, `x: int`. */ + VARIABLE_ANNOTATION = 'VARIABLE_ANNOTATION', + /** The target of `typing.cast`. */ + CAST_TARGET = 'CAST_TARGET', + /** The second argument of `isinstance` — the narrowing lever. */ + ISINSTANCE_TYPE = 'ISINSTANCE_TYPE', + /** The second argument of `issubclass`. */ + ISSUBCLASS_TYPE = 'ISSUBCLASS_TYPE', + /** A type in an `except` clause. */ + EXCEPT_TYPE = 'EXCEPT_TYPE', + /** A type in a `raise` statement. */ + RAISE_TYPE = 'RAISE_TYPE', + /** The right-hand side of a type alias. */ + TYPE_ALIAS = 'TYPE_ALIAS', + /** A `TypeVar` bound. */ + TYPEVAR_BOUND = 'TYPEVAR_BOUND', + /** An argument inside a subscripted generic — a CHILD reference. */ + GENERIC_ARGUMENT = 'GENERIC_ARGUMENT', + /** Part of an `@overload` signature. */ + OVERLOAD_SIGNATURE = 'OVERLOAD_SIGNATURE', + /** Recovered from a PEP 484 `# type:` comment. */ + TYPE_COMMENT = 'TYPE_COMMENT', +} diff --git a/parser/src/enums/python/type-references/PythonTypeRefKind.ts b/parser/src/enums/python/type-references/PythonTypeRefKind.ts new file mode 100644 index 000000000..44798b1ed --- /dev/null +++ b/parser/src/enums/python/type-references/PythonTypeRefKind.ts @@ -0,0 +1,55 @@ +/** + * The syntactic form of a type reference. + * + * Distinguishes the shapes that a resolver has to treat differently, rather than + * flattening everything to "a name". `UNION_PEP604` and `OPTIONAL` are separate + * from `SUBSCRIPT` because both admit more than one type at the same position, + * which is the case that breaks a naive "the annotation is the type" assumption. + * + * ## Examples + * + * ```python + * x: User # NAME + * x: app.models.User # DOTTED_NAME + * x: Dict[str, User] # SUBSCRIPT, with two children + * x: int | None # UNION_PEP604 + * x: Optional[User] # OPTIONAL — the #1 subscript, 3,517 in the corpus + * x: "User" # STRING_FORWARD_REF + * x: Literal["a", "b"] # LITERAL_TYPE + * x: Callable[[int], str] # CALLABLE + * x: Tuple[int, str] # TUPLE_TYPE + * x: T # TYPE_VAR + * ``` + * + * Schema v6 §2.6 c0. + */ +export enum PythonTypeRefKind { + /** A bare name. */ + NAME = 'NAME', + /** A dotted path. */ + DOTTED_NAME = 'DOTTED_NAME', + /** A subscripted generic; its arguments are child references. */ + SUBSCRIPT = 'SUBSCRIPT', + /** PEP 604 `A | B`. */ + UNION_PEP604 = 'UNION_PEP604', + /** `Optional[X]` — admits `None`, so `isOptional` is set alongside. */ + OPTIONAL = 'OPTIONAL', + /** A quoted forward reference. */ + STRING_FORWARD_REF = 'STRING_FORWARD_REF', + /** `Literal[...]` — the arguments are values, not types. */ + LITERAL_TYPE = 'LITERAL_TYPE', + /** `Callable[[...], R]`. */ + CALLABLE = 'CALLABLE', + /** `Tuple[...]`. */ + TUPLE_TYPE = 'TUPLE_TYPE', + /** A `TypeVar`. */ + TYPE_VAR = 'TYPE_VAR', + /** `Any` — explicitly unconstrained, which is different from unknown. */ + ANY = 'ANY', + /** `None` in a type position. */ + NONE_TYPE = 'NONE_TYPE', + /** `...` in a type position, as in `Callable[..., R]`. */ + ELLIPSIS_TYPE = 'ELLIPSIS_TYPE', + /** Any other expression appearing in a type position. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/python/type-references/PythonTypeRefOwnerKind.ts b/parser/src/enums/python/type-references/PythonTypeRefOwnerKind.ts new file mode 100644 index 000000000..6515544c4 --- /dev/null +++ b/parser/src/enums/python/type-references/PythonTypeRefOwnerKind.ts @@ -0,0 +1,19 @@ +/** + * Discriminator for `py_type_reference.typeReferenceOwnerHash`. + * + * Invariant #1 resolves a polymorphic FK in the relation its discriminator + * names, so this decides which table the owner hash is looked up in. + * + * Schema v6 §2.6 c16. + */ +export enum PythonTypeRefOwnerKind { + TYPE = 'TYPE', + METHOD = 'METHOD', + METHOD_PARAM = 'METHOD_PARAM', + FIELD = 'FIELD', + BINDING = 'BINDING', + EXPRESSION = 'EXPRESSION', + DECORATOR = 'DECORATOR', + TYPE_BASE = 'TYPE_BASE', + BLOCK = 'BLOCK', +} diff --git a/parser/src/enums/python/type-references/PythonWildcardVariance.ts b/parser/src/enums/python/type-references/PythonWildcardVariance.ts new file mode 100644 index 000000000..d20266c47 --- /dev/null +++ b/parser/src/enums/python/type-references/PythonWildcardVariance.ts @@ -0,0 +1,14 @@ +/** + * TypeVar variance, occupying Java's wildcard-variance column. + * + * Python spells it on the `TypeVar` itself — `TypeVar("T", covariant=True)` — + * rather than at the use site as Java's `? extends` does, but the column serves + * the same purpose and keeps position parity. + * + * Schema v6 §2.6 c12. + */ +export enum PythonWildcardVariance { + COVARIANT = 'COVARIANT', + CONTRAVARIANT = 'CONTRAVARIANT', + INVARIANT = 'INVARIANT', +} diff --git a/parser/src/enums/python/type-references/index.ts b/parser/src/enums/python/type-references/index.ts new file mode 100644 index 000000000..49e9b6e95 --- /dev/null +++ b/parser/src/enums/python/type-references/index.ts @@ -0,0 +1,4 @@ +export { PythonTypeRefContext } from '@/enums/python/type-references/PythonTypeRefContext'; +export { PythonTypeRefKind } from '@/enums/python/type-references/PythonTypeRefKind'; +export { PythonTypeRefOwnerKind } from '@/enums/python/type-references/PythonTypeRefOwnerKind'; +export { PythonWildcardVariance } from '@/enums/python/type-references/PythonWildcardVariance'; diff --git a/parser/src/enums/python/types/PythonBaseKind.ts b/parser/src/enums/python/types/PythonBaseKind.ts new file mode 100644 index 000000000..534ae897d --- /dev/null +++ b/parser/src/enums/python/types/PythonBaseKind.ts @@ -0,0 +1,48 @@ +/** + * The syntactic shape of one entry in a class's base list. + * + * Bases are **ordered** (C3 depends on it) and can be arbitrary expressions, + * while `metaclass=` and `total=` are syntactically in the same list but + * semantically are not bases at all — which is why this is its own relation + * rather than a type reference. + * + * ## Examples + * + * ```python + * class A(Base): ... # NAME + * class B(collections.abc.Mapping): ... # DOTTED_NAME + * class C(Generic[T]): ... # SUBSCRIPT — 14.6% of bases + * class D(mixin_factory()): ... # CALL — cannot be linearised + * class E(Base, metaclass=Meta): ... # KEYWORD_METACLASS + * class F(TypedDict, total=False): ... # KEYWORD_OTHER + * class G(*bases): ... # STARRED + * class H: ... # IMPLICIT_OBJECT + * ``` + * + * Schema v6 §2.5 c0. + */ +export enum PythonBaseKind { + /** A bare name. */ + NAME = 'NAME', + + /** A dotted path. */ + DOTTED_NAME = 'DOTTED_NAME', + + /** A subscripted generic such as `Generic[T]`. */ + SUBSCRIPT = 'SUBSCRIPT', + + /** A call — a dynamically produced base class. */ + CALL = 'CALL', + + /** The `metaclass=` keyword argument. */ + KEYWORD_METACLASS = 'KEYWORD_METACLASS', + + /** Any other keyword argument, such as `total=`. */ + KEYWORD_OTHER = 'KEYWORD_OTHER', + + /** An unpacked base list, `*bases`. */ + STARRED = 'STARRED', + + /** Synthetic row for a class with no explicit bases. */ + IMPLICIT_OBJECT = 'IMPLICIT_OBJECT', +} diff --git a/parser/src/enums/python/types/PythonMroKind.ts b/parser/src/enums/python/types/PythonMroKind.ts new file mode 100644 index 000000000..3127ab471 --- /dev/null +++ b/parser/src/enums/python/types/PythonMroKind.ts @@ -0,0 +1,24 @@ +/** + * How a class's method resolution order is determined. + * + * This exists so the engine can distinguish "implicit `object`, trivial MRO" + * from a real C3 linearisation from a base it cannot linearise at all, without + * re-deriving it from `py_type_base` on every query. The measured + * distribution justifies all four: 19.5% of classes have no explicit base and + * 12.1% have more than one. + * + * Schema v6 §2.4 c21. + */ +export enum PythonMroKind { + /** Multiple bases, all statically known — a real C3 linearisation. */ + C3_LINEARIZABLE = 'C3_LINEARIZABLE', + + /** Exactly one statically known base. */ + SINGLE_INHERITANCE = 'SINGLE_INHERITANCE', + + /** No explicit base: the MRO is `[cls, object]`. */ + IMPLICIT_OBJECT = 'IMPLICIT_OBJECT', + + /** At least one base is computed, so the MRO cannot be linearised statically. */ + DYNAMIC_UNKNOWN = 'DYNAMIC_UNKNOWN', +} diff --git a/parser/src/enums/python/types/PythonTypeAccess.ts b/parser/src/enums/python/types/PythonTypeAccess.ts new file mode 100644 index 000000000..93d7171d3 --- /dev/null +++ b/parser/src/enums/python/types/PythonTypeAccess.ts @@ -0,0 +1,26 @@ +/** + * Access level inferred from a name's leading underscores. + * + * Python has no access keywords; visibility is a naming convention, and exactly + * one part of it is enforced by the language (name mangling of `__x`). + * + * ## Examples + * + * ```python + * class Service: ... # PUBLIC_ACCESS + * class _Internal: ... # PROTECTED_ACCESS — convention only + * class __Private: ... # PRIVATE_ACCESS — name-mangled by the compiler + * ``` + * + * Schema v6 §2.4 c4. + */ +export enum PythonTypeAccess { + /** No leading underscore. */ + PUBLIC_ACCESS = 'PUBLIC_ACCESS', + + /** One leading underscore — "internal use" by convention. */ + PROTECTED_ACCESS = 'PROTECTED_ACCESS', + + /** Two leading underscores — name-mangled, the only enforced case. */ + PRIVATE_ACCESS = 'PRIVATE_ACCESS', +} diff --git a/parser/src/enums/python/types/PythonTypeCategory.ts b/parser/src/enums/python/types/PythonTypeCategory.ts new file mode 100644 index 000000000..1870c044e --- /dev/null +++ b/parser/src/enums/python/types/PythonTypeCategory.ts @@ -0,0 +1,57 @@ +/** + * What kind of class a `class` statement declares. + * + * Python has one `class` keyword but many semantically distinct kinds, and the + * distinctions change dispatch: a `NamedTuple` generates `__init__` in field + * order, a `Protocol` member is never a call target, an `Enum` member is a + * class attribute holding an instance of its own class. + * + * ## Examples + * + * ```python + * class Service: ... # CLASS_TYPE + * class MyError(ValueError): ... # EXCEPTION_CLASS_TYPE + * class Color(Enum): ... # ENUM_CLASS_TYPE + * class Reader(Protocol): ... # PROTOCOL_TYPE + * class Base(ABC): ... # ABC_TYPE + * class Point(NamedTuple): ... # NAMEDTUPLE_TYPE + * class Config(TypedDict): ... # TYPEDDICT_TYPE + * @dataclass + * class User: ... # DATACLASS_TYPE + * class Meta(type): ... # METACLASS_TYPE + * class Box(Generic[T]): ... # GENERIC_TYPE + * ``` + * + * Schema v6 §2.4 c3. + */ +export enum PythonTypeCategory { + /** An ordinary class. */ + CLASS_TYPE = 'CLASS_TYPE', + + /** Inherits from `Exception` / `BaseException` or a known exception. */ + EXCEPTION_CLASS_TYPE = 'EXCEPTION_CLASS_TYPE', + + /** An `Enum`, `IntEnum`, `Flag`, or `StrEnum` subclass. */ + ENUM_CLASS_TYPE = 'ENUM_CLASS_TYPE', + + /** A `typing.Protocol` — structural typing; members are declarations. */ + PROTOCOL_TYPE = 'PROTOCOL_TYPE', + + /** An abstract base class: inherits `ABC` or uses `ABCMeta`. */ + ABC_TYPE = 'ABC_TYPE', + + /** A `typing.NamedTuple` or `collections.namedtuple` class. */ + NAMEDTUPLE_TYPE = 'NAMEDTUPLE_TYPE', + + /** A `typing.TypedDict`. */ + TYPEDDICT_TYPE = 'TYPEDDICT_TYPE', + + /** Decorated with `@dataclass` — `__init__` is generated in field order. */ + DATACLASS_TYPE = 'DATACLASS_TYPE', + + /** Inherits from `type` — instances of it are themselves classes. */ + METACLASS_TYPE = 'METACLASS_TYPE', + + /** Parameterised with `Generic[...]`. */ + GENERIC_TYPE = 'GENERIC_TYPE', +} diff --git a/parser/src/enums/python/types/PythonTypeModifier.ts b/parser/src/enums/python/types/PythonTypeModifier.ts new file mode 100644 index 000000000..4e557af5a --- /dev/null +++ b/parser/src/enums/python/types/PythonTypeModifier.ts @@ -0,0 +1,55 @@ +/** + * Class-level modifiers, emitted as a comma-set. + * + * The last four are **dispatch escape hatches** rather than descriptions: a + * class defining `__getattr__` can answer for attributes that appear nowhere in + * the source, so a resolver must know not to trust an "attribute not found" + * conclusion about it. Measured at 1.8% of classes — rare enough to mark rather + * than redesign around. + * + * Schema v6 §2.4 c5. + */ +export enum PythonTypeModifier { + /** Has abstract methods or an ABC metaclass. */ + ABSTRACT = 'ABSTRACT', + + /** Decorated `@final`. */ + FINAL = 'FINAL', + + /** A frozen dataclass — instances are immutable. */ + FROZEN = 'FROZEN', + + /** Declares `__slots__`, so instances have no `__dict__`. */ + SLOTS = 'SLOTS', + + /** Parameterised with `Generic[...]`. */ + GENERIC = 'GENERIC', + + /** A `@runtime_checkable` Protocol. */ + RUNTIME_CHECKABLE = 'RUNTIME_CHECKABLE', + + /** Defines `__getattr__` — attribute lookup can succeed for unknown names. */ + HAS_GETATTR = 'HAS_GETATTR', + + /** Defines `__setattr__` — attribute writes are intercepted. */ + HAS_SETATTR = 'HAS_SETATTR', + + /** Defines `__call__` — instances are callable. */ + HAS_CALL = 'HAS_CALL', + + /** Instances are callable, so a "variable" may in fact be a call target. */ + /** + * **NOT EMITTED — undefined as specified.** + * + * A class defines `__call__` if and only if its instances are callable, so + * this and {@link HAS_CALL} are coextensive as the schema defines them and one + * of the two is dead. The parser emits `HAS_CALL` alone rather than inventing a + * distinction to justify keeping both. + * + * A0 owns the resolution: either drop this value, or redefine the pair as + * `__call__` declared ON this class versus callable INCLUDING inherited — + * both detectable (`in cls.__dict__` versus `hasattr`). Kept in the enum so + * the schema and the code stay in step until that call is made. + */ + CALLABLE_INSTANCE = 'CALLABLE_INSTANCE', +} diff --git a/parser/src/enums/python/types/PythonTypePlacement.ts b/parser/src/enums/python/types/PythonTypePlacement.ts new file mode 100644 index 000000000..99f2b6d7e --- /dev/null +++ b/parser/src/enums/python/types/PythonTypePlacement.ts @@ -0,0 +1,39 @@ +/** + * Where a class is declared, which decides what can see it. + * + * `TYPE_CHECKING_PLACEMENT` is the one that produces findings: a class defined + * only under `if TYPE_CHECKING:` **does not exist at runtime**, so instantiating + * or subclassing it outside an annotation is a bug. + * + * ## Examples + * + * ```python + * class Top: ... # TOP_LEVEL_PLACEMENT + * class Outer: + * class Inner: ... # NESTED_PLACEMENT + * def f(): + * class Local: ... # LOCAL_PLACEMENT + * if sys.platform == 'win32': + * class Impl: ... # CONDITIONAL_PLACEMENT + * if TYPE_CHECKING: + * class Stub: ... # TYPE_CHECKING_PLACEMENT + * ``` + * + * Schema v6 §2.4 c6. + */ +export enum PythonTypePlacement { + /** Directly at module level. */ + TOP_LEVEL_PLACEMENT = 'TOP_LEVEL_PLACEMENT', + + /** Inside another class body. */ + NESTED_PLACEMENT = 'NESTED_PLACEMENT', + + /** Inside a function — a fresh class object per call. */ + LOCAL_PLACEMENT = 'LOCAL_PLACEMENT', + + /** Inside an `if` / `try` at module level. */ + CONDITIONAL_PLACEMENT = 'CONDITIONAL_PLACEMENT', + + /** Under `if TYPE_CHECKING:` — absent at runtime. */ + TYPE_CHECKING_PLACEMENT = 'TYPE_CHECKING_PLACEMENT', +} diff --git a/parser/src/enums/python/types/index.ts b/parser/src/enums/python/types/index.ts new file mode 100644 index 000000000..b35330dad --- /dev/null +++ b/parser/src/enums/python/types/index.ts @@ -0,0 +1,6 @@ +export { PythonBaseKind } from '@/enums/python/types/PythonBaseKind'; +export { PythonMroKind } from '@/enums/python/types/PythonMroKind'; +export { PythonTypeAccess } from '@/enums/python/types/PythonTypeAccess'; +export { PythonTypeCategory } from '@/enums/python/types/PythonTypeCategory'; +export { PythonTypeModifier } from '@/enums/python/types/PythonTypeModifier'; +export { PythonTypePlacement } from '@/enums/python/types/PythonTypePlacement'; diff --git a/parser/src/enums/typescript/blocks/TsBlockKind.ts b/parser/src/enums/typescript/blocks/TsBlockKind.ts new file mode 100644 index 000000000..8b78d2733 --- /dev/null +++ b/parser/src/enums/typescript/blocks/TsBlockKind.ts @@ -0,0 +1,70 @@ +/** + * What kind of statement block this is. + * + * Positions 0–17 of `ts_block` mirror `java_block`, so this enum starts from + * Java's `BlockKind`. Blocks earn their relation twice here: caller attribution, + * as in Java, AND as the lexical scope of a `let`/`const` — which is what lets + * `ts_variable` exist with no `ts_scope` relation. + * + * That second job is measured, not assumed: 34,798 identifier references, and the + * `ts_block -> ts_method -> ts_type -> ts_module` chain reaches every one with + * zero unreachable. The chain is only unbroken if these rows exist. + * + * ## ELSE_IF is not a synthetic convenience + * + * `else if` is a nested `IfStatement` in the AST. Flattening it loses which + * guard governs which body, and the guard is the narrowing position — so the + * chain is preserved with `ELSE_IF` naming the middle links and `ELSE` the last. + * + * ```ts + * if (a) { } // IF + * else if (b) { } // ELSE_IF + * else { } // ELSE + * ``` + * + * Schema §4.16 c0, ruling OQ-6. + */ +export enum TsBlockKind { + /** A function body. */ + FUNCTION_BODY = 'FUNCTION_BODY', + /** An arrow body written as a block. */ + ARROW_BODY = 'ARROW_BODY', + /** The `then` branch of an `if`. */ + IF = 'IF', + /** The `then` branch of an `else if` — a nested `IfStatement`. */ + ELSE_IF = 'ELSE_IF', + /** A final `else`. */ + ELSE = 'ELSE', + /** `for (…;…;…)`. */ + FOR = 'FOR', + /** `for…of`. */ + FOR_OF = 'FOR_OF', + /** `for…in`. */ + FOR_IN = 'FOR_IN', + /** `for await…of`. */ + FOR_AWAIT_OF = 'FOR_AWAIT_OF', + /** `while`. */ + WHILE = 'WHILE', + /** `do…while`. */ + DO_WHILE = 'DO_WHILE', + /** A `try` block. */ + TRY = 'TRY', + /** A `catch` clause. Its binding is scoped here, not to the enclosing block. */ + CATCH = 'CATCH', + /** A `finally` block. */ + FINALLY = 'FINALLY', + /** A `case` clause. */ + SWITCH_CASE = 'SWITCH_CASE', + /** A `default` clause. */ + SWITCH_DEFAULT = 'SWITCH_DEFAULT', + /** A labelled statement's body. */ + LABELED = 'LABELED', + /** A bare `{ … }` — a scope with no control flow. */ + BARE_BLOCK = 'BARE_BLOCK', + /** `static { }` on a class. */ + STATIC_BLOCK = 'STATIC_BLOCK', + /** A module body. */ + MODULE_BODY = 'MODULE_BODY', + /** A namespace body. */ + NAMESPACE_BODY = 'NAMESPACE_BODY', +} diff --git a/parser/src/enums/typescript/blocks/index.ts b/parser/src/enums/typescript/blocks/index.ts new file mode 100644 index 000000000..eabb058b7 --- /dev/null +++ b/parser/src/enums/typescript/blocks/index.ts @@ -0,0 +1 @@ +export { TsBlockKind } from '@/enums/typescript/blocks/TsBlockKind'; diff --git a/parser/src/enums/typescript/call-sites/TsCallKind.ts b/parser/src/enums/typescript/call-sites/TsCallKind.ts new file mode 100644 index 000000000..5adf14042 --- /dev/null +++ b/parser/src/enums/typescript/call-sites/TsCallKind.ts @@ -0,0 +1,63 @@ +/** + * What kind of call this is. + * + * ## Examples + * + * ```ts + * f(x) // FUNCTION_CALL + * a.b(x) // METHOD_CALL + * ops[name](x) // METHOD_CALL — a computed member is still a member + * new C(x) // CONSTRUCTOR_CALL + * super(x) // SUPER_CALL + * super.m(x) // METHOD_CALL with receiverKind SUPER + * tag`a${b}` // TAGGED_TEMPLATE_CALL + * a?.b(x) // OPTIONAL_CALL + * import("m") // DYNAMIC_IMPORT_CALL + * @Component({ … }) // DECORATOR_CALL + * ``` + * + * ## OPTIONAL_CALL is a kind, not a flag + * + * `a?.b()` and `a.b()` have the SAME target and different reachability. A rule + * that treats them alike is right about the target and wrong about whether the + * call happens, so the distinction is in the kind where a rule cannot miss it. + * + * ## INDEX_CALL is a resolution outcome, never read from syntax + * + * A computed callee is `METHOD_CALL`: `ops[name](a, b)` calls a member and the + * brackets only mean the name is computed. `INDEX_CALL` is reserved for a call + * that resolved THROUGH AN INDEX SIGNATURE, which is a fact about the receiver's + * TYPE. Syntax cannot tell the two apart, so syntax does not try. + * + * ## JSX_COMPONENT_CALL is RESERVED and carries ZERO rows + * + * TSX is out of the first freeze. The representation is settled — a JSX element + * IS a call to its component, with the whole props object as argument 0 and + * children folded into a reserved `children` prop, because a component has + * exactly one parameter and mapping attributes positionally would be wrong. The + * gate asserts the emptiness, so switching TSX on appears as a named failure. + * + * Schema §4.15 c0, §4.15.1. + */ +export enum TsCallKind { + /** An unqualified call: `f(x)`. */ + FUNCTION_CALL = 'FUNCTION_CALL', + /** A call on a receiver: `a.b(x)`, `ops[name](x)`. */ + METHOD_CALL = 'METHOD_CALL', + /** `new C(x)`. */ + CONSTRUCTOR_CALL = 'CONSTRUCTOR_CALL', + /** `super(x)` — the base constructor. */ + SUPER_CALL = 'SUPER_CALL', + /** `` tag`…` `` — the tag is called with the strings and the substitutions. */ + TAGGED_TEMPLATE_CALL = 'TAGGED_TEMPLATE_CALL', + /** Resolved THROUGH an index signature. A resolution outcome, not a syntactic kind. */ + INDEX_CALL = 'INDEX_CALL', + /** `import("m")` — the target is a module, not a function. */ + DYNAMIC_IMPORT_CALL = 'DYNAMIC_IMPORT_CALL', + /** A decorator application — it runs at class-definition time. */ + DECORATOR_CALL = 'DECORATOR_CALL', + /** `a?.b()` — same target as `a.b()`, different reachability. */ + OPTIONAL_CALL = 'OPTIONAL_CALL', + /** RESERVED for TSX. Must carry zero rows in freeze 1; the gate asserts it. */ + JSX_COMPONENT_CALL = 'JSX_COMPONENT_CALL', +} diff --git a/parser/src/enums/typescript/call-sites/TsReceiverKind.ts b/parser/src/enums/typescript/call-sites/TsReceiverKind.ts new file mode 100644 index 000000000..7631d0066 --- /dev/null +++ b/parser/src/enums/typescript/call-sites/TsReceiverKind.ts @@ -0,0 +1,61 @@ +/** + * The SHAPE of a call's receiver — what resolution dispatches on. + * + * Reported precisely rather than collapsed to a boolean, because the outcome per + * receiver shape is the number that tells a working parser from one that handles + * only the easy half. An aggregate hides it: a parser can emit every hop for + * unqualified calls and none for method calls and still show a respectable total. + * + * ## The shapes, and what each needs to be resolvable + * + * ```ts + * f() // NONE — the callee itself resolves + * a.f() // IDENTIFIER — `a`'s DECLARED type, from its declaration + * this.f() // THIS — the enclosing type + * super.f() // SUPER — an `inheritsMembers` heritage row + * a.b.f() // PROPERTY_CHAIN — each hop's declared type, in turn + * g().f() // CALL_RESULT — the inner call's RETURN type + * a[i].f() // ELEMENT_ACCESS — an element type; not derivable from syntax + * (a).f() // PARENTHESIZED — unwrapped, so this should not appear + * a!.f() // NON_NULL — the wrapped expression's type + * (a as T).f() // AS_EXPRESSION — the asserted type, which IS written down + * (await p).f() // AWAIT_RESULT — the awaited type + * ``` + * + * `AS_EXPRESSION` is the one wrapper where the receiver's type is stated + * outright: `assertedTypeReferenceLinkHash` on the expression row carries it, so + * no inference is needed. + * + * `UNKNOWN` covers shapes the enum does not name — an array literal, a string + * literal, a binary expression. Their types are still derivable from emitted + * columns (`kind`, `literalType`), which is why `UNKNOWN` here does not mean + * unresolvable. + * + * Schema §4.15 c2. + */ +export enum TsReceiverKind { + /** No receiver: an unqualified call. */ + NONE = 'NONE', + /** A bare name. The primary path: its DECLARED type is written at its declaration. */ + IDENTIFIER = 'IDENTIFIER', + /** `this`. */ + THIS = 'THIS', + /** `super`. */ + SUPER = 'SUPER', + /** A dotted chain: each hop needs the previous hop's declared type. */ + PROPERTY_CHAIN = 'PROPERTY_CHAIN', + /** A call's return value. */ + CALL_RESULT = 'CALL_RESULT', + /** `a[i]` — an element type, and not derivable from syntax. */ + ELEMENT_ACCESS = 'ELEMENT_ACCESS', + /** Parenthesised. Unwrapped at emit, so this should not appear in output. */ + PARENTHESIZED = 'PARENTHESIZED', + /** `a!` — the wrapped expression's type. */ + NON_NULL = 'NON_NULL', + /** `(a as T)` — the asserted type is written down and needs no inference. */ + AS_EXPRESSION = 'AS_EXPRESSION', + /** `(await p)`. */ + AWAIT_RESULT = 'AWAIT_RESULT', + /** A shape this enum does not name: a literal, an array literal, a binary node. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/typescript/call-sites/TsResolutionEvidence.ts b/parser/src/enums/typescript/call-sites/TsResolutionEvidence.ts new file mode 100644 index 000000000..881e1f2fb --- /dev/null +++ b/parser/src/enums/typescript/call-sites/TsResolutionEvidence.ts @@ -0,0 +1,50 @@ +/** + * WHY the parser believes a target — the syntax it used. + * + * The parser fills the resolution columns only where syntax decides them, so a + * wrong link is traceable to the RULE that produced it rather than to "the + * parser". That matters because the costs are asymmetric: a filled target that + * disagrees with `getResolvedSignature` is a hard failure, while an unfilled one + * is a measurement the engine completes. + * + * ## What each value means the parser did + * + * ```ts + * const r: Repo = …; r.find() // DECLARED_RECEIVER_TYPE — read `r`'s annotation + * import { f } from "m"; f() // IMPORT_BINDING — the callee is an import + * function g() { } g() // LOCAL_BINDING — same file, one lookup + * this.m() // THIS_MEMBER — the enclosing type + * super.m() // SUPER_MEMBER — an inheritsMembers row + * Namespace.f() // NAMESPACE_QUALIFIED — the namespace's table + * handler[name]() // INDEX_SIGNATURE — through an index signature + * console.log() // AMBIENT_GLOBAL — a curated global + * g().m() // NONE — needs an inferred type + * ``` + * + * `NONE` with a non-zero `overloadCandidateCount` is a distinct and useful state: + * a real overload set was found and arity could not narrow it to one. That is not + * "nothing found" — choosing between same-arity overloads needs argument TYPES, + * and picking the first would be wrong on 77.6% of real overloaded calls. + * + * Schema §4.15 c18. + */ +export enum TsResolutionEvidence { + /** The receiver's annotation at its declaration site. The primary mechanism. */ + DECLARED_RECEIVER_TYPE = 'DECLARED_RECEIVER_TYPE', + /** The callee or receiver is a name bound by an import. */ + IMPORT_BINDING = 'IMPORT_BINDING', + /** A declaration in the same file, reachable in one lookup. */ + LOCAL_BINDING = 'LOCAL_BINDING', + /** `this` plus the enclosing type's members. */ + THIS_MEMBER = 'THIS_MEMBER', + /** `super` plus an `inheritsMembers` heritage row. */ + SUPER_MEMBER = 'SUPER_MEMBER', + /** A namespace-qualified name, resolved in the namespace's own table. */ + NAMESPACE_QUALIFIED = 'NAMESPACE_QUALIFIED', + /** An index signature on the receiver's type. */ + INDEX_SIGNATURE = 'INDEX_SIGNATURE', + /** A curated ambient global. A terminal, not a link. */ + AMBIENT_GLOBAL = 'AMBIENT_GLOBAL', + /** No syntactic evidence. With candidates > 1, a set was seen and not chosen from. */ + NONE = 'NONE', +} diff --git a/parser/src/enums/typescript/call-sites/TsResolvedTargetKind.ts b/parser/src/enums/typescript/call-sites/TsResolvedTargetKind.ts new file mode 100644 index 000000000..b2f327485 --- /dev/null +++ b/parser/src/enums/typescript/call-sites/TsResolvedTargetKind.ts @@ -0,0 +1,51 @@ +/** + * What a call site's target IS, once resolution has run. + * + * ## Measured distribution + * + * 45.7% project, 42.3% `lib.*.d.ts`, 9.7% `node_modules`, 2.3% synthesized. So + * **more than half of all call targets are declared outside the project** — a + * closed-world model that treats external targets as failures fails by + * construction. + * + * ## The three members that are TERMINALS, not failures + * + * `LIB_SIGNATURE`, `AMBIENT_SIGNATURE` and `SYNTHESIZED_NO_DECLARATION` all mean + * "the target exists and is not a row in this fact base". The closed-world gate + * counts them as CLOSED. `UNRESOLVED` means the parser does not know, and is the + * only member that counts against it. + * + * `SYNTHESIZED_NO_DECLARATION` deserves its own member: **225 of 9,627 measured + * call sites** resolve to a signature with NO declaration node anywhere — an + * implicit constructor, a synthesized member. Leaving those empty would make + * them indistinguishable from a resolution failure. + * + * ## PROJECT_IMPLEMENTATION versus PROJECT_SIGNATURE + * + * ```ts + * function f(x: number): void; // a call resolves HERE → PROJECT_SIGNATURE + * function f(x: unknown): void { } // the code that runs is HERE + * ``` + * + * 44.3% of resolved targets are bodiless. Reading one as the code that runs + * attributes behaviour to a declaration that has none, which is why the two are + * separate members and why `isAmbientTarget` repeats the fact as a boolean. + * + * Schema §4.15 c14. + */ +export enum TsResolvedTargetKind { + /** A project declaration WITH a body: the code that actually runs. */ + PROJECT_IMPLEMENTATION = 'PROJECT_IMPLEMENTATION', + /** A project declaration with no body: an overload signature, an abstract member. */ + PROJECT_SIGNATURE = 'PROJECT_SIGNATURE', + /** `declare`d in project source — bodiless by construction. A closed terminal. */ + AMBIENT_SIGNATURE = 'AMBIENT_SIGNATURE', + /** Declared in `lib.*.d.ts` or `node_modules`. The engine closes it from `lib_ts_*`. */ + LIB_SIGNATURE = 'LIB_SIGNATURE', + /** No declaration node exists: an implicit constructor. 2.3% measured. */ + SYNTHESIZED_NO_DECLARATION = 'SYNTHESIZED_NO_DECLARATION', + /** Resolved through an index signature. */ + INDEX_SIGNATURE = 'INDEX_SIGNATURE', + /** The parser does not know. The only member the closed-world gate counts against. */ + UNRESOLVED = 'UNRESOLVED', +} diff --git a/parser/src/enums/typescript/call-sites/index.ts b/parser/src/enums/typescript/call-sites/index.ts new file mode 100644 index 000000000..9331f2d87 --- /dev/null +++ b/parser/src/enums/typescript/call-sites/index.ts @@ -0,0 +1,4 @@ +export { TsCallKind } from '@/enums/typescript/call-sites/TsCallKind'; +export { TsReceiverKind } from '@/enums/typescript/call-sites/TsReceiverKind'; +export { TsResolutionEvidence } from '@/enums/typescript/call-sites/TsResolutionEvidence'; +export { TsResolvedTargetKind } from '@/enums/typescript/call-sites/TsResolvedTargetKind'; diff --git a/parser/src/enums/typescript/comments/TsCommentKind.ts b/parser/src/enums/typescript/comments/TsCommentKind.ts new file mode 100644 index 000000000..5aefb12ba --- /dev/null +++ b/parser/src/enums/typescript/comments/TsCommentKind.ts @@ -0,0 +1,31 @@ +/** + * What kind of comment this is. + * + * Positions 0–9 of `ts_comment` mirror `java_comment`, so this starts from Java's + * `CommentKind` — but TypeScript adds two members that are not commentary at + * all. A `/// ` is a MODULE EDGE and a `@ts-ignore` SUPPRESSES A + * DIAGNOSTIC, so both change what the program means. Treating them as prose + * loses a dependency and a suppression respectively. + * + * ```ts + * // a note LINE + * /* a note *\/ BLOCK + * /** @deprecated use b *\/ JSDOC — jsDocTags = deprecated + * /// TRIPLE_SLASH_DIRECTIVE — a module edge + * // @ts-expect-error TS_DIRECTIVE — suppresses the next line + * ``` + * + * Schema §4.17 c0. + */ +export enum TsCommentKind { + /** `// …`. */ + LINE = 'LINE', + /** A block comment that is not JSDoc. */ + BLOCK = 'BLOCK', + /** A block comment opening with two asterisks. Carries `jsDocTags`. */ + JSDOC = 'JSDOC', + /** `/// ` — a real module edge that also feeds `ts_import`. */ + TRIPLE_SLASH_DIRECTIVE = 'TRIPLE_SLASH_DIRECTIVE', + /** `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck` — suppresses diagnostics. */ + TS_DIRECTIVE = 'TS_DIRECTIVE', +} diff --git a/parser/src/enums/typescript/comments/TsDirectiveKind.ts b/parser/src/enums/typescript/comments/TsDirectiveKind.ts new file mode 100644 index 000000000..7b2a1c463 --- /dev/null +++ b/parser/src/enums/typescript/comments/TsDirectiveKind.ts @@ -0,0 +1,37 @@ +/** + * Which compiler directive a comment carries. + * + * Non-empty only for `TS_DIRECTIVE` and `TRIPLE_SLASH_DIRECTIVE` comments, and + * every member changes program meaning rather than describing it. + * + * ## The suppressions + * + * `TS_IGNORE` and `TS_EXPECT_ERROR` are not interchangeable: the first silences + * an error if there is one, the second REQUIRES one and errors if the line is + * clean. A codebase migrating from the first to the second is measurably + * tightening, and a fact base that folds them cannot see it. `TS_NOCHECK` + * disables checking for a whole file. + * + * ## The module edges + * + * `REFERENCE_PATH`, `REFERENCE_TYPES` and `REFERENCE_LIB` are dependencies + * written as comments. In ambient code they are frequently the only edge a file + * has, so they also feed `ts_import` — a fact base built from import statements + * alone loses them entirely. + * + * Schema §4.17 c11. + */ +export enum TsDirectiveKind { + /** `@ts-ignore` — silences an error on the next line if there is one. */ + TS_IGNORE = 'TS_IGNORE', + /** `@ts-expect-error` — REQUIRES an error on the next line. */ + TS_EXPECT_ERROR = 'TS_EXPECT_ERROR', + /** `@ts-nocheck` — disables checking for the whole file. */ + TS_NOCHECK = 'TS_NOCHECK', + /** `/// ` — a file dependency. */ + REFERENCE_PATH = 'REFERENCE_PATH', + /** `/// ` — a package's type dependency. */ + REFERENCE_TYPES = 'REFERENCE_TYPES', + /** `/// ` — a built-in lib dependency. */ + REFERENCE_LIB = 'REFERENCE_LIB', +} diff --git a/parser/src/enums/typescript/comments/index.ts b/parser/src/enums/typescript/comments/index.ts new file mode 100644 index 000000000..b48bc1dbf --- /dev/null +++ b/parser/src/enums/typescript/comments/index.ts @@ -0,0 +1,2 @@ +export { TsCommentKind } from '@/enums/typescript/comments/TsCommentKind'; +export { TsDirectiveKind } from '@/enums/typescript/comments/TsDirectiveKind'; diff --git a/parser/src/enums/typescript/decorators/TsDecoratorArgumentValueType.ts b/parser/src/enums/typescript/decorators/TsDecoratorArgumentValueType.ts new file mode 100644 index 000000000..dd21c6924 --- /dev/null +++ b/parser/src/enums/typescript/decorators/TsDecoratorArgumentValueType.ts @@ -0,0 +1,57 @@ +/** + * The shape of a decorator argument value. + * + * Positions 0–10 of `ts_decorator_argument` mirror `java_annotation_argument`, + * so this enum starts from Java's `ArgumentValueType`. This is where framework + * routes and DI tokens live — `@Get("/users/:id")`, `@Inject(UserRepository)`, + * `@Column({ type: "varchar" })` — the direct analogue of the Java relation that + * CWE detection already keys on for `@RequestMapping`. + * + * ## CLASS_REFERENCE is separated from IDENTIFIER on purpose + * + * ```ts + * @Inject(UserRepository) // CLASS_REFERENCE — a DI token + * @Cache(defaultTtl) // IDENTIFIER — a value + * ``` + * + * The DI pattern is the one this relation exists to surface, and folding it into + * `IDENTIFIER` would make it unqueryable. The distinction is a heuristic on the + * name's case, and it stays labelled as one: the CLAIM is carried by + * `referencedTypeHash`, which is filled only when the name actually resolves to + * a type. + * + * ## An options object becomes N NAMED rows + * + * `@Column({ type: "varchar", nullable: true })` emits one row per property with + * `argumentName` set, not one opaque blob a rule would have to re-parse. + * + * Schema §4.19 c2. + */ +export enum TsDecoratorArgumentValueType { + /** `"users"`. */ + STRING = 'STRING', + /** `300`, `1_000n`. */ + NUMBER = 'NUMBER', + /** `true`, `false`. */ + BOOLEAN = 'BOOLEAN', + /** `null`. */ + NULL = 'NULL', + /** `undefined`. */ + UNDEFINED = 'UNDEFINED', + /** A lowercase-initial name — a value rather than a token. */ + IDENTIFIER = 'IDENTIFIER', + /** `{ … }` — emitted as one NAMED row per property. */ + OBJECT = 'OBJECT', + /** `[ … ]`. */ + ARRAY = 'ARRAY', + /** `() => …` — a lazy token, common in circular-dependency workarounds. */ + ARROW = 'ARROW', + /** `f()` or `new C()`. */ + CALL = 'CALL', + /** A capitalised bare name — the DI-token pattern. Claim carried by `referencedTypeHash`. */ + CLASS_REFERENCE = 'CLASS_REFERENCE', + /** A template literal. */ + TEMPLATE = 'TEMPLATE', + /** Anything else. Recorded rather than guessed at. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/typescript/decorators/TsDecoratorContext.ts b/parser/src/enums/typescript/decorators/TsDecoratorContext.ts new file mode 100644 index 000000000..2cb4545a7 --- /dev/null +++ b/parser/src/enums/typescript/decorators/TsDecoratorContext.ts @@ -0,0 +1,42 @@ +/** + * What a decorator is applied to. + * + * ```ts + * @Entity() // CLASS_DECLARATION + * class User { + * @Column() name: string; // FIELD_DECLARATION + * @observable accessor count = 0; // AUTO_ACCESSOR + * @Log() save() { } // METHOD_DECLARATION + * @Memo() get full() { return "" } // ACCESSOR_DECLARATION + * constructor(@Inject(Repo) r: Repo) { } // PARAMETER_DECLARATION + * } + * ``` + * + * ## PARAMETER_DECLARATION exists in only one dialect + * + * Parameter decorators are legal ONLY under `experimentalDecorators` — the + * standard system has no such thing. So a row with this context and + * `decoratorSystem = STANDARD_TC39` is impossible, and the gate asserts it: the + * combination proves the system was read from somewhere other than the governing + * tsconfig. + * + * They are also where taint SOURCES are declared in real TypeScript backends — + * A DI framework's `@Body()`, `@Query()`, `@Param()` — the direct analogue of Spring's + * `@RequestParam`, which Java CWE detection already keys on. + * + * Schema §4.18 c2. + */ +export enum TsDecoratorContext { + /** On a class or class expression. */ + CLASS_DECLARATION = 'CLASS_DECLARATION', + /** On a method. */ + METHOD_DECLARATION = 'METHOD_DECLARATION', + /** On a property. */ + FIELD_DECLARATION = 'FIELD_DECLARATION', + /** On a getter or setter. */ + ACCESSOR_DECLARATION = 'ACCESSOR_DECLARATION', + /** On a parameter. LEGAL ONLY under `experimentalDecorators`. */ + PARAMETER_DECLARATION = 'PARAMETER_DECLARATION', + /** On an `accessor` field — a getter/setter pair with backing storage. */ + AUTO_ACCESSOR = 'AUTO_ACCESSOR', +} diff --git a/parser/src/enums/typescript/decorators/TsDecoratorKind.ts b/parser/src/enums/typescript/decorators/TsDecoratorKind.ts new file mode 100644 index 000000000..aef38f04b --- /dev/null +++ b/parser/src/enums/typescript/decorators/TsDecoratorKind.ts @@ -0,0 +1,30 @@ +/** + * The syntactic form of a decorator. + * + * Positions 0–12 of `ts_decorator` mirror `java_annotation`, and the shared + * `annotation_on` projection reads them. But a decorator is NOT an annotation: a + * Java annotation is inert metadata, while a decorator is an expression that + * RUNS at class-definition time and may REPLACE its target. That is why every + * decorator row carries `tsExpressionLinkHash` into the call graph, and why + * `@Component({ … })` is a call site like any other. + * + * ```ts + * @Injectable // MARKER — the reference IS the decorator + * @Component({ … }) // CALL — a factory whose RESULT decorates + * @core.Injectable() // MEMBER_EXPRESSION — reached through a namespace + * @(decorators["audit"]) // COMPUTED — the name is not statically known + * @(record("parenthesised")) // CALL — parentheses are punctuation + * ``` + * + * Schema §4.18 c1. + */ +export enum TsDecoratorKind { + /** `@Injectable` — no call; the reference itself decorates. */ + MARKER = 'MARKER', + /** `@Component({ … })` — a factory call whose RETURN VALUE decorates. */ + CALL = 'CALL', + /** `@core.Injectable` — reached through a dotted path. */ + MEMBER_EXPRESSION = 'MEMBER_EXPRESSION', + /** `@(map["key"])` — the decorator is not statically named. */ + COMPUTED = 'COMPUTED', +} diff --git a/parser/src/enums/typescript/decorators/TsDecoratorSemantics.ts b/parser/src/enums/typescript/decorators/TsDecoratorSemantics.ts new file mode 100644 index 000000000..d56463368 --- /dev/null +++ b/parser/src/enums/typescript/decorators/TsDecoratorSemantics.ts @@ -0,0 +1,27 @@ +/** + * Whether a decorator REPLACES the entity it decorates, or only observes it. + * + * No Java annotation can do the first, which is why this column exists at all: + * a decorator that returns a value substitutes the class, method or accessor, + * and every later reference sees the substitute. + * + * ## UNKNOWN is the honest answer from a use site, and usually the only one + * + * Whether a decorator replaces its target depends on whether its IMPLEMENTATION + * returns a value. That is a property of the decorator FUNCTION, not of this + * application — and the function is usually in another package. Inferring it + * from the use site would be a fact about code the parser has not read. + * + * `resolvedDecoratorMethodLinkHash` is the FK that would let an engine decide it + * once the decorator's own declaration is in the fact base. + * + * Schema §4.18 c14. + */ +export enum TsDecoratorSemantics { + /** The decorator returns a substitute; later references see it, not the original. */ + REPLACES_TARGET = 'REPLACES_TARGET', + /** The decorator returns nothing; the target is unchanged. */ + OBSERVES_TARGET = 'OBSERVES_TARGET', + /** Not decidable from the use site. The usual answer, and an honest one. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/typescript/decorators/TsDecoratorSystem.ts b/parser/src/enums/typescript/decorators/TsDecoratorSystem.ts new file mode 100644 index 000000000..4bc3cb131 --- /dev/null +++ b/parser/src/enums/typescript/decorators/TsDecoratorSystem.ts @@ -0,0 +1,45 @@ +/** + * Which decorator system governs a decorator, from the GOVERNING tsconfig. + * + * ## This cannot be got right from the source + * + * The source is IDENTICAL under both systems and the facts are not. Standard + * TC39 decorators and legacy `experimentalDecorators` differ in evaluation + * ORDER, in what the decorator function RECEIVES, and in whether parameter + * decorators are legal at all. Nothing in the file says which applies — only the + * tsconfig that governs it does. + * + * ## Why a per-run constant is wrong + * + * A repository is not one program. In this repository's own fixture corpus: + * + * ``` + * staging/ 38 decorators STANDARD_TC39 + * staging/annotations/legacy/ 18 decorators LEGACY_EXPERIMENTAL + * ``` + * + * Three directories apart, one corpus, two answers — because `legacy/` has its + * own tsconfig with `experimentalDecorators: true`, and the two systems cannot + * share one program. A parser assuming one system per run gets one of them + * wrong, and gets it wrong in a way that looks entirely plausible: the rows are + * well formed and the count is right. + * + * That is why the resolver walks up from each file to the nearest tsconfig that + * actually CLAIMS it, honouring `include`, `exclude` and `extends`. Nearest + * alone is not enough: the nearest ancestor of `legacy/legacy-decorators.ts` is + * the config that explicitly excludes it. + * + * ## The falsifiable consequence + * + * A PARAMETER decorator is legal only under `experimentalDecorators`. That is + * grammar, not policy — so one stamped `STANDARD_TC39` proves the value came + * from somewhere other than the governing tsconfig, and the gate fails on it. + * + * Schema §4.18 c13. + */ +export enum TsDecoratorSystem { + /** TS 5.0+, ECMAScript stage 3. The default when `experimentalDecorators` is off. */ + STANDARD_TC39 = 'STANDARD_TC39', + /** `experimentalDecorators: true`. The DI and ORM frameworks. Parameter decorators legal. */ + LEGACY_EXPERIMENTAL = 'LEGACY_EXPERIMENTAL', +} diff --git a/parser/src/enums/typescript/decorators/index.ts b/parser/src/enums/typescript/decorators/index.ts new file mode 100644 index 000000000..ab17f361e --- /dev/null +++ b/parser/src/enums/typescript/decorators/index.ts @@ -0,0 +1,5 @@ +export { TsDecoratorArgumentValueType } from '@/enums/typescript/decorators/TsDecoratorArgumentValueType'; +export { TsDecoratorContext } from '@/enums/typescript/decorators/TsDecoratorContext'; +export { TsDecoratorKind } from '@/enums/typescript/decorators/TsDecoratorKind'; +export { TsDecoratorSemantics } from '@/enums/typescript/decorators/TsDecoratorSemantics'; +export { TsDecoratorSystem } from '@/enums/typescript/decorators/TsDecoratorSystem'; diff --git a/parser/src/enums/typescript/enum-members/TsEnumMemberValueKind.ts b/parser/src/enums/typescript/enum-members/TsEnumMemberValueKind.ts new file mode 100644 index 000000000..efd00c473 --- /dev/null +++ b/parser/src/enums/typescript/enum-members/TsEnumMemberValueKind.ts @@ -0,0 +1,45 @@ +/** + * How an enum member's value is determined. + * + * ## Why COMPUTED must be its own member + * + * An enum member's value is not always statically known, and the difference is + * behavioural rather than cosmetic: a computed member cannot be inlined, cannot + * appear in a `const enum`, and cannot be used as a literal type. A fact base + * that records every member as if it had a known value claims a constant where + * the program has a computation. + * + * ```ts + * enum Status { + * Active, // IMPLICIT_NUMERIC — 0, assigned by position + * Closed = 3, // EXPLICIT_NUMERIC + * Archived, // IMPLICIT_NUMERIC — 4, continues from 3 + * } + * enum Direction { + * Up = "UP", // EXPLICIT_STRING + * } + * enum Flags { + * Read = 1 << 0, // CONSTANT_EXPRESSION — foldable at compile time + * Both = Read | Write, // CONSTANT_EXPRESSION + * Runtime = compute(), // COMPUTED — not knowable without running it + * } + * ``` + * + * `CONSTANT_EXPRESSION` is separated from `COMPUTED` because the compiler folds + * the former and refuses the latter in a `const enum` — so the distinction + * decides whether a reference to the member can have a runtime target at all. + * + * Schema §4.10 c12. + */ +export enum TsEnumMemberValueKind { + /** No initializer: the value is the previous member's plus one. */ + IMPLICIT_NUMERIC = 'IMPLICIT_NUMERIC', + /** `= 3`. */ + EXPLICIT_NUMERIC = 'EXPLICIT_NUMERIC', + /** `= "UP"`. A string enum member cannot be reverse-mapped. */ + EXPLICIT_STRING = 'EXPLICIT_STRING', + /** `= 1 << 0`, `= Read | Write` — folded by the compiler. */ + CONSTANT_EXPRESSION = 'CONSTANT_EXPRESSION', + /** `= compute()` — not statically known, and illegal in a `const enum`. */ + COMPUTED = 'COMPUTED', +} diff --git a/parser/src/enums/typescript/enum-members/index.ts b/parser/src/enums/typescript/enum-members/index.ts new file mode 100644 index 000000000..8c2916f98 --- /dev/null +++ b/parser/src/enums/typescript/enum-members/index.ts @@ -0,0 +1 @@ +export { TsEnumMemberValueKind } from '@/enums/typescript/enum-members/TsEnumMemberValueKind'; diff --git a/parser/src/enums/typescript/exports/TsExportKind.ts b/parser/src/enums/typescript/exports/TsExportKind.ts new file mode 100644 index 000000000..adfd08cc1 --- /dev/null +++ b/parser/src/enums/typescript/exports/TsExportKind.ts @@ -0,0 +1,61 @@ +/** + * What an export row exposes, and how. + * + * ## No Java analogue at all + * + * Java visibility is a modifier and there is no re-export. TypeScript needs this + * relation because **a re-export chain is the only path from an importer to the + * real declaration** — and there are 1,251 export declarations and 86 + * `export *` in the measured corpus. Python solved the same problem with + * `__all__`, which is a weaker instrument: it lists names without saying where + * they came from. + * + * ## Examples + * + * ```ts + * export class C { } // INLINE_DECLARATION + * export { a }; // NAMED_EXPORT + * export { a as b }; // NAMED_ALIAS + * export default class D { } // DEFAULT_EXPORT + * export default compute(); // DEFAULT_EXPRESSION + * export * from "./m"; // EXPORT_STAR + * export * as ns from "./m"; // EXPORT_STAR_AS_NAMESPACE + * export = Legacy; // EXPORT_ASSIGNMENT + * export type { T }; // TYPE_ONLY_NAMED + * export type * from "./m"; // TYPE_ONLY_STAR + * export import X = a.b.C; // EXPORT_IMPORT_EQUALS + * ``` + * + * ## EXPORT_STAR is a real soundness surface + * + * It exports *everything* from the source module, and the set is not knowable + * from the row alone — the engine expands it by joining the source module's + * exports. Like Python's `import *`, but at **86 sites** rather than 22, so it + * is not an edge case here. + * + * Schema §4.13 c2. + */ +export enum TsExportKind { + /** `export class C` — the declaration and the export are one statement. */ + INLINE_DECLARATION = 'INLINE_DECLARATION', + /** `export { a }` — exports a name declared elsewhere in the file. */ + NAMED_EXPORT = 'NAMED_EXPORT', + /** `export { a as b }` — the importer sees `b`. */ + NAMED_ALIAS = 'NAMED_ALIAS', + /** `export default class D` — bound under the reserved name `default`. */ + DEFAULT_EXPORT = 'DEFAULT_EXPORT', + /** `export default compute()` — an expression, with no declaration to point at. */ + DEFAULT_EXPRESSION = 'DEFAULT_EXPRESSION', + /** `export * from "./m"` — exports a set this row cannot name. */ + EXPORT_STAR = 'EXPORT_STAR', + /** `export * as ns from "./m"` — the set arrives under one name. */ + EXPORT_STAR_AS_NAMESPACE = 'EXPORT_STAR_AS_NAMESPACE', + /** `export = X` — the CommonJS whole-module form. */ + EXPORT_ASSIGNMENT = 'EXPORT_ASSIGNMENT', + /** `export type { T }` — must create no call-graph edge. */ + TYPE_ONLY_NAMED = 'TYPE_ONLY_NAMED', + /** `export type * from "./m"`. */ + TYPE_ONLY_STAR = 'TYPE_ONLY_STAR', + /** `export import X = a.b.C`. */ + EXPORT_IMPORT_EQUALS = 'EXPORT_IMPORT_EQUALS', +} diff --git a/parser/src/enums/typescript/exports/TsExportedEntityKind.ts b/parser/src/enums/typescript/exports/TsExportedEntityKind.ts new file mode 100644 index 000000000..8da788686 --- /dev/null +++ b/parser/src/enums/typescript/exports/TsExportedEntityKind.ts @@ -0,0 +1,30 @@ +/** + * What kind of entity an export exposes. + * + * A discriminator for the polymorphic `exportedEntityLinkHash` FK, so a consumer + * knows which relation to join. `UNKNOWN` is the honest value for a pure + * re-export, where the entity lives in a module this row does not name and the + * engine must follow `resolvedSourceModuleLinkHash` to find it. + * + * Schema §4.13 c9. + */ +export enum TsExportedEntityKind { + /** A class, interface, enum or type alias. */ + TYPE = 'TYPE', + /** A function. */ + METHOD = 'METHOD', + /** A member — reachable when the export names one. */ + FIELD = 'FIELD', + /** A `const`, `let` or `var`. */ + VARIABLE = 'VARIABLE', + /** An enum, when the export names the enum rather than a member. */ + ENUM = 'ENUM', + /** A namespace. */ + NAMESPACE = 'NAMESPACE', + /** A whole module — `export = X` or `export * as ns`. */ + MODULE = 'MODULE', + /** `export default compute()` — an expression with no declaration. */ + EXPRESSION = 'EXPRESSION', + /** A pure re-export: the entity is in another module, and the chain must be followed. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/typescript/exports/index.ts b/parser/src/enums/typescript/exports/index.ts new file mode 100644 index 000000000..ad3d241c6 --- /dev/null +++ b/parser/src/enums/typescript/exports/index.ts @@ -0,0 +1,2 @@ +export { TsExportedEntityKind } from '@/enums/typescript/exports/TsExportedEntityKind'; +export { TsExportKind } from '@/enums/typescript/exports/TsExportKind'; diff --git a/parser/src/enums/typescript/expressions/TsEdgeRole.ts b/parser/src/enums/typescript/expressions/TsEdgeRole.ts new file mode 100644 index 000000000..b75715ac5 --- /dev/null +++ b/parser/src/enums/typescript/expressions/TsEdgeRole.ts @@ -0,0 +1,95 @@ +/** + * The edge from a parent expression to a child. In the PRIMARY KEY. + * + * In the key alongside `position` because two children of one parent can share + * an index in different roles: a binary node's left and right operands are both + * position 0 of their own role, and a call's callee and first argument likewise. + * + * ## The shape of a method call, which every resolution rule reads + * + * ```ts + * a.b(c, d) + * ``` + * + * ``` + * CALL_EXPRESSION + * METHOD_NAME → PROPERTY_ACCESS (a.b) + * RECEIVER → IDENTIFIER_REFERENCE (a) + * PROPERTY_NAME → IDENTIFIER_REFERENCE (b) + * ARGUMENT 0 → IDENTIFIER_REFERENCE (c) + * ARGUMENT 1 → IDENTIFIER_REFERENCE (d) + * ``` + * + * `ts_call_site.receiverExpressionLinkHash` points at the RECEIVER — the + * callee's child, and therefore the call's grandchild. That is why call sites are + * emitted in a second pass, once every expression row exists. + * + * ## ROOT is not a child edge + * + * It marks the top of a tree, where `parentExpressionHash` is `""`. Every other + * member implies a parent. + * + * Schema §4.14 c1. + */ +export enum TsEdgeRole { + /** The top of an expression tree. `parentExpressionHash` is empty. */ + ROOT = 'ROOT', + /** The object a member is read from: `a` in `a.b`. */ + RECEIVER = 'RECEIVER', + /** A namespace or type qualifier in a dotted path. */ + QUALIFIER = 'QUALIFIER', + /** A call or `new` argument. `position` is the argument index. */ + ARGUMENT = 'ARGUMENT', + /** The callee of a call — an identifier, a property access, or a function expression. */ + METHOD_NAME = 'METHOD_NAME', + /** The member being read: `b` in `a.b`. */ + PROPERTY_NAME = 'PROPERTY_NAME', + /** The index of an element access: `i` in `a[i]`. */ + INDEX_ARGUMENT = 'INDEX_ARGUMENT', + /** The left operand of a binary or assignment node. */ + LEFT_OPERAND = 'LEFT_OPERAND', + /** The right operand. */ + RIGHT_OPERAND = 'RIGHT_OPERAND', + /** The condition of a ternary. */ + TERNARY_CONDITION = 'TERNARY_CONDITION', + /** The `?` branch. */ + TERNARY_THEN = 'TERNARY_THEN', + /** The `:` branch. */ + TERNARY_ELSE = 'TERNARY_ELSE', + /** The operand of a unary, `await`, `yield`, `delete`, `typeof`, `void` or `!`. */ + UNARY_OPERAND = 'UNARY_OPERAND', + /** The value being cast: `x` in `x as T`. */ + AS_OPERAND = 'AS_OPERAND', + /** The value being checked: `x` in `x satisfies T`. */ + SATISFIES_OPERAND = 'SATISFIES_OPERAND', + /** The operand of `...x`. */ + SPREAD_OPERAND = 'SPREAD_OPERAND', + /** An interpolated expression inside a template. */ + TEMPLATE_SPAN = 'TEMPLATE_SPAN', + /** A concise arrow body — an expression, not a block. */ + ARROW_BODY = 'ARROW_BODY', + /** The value of an object-literal property. */ + /** + * The KEY of an object-literal property, when it is statically known. + * + * Sibling of {@link OBJECT_PROPERTY_VALUE} at the same `position`, so the two + * join on `(parentExpressionHash, position)`. A COMPUTED key has no row: the + * value row then stands at its position with no key beside it, which is how a + * consumer tells "dynamic" from "absent". + * + * Kept apart from `PROPERTY_NAME` on purpose. That role is a member being + * READ (`a.length`); this one is a member being DEFINED. Conflating them + * would make "does this code set header X" and "does this code read header X" + * the same query. + */ + OBJECT_PROPERTY_KEY = 'OBJECT_PROPERTY_KEY', + OBJECT_PROPERTY_VALUE = 'OBJECT_PROPERTY_VALUE', + /** An element of an array literal. */ + ARRAY_ELEMENT = 'ARRAY_ELEMENT', + /** The tag of a tagged template — the thing being called. */ + TAG_EXPRESSION = 'TAG_EXPRESSION', + /** RESERVED for TSX. Carries zero rows in freeze 1. */ + JSX_ATTRIBUTE_VALUE = 'JSX_ATTRIBUTE_VALUE', + /** RESERVED for TSX. Carries zero rows in freeze 1. */ + JSX_CHILD = 'JSX_CHILD', +} diff --git a/parser/src/enums/typescript/expressions/TsExpressionKind.ts b/parser/src/enums/typescript/expressions/TsExpressionKind.ts new file mode 100644 index 000000000..49269846a --- /dev/null +++ b/parser/src/enums/typescript/expressions/TsExpressionKind.ts @@ -0,0 +1,105 @@ +/** + * What kind of expression node this is. + * + * Positions 0–24 of `ts_expression` are byte-for-byte `java_expression` 0–24, so + * `expr_kind`, `expr_child` and `expr_owner` port as renames and this enum + * starts from Java's `ExpressionKind`. + * + * ## Members with no Java analogue, and why each is a separate kind + * + * ```ts + * x as T // AS_EXPRESSION — a cast that WIDENS or NARROWS + * x satisfies T // SATISFIES_EXPRESSION — checks WITHOUT changing the type + * x // TYPE_ASSERTION — the older cast form; JSX in .tsx + * x! // NON_NULL_EXPRESSION — asserts non-null, changes nothing else + * ...args // SPREAD_ELEMENT — argument positions become unknowable + * import("m") // DYNAMIC_IMPORT — a module edge inside an expression + * ``` + * + * `AS_EXPRESSION` and `SATISFIES_EXPRESSION` are not interchangeable: + * `as` changes the type the checker believes, `satisfies` asserts and leaves the + * inferred type alone. A rule that treats them alike is wrong about which type + * flows onward. + * + * ## PARENTHESIZED is deliberately ABSENT + * + * A parenthesised expression is punctuation, not a fact. Emitting a row for it + * would put a node between a call and its receiver that no resolution rule + * expects, so parentheses are transparent and the operand takes their place. + * `(a).b()` therefore has exactly the shape of `a.b()`. + * + * ## The JSX members are RESERVED and carry ZERO rows + * + * TSX is out of the first freeze. The representation is decided — a JSX element + * IS a call to its component, with the whole props object as argument 0 — and + * nothing emits it. The gate asserts the emptiness, so the day TSX is switched + * on it appears as a named gate failure rather than as new rows nobody noticed. + * + * Schema §4.14 c0, §4.15.1. + */ +export enum TsExpressionKind { + /** `f(x)`. 1:1 with a `ts_call_site` row. */ + CALL_EXPRESSION = 'CALL_EXPRESSION', + /** `new C(x)`. Also 1:1 with a call site. */ + NEW_EXPRESSION = 'NEW_EXPRESSION', + /** `a.b`. Children: RECEIVER and PROPERTY_NAME. */ + PROPERTY_ACCESS = 'PROPERTY_ACCESS', + /** `a[i]`. Children: RECEIVER and INDEX_ARGUMENT. */ + ELEMENT_ACCESS = 'ELEMENT_ACCESS', + /** A bare name. Carries `referencedEntityHash` when the parser could resolve it. */ + IDENTIFIER_REFERENCE = 'IDENTIFIER_REFERENCE', + /** `this`. Resolves through the enclosing method's owning type. */ + THIS_REFERENCE = 'THIS_REFERENCE', + /** `super`. Resolves through an `inheritsMembers` heritage row. */ + SUPER_REFERENCE = 'SUPER_REFERENCE', + /** A string, number, bigint, boolean, `null` or regex literal. */ + LITERAL = 'LITERAL', + /** `` `a${b}` `` — has TEMPLATE_SPAN children. */ + TEMPLATE_EXPRESSION = 'TEMPLATE_EXPRESSION', + /** `` tag`a${b}` `` — a CALL to `tag`, so it gets a call site. */ + TAGGED_TEMPLATE = 'TAGGED_TEMPLATE', + /** `() => …`. Also a `ts_method` row: 161 measured call targets. */ + ARROW_FUNCTION = 'ARROW_FUNCTION', + /** `function () { }`. Also a `ts_method` row. */ + FUNCTION_EXPRESSION = 'FUNCTION_EXPRESSION', + /** `class { }`. Also a `ts_type` row, linked by `anonymousTypeHash`. */ + CLASS_EXPRESSION = 'CLASS_EXPRESSION', + /** `{ a: 1 }`. */ + OBJECT_LITERAL = 'OBJECT_LITERAL', + /** `[1, 2]`. */ + ARRAY_LITERAL = 'ARRAY_LITERAL', + /** `a + b`, `a && b`, `a ?? b`. */ + BINARY_EXPRESSION = 'BINARY_EXPRESSION', + /** `-a`, `!a`, `a++`. `unaryFixity` says which side the operator was on. */ + UNARY_EXPRESSION = 'UNARY_EXPRESSION', + /** `a ? b : c`. */ + TERNARY_EXPRESSION = 'TERNARY_EXPRESSION', + /** `a = b`. */ + ASSIGNMENT_EXPRESSION = 'ASSIGNMENT_EXPRESSION', + /** `a += b`, `a ??= b` — reads and writes in one node. */ + COMPOUND_ASSIGNMENT = 'COMPOUND_ASSIGNMENT', + /** `x as T` — changes the type the checker believes. */ + AS_EXPRESSION = 'AS_EXPRESSION', + /** `x satisfies T` — asserts without changing the inferred type. */ + SATISFIES_EXPRESSION = 'SATISFIES_EXPRESSION', + /** `x` — the older cast form. Illegal in `.tsx`, where `<` opens JSX. */ + TYPE_ASSERTION = 'TYPE_ASSERTION', + /** `x!` — asserts non-null and changes nothing else. */ + NON_NULL_EXPRESSION = 'NON_NULL_EXPRESSION', + /** `await x`. */ + AWAIT_EXPRESSION = 'AWAIT_EXPRESSION', + /** `yield x`. */ + YIELD_EXPRESSION = 'YIELD_EXPRESSION', + /** `...x` — marks where positional argument flow becomes unknowable. */ + SPREAD_ELEMENT = 'SPREAD_ELEMENT', + /** `import("m")` — a module edge, and a call site of kind DYNAMIC_IMPORT_CALL. */ + DYNAMIC_IMPORT = 'DYNAMIC_IMPORT', + /** RESERVED for TSX. Carries zero rows in freeze 1. */ + JSX_ELEMENT = 'JSX_ELEMENT', + /** RESERVED for TSX. Carries zero rows in freeze 1. */ + JSX_SELF_CLOSING = 'JSX_SELF_CLOSING', + /** `delete a`, `typeof a`, `void a` — `operatorString` says which. */ + DELETE_TYPEOF_VOID = 'DELETE_TYPEOF_VOID', + /** `a, b` — the comma operator. */ + SEQUENCE_EXPRESSION = 'SEQUENCE_EXPRESSION', +} diff --git a/parser/src/enums/typescript/expressions/TsExpressionOwnerKind.ts b/parser/src/enums/typescript/expressions/TsExpressionOwnerKind.ts new file mode 100644 index 000000000..0d60b896e --- /dev/null +++ b/parser/src/enums/typescript/expressions/TsExpressionOwnerKind.ts @@ -0,0 +1,39 @@ +/** + * What `ts_expression.expressionOwnerHash` points at. + * + * The owner FK is polymorphic, so this column tells a consumer which relation to + * join. It is also how caller attribution survives: an expression inside a block + * is owned by the block, and the chain `ts_block -> ts_method -> ts_type -> + * ts_module` reaches the enclosing function from there. + * + * That chain is why there is no `ts_scope` relation. Measured: 34,798 identifier + * references, and the chain reaches **every one** with zero unreachable — so a + * scope table would carry no information the FKs do not already carry. + * + * `MODULE_INIT` is the owner of top-level executable code, via the synthetic + * `` method. Every expression has an owner; there is no orphan. + * + * Schema §4.14 c3, ruling OQ-6. + */ +export enum TsExpressionOwnerKind { + /** A function-shaped declaration's body, outside any block. */ + METHOD = 'METHOD', + /** A member initializer. */ + FIELD = 'FIELD', + /** A variable initializer. */ + VARIABLE = 'VARIABLE', + /** A statement block — the usual owner inside a function. */ + BLOCK = 'BLOCK', + /** A type declaration, for a heritage expression. */ + TYPE = 'TYPE', + /** The synthetic `` initializer: top-level executable code. */ + MODULE_INIT = 'MODULE_INIT', + /** A decorator expression. */ + DECORATOR = 'DECORATOR', + /** `export default `. */ + EXPORT = 'EXPORT', + /** An enum member's initializer. */ + ENUM_MEMBER = 'ENUM_MEMBER', + /** A parameter default. */ + PARAMETER_DEFAULT = 'PARAMETER_DEFAULT', +} diff --git a/parser/src/enums/typescript/expressions/TsLiteralType.ts b/parser/src/enums/typescript/expressions/TsLiteralType.ts new file mode 100644 index 000000000..53f4b2adb --- /dev/null +++ b/parser/src/enums/typescript/expressions/TsLiteralType.ts @@ -0,0 +1,34 @@ +/** + * The kind of a literal expression. + * + * Non-empty exactly when `ts_expression.kind` is `LITERAL`, + * `TEMPLATE_EXPRESSION`, or an identifier spelling `undefined` — which the + * grammar calls an identifier and every reader calls a literal. Recording it as + * one is what lets an optionality rule see it. + * + * `NO_SUBSTITUTION_TEMPLATE` is separated from `TEMPLATE` because the first is a + * constant string and the second interpolates: only one of them can be a literal + * type, and only one of them evaluates its operands. + * + * Schema §4.14 c9. + */ +export enum TsLiteralType { + /** `"a"` or `'a'`. */ + STRING = 'STRING', + /** `42`, `0x1f`, `1_000`. */ + NUMBER = 'NUMBER', + /** `42n`. */ + BIGINT = 'BIGINT', + /** `true` or `false`. */ + BOOLEAN = 'BOOLEAN', + /** `null`. */ + NULL = 'NULL', + /** `undefined` — an identifier in the grammar, a literal in practice. */ + UNDEFINED = 'UNDEFINED', + /** `/re/g`. */ + REGEX = 'REGEX', + /** `` `a${b}` `` — interpolates, so it evaluates its spans. */ + TEMPLATE = 'TEMPLATE', + /** `` `a` `` — a constant string, and usable as a literal type. */ + NO_SUBSTITUTION_TEMPLATE = 'NO_SUBSTITUTION_TEMPLATE', +} diff --git a/parser/src/enums/typescript/expressions/TsReferencedEntityKind.ts b/parser/src/enums/typescript/expressions/TsReferencedEntityKind.ts new file mode 100644 index 000000000..54ba95201 --- /dev/null +++ b/parser/src/enums/typescript/expressions/TsReferencedEntityKind.ts @@ -0,0 +1,48 @@ +/** + * What an identifier reference RESOLVED to. + * + * A discriminator for the polymorphic `referencedEntityHash` FK, and the column + * the oracle checks against `getSymbolAtLocation`. + * + * ## UNKNOWN and AMBIENT_GLOBAL are different answers + * + * `AMBIENT_GLOBAL` means "resolved, to something outside this analysis" — + * `console`, `Promise`, `btoa`. The engine closes it from `lib_ts_*`, and the + * closed-world gate counts it as CLOSED. + * + * `UNKNOWN` means the parser could not resolve it. Collapsing the two would + * report a resolution GAP as a success, which is why the ambient set is curated + * rather than being "anything not found". + * + * For a global there is no import row to point at, so a curated set is the only + * mechanism available — unlike a TYPE name, where an unresolved name is simply + * emitted as written and the engine looks it up. + * + * Schema §4.14 c14. + */ +export enum TsReferencedEntityKind { + /** A class, interface, enum or type alias. */ + TYPE = 'TYPE', + /** A function-shaped declaration, including a named function expression. */ + METHOD = 'METHOD', + /** A member. */ + FIELD = 'FIELD', + /** A `const`, `let`, `var`, or a destructured binding's declaration. */ + VARIABLE = 'VARIABLE', + /** A formal parameter, including an arrow's. */ + PARAMETER = 'PARAMETER', + /** An enum member. */ + ENUM_MEMBER = 'ENUM_MEMBER', + /** A name bound by an import — the engine follows `resolvedFilePath` from here. */ + IMPORT_BINDING = 'IMPORT_BINDING', + /** A namespace. */ + NAMESPACE = 'NAMESPACE', + /** `this`. */ + THIS = 'THIS', + /** `super`. */ + SUPER = 'SUPER', + /** Declared outside this analysis: `console`, `Promise`, `btoa`. A closed terminal. */ + AMBIENT_GLOBAL = 'AMBIENT_GLOBAL', + /** Not resolved. A measured GAP, deliberately distinguishable from a terminal. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/typescript/expressions/TsRootContext.ts b/parser/src/enums/typescript/expressions/TsRootContext.ts new file mode 100644 index 000000000..6d1f06bcf --- /dev/null +++ b/parser/src/enums/typescript/expressions/TsRootContext.ts @@ -0,0 +1,74 @@ +/** + * Where the ROOT of an expression tree sits in the surrounding syntax. + * + * Inherited by every node in the tree, so a leaf can be filtered by the + * statement that contains it without walking to the root. "Every call in a + * condition", "every `new` in a field initializer" are one predicate each. + * + * ## Two contexts that carry behavioural weight + * + * `PARAMETER_DEFAULT` — the tree runs on every call that omits the argument, not + * once. `FIELD_INITIALIZER` — the tree runs on every construction. A rule + * counting allocations or side effects needs both, and neither is visible from + * the expression's own kind. + * + * `DECORATOR_EXPRESSION` is where TypeScript departs from Java hardest: a Java + * annotation is inert metadata, while a decorator is an expression that RUNS at + * class-definition time and may REPLACE its target. + * + * Schema §4.14 c2. + */ +export enum TsRootContext { + /** A statement that is just an expression. */ + EXPRESSION_STATEMENT = 'EXPRESSION_STATEMENT', + /** The initializer of a `const`/`let`/`var`. */ + VARIABLE_INITIALIZER = 'VARIABLE_INITIALIZER', + /** A member initializer — runs on every construction. */ + FIELD_INITIALIZER = 'FIELD_INITIALIZER', + /** The operand of `return`. */ + RETURN_VALUE = 'RETURN_VALUE', + /** An `if`, `while`, `do` or `for` condition — the narrowing position. */ + CONDITION = 'CONDITION', + /** An argument list evaluated outside a call node. */ + ARGUMENT_LIST = 'ARGUMENT_LIST', + /** The operand of `throw`. */ + THROW_VALUE = 'THROW_VALUE', + /** The subject of a `switch`. */ + SWITCH_SUBJECT = 'SWITCH_SUBJECT', + /** A `case` label. */ + CASE_LABEL = 'CASE_LABEL', + /** A `for` initializer or incrementor, or a `for…of` iterable. */ + LOOP_HEADER = 'LOOP_HEADER', + /** A parameter default — runs on every call that omits the argument. */ + PARAMETER_DEFAULT = 'PARAMETER_DEFAULT', + /** A decorator expression — runs at class-definition time and may replace its target. */ + DECORATOR_EXPRESSION = 'DECORATOR_EXPRESSION', + /** `export default ` or `export = `. */ + EXPORT_VALUE = 'EXPORT_VALUE', + /** An enum member's initializer. */ + ENUM_MEMBER_VALUE = 'ENUM_MEMBER_VALUE', + /** A class `extends` clause — evaluated at runtime, including the mixin form. */ + HERITAGE_EXPRESSION = 'HERITAGE_EXPRESSION', + /** A concise arrow body. */ + ARROW_BODY_EXPRESSION = 'ARROW_BODY_EXPRESSION', + /** A computed member name: `[key]` in `{ [key]: 1 }`. */ + COMPUTED_PROPERTY_NAME = 'COMPUTED_PROPERTY_NAME', + /** The operand of a top-level `await` or `yield`. */ + YIELD_OR_AWAIT_OPERAND = 'YIELD_OR_AWAIT_OPERAND', + /** A position not otherwise named. Recorded rather than guessed at. */ + /** + * The expression inside a JSX brace: `{t(msg)}` as a child, or + * `label={t(msg)}` as an attribute value. + * + * NOT one of the reserved TSX values. Those describe the component CALL -- + * `` as `Badge({...})`, with attributes as JSX_ATTRIBUTE_VALUE edges + * under argument 0 -- and that whole structure stays empty in freeze 1. + * This is narrower and independent: a call written inside a brace is an + * ordinary call that happens to sit in JSX, and dropping it cost 4,488 of + * one UI-framework application's 14,335 call sites. It is a ROOT because the JSX element above + * it emits no row to be a child of. + */ + JSX_EMBEDDED_EXPRESSION = 'JSX_EMBEDDED_EXPRESSION', + + UNKNOWN_CONTEXT = 'UNKNOWN_CONTEXT', +} diff --git a/parser/src/enums/typescript/expressions/TsUnaryFixity.ts b/parser/src/enums/typescript/expressions/TsUnaryFixity.ts new file mode 100644 index 000000000..f1b270f54 --- /dev/null +++ b/parser/src/enums/typescript/expressions/TsUnaryFixity.ts @@ -0,0 +1,19 @@ +/** + * Which side of its operand a unary operator was written on. + * + * Load-bearing for exactly one pair, where the fixity changes the VALUE of the + * expression rather than its formatting: + * + * ```ts + * const a = i++; // POSTFIX — `a` is the value BEFORE the increment + * const b = ++i; // PREFIX — `b` is the value AFTER it + * ``` + * + * Schema §4.14 c12. + */ +export enum TsUnaryFixity { + /** `++i`, `-x`, `!x` — the operator precedes the operand. */ + PREFIX = 'PREFIX', + /** `i++`, `i--` — the operator follows it, and the value is the one before. */ + POSTFIX = 'POSTFIX', +} diff --git a/parser/src/enums/typescript/expressions/index.ts b/parser/src/enums/typescript/expressions/index.ts new file mode 100644 index 000000000..dcf8c1aab --- /dev/null +++ b/parser/src/enums/typescript/expressions/index.ts @@ -0,0 +1,7 @@ +export { TsEdgeRole } from '@/enums/typescript/expressions/TsEdgeRole'; +export { TsExpressionKind } from '@/enums/typescript/expressions/TsExpressionKind'; +export { TsExpressionOwnerKind } from '@/enums/typescript/expressions/TsExpressionOwnerKind'; +export { TsLiteralType } from '@/enums/typescript/expressions/TsLiteralType'; +export { TsReferencedEntityKind } from '@/enums/typescript/expressions/TsReferencedEntityKind'; +export { TsRootContext } from '@/enums/typescript/expressions/TsRootContext'; +export { TsUnaryFixity } from '@/enums/typescript/expressions/TsUnaryFixity'; diff --git a/parser/src/enums/typescript/fields/TsFieldAccess.ts b/parser/src/enums/typescript/fields/TsFieldAccess.ts new file mode 100644 index 000000000..732cc6a87 --- /dev/null +++ b/parser/src/enums/typescript/fields/TsFieldAccess.ts @@ -0,0 +1,31 @@ +/** + * How reachable a member is. + * + * Carries the same `PRIVATE_ACCESS` / `PRIVATE_NAME_ACCESS` distinction as + * `TsMethodAccess`, and for the same reason: `private` is erased at emit and + * still present at runtime, while `#x` is enforced by the runtime and cannot be + * reached even by reflection. + * + * ```ts + * class C { + * private soft = 1; // PRIVATE_ACCESS + * #hard = 1; // PRIVATE_NAME_ACCESS + * } + * ``` + * + * Schema §4.8 c11. + */ +export enum TsFieldAccess { + /** `public`, or no access modifier. */ + PUBLIC_ACCESS = 'PUBLIC_ACCESS', + /** `private` — erased at emit; reachable at runtime. */ + PRIVATE_ACCESS = 'PRIVATE_ACCESS', + /** `protected` — erased at emit. */ + PROTECTED_ACCESS = 'PROTECTED_ACCESS', + /** `#x` — a HARD runtime private. */ + PRIVATE_NAME_ACCESS = 'PRIVATE_NAME_ACCESS', + /** An exported module-level declaration. */ + EXPORTED_ACCESS = 'EXPORTED_ACCESS', + /** A module-level declaration with no `export`. */ + MODULE_LOCAL_ACCESS = 'MODULE_LOCAL_ACCESS', +} diff --git a/parser/src/enums/typescript/fields/TsFieldModifier.ts b/parser/src/enums/typescript/fields/TsFieldModifier.ts new file mode 100644 index 000000000..f035918a4 --- /dev/null +++ b/parser/src/enums/typescript/fields/TsFieldModifier.ts @@ -0,0 +1,41 @@ +/** + * Modifiers on a member. A comma-set column, sorted. + * + * ```ts + * class C { + * static readonly MAX = 10; // READONLY,STATIC + * declare brand: string; // DECLARE + * abstract kind: string; // ABSTRACT + * override id = 1; // OVERRIDE + * optional?: number; // OPTIONAL + * definite!: number; // DEFINITE_ASSIGNMENT + * accessor value = 0; // ACCESSOR + * } + * ``` + * + * `OPTIONAL` is duplicated as the boolean `ts_field.isOptional` because it is + * load-bearing for structural satisfaction: an ABSENT optional member does not + * break assignability, so a rule that ignores it rejects classes that + * legitimately satisfy an interface. The boolean is there so that rule never has + * to split a comma-set. + * + * Schema §4.8 c12. + */ +export enum TsFieldModifier { + /** `static`. */ + STATIC = 'STATIC', + /** `readonly` — 8,901 measured. */ + READONLY = 'READONLY', + /** `declare` — asserts an inherited member without emitting one. */ + DECLARE = 'DECLARE', + /** `abstract`. */ + ABSTRACT = 'ABSTRACT', + /** `override`. */ + OVERRIDE = 'OVERRIDE', + /** `x?: T` — 5,196 measured; load-bearing for structural satisfaction. */ + OPTIONAL = 'OPTIONAL', + /** `x!: T` — asserts assignment the checker cannot see. */ + DEFINITE_ASSIGNMENT = 'DEFINITE_ASSIGNMENT', + /** `accessor x` — a getter/setter pair with backing storage. */ + ACCESSOR = 'ACCESSOR', +} diff --git a/parser/src/enums/typescript/fields/TsMemberKind.ts b/parser/src/enums/typescript/fields/TsMemberKind.ts new file mode 100644 index 000000000..4ac38d517 --- /dev/null +++ b/parser/src/enums/typescript/fields/TsMemberKind.ts @@ -0,0 +1,73 @@ +/** + * What kind of value-shaped member a `ts_field` row describes. + * + * ## The owner-qualified values, and why they exist + * + * `PROPERTY_SIGNATURE` and `TYPE_LITERAL_PROPERTY` are the same syntax in + * different places, and they differ in the ONE thing a consumer must not get + * wrong: what `tsTypeLinkHash` points at. An interface member is owned by a + * `ts_type`; a type-literal member is owned by a `ts_type_reference`, because an + * anonymous shape has no declaration and §4.2 forbids inventing one. + * + * The enum was already built this way — `OBJECT_LITERAL_PROPERTY` and + * `PARAMETER_PROPERTY` are owner-qualified too — so this is the existing pattern + * applied consistently rather than a new mechanism. The prefixes make it + * fail-safe: `TS_TYPE_…` and `TS_TYPE_REFERENCE_…` differ, so a rule joining + * against `ts_type` finds NO match for a shape member rather than a wrong one. + * + * ## Examples + * + * ```ts + * class C { + * name: string; // PROPERTY_DECLARATION + * accessor count = 0; // AUTO_ACCESSOR — a getter/setter pair plus storage + * constructor(private r: R) { } // PARAMETER_PROPERTY — declared by a parameter + * } + * interface I { + * readonly id: string; // PROPERTY_SIGNATURE isTypeOnly = true + * [key: string]: unknown; // INDEX_SIGNATURE + * } + * const o = { a: 1 }; // OBJECT_LITERAL_PROPERTY + * ``` + * + * ## INDEX_SIGNATURE is a live resolution path, not a curiosity + * + * 126 measured, and a call through one resolved to a `FunctionType` in the + * measurement — so `handler[name]()` has a real target. `indexKeyTypeName` + * carries the key type because `[k: string]` and `[k: symbol]` admit different + * accesses. + * + * Schema §4.8 c13. + */ +export enum TsMemberKind { + /** `x: T` on a class. Has runtime existence. */ + PROPERTY_DECLARATION = 'PROPERTY_DECLARATION', + + /** `x: T` on an INTERFACE. Type-only. Owned by a `ts_type`. */ + PROPERTY_SIGNATURE = 'PROPERTY_SIGNATURE', + + /** `[k: string]: T` on an INTERFACE — admits members this relation cannot enumerate. */ + INDEX_SIGNATURE = 'INDEX_SIGNATURE', + + /** + * `x: T` inside an ANONYMOUS type literal — `{ x: T }`. + * + * Owner-qualified because the owner FK points somewhere else: `tsTypeLinkHash` + * is a `ts_type_reference` here, not a `ts_type`, since an anonymous shape has + * no declaration and §4.2 forbids inventing one. This column IS the + * discriminator, so a rule that wants declared members only filters on it. + */ + TYPE_LITERAL_PROPERTY = 'TYPE_LITERAL_PROPERTY', + + /** `[k: string]: T` inside an anonymous type literal. Owner is the shape. */ + TYPE_LITERAL_INDEX_SIGNATURE = 'TYPE_LITERAL_INDEX_SIGNATURE', + + /** Declared by `constructor(private x: T)`. Links back via `originParameterLinkHash`. */ + PARAMETER_PROPERTY = 'PARAMETER_PROPERTY', + + /** `{ a: 1 }` — a property of an object literal. */ + OBJECT_LITERAL_PROPERTY = 'OBJECT_LITERAL_PROPERTY', + + /** `accessor x = 1` — a getter/setter pair with backing storage. */ + AUTO_ACCESSOR = 'AUTO_ACCESSOR', +} diff --git a/parser/src/enums/typescript/fields/index.ts b/parser/src/enums/typescript/fields/index.ts new file mode 100644 index 000000000..5f13fd226 --- /dev/null +++ b/parser/src/enums/typescript/fields/index.ts @@ -0,0 +1,3 @@ +export { TsFieldAccess } from '@/enums/typescript/fields/TsFieldAccess'; +export { TsFieldModifier } from '@/enums/typescript/fields/TsFieldModifier'; +export { TsMemberKind } from '@/enums/typescript/fields/TsMemberKind'; diff --git a/parser/src/enums/typescript/heritage/TsClauseToken.ts b/parser/src/enums/typescript/heritage/TsClauseToken.ts new file mode 100644 index 000000000..3bb90a51e --- /dev/null +++ b/parser/src/enums/typescript/heritage/TsClauseToken.ts @@ -0,0 +1,16 @@ +/** + * Which keyword introduced a heritage entry. + * + * In the primary key of `ts_type_heritage`, and therefore never derived from + * `heritageKind`: `class C extends B implements B` is legal, and the two rows + * must not collide. + * + * Schema §4.3 c1. + */ +export enum TsClauseToken { + /** `extends` — really does inherit members. */ + EXTENDS = 'EXTENDS', + + /** `implements` — asserts, and inherits nothing. */ + IMPLEMENTS = 'IMPLEMENTS', +} diff --git a/parser/src/enums/typescript/heritage/TsHeritageKind.ts b/parser/src/enums/typescript/heritage/TsHeritageKind.ts new file mode 100644 index 000000000..e7096ef4f --- /dev/null +++ b/parser/src/enums/typescript/heritage/TsHeritageKind.ts @@ -0,0 +1,52 @@ +/** + * What a single `extends` / `implements` clause entry IS. + * + * ## This relation records SYNTAX, and that is the whole point + * + * The distinction does not exist in Java, where both clauses are authoritative + * for subtyping, and collapsing it is the most consequential porting error + * available here. Measured: **60.4% of classes declare no `implements` at all**, + * **21.5%** of assignable (class, interface) pairs appear in no syntax anywhere, + * and **15.4%** are mutually assignable — which is not identity. + * + * So an `IMPLEMENTS_CLAUSE` row says "someone wrote this down". It does not say + * the subtyping holds, and it does not say members are inherited. For actual + * subtyping the engine derives `ts_type_satisfies` and the oracle adjudicates it + * with `isTypeAssignableTo`. + * + * ## Examples + * + * ```ts + * class A extends Base { } // EXTENDS_CLASS + * interface I extends Other { } // EXTENDS_INTERFACE + * class B implements Serializable { } // IMPLEMENTS_CLAUSE + * class C extends mixin(Base) { } // EXTENDS_EXPRESSION isDynamic + * interface D extends { a: number } { } // EXTENDS_TYPE_LITERAL + * ``` + * + * `EXTENDS_EXPRESSION` is the mixin form. The base is COMPUTED, so the parser + * cannot name it and says so via `isDynamic` rather than guessing at the callee — + * which would attribute the members of whatever `mixin` happens to return. + * + * Schema §4.3 c0, §3.2. + */ +export enum TsHeritageKind { + /** A class extending a class. Inherits members. */ + EXTENDS_CLASS = 'EXTENDS_CLASS', + + /** An interface extending an interface. Inherits members. */ + EXTENDS_INTERFACE = 'EXTENDS_INTERFACE', + + /** + * `implements` — a compile-time ASSERTION that inherits nothing. + * + * Walking this as a member-lookup edge is correct in Java and wrong here. + */ + IMPLEMENTS_CLAUSE = 'IMPLEMENTS_CLAUSE', + + /** `extends mixin(Base)` — a computed base the parser cannot name. */ + EXTENDS_EXPRESSION = 'EXTENDS_EXPRESSION', + + /** `extends { a: number }` — an anonymous shape as a supertype. */ + EXTENDS_TYPE_LITERAL = 'EXTENDS_TYPE_LITERAL', +} diff --git a/parser/src/enums/typescript/heritage/index.ts b/parser/src/enums/typescript/heritage/index.ts new file mode 100644 index 000000000..4a56d33bb --- /dev/null +++ b/parser/src/enums/typescript/heritage/index.ts @@ -0,0 +1,2 @@ +export { TsClauseToken } from '@/enums/typescript/heritage/TsClauseToken'; +export { TsHeritageKind } from '@/enums/typescript/heritage/TsHeritageKind'; diff --git a/parser/src/enums/typescript/imports/TsImportKind.ts b/parser/src/enums/typescript/imports/TsImportKind.ts new file mode 100644 index 000000000..a625575cb --- /dev/null +++ b/parser/src/enums/typescript/imports/TsImportKind.ts @@ -0,0 +1,81 @@ +/** + * What an import row binds, and how. + * + * Positions 0–8 of `ts_import` mirror `java_import` with two slots repurposed: + * Java's `isStatic` becomes `isTypeOnly` and its `isOnDemand` becomes + * `isWildcard`, so the shared `import_wildcard` projection keeps its name. + * + * ## One declaration with N named specifiers emits N ROWS + * + * `import { a, type B }` is one declaration and two facts with DIFFERENT runtime + * existence. A per-declaration row would have to pick one answer for + * `isTypeOnly` and be wrong about the other. **45.6% of ecosystem import + * declarations are type-only**, plus 34 using the inline `{ type X }` form, so + * this is the common case rather than an edge one. + * + * ## Examples + * + * ```ts + * import { a } from "m"; // NAMED + * import { a as b } from "m"; // NAMED_ALIAS + * import d from "m"; // DEFAULT + * import * as ns from "m"; // NAMESPACE isWildcard = true + * import "m"; // SIDE_EFFECT binds nothing + * import type { T } from "m"; // TYPE_ONLY_NAMED + * import type D from "m"; // TYPE_ONLY_DEFAULT + * import type * as N from "m"; // TYPE_ONLY_NAMESPACE + * import { type T, v } from "m"; // INLINE_TYPE_SPECIFIER (T) + NAMED (v) + * import x = require("m"); // IMPORT_EQUALS_REQUIRE + * import y = a.b.C; // IMPORT_EQUALS_ENTITY — no module at all + * const m = await import("m"); // DYNAMIC_IMPORT + * const r = require("m"); // REQUIRE_CALL + * /// // TRIPLE_SLASH_REFERENCE + * ``` + * + * ## Three members that a statement-only walk misses entirely + * + * `DYNAMIC_IMPORT`, `REQUIRE_CALL` and `TRIPLE_SLASH_REFERENCE` are reachable + * from no top-level import declaration — the first two live inside function + * bodies and the third is a comment. They are still real module edges, and + * without them the only record that the target file is reachable is gone. + * + * ## IMPORT_EQUALS_ENTITY names no module + * + * `import Units = Geometry.Units` aliases an ENTITY in the same file. An empty + * `resolvedFilePath` is the CORRECT answer for it, not a failure — the hop is + * the dotted path in `moduleOrEntityName`. + * + * Schema §4.12 c0. + */ +export enum TsImportKind { + /** `import { a } from "m"`. */ + NAMED = 'NAMED', + /** `import { a as b } from "m"`. */ + NAMED_ALIAS = 'NAMED_ALIAS', + /** `import d from "m"` — binds the source module's `default` export. */ + DEFAULT = 'DEFAULT', + /** `import * as ns from "m"`. Java's `TYPE_ON_DEMAND` slot. */ + NAMESPACE = 'NAMESPACE', + /** `import "m"` — no binding, but a real module edge and often the only one. */ + SIDE_EFFECT = 'SIDE_EFFECT', + /** `import type { T } from "m"` — no runtime existence. */ + TYPE_ONLY_NAMED = 'TYPE_ONLY_NAMED', + /** `import type D from "m"`. */ + TYPE_ONLY_DEFAULT = 'TYPE_ONLY_DEFAULT', + /** `import type * as N from "m"`. */ + TYPE_ONLY_NAMESPACE = 'TYPE_ONLY_NAMESPACE', + /** `import { type T }` — type-only on the SPECIFIER, not the declaration. */ + INLINE_TYPE_SPECIFIER = 'INLINE_TYPE_SPECIFIER', + /** `import x = require("m")` — the CommonJS interop form. */ + IMPORT_EQUALS_REQUIRE = 'IMPORT_EQUALS_REQUIRE', + /** `import y = a.b.C` — an ENTITY alias; there is no module to resolve. */ + IMPORT_EQUALS_ENTITY = 'IMPORT_EQUALS_ENTITY', + /** `import("m")` — reachable only from inside an expression. */ + DYNAMIC_IMPORT = 'DYNAMIC_IMPORT', + /** `import("m").T` in a TYPE position. */ + TYPE_IMPORT_NODE = 'TYPE_IMPORT_NODE', + /** `require("m")` — reachable only from inside an expression. */ + REQUIRE_CALL = 'REQUIRE_CALL', + /** `/// ` or `types=…` — a module edge written as a comment. */ + TRIPLE_SLASH_REFERENCE = 'TRIPLE_SLASH_REFERENCE', +} diff --git a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts new file mode 100644 index 000000000..d2d32d535 --- /dev/null +++ b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts @@ -0,0 +1,50 @@ +/** + * WHERE a specifier landed, as `ts.resolveModuleName` reported it. + * + * ## This column is parser-legal, and that is not obvious + * + * `ts.resolveModuleName` needs **no `ts.Program`**: it is a pure function of a + * specifier, compiler options and a host, and it returns `undefined` for an + * unresolvable specifier rather than guessing. That is what makes + * `resolvedFilePath` a parser column rather than engine work — and it matters + * beyond imports, because a module augmentation's merge key is keyed on the + * RESOLVED target module. + * + * ## The distinction the engine cannot make without this column + * + * `resolvedFilePath` is empty for three completely different reasons, and only + * one of them is a problem: + * + * ```ts + * import * as path from "path"; // BUILTIN_NODE — no file, and none needed + * import { T } from "some-pkg"; // AMBIENT_MODULE — a `declare module` in this analysis + * import { U } from "./missing"; // UNRESOLVED — genuinely not found + * ``` + * + * Collapsing them means the engine cannot tell a Node builtin from a project + * import that failed. Measured on one repository: unprefixed Node builtins + * accounted for **241 of 259** apparently-incomplete import hops. + * + * `UNRESOLVED` is an honest negative about THIS analysis, not a claim about the + * outside world. + * + * Schema §4.12 c16. + */ +export enum TsImportResolutionKind { + /** A relative specifier that resolved to a file. */ + RELATIVE_FILE = 'RELATIVE_FILE', + /** Resolved through a tsconfig `paths` mapping. */ + PATHS_ALIAS = 'PATHS_ALIAS', + /** Resolved to a `.d.ts` inside `node_modules`. */ + NODE_MODULES_TYPES = 'NODE_MODULES_TYPES', + /** Resolved to source inside `node_modules`. */ + NODE_MODULES_SOURCE = 'NODE_MODULES_SOURCE', + /** Resolved through a package's `exports` map. */ + PACKAGE_EXPORTS = 'PACKAGE_EXPORTS', + /** Matched a `declare module "x"` in this analysis. No file, and none needed. */ + AMBIENT_MODULE = 'AMBIENT_MODULE', + /** A Node builtin, with or without the `node:` prefix. */ + BUILTIN_NODE = 'BUILTIN_NODE', + /** Not found. An honest negative, and distinguishable from the two above. */ + UNRESOLVED = 'UNRESOLVED', +} diff --git a/parser/src/enums/typescript/imports/TsResolvedExtension.ts b/parser/src/enums/typescript/imports/TsResolvedExtension.ts new file mode 100644 index 000000000..8c09472e6 --- /dev/null +++ b/parser/src/enums/typescript/imports/TsResolvedExtension.ts @@ -0,0 +1,31 @@ +/** + * The extension of the file a specifier resolved to. + * + * Recorded because the specifier does not determine it. Verified on 6.0.3: + * `"./a.js"` resolves to `a.ts` with extension `.ts` — the specifier names the + * EMIT and resolution finds the SOURCE. A consumer that reads the specifier and + * assumes the extension is wrong under `node16` and `nodenext`, which is most + * current code. + * + * `.d.ts` is the member that matters for provenance: it means the target is + * declarations only, so the engine stages it into `lib_ts_*` rather than + * treating it as project source. + * + * Schema §4.12 c17. + */ +export enum TsResolvedExtension { + /** `.ts` — including a `"./a.js"` specifier that resolved to source. */ + TS = '.ts', + /** `.tsx`. */ + TSX = '.tsx', + /** `.d.ts` — declarations only, so the target has no bodies. */ + DTS = '.d.ts', + /** `.mts`. */ + MTS = '.mts', + /** `.cts`. */ + CTS = '.cts', + /** `.json` under `resolveJsonModule`. */ + JSON = '.json', + /** `.js` — resolved to emitted or hand-written JavaScript. */ + JS = '.js', +} diff --git a/parser/src/enums/typescript/imports/index.ts b/parser/src/enums/typescript/imports/index.ts new file mode 100644 index 000000000..041de2ac5 --- /dev/null +++ b/parser/src/enums/typescript/imports/index.ts @@ -0,0 +1,3 @@ +export { TsImportKind } from '@/enums/typescript/imports/TsImportKind'; +export { TsImportResolutionKind } from '@/enums/typescript/imports/TsImportResolutionKind'; +export { TsResolvedExtension } from '@/enums/typescript/imports/TsResolvedExtension'; diff --git a/parser/src/enums/typescript/index.ts b/parser/src/enums/typescript/index.ts new file mode 100644 index 000000000..ae4490e1d --- /dev/null +++ b/parser/src/enums/typescript/index.ts @@ -0,0 +1,18 @@ +export * from '@/enums/typescript/blocks'; +export * from '@/enums/typescript/call-sites'; +export * from '@/enums/typescript/comments'; +export * from '@/enums/typescript/decorators'; +export * from '@/enums/typescript/enum-members'; +export * from '@/enums/typescript/exports'; +export * from '@/enums/typescript/expressions'; +export * from '@/enums/typescript/fields'; +export * from '@/enums/typescript/heritage'; +export * from '@/enums/typescript/imports'; +export * from '@/enums/typescript/method-parameters'; +export * from '@/enums/typescript/methods'; +export * from '@/enums/typescript/parse-gaps'; +export * from '@/enums/typescript/modules'; +export * from '@/enums/typescript/type-parameters'; +export * from '@/enums/typescript/type-references'; +export * from '@/enums/typescript/types'; +export * from '@/enums/typescript/variables'; diff --git a/parser/src/enums/typescript/method-parameters/TsDefaultValueKind.ts b/parser/src/enums/typescript/method-parameters/TsDefaultValueKind.ts new file mode 100644 index 000000000..6608eada3 --- /dev/null +++ b/parser/src/enums/typescript/method-parameters/TsDefaultValueKind.ts @@ -0,0 +1,63 @@ +/** + * The shape of a parameter's default value. + * + * A categorical summary of an expression that also has a full `ts_expression` + * tree hanging off `tsExpressionLinkHash`. The column exists so a rule can + * filter — "every parameter defaulting to a call" — without walking the tree, + * and so a default that is a literal is distinguishable from one that runs code. + * + * ## Examples + * + * ```ts + * function f( + * a = "x", // STRING + * b = 1, // NUMBER + * c = true, // BOOL + * d = null, // NULL + * e = undefined, // UNDEFINED + * g = { k: 1 }, // OBJECT + * h = [1, 2], // ARRAY + * i = make(), // CALL — runs at every call with `i` omitted + * j = new Repo(), // NEW — allocates at every such call + * k = DEFAULT, // IDENTIFIER + * l = () => 0, // ARROW + * m = `${x}`, // TEMPLATE + * ) { } + * ``` + * + * `CALL` and `NEW` are the members worth filtering on: a default that constructs + * or invokes runs once per call, not once per program, and that is a real + * behavioural fact rather than a formatting detail. + * + * Schema §4.7 c16. + */ +export enum TsDefaultValueKind { + /** No default. */ + NONE = 'NONE', + /** A string or no-substitution template literal. */ + STRING = 'STRING', + /** A numeric or bigint literal. */ + NUMBER = 'NUMBER', + /** `true` or `false`. */ + BOOL = 'BOOL', + /** `null`. */ + NULL = 'NULL', + /** `undefined`. */ + UNDEFINED = 'UNDEFINED', + /** An object literal. */ + OBJECT = 'OBJECT', + /** An array literal. */ + ARRAY = 'ARRAY', + /** A call — evaluated on every invocation that omits the argument. */ + CALL = 'CALL', + /** A `new` expression — allocates on every such invocation. */ + NEW = 'NEW', + /** A bare identifier reference. */ + IDENTIFIER = 'IDENTIFIER', + /** An arrow or function expression. */ + ARROW = 'ARROW', + /** A template expression with substitutions. */ + TEMPLATE = 'TEMPLATE', + /** Anything else — recorded rather than guessed at. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/typescript/method-parameters/TsParamKind.ts b/parser/src/enums/typescript/method-parameters/TsParamKind.ts new file mode 100644 index 000000000..712e53538 --- /dev/null +++ b/parser/src/enums/typescript/method-parameters/TsParamKind.ts @@ -0,0 +1,58 @@ +/** + * What kind of formal parameter this is. In the primary key. + * + * In the key because `paramName` is `""` for a destructured parameter, so + * `function f({ a }, [b])` has two nameless parameters at different positions + * that differ only in kind. + * + * ## Examples + * + * ```ts + * function f( + * this: Window, // THIS — position 0, and not a runtime argument + * a: string, // REQUIRED + * b?: number, // OPTIONAL + * { x }: Point, // BINDING_OBJECT + * [y]: number[], // BINDING_ARRAY + * ...rest: string[] // REST + * ) { } + * + * class C { + * constructor(private readonly repo: Repo) { } // PARAMETER_PROPERTY + * } + * ``` + * + * ## Two members that change what an engine may conclude + * + * **THIS** is a type annotation, not an argument. Counting it as one shifts + * every subsequent argument by a position, which silently mismatches arguments + * to parameters for the whole signature. + * + * **PARAMETER_PROPERTY** means one parameter also DECLARES A FIELD — no Java or + * Python analogue. It is recorded as a cross-FK to the `ts_field` row rather + * than a duplicated row, so the field is counted once in the owning type's shape. + * + * Schema §4.7 c12. + */ +export enum TsParamKind { + /** An ordinary required parameter. */ + REQUIRED = 'REQUIRED', + + /** `b?: T`, or a parameter with a default. Changes ARITY MATCHING. */ + OPTIONAL = 'OPTIONAL', + + /** `...rest: T[]` — accepts any number of trailing arguments. */ + REST = 'REST', + + /** An explicit `this: T`. A type annotation occupying position 0, not an argument. */ + THIS = 'THIS', + + /** `{ a, b }: T` — binds several names and is itself nameless. */ + BINDING_OBJECT = 'BINDING_OBJECT', + + /** `[a, b]: T[]` — as above, positionally. */ + BINDING_ARRAY = 'BINDING_ARRAY', + + /** `constructor(private x: T)` — this parameter also declares a field. */ + PARAMETER_PROPERTY = 'PARAMETER_PROPERTY', +} diff --git a/parser/src/enums/typescript/method-parameters/TsParameterPropertyModifier.ts b/parser/src/enums/typescript/method-parameters/TsParameterPropertyModifier.ts new file mode 100644 index 000000000..a47fbaa8a --- /dev/null +++ b/parser/src/enums/typescript/method-parameters/TsParameterPropertyModifier.ts @@ -0,0 +1,33 @@ +/** + * The modifiers that turn a constructor parameter into a field declaration. + * + * A comma-set, and non-empty exactly when `paramKind` is `PARAMETER_PROPERTY`. + * The construct has no Java or Python analogue: one piece of syntax declares two + * entities. + * + * ```ts + * class Service { + * constructor( + * private readonly repo: Repository, // PRIVATE,READONLY + * public name: string, // PUBLIC + * protected id: number, // PROTECTED + * ) { } + * } + * ``` + * + * Each of those emits a `ts_method_parameter` row AND a `ts_field` row, linked + * by `declaredFieldLinkHash` / `originParameterLinkHash` so the field is counted + * once in the type's shape rather than twice. + * + * Schema §4.7 c18. + */ +export enum TsParameterPropertyModifier { + /** `private` — erased at emit. */ + PRIVATE = 'PRIVATE', + /** `protected` — erased at emit. */ + PROTECTED = 'PROTECTED', + /** `public` — explicit, and the only form that is also the default. */ + PUBLIC = 'PUBLIC', + /** `readonly` — assignable only in the constructor. */ + READONLY = 'READONLY', +} diff --git a/parser/src/enums/typescript/method-parameters/index.ts b/parser/src/enums/typescript/method-parameters/index.ts new file mode 100644 index 000000000..3727cb3aa --- /dev/null +++ b/parser/src/enums/typescript/method-parameters/index.ts @@ -0,0 +1,3 @@ +export { TsDefaultValueKind } from '@/enums/typescript/method-parameters/TsDefaultValueKind'; +export { TsParameterPropertyModifier } from '@/enums/typescript/method-parameters/TsParameterPropertyModifier'; +export { TsParamKind } from '@/enums/typescript/method-parameters/TsParamKind'; diff --git a/parser/src/enums/typescript/methods/TsBodyPresence.ts b/parser/src/enums/typescript/methods/TsBodyPresence.ts new file mode 100644 index 000000000..9a372aefb --- /dev/null +++ b/parser/src/enums/typescript/methods/TsBodyPresence.ts @@ -0,0 +1,46 @@ +/** + * Whether a declaration carries a body, and if not, WHY. + * + * ## The single most expensive mistake this column prevents + * + * **44.3% of resolved call targets are bodiless**, and 99.6% of the bodiless + * interface targets are ambient. Attributing a call *implementation* to a `.d.ts` + * line means attributing behaviour to a file that contains none — and nothing + * downstream can detect the error once made, because the row looks exactly like + * a real target. + * + * A bodiless row is still a legitimate call TARGET. The two facts are not in + * tension: `resolvedSignatureLinkHash` may point here, and + * `isAmbientTarget` says the code that runs is elsewhere. + * + * ## Why the reasons are distinguished rather than collapsed to a boolean + * + * ```ts + * interface I { m(): void } // NO_BODY_INTERFACE — can NEVER have a body + * abstract class A { abstract m(): void } // NO_BODY_ABSTRACT — a subclass supplies one + * declare function f(): void; // NO_BODY_AMBIENT — the body is outside this analysis + * function g(x: number): void; // NO_BODY_OVERLOAD — the body is the next declaration + * function g(x: unknown): void { } // HAS_BODY + * ``` + * + * Each reason implies a different place to look for the implementation, and only + * one of them means "nowhere in this fact base". + * + * Schema §4.6 c27. + */ +export enum TsBodyPresence { + /** The declaration carries a body: this is code that runs. */ + HAS_BODY = 'HAS_BODY', + + /** An overload signature; the implementation is a sibling declaration. */ + NO_BODY_OVERLOAD = 'NO_BODY_OVERLOAD', + + /** `declare`, or inside a `.d.ts`: the body is outside this analysis. */ + NO_BODY_AMBIENT = 'NO_BODY_AMBIENT', + + /** An interface, type-literal or function-type member. Can never have a body. */ + NO_BODY_INTERFACE = 'NO_BODY_INTERFACE', + + /** `abstract`: a subclass supplies the body. */ + NO_BODY_ABSTRACT = 'NO_BODY_ABSTRACT', +} diff --git a/parser/src/enums/typescript/methods/TsMethodAccess.ts b/parser/src/enums/typescript/methods/TsMethodAccess.ts new file mode 100644 index 000000000..92b32504e --- /dev/null +++ b/parser/src/enums/typescript/methods/TsMethodAccess.ts @@ -0,0 +1,43 @@ +/** + * How reachable a function-shaped declaration is. + * + * Mixes two systems because TypeScript does: class members carry Java-style + * modifiers, while top-level functions are governed by `export`. Both appear in + * one column because both answer the same question — can this be called from + * outside — and a consumer should not need to know which mechanism applied. + * + * ## `PRIVATE_ACCESS` and `PRIVATE_NAME_ACCESS` are not the same fact + * + * ```ts + * class C { + * private soft() { } // PRIVATE_ACCESS — erased at emit, reachable at runtime + * #hard() { } // PRIVATE_NAME_ACCESS — enforced by the runtime + * } + * ``` + * + * `private` is a compile-time assertion that disappears; `#name` is a real + * runtime private that cannot be reached even by reflection. A security or + * reachability rule that treats them alike is wrong about one of them, so they + * never share a value. + * + * Schema §4.6 c10. + */ +export enum TsMethodAccess { + /** `public`, or a class member with no access modifier. */ + PUBLIC_ACCESS = 'PUBLIC_ACCESS', + + /** `private` — erased at emit; still present at runtime. */ + PRIVATE_ACCESS = 'PRIVATE_ACCESS', + + /** `protected` — erased at emit. */ + PROTECTED_ACCESS = 'PROTECTED_ACCESS', + + /** `#m()` — a HARD runtime private, unreachable from outside the class. */ + PRIVATE_NAME_ACCESS = 'PRIVATE_NAME_ACCESS', + + /** `export function f` — callable from an importing module. */ + EXPORTED_ACCESS = 'EXPORTED_ACCESS', + + /** A module-level function with no `export`. */ + MODULE_LOCAL_ACCESS = 'MODULE_LOCAL_ACCESS', +} diff --git a/parser/src/enums/typescript/methods/TsMethodKind.ts b/parser/src/enums/typescript/methods/TsMethodKind.ts new file mode 100644 index 000000000..c27dcbcf2 --- /dev/null +++ b/parser/src/enums/typescript/methods/TsMethodKind.ts @@ -0,0 +1,119 @@ +/** + * What kind of function-shaped declaration a `ts_method` row describes. + * + * Wider than Java's `MethodKind` because TypeScript has more shapes that are + * callable, and — the part that matters — because **every bodiless signature is a + * row here**. 44.3% of resolved call targets in the measured corpus are + * `MethodSignature`: an interface member with no body. A relation holding only + * implementations would be missing nearly half the call graph's leaves. + * + * ## Examples + * + * ```ts + * function f() { } // FUNCTION_DECLARATION + * class C { + * m() { } // METHOD_DECLARATION + * constructor() { } // CONSTRUCTOR + * get x() { return 1 } // GETTER + * set x(v: number) { } // SETTER + * static { } // CLASS_STATIC_BLOCK + * } + * const a = () => { }; // ARROW_FUNCTION + * const b = function () { }; // FUNCTION_EXPRESSION + * interface I { + * m(): void; // METHOD_SIGNATURE + * (x: number): string; // CALL_SIGNATURE + * new (x: number): I; // CONSTRUCT_SIGNATURE + * } + * type H = (e: Event) => void; // FUNCTION_TYPE_SIGNATURE + * type K = new (x: number) => I; // CONSTRUCTOR_TYPE_SIGNATURE + * const o = { m() { } }; // OBJECT_LITERAL_METHOD + * ``` + * + * ## Two members that exist for measured reasons + * + * **ARROW_FUNCTION.** 703 arrows in one corpus, 161 of them resolved call + * targets. An arrow has no name a call site could match, so it is reached only + * through the variable that binds it — which is why arrows are rows here rather + * than expression detail, and why `ts_variable.boundFunctionLinkHash` exists. + * + * **FUNCTION_TYPE_SIGNATURE.** `const f: (s: S) => string = (s) => s.id` resolves + * its calls to the ANNOTATION's signature, not to the arrow assigned to it. A + * parser offering only the arrow disagrees with `getResolvedSignature` on every + * such call, so a function type written in type position gets its own row. + * + * **MODULE_INITIALIZER** is synthetic: top-level executable statements need an + * owner, and inventing one lazily would make a file with no top-level code + * structurally different from one with it. + * + * Schema §4.6 c16. + */ +export enum TsMethodKind { + /** `function f() { }`. */ + FUNCTION_DECLARATION = 'FUNCTION_DECLARATION', + + /** A method on a class. */ + METHOD_DECLARATION = 'METHOD_DECLARATION', + + /** `constructor(…)`. Named ``. */ + CONSTRUCTOR = 'CONSTRUCTOR', + + /** `get x() { }`. */ + GETTER = 'GETTER', + + /** `set x(v) { }`. */ + SETTER = 'SETTER', + + /** `() => …`. Named ``; 161 measured call targets. */ + ARROW_FUNCTION = 'ARROW_FUNCTION', + + /** `function () { }` in an expression position. A NAMED one binds its own name inside its body. */ + FUNCTION_EXPRESSION = 'FUNCTION_EXPRESSION', + + /** `m(): void;` on an INTERFACE. Bodiless by construction; owned by a `ts_type`. */ + METHOD_SIGNATURE = 'METHOD_SIGNATURE', + + /** `(x: number): string;` on an interface. Named ``. */ + CALL_SIGNATURE = 'CALL_SIGNATURE', + + /** `new (x: number): I;` on an interface. Named ``. */ + CONSTRUCT_SIGNATURE = 'CONSTRUCT_SIGNATURE', + + /** + * `m(): void` inside an ANONYMOUS type literal — `{ m(): void }`. + * + * Owner-qualified because `tsTypeLinkHash` points at a `ts_type_reference` + * here: an anonymous shape has no declaration. 28 measured calls resolve to + * one of these, and 1,352 property accesses land on a type-literal member — + * the number that matters, because property-chain walking is how a receiver + * gets typed. + */ + TYPE_LITERAL_METHOD_SIGNATURE = 'TYPE_LITERAL_METHOD_SIGNATURE', + + /** `(x: T): R` inside an anonymous type literal. Owner is the shape. */ + TYPE_LITERAL_CALL_SIGNATURE = 'TYPE_LITERAL_CALL_SIGNATURE', + + /** `new (x: T): R` inside an anonymous type literal. Owner is the shape. */ + TYPE_LITERAL_CONSTRUCT_SIGNATURE = 'TYPE_LITERAL_CONSTRUCT_SIGNATURE', + + /** + * `(e: Event) => void` written in TYPE position. A real call target. + * + * Needs no owner-qualified twin: a function type node is the ONLY thing that + * can own one, so `tsTypeLinkHash` is always the node's own + * `ts_type_reference` row. + */ + FUNCTION_TYPE_SIGNATURE = 'FUNCTION_TYPE_SIGNATURE', + + /** `new (x: number) => I` written in TYPE position. Owned by its own type node. */ + CONSTRUCTOR_TYPE_SIGNATURE = 'CONSTRUCTOR_TYPE_SIGNATURE', + + /** `{ m() { } }` — callable, and reached through no other path. */ + OBJECT_LITERAL_METHOD = 'OBJECT_LITERAL_METHOD', + + /** `static { }` — runs once at class-definition time. Named ``. */ + CLASS_STATIC_BLOCK = 'CLASS_STATIC_BLOCK', + + /** Synthetic owner of top-level executable statements. Named ``. */ + MODULE_INITIALIZER = 'MODULE_INITIALIZER', +} diff --git a/parser/src/enums/typescript/methods/TsMethodModifier.ts b/parser/src/enums/typescript/methods/TsMethodModifier.ts new file mode 100644 index 000000000..0a9d2e4c2 --- /dev/null +++ b/parser/src/enums/typescript/methods/TsMethodModifier.ts @@ -0,0 +1,50 @@ +/** + * Modifiers on a function-shaped declaration. A comma-set column, sorted. + * + * ## Examples + * + * ```ts + * class C { + * static async *gen() { } // ASYNC,GENERATOR,STATIC + * abstract run(): void; // ABSTRACT + * override handle() { } // OVERRIDE + * optional?(): void; // OPTIONAL + * } + * export default function main() { } // DEFAULT_EXPORT,EXPORT + * declare function ambient(): void; // DECLARE + * ``` + * + * `OPTIONAL` and `ABSTRACT` both imply the declaration may have no body, which + * `ts_method.bodyPresence` records separately and more precisely — this column + * says what was written, that one says what it means for the call graph. + * + * Schema §4.6 c11. + */ +export enum TsMethodModifier { + /** `static`. */ + STATIC = 'STATIC', + + /** `abstract` — no body, and the subclass must supply one. */ + ABSTRACT = 'ABSTRACT', + + /** `async` — the declared return type is wrapped in a promise. */ + ASYNC = 'ASYNC', + + /** `function*` or `*m()` — returns a generator. */ + GENERATOR = 'GENERATOR', + + /** `declare` — asserts an existing function and emits nothing. */ + DECLARE = 'DECLARE', + + /** `override` — asserts a base member exists. */ + OVERRIDE = 'OVERRIDE', + + /** `m?()` — the member may be absent, which changes structural satisfaction. */ + OPTIONAL = 'OPTIONAL', + + /** `export`. */ + EXPORT = 'EXPORT', + + /** `export default`. */ + DEFAULT_EXPORT = 'DEFAULT_EXPORT', +} diff --git a/parser/src/enums/typescript/methods/TsSignatureRole.ts b/parser/src/enums/typescript/methods/TsSignatureRole.ts new file mode 100644 index 000000000..f5f316f53 --- /dev/null +++ b/parser/src/enums/typescript/methods/TsSignatureRole.ts @@ -0,0 +1,48 @@ +/** + * Overload identity — a first-class column, not a flag. + * + * ## The measurement that makes this necessary + * + * 11,599 overload signatures in one ecosystem corpus, and **77.6% of calls to a + * multi-declaration symbol resolve to a NON-FIRST declaration.** So a call site + * that points at a NAME is wrong four times in five, and picking the first + * declaration is worse than picking nothing. `ts_call_site` must be able to name + * ONE signature, which is why the primary key is per signature and why this + * column exists to say which of the set a row is. + * + * ## Worked example + * + * ```ts + * function connect(port: number): Socket; // OVERLOAD_SIGNATURE index 0 + * function connect(host: string, port: number): Socket; // OVERLOAD_SIGNATURE index 1 + * function connect(a: unknown, b?: unknown): Socket { // IMPLEMENTATION index 2 + * return open(a, b); + * } + * + * declare function ambient(x: number): void; // AMBIENT + * declare function ambient(x: string): void; // AMBIENT + * + * function sole() { } // SOLE + * interface I { m(): void } // SOLE — no set to be one of + * ``` + * + * The IMPLEMENTATION is the only member of the set that is callable at runtime, + * and it is NOT the signature a call resolves to — tsc resolves to one of the + * overload signatures. An engine needs both facts, which is why the role and + * `bodyPresence` are separate columns. + * + * Schema §4.6 c25. + */ +export enum TsSignatureRole { + /** The only declaration of its name in its table. */ + SOLE = 'SOLE', + + /** One of N bodiless declarations preceding an implementation. */ + OVERLOAD_SIGNATURE = 'OVERLOAD_SIGNATURE', + + /** The declaration carrying the body for an overload set. */ + IMPLEMENTATION = 'IMPLEMENTATION', + + /** Bodiless with no implementation anywhere in source — `declare function`. */ + AMBIENT = 'AMBIENT', +} diff --git a/parser/src/enums/typescript/methods/index.ts b/parser/src/enums/typescript/methods/index.ts new file mode 100644 index 000000000..a782b8d73 --- /dev/null +++ b/parser/src/enums/typescript/methods/index.ts @@ -0,0 +1,5 @@ +export { TsBodyPresence } from '@/enums/typescript/methods/TsBodyPresence'; +export { TsMethodAccess } from '@/enums/typescript/methods/TsMethodAccess'; +export { TsMethodKind } from '@/enums/typescript/methods/TsMethodKind'; +export { TsMethodModifier } from '@/enums/typescript/methods/TsMethodModifier'; +export { TsSignatureRole } from '@/enums/typescript/methods/TsSignatureRole'; diff --git a/parser/src/enums/typescript/modules/TsEmissionRegime.ts b/parser/src/enums/typescript/modules/TsEmissionRegime.ts new file mode 100644 index 000000000..4b07ebab6 --- /dev/null +++ b/parser/src/enums/typescript/modules/TsEmissionRegime.ts @@ -0,0 +1,23 @@ +/** + * The analysis regime, stamped into `ts_module`'s PRIMARY KEY. + * + * ## Why this is in a key and `targetTsVersion` is not + * + * Both answer "which compiler", and they are deliberately separate. The module + * hash chains into every child key in the fact base, so putting a patch version + * there would invalidate every row on a `6.0.3` → `6.0.4` bump — for a change + * that alters nothing about the facts. `targetTsVersion` carries the exact + * version as non-key provenance instead. + * + * What must be distinguishable from INSIDE the fact base is the regime: a 6.x + * in-process parse and a future 7.x out-of-process one produce structurally + * different fact sets, and a database holding both must not let them collide. + * TypeScript 7 ships no in-process JS parser at all — no `ts.createSourceFile`, + * no `TypeChecker` — so that day is a change of regime, not of version. + * + * Schema §4.1 c16, ruling OQ-4. + */ +export enum TsEmissionRegime { + /** `ts.createSourceFile` from an in-process `typescript@6.x`. */ + TS6_INPROC = 'ts6-inproc', +} diff --git a/parser/src/enums/typescript/modules/TsMergeScopePrefix.ts b/parser/src/enums/typescript/modules/TsMergeScopePrefix.ts new file mode 100644 index 000000000..c551ed169 --- /dev/null +++ b/parser/src/enums/typescript/modules/TsMergeScopePrefix.ts @@ -0,0 +1,87 @@ +/** + * The symbol-table prefixes that make declaration merging computable. + * + * ## The problem this solves + * + * `name -> single entity` is FALSE in TypeScript. 1,986 symbols in the measured + * corpus have more than one declaration and one has 43; merging crosses + * declaration kinds (class+interface, function+namespace) and crosses files. + * So a primary key cannot be a name, and the merged entity needs an identity of + * its own. + * + * TypeScript's own rule is that a symbol IS a `(symbol table, escaped name)` + * pair and merging IS "same table, same name". These prefixes name the table + * syntactically, so `declarationGroupKey = md5(mergeScopeKey ‖ escapedName)` + * reproduces the binder's answer by construction rather than by approximation. + * + * ## Every member except GLOBAL is a PREFIX + * + * They are followed by `:` and a hash. `GLOBAL` is a whole value: there is only + * one global scope, which is exactly why two scripts' declarations merge. + * + * ## Worked example + * + * ```ts + * // a.ts — a module (it exports) + * export interface Shape { area(): number } // MODULE_EXPORTS: + * interface Hidden { x: number } // MODULE_LOCALS: + * + * // b.ts + * declare module "./a" { + * interface Shape { extra: boolean } // MODULE_EXPORTS: ← merges + * } + * namespace Geometry { + * export type Path = Point[]; // NS: + * type Internal = number; // LOCALS: + * } + * function outer() { + * interface Local { y: number } // LOCALS: + * } + * ``` + * + * Two facts fall out of that example and both were measured: + * + * - `Shape` in `a.ts` and `Shape` inside the augmentation share a table, so they + * are ONE type. That is why the augmentation's key uses the RESOLVED target + * module, and why `ts.resolveModuleName` being parser-legal matters to the KEY + * and not only to `ts_import`. + * - Exported and non-exported declarations of one name are TWO symbols. tsc + * rejects mixing them (TS2395), which is the evidence that the tables really + * are separate — and the reason each scope carries two keys, not one. + * + * Schema §3.1, §4.2 c16. + */ +export enum TsMergeScopePrefix { + /** Exported from a module file — the module symbol's `exports` table. */ + MODULE_EXPORTS = 'MODULE_EXPORTS', + + /** Declared but not exported — the source file's `locals` table. */ + MODULE_LOCALS = 'MODULE_LOCALS', + + /** + * A global script, `declare global`, or an ambient module declaration. + * + * A whole value with no suffix, unlike every other member. + */ + GLOBAL = 'GLOBAL', + + /** + * Exported from a namespace, keyed by the NAMESPACE's own group key. + * + * Keyed on the group rather than the declaration so a member survives its + * namespace merging with a class, a function or an enum of the same name. + */ + NS = 'NS', + + /** + * Inside a function body or a block, keyed by the BINDER SCOPE. + * + * The schema writes this as `LOCALS:`; the parser keys it on + * the scope instead. Keying on the method merges two same-named block-scoped + * declarations inside one function into a single symbol, which tsc does not + * do — so the coarser key fails the very partition gate the formula exists to + * pass. The scope hash derives from the module hash and the scope node's byte + * range, so it is stable across runs and independent of traversal order. + */ + LOCALS = 'LOCALS', +} diff --git a/parser/src/enums/typescript/modules/TsModuleKind.ts b/parser/src/enums/typescript/modules/TsModuleKind.ts new file mode 100644 index 000000000..a5bdec0b1 --- /dev/null +++ b/parser/src/enums/typescript/modules/TsModuleKind.ts @@ -0,0 +1,82 @@ +/** + * What kind of module a `ts_module` row describes. + * + * TypeScript needs this enum where Java needs none, because a "module" here is + * three things at once: the unit of import resolution, the symbol MERGE TABLE + * that declaration merging keys off, and the boundary between module scope and + * global scope. Java's package is implicit in a qualified name and carries none + * of those roles. + * + * ## The distinction that matters most + * + * `SOURCE_MODULE` versus `SCRIPT_GLOBAL` is decided by a single question — does + * the file have a top-level `import` or `export`? — and the answer changes which + * table every declaration in the file lands in. A script's declarations go to + * `GLOBAL` and merge with every other script's; a module's go to that module's + * own table and merge with nothing outside it. + * + * ## Examples + * + * ```ts + * // views.ts — has a top-level import → SOURCE_MODULE + * import { User } from "./user"; + * export interface View { user: User } + * + * // globals.ts — no import, no export → SCRIPT_GLOBAL + * interface AppConfig { readonly name: string } + * + * // api.d.ts → DECLARATION_FILE + * export declare function get(url: string): Promise; + * + * // inside any file: + * declare module "untyped-legacy-pkg" { } → AMBIENT_MODULE_DECLARATION + * declare module "./augmented-base" { } → MODULE_AUGMENTATION + * declare global { } → GLOBAL_AUGMENTATION + * ``` + * + * **What gets extracted** from one file holding two `declare module` blocks and a + * `declare global`: **four** `ts_module` rows — the file itself plus one per + * block. That is why `declaredSpecifier` and `startLine` are both in the primary + * key: 173 ambient module declarations were measured across a corpus, several + * files holding more than one. + * + * Schema §4.1 c5. + */ +export enum TsModuleKind { + /** A `.ts`/`.mts`/`.cts` file with a top-level `import` or `export`. */ + SOURCE_MODULE = 'SOURCE_MODULE', + + /** + * A file with NO top-level import or export. + * + * Its declarations land in GLOBAL scope, which is what lets two such files + * declare halves of one interface and have them merge. + */ + SCRIPT_GLOBAL = 'SCRIPT_GLOBAL', + + /** A `.d.ts` file: declarations with no bodies. */ + DECLARATION_FILE = 'DECLARATION_FILE', + + /** + * `declare module "some-package" { … }` with a non-relative specifier. + * + * An independently importable namespace, and a merge scope of its own. Two + * files declaring `declare module "*.svg"` declare ONE symbol. + */ + AMBIENT_MODULE_DECLARATION = 'AMBIENT_MODULE_DECLARATION', + + /** + * `declare module "./local-file" { … }` with a relative specifier. + * + * Reopens an EXISTING module and adds to it — how every plugin ecosystem in + * TypeScript extends its host. The declarations inside belong to the TARGET + * module's table, not to the declaring file's. + */ + MODULE_AUGMENTATION = 'MODULE_AUGMENTATION', + + /** `declare global { … }`: a module contributing to the global scope. */ + GLOBAL_AUGMENTATION = 'GLOBAL_AUGMENTATION', + + /** A `.json` file imported under `resolveJsonModule`. */ + JSON_MODULE = 'JSON_MODULE', +} diff --git a/parser/src/enums/typescript/modules/TsModuleResolutionMode.ts b/parser/src/enums/typescript/modules/TsModuleResolutionMode.ts new file mode 100644 index 000000000..03d90e88a --- /dev/null +++ b/parser/src/enums/typescript/modules/TsModuleResolutionMode.ts @@ -0,0 +1,30 @@ +/** + * How specifiers resolve, read from the tsconfig that GOVERNS the file. + * + * Never a run-wide constant. A repository is not one program: two directories + * can declare different modes, and the same specifier legitimately resolves + * differently under each. `./a.js` means "the file a.js" under `NODE10` and "the + * emit of a.ts" under `NODE16`, so a fact base that flattens the modes cannot + * explain its own `ts_import.resolvedFilePath` values. + * + * Paired with `ts_import.resolutionKind`: that column says WHERE a specifier + * landed, and this one says under which rules. + * + * Schema §4.1 c13. + */ +export enum TsModuleResolutionMode { + /** `node16` — extension-bearing relative specifiers, `exports` honoured. */ + NODE16 = 'NODE16', + + /** `nodenext` — as `node16`, tracking Node's current behaviour. */ + NODENEXT = 'NODENEXT', + + /** `bundler` — extensionless specifiers, `exports` honoured. */ + BUNDLER = 'BUNDLER', + + /** `node`/`node10` — the classic CommonJS algorithm. */ + NODE10 = 'NODE10', + + /** `classic` — pre-Node resolution; almost never seen in current code. */ + CLASSIC = 'CLASSIC', +} diff --git a/parser/src/enums/typescript/modules/TsScriptKind.ts b/parser/src/enums/typescript/modules/TsScriptKind.ts new file mode 100644 index 000000000..5959489fa --- /dev/null +++ b/parser/src/enums/typescript/modules/TsScriptKind.ts @@ -0,0 +1,33 @@ +/** + * `ts.ScriptKind` — how the file is PARSED, not merely what it is named. + * + * Load-bearing for exactly one reason: it decides whether `<` opens a JSX + * element or a type assertion. `x` is a cast in `.ts` and a JSX element in + * `.tsx`, so the same bytes produce different trees and the kind cannot be + * guessed from content. + * + * `.mts` and `.cts` are parsed as `TS`; they differ in module RESOLUTION, which + * `ts_module.moduleResolutionMode` records separately. Only `.tsx` changes the + * grammar. + * + * Schema §4.1 c6. + */ +export enum TsScriptKind { + /** `.ts` — `x` is a type assertion. */ + TS = 'TS', + + /** `.tsx` — `` opens JSX, so a type assertion must be written `x as T`. */ + TSX = 'TSX', + + /** `.d.ts` — declarations only. */ + DTS = 'DTS', + + /** `.mts` — parsed as TS, resolved as ESM. */ + MTS = 'MTS', + + /** `.cts` — parsed as TS, resolved as CommonJS. */ + CTS = 'CTS', + + /** `.json` under `resolveJsonModule`. */ + JSON = 'JSON', +} diff --git a/parser/src/enums/typescript/modules/index.ts b/parser/src/enums/typescript/modules/index.ts new file mode 100644 index 000000000..12d894ec7 --- /dev/null +++ b/parser/src/enums/typescript/modules/index.ts @@ -0,0 +1,5 @@ +export { TsEmissionRegime } from '@/enums/typescript/modules/TsEmissionRegime'; +export { TsMergeScopePrefix } from '@/enums/typescript/modules/TsMergeScopePrefix'; +export { TsModuleKind } from '@/enums/typescript/modules/TsModuleKind'; +export { TsModuleResolutionMode } from '@/enums/typescript/modules/TsModuleResolutionMode'; +export { TsScriptKind } from '@/enums/typescript/modules/TsScriptKind'; diff --git a/parser/src/enums/typescript/parse-gaps/TsParseGapKind.ts b/parser/src/enums/typescript/parse-gaps/TsParseGapKind.ts new file mode 100644 index 000000000..cd5a48002 --- /dev/null +++ b/parser/src/enums/typescript/parse-gaps/TsParseGapKind.ts @@ -0,0 +1,35 @@ +/** + * Why a construct could not be represented. + * + * ## An always-empty relation that must exist anyway + * + * Measured **zero** rows over 25.9 MB of real TypeScript with + * `ts.createSourceFile` — and that is precisely the argument for emitting it. An + * always-empty relation that suddenly has rows is a SIGNAL. A missing relation + * is a silence, and the difference matters on the day a file starts failing to + * parse: one shows up as data, the other as slightly fewer facts than yesterday. + * + * The relation RECORDS the gap and never rewrites source. Positions stay + * measured, so a consumer can go and look. + * + * `FILE_TOO_LARGE` exists for provenance rather than for use here: it is the + * tree-sitter 32,767-character ceiling that the compiler's own parser does not + * have, and 4.7% of real TypeScript files exceed it. Keeping the member means a + * future move to tree-sitter reports the truncation rather than losing it. + * + * Schema §4.20 c0. + */ +export enum TsParseGapKind { + /** The parser produced a diagnostic. `diagnosticCode` carries its number. */ + PARSE_DIAGNOSTIC = 'PARSE_DIAGNOSTIC', + /** Syntax the extractor has no representation for. */ + UNSUPPORTED_SYNTAX = 'UNSUPPORTED_SYNTAX', + /** Beyond the parse-buffer ceiling. Never fires under `ts.createSourceFile`. */ + FILE_TOO_LARGE = 'FILE_TOO_LARGE', + /** The file could not be decoded as UTF-8. */ + ENCODING_ERROR = 'ENCODING_ERROR', + /** The file could not be read. */ + READ_ERROR = 'READ_ERROR', + /** Claimed by no tsconfig, so no program governs it. */ + EXCLUDED_BY_CONFIG = 'EXCLUDED_BY_CONFIG', +} diff --git a/parser/src/enums/typescript/parse-gaps/index.ts b/parser/src/enums/typescript/parse-gaps/index.ts new file mode 100644 index 000000000..9f5ec5059 --- /dev/null +++ b/parser/src/enums/typescript/parse-gaps/index.ts @@ -0,0 +1 @@ +export { TsParseGapKind } from '@/enums/typescript/parse-gaps/TsParseGapKind'; diff --git a/parser/src/enums/typescript/type-parameters/TsTypeParameterOwnerKind.ts b/parser/src/enums/typescript/type-parameters/TsTypeParameterOwnerKind.ts new file mode 100644 index 000000000..ce9aedddb --- /dev/null +++ b/parser/src/enums/typescript/type-parameters/TsTypeParameterOwnerKind.ts @@ -0,0 +1,67 @@ +/** + * What declares a type parameter. + * + * ## One relation where Java has two, and why + * + * Java splits `java_type_parameter` from `java_method_type_parameter` because + * types and methods are the only two owners it has. TypeScript attaches type + * parameters to **nine** kinds of declaration, so N relations would multiply + * without adding information. `ownerKind` plus the polymorphic `ownerLinkHash` + * carries it instead. + * + * This costs the `method_type_parameter` projection a rename plus a filter on + * this column — `ownerKind IN (FUNCTION, METHOD, ARROW, CALL_SIGNATURE, + * CONSTRUCT_SIGNATURE)` — and that is the whole cost. It is a deliberate + * divergence from Java, recorded here so the projection author does not go + * looking for a second relation. + * + * ## Bounds are still separated, because they answer different questions + * + * `ts_type_reference.context` distinguishes `TYPE_PARAM_BOUND` (a class, + * interface or type-alias parameter) from `METHOD_TYPE_PARAM_BOUND` (a function + * or method parameter), exactly as Java does — so the Java query "every bound on + * a method type parameter" still has a direct translation even though the + * declaration rows share one relation. + * + * ## Examples + * + * ```ts + * class Box { } // CLASS + * interface Repo { } // INTERFACE + * type Pair = [A, B]; // TYPE_ALIAS + * function map(x: T): U { } // FUNCTION + * class C { m(x: T) { } } // METHOD + * const f = (x: T) => x; // ARROW + * interface I { (x: T): T } // CALL_SIGNATURE + * interface J { new (x: T): J } // CONSTRUCT_SIGNATURE + * type Keys = { [K in keyof T]: T[K] } // MAPPED_TYPE — K is declared by the mapping + * type El = T extends (infer U)[] ? U : never // INFER_TYPE — U is declared by `infer` + * ``` + * + * The last two are why this cannot be a two-member enum: `K` and `U` are real + * type parameters with real scopes, declared by constructs Java does not have. + * + * Schema §4.4 c7. + */ +export enum TsTypeParameterOwnerKind { + /** `class Box`. */ + CLASS = 'CLASS', + /** `interface Repo`. */ + INTERFACE = 'INTERFACE', + /** `type Pair`. */ + TYPE_ALIAS = 'TYPE_ALIAS', + /** `function map()`. */ + FUNCTION = 'FUNCTION', + /** A method, constructor or accessor on a class. */ + METHOD = 'METHOD', + /** `(x: T) => x`. */ + ARROW = 'ARROW', + /** `(x: T): T` on an interface or type literal. */ + CALL_SIGNATURE = 'CALL_SIGNATURE', + /** `new (x: T): I`. */ + CONSTRUCT_SIGNATURE = 'CONSTRUCT_SIGNATURE', + /** `[K in keyof T]` — the mapping declares `K`. */ + MAPPED_TYPE = 'MAPPED_TYPE', + /** `infer U` — the conditional declares `U`. 1,337 measured. */ + INFER_TYPE = 'INFER_TYPE', +} diff --git a/parser/src/enums/typescript/type-parameters/TsVarianceAnnotation.ts b/parser/src/enums/typescript/type-parameters/TsVarianceAnnotation.ts new file mode 100644 index 000000000..592f1c608 --- /dev/null +++ b/parser/src/enums/typescript/type-parameters/TsVarianceAnnotation.ts @@ -0,0 +1,31 @@ +/** + * DECLARATION-SITE variance on a type parameter. TypeScript 4.7. + * + * **562 measured** in one ecosystem corpus, so this is not hypothetical syntax. + * + * Java's `WildcardVariance` is USE-site — `List` annotates the + * reference. This is declaration-site: the parameter itself declares how it may + * vary, and every reference inherits it. The two are not interchangeable, which + * is why they are separate columns on separate relations rather than one shared + * value. + * + * ```ts + * interface Producer { get(): T } // OUT — covariant + * interface Consumer { put(x: T): void } // IN — contravariant + * interface Both { swap(x: T): T } // IN_OUT — invariant + * interface Plain { } // "" — inferred by the checker + * ``` + * + * An absent annotation is `""`, not a guess: the checker infers variance + * structurally, and the parser records only what was written. + * + * Schema §4.4 c13. + */ +export enum TsVarianceAnnotation { + /** `in T` — contravariant; the parameter appears only in input positions. */ + IN = 'IN', + /** `out T` — covariant; the parameter appears only in output positions. */ + OUT = 'OUT', + /** `in out T` — invariant; asserted explicitly. */ + IN_OUT = 'IN_OUT', +} diff --git a/parser/src/enums/typescript/type-parameters/index.ts b/parser/src/enums/typescript/type-parameters/index.ts new file mode 100644 index 000000000..4ef571e2d --- /dev/null +++ b/parser/src/enums/typescript/type-parameters/index.ts @@ -0,0 +1,2 @@ +export { TsTypeParameterOwnerKind } from '@/enums/typescript/type-parameters/TsTypeParameterOwnerKind'; +export { TsVarianceAnnotation } from '@/enums/typescript/type-parameters/TsVarianceAnnotation'; diff --git a/parser/src/enums/typescript/type-references/TsReferenceOwnerKind.ts b/parser/src/enums/typescript/type-references/TsReferenceOwnerKind.ts new file mode 100644 index 000000000..e6d93b662 --- /dev/null +++ b/parser/src/enums/typescript/type-references/TsReferenceOwnerKind.ts @@ -0,0 +1,38 @@ +/** + * What `ts_type_reference.typeReferenceOwnerHash` points at. + * + * The owner FK is polymorphic — a type reference can be owned by a field, a + * parameter, a heritage clause, an expression or another type reference — so a + * consumer needs this column to know which relation to join against. Java's + * `ReferenceOwnerKind` carries the same discriminator for the same reason. + * + * Schema §4.5 c16. + */ +export enum TsReferenceOwnerKind { + /** A type declaration: a type alias RHS, a class's type parameter bound. */ + TYPE = 'TYPE', + /** A function-shaped declaration: its return type. */ + METHOD = 'METHOD', + /** A formal parameter's annotation, including an arrow's. */ + METHOD_PARAM = 'METHOD_PARAM', + /** A member's annotation. */ + FIELD = 'FIELD', + /** A variable's annotation. */ + VARIABLE = 'VARIABLE', + /** A type parameter's bound or default. */ + TYPE_PARAMETER = 'TYPE_PARAMETER', + /** An expression: `as T`, `instanceof C`, `new C()`, `f()`. */ + EXPRESSION = 'EXPRESSION', + /** A decorator: the type it names, or a type in its arguments. */ + DECORATOR = 'DECORATOR', + /** A heritage clause entry — the twin that feeds the shared name resolver. */ + HERITAGE = 'HERITAGE', + /** Another type reference — a child in a composite type node. */ + TYPE_REFERENCE = 'TYPE_REFERENCE', + /** An enum member. */ + ENUM_MEMBER = 'ENUM_MEMBER', + /** An export clause. */ + EXPORT = 'EXPORT', + /** The module itself. */ + MODULE = 'MODULE', +} diff --git a/parser/src/enums/typescript/type-references/TsTypeRefContext.ts b/parser/src/enums/typescript/type-references/TsTypeRefContext.ts new file mode 100644 index 000000000..cf05ab14e --- /dev/null +++ b/parser/src/enums/typescript/type-references/TsTypeRefContext.ts @@ -0,0 +1,187 @@ +/** + * WHERE a type reference appears in TypeScript source. + * + * Mirrors Java's `TypeRefContext` member for member wherever the languages agree, + * so the shared name-to-type projection ports as a rename. Members are added + * only where TypeScript has a construct Java lacks, and Java members are dropped + * only where TypeScript has no such construct (`PERMITS`, `THROWS_CLAUSE`, + * `ARRAY_CREATION_TYPE`, `RECORD_PATTERN_TYPE`, `SWITCH_TYPE_PATTERN`, + * `METHOD_REFERENCE_QUALIFIER` — none of which exists here). + * + * ## This column is what makes the type graph queryable by ROLE + * + * "Every type used as a method return", "every type in an `instanceof` guard", + * "every bound on a method type parameter". Without it, a `List` in a + * return position and one in a cast are indistinguishable rows. + * + * ## Comprehensive example + * + * ```ts + * @Injectable() // DECORATOR_TYPE: Injectable + * export class UserService // TYPE_PARAM_BOUND: BaseEntity, Auditable + * extends AbstractService // SUPER_TYPE: AbstractService, TYPE_ARGUMENT: T + * implements CrudService { // IMPLEMENTS_INTERFACE: CrudService (asserts only) + * + * private repository: Repository; // FIELD_TYPE: Repository, TYPE_ARGUMENT: T + * [key: string]: unknown; // INDEX_SIGNATURE_KEY: string + * // INDEX_SIGNATURE_VALUE: unknown + * + * findById( // METHOD_TYPE_PARAM_BOUND: string + * id: K, // METHOD_PARAM: K + * ): Optional { // METHOD_RETURN: Optional, TYPE_ARGUMENT: T + * const cached = cache.get(id) as T; // AS_TARGET: T + * const legacy = cache.get(id); // TYPE_ASSERTION: T + * const checked = cfg satisfies Config; // SATISFIES_TARGET: Config + * if (cached instanceof Widget) { } // INSTANCEOF_TYPE: Widget + * const made = new Repository(); // OBJECT_CREATION_TYPE: Repository + * // TYPE_ARGUMENT: T + * const empty = makeList(); // METHOD_TYPE_ARGUMENT: string + * let local: Map; // VARIABLE_TYPE: Map, TYPE_ARGUMENT: string, T + * return none(); + * } + * + * isWidget(x: unknown): x is Widget { } // TYPE_PREDICATE_TARGET: Widget + * } + * + * type Handler = (e: Event) => void; // TYPE_ALIAS_RHS: the function type + * // METHOD_PARAM: Event, METHOD_RETURN: void + * type Keys = { [K in keyof T]: T[K] }; // MAPPED_CONSTRAINT: keyof T + * type Elem = T extends (infer U)[] ? U : never; // CONDITIONAL_CHECK: T + * // CONDITIONAL_EXTENDS: (infer U)[] + * // CONDITIONAL_TRUE: U + * // CONDITIONAL_FALSE: never + * type Route = `/${string}`; // TEMPLATE_SPAN: string + * type Lib = import("pkg").Thing; // IMPORT_TYPE_QUALIFIER: Thing + * ``` + * + * ## Contexts that appear only in VALUE positions + * + * `OBJECT_CREATION_TYPE`, `INSTANCEOF_TYPE`, `METHOD_TYPE_ARGUMENT`, + * `DECORATOR_TYPE` and `DECORATOR_ARGUMENT_TYPE` name types written inside + * expressions. They are the only contexts for which `isTypeOnlyPosition` is + * `false`: the name is evaluated at runtime, so it is a real value reference as + * well as a type reference. Every other context is type-only, which is the + * structural half of the guarantee that type-level constructs never reach the + * call graph. + * + * Schema §4.5 c1. + */ +export enum TsTypeRefContext { + // -- declaration positions ------------------------------------------------- + + /** A bound on a CLASS, INTERFACE or TYPE ALIAS type parameter: `class Box`. */ + TYPE_PARAM_BOUND = 'TYPE_PARAM_BOUND', + + /** A bound on a METHOD or FUNCTION type parameter: `function f(x: T)`. */ + METHOD_TYPE_PARAM_BOUND = 'METHOD_TYPE_PARAM_BOUND', + + /** The default of a type parameter: `class Box`. */ + TYPE_PARAM_DEFAULT = 'TYPE_PARAM_DEFAULT', + + /** A supertype in an `extends` clause. Inherits members. */ + SUPER_TYPE = 'SUPER_TYPE', + + /** + * A name written in an `implements` clause. + * + * Records that someone wrote it down, and NOTHING about subtyping — 60.4% of + * classes satisfy their interfaces with no such clause. Java's member of the + * same name is authoritative; this one is not. + */ + IMPLEMENTS_INTERFACE = 'IMPLEMENTS_INTERFACE', + + /** The declared type of a class property or interface property signature. */ + FIELD_TYPE = 'FIELD_TYPE', + + /** The declared return type of a function-shaped declaration. */ + METHOD_RETURN = 'METHOD_RETURN', + + /** The declared type of a formal parameter, including an arrow's. */ + METHOD_PARAM = 'METHOD_PARAM', + + /** The declared type of a variable. */ + VARIABLE_TYPE = 'VARIABLE_TYPE', + + /** The right-hand side of a type alias. */ + TYPE_ALIAS_RHS = 'TYPE_ALIAS_RHS', + + /** The key type of an index signature: `string` in `[k: string]: T`. */ + INDEX_SIGNATURE_KEY = 'INDEX_SIGNATURE_KEY', + + /** The value type of an index signature. */ + INDEX_SIGNATURE_VALUE = 'INDEX_SIGNATURE_VALUE', + + /** The declared type of an enum member. */ + ENUM_MEMBER_TYPE = 'ENUM_MEMBER_TYPE', + + /** The twin reference minted for every `ts_type_heritage` row. */ + HERITAGE_TWIN = 'HERITAGE_TWIN', + + // -- expression positions: evaluated at runtime --------------------------- + + /** The target of `x as T`. */ + AS_TARGET = 'AS_TARGET', + + /** The target of `x satisfies T` — checks without widening. */ + SATISFIES_TARGET = 'SATISFIES_TARGET', + + /** The target of the older cast form `x`. Java's `CAST_EXPRESSION`. */ + TYPE_ASSERTION = 'TYPE_ASSERTION', + + /** The type named by `new Foo()`. Java's `OBJECT_CREATION_TYPE`. */ + OBJECT_CREATION_TYPE = 'OBJECT_CREATION_TYPE', + + /** The right operand of `x instanceof Foo` — the main narrowing lever. */ + INSTANCEOF_TYPE = 'INSTANCEOF_TYPE', + + /** An explicit type argument at a CALL or NEW site: `makeList()`. */ + METHOD_TYPE_ARGUMENT = 'METHOD_TYPE_ARGUMENT', + + /** The type a decorator names: `Injectable` in `@Injectable()`. */ + DECORATOR_TYPE = 'DECORATOR_TYPE', + + /** A type named inside a decorator argument — where DI tokens live. */ + DECORATOR_ARGUMENT_TYPE = 'DECORATOR_ARGUMENT_TYPE', + + // -- type-level constructs, which exist in no other relation -------------- + + /** The asserted type of a type predicate: `Widget` in `x is Widget`. */ + TYPE_PREDICATE_TARGET = 'TYPE_PREDICATE_TARGET', + + /** A type argument in a TYPE position: `string` in `Map`. */ + TYPE_ARGUMENT = 'TYPE_ARGUMENT', + + /** The constraint of a mapped type: `keyof T` in `{ [K in keyof T]: … }`. */ + MAPPED_CONSTRAINT = 'MAPPED_CONSTRAINT', + + /** The `as` clause of a mapped type — key remapping. */ + MAPPED_TEMPLATE = 'MAPPED_TEMPLATE', + + /** The checked type of a conditional: `T` in `T extends U ? A : B`. */ + CONDITIONAL_CHECK = 'CONDITIONAL_CHECK', + + /** The compared type of a conditional. */ + CONDITIONAL_EXTENDS = 'CONDITIONAL_EXTENDS', + + /** The true branch of a conditional. */ + CONDITIONAL_TRUE = 'CONDITIONAL_TRUE', + + /** The false branch of a conditional. */ + CONDITIONAL_FALSE = 'CONDITIONAL_FALSE', + + /** An interpolated type in a template literal type. */ + TEMPLATE_SPAN = 'TEMPLATE_SPAN', + + /** The qualifier of an import type: `Thing` in `import("pkg").Thing`. */ + IMPORT_TYPE_QUALIFIER = 'IMPORT_TYPE_QUALIFIER', + + /** + * A member of a composite type node. + * + * A union or intersection member, an array element, a tuple element, an + * indexed-access operand, a `keyof` operand, a type-literal member. Composite + * nodes are N ROWS with a parent FK, never one row with a list — the maximum + * union arity measured is 208. + */ + TYPE_ELEMENT = 'TYPE_ELEMENT', +} diff --git a/parser/src/enums/typescript/type-references/TsTypeRefKind.ts b/parser/src/enums/typescript/type-references/TsTypeRefKind.ts new file mode 100644 index 000000000..81e236f08 --- /dev/null +++ b/parser/src/enums/typescript/type-references/TsTypeRefKind.ts @@ -0,0 +1,149 @@ +/** + * The SHAPE of a type node. + * + * Positions 0–16 of `ts_type_reference` mirror `java_type_reference`, and this + * enum starts from Java's `TypeRefKind` — but TypeScript's type system is where + * the two languages diverge most, and most of these members have no Java + * analogue at all. + * + * ## The measurement that shapes this enum + * + * In one ecosystem corpus: conditional types **2,174**, mapped **605**, + * template-literal **161**, `infer` **1,337**, indexed-access **2,354**, + * `keyof`/`readonly`/`unique` **3,030**, `typeof` **775**. That is 10,436 nodes + * with no Java or Python counterpart. They live in this relation and in no + * other, which is the structural guarantee that a conditional type can never + * reach the call graph — there is simply no relation for it to be in. + * + * ## A union is N ROWS, never one row with a list + * + * `A | B | C` is one `UNION` row with `childCount = 3` plus three child rows + * carrying `position` and `parentReferenceHash`. Measured union arity: p50 2, + * p90 3, p99 7, **max 208**, with 45 nodes containing a nested union. A + * comma-set column fails all three ways. + * + * `position` is SOURCE order. The checker normalises `boolean` into + * `true | false` and reorders by type id, so `string | number | boolean` has + * source order `[string, number, boolean]` and checker order + * `[string, number, false, true]` — comparing member-wise against the checker + * fails on correct output. + * + * ## Examples + * + * ```ts + * User // TYPE_REFERENCE + * string // PRIMITIVE + * "active" // LITERAL literalValue = "active" + * T // TYPE_VARIABLE (T is in scope as a type parameter) + * User[] // ARRAY → TYPE_ELEMENT: User + * [string, number] // TUPLE → 2 TYPE_ELEMENT children + * A | B // UNION → 2 TYPE_ELEMENT children + * A & B // INTERSECTION + * (e: Event) => void // FUNCTION_TYPE → METHOD_PARAM, METHOD_RETURN + * new () => C // CONSTRUCTOR_TYPE + * { a: string } // TYPE_LITERAL → TYPE_ELEMENT per member + * T extends U ? A : B // CONDITIONAL → 4 children + * { [K in keyof T]: T[K] } // MAPPED + * `/${string}` // TEMPLATE_LITERAL + * T["key"] // INDEXED_ACCESS → 2 TYPE_ELEMENT children + * typeof config // TYPE_QUERY + * keyof T // TYPE_OPERATOR + * readonly T[] // TYPE_OPERATOR wildcardVariance = READONLY + * unique symbol // TYPE_OPERATOR wildcardVariance = UNIQUE + * infer U // INFER + * x is Widget // TYPE_PREDICATE + * import("pkg").Thing // IMPORT_TYPE + * this // THIS_TYPE + * (A | B) // PARENTHESIZED + * [...T[]] // REST + * [a?: string] // OPTIONAL + * [name: string] // NAMED_TUPLE_MEMBER + * intrinsic // INTRINSIC + * ``` + * + * Schema §4.5 c0, §3.3, §3.4. + */ +export enum TsTypeRefKind { + /** A named type: `User`, `ns.Thing`, `Map`. */ + TYPE_REFERENCE = 'TYPE_REFERENCE', + + /** A keyword type: `string`, `number`, `unknown`, `never`, `void`, `null`. */ + PRIMITIVE = 'PRIMITIVE', + + /** A literal type: `"active"`, `42`, `true`. 12,706 measured. */ + LITERAL = 'LITERAL', + + /** `T[]`. The element hangs off as a `TYPE_ELEMENT` child. */ + ARRAY = 'ARRAY', + + /** `[string, number]`. */ + TUPLE = 'TUPLE', + + /** `A | B`. N child rows, never a list. Max arity measured: 208. */ + UNION = 'UNION', + + /** `A & B`. */ + INTERSECTION = 'INTERSECTION', + + /** `(e: Event) => void`. Also gets a `ts_method` row: it is a real call target. */ + FUNCTION_TYPE = 'FUNCTION_TYPE', + + /** `new () => C`. */ + CONSTRUCTOR_TYPE = 'CONSTRUCTOR_TYPE', + + /** `{ a: string }` — an anonymous shape. 5,015 measured; no `ts_type` row. */ + TYPE_LITERAL = 'TYPE_LITERAL', + + /** `T extends U ? A : B`. 2,174 measured. */ + CONDITIONAL = 'CONDITIONAL', + + /** `{ [K in keyof T]: T[K] }`. 605 measured. */ + MAPPED = 'MAPPED', + + /** `` `/${string}` ``. 161 measured. */ + TEMPLATE_LITERAL = 'TEMPLATE_LITERAL', + + /** `T["key"]`. 2,354 measured. */ + INDEXED_ACCESS = 'INDEXED_ACCESS', + + /** `typeof config` in TYPE position — not the `typeof` operator. 775 measured. */ + TYPE_QUERY = 'TYPE_QUERY', + + /** `keyof T`, `readonly T[]`, `unique symbol`. 3,030 measured. */ + TYPE_OPERATOR = 'TYPE_OPERATOR', + + /** `infer U` inside a conditional. 1,337 measured. */ + INFER = 'INFER', + + /** `x is Widget` — 440 measured, and the engine's narrowing lever. */ + TYPE_PREDICATE = 'TYPE_PREDICATE', + + /** `import("pkg").Thing`. Carries `importSpecifier`. */ + IMPORT_TYPE = 'IMPORT_TYPE', + + /** `this` in a type position — polymorphic, and not the enclosing class. */ + THIS_TYPE = 'THIS_TYPE', + + /** `(A | B)` — punctuation preserved so source arity survives. */ + PARENTHESIZED = 'PARENTHESIZED', + + /** `[...T[]]` — a rest element in a tuple. */ + REST = 'REST', + + /** `[a?: string]` — an optional tuple element. */ + OPTIONAL = 'OPTIONAL', + + /** `[name: string]` — a labelled tuple element. */ + NAMED_TUPLE_MEMBER = 'NAMED_TUPLE_MEMBER', + + /** + * A reference to a TYPE PARAMETER in scope, not to a declared type. + * + * Distinguished from `TYPE_REFERENCE` so the resolution layer does not chase + * 42,032 phantom names: `T` inside `class Box` names no declaration. + */ + TYPE_VARIABLE = 'TYPE_VARIABLE', + + /** `intrinsic` — a compiler-implemented type such as `Uppercase`. */ + INTRINSIC = 'INTRINSIC', +} diff --git a/parser/src/enums/typescript/type-references/TsWildcardVariance.ts b/parser/src/enums/typescript/type-references/TsWildcardVariance.ts new file mode 100644 index 000000000..0465de24e --- /dev/null +++ b/parser/src/enums/typescript/type-references/TsWildcardVariance.ts @@ -0,0 +1,28 @@ +/** + * Java's `wildcardVariance` slot, at the same column position, REPURPOSED. + * + * TypeScript has no use-site wildcards — there is no `? extends T` — so the slot + * would otherwise be permanently empty at a position the shared projection reads. + * It carries the type OPERATORS that modify a type in place instead, which is + * the nearest thing the language has to a use-site modifier. + * + * ```ts + * readonly string[] // READONLY + * unique symbol // UNIQUE + * ``` + * + * This is the cross-language naming rule working as intended: same position, + * same spirit, language-correct values. Forcing Java's `EXTENDS`/`SUPER` here + * would be false parity. + * + * Declaration-site variance (`in` / `out`, TypeScript 4.7) is a different fact + * and lives on `ts_type_parameter.varianceAnnotation`. + * + * Schema §4.5 c12. + */ +export enum TsWildcardVariance { + /** `readonly T[]` — the array cannot be mutated through this reference. */ + READONLY = 'READONLY', + /** `unique symbol` — a nominal symbol type. */ + UNIQUE = 'UNIQUE', +} diff --git a/parser/src/enums/typescript/type-references/index.ts b/parser/src/enums/typescript/type-references/index.ts new file mode 100644 index 000000000..4d53bef3e --- /dev/null +++ b/parser/src/enums/typescript/type-references/index.ts @@ -0,0 +1,4 @@ +export { TsReferenceOwnerKind } from '@/enums/typescript/type-references/TsReferenceOwnerKind'; +export { TsTypeRefContext } from '@/enums/typescript/type-references/TsTypeRefContext'; +export { TsTypeRefKind } from '@/enums/typescript/type-references/TsTypeRefKind'; +export { TsWildcardVariance } from '@/enums/typescript/type-references/TsWildcardVariance'; diff --git a/parser/src/enums/typescript/types/TsDeclarationSpace.ts b/parser/src/enums/typescript/types/TsDeclarationSpace.ts new file mode 100644 index 000000000..ee78e7552 --- /dev/null +++ b/parser/src/enums/typescript/types/TsDeclarationSpace.ts @@ -0,0 +1,49 @@ +/** + * Which MEANINGS a declaration occupies. A comma-set column. + * + * This is the column that explains why merging is legal in some combinations and + * an error in others: two declarations may merge only when their spaces do not + * collide. A class occupies TYPE and VALUE; an interface occupies TYPE alone; so + * `class C` + `interface C` merges (28 such groups measured) while `class C` + + * `class C` is an error. Without the spaces, the engine can group declarations + * but cannot say what the group MEANS. + * + * ## Spaces by declaration + * + * ```ts + * class C { } // TYPE, VALUE + * interface I { } // TYPE + * type A = number; // TYPE + * enum E { } // TYPE, VALUE, NAMESPACE + * namespace N { } // NAMESPACE, and VALUE only when INSTANTIATED + * function f() { } // VALUE + * const x = 1; // VALUE + * ``` + * + * ## The namespace subtlety, and why it is not cosmetic + * + * ```ts + * namespace Types { // NAMESPACE only — erased entirely + * export interface Point { x: number } + * } + * namespace Geometry { // NAMESPACE, VALUE — emits an IIFE + * export const origin = { x: 0 }; + * } + * ``` + * + * A parser that treats every namespace as a value invents a runtime entity for + * the erased ones; one that treats none as a value loses the real ones. tsc + * decides it from the body alone, so it is decidable without a checker. + * + * Schema §4.2 c18, §3.1. + */ +export enum TsDeclarationSpace { + /** Usable in a type position. */ + TYPE = 'TYPE', + + /** Exists at runtime and can be referenced from an expression. */ + VALUE = 'VALUE', + + /** Can be qualified with a dot to reach members: a namespace or an enum. */ + NAMESPACE = 'NAMESPACE', +} diff --git a/parser/src/enums/typescript/types/TsTypeAccess.ts b/parser/src/enums/typescript/types/TsTypeAccess.ts new file mode 100644 index 000000000..24280d450 --- /dev/null +++ b/parser/src/enums/typescript/types/TsTypeAccess.ts @@ -0,0 +1,39 @@ +/** + * How visible a type declaration is. + * + * TypeScript visibility is EXPORT-based, not modifier-based, which is why Java's + * `PACKAGE_ACCESS` has no member here and why the values name export forms + * rather than keywords. There is no `public`/`private` on a type declaration. + * + * ## Examples + * + * ```ts + * export class A { } // EXPORTED_ACCESS + * export default class B { } // DEFAULT_EXPORT_ACCESS + * class C { } // MODULE_LOCAL_ACCESS (in a module file) + * interface D { } // GLOBAL_ACCESS (in a global script) + * namespace N { interface E { } } // NAMESPACE_LOCAL_ACCESS (not exported from N) + * ``` + * + * `MODULE_LOCAL_ACCESS` and `GLOBAL_ACCESS` are the same syntax in different + * files, and the difference is load-bearing: the first cannot be reached from + * outside its file, the second merges with every other script's declarations. + * + * Schema §4.2 c4. + */ +export enum TsTypeAccess { + /** `export class C` — reachable by name from an importing module. */ + EXPORTED_ACCESS = 'EXPORTED_ACCESS', + + /** `export default class C` — reachable under the binder's reserved name `default`. */ + DEFAULT_EXPORT_ACCESS = 'DEFAULT_EXPORT_ACCESS', + + /** Declared in a module file without `export` — unreachable from outside. */ + MODULE_LOCAL_ACCESS = 'MODULE_LOCAL_ACCESS', + + /** Declared in a global script, or inside `declare global`. */ + GLOBAL_ACCESS = 'GLOBAL_ACCESS', + + /** Declared inside a namespace without `export` — not reachable by qualified name. */ + NAMESPACE_LOCAL_ACCESS = 'NAMESPACE_LOCAL_ACCESS', +} diff --git a/parser/src/enums/typescript/types/TsTypeCategory.ts b/parser/src/enums/typescript/types/TsTypeCategory.ts new file mode 100644 index 000000000..c0b7dbcac --- /dev/null +++ b/parser/src/enums/typescript/types/TsTypeCategory.ts @@ -0,0 +1,74 @@ +/** + * What kind of type DECLARATION a `ts_type` row describes. + * + * Mirrors `TypeCategory` in the Java enums, position for position, and diverges + * only where the language does: TypeScript has no records and no + * `@interface`, and adds type aliases, namespaces and class expressions. + * + * ## Anonymous types are NOT here + * + * 21,956 function types and 5,015 type literals were measured in one ecosystem + * corpus. None has a name, a declaration or a merge identity, so none gets a + * `ts_type` row — they live in the `ts_type_reference` tree. Keeping this + * relation to declarations is what keeps it key-able at all. + * + * ## Examples + * + * ```ts + * class UserService { } // CLASS_TYPE + * abstract class BaseService { } // CLASS_TYPE (+ ABSTRACT modifier) + * interface Repository { } // INTERFACE_TYPE isTypeOnly = true + * type Handler = (e: Event) => void; // TYPE_ALIAS_TYPE isTypeOnly = true + * enum Status { Active, Closed } // ENUM_TYPE + * const enum Direction { Up, Down } // CONST_ENUM_TYPE + * namespace Geometry { } // NAMESPACE_TYPE + * const Widget = class Inner { }; // CLASS_EXPRESSION_TYPE + * ``` + * + * ## Why CONST_ENUM_TYPE is its own member + * + * A `const enum` member is INLINED at every use site, so a reference to it may + * have no runtime target at all. Folding it into `ENUM_TYPE` would leave the + * engine unable to tell a reachable member read from one the compiler erased. + * + * ## Why TYPE_ALIAS_TYPE gets a row at all + * + * It is a named declaration, it merges, it can be `extends`-ed, and there are + * 2,491 of them. What it does not get is any path into `ts_call_site`: its + * right-hand side hangs off `aliasTargetReferenceLinkHash` into the type graph, + * and that is its only edge. + * + * Schema §4.2 c3. + */ +export enum TsTypeCategory { + /** `class C { }` — occupies both the TYPE and VALUE declaration spaces. */ + CLASS_TYPE = 'CLASS_TYPE', + + /** `interface I { }` — TYPE space only, so `isTypeOnly` is true. */ + INTERFACE_TYPE = 'INTERFACE_TYPE', + + /** `enum E { }` — occupies TYPE, VALUE and NAMESPACE spaces at once. */ + ENUM_TYPE = 'ENUM_TYPE', + + /** `const enum E { }` — members are inlined, so uses may have no runtime target. */ + CONST_ENUM_TYPE = 'CONST_ENUM_TYPE', + + /** `type T = …` — TYPE space only, and no path into the call graph. */ + TYPE_ALIAS_TYPE = 'TYPE_ALIAS_TYPE', + + /** + * `namespace N { }`, or `declare module "x" { }`. + * + * Occupies VALUE only when INSTANTIATED — a namespace holding nothing but + * types is erased entirely. + */ + NAMESPACE_TYPE = 'NAMESPACE_TYPE', + + /** + * `class { }` in an expression position. + * + * Declares nothing in any symbol table, so it merges with nothing: two + * `class {}` expressions are two types even when bound to the same name. + */ + CLASS_EXPRESSION_TYPE = 'CLASS_EXPRESSION_TYPE', +} diff --git a/parser/src/enums/typescript/types/TsTypeModifier.ts b/parser/src/enums/typescript/types/TsTypeModifier.ts new file mode 100644 index 000000000..2141f16c6 --- /dev/null +++ b/parser/src/enums/typescript/types/TsTypeModifier.ts @@ -0,0 +1,37 @@ +/** + * Modifiers on a type declaration. A comma-set column, sorted at emit. + * + * Sorted so the value does not depend on the order the modifiers were written + * in — `export abstract class` and `abstract export class` are the same fact, + * and a rule matching on the string must not have to know which was typed. + * + * ## Examples + * + * ```ts + * export abstract class Base { } // ABSTRACT,EXPORT,GENERIC + * declare class Ambient { } // DECLARE + * export default class Widget { } // DEFAULT_EXPORT,EXPORT + * const enum Direction { Up } // CONST + * ``` + * + * Schema §4.2 c5. + */ +export enum TsTypeModifier { + /** `abstract class` — cannot be constructed directly. */ + ABSTRACT = 'ABSTRACT', + + /** `declare` — the declaration asserts an existing entity and emits nothing. */ + DECLARE = 'DECLARE', + + /** `const enum` — members are inlined at use sites. */ + CONST = 'CONST', + + /** `export` is present. */ + EXPORT = 'EXPORT', + + /** `export default` is present. */ + DEFAULT_EXPORT = 'DEFAULT_EXPORT', + + /** The declaration has at least one type parameter. */ + GENERIC = 'GENERIC', +} diff --git a/parser/src/enums/typescript/types/TsTypePlacement.ts b/parser/src/enums/typescript/types/TsTypePlacement.ts new file mode 100644 index 000000000..8967e618e --- /dev/null +++ b/parser/src/enums/typescript/types/TsTypePlacement.ts @@ -0,0 +1,41 @@ +/** + * Where a type declaration sits, structurally. + * + * Answers "what encloses this declaration" without a line-range trick. + * `ts_type` also carries explicit `enclosingTypeLinkHash` and + * `enclosingMethodLinkHash` FKs; this column is the cheap categorical form of + * the same fact, so a rule can filter before it joins. + * + * ## Examples + * + * ```ts + * class Top { } // TOP_LEVEL_PLACEMENT + * namespace N { class Inner { } } // NAMESPACE_PLACEMENT + * class Outer { } // (a nested type inside a class → + * // NESTED_PLACEMENT) + * function make() { interface Local { } } // LOCAL_PLACEMENT + * const W = class { }; // EXPRESSION_PLACEMENT + * declare module "pkg" { interface X { } } // AMBIENT_MODULE_PLACEMENT + * ``` + * + * Schema §4.2 c6. + */ +export enum TsTypePlacement { + /** Directly at the top level of a file. */ + TOP_LEVEL_PLACEMENT = 'TOP_LEVEL_PLACEMENT', + + /** Inside a `namespace` or `module` block. */ + NAMESPACE_PLACEMENT = 'NAMESPACE_PLACEMENT', + + /** Inside another type declaration. */ + NESTED_PLACEMENT = 'NESTED_PLACEMENT', + + /** Inside a function body or a block — a local type. */ + LOCAL_PLACEMENT = 'LOCAL_PLACEMENT', + + /** A class expression: declared in an expression position. */ + EXPRESSION_PLACEMENT = 'EXPRESSION_PLACEMENT', + + /** Inside `declare module "x" { … }`. */ + AMBIENT_MODULE_PLACEMENT = 'AMBIENT_MODULE_PLACEMENT', +} diff --git a/parser/src/enums/typescript/types/index.ts b/parser/src/enums/typescript/types/index.ts new file mode 100644 index 000000000..7118bfe5b --- /dev/null +++ b/parser/src/enums/typescript/types/index.ts @@ -0,0 +1,5 @@ +export { TsDeclarationSpace } from '@/enums/typescript/types/TsDeclarationSpace'; +export { TsTypeAccess } from '@/enums/typescript/types/TsTypeAccess'; +export { TsTypeCategory } from '@/enums/typescript/types/TsTypeCategory'; +export { TsTypeModifier } from '@/enums/typescript/types/TsTypeModifier'; +export { TsTypePlacement } from '@/enums/typescript/types/TsTypePlacement'; diff --git a/parser/src/enums/typescript/variables/TsBindingSourceKind.ts b/parser/src/enums/typescript/variables/TsBindingSourceKind.ts new file mode 100644 index 000000000..201d6f6b5 --- /dev/null +++ b/parser/src/enums/typescript/variables/TsBindingSourceKind.ts @@ -0,0 +1,29 @@ +/** + * What a destructured name binds to. + * + * `const { a: renamed } = o` declares `renamed`, and the fact that it reads + * property `a` lives only in the pattern. Without it a consumer sees a variable + * whose origin cannot be recovered from its name -- and the shorthand form + * `{ a }` hides the problem, because there the name and the property coincide. + * + * An enum rather than a bare string because a rest element binds neither a + * single property nor a single index. `""` alone could not tell "this is a rest + * element" from "this is not a destructuring at all", and those are opposite + * facts. + */ +export enum TsBindingSourceKind { + /** Not a destructured name. Every ordinary declaration carries this. */ + NONE = 'NONE', + + /** `const { a } = o` and `const { a: renamed } = o` — binds property `a`. */ + PROPERTY = 'PROPERTY', + + /** `const [first, second] = xs` — binds by position. */ + INDEX = 'INDEX', + + /** `const { a, ...rest } = o` — binds every property not named above it. */ + OBJECT_REST = 'OBJECT_REST', + + /** `const [a, ...tail] = xs` — binds every element from its index onward. */ + ARRAY_REST = 'ARRAY_REST', +} diff --git a/parser/src/enums/typescript/variables/TsVariableDeclarationKind.ts b/parser/src/enums/typescript/variables/TsVariableDeclarationKind.ts new file mode 100644 index 000000000..a498292c7 --- /dev/null +++ b/parser/src/enums/typescript/variables/TsVariableDeclarationKind.ts @@ -0,0 +1,47 @@ +/** + * Which keyword declared a variable. + * + * Not cosmetic: the keyword decides which SYMBOL TABLE the declaration lands in, + * and therefore what merges with what. `var` is function-scoped, everything else + * is block-scoped — which is why the binder maintains two tables and why two + * `const x` in sibling blocks of one function are two symbols while two `var x` + * are one. + * + * ```ts + * const a = 1; // CONST + * let b = 1; // LET + * var c = 1; // VAR — function-scoped + * using d = open(); // USING — disposed at scope exit (TS 5.2) + * await using e = openAsync(); // AWAIT_USING — awaited disposal + * try { } catch (f) { } // CATCH + * for (const g of xs) { } // FOR_OF + * for (const h in o) { } // FOR_IN + * for (let i = 0; …) { } // FOR_INIT + * ``` + * + * `USING` and `AWAIT_USING` are TypeScript 5.2's analogue of try-with-resources: + * the binding is disposed when its scope exits, which is a real control-flow fact + * that `ts_block.resourceCount` also records. + * + * Schema §4.11 c16. + */ +export enum TsVariableDeclarationKind { + /** `const` — block-scoped, not reassignable. */ + CONST = 'CONST', + /** `let` — block-scoped. */ + LET = 'LET', + /** `var` — FUNCTION-scoped, and therefore in a different symbol table. */ + VAR = 'VAR', + /** `using` — disposed at scope exit. */ + USING = 'USING', + /** `await using` — awaited disposal at scope exit. */ + AWAIT_USING = 'AWAIT_USING', + /** A `catch (e)` binding. tsc's node for this IS a VariableDeclaration. */ + CATCH = 'CATCH', + /** A `for…of` binding. */ + FOR_OF = 'FOR_OF', + /** A `for…in` binding. */ + FOR_IN = 'FOR_IN', + /** A `for (…;…;…)` initializer binding. */ + FOR_INIT = 'FOR_INIT', +} diff --git a/parser/src/enums/typescript/variables/TsVariableInitializerKind.ts b/parser/src/enums/typescript/variables/TsVariableInitializerKind.ts new file mode 100644 index 000000000..551b934d3 --- /dev/null +++ b/parser/src/enums/typescript/variables/TsVariableInitializerKind.ts @@ -0,0 +1,54 @@ +/** + * The shape of a variable's initializer. + * + * ## Two members carry the whole reason this column exists + * + * `ARROW` and `FUNCTION_EXPRESSION` are what `boundFunctionLinkHash` keys off: + * + * ```ts + * const f = () => { … }; + * f(); // resolves through the VARIABLE, not by name + * ``` + * + * **161 measured call targets are arrow functions**, and an arrow has no name a + * call site could match — the variable is the only route to it. Java never needed + * this link; Python folded the equivalent into `py_binding`. + * + * `NEW` and `CALL` are the next most useful: an initializer that constructs + * names its type as plainly as an annotation would, which is a syntactic fact + * rather than an inference. + * + * Schema §4.11 c18. + */ +export enum TsVariableInitializerKind { + /** `() => …` — the link that makes the arrow callable by name. */ + ARROW = 'ARROW', + /** `function () { }` — likewise. */ + FUNCTION_EXPRESSION = 'FUNCTION_EXPRESSION', + /** `new C()` — names the type as plainly as an annotation. */ + NEW = 'NEW', + /** `f()` — the type is the callee's return type. */ + CALL = 'CALL', + /** `{ … }`. */ + OBJECT_LITERAL = 'OBJECT_LITERAL', + /** `[ … ]`. */ + ARRAY_LITERAL = 'ARRAY_LITERAL', + /** A string, number, boolean or `null` literal. */ + LITERAL = 'LITERAL', + /** A bare identifier — an alias for another binding. */ + IDENTIFIER = 'IDENTIFIER', + /** `x as T` — the asserted type is written down. */ + AS_EXPRESSION = 'AS_EXPRESSION', + /** `x satisfies T`. */ + SATISFIES = 'SATISFIES', + /** `await p`. */ + AWAIT = 'AWAIT', + /** A template literal. */ + TEMPLATE = 'TEMPLATE', + /** `class { }` — also emits a `ts_type` row. */ + CLASS_EXPRESSION = 'CLASS_EXPRESSION', + /** No initializer. */ + NONE = 'NONE', + /** Anything else. Recorded rather than guessed at. */ + UNKNOWN = 'UNKNOWN', +} diff --git a/parser/src/enums/typescript/variables/TsVariableScopeKind.ts b/parser/src/enums/typescript/variables/TsVariableScopeKind.ts new file mode 100644 index 000000000..91a783656 --- /dev/null +++ b/parser/src/enums/typescript/variables/TsVariableScopeKind.ts @@ -0,0 +1,42 @@ +/** + * Where a variable is scoped. + * + * Wider than Java's `LocalVariableScopeKind` because a module-level `const` is a + * first-class declaration here: it merges, it is exported, it is imported by + * name, and 161 measured call targets are arrow functions reached only through + * the variable that binds them. + * + * ## The three that are not "a block" + * + * ```ts + * for (let i = 0; …) { } // FOR_BINDING — re-bound per iteration for `let` + * try { } catch (e) { } // CATCH_BINDING — scoped to the catch clause alone + * namespace N { const x = 1; } // NAMESPACE_SCOPE + * ``` + * + * `FOR_BINDING` and `CATCH_BINDING` are separated because their scopes are not + * the surrounding block: a `let` in a `for` header is a fresh binding each + * iteration, which is what makes closures over it behave differently from `var`. + * + * Schema §4.11 c8. + */ +export enum TsVariableScopeKind { + /** Top level of a module file. Exportable and importable. */ + MODULE_SCOPE = 'MODULE_SCOPE', + /** Top level of a global script, or inside `declare global`. */ + GLOBAL_SCOPE = 'GLOBAL_SCOPE', + /** Directly in a function body. */ + FUNCTION_BODY = 'FUNCTION_BODY', + /** Directly in an arrow body. */ + ARROW_BODY = 'ARROW_BODY', + /** In a nested block — a `let`/`const` shadowing an outer name. */ + BLOCK_SCOPE = 'BLOCK_SCOPE', + /** A loop header binding, re-bound per iteration for `let`. */ + FOR_BINDING = 'FOR_BINDING', + /** A `catch (e)` binding, scoped to the clause. */ + CATCH_BINDING = 'CATCH_BINDING', + /** Inside a namespace body. */ + NAMESPACE_SCOPE = 'NAMESPACE_SCOPE', + /** Inside a `.d.ts` or a `declare` context: no runtime existence. */ + AMBIENT_SCOPE = 'AMBIENT_SCOPE', +} diff --git a/parser/src/enums/typescript/variables/index.ts b/parser/src/enums/typescript/variables/index.ts new file mode 100644 index 000000000..19f02ff1d --- /dev/null +++ b/parser/src/enums/typescript/variables/index.ts @@ -0,0 +1,4 @@ +export { TsVariableDeclarationKind } from '@/enums/typescript/variables/TsVariableDeclarationKind'; +export { TsVariableInitializerKind } from '@/enums/typescript/variables/TsVariableInitializerKind'; +export { TsVariableScopeKind } from '@/enums/typescript/variables/TsVariableScopeKind'; +export { TsBindingSourceKind } from '@/enums/typescript/variables/TsBindingSourceKind'; diff --git a/parser/src/enums/xml/XmlValueReferenceType.ts b/parser/src/enums/xml/XmlValueReferenceType.ts new file mode 100644 index 000000000..93dbf4ec3 --- /dev/null +++ b/parser/src/enums/xml/XmlValueReferenceType.ts @@ -0,0 +1,33 @@ +/** + * Type of a value reference found within XML text content or attribute values. + * + * Classification is purely structural — we detect the syntax pattern, not + * the runtime semantics. Whether `${env.DB_HOST}` is an env var or + * `${spring.version}` is a Maven property cannot be determined at parse time. + * + * ## Examples + * + * ```xml + * + * ${spring.version} + * ${env.DB_HOST} + * ${project.artifactId} + * + * + * ${db.host:localhost} + * ${env.DB_PORT:5432} + * + * + * #{systemProperties['user.home']} + * ``` + */ +export enum XmlValueReferenceType { + /** ${name} — placeholder without a default value */ + PROPERTY_PLACEHOLDER = 'PROPERTY_PLACEHOLDER', + + /** ${name:default} — placeholder with a default value */ + PLACEHOLDER_WITH_DEFAULT = 'PLACEHOLDER_WITH_DEFAULT', + + /** #{...} — Spring Expression Language expression */ + SPEL_EXPRESSION = 'SPEL_EXPRESSION', +} diff --git a/parser/src/enums/xml/index.ts b/parser/src/enums/xml/index.ts new file mode 100644 index 000000000..2fb3bc52d --- /dev/null +++ b/parser/src/enums/xml/index.ts @@ -0,0 +1 @@ +export { XmlValueReferenceType } from '@/enums/xml/XmlValueReferenceType'; diff --git a/parser/src/enums/yaml/YamlValueSegmentType.ts b/parser/src/enums/yaml/YamlValueSegmentType.ts new file mode 100644 index 000000000..d00462431 --- /dev/null +++ b/parser/src/enums/yaml/YamlValueSegmentType.ts @@ -0,0 +1,48 @@ +/** + * Type of a value segment within a YAML property value expression. + * + * Uses the same two-pass classification as the properties parser: + * collect all YAML keys first, then classify `${...}` references as + * PROPERTY_REFERENCE when the name matches a known key, or ENV_VARIABLE otherwise. + * + * ## Segment Types + * + * ```yaml + * # LITERAL — plain text + * app.name: Auth Service + * + * # ENV_VARIABLE — ${VAR} with no default, not a known YAML key + * db.url: ${DATABASE_URL} + * + * # ENV_WITH_DEFAULT — ${VAR:default} with default, not a known YAML key + * db.host: ${DB_HOST:localhost} + * + * # PROPERTY_REFERENCE — ${key} where key matches another YAML key in the file + * app.url: ${app.base-url}/health + * + * # PROPERTY_REF_WITH_DEFAULT — ${key:default} where key matches another YAML key + * app.region: ${app.default-region:us-east-1} + * + * # EMPTY — empty or absent value + * placeholder: + * ``` + */ +export enum YamlValueSegmentType { + /** Plain text with no references */ + LITERAL = 'LITERAL', + + /** ${VAR} — environment variable reference with no default, not a known YAML key */ + ENV_VARIABLE = 'ENV_VARIABLE', + + /** ${VAR:default} — environment variable with a default value, not a known YAML key */ + ENV_WITH_DEFAULT = 'ENV_WITH_DEFAULT', + + /** ${prop.key} — references another YAML key defined in the same file */ + PROPERTY_REFERENCE = 'PROPERTY_REFERENCE', + + /** ${prop.key:default} — references another YAML key with a fallback default */ + PROPERTY_REF_WITH_DEFAULT = 'PROPERTY_REF_WITH_DEFAULT', + + /** Empty or absent value */ + EMPTY = 'EMPTY', +} diff --git a/parser/src/enums/yaml/YamlValueType.ts b/parser/src/enums/yaml/YamlValueType.ts new file mode 100644 index 000000000..5106798e7 --- /dev/null +++ b/parser/src/enums/yaml/YamlValueType.ts @@ -0,0 +1,61 @@ +/** + * Type of a value in a YAML document. + * + * ## Examples + * + * ```yaml + * # STRING + * app.name: My Service + * quoted: "hello world" + * + * # INTEGER + * server.port: 8080 + * + * # FLOAT + * timeout: 30.5 + * + * # BOOLEAN + * debug: true + * + * # NULL + * optional-field: null + * also-null: ~ + * + * # EMPTY — key with no value + * placeholder: + * + * # MAP — value is a nested mapping + * server: + * port: 8080 + * + * # SEQUENCE — value is a list + * hosts: + * - localhost + * - remote + * ``` + */ +export enum YamlValueType { + /** Plain text / quoted string */ + STRING = 'STRING', + + /** Whole number (e.g., 8080, -1) */ + INTEGER = 'INTEGER', + + /** Floating-point number (e.g., 30.5) */ + FLOAT = 'FLOAT', + + /** true / false / yes / no / on / off */ + BOOLEAN = 'BOOLEAN', + + /** null / ~ / empty */ + NULL = 'NULL', + + /** Key present but no value assigned */ + EMPTY = 'EMPTY', + + /** Value is a nested mapping (map/object) */ + MAP = 'MAP', + + /** Value is a sequence (list/array) */ + SEQUENCE = 'SEQUENCE', +} diff --git a/parser/src/enums/yaml/index.ts b/parser/src/enums/yaml/index.ts new file mode 100644 index 000000000..4c0e1a204 --- /dev/null +++ b/parser/src/enums/yaml/index.ts @@ -0,0 +1,2 @@ +export { YamlValueType } from '@/enums/yaml/YamlValueType'; +export { YamlValueSegmentType } from '@/enums/yaml/YamlValueSegmentType'; diff --git a/parser/src/extract.ts b/parser/src/extract.ts new file mode 100644 index 000000000..7d9983d7f --- /dev/null +++ b/parser/src/extract.ts @@ -0,0 +1,341 @@ +import * as fs from 'fs'; +import * as fsp from 'fs/promises'; +import * as path from 'path'; + +import { JAVA_TEST_DIR, ANALYSIS_OUTPUT_DIR } from '@/constants/consts'; +import { ProjectInfo, ProjectLanguage } from '@/types/ProjectInfo'; +import { ProjectScanner } from '@/utils/project-scanner'; +import { GradleProjectAnalyzer } from '@/workflows/gradle/gradle-project-analyzer'; +import { JavaProjectAnalyzer } from '@/workflows/java/java-project-analyzer'; +import { PropertiesProjectAnalyzer } from '@/workflows/properties/properties-project-analyzer'; +import { PythonProjectAnalyzer } from '@/workflows/python/python-project-analyzer'; +import { ServicesProjectAnalyzer } from '@/workflows/services/services-project-analyzer'; +import { JavaScriptProjectAnalyzer } from '@/workflows/javascript/javascript-project-analyzer'; +import { TypeScriptProjectAnalyzer } from '@/workflows/typescript/typescript-project-analyzer'; +import { XmlProjectAnalyzer } from '@/workflows/xml/xml-project-analyzer'; +import { YamlProjectAnalyzer } from '@/workflows/yaml/yaml-project-analyzer'; + +export interface ExtractOptions { + /** Path to the project/codebase to scan. */ + projectPath: string; + /** Service-version link / commit tag stamped onto every extracted fact. */ + versionLink: string; + /** Exclude test directories ("test", "tests"). Default: false. */ + excludeTests?: boolean; + /** Directory to write extracted facts to. Default: the analyzers' built-in location. */ + outputDir?: string; + /** + * `flat` (default): every language's tables side by side in outputDir, as always. + * `per-language`: outputDir/java/, outputDir/typescript/, outputDir/python/, + * outputDir/javascript/ — one folder per language that had a project, holding only + * that language's tables (the config tables — properties, XML, YAML, Gradle, + * services — go with Java, whose rules are the only reader). A consumer that solves + * one language at a time points at one folder and sees nothing else. + */ + layout?: 'flat' | 'per-language'; +} + +/** + * Merge the relation files several per-project runs wrote into their own scratch + * folders into one set: the header once, every project's rows after it, in project + * order. Two TypeScript (or Python) projects in one tree used to be analysed + * concurrently into the SAME folder, and each relation was opened with a truncating + * write — whichever project finished last kept its rows and the other's vanished, + * silently and in an order that varied between runs. The JavaScript analyzer avoids + * this by unioning its roots up front; the languages that take one root per call + * get the same guarantee here, at the file level, without touching their analyzers. + * A zero-byte relation (no rows, no header) contributes nothing but still ensures + * the file exists in the merged set. + */ +async function mergeProjectOutputs(scratchDirs: string[], outputDir: string): Promise { + const seen = new Map(); // filename → header already written + for (const dir of scratchDirs) { + let names: string[] = []; + try { names = (await fsp.readdir(dir)).filter((n) => n.endsWith('.csv')); } catch { continue; } + for (const name of names.sort()) { + const src = path.join(dir, name); + const dst = path.join(outputDir, name); + const text = await fsp.readFile(src, 'utf-8'); + if (!seen.has(name)) { + await fsp.writeFile(dst, text, 'utf-8'); + seen.set(name, text.length > 0); + continue; + } + if (text.length === 0) continue; + const nl = text.indexOf('\n'); + const header = nl < 0 ? text : text.slice(0, nl); + const body = nl < 0 ? '' : text.slice(nl + 1); + if (!seen.get(name)) { + // the first project wrote a zero-byte file for this relation; this one has rows + await fsp.writeFile(dst, header + '\n' + body, 'utf-8'); + seen.set(name, true); + } else if (body.length > 0) { + await fsp.appendFile(dst, body.endsWith('\n') ? body : body + '\n', 'utf-8'); + } + } + await fsp.rm(dir, { recursive: true, force: true }); + } +} + +/** A fresh scratch folder per project, under the output directory so it is on the same volume. */ +function scratchFor(outputDir: string, language: string, index: number): string { + const dir = path.join(outputDir, `.${language}-project-${index}`); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +/** Runs a promise and returns its value alongside how long it took, in seconds. */ +async function timed(work: Promise): Promise<{ value: T; seconds: number }> { + const startedAt = Date.now(); + const value = await work; + return { value, seconds: (Date.now() - startedAt) / 1000 }; +} + +/** + * Prints what a per-project analyzer produced. + * + * Java, XML, YAML, Gradle, Properties and services each print their own + * tallies from inside their workflow. Python and TypeScript return a summary object instead, + * which nothing was reading, so those two languages were silent even on a run + * that analysed hundreds of files. `filesRejected` and `extractionErrors` are + * printed separately and only when non-zero: a rejection is a decision, an + * extraction error is always a defect, and a caller that cannot tell them apart + * cannot tell a clean run from a parser that crashed on every file. + */ +function reportLanguage( + label: string, + seconds: number, + summaries: ReadonlyArray<{ + filesSeen: number; + filesAnalysed: number; + filesRejected?: number; + extractionErrors?: number; + counts?: Record; + }> +): void { + if (summaries.length === 0) { + return; + } + const total = (pick: (s: (typeof summaries)[number]) => number | undefined): number => + summaries.reduce((sum, s) => sum + (pick(s) ?? 0), 0); + const analysed = total((s) => s.filesAnalysed); + const rows = summaries.reduce( + (sum, s) => sum + Object.values(s.counts ?? {}).reduce((a, b) => a + b, 0), + 0 + ); + // Padded to the same column the other languages use, so a run reads as one + // report rather than two formats. + const field = (text: string): string => `${label} ${text}:`.padEnd(30); + console.log(`\n📊 ${field('files analysed')}${analysed}`); + console.log(`📊 ${field('rows extracted')}${rows}`); + const rejected = total((s) => s.filesRejected); + const errored = total((s) => s.extractionErrors); + if (rejected > 0) { + console.log(` ⏭ ${rejected} file(s) skipped — see the skipped-files report`); + } + if (errored > 0) { + console.log(` ❌ ${errored} file(s) errored during extraction`); + } + console.log(`⏱️ ${label} analysis completed in ${seconds.toFixed(2)}s`); +} + +/** + * Scan a codebase and extract Java/Python/TypeScript/Gradle/XML/YAML/Properties + * and META-INF/services facts. + * + * This is the parser core and the package's main export. `src/index.ts` wraps it + * for command-line use; import it directly to drive the parser from code. + */ +export async function extractProject(opts: ExtractOptions): Promise { + const excludeTests = opts.excludeTests ?? false; + const outputDir = opts.outputDir ? path.resolve(opts.outputDir) : undefined; + + if (excludeTests) { + console.log(`🚫 Test directories (${JAVA_TEST_DIR.source}) will be excluded from analysis\n`); + } + + const startedAt = Date.now(); + const absolutePath = path.resolve(opts.projectPath); + const scanner = new ProjectScanner(); + + console.log('⏳ Scanning for projects...'); + const allProjects = await scanner.scanForProjects(absolutePath); + console.log(`✅ Scan complete! Found ${allProjects.length} total project(s)\n`); + + const projectsByLanguage = scanner.groupByLanguage(allProjects); + console.log('📋 Projects by language:'); + for (const [language, projects] of projectsByLanguage) { + console.log(` ${language}: ${projects.length} project(s)`); + } + + // Ensure the root directory is always included as a scan target for file-type + // analyzers (XML, YAML, Properties, Gradle, services) so root-level config + // files like build.xml, settings.gradle, pom.xml, etc. are not missed. + const rootEntry: ProjectInfo = { + name: path.basename(absolutePath), + path: absolutePath, + language: ProjectLanguage.UNKNOWN, + hasSourceFiles: false, + }; + const rootAlreadyIncluded = allProjects.some(p => p.path === absolutePath); + const scanTargets: ProjectInfo[] = rootAlreadyIncluded + ? allProjects + : [rootEntry, ...allProjects]; + + const javaProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.JAVA); + const pythonProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.PYTHON); + const typescriptProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.TYPESCRIPT); + const javascriptProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.JAVASCRIPT); + + // Where each language writes. Flat: everything into outputDir. Per-language: a folder per + // language, created only for a language that had a project, so an absent language leaves + // no folder of zero-byte tables behind. + const perLanguage = opts.layout === 'per-language'; + const baseOut = outputDir ?? ANALYSIS_OUTPUT_DIR; + const dirFor = (language: string, present: boolean): string | undefined => { + if (!perLanguage) return outputDir; + if (!present) return undefined; + const d = path.join(baseOut, language); + fs.mkdirSync(d, { recursive: true }); + return d; + }; + const javaOut = dirFor('java', javaProjects.length > 0); + const typescriptOut = dirFor('typescript', typescriptProjects.length > 0); + const pythonOut = dirFor('python', pythonProjects.length > 0); + const javascriptOut = dirFor('javascript', javascriptProjects.length > 0); + // The config analyzers walk every scan target and always write; without a Java project + // their tables have no reader, so in per-language mode they go to a scratch folder that + // is discarded rather than into a java/ folder that would announce a language absent here. + const configOut = perLanguage ? (javaOut ?? scratchFor(baseOut, 'config', 0)) : outputDir; + + const javaAnalyzer = new JavaProjectAnalyzer(undefined, javaOut ?? (perLanguage ? scratchFor(baseOut, 'java', 0) : outputDir)); + const propertiesAnalyzer = new PropertiesProjectAnalyzer(configOut); + const xmlAnalyzer = new XmlProjectAnalyzer(configOut); + const yamlAnalyzer = new YamlProjectAnalyzer(configOut); + const gradleAnalyzer = new GradleProjectAnalyzer(configOut); + const servicesAnalyzer = new ServicesProjectAnalyzer(configOut); + const pythonAnalyzer = new PythonProjectAnalyzer(); + const typescriptAnalyzer = new TypeScriptProjectAnalyzer(); + const javascriptAnalyzer = new JavaScriptProjectAnalyzer(); + + // Positions matter: java, properties, xml, yaml, gradle, services, typescript, + // python, javascript. Counting them wrong bound typescriptSummaries to + // gradle's void return, and the mistake surfaced only as a type error — so a + // new analyzer is APPENDED rather than inserted, and the destructuring below + // is checked against this list rather than against memory. + const [, , , , , , typescriptSummaries, pythonSummaries, javascriptSummaries] + = await Promise.all([ + javaAnalyzer.analyzeJavaProjects(javaProjects, opts.versionLink, excludeTests), + propertiesAnalyzer.analyzePropertiesFiles(scanTargets, opts.versionLink), + xmlAnalyzer.analyzeXmlFiles(scanTargets, opts.versionLink), + yamlAnalyzer.analyzeYamlFiles(scanTargets, opts.versionLink), + gradleAnalyzer.analyzeGradleFiles(scanTargets, opts.versionLink), + // META-INF/services is given the same scan targets as the other file-type + // analyzers rather than the Java project list: a provider-configuration file + // lives in a resources directory, which a module may ship with no .java + // source of its own. + servicesAnalyzer.analyzeServicesFiles(scanTargets, opts.versionLink), + // Python takes one root per call where Java takes the whole list, so the + // projects are walked here rather than pushing a list-shaped API onto it. + // serviceVersionLink is passed UNHASHED on purpose: the analyzer hashes it + // the same way Java does, so the two languages produce joinable values. + // Passing a raw string into a column named ...LinkHash is the mistake that + // option exists to prevent. + // TypeScript takes one root per call, as Python does. serviceVersionLink is + // passed UNHASHED on purpose: the analyzer hashes it exactly as Java and + // Python do, so the three languages produce joinable values. Passing a raw + // string into a column named ...LinkHash is the mistake that option exists + // to prevent. + // + // A TypeScript PROGRAM is the unit of merge scope: two programs have two + // global scopes, and analysing them as one merges symbols tsc keeps apart. + // So the work is per program, and `analyzePrograms` is what expands a + // discovered project into them -- a monorepo root's tsconfig claims only + // the files at the top, and every package below is its own program, so + // treating the project as one program reached 24 of 965 files on one such + // repository. The output is one flat set, as Java's is. + timed(Promise.all(typescriptProjects.map((project, i) => + typescriptAnalyzer.analyzePrograms({ + rootDir: project.path, + // one scratch folder per project; merged below — see mergeProjectOutputs + outputDir: scratchFor(typescriptOut ?? baseOut, 'typescript', i), + baseMservPath: absolutePath, + serviceVersionLink: opts.versionLink, + excludeDirs: excludeTests + ? ['node_modules', '.git', 'dist', 'build', 'out', 'coverage', + 'test', 'tests', '__tests__', '.next', '.turbo'] + : undefined, + })))), + timed(Promise.all(pythonProjects.map((project, i) => + pythonAnalyzer.analyze({ + rootDir: project.path, + outputDir: scratchFor(pythonOut ?? baseOut, 'python', i), + baseMservPath: absolutePath, + serviceVersionLink: opts.versionLink, + // Python has no excludeTests flag; test discovery is by convention, so + // the equivalent is skipping the directories those conventions use. + // The defaults are repeated because excludeDirs REPLACES them rather + // than adding to them — passing only the test names would have started + // analysing .venv and site-packages as project source. + excludeDirs: excludeTests + ? ['__pycache__', '.git', 'node_modules', '.venv', 'venv', '.tox', + 'tests', 'test', '__tests__'] + : undefined, + })))), + // JavaScript takes one root per call, as Python and TypeScript do, and + // hashes serviceVersionLink itself so all four languages produce joinable + // values. Unlike TypeScript there is no program expansion: JavaScript has no + // tsconfig to define one, and the per-file unit of configuration is the + // nearest `package.json`, which the analyzer resolves per file. + // ONE call for every JavaScript root, not one per project. Calling per + // project against a single output directory does not merge the sets, it + // OVERWRITES them — every project but the last vanishes — and concurrently + // it races on the temporary files as well. `analyzeAll` unions the file + // lists first, which also deduplicates the files a monorepo root and its + // packages both claim. + timed(Promise.all([ + javascriptAnalyzer.analyzeAll(javascriptProjects.map((project) => project.path), { + outputDir: javascriptOut ?? (perLanguage ? scratchFor(baseOut, 'javascript', 0) : baseOut), + baseMservPath: absolutePath, + serviceVersionLink: opts.versionLink, + // excludeDirs REPLACES the defaults rather than adding to them, so the + // defaults are repeated — passing only the test names would have started + // analysing node_modules as project source, which is the one thing the + // provenance split exists to prevent. + excludeDirs: excludeTests + ? ['node_modules', 'bower_components', '.git', 'dist', 'build', 'out', + 'coverage', '.next', '.nuxt', '.turbo', '.cache', '.yarn', + 'test', 'tests', '__tests__', 'spec'] + : undefined, + }), + ])), + ]); + + // Every per-project scratch folder is merged into its language's folder now, in project + // order, and removed. In flat mode the language folder IS outputDir. + await mergeProjectOutputs(typescriptProjects.map((_, i) => path.join(typescriptOut ?? baseOut, `.typescript-project-${i}`)), typescriptOut ?? baseOut); + await mergeProjectOutputs(pythonProjects.map((_, i) => path.join(pythonOut ?? baseOut, `.python-project-${i}`)), pythonOut ?? baseOut); + if (perLanguage) { + for (const stray of ['.config-project-0', '.java-project-0', '.javascript-project-0']) { + fs.rmSync(path.join(baseOut, stray), { recursive: true, force: true }); + } + } + + // Python and TypeScript ran and wrote their CSVs but reported nothing, while + // every other language printed counts and a duration. A run over a Python + // project ended on "Found 0 Java project(s)" and a string of empty XML and + // Gradle tallies, with no sign the Python analysis had happened at all. The + // summaries were already returned by the analyzers and simply discarded. + reportLanguage('Python', pythonSummaries.seconds, pythonSummaries.value); + reportLanguage('TypeScript', typescriptSummaries.seconds, typescriptSummaries.value); + reportLanguage('JavaScript', javascriptSummaries.seconds, javascriptSummaries.value); + + // Wall clock for the whole run. The per-language figures above will NOT sum to + // it: the analyzers run concurrently, so their durations overlap. Reporting + // both is the point -- the per-language number says which parser is slow, the + // total says what the caller actually waited. + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(2); + console.log(`\n⏱️ TOTAL analysis time: ${elapsed}s`); + console.log('✨ Analysis complete!\n'); +} diff --git a/parser/src/index.ts b/parser/src/index.ts new file mode 100644 index 000000000..94b2a943a --- /dev/null +++ b/parser/src/index.ts @@ -0,0 +1,56 @@ +/** + * The parser's command-line entry point. Arguments are positional: + * + * node dist/index.js [outputDir] + * + * projectsDir directory scanned for projects (recursively) + * serviceVersionLink commit tag stamped onto every extracted fact; required + * excludeTests "true" or "false"; anything else exits 1 + * outputDir optional; defaults to the analyzers' built-in location + * + * To drive the parser from code rather than a shell, import `extractProject` + * from `@/extract` directly — that is the package's main export, and this file + * is a thin argument-parsing wrapper around it. + */ +import * as path from 'path'; + +import { extractProject } from '@/extract'; + +async function main() { + // `--per-language` may appear anywhere: outputDir// instead of one flat folder. + const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith('--'))); + const args = process.argv.slice(2).filter((a) => !a.startsWith('--')); + for (const f of flags) { + if (f !== '--per-language') { console.error(`❌ Error: unknown option ${f}`); process.exit(1); } + } + const projectsDirectory = args[0]; + const serviceVersionLink = args[1]; + const excludeTestsArg = args[2]; + const outputDirArg = args[3]; + + if (!projectsDirectory) { + console.error('❌ Error: Please provide a projects directory path'); + process.exit(1); + } + if (!serviceVersionLink) { + console.error('❌ Error: Please provide a service version link'); + process.exit(1); + } + if (excludeTestsArg !== undefined && excludeTestsArg !== 'true' && excludeTestsArg !== 'false') { + console.error('❌ Error: excludeTests must be "true" or "false"'); + process.exit(1); + } + + await extractProject({ + projectPath: path.resolve(projectsDirectory), + versionLink: serviceVersionLink, + excludeTests: excludeTestsArg === 'true', + outputDir: outputDirArg, + layout: flags.has('--per-language') ? 'per-language' : 'flat', + }); +} + +main().catch((error) => { + console.error('❌ Error:', error); + process.exit(1); +}); diff --git a/parser/src/interfaces/EntityIdentifiable.ts b/parser/src/interfaces/EntityIdentifiable.ts new file mode 100644 index 000000000..b003fba0a --- /dev/null +++ b/parser/src/interfaces/EntityIdentifiable.ts @@ -0,0 +1,33 @@ +/** + * Interface for entities that can be uniquely identified and exported + */ +export interface EntityIdentifiable { + /** + * Returns the unique hash identifier for this entity + * @return Hash value using a hash algorithm defined in the constants + */ + getHash(): string; + + /** + * Generates and sets the hash for this entity + */ + generateHash(): void; + + /** + * Combines the entries + * @return The combination of unique entry identifiers except the hash + */ + getEntryCombined(): string; + + /** + * Converts the entity to CSV format (tab-separated values) + * @return Tab-separated string representation of the entity + */ + toCsv(): string; + + /** + * Returns the CSV header row for this entity type + * @return Tab-separated header string with column names + */ + getCsvHeader(): string; +} diff --git a/parser/src/language-detectors/file-system-helper.ts b/parser/src/language-detectors/file-system-helper.ts new file mode 100644 index 000000000..38982d746 --- /dev/null +++ b/parser/src/language-detectors/file-system-helper.ts @@ -0,0 +1,33 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export class FileSystemHelper { + /** + * Checks if directory contains files with specific extension + */ + static async hasFilesWithExtension( + dirPath: string, + extension: string, + maxDepth: number = 2 + ): Promise { + if (maxDepth <= 0) return false; + + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(extension)) { + return true; + } + if (entry.isDirectory() && !entry.name.startsWith('.')) { + const subPath = path.join(dirPath, entry.name); + const found = await this.hasFilesWithExtension(subPath, extension, maxDepth - 1); + if (found) return true; + } + } + return false; + } catch (error) { + return false; + } + } +} diff --git a/parser/src/language-detectors/index.ts b/parser/src/language-detectors/index.ts new file mode 100644 index 000000000..407441922 --- /dev/null +++ b/parser/src/language-detectors/index.ts @@ -0,0 +1,5 @@ +export type { LanguageDetector } from '@/language-detectors/language-detector'; +export { JavaDetector } from '@/language-detectors/java-detector'; +export { FileSystemHelper } from '@/language-detectors/file-system-helper'; +export { JavaScriptDetector } from './javascript-detector'; +export { PythonDetector } from './python-detector'; diff --git a/parser/src/language-detectors/java-detector.ts b/parser/src/language-detectors/java-detector.ts new file mode 100644 index 000000000..4bc03f2c6 --- /dev/null +++ b/parser/src/language-detectors/java-detector.ts @@ -0,0 +1,92 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { LanguageDetector } from './language-detector'; + +import { FILE_EXTENSIONS } from '@/constants/consts'; +import { ProjectLanguage } from '@/types/ProjectInfo'; + +export class JavaDetector implements LanguageDetector { + readonly language = ProjectLanguage.JAVA; + readonly MAX_DEPTH = 5; + + async isProject(projectPath: string): Promise { + try { + const files = await fs.readdir(projectPath); + + const hasPom = files.includes('pom.xml'); + const hasGradle = files.includes('build.gradle') || files.includes('build.gradle.kts'); + const hasMaven = files.includes('mvnw') || files.includes('mvnw.cmd'); + + if (hasPom || hasGradle || hasMaven) { + return true; + } + + const hasSrcMain = files.includes('src'); + if (hasSrcMain) { + const srcPath = path.join(projectPath, 'src'); + const srcStats = await fs.stat(srcPath); + if (srcStats.isDirectory()) { + const srcContents = await fs.readdir(srcPath); + if (srcContents.includes('main')) { + const mainPath = path.join(srcPath, 'main'); + const mainContents = await fs.readdir(mainPath); + if (mainContents.includes('java')) { + return true; + } + } + } + } + + return await this.hasSourceFiles(projectPath); + } catch (error) { + return false; + } + } + + async detectBuildSystem(projectPath: string): Promise { + try { + const files = await fs.readdir(projectPath); + + if (files.includes('pom.xml')) { + return 'Maven'; + } + if (files.includes('build.gradle') || files.includes('build.gradle.kts')) { + return 'Gradle'; + } + return undefined; + } catch (error) { + return undefined; + } + } + + async hasSourceFiles(projectPath: string): Promise { + return this.hasFilesWithExtension(projectPath, FILE_EXTENSIONS.JAVA, this.MAX_DEPTH); + } + + private async hasFilesWithExtension( + dirPath: string, + extension: string, + maxDepth: number + ): Promise { + if (maxDepth <= 0) return false; + + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(extension)) { + return true; + } + if (entry.isDirectory() && !entry.name.startsWith('.')) { + const subPath = path.join(dirPath, entry.name); + const found = await this.hasFilesWithExtension(subPath, extension, maxDepth - 1); + if (found) return true; + } + } + return false; + } catch (error) { + return false; + } + } +} diff --git a/parser/src/language-detectors/javascript-detector.ts b/parser/src/language-detectors/javascript-detector.ts new file mode 100644 index 000000000..6935e48f7 --- /dev/null +++ b/parser/src/language-detectors/javascript-detector.ts @@ -0,0 +1,137 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { LanguageDetector } from './language-detector'; + +import { JS_SKIP_DIRECTORIES } from '@/constants/javascript-constants'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { isJavaScriptSourceFile } from '@/utils/javascript'; + +/** + * Detects JavaScript projects. + * + * ## The hard part is not finding JavaScript; it is not claiming TypeScript + * + * Almost every TypeScript project contains JavaScript — config files, build + * scripts, compiled output — and almost every one has a `package.json`. So a + * detector that matches on `package.json`, or on the mere presence of a `.js` + * file, claims TypeScript repositories and their facts land under the wrong + * language. + * + * Two rules prevent that: + * + * 1. **A `tsconfig.json` disqualifies the directory outright.** It DEFINES a + * TypeScript program, which is a stronger claim than any JavaScript manifest + * can make, and `TypeScriptDetector` runs first for the same reason. + * 2. **A `package.json` alone is not enough.** JavaScript source must actually + * be present. `package.json` is the manifest of *both* languages and of + * plenty of repositories that contain neither. + * + * ## `isProject` is SHALLOW and `hasSourceFiles` is deep + * + * The same split the Python and TypeScript detectors make, for the reason the + * Python one records: the scanner stops descending as soon as a directory claims + * to be a project, so answering yes for any ancestor that merely *contains* + * JavaScript swallows every sub-project beneath it. A monorepo root with + * `packages/a` and `packages/b` would become one project and one of them would + * never be analysed. + * + * `node_modules` is excluded by name during the walk. Not a scale optimisation: + * those files are real JavaScript, and analysing them as PROJECT code stages + * third-party declarations into `js_*` when they belong in `lib_js_*` — the one + * distinction the whole provenance split exists to make. + */ +export class JavaScriptDetector implements LanguageDetector { + readonly language = ProjectLanguage.JAVASCRIPT; + readonly MAX_DEPTH = 5; + + /** + * A config that claims the directory for TypeScript. + * + * `jsconfig.json` is deliberately NOT here: it is the JavaScript spelling of + * the same file and is a positive signal, not a disqualifying one. + */ + private static readonly TYPESCRIPT_MANIFESTS = [ + 'tsconfig.json', + 'tsconfig.base.json', + ]; + + /** Files that mark a JavaScript project, given that source is also present. */ + private static readonly MANIFESTS = [ + 'package.json', + 'jsconfig.json', + ]; + + private static readonly SKIP = new Set(JS_SKIP_DIRECTORIES); + + async isProject(projectPath: string): Promise { + try { + const files = await fs.readdir(projectPath); + if (JavaScriptDetector.TYPESCRIPT_MANIFESTS.some((m) => files.includes(m))) { + return false; + } + if (!JavaScriptDetector.MANIFESTS.some((m) => files.includes(m))) { + // No manifest at all: a loose directory of scripts is still a project, + // and 15.4% of measured files have no `package.json` anywhere above + // them. Source is then the only signal there is. + return this.hasJavaScriptSource(projectPath, 1); + } + // A manifest AND source. `package.json` alone matches a TypeScript + // package, a Python package with an npm wrapper, and an empty repository. + return this.hasJavaScriptSource(projectPath, 1); + } catch { + return false; + } + } + + async detectBuildSystem(projectPath: string): Promise { + try { + const files = await fs.readdir(projectPath); + if (files.includes('pnpm-lock.yaml')) { + return 'pnpm'; + } + if (files.includes('yarn.lock')) { + return 'Yarn'; + } + if (files.includes('bun.lockb') || files.includes('bun.lock')) { + return 'Bun'; + } + if (files.includes('package-lock.json') || files.includes('package.json')) { + return 'npm'; + } + return undefined; + } catch { + return undefined; + } + } + + /** Deep, unlike {@link isProject}: "is there JavaScript under here" is a different question. */ + async hasSourceFiles(projectPath: string): Promise { + return this.hasJavaScriptSource(projectPath, this.MAX_DEPTH); + } + + private async hasJavaScriptSource(dirPath: string, maxDepth: number): Promise { + if (maxDepth <= 0) { + return false; + } + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() + && isJavaScriptSourceFile(entry.name)) { + return true; + } + if (entry.isDirectory() + && !entry.name.startsWith('.') + && !JavaScriptDetector.SKIP.has(entry.name)) { + if (await this.hasJavaScriptSource(path.join(dirPath, entry.name), maxDepth - 1)) { + return true; + } + } + } + return false; + } catch { + return false; + } + } +} diff --git a/parser/src/language-detectors/language-detector.ts b/parser/src/language-detectors/language-detector.ts new file mode 100644 index 000000000..978d0d57a --- /dev/null +++ b/parser/src/language-detectors/language-detector.ts @@ -0,0 +1,29 @@ +import { ProjectLanguage } from '@/types/ProjectInfo'; + +export interface LanguageDetector { + /** + * The language this detector is responsible for + */ + readonly language: ProjectLanguage; + + /** + * Checks if the given directory is a project of this language + * @param projectPath Path to the directory to check + * @returns True if this is a project of this language + */ + isProject(projectPath: string): Promise; + + /** + * Detects the build system or framework for this language + * @param projectPath Path to the project directory + * @returns Build system/framework name, or undefined if not detected + */ + detectBuildSystem(projectPath: string): Promise; + + /** + * Checks if directory has source files of this language + * @param projectPath Path to check + * @returns True if source files are found + */ + hasSourceFiles(projectPath: string): Promise; +} diff --git a/parser/src/language-detectors/python-detector.ts b/parser/src/language-detectors/python-detector.ts new file mode 100644 index 000000000..5a8207fe9 --- /dev/null +++ b/parser/src/language-detectors/python-detector.ts @@ -0,0 +1,119 @@ +import { PYTHON_PACKAGE_INIT_FILENAMES } from '@/constants/python-constants'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { LanguageDetector } from './language-detector'; + +import { FILE_EXTENSIONS } from '@/constants/consts'; +import { ProjectLanguage } from '@/types/ProjectInfo'; + +/** + * Detects Python projects. + * + * Python has no single required manifest the way Maven has `pom.xml`, and the + * common ones are all optional: a package can ship with `pyproject.toml`, + * `setup.py`, `setup.cfg`, a requirements file, a Pipfile, or none of them. A + * directory of `.py` files with no manifest at all is still a Python project and + * is what most analysis targets actually look like, so source files are a + * sufficient signal rather than a fallback. + * + * Virtualenvs are excluded by name during the walk. A `site-packages` tree is + * thousands of third-party files that would be analysed as if they were the + * project, and `.venv` sitting in the root is the normal case, not the exception. + */ +export class PythonDetector implements LanguageDetector { + readonly language = ProjectLanguage.PYTHON; + readonly MAX_DEPTH = 5; + + /** Manifests that mark a Python project even before any source is found. */ + private static readonly MANIFESTS = [ + 'pyproject.toml', + 'setup.py', + 'setup.cfg', + 'requirements.txt', + 'requirements-dev.txt', + 'Pipfile', + 'poetry.lock', + 'tox.ini', + 'environment.yml', + ]; + + /** Directories that contain Python but are never the project under analysis. */ + private static readonly SKIP = new Set([ + 'site-packages', 'dist-packages', 'node_modules', '__pycache__', + 'venv', '.venv', 'env', '.env', '.tox', '.nox', 'build', 'dist', + '.mypy_cache', '.pytest_cache', '.ruff_cache', '.eggs', + ]); + + async isProject(projectPath: string): Promise { + try { + const files = await fs.readdir(projectPath); + if (PythonDetector.MANIFESTS.some((m) => files.includes(m))) { + return true; + } + // A package directory is a strong signal even with no manifest above it. + // Both extensions count: a stub-only distribution ships `__init__.pyi` + // and no `.py` at all. + if (PYTHON_PACKAGE_INIT_FILENAMES.some((marker) => files.includes(marker))) { + return true; + } + // SHALLOW on purpose. The scanner stops descending as soon as a directory + // claims to be a project, so answering yes for any ancestor that merely + // CONTAINS Python swallows every sub-project beneath it. A monorepo root + // with svc-java/ and svc-python/ was detected as one Python project and + // the Java service was never analysed at all. + // + // A project root is where the markers are, not any directory above them. + return await this.hasPythonSource(projectPath, 1); + } catch { + return false; + } + } + + async detectBuildSystem(projectPath: string): Promise { + try { + const files = await fs.readdir(projectPath); + if (files.includes('poetry.lock')) return 'Poetry'; + if (files.includes('Pipfile')) return 'Pipenv'; + if (files.includes('pyproject.toml')) return 'PEP 517'; + if (files.includes('setup.py') || files.includes('setup.cfg')) return 'setuptools'; + if (files.includes('requirements.txt')) return 'pip'; + return undefined; + } catch { + return undefined; + } + } + + /** + * Deep, unlike {@link isProject}. This answers "is there Python under here", + * which is the right question for the reported `hasSourceFiles` flag and the + * wrong one for deciding where a project begins. + */ + async hasSourceFiles(projectPath: string): Promise { + return this.hasPythonSource(projectPath, this.MAX_DEPTH); + } + + private async hasPythonSource(dirPath: string, maxDepth: number): Promise { + if (maxDepth <= 0) return false; + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() + && (entry.name.endsWith(FILE_EXTENSIONS.PYTHON) + || entry.name.endsWith(FILE_EXTENSIONS.PYTHON_STUB))) { + return true; + } + if (entry.isDirectory() + && !entry.name.startsWith('.') + && !PythonDetector.SKIP.has(entry.name)) { + if (await this.hasPythonSource(path.join(dirPath, entry.name), maxDepth - 1)) { + return true; + } + } + } + return false; + } catch { + return false; + } + } +} diff --git a/parser/src/language-detectors/typescript-detector.ts b/parser/src/language-detectors/typescript-detector.ts new file mode 100644 index 000000000..7f64637bb --- /dev/null +++ b/parser/src/language-detectors/typescript-detector.ts @@ -0,0 +1,103 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { LanguageDetector } from './language-detector'; + +import { TS_SKIP_DIRECTORIES, TS_SOURCE_EXTENSIONS } from '@/constants/typescript-constants'; +import { ProjectLanguage } from '@/types/ProjectInfo'; + +/** + * Detects TypeScript projects. + * + * `tsconfig.json` is the strong signal, and unlike Python's manifests it is not + * merely conventional: it DEFINES the program, so a directory that has one is a + * project boundary by the compiler's own reckoning. That matters more here than + * elsewhere, because a program is also the unit of merge scope — two programs + * have two global scopes, and analysing them as one merges symbols tsc keeps + * apart. + * + * `package.json` alone is not enough: a JavaScript package with no TypeScript in + * it would match, and analysing it would produce an empty fact base attributed + * to a language it does not use. Source files are the fallback signal, exactly + * as they are for Python. + * + * `node_modules` is excluded by name during the walk. It is not a scale + * optimisation — the files in there are real TypeScript, and analysing them as + * PROJECT code would stage tens of thousands of third-party declarations into + * `ts_*` when they belong in `lib_ts_*`, which is the one distinction the whole + * provenance split exists to make. + */ +export class TypeScriptDetector implements LanguageDetector { + readonly language = ProjectLanguage.TYPESCRIPT; + readonly MAX_DEPTH = 5; + + /** Files that mark a TypeScript project before any source is found. */ + private static readonly MANIFESTS = [ + 'tsconfig.json', + 'tsconfig.base.json', + 'jsconfig.json', + ]; + + private static readonly SKIP = new Set(TS_SKIP_DIRECTORIES); + + async isProject(projectPath: string): Promise { + try { + const files = await fs.readdir(projectPath); + if (TypeScriptDetector.MANIFESTS.some((m) => files.includes(m))) { + return true; + } + // SHALLOW on purpose, for the reason the Python detector records: the + // scanner stops descending as soon as a directory claims to be a project, + // so answering yes for any ancestor that merely CONTAINS TypeScript + // swallows every sub-project beneath it. A monorepo root with svc-api/ + // and svc-web/ would become one project and one of them would never be + // analysed. + return await this.hasTypeScriptSource(projectPath, 1); + } catch { + return false; + } + } + + async detectBuildSystem(projectPath: string): Promise { + try { + const files = await fs.readdir(projectPath); + if (files.includes('pnpm-lock.yaml')) return 'pnpm'; + if (files.includes('yarn.lock')) return 'Yarn'; + if (files.includes('bun.lockb') || files.includes('bun.lock')) return 'Bun'; + if (files.includes('package-lock.json')) return 'npm'; + if (files.includes('package.json')) return 'npm'; + if (files.includes('tsconfig.json')) return 'tsc'; + return undefined; + } catch { + return undefined; + } + } + + /** Deep, unlike {@link isProject}: "is there TypeScript under here" is a different question. */ + async hasSourceFiles(projectPath: string): Promise { + return this.hasTypeScriptSource(projectPath, this.MAX_DEPTH); + } + + private async hasTypeScriptSource(dirPath: string, maxDepth: number): Promise { + if (maxDepth <= 0) return false; + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() + && TS_SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension))) { + return true; + } + if (entry.isDirectory() + && !entry.name.startsWith('.') + && !TypeScriptDetector.SKIP.has(entry.name)) { + if (await this.hasTypeScriptSource(path.join(dirPath, entry.name), maxDepth - 1)) { + return true; + } + } + } + return false; + } catch { + return false; + } + } +} diff --git a/parser/src/parsers/base-extractor.ts b/parser/src/parsers/base-extractor.ts new file mode 100644 index 000000000..7341dc722 --- /dev/null +++ b/parser/src/parsers/base-extractor.ts @@ -0,0 +1,15 @@ +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; + +/** + * Base interface for all extractors that parse source code and extract entities + */ +export interface BaseExtractor { + /** + * Extracts dedicated entities from a source file + * @param filePath Path to the source file + * @param fileContent Content of the source file + * @param serviceVersionHash Hash identifying the service version + * @returns Array of extracted entities + */ + extract(filePath: string, fileContent: string, serviceVersionHash: string): T[]; +} diff --git a/parser/src/parsers/code-extractor.ts b/parser/src/parsers/code-extractor.ts new file mode 100644 index 000000000..3587a84fb --- /dev/null +++ b/parser/src/parsers/code-extractor.ts @@ -0,0 +1,148 @@ +import { EntityIdentifiable } from '@/interfaces/EntityIdentifiable'; +import { BaseExtractor } from '@/parsers/base-extractor'; +import { ParserFactory } from '@/parsers/parser-factory'; +import { ProjectLanguage } from '@/types/ProjectInfo'; + +/** + * Generic code extractor that delegates to language-specific extractors + * Coordinates parsing and extraction across different programming languages + */ +export class CodeExtractor { + private parserFactory: ParserFactory; + private extractorRegistry: Map>>; + + constructor(parserFactory?: ParserFactory) { + this.parserFactory = parserFactory || new ParserFactory(); + this.extractorRegistry = new Map(); + } + + /** + * Registers an extractor for a specific language and entity type + * @param language Programming language + * @param entityType Type of entity (e.g., 'TypeRegistry', 'Method') + * @param extractor Extractor implementation + */ + registerExtractor( + language: ProjectLanguage, + entityType: string, + extractor: BaseExtractor + ): void { + if (!this.extractorRegistry.has(language)) { + this.extractorRegistry.set(language, new Map()); + } + this.extractorRegistry.get(language)!.set(entityType, extractor); + } + + /** + * Extracts entities from a source file + * @param language Programming language of the source file + * @param entityType Type of entity to extract + * @param filePath Path to the source file + * @param fileContent Content of the source file + * @param serviceVersionHash Service version hash + * @returns Array of extracted entities + */ + extract( + language: ProjectLanguage, + entityType: string, + filePath: string, + fileContent: string, + serviceVersionHash: string + ): T[] { + const languageExtractors = this.extractorRegistry.get(language); + if (!languageExtractors) { + console.warn(`No extractors registered for language: ${language}`); + return []; + } + + const extractor = languageExtractors.get(entityType); + if (!extractor) { + console.warn(`No extractor registered for ${entityType} in ${language}`); + return []; + } + + try { + return extractor.extract(filePath, fileContent, serviceVersionHash); + } catch (error) { + console.error(`Error extracting ${entityType} from ${filePath}:`, error); + return []; + } + } + + /** + * Gets the extractor for a specific language and entity type + * @param language Programming language + * @param entityType Type of entity to extract + * @returns The extractor instance or undefined + */ + getExtractor( + language: ProjectLanguage, + entityType: string + ): BaseExtractor | undefined { + const languageExtractors = this.extractorRegistry.get(language); + if (!languageExtractors) { + return undefined; + } + return languageExtractors.get(entityType); + } + + /** + * Extracts entities from multiple files + * @param language Programming language + * @param entityType Type of entity to extract + * @param files Array of file paths and contents + * @param serviceVersionHash Service version hash + * @returns Array of all extracted entities + */ + extractFromFiles( + language: ProjectLanguage, + entityType: string, + files: { path: string; content: string }[], + serviceVersionHash: string + ): T[] { + const allEntities: T[] = []; + + for (const file of files) { + const entities = this.extract( + language, + entityType, + file.path, + file.content, + serviceVersionHash + ); + allEntities.push(...entities); + } + + return allEntities; + } + + /** + * Gets the parser for a specific language + * @param language Programming language + * @returns Language parser or undefined + */ + getParser(language: ProjectLanguage) { + return this.parserFactory.getParser(language); + } + + /** + * Checks if extraction is supported for a language and entity type + * @param language Programming language + * @param entityType Type of entity + * @returns True if supported + */ + isSupported(language: ProjectLanguage, entityType: string): boolean { + const languageExtractors = this.extractorRegistry.get(language); + return languageExtractors?.has(entityType) ?? false; + } + + /** + * Gets all registered entity types for a language + * @param language Programming language + * @returns Array of entity type names + */ + getRegisteredEntityTypes(language: ProjectLanguage): string[] { + const languageExtractors = this.extractorRegistry.get(language); + return languageExtractors ? Array.from(languageExtractors.keys()) : []; + } +} diff --git a/parser/src/parsers/gradle/dependency-coordinate-parser.ts b/parser/src/parsers/gradle/dependency-coordinate-parser.ts new file mode 100644 index 000000000..ec449d651 --- /dev/null +++ b/parser/src/parsers/gradle/dependency-coordinate-parser.ts @@ -0,0 +1,371 @@ +import { GradleDependencyNotation } from '@/enums/gradle/declarations/GradleDependencyNotation'; +import { GradleVersionSource } from '@/enums/gradle/dependencies/GradleVersionSource'; + +/** One coordinate split out of a dependency declaration's argument text. */ +export interface ParsedCoordinate { + notation: GradleDependencyNotation; + group: string; + artifact: string; + version: string; + classifier: string; + extension: string; + versionSource: GradleVersionSource; + /** `:core` for a project dependency. */ + projectPath: string; + /** The file pattern for files()/fileTree(). */ + fileSpec: string; + /** `spring.boot.starter` for `libs.spring.boot.starter`. */ + catalogAlias: string; +} + +/** + * Splits a dependency declaration's arguments into coordinates. + * + * ## Why a version is not just "the third colon-separated field" + * + * `implementation 'com.example:lib'` has two fields and is a complete, valid + * dependency: a BOM or platform supplies the version. `implementation + * 'com.example:lib:1.0:tests@jar'` has five things in it. `implementation + * libs.spring.core` has none of them — the coordinate lives in a TOML file. + * `implementation depString` has the whole thing behind a variable. + * + * All four produce a row. What separates them is `versionSource`, which says + * whether an empty version column means "the build omitted it deliberately", + * "the parser could not read it", or "it is in the catalog and the linker will + * fill it in". A consumer asking which projects pin a vulnerable version needs + * that distinction; without it, every unread version reads as "not pinned". + * + * ## Returns a list, not one coordinate + * + * `files('a.jar', 'b.jar')` is one declaration and two artifacts, and + * `libs.bundles.spring` is one declaration and however many the bundle holds. + * Returning a single coordinate would force the caller to either drop the rest + * or pack them into one string. + */ +export class DependencyCoordinateParser { + /** + * @param argsText The declaration's argument text, quotes and all. + */ + static parse(argsText: string): ParsedCoordinate[] { + const text = argsText.trim(); + if (!text) return []; + + const wrapper = this.matchWrapper(text); + if (wrapper) return this.parseWrapper(wrapper.name, wrapper.inner, text); + + // `projects.core.testing` is a TYPE-SAFE PROJECT ACCESSOR, not a catalog + // one — it means `project(":core:testing")`. Both are bare dotted chains, + // so a check for "dotted identifier" alone sends every internal module + // dependency into the catalog relation, where it matches nothing and + // reports no coordinate. Android's Now in Android has 100 of these against + // 245 total dependencies; they are most of its own module graph. + if (this.isProjectAccessor(text)) return [this.projectAccessorCoordinate(text)]; + + if (this.isCatalogAccessor(text)) return [this.catalogCoordinate(text)]; + + if (this.isMapNotation(text)) return [this.parseMapNotation(text)]; + + const literal = this.stripQuotes(text); + if (literal !== null) return [this.parseGav(literal)]; + + // Not quoted, not a call, not a map: the coordinate is behind a name this + // parser cannot evaluate. Recorded as such rather than guessed at. + return [{ + ...this.empty(), + notation: GradleDependencyNotation.VARIABLE_REFERENCE, + versionSource: GradleVersionSource.VARIABLE, + }]; + } + + // ── wrappers: project(), files(), platform(), testFixtures(), … ────────── + + private static readonly WRAPPER_NOTATIONS: Record = { + project: GradleDependencyNotation.PROJECT, + files: GradleDependencyNotation.FILES, + fileTree: GradleDependencyNotation.FILE_TREE, + platform: GradleDependencyNotation.PLATFORM, + enforcedPlatform: GradleDependencyNotation.ENFORCED_PLATFORM, + testFixtures: GradleDependencyNotation.TEST_FIXTURES, + gradleApi: GradleDependencyNotation.GRADLE_API, + gradleTestKit: GradleDependencyNotation.GRADLE_TEST_KIT, + localGroovy: GradleDependencyNotation.LOCAL_GROOVY, + create: GradleDependencyNotation.CREATE_METHOD, + }; + + private static matchWrapper(text: string): { name: string; inner: string } | null { + const m = /^([A-Za-z_][A-Za-z0-9_.]*)\s*\(([\s\S]*)\)$/.exec(text); + if (!m) return null; + const name = (m[1] ?? '').split('.').pop() ?? ''; + if (!(name in this.WRAPPER_NOTATIONS)) return null; + return { name, inner: m[2] ?? '' }; + } + + private static parseWrapper(name: string, inner: string, whole: string): ParsedCoordinate[] { + const notation = this.WRAPPER_NOTATIONS[name]!; + const innerText = inner.trim(); + + switch (notation) { + case GradleDependencyNotation.PROJECT: { + // project(':core') and project(path: ':core', configuration: 'x') + const pathArg = /(?:^|[(,]\s*)path\s*:\s*(['"])([^'"]*)\1/.exec(whole); + const projectPath = pathArg + ? (pathArg[2] ?? '') + : (this.stripQuotes(this.firstArgument(innerText)) ?? ''); + return [{ ...this.empty(), notation, projectPath, versionSource: GradleVersionSource.ABSENT }]; + } + + case GradleDependencyNotation.FILES: + case GradleDependencyNotation.FILE_TREE: { + // Every argument is its own artifact. One row each, so a query for a + // jar by name does not have to split a packed column. + const specs = this.splitTopLevel(innerText, ',') + .map((a) => this.stripQuotes(a.trim()) ?? a.trim()) + .filter(Boolean); + if (!specs.length) { + return [{ ...this.empty(), notation, fileSpec: innerText, versionSource: GradleVersionSource.ABSENT }]; + } + return specs.map((fileSpec) => ({ + ...this.empty(), notation, fileSpec, versionSource: GradleVersionSource.ABSENT, + })); + } + + case GradleDependencyNotation.GRADLE_API: + case GradleDependencyNotation.GRADLE_TEST_KIT: + case GradleDependencyNotation.LOCAL_GROOVY: + // Supplied by the running Gradle distribution; version is its version. + return [{ ...this.empty(), notation, versionSource: GradleVersionSource.ABSENT }]; + + default: { + // platform(…), enforcedPlatform(…), testFixtures(…), create(…) all + // wrap a real coordinate. Parse the inside, keep the outer notation: + // the fact that it is a platform is what makes the row mean something + // different from the same coordinate declared directly. + const innerCoords = this.parse(innerText); + if (!innerCoords.length) { + return [{ ...this.empty(), notation, versionSource: GradleVersionSource.UNKNOWN }]; + } + return innerCoords.map((c) => ({ ...c, notation })); + } + } + } + + // ── version catalog accessors ──────────────────────────────────────────── + + /** + * `libs.spring.core`, `libs.bundles.spring`, `libs.plugins.boot`. + * + * Matched structurally: a dotted chain of identifiers with no quotes, no + * parentheses and no colon. The leading segment is the catalog name, which + * is `libs` by convention but can be anything settings registered, so it is + * not hard-coded — the alias is everything after the first dot, and the + * linker matches it against the catalogs actually found. + */ + /** + * Gradle generates these from the settings file when + * `enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")` is on, rooted at the + * fixed name `projects`. + */ + private static isProjectAccessor(text: string): boolean { + return /^projects\.[A-Za-z_][A-Za-z0-9_.]*$/.test(text); + } + + /** + * `projects.core.dataTest` → a provisional `:core:dataTest`. + * + * Provisional because Gradle camel-cases the accessor from the project name, + * so `:core:data-test` and `:core:dataTest` generate the same accessor and + * the mapping cannot be inverted from the text alone. The linker matches it + * against the projects the settings file actually declared, which is the + * evidence that settles it; until then this is the literal reading. + */ + private static projectAccessorCoordinate(text: string): ParsedCoordinate { + const segments = text.split('.').slice(1).filter(Boolean); + return { + ...this.empty(), + notation: GradleDependencyNotation.PROJECT, + projectPath: ':' + segments.join(':'), + versionSource: GradleVersionSource.ABSENT, + }; + } + + private static isCatalogAccessor(text: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(text) + && !text.includes(':'); + } + + private static catalogCoordinate(text: string): ParsedCoordinate { + const segments = text.split('.'); + const rest = segments.slice(1); + const isBundle = rest[0] === 'bundles'; + const alias = (isBundle || rest[0] === 'plugins' || rest[0] === 'versions') + ? rest.slice(1).join('.') + : rest.join('.'); + + return { + ...this.empty(), + notation: isBundle + ? GradleDependencyNotation.VERSION_CATALOG_BUNDLE + : GradleDependencyNotation.VERSION_CATALOG_ACCESSOR, + catalogAlias: alias, + // Not CATALOG yet. The catalog may not be in the corpus at all, and + // claiming the version came from one before the link is made would put a + // source on a row that still has no version. + versionSource: GradleVersionSource.UNKNOWN, + }; + } + + // ── map notation ───────────────────────────────────────────────────────── + + private static isMapNotation(text: string): boolean { + return /(^|[\s,(])(group|name|module|version|classifier|ext)\s*:/.test(text); + } + + private static parseMapNotation(text: string): ParsedCoordinate { + const read = (key: string): string => { + const m = new RegExp(`(?:^|[\\s,(])${key}\\s*:\\s*(['"])([^'"]*)\\1`).exec(text); + if (m) return m[2] ?? ''; + // Unquoted value — a variable rather than a literal. + const bare = new RegExp(`(?:^|[\\s,(])${key}\\s*:\\s*([A-Za-z_$][\\w.$]*)`).exec(text); + return bare ? (bare[1] ?? '') : ''; + }; + + let group = read('group'); + let artifact = read('name'); + const module = read('module'); + if (module) { + const colon = module.indexOf(':'); + group = colon >= 0 ? module.slice(0, colon) : module; + artifact = colon >= 0 ? module.slice(colon + 1) : ''; + } + + const version = read('version'); + return { + ...this.empty(), + notation: GradleDependencyNotation.MAP_NOTATION, + group, + artifact, + version, + classifier: read('classifier'), + extension: read('ext'), + versionSource: this.versionSourceFor(version), + }; + } + + // ── plain GAV strings ──────────────────────────────────────────────────── + + /** + * `group:artifact:version:classifier@ext`, with every field after the second + * optional. + */ + private static parseGav(literal: string): ParsedCoordinate { + let text = literal; + let extension = ''; + + const at = text.lastIndexOf('@'); + // An `@` inside an interpolation is not an extension separator. + if (at > 0 && !text.slice(at).includes('}')) { + extension = text.slice(at + 1); + text = text.slice(0, at); + } + + const parts = this.splitGavOutsideInterpolation(text); + const group = parts[0] ?? ''; + const artifact = parts[1] ?? ''; + const version = parts[2] ?? ''; + const classifier = parts[3] ?? ''; + + let notation: GradleDependencyNotation; + if (parts.length < 2) { + // A single field is not a coordinate. Saying STRING_NOTATION here is what + // hands a consumer a group with no artifact and no way to tell. + notation = GradleDependencyNotation.UNKNOWN; + } else if (text.includes('$')) { + notation = GradleDependencyNotation.INTERPOLATED_STRING; + } else if (extension) { + notation = GradleDependencyNotation.STRING_WITH_EXTENSION; + } else if (classifier) { + notation = GradleDependencyNotation.STRING_WITH_CLASSIFIER; + } else { + notation = GradleDependencyNotation.STRING_NOTATION; + } + + return { + ...this.empty(), + notation, group, artifact, version, classifier, extension, + versionSource: this.versionSourceFor(version), + }; + } + + /** + * Splits on `:` at interpolation depth zero. `"g:a:${versions.x}"` must not + * split inside the `${…}`, and a Groovy interpolation can legally contain a + * colon — `${map['a:b']}` — so a plain split produces a version of `${map[`. + */ + private static splitGavOutsideInterpolation(text: string): string[] { + const out: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '$' && text[i + 1] === '{') { depth++; i++; continue; } + if (ch === '}' && depth > 0) { depth--; continue; } + if (ch === ':' && depth === 0) { out.push(text.slice(start, i)); start = i + 1; } + } + out.push(text.slice(start)); + return out; + } + + private static versionSourceFor(version: string): GradleVersionSource { + if (!version) return GradleVersionSource.ABSENT; + if (version.includes('$')) return GradleVersionSource.INTERPOLATED; + return GradleVersionSource.LITERAL; + } + + // ── shared text helpers ────────────────────────────────────────────────── + + private static firstArgument(text: string): string { + return (this.splitTopLevel(text, ',')[0] ?? '').trim(); + } + + /** Splits on a separator outside quotes and outside brackets. */ + static splitTopLevel(text: string, sep: string): string[] { + const out: string[] = []; + let depth = 0; + let inSingle = false; + let inDouble = false; + let start = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (!inSingle && !inDouble) { + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth--; + else if (ch === sep && depth === 0) { out.push(text.slice(start, i)); start = i + 1; } + } + } + out.push(text.slice(start)); + return out; + } + + /** Returns null when the text is not a quoted string. */ + private static stripQuotes(text: string): string | null { + const t = text.trim(); + if (t.length < 2) return null; + if (t.startsWith("'''") && t.endsWith("'''") && t.length >= 6) return t.slice(3, -3); + if (t.startsWith('"""') && t.endsWith('"""') && t.length >= 6) return t.slice(3, -3); + if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) { + return t.slice(1, -1); + } + return null; + } + + private static empty(): ParsedCoordinate { + return { + notation: GradleDependencyNotation.UNKNOWN, + group: '', artifact: '', version: '', classifier: '', extension: '', + versionSource: GradleVersionSource.UNKNOWN, + projectPath: '', fileSpec: '', catalogAlias: '', + }; + } +} diff --git a/parser/src/parsers/gradle/extractors/gradle-catalog-extractor.ts b/parser/src/parsers/gradle/extractors/gradle-catalog-extractor.ts new file mode 100644 index 000000000..b5b0b86ff --- /dev/null +++ b/parser/src/parsers/gradle/extractors/gradle-catalog-extractor.ts @@ -0,0 +1,299 @@ +import * as path from 'path'; + +import { GradleCatalogEntry } from '@/analysis-types/gradle/GradleCatalogEntry'; +import { GradleParseGap } from '@/analysis-types/gradle/GradleParseGap'; +import { GradleCatalogEntryKind } from '@/enums/gradle/catalog/GradleCatalogEntryKind'; +import { GradleCatalogNotation } from '@/enums/gradle/catalog/GradleCatalogNotation'; +import { GradleParseGapReason } from '@/enums/gradle/parse-gaps/GradleParseGapReason'; +import { + CatalogTable, RawCatalogEntry, VersionCatalogParser, +} from '@/parsers/gradle/version-catalog-parser'; + +export interface CatalogExtractionResult { + entries: GradleCatalogEntry[]; + parseGaps: GradleParseGap[]; +} + +/** + * Turns a version catalog TOML file into classified catalog rows. + * + * The classification is the whole value here. A raw reader gives back + * `{ module: "a:b", "version.ref": "c" }`; what a dependency query needs is a + * group, an artifact, and an honest statement about where the version came + * from. Those are different jobs and this is the second one. + * + * ## Version refs are resolved, but only within one catalog + * + * Gradle resolves `version.ref` against the `[versions]` table of the SAME + * catalog — a ref never reaches across files. So the pass runs here, over one + * file, rather than in the project linker. An entry whose ref names nothing + * keeps an empty `resolvedVersion`: Gradle would fail that build, and echoing + * the ref back as if it were a version number would hide a real defect in the + * build under a plausible looking row. + */ +export class GradleCatalogExtractor { + /** + * @param catalogName The accessor prefix the build uses for this catalog. + * `gradle/libs.versions.toml` is reached as `libs`; a + * catalog registered under another name in settings is + * reached under that name instead. + */ + extract( + filePath: string, + fileContent: string, + scriptHash: string, + baseMservPath: string, + serviceVersionHash: string, + catalogName?: string + ): CatalogExtractionResult { + const name = catalogName ?? GradleCatalogExtractor.defaultCatalogName(filePath); + const parsed = VersionCatalogParser.parse(fileContent); + + const entries: GradleCatalogEntry[] = []; + for (const raw of parsed.entries) { + const entry = this.toEntry(raw, name, scriptHash, filePath, baseMservPath, serviceVersionHash); + if (entry) entries.push(entry); + } + + this.resolveVersionRefs(entries); + + const parseGaps = parsed.unparsedLines.map((u) => + GradleParseGap.builder( + GradleParseGapReason.ERROR_NODE, + scriptHash, filePath, baseMservPath, + u.line, u.line, 0, u.text.length, + serviceVersionHash + ) + .withNodeType('catalog_entry') + .withOriginalText(u.text) + .build() + ); + + return { entries, parseGaps }; + } + + /** + * `gradle/libs.versions.toml` → `libs`. + * `gradle/testLibs.versions.toml` → `testLibs`. + */ + static defaultCatalogName(filePath: string): string { + const base = path.basename(filePath); + const stripped = base.replace(/\.versions\.toml$/i, ''); + return stripped === base ? 'libs' : stripped; + } + + private toEntry( + raw: RawCatalogEntry, + catalogName: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + serviceVersionHash: string + ): GradleCatalogEntry | null { + const kind = GradleCatalogExtractor.KIND_BY_TABLE[raw.table]; + if (!kind) return null; + + switch (raw.table) { + case 'versions': return this.versionEntry(raw, kind, catalogName, scriptHash, filePath, baseMservPath, serviceVersionHash); + case 'bundles': return this.bundleEntry(raw, kind, catalogName, scriptHash, filePath, baseMservPath, serviceVersionHash); + case 'plugins': return this.pluginEntry(raw, kind, catalogName, scriptHash, filePath, baseMservPath, serviceVersionHash); + case 'libraries': return this.libraryEntry(raw, kind, catalogName, scriptHash, filePath, baseMservPath, serviceVersionHash); + default: return null; + } + } + + private static readonly KIND_BY_TABLE: Record = { + versions: GradleCatalogEntryKind.VERSION, + libraries: GradleCatalogEntryKind.LIBRARY, + bundles: GradleCatalogEntryKind.BUNDLE, + plugins: GradleCatalogEntryKind.PLUGIN, + }; + + private versionEntry( + raw: RawCatalogEntry, kind: GradleCatalogEntryKind, catalogName: string, + scriptHash: string, filePath: string, baseMservPath: string, svh: string + ): GradleCatalogEntry { + // A [versions] entry is either a plain string or a rich constraint. + // A rich one is NOT collapsed to a single version: `strictly = "[1.0, 2.0["` + // is a range, and writing it into the version column would turn a + // constraint into a pin for every consumer downstream. + if (raw.inlineTable) { + const rich = this.richConstraintText(raw.inlineTable); + const preferred = raw.inlineTable['require'] ?? raw.inlineTable['prefer'] ?? ''; + return this.build(kind, raw, GradleCatalogNotation.VERSION_RICH, catalogName, scriptHash, filePath, baseMservPath, svh) + .withVersion(preferred) + .withRichVersionConstraint(rich) + .build(); + } + return this.build(kind, raw, GradleCatalogNotation.VERSION_LITERAL, catalogName, scriptHash, filePath, baseMservPath, svh) + .withVersion(raw.stringValue ?? '') + .build(); + } + + private bundleEntry( + raw: RawCatalogEntry, kind: GradleCatalogEntryKind, catalogName: string, + scriptHash: string, filePath: string, baseMservPath: string, svh: string + ): GradleCatalogEntry { + return this.build(kind, raw, GradleCatalogNotation.BUNDLE_LIST, catalogName, scriptHash, filePath, baseMservPath, svh) + .withBundleMembers((raw.arrayValue ?? []).join(',')) + .build(); + } + + private pluginEntry( + raw: RawCatalogEntry, kind: GradleCatalogEntryKind, catalogName: string, + scriptHash: string, filePath: string, baseMservPath: string, svh: string + ): GradleCatalogEntry { + if (raw.stringValue !== undefined) { + // boot = "org.springframework.boot:3.2.2" — id and version on one colon. + const idx = raw.stringValue.lastIndexOf(':'); + const id = idx > 0 ? raw.stringValue.slice(0, idx) : raw.stringValue; + const version = idx > 0 ? raw.stringValue.slice(idx + 1) : ''; + return this.build(kind, raw, GradleCatalogNotation.PLUGIN_SHORTHAND, catalogName, scriptHash, filePath, baseMservPath, svh) + .withPluginId(id) + .withVersion(version) + .build(); + } + + const t = raw.inlineTable ?? {}; + const versionRef = t['version.ref'] ?? ''; + const notation = versionRef + ? GradleCatalogNotation.PLUGIN_ID_VERSION_REF + : GradleCatalogNotation.PLUGIN_ID_LITERAL; + + return this.build(kind, raw, notation, catalogName, scriptHash, filePath, baseMservPath, svh) + .withPluginId(t['id'] ?? '') + .withVersion(t['version'] ?? '') + .withVersionRef(versionRef) + .withRichVersionConstraint(this.richConstraintText(t)) + .build(); + } + + private libraryEntry( + raw: RawCatalogEntry, kind: GradleCatalogEntryKind, catalogName: string, + scriptHash: string, filePath: string, baseMservPath: string, svh: string + ): GradleCatalogEntry { + if (raw.stringValue !== undefined) { + // a = "com.example:lib:1.0" — the shorthand always carries its version. + const parts = raw.stringValue.split(':'); + return this.build(kind, raw, GradleCatalogNotation.SHORTHAND_STRING, catalogName, scriptHash, filePath, baseMservPath, svh) + .withGroup(parts[0] ?? '') + .withArtifact(parts[1] ?? '') + .withVersion(parts[2] ?? '') + .build(); + } + + const t = raw.inlineTable ?? {}; + let group = t['group'] ?? ''; + let artifact = t['name'] ?? ''; + const module = t['module'] ?? ''; + if (module) { + const colon = module.indexOf(':'); + group = colon >= 0 ? module.slice(0, colon) : module; + artifact = colon >= 0 ? module.slice(colon + 1) : ''; + } + + const version = t['version'] ?? ''; + const versionRef = t['version.ref'] ?? ''; + const rich = this.richConstraintText(t); + + // The notation records which of the three version forms was used, so a + // consumer can tell "no version because a BOM supplies it" from "no + // version yet because the ref has not been resolved". + let notation: GradleCatalogNotation; + if (rich && !version && !versionRef) { + notation = GradleCatalogNotation.VERSION_RICH; + } else if (module) { + notation = versionRef + ? GradleCatalogNotation.MODULE_VERSION_REF + : version + ? GradleCatalogNotation.MODULE_LITERAL + : GradleCatalogNotation.MODULE_NO_VERSION; + } else { + notation = versionRef + ? GradleCatalogNotation.GROUP_NAME_VERSION_REF + : version + ? GradleCatalogNotation.GROUP_NAME_LITERAL + : GradleCatalogNotation.GROUP_NAME_NO_VERSION; + } + + return this.build(kind, raw, notation, catalogName, scriptHash, filePath, baseMservPath, svh) + .withGroup(group) + .withArtifact(artifact) + .withVersion(version || (t['version.require'] ?? t['version.prefer'] ?? '')) + .withVersionRef(versionRef) + .withRichVersionConstraint(rich) + .build(); + } + + /** + * Flattens `strictly`/`require`/`prefer`/`reject`/`rejectAll` into one + * readable constraint. Kept as text rather than columns because a rich + * version is a constraint expression, and splitting it across five mostly + * empty columns would suggest they can be queried independently. They cannot: + * a `strictly` with a `reject` means something neither says alone. + */ + private richConstraintText(t: Record): string { + const keys = ['strictly', 'require', 'prefer', 'reject', 'rejectAll']; + const parts: string[] = []; + for (const k of keys) { + const direct = t[k]; + const dotted = t[`version.${k}`]; + const value = direct ?? dotted; + if (value !== undefined && value !== '') parts.push(`${k}=${value}`); + } + return parts.join(';'); + } + + /** + * Fills `resolvedVersion` on every entry whose `version.ref` names a + * [versions] entry in the same catalog, and links the two rows. + * + * Refs are single-hop by design: Gradle does not allow a version entry to + * reference another version entry, so there is no chain to follow and no + * cycle to guard against. + */ + private resolveVersionRefs(entries: GradleCatalogEntry[]): void { + const versions = new Map(); + for (const e of entries) { + if (e.getEntryKind() === GradleCatalogEntryKind.VERSION) { + versions.set(e.getAlias(), e); + } + } + + for (const e of entries) { + const ref = e.getVersionRef(); + if (!ref) continue; + // Gradle matches a ref against the alias in its normalised form, so + // `version.ref = "spring-boot"` and an entry keyed `spring.boot` are the + // same version. Matching the literal alias alone misses that. + const target = versions.get(ref) + ?? versions.get(ref.replace(/[-_]/g, '.')) + ?? [...versions.values()].find((v) => v.getAccessorPath() === GradleCatalogEntryHelper.accessor(ref)); + if (!target) continue; + e.setResolvedVersion(target.getVersion(), target.getHash()); + } + } + + private build( + kind: GradleCatalogEntryKind, + raw: RawCatalogEntry, + notation: GradleCatalogNotation, + catalogName: string, + scriptHash: string, + filePath: string, + baseMservPath: string, + svh: string + ) { + return GradleCatalogEntry.builder( + kind, raw.alias, notation, scriptHash, filePath, baseMservPath, + raw.startLine, raw.endLine, svh + ).withCatalogName(catalogName); + } +} + +/** Small indirection so the accessor rule lives in exactly one place. */ +class GradleCatalogEntryHelper { + static accessor(alias: string): string { + return GradleCatalogEntry.toAccessorPath(alias); + } +} diff --git a/parser/src/parsers/gradle/extractors/gradle-file-extractor.ts b/parser/src/parsers/gradle/extractors/gradle-file-extractor.ts new file mode 100644 index 000000000..b27407598 --- /dev/null +++ b/parser/src/parsers/gradle/extractors/gradle-file-extractor.ts @@ -0,0 +1,3404 @@ +import Parser from 'tree-sitter'; + +import { GradleBlock } from '@/analysis-types/gradle/GradleBlock'; +import { GradleComment } from '@/analysis-types/gradle/GradleComment'; +import { GradleDeclaration } from '@/analysis-types/gradle/GradleDeclaration'; +import { GradleDependencyCoordinate } from '@/analysis-types/gradle/GradleDependencyCoordinate'; +import { GradleParseGap } from '@/analysis-types/gradle/GradleParseGap'; +import { GradleValueReference } from '@/analysis-types/gradle/GradleValueReference'; +import { GradleBlockType } from '@/enums/gradle/blocks/GradleBlockType'; +import { GradleDeclarationType } from '@/enums/gradle/declarations/GradleDeclarationType'; +import { GradleDependencyNotation } from '@/enums/gradle/declarations/GradleDependencyNotation'; +import { GradlePluginSyntax } from '@/enums/gradle/declarations/GradlePluginSyntax'; +import { GradlePropertyScope } from '@/enums/gradle/declarations/GradlePropertyScope'; +import { GradleRepositoryType } from '@/enums/gradle/declarations/GradleRepositoryType'; +import { GradleTaskStyle } from '@/enums/gradle/declarations/GradleTaskStyle'; +import { GradleVersionSource } from '@/enums/gradle/dependencies/GradleVersionSource'; +import { GradleDSLDialect } from '@/enums/gradle/files/GradleDSLDialect'; +import { GradleParseStatus } from '@/enums/gradle/files/GradleParseStatus'; +import { GradleScriptKind } from '@/enums/gradle/files/GradleScriptKind'; +import { GradleParseGapReason } from '@/enums/gradle/parse-gaps/GradleParseGapReason'; +import { GradleReferenceResolution } from '@/enums/gradle/value-references/GradleReferenceResolution'; +import { GradleValueReferenceType } from '@/enums/gradle/value-references/GradleValueReferenceType'; +import { BaseExtractor } from '@/parsers/base-extractor'; +import { DependencyCoordinateParser } from '@/parsers/gradle/dependency-coordinate-parser'; +import { GradleCommentScanner } from '@/parsers/gradle/gradle-comment-scanner'; +import { GroovyParser } from '@/parsers/gradle/groovy-parser'; + +/** Everything one Gradle script yields. */ +export interface GradleFileExtractionResult { + blocks: GradleBlock[]; + declarations: GradleDeclaration[]; + valueReferences: GradleValueReference[]; + coordinates: GradleDependencyCoordinate[]; + comments: GradleComment[]; + parseGaps: GradleParseGap[]; + parseStatus: GradleParseStatus; +} + +/** What the caller must have decided before a script can be extracted. */ +export interface GradleExtractionContext { + filePath: string; + baseMservPath: string; + dialect: GradleDSLDialect; + /** GRADLE_SCRIPT hash. Every emitted row chains off it. */ + scriptHash: string; + /** + * What role the file plays. Needed because the same method name means + * different things in different scripts — `include` declares a project in a + * settings file and filters filenames in a copy spec. + */ + scriptKind: GradleScriptKind; + serviceVersionHash: string; +} + +/** + * Extracts Gradle entities from a build script using tree-sitter-groovy. + * + * Emits blocks, declarations, value references, dependency coordinates, + * comments and parse gaps from one file. + * + * ## The grammar does not match the language, and that shapes everything here + * + * tree-sitter-groovy parses Groovy. This extractor is handed Kotlin DSL as + * well, plus Groovy constructs the grammar has no rule for: GString + * interpolation, the Elvis operator, empty single-quoted strings, closure + * parameter lists. Each of those does not merely fail locally — an ERROR node + * in this grammar swallows the rest of the enclosing block, so one unparseable + * `?:` on line 12 can silently delete every dependency below it. + * + * The answer is to rewrite the source into something the grammar accepts, and + * then to be honest about it. Two rules keep that from becoming a lie: + * + * 1. **Every rewrite preserves line count.** A replacement carries forward the + * newlines it consumed, so a position reported against the rewritten text is + * a real line in the original file. Without this, one multi-line + * interpolation shifts every position below it in the file. + * 2. **Every lossy rewrite emits a parse gap.** Dropping a closure's parameter + * names, a type cast, or a `::class` is information the emitted rows no + * longer contain, and a consumer is entitled to know which regions those + * were. Rewrites that round-trip — the `__INTERP__` placeholder, the + * `'_EMPTY_'` stand-in — are restored afterwards and are not gaps. + * + * Comments are scanned off the ORIGINAL bytes rather than read from the tree, + * for the same reason: whatever an ERROR node swallows is unreachable from the + * tree, and on a heavily rewritten Kotlin file that is most of it. + * + * ## Per-file state + * + * `beginFile()` records the file's context — path, dialect, script hash — on + * the instance, and the walk threads the positional copies it already had. + * The instance copy is what the rewrite and gap helpers read, since those run + * outside the walk entirely. Nothing outside `extractScript()` may set it, and + * the class is therefore single-use per file: one call, one file, state reset + * at entry. + */ +export class GradleFileExtractor implements BaseExtractor { + private groovyParser: GroovyParser; + private extractedDeclarations: GradleDeclaration[] = []; + private extractedValueReferences: GradleValueReference[] = []; + private extractedCoordinates: GradleDependencyCoordinate[] = []; + private extractedComments: GradleComment[] = []; + private extractedParseGaps: GradleParseGap[] = []; + private originalLines: string[] = []; + + // ── per-file context, set by beginFile() ── + private filePath: string = ''; + private baseMservPath: string = ''; + private dialect: GradleDSLDialect = GradleDSLDialect.GROOVY; + private scriptHash: string = ''; + private scriptKind: GradleScriptKind = GradleScriptKind.PROJECT_BUILD; + private serviceVersionHash: string = ''; + + /** Args stripped by the trailing-closure rewrite, keyed by 1-indexed line number. */ + private strippedClosureArgs: Map = new Map(); + + /** blockHash → what that block is, for declarations that need their context. */ + private blockContext: Map = new Map(); + + constructor() { + this.groovyParser = new GroovyParser(); + } + + /** + * Returns all declarations extracted during the last extract() call. + */ + getExtractedDeclarations(): GradleDeclaration[] { + return this.extractedDeclarations; + } + + /** + * Returns all value references extracted during the last extract() call. + */ + getExtractedValueReferences(): GradleValueReference[] { + return this.extractedValueReferences; + } + + getExtractedCoordinates(): GradleDependencyCoordinate[] { + return this.extractedCoordinates; + } + + getExtractedComments(): GradleComment[] { + return this.extractedComments; + } + + getExtractedParseGaps(): GradleParseGap[] { + return this.extractedParseGaps; + } + + /** + * BaseExtractor conformance. Derives the context it needs from the path, + * which is enough for the block/declaration relations but produces a script + * hash unconnected to the one the workflow assigns. + * + * Prefer `extractScript`. This exists so the Gradle extractor still + * satisfies the same interface as every other one in the repository, and for + * callers that want blocks out of a single file with no project around it. + */ + extract(filePath: string, fileContent: string, serviceVersionHash: string): GradleBlock[] { + return this.extractScript(fileContent, { + filePath, + baseMservPath: this.extractBaseMservPath(filePath), + dialect: this.detectDialect(filePath), + scriptHash: '', + scriptKind: GradleScriptKind.PROJECT_BUILD, + serviceVersionHash, + }).blocks; + } + + /** + * Extracts every relation from one Gradle script. + * + * Never throws. A file the grammar cannot handle at all comes back with + * `parseStatus = FAILED` and a parse-gap row covering it, rather than an + * empty result that reads identically to a build file declaring nothing. + */ + extractScript( + fileContent: string, + context: GradleExtractionContext + ): GradleFileExtractionResult { + this.beginFile(fileContent, context); + + // Comments come off the original bytes, before any rewrite, because a + // rewritten region can become an ERROR node and everything inside an + // ERROR node is unreachable from the tree. + this.collectComments(fileContent); + + const preprocessed = this.preprocess(fileContent); + + let tree: Parser.Tree; + try { + tree = this.groovyParser.parse(preprocessed); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[GradleFileExtractor] Failed to parse ${context.filePath}: ${message}`); + this.addGap( + GradleParseGapReason.PARSE_FAILED, + 1, this.originalLines.length || 1, 0, 0, + 'file', message + ); + return this.result([], GradleParseStatus.FAILED); + } + + const rootNode = this.groovyParser.getRootNode(tree); + const blocks: GradleBlock[] = []; + + this.walkNode( + rootNode, blocks, + this.filePath, this.baseMservPath, this.dialect, this.serviceVersionHash, + '', // parentBlockHash — the root has none + 0 // depth + ); + + // Whatever the grammar could not read. Recorded before the restoration + // pass so the offsets are the ones the tree actually reported. + this.collectTreeGaps(rootNode); + + // Placeholders introduced by the round-tripping rewrites go back to the + // original text they stood in for. + this.restorePreprocessedValues(); + + // The interpolation placeholder hid every ${...} from the reference scan + // during the walk; now that the real text is back, catch them. + this.extractRestoredGStringRefs(this.filePath, this.baseMservPath, this.serviceVersionHash); + + // Split every DEPENDENCY declaration into coordinates. + this.extractCoordinates(); + + // Link references to the properties they name, within this file. + this.resolveValueReferences(); + + // Attribute each comment to the innermost block that contains it. + this.attachComments(blocks); + + // Counts are a property of the finished tree, so they are written last. + this.populateBlockCounts(blocks); + + return this.result(blocks, this.extractedParseGaps.length ? GradleParseStatus.PARTIAL : GradleParseStatus.OK); + } + + /** + * Records a block's identity so a declaration created later in the walk can + * ask what kind of block encloses it. + * + * A declaration's meaning depends on it. `smokeTest.extendsFrom test` is a + * CONFIGURATION inside `configurations { }` and an ordinary statement + * anywhere else; `guavaVersion = '1.0'` is an ext property inside `ext { }` + * and a project property at the top level. The walk knows the enclosing + * block only as a hash, so the type has to be looked up. + */ + private registerBlock(block: GradleBlock): void { + this.blockContext.set(block.getHash(), { + type: block.getBlockType(), + name: block.getBlockName(), + parent: block.getParentBlockHash(), + }); + } + + /** Whether the given block, or any block above it, is of this type. */ + private isWithin(blockHash: string, type: GradleBlockType): boolean { + let current = blockHash; + const seen = new Set(); + while (current && !seen.has(current)) { + seen.add(current); + const ctx = this.blockContext.get(current); + if (!ctx) return false; + if (ctx.type === type) return true; + current = ctx.parent; + } + return false; + } + + /** Resets per-file state. Nothing outside extractScript may call this. */ + private beginFile(fileContent: string, context: GradleExtractionContext): void { + this.extractedDeclarations = []; + this.extractedValueReferences = []; + this.extractedCoordinates = []; + this.extractedComments = []; + this.extractedParseGaps = []; + this.strippedClosureArgs = new Map(); + this.blockContext = new Map(); + this.originalLines = fileContent.split('\n'); + + this.filePath = context.filePath; + this.baseMservPath = context.baseMservPath; + this.dialect = context.dialect; + this.scriptHash = context.scriptHash; + this.scriptKind = context.scriptKind; + this.serviceVersionHash = context.serviceVersionHash; + } + + private result(blocks: GradleBlock[], parseStatus: GradleParseStatus): GradleFileExtractionResult { + return { + blocks, + declarations: this.extractedDeclarations, + valueReferences: this.extractedValueReferences, + coordinates: this.extractedCoordinates, + comments: this.extractedComments, + parseGaps: this.extractedParseGaps, + parseStatus, + }; + } + + // ─── Preprocessing ───────────────────────────────────────────── + + /** + * Rewrites the source into something tree-sitter-groovy will accept. + * + * Order matters and is not arbitrary: + * + * - Interpolation goes first. A `{` inside `${...}` is a block-opening + * brace to this grammar, so leaving one in place corrupts brace matching + * for the whole rest of the file — every block boundary after it is wrong. + * - Type annotations run before `by` delegation, because the annotation + * rewrite is what turns `val x: String by y` into `val x by y`. + * - The dependency-wrapper rewrite runs before closure parameters, because + * it matches on `config wrapper(` and a stripped closure parameter can + * leave text that looks like one. + * + * Every step preserves line count. Every lossy step records a gap. + */ + private preprocess(source: string): string { + let out = source; + out = this.normalizeGStringInterpolation(out); // round-trips + out = this.stripKotlinTypeAnnotations(out); // round-trips (type is in the value) + out = this.stripKotlinByDelegation(out); // round-trips + out = this.stripKotlinClassReferences(out); // LOSSY + out = this.stripKotlinInlineGenerics(out); // LOSSY + out = this.stripKotlinTypeCasts(out); // LOSSY + out = this.stripTrailingClosureArgs(out); // round-trips (args are recovered) + out = this.normalizeElvisOperator(out); // LOSSY (changes the operator) + out = this.normalizeEmptyStringLiterals(out); // round-trips + out = this.stripNonAsciiCharacters(out); // LOSSY + out = this.convertNamedParamQuotes(out); // round-trips + out = this.normalizeDependencyWrapperCalls(out); // round-trips + out = this.normalizeCatalogAccessorCalls(out); // round-trips + out = this.stripClosureParameters(out); // LOSSY + return out; + } + + /** + * Applies a rewrite, keeping the line count identical and optionally + * recording each replaced region as a parse gap. + * + * The line-count guarantee is the load-bearing part. Positions are reported + * against the rewritten text, so a rewrite that swallowed a newline would + * shift every row below it in the file by one line — silently, and only on + * the files that happen to contain a multi-line construct. + */ + private rewrite( + source: string, + pattern: RegExp, + replacer: (match: RegExpExecArray) => string, + reason: GradleParseGapReason | null + ): string { + const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'); + let out = ''; + let last = 0; + let m: RegExpExecArray | null; + + while ((m = global.exec(source)) !== null) { + // A zero-width match would loop forever; step past it. + if (m[0].length === 0) { global.lastIndex++; continue; } + + const matched = m[0]; + const replacement = replacer(m); + const newlines = (matched.match(/\n/g) || []).length; + + if (reason) { + const startLine = this.lineOf(source, m.index); + const startColumn = m.index - this.lineStartOf(source, m.index); + this.addGap( + reason, + startLine, startLine + newlines, + startColumn, startColumn + matched.length, + 'preprocessor', matched + ); + } + + out += source.slice(last, m.index) + replacement + '\n'.repeat(newlines); + last = m.index + matched.length; + global.lastIndex = last; + } + + return out + source.slice(last); + } + + private lineOf(source: string, index: number): number { + let line = 1; + for (let i = 0; i < index && i < source.length; i++) { + if (source[i] === '\n') line++; + } + return line; + } + + private lineStartOf(source: string, index: number): number { + const nl = source.lastIndexOf('\n', index - 1); + return nl < 0 ? 0 : nl + 1; + } + + private addGap( + reason: GradleParseGapReason, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + nodeType: string, + originalText: string + ): void { + this.extractedParseGaps.push( + GradleParseGap.builder( + reason, this.scriptHash, this.filePath, this.baseMservPath, + startLine, endLine, startColumn, endColumn, this.serviceVersionHash + ) + .withNodeType(nodeType) + .withOriginalText(originalText.length > 400 ? originalText.slice(0, 400) : originalText) + .build() + ); + } + + // ─── Parse gaps from the tree ────────────────────────────────── + + /** + * Walks the finished tree for ERROR and MISSING nodes. + * + * Only the OUTERMOST error in any subtree is recorded. tree-sitter nests + * errors freely, and emitting one row per nested node turns a single + * unparseable line into dozens of rows that all describe the same region — + * which makes the parse-gap count useless as a health signal, which is the + * main thing it is for. + */ + private collectTreeGaps(root: Parser.SyntaxNode): void { + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === 'ERROR' || node.isMissing) { + this.addGap( + node.isMissing ? GradleParseGapReason.MISSING_NODE : GradleParseGapReason.ERROR_NODE, + node.startPosition.row + 1, + node.endPosition.row + 1, + node.startPosition.column, + node.endPosition.column, + node.type, + this.originalTextFor(node) + ); + return; // do not descend: nested errors describe the same region + } + for (const child of node.children) visit(child); + }; + for (const child of root.children) visit(child); + } + + /** + * The ORIGINAL text for a node's line range, not the rewritten text the node + * actually covers. A gap row exists so somebody can go and read what was + * really there; handing back the parser's own mangled version of it would + * defeat the purpose. + */ + private originalTextFor(node: Parser.SyntaxNode): string { + const from = node.startPosition.row; + const to = Math.min(node.endPosition.row, this.originalLines.length - 1); + if (from < 0 || from >= this.originalLines.length) return node.text; + return this.originalLines.slice(from, to + 1).join('\n').trim(); + } + + // ─── Comments ────────────────────────────────────────────────── + + private collectComments(source: string): void { + for (const c of GradleCommentScanner.scan(source)) { + this.extractedComments.push( + GradleComment.builder( + c.kind, c.text, this.scriptHash, this.filePath, this.baseMservPath, + c.startLine, c.endLine, c.startColumn, c.endColumn, this.serviceVersionHash + ) + .withIsCommentedOutCode(c.isCommentedOutCode) + .build() + ); + } + } + + /** + * Attributes each comment to the innermost block whose line range contains + * it, and to the first declaration that starts at or after it. + * + * Innermost wins because a comment inside `dependencies { }` is about a + * dependency, not about the file. Ties are broken by the narrower range, + * which is what "innermost" means once two blocks start on the same line. + */ + private attachComments(blocks: GradleBlock[]): void { + if (!this.extractedComments.length) return; + + const sortedDecls = [...this.extractedDeclarations].sort( + (a, b) => a.getStartLine() - b.getStartLine() + ); + + for (const comment of this.extractedComments) { + const line = comment.getStartLine(); + + let best: GradleBlock | undefined; + let bestSpan = Number.MAX_SAFE_INTEGER; + for (const block of blocks) { + if (block.getStartLine() > line || block.getEndLine() < line) continue; + const span = block.getEndLine() - block.getStartLine(); + if (span < bestSpan) { best = block; bestSpan = span; } + } + if (best) comment.setOwnerBlockHash(best.getHash()); + + const next = sortedDecls.find((d) => d.getStartLine() >= line); + if (next) comment.setNextDeclarationHash(next.getHash()); + } + } + + // ─── Block counts ────────────────────────────────────────────── + + /** + * Fills childBlockCount and declarationCount, which were columns that + * always read zero. + * + * Counts direct children only. A recursive total would make + * `childBlockCount` on a root block equal the file's block count, and a + * consumer summing the column would then count every block once per + * ancestor. + */ + private populateBlockCounts(blocks: GradleBlock[]): void { + const childBlocks = new Map(); + const childDecls = new Map(); + + for (const block of blocks) { + const parent = block.getParentBlockHash(); + if (parent) childBlocks.set(parent, (childBlocks.get(parent) ?? 0) + 1); + } + for (const decl of this.extractedDeclarations) { + const parent = decl.getParentBlockHash(); + if (parent) childDecls.set(parent, (childDecls.get(parent) ?? 0) + 1); + } + + for (const block of blocks) { + block.setChildBlockCount(childBlocks.get(block.getHash()) ?? 0); + block.setDeclarationCount(childDecls.get(block.getHash()) ?? 0); + } + } + + /** + * Restores preprocessing placeholders (__INTERP__, _EMPTY_) in extracted + * declaration names/values back to the original source text. + * + * Uses the stored originalLines to find the real ${...} expressions + * for each declaration's line range. + */ + private restorePreprocessedValues(): void { + const restored: GradleDeclaration[] = []; + const remap = new Map(); + for (const decl of this.extractedDeclarations) { + const name = decl.getName(); + const value = decl.getValue(); + + // Check if declaration needs placeholder restoration + const needsPlaceholderRestore = + name.includes('__INTERP__') || name.includes('_EMPTY_') || + value.includes('__INTERP__') || value.includes('_EMPTY_'); + + // Check if declaration spans lines where step 6 stripped closure args + let needsClosureArgRestore = false; + for (let ln = decl.getStartLine(); ln <= decl.getEndLine(); ln++) { + if (this.strippedClosureArgs.has(ln)) { needsClosureArgRestore = true; break; } + } + + if (!needsPlaceholderRestore && !needsClosureArgRestore) { + restored.push(decl); + continue; + } + + let restoredName = needsPlaceholderRestore + ? this.restorePreprocessing(name, decl.getStartLine(), decl.getEndLine()) + : name; + let restoredValue = needsPlaceholderRestore + ? this.restorePreprocessing(value, decl.getStartLine(), decl.getEndLine()) + : value; + + // Restore stripped closure args: replace "methodName {" with "methodName(args) {" + if (needsClosureArgRestore) { + for (let ln = decl.getStartLine(); ln <= decl.getEndLine(); ln++) { + const saved = this.strippedClosureArgs.get(ln); + if (!saved) continue; + const stripped = saved.methodName + ' {'; + const restored_text = saved.methodName + '(' + saved.args + ') {'; + restoredName = restoredName.replace(stripped, restored_text); + restoredValue = restoredValue.replace(stripped, restored_text); + } + } + + // Rebuild the declaration with restored text + const rebuilt = GradleDeclaration.builder( + decl.getDeclarationType(), restoredName, decl.getDslDialect(), + decl.getParentBlockHash(), decl.getScriptHash(), decl.getFilePath(), decl.getBaseMservPath(), + decl.getStartLine(), decl.getEndLine(), + decl.getStartColumn(), decl.getEndColumn(), + decl.getServiceVersionLinkHash() + ) + .withValue(restoredValue) + .withNotation(decl.getNotation()) + .withQualifier(decl.getQualifier()) + .withHasConfigBlock(decl.getHasConfigBlock()) + .withReason(decl.getReason()) + .build(); + + // Rebuilding changes the declaration's content and therefore its key. + // Any value reference created during the walk still points at the old + // one, and would be left dangling — a foreign key into a row that no + // longer exists, which is strictly worse than an empty one. Remap them. + remap.set(decl.getHash(), rebuilt.getHash()); + restored.push(rebuilt); + } + this.extractedDeclarations = restored; + + if (remap.size) this.remapDeclarationHashes(remap); + } + + /** + * Re-points every child row at a declaration's new key. + * + * Value references are rebuilt rather than mutated because the owning + * declaration's hash is part of THEIR key too — chaining means a parent + * re-key cascades, and quietly leaving the child's own key derived from the + * dead parent would make two runs of the same file disagree. + */ + private remapDeclarationHashes(remap: Map): void { + this.extractedValueReferences = this.extractedValueReferences.map((ref) => { + const next = remap.get(ref.getOwnerDeclarationHash()); + if (!next) return ref; + + const rebuilt = GradleValueReference.builder( + ref.getReferenceExpression(), ref.getReferenceType(), ref.getRawFragment(), + ref.getScriptHash(), ref.getFilePath(), ref.getBaseMservPath(), + ref.getStartLine(), ref.getEndLine(), ref.getStartColumn(), ref.getEndColumn(), + ref.getServiceVersionLinkHash() + ) + .withOwnerDeclarationHash(next) + .withOwnerBlockHash(ref.getOwnerBlockHash()) + .withDefaultValue(ref.getDefaultValue()) + .withResolutionKind(ref.getResolutionKind()) + .build(); + return rebuilt; + }); + } + + /** + * Replaces __INTERP__ and _EMPTY_ placeholders in text with original + * source content from the given line range. + */ + private restorePreprocessing(text: string, startLine: number, endLine: number): string { + let result = text; + + // Restore __INTERP__ → original ${...} expressions + if (result.includes('__INTERP__')) { + for (let i = startLine - 1; i < endLine && i < this.originalLines.length; i++) { + const line = this.originalLines[i]; + if (!line) continue; + const matches = line.match(/\$\{[^}]+\}/g); + if (matches) { + for (const m of matches) { + result = result.replace('__INTERP__', m); + } + } + } + } + + // Restore '_EMPTY_' → '' (handles both single and double quote wrappers) + result = result.replace(/'_EMPTY_'/g, "''").replace(/"_EMPTY_"/g, "''"); + + return result; + } + + /** + * Post-restoration pass: scans restored declaration names/values for + * GString interpolation patterns (${expr}, $var) and emits value references. + * + * During AST walking, step 0 had replaced ${...} with __INTERP__, so + * scanStringForGStringRefs could not detect them. After restorePreprocessedValues + * puts the original text back, this pass catches those references. + */ + private extractRestoredGStringRefs( + filePath: string, + baseMservPath: string, + serviceVersionHash: string + ): void { + for (const decl of this.extractedDeclarations) { + const value = decl.getValue(); + const name = decl.getName(); + // Only process declarations whose restored text actually contains $ + if (!value.includes('$') && !name.includes('$')) continue; + + const startLine = decl.getStartLine(); + const endLine = decl.getEndLine(); + const startCol = decl.getStartColumn(); + const endCol = decl.getEndColumn(); + const ownerHash = decl.getHash(); + const blockHash = decl.getParentBlockHash(); + + // Scan both name and value for GString patterns + for (const text of [name, value]) { + if (!text.includes('$')) continue; + + // ${expr} patterns + const fullPattern = /\$\{([^}]+)\}/g; + let match: RegExpExecArray | null; + while ((match = fullPattern.exec(text)) !== null) { + const expr = (match[1] ?? '').trim(); + // Check if this exact ref was already captured (avoid duplicates) + const alreadyExists = this.extractedValueReferences.some( + r => r.getReferenceExpression() === expr && r.getOwnerDeclarationHash() === ownerHash + ); + if (alreadyExists) continue; + + const refType = expr.includes('.') + ? GradleValueReferenceType.EXT_PROPERTY_ACCESS + : GradleValueReferenceType.GSTRING_INTERPOLATION; + + const ref = GradleValueReference.builder( + expr, refType, match[0], + this.scriptHash, + filePath, baseMservPath, + startLine, endLine, startCol, endCol, + serviceVersionHash + ) + .withOwnerDeclarationHash(ownerHash) + .withOwnerBlockHash(blockHash) + .build(); + this.extractedValueReferences.push(ref); + } + + // $varName patterns (skip if inside ${...}) + const simplePattern = /\$([a-zA-Z_][a-zA-Z0-9_.]*)/g; + while ((match = simplePattern.exec(text)) !== null) { + if (match.index > 0 && text[match.index + 1] === '{') continue; + const inFullInterp = text.substring(0, match.index).lastIndexOf('${') > text.substring(0, match.index).lastIndexOf('}'); + if (inFullInterp) continue; + + const simpleExpr = match[1] ?? ''; + const alreadyExists = this.extractedValueReferences.some( + r => r.getReferenceExpression() === simpleExpr && r.getOwnerDeclarationHash() === ownerHash + ); + if (alreadyExists) continue; + + const refType = simpleExpr.includes('.') + ? GradleValueReferenceType.EXT_PROPERTY_ACCESS + : GradleValueReferenceType.GSTRING_SIMPLE; + + const ref = GradleValueReference.builder( + simpleExpr, refType, match[0], + this.scriptHash, + filePath, baseMservPath, + startLine, endLine, startCol, endCol, + serviceVersionHash + ) + .withOwnerDeclarationHash(ownerHash) + .withOwnerBlockHash(blockHash) + .build(); + this.extractedValueReferences.push(ref); + } + } + } + } + + // ─── AST Walking ─────────────────────────────────────────────── + + /** + * Recursively walks the AST, identifying blocks and declarations. + */ + private walkNode( + node: Parser.SyntaxNode, + blocks: GradleBlock[], + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + depth: number + ): void { + const children = node.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + switch (child.type) { + case 'expression_statement': + this.processExpressionStatement( + child, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth + ); + break; + + // A one-line closure — `dependencies { implementation("a:b:1.0") }` — + // puts the call directly under the closure, where a multi-line one + // wraps it in an expression_statement. Without these cases the call + // fell to the catch-all and became a STATEMENT named + // `method_invocation: implementation(...)`, so a build that writes its + // dependencies on one line reported none at all. + case 'method_invocation': + case 'function_call': + this.processMethodInvocation( + child, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth + ); + break; + + case 'assignment': + case 'assignment_expression': + this.processAssignment( + child, child, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash + ); + break; + + case 'if_statement': + this.processControlFlow( + child, GradleBlockType.IF, blocks, filePath, baseMservPath, + dialect, serviceVersionHash, parentBlockHash, depth + ); + break; + + case 'for_statement': + this.processControlFlow( + child, GradleBlockType.FOR, blocks, filePath, baseMservPath, + dialect, serviceVersionHash, parentBlockHash, depth + ); + break; + + case 'for_in_statement': + this.processControlFlow( + child, GradleBlockType.FOR_EACH, blocks, filePath, baseMservPath, + dialect, serviceVersionHash, parentBlockHash, depth + ); + break; + + case 'while_statement': + this.processControlFlow( + child, GradleBlockType.WHILE, blocks, filePath, baseMservPath, + dialect, serviceVersionHash, parentBlockHash, depth + ); + break; + + case 'do_while_statement': + this.processControlFlow( + child, GradleBlockType.DO_WHILE, blocks, filePath, baseMservPath, + dialect, serviceVersionHash, parentBlockHash, depth + ); + break; + + case 'try_statement': + this.processTryStatement( + child, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth + ); + break; + + case 'switch_statement': + this.processSwitchStatement( + child, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth + ); + break; + + case 'local_variable_declaration': + this.processLocalVariableDeclaration( + child, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash + ); + break; + + case 'juxt_function_call': { + // Try Kotlin DSL plugin pattern: id("...") version "x.y.z" [apply false] + // tree-sitter-groovy splits this across siblings, so we need lookahead + const consumed = this.tryProcessPluginIdDeclaration( + child, children, i, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash + ); + if (consumed > 0) { + i += consumed; // Skip consumed sibling nodes + } else { + // Check for split dependency wrapper pattern: + // juxt_function_call(implementation, project) + expression_statement((':core')) + let mergeNode: Parser.SyntaxNode | undefined; + const firstId = this.getFirstIdentifier(child); + const argText = this.getApplicationArgText(child); + if (GradleFileExtractor.DEPENDENCY_CONFIGS.has(firstId) && + GradleFileExtractor.DEPENDENCY_WRAPPER_FUNCTIONS.has(argText)) { + const nextSibling = children[i + 1]; + if (nextSibling?.type === 'expression_statement') { + const parenExpr = nextSibling.children.find( + (c: Parser.SyntaxNode) => c.type === 'parenthesized_expression' + ); + if (parenExpr) { + mergeNode = nextSibling; + i++; // skip the consumed sibling + } + } + } + this.processApplicationExpression( + child, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth, mergeNode + ); + } + break; + } + + case 'import': + case 'import_declaration': + case 'package_declaration': + case 'class_declaration': + case 'return_statement': + case 'throw_statement': + this.processUncategorizedStatement( + child, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash + ); + break; + + default: + // Skip structural/token nodes; capture anything else as STATEMENT + if (child.isNamed && !GradleFileExtractor.STRUCTURAL_NODE_TYPES.has(child.type)) { + this.processUncategorizedStatement( + child, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash + ); + } + // Continue walking for any children + this.walkNode( + child, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth + ); + break; + } + } + } + + // ─── Expression Statement Processing ─────────────────────────── + + /** + * Processes an expression_statement, which in Gradle DSL is the + * most common top-level pattern: + * - method_invocation with closure → DSL block (dependencies { }, plugins { }) + * - assignment → property + * - plain method call → declaration or statement + */ + private processExpressionStatement( + node: Parser.SyntaxNode, + blocks: GradleBlock[], + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + depth: number + ): void { + const expr = node.children[0]; + if (!expr) return; + + if (expr.type === 'method_invocation' || expr.type === 'function_call') { + this.processMethodInvocation( + expr, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth + ); + } else if (expr.type === 'assignment' || expr.type === 'assignment_expression') { + this.processAssignment( + expr, node, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash + ); + } else if (expr.type === 'application_expression' || expr.type === 'juxt_function_call') { + // Groovy DSL pattern: methodName arg1, arg2 (no parens) + this.processApplicationExpression( + expr, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth + ); + } else { + // Catch-all: capture any other expression type as STATEMENT + this.processUncategorizedStatement( + node, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash + ); + } + } + + // ─── DSL Block Detection ─────────────────────────────────────── + + /** + * Maps well-known Gradle DSL block names to their GradleBlockType. + */ + private static readonly DSL_BLOCK_MAP: Record = { + 'plugins': GradleBlockType.PLUGINS, + 'dependencies': GradleBlockType.DEPENDENCIES, + 'repositories': GradleBlockType.REPOSITORIES, + 'allprojects': GradleBlockType.ALLPROJECTS, + 'subprojects': GradleBlockType.SUBPROJECTS, + 'buildscript': GradleBlockType.BUILDSCRIPT, + 'ext': GradleBlockType.EXT, + 'configurations': GradleBlockType.CONFIGURATIONS, + 'task': GradleBlockType.TASK, + }; + + /** + * Processes a method invocation that may have a closure (DSL block). + * Example: dependencies { ... } → method_invocation with closure child + */ + private processMethodInvocation( + node: Parser.SyntaxNode, + blocks: GradleBlock[], + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + depth: number + ): void { + const methodName = this.getMethodName(node); + const closureNode = this.findChildByType(node, 'closure'); + + if (closureNode) { + // This is a DSL block: methodName { ... } + const blockType = GradleFileExtractor.DSL_BLOCK_MAP[methodName] || GradleBlockType.DSL_BLOCK; + + const block = GradleBlock.builder( + blockType, + depth, + dialect, + this.scriptHash, + filePath, + baseMservPath, + node.startPosition.row + 1, + node.endPosition.row + 1, + node.startPosition.column, + node.endPosition.column, + serviceVersionHash + ) + .withBlockName(methodName) + .withParentBlockHash(parentBlockHash) + .build(); + + this.registerBlock(block); + + blocks.push(block); + + // Recover stripped args for method("arg") { closure } patterns + this.recoverStrippedClosureArgs( + node, block, methodName, dialect, filePath, baseMservPath, serviceVersionHash + ); + + // maven { }, ivy { }, flatDir { } inside repositories { } + this.emitRepositoryBlockDeclaration( + block, methodName, dialect, filePath, baseMservPath, serviceVersionHash + ); + + // Recurse into the closure body + this.walkNode( + closureNode, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, block.getHash(), depth + 1 + ); + } else { + // No closure → this is a declaration/statement inside a block + this.processDeclarationFromMethodCall( + node, methodName, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash + ); + } + } + + /** + * Processes Groovy application expression (no-paren method calls). + * Example: implementation 'com.google.guava:guava:32.1.3-jre' + */ + private processApplicationExpression( + node: Parser.SyntaxNode, + blocks: GradleBlock[], + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + depth: number, + mergeNode?: Parser.SyntaxNode + ): void { + const methodName = this.getFirstIdentifier(node); + const closureNode = this.findChildByType(node, 'closure'); + + if (closureNode) { + // DSL block via application expression: task hello { ... } + const blockType = GradleFileExtractor.DSL_BLOCK_MAP[methodName] || GradleBlockType.DSL_BLOCK; + + const block = GradleBlock.builder( + blockType, + depth, + dialect, + this.scriptHash, + filePath, + baseMservPath, + node.startPosition.row + 1, + node.endPosition.row + 1, + node.startPosition.column, + node.endPosition.column, + serviceVersionHash + ) + .withBlockName(methodName) + .withParentBlockHash(parentBlockHash) + .build(); + + this.registerBlock(block); + + blocks.push(block); + + // Recover stripped args for method("arg") { closure } patterns + this.recoverStrippedClosureArgs( + node, block, methodName, dialect, filePath, baseMservPath, serviceVersionHash + ); + + // maven { }, ivy { }, flatDir { } inside repositories { } + this.emitRepositoryBlockDeclaration( + block, methodName, dialect, filePath, baseMservPath, serviceVersionHash + ); + + this.walkNode( + closureNode, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, block.getHash(), depth + 1 + ); + } else { + // No closure → declaration (e.g., implementation 'guava:...') + this.processDeclarationFromApplicationExpr( + node, methodName, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, mergeNode + ); + } + } + + /** + * When step 6 strips method("arg") { closure } → method { closure }, + * this method recovers the stripped args and emits a declaration + * linked to the block. DEPENDENCY for dep configs, STATEMENT for others. + */ + private recoverStrippedClosureArgs( + node: Parser.SyntaxNode, + block: GradleBlock, + blockMethodName: string, + dialect: GradleDSLDialect, + filePath: string, + baseMservPath: string, + serviceVersionHash: string + ): void { + const startLine = node.startPosition.row + 1; + const saved = this.strippedClosureArgs.get(startLine); + if (!saved) return; + + const { args } = saved; + // The stashed name carries the receiver (`tasks.register`); the block's own + // name is just the last segment, so the stashed one is what classifies. + const endLine = node.endPosition.row + 1; + const startCol = node.startPosition.column; + const endCol = node.endPosition.column; + + if (GradleFileExtractor.DEPENDENCY_CONFIGS.has(blockMethodName)) { + const notation = this.classifyDependencyNotation(args); + const decl = GradleDeclaration.builder( + GradleDeclarationType.DEPENDENCY, args, dialect, block.getHash(), + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startCol, endCol, + serviceVersionHash + ) + .withValue(args) + .withQualifier(blockMethodName) + .withNotation(notation) + .build(); + this.extractedDeclarations.push(decl); + return; + } + + // `tasks.register('copyDocs', Copy) { }` reaches here rather than the + // declaration path, because the trailing-closure rewrite turned it into + // `tasks.register { }` — a block. Without this the task is a STATEMENT and + // TASKS_REGISTER is a style nothing ever produces. + const task = this.classifyTask(saved.methodName, args, false); + if (task && task.taskName) { + const decl = GradleDeclaration.builder( + GradleDeclarationType.TASK, task.taskName, dialect, block.getHash(), + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startCol, endCol, + serviceVersionHash + ) + .withValue(args) + .withNotation(task.style) + .withQualifier(task.taskType) + .withHasConfigBlock(true) + .build(); + this.extractedDeclarations.push(decl); + return; + } + + { + // Non-dep-config: emit as STATEMENT linked to the block + const decl = GradleDeclaration.builder( + GradleDeclarationType.STATEMENT, saved.methodName, dialect, block.getHash(), + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startCol, endCol, + serviceVersionHash + ) + .withValue(args) + .build(); + this.extractedDeclarations.push(decl); + } + } + + // ─── Declaration Extraction ──────────────────────────────────── + + /** Node types that are structural/tokens and should NOT be captured as statements in the default catch-all. */ + private static readonly STRUCTURAL_NODE_TYPES = new Set([ + 'block', 'closure', 'argument_list', 'arguments', 'parenthesized_expression', + 'identifier', 'string_literal', 'character_literal', 'number_literal', + 'boolean_literal', 'null_literal', 'comment', 'line_comment', 'block_comment', + 'ERROR', 'program', 'source_file', + ]); + + /** Known dependency wrapper functions that take arguments in parens. + * tree-sitter-groovy sometimes splits `implementation project(':core')` into + * juxt_function_call(implementation, project) + expression_statement((':core')). + * When this split is detected, the extractor merges them back together. */ + private static readonly DEPENDENCY_WRAPPER_FUNCTIONS = new Set([ + 'project', 'files', 'fileTree', 'platform', 'enforcedPlatform', + 'testFixtures', 'gradleApi', 'gradleTestKit', 'localGroovy', + ]); + + /** Known dependency configuration names */ + private static readonly DEPENDENCY_CONFIGS = new Set([ + 'implementation', 'api', 'compileOnly', 'runtimeOnly', + 'testImplementation', 'testCompileOnly', 'testRuntimeOnly', + 'annotationProcessor', 'testAnnotationProcessor', 'kapt', 'ksp', + 'classpath', 'compile', 'runtime', 'testCompile', 'testRuntime', + 'compileOnlyApi', 'debugImplementation', 'releaseImplementation', + 'developmentOnly', 'providedCompile', 'providedRuntime', + 'debugApi', 'debugCompileOnly', 'debugRuntimeOnly', + 'releaseApi', 'releaseCompileOnly', 'releaseRuntimeOnly', + 'androidTestImplementation', 'androidTestApi', 'androidTestCompileOnly', 'androidTestRuntimeOnly', + 'testFixturesImplementation', 'testFixturesApi', 'testFixturesCompileOnly', 'testFixturesRuntimeOnly', + ]); + + /** Known repository shortcut names */ + private static readonly REPOSITORY_SHORTCUTS: Record = { + 'mavenCentral': GradleRepositoryType.MAVEN_CENTRAL, + 'mavenLocal': GradleRepositoryType.MAVEN_LOCAL, + 'google': GradleRepositoryType.GOOGLE, + 'gradlePluginPortal': GradleRepositoryType.GRADLE_PLUGIN_PORTAL, + 'jcenter': GradleRepositoryType.JCENTER, + }; + + /** `tasks.register('x')` and friends: the method name to the task style. */ + private static readonly TASK_METHOD_STYLES: Record = { + 'register': GradleTaskStyle.TASKS_REGISTER, + 'named': GradleTaskStyle.TASKS_NAMED, + 'create': GradleTaskStyle.TASKS_REGISTER, + 'maybeCreate': GradleTaskStyle.TASKS_REGISTER, + 'addRule': GradleTaskStyle.TASK_RULE, + }; + + /** + * Recognises a task definition or configuration. + * + * `TASK` was one of the eight declaration types and nothing ever emitted it: + * `task hello { }` produced a TASK block with no declaration under it, and + * `tasks.register('hello')` produced a STATEMENT indistinguishable from any + * other method call. Both are now TASK rows carrying the style that created + * them, because "which tasks exist and how were they declared" is a question + * the block tree alone cannot answer. + * + * @param methodName Receiver-qualified where the grammar gave one, e.g. + * `tasks.register`. + * @returns The style and the task's own name, or null if not a task. + */ + private classifyTask( + methodName: string, + args: string, + hasTypeArgument: boolean + ): { style: GradleTaskStyle; taskName: string; taskType: string } | null { + const segments = methodName.split('.'); + const last = segments[segments.length - 1] ?? ''; + const receiver = segments.length > 1 ? segments[segments.length - 2] : ''; + + // task hello / task hello(type: Copy) + if (methodName === 'task') { + const first = (DependencyCoordinateParser.splitTopLevel(args, ',')[0] ?? '').trim(); + const typed = /type\s*:\s*([A-Za-z_][\w.]*)/.exec(args); + return { + style: typed ? GradleTaskStyle.TASK_KEYWORD_TYPED : GradleTaskStyle.TASK_KEYWORD, + taskName: this.stripQuotes(first), + taskType: typed ? (typed[1] ?? '') : '', + }; + } + + // tasks.register(...) / tasks.named(...) / tasks.create(...) + if (receiver !== 'tasks') return null; + const base = GradleFileExtractor.TASK_METHOD_STYLES[last]; + if (!base) return null; + + const parts = DependencyCoordinateParser.splitTopLevel(args, ',').map((a) => a.trim()); + const taskName = this.stripQuotes(parts[0] ?? ''); + // Either `tasks.register('x', Copy)` or `tasks.register("x")` — and + // the second form has already had its type argument stripped by + // preprocessing, which is why hasTypeArgument is passed in rather than + // read off the text. + const secondArg = parts.length > 1 ? (parts[1] ?? '') : ''; + const taskType = secondArg && !secondArg.includes(':') ? secondArg : ''; + const typed = Boolean(taskType) || hasTypeArgument; + + let style = base; + if (typed && base === GradleTaskStyle.TASKS_REGISTER) style = GradleTaskStyle.TASKS_REGISTER_TYPED; + if (typed && base === GradleTaskStyle.TASKS_NAMED) style = GradleTaskStyle.TASKS_NAMED_TYPED; + + return { style, taskName, taskType }; + } + + /** + * The property scope for an assignment, from the block that encloses it. + * + * `GradlePropertyScope` was a fully documented enum that nothing ever used, + * so every PROPERTY row carried an empty qualifier and `ext { }` properties + * were indistinguishable from project properties. They behave differently — + * an ext property is visible to subprojects and a local `def` is not — so a + * consumer resolving a version reference needs to know which it found. + */ + private propertyScopeFor(name: string, parentBlockHash: string, isLocalVar: boolean): GradlePropertyScope { + if (name.startsWith('ext.') || name.startsWith('project.ext.')) return GradlePropertyScope.EXT_SINGLE; + + if (this.isWithin(parentBlockHash, GradleBlockType.EXT)) { + return this.isWithin(parentBlockHash, GradleBlockType.BUILDSCRIPT) + ? GradlePropertyScope.BUILDSCRIPT_EXT + : GradlePropertyScope.EXT_BLOCK; + } + + // A `def`/`val` is script-local: not a project property, and not visible + // to any other script. Collapsing it into PROJECT would make a downstream + // "which projects set this version" query claim reach it does not have. + if (isLocalVar) return GradlePropertyScope.LOCAL_VARIABLE; + + return GradlePropertyScope.PROJECT; + } + + /** + * Creates a declaration from a method call without closure. + * Determines if it's a dependency, plugin, repository, task, configuration, + * exclusion, or generic statement. + */ + private processDeclarationFromMethodCall( + node: Parser.SyntaxNode, + methodName: string, + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string + ): void { + const args = this.getArgumentsText(node); + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + const startColumn = node.startPosition.column; + const endColumn = node.endPosition.column; + + // Dependency: implementation("group:artifact:version") + if (GradleFileExtractor.DEPENDENCY_CONFIGS.has(methodName)) { + const notation = this.classifyDependencyNotation(args); + const decl = GradleDeclaration.builder( + GradleDeclarationType.DEPENDENCY, args, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(args) + .withQualifier(methodName) + .withNotation(notation) + .build(); + + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, args, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + return; + } + + // Repository shortcut: mavenCentral() + const repoType = GradleFileExtractor.REPOSITORY_SHORTCUTS[methodName]; + if (repoType) { + const decl = GradleDeclaration.builder( + GradleDeclarationType.REPOSITORY, methodName, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withNotation(repoType) + .build(); + + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, methodName, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + return; + } + + // Plugin: id 'org.springframework.boot' (inside plugins block) + if (methodName === 'id') { + const decl = GradleDeclaration.builder( + GradleDeclarationType.PLUGIN, args, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withNotation(GradlePluginSyntax.PLUGINS_BLOCK_ID) + .build(); + + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, args, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + return; + } + + // Include: include ':core', ':auth' + // + // Only in a settings script. `include` is a method on Settings there, but + // in a build script it is a CopySpec filter — `from('src') { include + // '*.txt' }` — and treating those as project includes invents projects + // that do not exist. Spring Framework alone produced three. + if (this.isSettingsScript() && (methodName === 'include' || methodName === 'includeBuild')) { + // One row per included path. `include ':a', ':b'` declares two projects, + // and packing both into one row makes the project graph unqueryable. + for (const piece of DependencyCoordinateParser.splitTopLevel(args, ',')) { + const projectPath = this.normalizeProjectPath(this.stripQuotes(piece.trim())); + if (!projectPath) continue; + const decl = GradleDeclaration.builder( + GradleDeclarationType.INCLUDE, projectPath, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(projectPath) + .withQualifier(methodName) + .build(); + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, projectPath, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + } + return; + } + + if (this.emitTaskOrConfigOrExclude( + node, methodName, args, dialect, filePath, baseMservPath, + startLine, endLine, startColumn, endColumn, serviceVersionHash, parentBlockHash + )) { + return; + } + + // Fallback: generic STATEMENT + const stmtDecl = GradleDeclaration.builder( + GradleDeclarationType.STATEMENT, methodName, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(args) + .build(); + + this.extractedDeclarations.push(stmtDecl); + this.extractValueReferences(node, args, stmtDecl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + } + + /** + * Creates a declaration from an application expression (no-paren call). + * Example: implementation 'com.google.guava:guava:32.1.3-jre' + */ + private processDeclarationFromApplicationExpr( + node: Parser.SyntaxNode, + methodName: string, + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + mergeNode?: Parser.SyntaxNode + ): void { + // Get everything after the method name as the argument + let args = this.getApplicationArgText(node); + + // Merge split wrapper function args: project + (':core') → project(':core') + if (mergeNode) { + const parenExpr = mergeNode.children.find( + (c: Parser.SyntaxNode) => c.type === 'parenthesized_expression' + ); + if (parenExpr) { + args = args + parenExpr.text; + } + } + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + const startColumn = node.startPosition.column; + const endColumn = node.endPosition.column; + + // Dependency: implementation 'group:artifact:version' + if (GradleFileExtractor.DEPENDENCY_CONFIGS.has(methodName)) { + const notation = this.classifyDependencyNotation(args); + const decl = GradleDeclaration.builder( + GradleDeclarationType.DEPENDENCY, args, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(args) + .withQualifier(methodName) + .withNotation(notation) + .build(); + + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, args, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + return; + } + + // Plugin: id 'org.springframework.boot' (inside a plugins block). + // The method-call path recognised this; the no-paren path did not, so + // `plugins { id 'java' }` produced a STATEMENT named `id` and the build's + // plugins were absent from the plugin relation. + if (methodName === 'id') { + const pluginId = this.stripQuotes(args.trim()); + const decl = GradleDeclaration.builder( + GradleDeclarationType.PLUGIN, pluginId, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withNotation(GradlePluginSyntax.PLUGINS_BLOCK_ID) + .build(); + + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, args, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + return; + } + + // apply plugin: 'java' + if (methodName === 'apply') { + this.processApplyStatement( + node, args, dialect, filePath, baseMservPath, + startLine, endLine, startColumn, endColumn, + serviceVersionHash, parentBlockHash + ); + return; + } + + // Include: include ':core', ':auth' — settings scripts only, see above. + if (this.isSettingsScript() && (methodName === 'include' || methodName === 'includeBuild')) { + for (const piece of DependencyCoordinateParser.splitTopLevel(args, ',')) { + const projectPath = this.normalizeProjectPath(this.stripQuotes(piece.trim())); + if (!projectPath) continue; + const decl = GradleDeclaration.builder( + GradleDeclarationType.INCLUDE, projectPath, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(projectPath) + .withQualifier(methodName) + .build(); + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, projectPath, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + } + return; + } + + if (this.emitTaskOrConfigOrExclude( + node, methodName, args, dialect, filePath, baseMservPath, + startLine, endLine, startColumn, endColumn, serviceVersionHash, parentBlockHash + )) { + return; + } + + // Fallback: generic STATEMENT + const decl2 = GradleDeclaration.builder( + GradleDeclarationType.STATEMENT, methodName, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(args) + .build(); + + this.extractedDeclarations.push(decl2); + this.extractValueReferences(node, args, decl2.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + } + + /** + * Emits the three declaration kinds that need their enclosing block to be + * recognised at all, and reports whether it handled the statement. + * + * All three were previously swallowed by the STATEMENT catch-all, which is + * why `GradleTaskStyle` and the CONFIGURATION and EXCLUDE arms of the + * declaration type existed with nothing producing them. + */ + private emitTaskOrConfigOrExclude( + node: Parser.SyntaxNode, + methodName: string, + args: string, + dialect: GradleDSLDialect, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionHash: string, + parentBlockHash: string + ): boolean { + // exclude group: 'x', module: 'y' — a fact about the dependency graph, not + // a generic method call, and the reason EXCLUDE is its own type. + if (methodName === 'exclude' || methodName.endsWith('.exclude')) { + const group = /group\s*:\s*['"]([^'"]*)['"]/.exec(args)?.[1] ?? ''; + const module = /(?:module|name)\s*:\s*['"]([^'"]*)['"]/.exec(args)?.[1] ?? ''; + const coordinate = group && module ? `${group}:${module}` : (group || module || args); + const decl = GradleDeclaration.builder( + GradleDeclarationType.EXCLUDE, coordinate, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(args) + .withQualifier(this.blockContext.get(parentBlockHash)?.name ?? '') + .build(); + this.extractedDeclarations.push(decl); + return true; + } + + const task = this.classifyTask(methodName, args, this.strippedClosureArgs.has(startLine)); + if (task && task.taskName) { + const decl = GradleDeclaration.builder( + GradleDeclarationType.TASK, task.taskName, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(args) + .withNotation(task.style) + .withQualifier(task.taskType) + .build(); + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, args, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + return true; + } + + // Inside configurations { }, a bare name declares a configuration and + // `name.extendsFrom other` wires it to another. Outside that block the + // same text is an ordinary call, which is why this checks the ancestry + // rather than the method name. + if (this.isWithin(parentBlockHash, GradleBlockType.CONFIGURATIONS)) { + const extendsFrom = methodName.endsWith('.extendsFrom') || args.includes('extendsFrom'); + const name = methodName.split('.')[0] ?? methodName; + const decl = GradleDeclaration.builder( + GradleDeclarationType.CONFIGURATION, name, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(extendsFrom ? args : '') + .withQualifier(extendsFrom ? 'extendsFrom' : '') + .build(); + this.extractedDeclarations.push(decl); + return true; + } + + return false; + } + + // ─── Uncategorized Statement Catch-All ───────────────────── + + /** + * Creates a STATEMENT declaration for any node type not explicitly handled. + * Captures imports, package declarations, class definitions, return/throw, + * and any other unrecognized statement-level constructs. + */ + private processUncategorizedStatement( + node: Parser.SyntaxNode, + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string + ): void { + const nodeType = node.type; + const text = node.text.split('\n')[0]?.trim() || ''; + const name = `${nodeType}: ${text}`; + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + const startColumn = node.startPosition.column; + const endColumn = node.endPosition.column; + + const decl = GradleDeclaration.builder( + GradleDeclarationType.STATEMENT, name, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(node.text.trim()) + .build(); + + this.extractedDeclarations.push(decl); + } + + // ─── Assignment Processing ───────────────────────────────────── + + /** + * Processes an assignment: variable = value → PROPERTY declaration. + */ + private processAssignment( + node: Parser.SyntaxNode, + exprStmt: Parser.SyntaxNode, + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string + ): void { + const lhs = node.children[0]; + const rhs = node.children[2]; // skip '=' at index 1 + if (!lhs || !rhs) return; + + const name = lhs.text; + const value = rhs.text; + const startLine = exprStmt.startPosition.row + 1; + const endLine = exprStmt.endPosition.row + 1; + const startColumn = exprStmt.startPosition.column; + const endColumn = exprStmt.endPosition.column; + + const decl = GradleDeclaration.builder( + GradleDeclarationType.PROPERTY, name, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(value) + .withQualifier(this.propertyScopeFor(name, parentBlockHash, false)) + .build(); + + this.extractedDeclarations.push(decl); + this.extractValueReferences(rhs, value, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + + // Decompose Groovy map literals: versions = [awsSdk: '2.21.29', ...] → individual properties + this.decomposeMapLiteral(name, value, decl.getHash(), parentBlockHash, filePath, baseMservPath, dialect, serviceVersionHash, startLine); + } + + // ─── Local Variable Declaration ───────────────────────────────── + + /** + * Processes a local variable declaration: def x = value → PROPERTY declaration. + * AST: local_variable_declaration → [def, variable_declarator → [identifier, =, value]] + */ + private processLocalVariableDeclaration( + node: Parser.SyntaxNode, + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string + ): void { + // Find the variable_declarator child + const declarator = node.children.find(c => c.type === 'variable_declarator'); + if (!declarator) return; + + const nameNode = declarator.children.find(c => c.type === 'identifier'); + // Value is child after '=' + const eqIndex = declarator.children.findIndex(c => c.type === '='); + const valueNode = eqIndex >= 0 ? declarator.children[eqIndex + 1] : null; + + if (!nameNode) return; + + const name = nameNode.text; + const value = valueNode ? valueNode.text : ''; + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + const startColumn = node.startPosition.column; + const endColumn = node.endPosition.column; + + const decl = GradleDeclaration.builder( + GradleDeclarationType.PROPERTY, name, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(value) + .withQualifier(this.propertyScopeFor(name, parentBlockHash, true)) + .build(); + + this.extractedDeclarations.push(decl); + if (valueNode) { + this.extractValueReferences(valueNode, value, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + } + + // Decompose Groovy map literals: def versions = [awsSdk: '2.21.29', ...] → individual properties + this.decomposeMapLiteral(name, value, decl.getHash(), parentBlockHash, filePath, baseMservPath, dialect, serviceVersionHash, startLine); + } + + // ─── Map Literal Decomposition ───────────────────────────────── + + /** + * Decomposes a Groovy map literal value into individual PROPERTY declarations. + * + * versions = [awsSdk: '2.21.29', caffeine: '3.1.8'] + * → PROPERTY versions.awsSdk = '2.21.29' + * → PROPERTY versions.caffeine = '3.1.8' + * + * Each sub-property is linked to the parent property's hash. + */ + private decomposeMapLiteral( + parentName: string, + value: string, + _parentDeclHash: string, + parentBlockHash: string, + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + startLine: number + ): void { + const trimmed = value.trim(); + // Must look like a Groovy map literal: starts with [ and ends with ] + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return; + // Exclude list literals like ['a', 'b'] (no colon-separated entries) + if (!trimmed.includes(':')) return; + + const inner = trimmed.slice(1, -1); // strip [ ] + + // A LIST of maps is not a map, and decomposing one as if it were produces + // a row per repeated key rather than a row per entry: + // + // commonExcludes = [[group: 'a', module: 'x'], [group: 'b', module: 'y']] + // + // yields two `commonExcludes.group` properties with different values and + // no way to tell which belonged to which element. Elasticsearch has one of + // these with dozens of elements, and the collapsed rows collided on their + // own key. A nested bracket is the signal, and the outer PROPERTY row + // still carries the whole literal. + if (inner.includes('[')) return; + + // Match key: value entries — value can be quoted string or bare identifier/number + const entryPattern = /(\w+)\s*:\s*('[^']*'|"[^"]*"|[^,\]\n]+)/g; + let match: RegExpExecArray | null; + const seenKeys = new Set(); + + while ((match = entryPattern.exec(inner)) !== null) { + const key = match[1]; + const val = (match[2] ?? '').trim(); + // Remove trailing comma if present + const cleanVal = val.endsWith(',') ? val.slice(0, -1).trim() : val; + const qualifiedName = `${parentName}.${key}`; + + // A duplicate key in one map literal is not two properties — Groovy + // keeps the last. Emitting both produces two rows with one key. + if (!key || seenKeys.has(key)) continue; + seenKeys.add(key); + + // Each entry gets its own offset so entries on the same line stay + // distinct rows rather than colliding on a shared 0:0 position. + const before = inner.slice(0, match.index); + const entryLine = startLine + (before.match(/\n/g) || []).length; + const lastNl = before.lastIndexOf('\n'); + const entryColumn = lastNl < 0 ? match.index : match.index - lastNl - 1; + + const decl = GradleDeclaration.builder( + GradleDeclarationType.PROPERTY, qualifiedName, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, entryLine, entryLine, + entryColumn, entryColumn + match[0].length, + serviceVersionHash + ) + .withValue(cleanVal) + // The scope, not the parent map's name: the qualifier column is the + // scope for every other PROPERTY row and a consumer reading it should + // not get a variable name from this one shape. The parent is already + // recoverable from the qualified name's own prefix. + .withQualifier(GradlePropertyScope.EXT_MAP_ENTRY) + .build(); + + this.extractedDeclarations.push(decl); + } + } + + // ─── Kotlin DSL Plugin Pattern ───────────────────────────────── + + /** + * Attempts to process a Kotlin DSL plugin declaration: + * id("org.springframework.boot") version "3.1.5" [apply false] + * + * tree-sitter-groovy splits this across sibling nodes: + * [juxt_function_call] id("...") version ← current node + * [expression_statement] "3.1.5" ← next sibling (version value) + * --- or for apply false: --- + * [juxt_function_call] "1.0" apply ← version + apply keyword + * [expression_statement] false ← apply value + * + * @returns Number of extra siblings consumed (0 if not a plugin pattern). + */ + private tryProcessPluginIdDeclaration( + node: Parser.SyntaxNode, + siblings: Parser.SyntaxNode[], + index: number, + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string + ): number { + // Pattern: juxt_function_call → [method_invocation(id/kotlin, args), argument_list(version)] + const methodInvocation = node.children.find(c => c.type === 'method_invocation'); + if (!methodInvocation) return 0; + + const idName = this.getMethodName(methodInvocation); + if (idName !== 'id' && idName !== 'kotlin') return 0; + + // Check that the argument_list of the juxt_function_call contains 'version' + const outerArgList = node.children.find(c => c.type === 'argument_list'); + if (!outerArgList) return 0; + const hasVersion = outerArgList.children.some(c => c.type === 'identifier' && c.text === 'version'); + if (!hasVersion) return 0; + + // Extract plugin ID from the method invocation's arguments + const pluginIdRaw = this.getArgumentsText(methodInvocation); + const pluginId = this.stripQuotes(pluginIdRaw); + + // Look ahead for version value + let versionValue = ''; + let applyFalse = false; + let consumed = 0; + let endLine = node.endPosition.row + 1; + let endColumn = node.endPosition.column; + + const nextSibling = index + 1 < siblings.length ? siblings[index + 1] : null; + if (nextSibling) { + if (nextSibling.type === 'expression_statement') { + // Simple: id("...") version "3.1.5" + // next sibling is expression_statement containing the version string + const inner = nextSibling.children[0]; + versionValue = inner ? this.stripQuotes(inner.text) : ''; + endLine = nextSibling.endPosition.row + 1; + endColumn = nextSibling.endPosition.column; + consumed = 1; + } else if (nextSibling.type === 'juxt_function_call') { + // Complex: id("...") version "1.0" apply false + // next sibling is juxt_function_call: "1.0" apply + // sibling after that is expression_statement: false + const strChild = nextSibling.children.find( + c => c.type === 'string_literal' || c.type === 'character_literal' + ); + const applyArgList = nextSibling.children.find(c => c.type === 'argument_list'); + const hasApply = applyArgList?.children.some(c => c.type === 'identifier' && c.text === 'apply'); + + if (strChild && hasApply) { + versionValue = this.stripQuotes(strChild.text); + consumed = 1; + endLine = nextSibling.endPosition.row + 1; + endColumn = nextSibling.endPosition.column; + + // Consume the apply value (false) + const applyValueSibling = index + 2 < siblings.length ? siblings[index + 2] : null; + if (applyValueSibling?.type === 'expression_statement') { + applyFalse = applyValueSibling.text.trim() === 'false' || applyValueSibling.text.trim().includes('false'); + endLine = applyValueSibling.endPosition.row + 1; + endColumn = applyValueSibling.endPosition.column; + consumed = 2; + } + } + } + } + + const startLine = node.startPosition.row + 1; + const startColumn = node.startPosition.column; + + const decl = GradleDeclaration.builder( + GradleDeclarationType.PLUGIN, pluginId, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withValue(versionValue) + .withQualifier(idName) + .withNotation(GradlePluginSyntax.PLUGINS_BLOCK_ID) + .withHasConfigBlock(applyFalse) + .build(); + + this.extractedDeclarations.push(decl); + // Scan plugin name + version for value references (e.g., version from variable) + this.extractValueReferences(node, pluginId + ' ' + versionValue, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + return consumed; + } + + private isSettingsScript(): boolean { + return this.scriptKind === GradleScriptKind.SETTINGS + || this.scriptKind === GradleScriptKind.BUILD_SRC_SETTINGS; + } + + /** + * Gradle accepts `include 'core'` and `include ':core'` as the same project. + * The relation stores the canonical colon-prefixed form so that a settings + * include and a `project(':core')` dependency join on equal strings — most + * real settings files use the bare form and every dependency uses the other. + */ + private normalizeProjectPath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith(':')) return trimmed; + // A path with a separator is a directory spec, not a project name. + if (trimmed.includes('/') || trimmed.includes('\\')) return ''; + return ':' + trimmed; + } + + /** + * Strips surrounding single or double quotes from a string. + */ + private stripQuotes(s: string): string { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + return s; + } + + // ─── Value Reference Resolution ──────────────────────────────── + + /** + * Links each value reference to the PROPERTY declaration it names, within + * this file, and records what kind of link that was. + * + * ## Why the unresolved cases are split rather than left blank + * + * A reference this pass cannot match is not necessarily a miss. Some + * references have nothing in the corpus to match by construction — + * `System.getenv('CI')` names an environment variable, and no build file + * anywhere declares it. Others name a property that IS declared somewhere + * and simply was not linked, which is the parser's own gap. + * + * Marking both `UNRESOLVED` would make the coverage number track how many + * environment variables a build reads. So the reference types that can never + * resolve to a declaration are marked EXTERNAL here and excluded from + * coverage, and everything else is left UNRESOLVED_IN_CORPUS for the project + * pass, which sees the other files and can still resolve it. + */ + private resolveValueReferences(): void { + const propertyMap = new Map(); + for (const decl of this.extractedDeclarations) { + if (decl.getDeclarationType() === GradleDeclarationType.PROPERTY) { + propertyMap.set(decl.getName(), decl); + } + } + + for (const ref of this.extractedValueReferences) { + // Reads of the environment, of system properties, and of properties + // supplied on the command line. Nothing in any build file declares + // these, so an empty target is the correct and final answer. + if (GradleFileExtractor.ALWAYS_EXTERNAL_REFS.has(ref.getReferenceType())) { + ref.setResolution(GradleReferenceResolution.EXTERNAL); + continue; + } + + const expr = ref.getReferenceExpression(); + + const direct = propertyMap.get(expr); + if (direct) { + ref.setResolution(this.scopeOf(direct), direct.getHash()); + continue; + } + + // `versions.awsSdk` resolves to the `versions` map when the map itself + // was decomposed; the decomposed entry is preferred when present because + // it carries the actual version rather than the whole literal. + const dotIndex = expr.indexOf('.'); + if (dotIndex > 0) { + const head = expr.substring(0, dotIndex); + const owner = propertyMap.get(head); + if (owner) { + ref.setResolution(this.scopeOf(owner), owner.getHash()); + continue; + } + // `rootProject.foo` names a property of another script. + if (head === 'rootProject' || head === 'project') { + ref.setResolution(GradleReferenceResolution.UNRESOLVED_IN_CORPUS); + continue; + } + } + + // Left for the project pass, which can see the other scripts and the + // version catalogs. Concluding EXTERNAL here would freeze the weaker + // answer produced by the less informed pass. + ref.setResolution(GradleReferenceResolution.UNRESOLVED_IN_CORPUS); + } + } + + /** Reference kinds that read something no build file can declare. */ + private static readonly ALWAYS_EXTERNAL_REFS: ReadonlySet = new Set([ + GradleValueReferenceType.SYSTEM_PROPERTY, + GradleValueReferenceType.ENV_VARIABLE, + GradleValueReferenceType.ENV_VARIABLE_SHORT, + GradleValueReferenceType.SYSTEM_PROPERTY_PROVIDER, + GradleValueReferenceType.ENV_VARIABLE_PROVIDER, + GradleValueReferenceType.FILE_READ, + ]); + + /** EXT_PROPERTY when the property came from an ext block, LOCAL otherwise. */ + private scopeOf(decl: GradleDeclaration): GradleReferenceResolution { + const scope = decl.getQualifier(); + return (scope === GradlePropertyScope.EXT_BLOCK + || scope === GradlePropertyScope.EXT_SINGLE + || scope === GradlePropertyScope.EXT_SET + || scope === GradlePropertyScope.BUILDSCRIPT_EXT + || scope === GradlePropertyScope.EXT_MAP_ENTRY) + ? GradleReferenceResolution.EXT_PROPERTY + : GradleReferenceResolution.LOCAL_PROPERTY; + } + + // ─── Dependency Coordinates ──────────────────────────────────── + + /** + * Splits every DEPENDENCY declaration into coordinate rows. + * + * Runs after restoration so an interpolated coordinate is split on its real + * text — `"com.example:lib:${springVersion}"` rather than + * `"com.example:lib:__INTERP__"`. Splitting the placeholder would report a + * literal version of `__INTERP__` on every interpolated dependency in the + * corpus. + */ + private extractCoordinates(): void { + for (const decl of this.extractedDeclarations) { + if (decl.getDeclarationType() !== GradleDeclarationType.DEPENDENCY) continue; + + const configuration = decl.getQualifier(); + const parsed = DependencyCoordinateParser.parse(decl.getValue() || decl.getName()); + + for (const c of parsed) { + this.extractedCoordinates.push( + GradleDependencyCoordinate.builder( + configuration, + c.notation, + decl.getHash(), + decl.getParentBlockHash(), + this.scriptHash, + this.filePath, + this.baseMservPath, + decl.getStartLine(), + decl.getEndLine(), + this.serviceVersionHash + ) + .withGroup(c.group) + .withArtifact(c.artifact) + .withVersion(c.version) + .withClassifier(c.classifier) + .withExtension(c.extension) + .withVersionSource(c.versionSource) + .withProjectPath(c.projectPath) + .withFileSpec(c.fileSpec) + .withCatalogAlias(c.catalogAlias) + .withHasConfigBlock(decl.getHasConfigBlock()) + // A literal version needs no resolution, so it is its own resolved + // value. An interpolated or catalog one stays empty until the + // project pass actually finds what it points at. + .withResolvedVersion( + c.versionSource === GradleVersionSource.LITERAL ? c.version : '' + ) + .build() + ); + + // A catalog accessor is a reference to something outside this file, so + // it belongs in the reference relation too. Without a row here, the + // only trace of `implementation libs.spring.core` in that relation is + // nothing at all, and a coverage pass would count the build as having + // no unresolved references while every coordinate in it is empty. + if (c.catalogAlias) { + this.extractedValueReferences.push( + GradleValueReference.builder( + c.catalogAlias, + c.notation === GradleDependencyNotation.VERSION_CATALOG_BUNDLE + ? GradleValueReferenceType.VERSION_CATALOG_BUNDLE + : GradleValueReferenceType.VERSION_CATALOG_ACCESSOR, + decl.getValue() || decl.getName(), + this.scriptHash, + this.filePath, this.baseMservPath, + decl.getStartLine(), decl.getEndLine(), + decl.getStartColumn(), decl.getEndColumn(), + this.serviceVersionHash + ) + .withOwnerDeclarationHash(decl.getHash()) + .withOwnerBlockHash(decl.getParentBlockHash()) + .withResolutionKind(GradleReferenceResolution.UNRESOLVED_IN_CORPUS) + .build() + ); + } + } + } + } + + /** + * Emits a REPOSITORY row for `maven { }`, `ivy { }` and `flatDir { }`. + * + * These are the only repositories that are blocks rather than calls, so the + * shortcut path — which matches `mavenCentral()` and friends by method name — + * never saw them. The effect was that a build declaring nothing but a custom + * Maven repository produced no repository rows at all, which reads + * downstream as a build that resolves from nowhere. + */ + private static readonly REPOSITORY_BLOCK_TYPES: Record = { + 'maven': GradleRepositoryType.MAVEN_CUSTOM, + 'ivy': GradleRepositoryType.IVY, + 'flatDir': GradleRepositoryType.FLAT_DIR, + 'exclusiveContent': GradleRepositoryType.EXCLUSIVE_CONTENT, + }; + + private emitRepositoryBlockDeclaration( + block: GradleBlock, + blockName: string, + dialect: GradleDSLDialect, + filePath: string, + baseMservPath: string, + serviceVersionHash: string + ): void { + const repoType = GradleFileExtractor.REPOSITORY_BLOCK_TYPES[blockName]; + if (!repoType) return; + if (!this.isWithin(block.getParentBlockHash(), GradleBlockType.REPOSITORIES)) return; + + // The URL lives in a `url` declaration inside the block, which has not + // been walked yet. Read it off the original source instead, which also + // survives the case where the block's contents failed to parse. + const url = this.findUrlInLines(block.getStartLine(), block.getEndLine()); + + this.extractedDeclarations.push( + GradleDeclaration.builder( + GradleDeclarationType.REPOSITORY, url || blockName, dialect, + block.getHash(), this.scriptHash, + filePath, baseMservPath, + block.getStartLine(), block.getEndLine(), + block.getStartColumn(), block.getEndColumn(), + serviceVersionHash + ) + .withValue(url) + .withNotation(repoType) + .withQualifier(blockName) + .withHasConfigBlock(true) + .build() + ); + } + + /** First `url`/`setUrl` value in a line range of the original source. */ + private findUrlInLines(startLine: number, endLine: number): string { + for (let i = startLine - 1; i < endLine && i < this.originalLines.length; i++) { + const line = this.originalLines[i]; + if (!line) continue; + const m = /\b(?:url|setUrl)\s*[=(]?\s*(?:uri\s*\()?\s*['"]([^'"]+)['"]/.exec(line); + if (m) return m[1] ?? ''; + } + return ''; + } + + // ─── Apply Statement ─────────────────────────────────────────── + + /** + * Processes apply plugin: 'x' or apply from: 'path'. + */ + private processApplyStatement( + node: Parser.SyntaxNode, + args: string, + dialect: GradleDSLDialect, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + startColumn: number, + endColumn: number, + serviceVersionHash: string, + parentBlockHash: string + ): void { + const fullText = node.text; + + if (fullText.includes('plugin:')) { + const pluginName = this.extractStringLiteral(args.replace(/plugin\s*:\s*/, '')); + const decl = GradleDeclaration.builder( + GradleDeclarationType.PLUGIN, pluginName, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withNotation(GradlePluginSyntax.APPLY_PLUGIN_STRING) + .build(); + + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, pluginName, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + } else if (fullText.includes('from:')) { + const fromPath = this.extractStringLiteral(args.replace(/from\s*:\s*/, '')); + const isRemote = fromPath.startsWith('http://') || fromPath.startsWith('https://'); + const decl = GradleDeclaration.builder( + GradleDeclarationType.PLUGIN, fromPath, dialect, parentBlockHash, + this.scriptHash, + filePath, baseMservPath, startLine, endLine, startColumn, endColumn, + serviceVersionHash + ) + .withNotation(isRemote ? GradlePluginSyntax.APPLY_FROM_REMOTE : GradlePluginSyntax.APPLY_FROM_LOCAL) + .build(); + + this.extractedDeclarations.push(decl); + this.extractValueReferences(node, fromPath, decl.getHash(), parentBlockHash, filePath, baseMservPath, serviceVersionHash); + } + } + + // ─── Control Flow ────────────────────────────────────────────── + + /** + * Processes a control flow statement (if, for, while, etc.). + */ + private processControlFlow( + node: Parser.SyntaxNode, + blockType: GradleBlockType, + blocks: GradleBlock[], + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + depth: number + ): void { + const expression = this.extractConditionExpression(node); + + const block = GradleBlock.builder( + blockType, + depth, + dialect, + this.scriptHash, + filePath, + baseMservPath, + node.startPosition.row + 1, + node.endPosition.row + 1, + node.startPosition.column, + node.endPosition.column, + serviceVersionHash + ) + .withExpression(expression) + .withParentBlockHash(parentBlockHash) + .build(); + + this.registerBlock(block); + + blocks.push(block); + + // Walk the body of the control flow + const body = this.findChildByType(node, 'block') || this.findChildByType(node, 'closure'); + if (body) { + this.walkNode( + body, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, block.getHash(), depth + 1 + ); + } + + // Handle else/else-if branches for if statements + if (blockType === GradleBlockType.IF) { + this.processElseBranches( + node, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, parentBlockHash, depth + ); + } + } + + /** + * Processes else and else-if branches of an if statement. + */ + private processElseBranches( + ifNode: Parser.SyntaxNode, + blocks: GradleBlock[], + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + depth: number + ): void { + // tree-sitter-groovy represents else as an alternative child + for (const child of ifNode.children) { + if (child.type === 'else_clause' || child.type === 'else') { + const innerIf = this.findChildByType(child, 'if_statement'); + if (innerIf) { + // else if — flatten to ELSE_IF + this.processControlFlow( + innerIf, GradleBlockType.ELSE_IF, blocks, filePath, + baseMservPath, dialect, serviceVersionHash, parentBlockHash, depth + ); + } else { + // standalone else + const elseBody = this.findChildByType(child, 'block') || this.findChildByType(child, 'closure'); + if (elseBody) { + const elseBlock = GradleBlock.builder( + GradleBlockType.ELSE, + depth, + dialect, + this.scriptHash, + filePath, + baseMservPath, + child.startPosition.row + 1, + child.endPosition.row + 1, + child.startPosition.column, + child.endPosition.column, + serviceVersionHash + ) + .withParentBlockHash(parentBlockHash) + .build(); + + this.registerBlock(elseBlock); + + blocks.push(elseBlock); + + this.walkNode( + elseBody, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, elseBlock.getHash(), depth + 1 + ); + } + } + } + } + } + + // ─── Try/Catch/Finally ───────────────────────────────────────── + + /** + * Processes try/catch/finally statements, linking them via tryStatementHash. + */ + private processTryStatement( + node: Parser.SyntaxNode, + blocks: GradleBlock[], + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + depth: number + ): void { + // Create the TRY block + const tryBlock = GradleBlock.builder( + GradleBlockType.TRY, + depth, + dialect, + this.scriptHash, + filePath, + baseMservPath, + node.startPosition.row + 1, + node.endPosition.row + 1, + node.startPosition.column, + node.endPosition.column, + serviceVersionHash + ) + .withParentBlockHash(parentBlockHash) + .build(); + + this.registerBlock(tryBlock); + + blocks.push(tryBlock); + const tryHash = tryBlock.getHash(); + + // Walk try body + const tryBody = this.findChildByType(node, 'block') || this.findChildByType(node, 'closure'); + if (tryBody) { + this.walkNode( + tryBody, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, tryHash, depth + 1 + ); + } + + // Process catch clauses + for (const child of node.children) { + if (child.type === 'catch_clause' || child.type === 'catch') { + const caughtType = this.extractCaughtExceptionType(child); + const catchBlock = GradleBlock.builder( + GradleBlockType.CATCH, + depth, + dialect, + this.scriptHash, + filePath, + baseMservPath, + child.startPosition.row + 1, + child.endPosition.row + 1, + child.startPosition.column, + child.endPosition.column, + serviceVersionHash + ) + .withParentBlockHash(parentBlockHash) + .withTryStatementHash(tryHash) + .withCaughtExceptionTypes(caughtType) + .build(); + + this.registerBlock(catchBlock); + + blocks.push(catchBlock); + + const catchBody = this.findChildByType(child, 'block') || this.findChildByType(child, 'closure'); + if (catchBody) { + this.walkNode( + catchBody, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, catchBlock.getHash(), depth + 1 + ); + } + } + + if (child.type === 'finally_clause' || child.type === 'finally') { + const finallyBlock = GradleBlock.builder( + GradleBlockType.FINALLY, + depth, + dialect, + this.scriptHash, + filePath, + baseMservPath, + child.startPosition.row + 1, + child.endPosition.row + 1, + child.startPosition.column, + child.endPosition.column, + serviceVersionHash + ) + .withParentBlockHash(parentBlockHash) + .withTryStatementHash(tryHash) + .build(); + + this.registerBlock(finallyBlock); + + blocks.push(finallyBlock); + + const finallyBody = this.findChildByType(child, 'block') || this.findChildByType(child, 'closure'); + if (finallyBody) { + this.walkNode( + finallyBody, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, finallyBlock.getHash(), depth + 1 + ); + } + } + } + } + + // ─── Switch ──────────────────────────────────────────────────── + + /** + * Processes a switch statement: creates SWITCH parent + SWITCH_CASE children. + */ + private processSwitchStatement( + node: Parser.SyntaxNode, + blocks: GradleBlock[], + filePath: string, + baseMservPath: string, + dialect: GradleDSLDialect, + serviceVersionHash: string, + parentBlockHash: string, + depth: number + ): void { + const expression = this.extractConditionExpression(node); + + const switchBlock = GradleBlock.builder( + GradleBlockType.SWITCH, + depth, + dialect, + this.scriptHash, + filePath, + baseMservPath, + node.startPosition.row + 1, + node.endPosition.row + 1, + node.startPosition.column, + node.endPosition.column, + serviceVersionHash + ) + .withExpression(expression) + .withParentBlockHash(parentBlockHash) + .build(); + + this.registerBlock(switchBlock); + + blocks.push(switchBlock); + + // Walk through switch body looking for case clauses + const switchBody = this.findChildByType(node, 'switch_block') || this.findChildByType(node, 'block'); + if (switchBody) { + for (const child of switchBody.children) { + if (child.type === 'switch_block_statement_group' || child.type === 'case_clause' || child.type === 'default_clause') { + const caseLabel = this.extractCaseLabel(child); + const caseBlock = GradleBlock.builder( + GradleBlockType.SWITCH_CASE, + depth + 1, + dialect, + this.scriptHash, + filePath, + baseMservPath, + child.startPosition.row + 1, + child.endPosition.row + 1, + child.startPosition.column, + child.endPosition.column, + serviceVersionHash + ) + .withBlockName(caseLabel) + .withParentBlockHash(switchBlock.getHash()) + .build(); + + this.registerBlock(caseBlock); + + blocks.push(caseBlock); + + this.walkNode( + child, blocks, filePath, baseMservPath, dialect, + serviceVersionHash, caseBlock.getHash(), depth + 2 + ); + } + } + } + } + + // ─── Helper Methods ──────────────────────────────────────────── + + /** + * Pre-processes source to strip Kotlin type annotations that tree-sitter-groovy + * cannot parse (e.g., `String`, `String?`, `Map`). + * + * Transforms: + * val name: String = value → val name = value + * val name: String? = value → val name = value + * val name: String by delegate → val name by delegate + * val name: Map = value → val name = value + * + * Only applies to val/var declarations. Preserves line numbers (no line removal). + */ + private stripKotlinTypeAnnotations(source: string): string { + // Match val/var name: Type[?] [=|by] + // The type can be simple (String) or generic (Map>) + // We need to handle nested angle brackets for generics + // Not a gap: the declared type is redundant with the initialiser for + // every shape this parser reports on, and the PROPERTY row keeps the value. + return this.rewrite( + source, + /\b(val|var)\s+(\w+)\s*:\s*[A-Z]\w*(?:<[^>]*>)?\??\s*(=|by)\s/g, + (m) => `${m[1]} ${m[2]} ${m[3]} `, + null + ); + } + + /** + * Pre-processes source to convert Kotlin delegated property syntax to + * simple assignments that tree-sitter-groovy can parse. + * + * Transforms: + * val name by extra("value") → val name = extra("value") + * val name by project → val name = project + * val name by extra { ... } → val name = extra { ... } + * + * Must run AFTER stripKotlinTypeAnnotations (which already converts + * `val name: Type by delegate` → `val name by delegate`). + */ + private stripKotlinByDelegation(source: string): string { + return this.rewrite( + source, + /\b(val|var)\s+(\w+)\s+by\s+/g, + (m) => `${m[1]} ${m[2]} = `, + null + ); + } + + /** + * Replaces GString interpolation blocks ${...} with a safe placeholder. + * tree-sitter-groovy does not support GString interpolation and treats + * the { inside ${} as a block-opening brace, which corrupts all + * subsequent brace matching in the file. + * + * Transforms: + * "Bearer ${System.getenv("TOKEN")}" → "Bearer __INTERP__" + * "guava:${guavaVersion}" → "guava:__INTERP__" + */ + private normalizeGStringInterpolation(source: string): string { + // Round-trips: restorePreprocessedValues puts the original ${...} back, + // and extractRestoredGStringRefs then reads the references out of it. + // `[^}]` matches newlines on purpose — a multi-line interpolation still + // has to be neutralised — and rewrite() carries the newlines forward so + // nothing below it shifts. + return this.rewrite(source, /\$\{[^}]+\}/g, () => '__INTERP__', null); + } + + /** + * Strips parenthesized arguments before trailing closures for non-keyword + * identifiers. tree-sitter-groovy cannot parse `method(args) { closure }` + * correctly — it fails to associate the closure with the method call. + * + * Transforms: + * credentials(HttpHeaderCredentials) { → credentials { + * task('hello', type: Copy) { → task { + * + * Control flow keywords (if, for, while, etc.) are excluded. + */ + private stripTrailingClosureArgs(source: string): string { + // Round-trips: the args are stashed per line and recovered by + // recoverStrippedClosureArgs when the block is built, so nothing is lost. + return this.rewrite( + source, + // The receiver is captured too. `tasks.register('x', Copy) { }` is a + // task and a bare `register('x') { }` on some other object is not, and + // the two are indistinguishable once the `tasks.` is dropped. + /(?:^|[^\w.])([A-Za-z_][\w.]*)\([^)\n]*\)[^\S\n]*\{/gm, + (m) => { + const match = m[0]; + const name = m[1] ?? ''; + const leading = match.slice(0, match.indexOf(name)); + if (GradleFileExtractor.CONTROL_FLOW_KEYWORDS.has(name)) return match; + if (name.split('.').some((seg) => GradleFileExtractor.CONTROL_FLOW_KEYWORDS.has(seg))) return match; + // From the name's own offset, not the match's: the leading character + // the pattern consumes to prove the name is unqualified is often the + // preceding newline, which would file the args under the line above. + const lineNumber = this.lineOf(source, m.index + leading.length); + const openParen = match.indexOf('('); + const closeParen = match.lastIndexOf(')'); + if (openParen >= 0 && closeParen > openParen) { + this.strippedClosureArgs.set(lineNumber, { + methodName: name, + args: match.substring(openParen + 1, closeParen), + }); + } + return leading + name + ' {'; + }, + null + ); + } + + private static readonly CONTROL_FLOW_KEYWORDS = new Set([ + 'if', 'else', 'for', 'while', 'do', 'switch', 'catch', 'try', 'finally', 'synchronized', + ]); + + /** + * Strips Groovy closure parameter declarations so tree-sitter-groovy can + * parse the closure body. Without this, `{ project -> ... }` produces an + * ERROR node and everything inside becomes an unparseable blob. + * + * Transforms: + * { project -> → { + * { key, value -> → { + * { DependencyDetails details -> → { + */ + private stripClosureParameters(source: string): string { + // Match: { -> + // Handles: { x -> , { a, b -> , { Type x -> , { Type x, Type y -> + // LOSSY: the parameter names are gone and nothing recovers them, so a + // consumer reading `configurations.each { }` cannot tell what the closure + // called its argument. Recorded as a gap for exactly that reason. + return this.rewrite( + source, + /\{([ \t]*)(?:[A-Z]\w+\s+)?\w+(?:\s*,\s*(?:[A-Z]\w+\s+)?\w+)*\s*->/g, + (m) => '{' + (m[1] ?? ''), + GradleParseGapReason.DROPPED_CLOSURE_PARAMETERS + ); + } + + /** + * Replaces the Groovy/Kotlin Elvis operator (?:) with logical OR (||). + * tree-sitter-groovy cannot parse ?:, producing malformed nodes that + * extend to end-of-file. + * + * Transforms: + * findProperty('x') ?: 'default' → findProperty('x') || 'default' + */ + private normalizeElvisOperator(source: string): string { + // LOSSY: `a ?: b` and `a || b` are different operators — the first yields + // `a` when it is truthy, the second yields `true` — so any consumer + // reading the rewritten expression text is reading something the build + // never said. The default-value column on the reference relation carries + // the part that matters; the gap row says where the rest went. + return this.rewrite(source, /\?:/g, () => '||', GradleParseGapReason.REWRITTEN_ELVIS); + } + + /** + * Replaces empty single-quoted string literals '' with '_EMPTY_'. + * tree-sitter-groovy uses character_literal for single-quoted strings + * and cannot parse '' (empty) — it produces an ERROR node that spans + * to end-of-file, corrupting all subsequent block parsing. + * + * Uses negative lookahead/lookbehind to avoid matching inside triple- + * quoted strings ('''). + * + * Transforms: + * project.findProperty('x') || '' → project.findProperty('x') || '_EMPTY_' + */ + private normalizeEmptyStringLiterals(source: string): string { + // Round-trips: restorePreprocessing turns '_EMPTY_' back into ''. + return this.rewrite(source, /(? "'_EMPTY_'", null); + } + + /** + * Replaces non-ASCII characters (e.g. em-dash —, box-drawing ─) with _. + * tree-sitter-groovy cannot handle multi-byte UTF-8 characters and will + * produce ERROR nodes that cascade through the rest of the file. + */ + private stripNonAsciiCharacters(source: string): string { + // LOSSY: a non-ASCII character inside a string literal — a repository name, + // a comment marker, a licence header — becomes `_` in every emitted value. + // Runs of them are collapsed into one gap so a box-drawing banner does not + // produce sixty rows. + return this.rewrite( + source, + /[^\x00-\x7F]+/g, + (m) => '_'.repeat(m[0].length), + GradleParseGapReason.REPLACED_NON_ASCII + ); + } + + /** + * Converts single-quoted values in named parameter syntax to double-quoted. + * tree-sitter-groovy cannot parse `method(key: 'value')` inside a closure + * but handles `method(key: "value")` correctly. + * + * Transforms: + * project(path: ':shared') → project(path: ":shared") + * exclude group: 'org.x' → exclude group: "org.x" + */ + private convertNamedParamQuotes(source: string): string { + // Round-trips: only the quote character changes, and the value is read back + // from inside it either way. + return this.rewrite( + source, + /(\w+\s*:\s*)'([^'\n]*)'/g, + (m) => `${m[1]}"${m[2]}"`, + null + ); + } + + /** + * Wraps dependency wrapper function calls in parentheses so tree-sitter-groovy + * parses them as method invocations instead of splitting the wrapper name + * from its argument list. + * + * tree-sitter-groovy parses `implementation files('a.jar', 'b.jar')` as + * juxt_function_call(implementation, files) + a separate parenthesized + * expression ('a.jar', 'b.jar') which produces an ERROR node that cascades + * through the rest of the enclosing block. + * + * Uses balanced-paren counting to find the matching close paren, supporting + * nested calls like testFixtures(project(':core')). + * + * Transforms: + * implementation files('a.jar', 'b.jar') → implementation(files('a.jar', 'b.jar')) + * implementation platform('org:art:1.0') → implementation(platform('org:art:1.0')) + * testImpl testFixtures(project(':core')) → testImpl(testFixtures(project(':core'))) + */ + private normalizeDependencyWrapperCalls(source: string): string { + const depConfigs = GradleFileExtractor.DEPENDENCY_CONFIGS; + const wrappers = GradleFileExtractor.DEPENDENCY_WRAPPER_FUNCTIONS; + const pattern = /\b(\w+)\s+(\w+)\s*\(/g; + + let result = ''; + let lastIndex = 0; + let match; + + while ((match = pattern.exec(source)) !== null) { + const configName = match[1]!; + const wrapperName = match[2]!; + if (!depConfigs.has(configName) || !wrappers.has(wrapperName)) continue; + + // Find matching close paren with balanced counting + const openParenPos = match.index + match[0].length - 1; + let depth = 1; + let closePos = -1; + for (let j = openParenPos + 1; j < source.length; j++) { + if (source[j] === '(') depth++; + if (source[j] === ')') { + depth--; + if (depth === 0) { closePos = j; break; } + } + } + + if (closePos < 0) continue; + + // Transform: configName wrapperFunc(...) → configName(wrapperFunc(...)) + const configEnd = match.index + configName.length; + const inner = source.substring(configEnd, closePos + 1); + // Leading whitespace is dropped but its newlines are kept: trimming them + // away would shift every position below this call by however many lines + // the wrapper spanned. + const newlines = (inner.slice(0, inner.length - inner.trimStart().length).match(/\n/g) || []).length; + result += source.substring(lastIndex, configEnd); + result += '('; + result += inner.trimStart() + '\n'.repeat(newlines); + result += ')'; + lastIndex = closePos + 1; + pattern.lastIndex = closePos + 1; + } + + result += source.substring(lastIndex); + return result; + } + + /** + * Wraps a bare version catalog accessor in parentheses. + * + * tree-sitter-groovy splits `implementation libs.spring.boot.starter.web` + * into `juxt_function_call(implementation, libs)` and a separate + * `expression_statement(spring.boot.starter.web)`. The dependency row that + * falls out of that names `libs` — every catalog dependency in the corpus + * collapsing to the same meaningless coordinate — and the rest of the + * accessor becomes an unrelated statement. + * + * Wrapping it makes the grammar read one method invocation, which is the + * same shape it already handles for `implementation project(':core')`. + * + * Round-trips: only parentheses are added, and the accessor text inside them + * is untouched, so nothing is lost and no gap is warranted. + * + * Anchored to end-of-line and restricted to pure dotted identifiers so it + * cannot touch `implementation group: 'x', name: 'y'` (has a colon), + * `implementation project(':a')` (has parens), or a trailing closure. + */ + private normalizeCatalogAccessorCalls(source: string): string { + const configs = GradleFileExtractor.DEPENDENCY_CONFIGS; + return this.rewrite( + source, + /^([ \t]*)([A-Za-z_]\w*)[ \t]+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)[ \t]*$/gm, + (m) => { + const config = m[2] ?? ''; + if (!configs.has(config)) return m[0]; + return `${m[1]}${config}(${m[3]})`; + }, + null + ); + } + + /** + * Strips Kotlin ::class references that tree-sitter-groovy cannot parse. + * + * Transforms: + * HttpHeaderCredentials::class → HttpHeaderCredentials + * String::class.java → String + */ + private stripKotlinClassReferences(source: string): string { + // LOSSY: `Foo::class.java` becomes `Foo`, so the fact that the build named + // a class literal rather than a value is gone from every emitted row. + return this.rewrite( + source, + /(::\w+)(\.\w+)?/g, + () => '', + GradleParseGapReason.DROPPED_CLASS_REFERENCE + ); + } + + /** + * Strips inline generic type parameters on method calls that + * tree-sitter-groovy cannot parse. + * + * Transforms: + * create("header") → create("header") + * listOf() → listOf() + */ + private stripKotlinInlineGenerics(source: string): string { + // LOSSY, and it costs a real fact: `tasks.register("docs")` loses the + // task type, which is the one thing that distinguishes it from every other + // registered task. The gap row keeps the original text so the type is at + // least recoverable by hand. + return this.rewrite( + source, + /(\w+)<[^>]+>\s*\(/g, + (m) => `${m[1]}(`, + GradleParseGapReason.DROPPED_TYPE_ARGUMENTS + ); + } + + /** + * Strips Kotlin type casts (as Type / as Type?) that confuse + * tree-sitter-groovy, especially nullable casts. + * + * Transforms: + * findProperty("x") as String? ?: "default" → findProperty("x") ?: "default" + * value as Int → value + */ + private stripKotlinTypeCasts(source: string): string { + // LOSSY, and over-eager: the pattern matches any ` as Word` sequence, so a + // Groovy string containing the English word "as" followed by a capitalised + // word is rewritten too. The gap row is what makes that visible rather + // than silent. + return this.rewrite( + source, + /\s+as\s+\w+(?:<[^>]*>)?\??/g, + () => '', + GradleParseGapReason.DROPPED_TYPE_CAST + ); + } + + /** + * Detects Groovy vs Kotlin DSL dialect from file extension. + */ + private detectDialect(filePath: string): GradleDSLDialect { + return filePath.endsWith('.kts') ? GradleDSLDialect.KOTLIN : GradleDSLDialect.GROOVY; + } + + /** + * Extracts the base microservice/project path from the file path. + * Looks for common project root markers. + */ + private extractBaseMservPath(filePath: string): string { + // Walk up from file to find a directory containing build.gradle or settings.gradle + const parts = filePath.split('/'); + for (let i = parts.length - 2; i >= 0; i--) { + // Return the directory containing this gradle file + if (parts[i + 1]?.endsWith('.gradle') || parts[i + 1]?.endsWith('.gradle.kts')) { + return parts.slice(0, i + 1).join('/'); + } + } + return filePath; + } + + /** + * Gets the method name from a method_invocation node. + */ + private getMethodName(node: Parser.SyntaxNode): string { + // Try named children first + for (const child of node.children) { + if (child.type === 'identifier') { + return child.text; + } + if (child.type === 'property_expression' || child.type === 'member_access') { + return child.text; + } + } + // Fallback: first child text + return node.children[0]?.text || ''; + } + + /** + * Gets the first identifier from a node. + */ + private getFirstIdentifier(node: Parser.SyntaxNode): string { + for (const child of node.children) { + if (child.type === 'identifier') { + return child.text; + } + } + return node.children[0]?.text || ''; + } + + /** + * Finds the first child of a specific type. + */ + private findChildByType(node: Parser.SyntaxNode, type: string): Parser.SyntaxNode | undefined { + for (const child of node.children) { + if (child.type === type) { + return child; + } + } + return undefined; + } + + /** + * Gets arguments text from a method invocation. + */ + private getArgumentsText(node: Parser.SyntaxNode): string { + const argList = this.findChildByType(node, 'argument_list') || this.findChildByType(node, 'arguments'); + if (argList) { + // Strip surrounding parentheses + const text = argList.text; + if (text.startsWith('(') && text.endsWith(')')) { + return text.slice(1, -1).trim(); + } + return text.trim(); + } + return ''; + } + + /** + * Gets the argument portion of an application expression. + * In `implementation 'guava:...'`, returns `'guava:...'` + */ + private getApplicationArgText(node: Parser.SyntaxNode): string { + const children = node.children; + if (children.length > 1) { + // Skip the first identifier (method name), collect the rest + return children.slice(1) + .map(c => c.text) + .join(' ') + .trim(); + } + return ''; + } + + /** + * Extracts the condition/expression from a parenthesized expression. + */ + private extractConditionExpression(node: Parser.SyntaxNode): string { + const parenExpr = this.findChildByType(node, 'parenthesized_expression'); + if (parenExpr) { + const text = parenExpr.text; + if (text.startsWith('(') && text.endsWith(')')) { + return text.slice(1, -1).trim(); + } + return text; + } + return ''; + } + + /** + * Extracts the caught exception type from a catch clause. + */ + private extractCaughtExceptionType(node: Parser.SyntaxNode): string { + // Look for the type in catch (ExceptionType e) { } + for (const child of node.children) { + if (child.type === 'catch_formal_parameter' || child.type === 'formal_parameter') { + for (const param of child.children) { + if (param.type === 'type_identifier' || param.type === 'identifier') { + return param.text; + } + } + } + } + return ''; + } + + /** + * Extracts the case label text from a switch case/default clause. + */ + private extractCaseLabel(node: Parser.SyntaxNode): string { + for (const child of node.children) { + if (child.type === 'switch_label' || child.type === 'case') { + // Get the value after 'case' keyword + for (const labelChild of child.children) { + if (labelChild.type !== 'case' && labelChild.type !== ':') { + return labelChild.text; + } + } + } + if (child.type === 'default') { + return 'default'; + } + } + return ''; + } + + /** + * Strips surrounding quotes from a string literal. + */ + private extractStringLiteral(text: string): string { + const trimmed = text.trim(); + if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"'))) { + return trimmed.slice(1, -1); + } + return trimmed; + } + + // ─── Value Reference Extraction ───────────────────────────── + + /** + * Scans a declaration's value text for GString interpolation references + * and scans the AST node for method-based references (System.getenv, findProperty, etc.). + * + * Called after a declaration is created, so the declaration hash is available for linking. + */ + private extractValueReferences( + node: Parser.SyntaxNode, + valueText: string, + ownerDeclarationHash: string, + ownerBlockHash: string, + filePath: string, + baseMservPath: string, + serviceVersionHash: string + ): void { + // 1. Scan for GString interpolation in the value text + this.scanStringForGStringRefs( + valueText, node, ownerDeclarationHash, ownerBlockHash, + filePath, baseMservPath, serviceVersionHash + ); + + // 2. Scan AST for method-based value references + this.scanNodeForMethodBasedRefs( + node, ownerDeclarationHash, ownerBlockHash, + filePath, baseMservPath, serviceVersionHash + ); + } + + /** + * Scans a string value for GString patterns: ${expr}, $var, ${-> expr}. + */ + /** + * Resolves where a matched fragment actually sits, rather than handing every + * reference the whole enclosing node's range. + * + * That shortcut is not merely imprecise, it breaks identity. A reference's + * key is its owner plus its byte range, so three `$ES_HOME` occurrences in + * one 80-line declaration all produced the SAME key and collapsed into one + * row — 88 collisions in Elasticsearch alone, each one a reference the + * relation simply did not contain. + * + * The fragment is located inside the node's own text, with a cursor so the + * second occurrence is found after the first rather than matching it again. + * When it cannot be located — the scanned text was derived rather than taken + * verbatim from the node — the ordinal keeps the key unique and the position + * degrades to the node's start, which is where it already was. + */ + private locateFragment( + node: Parser.SyntaxNode, + fragment: string, + cursor: { at: number; ordinal: number } + ): { startLine: number; endLine: number; startColumn: number; endColumn: number } { + const nodeText = node.text; + const found = fragment ? nodeText.indexOf(fragment, cursor.at) : -1; + cursor.ordinal++; + + if (found < 0) { + // Ordinal offset keeps two unlocatable fragments in one declaration from + // sharing a key. It is a discriminator, not a claim about position. + const column = node.startPosition.column + cursor.ordinal; + return { + startLine: node.startPosition.row + 1, + endLine: node.startPosition.row + 1, + startColumn: column, + endColumn: column + fragment.length, + }; + } + + cursor.at = found + fragment.length; + + const before = nodeText.slice(0, found); + const newlines = (before.match(/\n/g) || []).length; + const lastNl = before.lastIndexOf('\n'); + const column = newlines === 0 + ? node.startPosition.column + found + : found - lastNl - 1; + + const inner = (fragment.match(/\n/g) || []).length; + const line = node.startPosition.row + 1 + newlines; + return { + startLine: line, + endLine: line + inner, + startColumn: column, + endColumn: column + (inner ? fragment.length - fragment.lastIndexOf('\n') - 1 : fragment.length), + }; + } + + private scanStringForGStringRefs( + text: string, + node: Parser.SyntaxNode, + ownerDeclarationHash: string, + ownerBlockHash: string, + filePath: string, + baseMservPath: string, + serviceVersionHash: string + ): void { + const cursor = { at: 0, ordinal: 0 }; + + // Match ${-> ...} (lazy GString) first — must come before ${...} + const lazyPattern = /\$\{->\s*([^}]+)\}/g; + let match: RegExpExecArray | null; + const processedRanges: [number, number][] = []; + + while ((match = lazyPattern.exec(text)) !== null) { + processedRanges.push([match.index, match.index + match[0].length]); + const lazyExpr = match[1] ?? ''; + const at = this.locateFragment(node, match[0], cursor); + const ref = GradleValueReference.builder( + lazyExpr.trim(), + GradleValueReferenceType.LAZY_GSTRING, + match[0], + this.scriptHash, + filePath, baseMservPath, + at.startLine, at.endLine, at.startColumn, at.endColumn, + serviceVersionHash + ) + .withOwnerDeclarationHash(ownerDeclarationHash) + .withOwnerBlockHash(ownerBlockHash) + .build(); + this.extractedValueReferences.push(ref); + } + + // Match ${expr} (full interpolation) — skip ranges already matched as lazy + const fullPattern = /\$\{([^}]+)\}/g; + while ((match = fullPattern.exec(text)) !== null) { + if (processedRanges.some(([s, e]) => match!.index >= s && match!.index < e)) continue; + const expr = (match[1] ?? '').trim(); + // Detect ext property access: ext.x, versions.x, rootProject.x + const refType = expr.includes('.') + ? GradleValueReferenceType.EXT_PROPERTY_ACCESS + : GradleValueReferenceType.GSTRING_INTERPOLATION; + + const at = this.locateFragment(node, match[0], cursor); + const ref = GradleValueReference.builder( + expr, + refType, + match[0], + this.scriptHash, + filePath, baseMservPath, + at.startLine, at.endLine, at.startColumn, at.endColumn, + serviceVersionHash + ) + .withOwnerDeclarationHash(ownerDeclarationHash) + .withOwnerBlockHash(ownerBlockHash) + .build(); + this.extractedValueReferences.push(ref); + } + + // Match $varName (simple dollar-prefix) — skip if inside ${...} + const simplePattern = /\$([a-zA-Z_][a-zA-Z0-9_.]*)/g; + while ((match = simplePattern.exec(text)) !== null) { + // Skip if this $ is part of a ${...} block + if (match.index > 0 && text[match.index + 1] === '{') continue; + // Check if inside an already-matched ${...} range + const alreadyMatched = processedRanges.some(([s, e]) => match!.index >= s && match!.index < e); + if (alreadyMatched) continue; + // Also check against full pattern ranges + const inFullInterp = text.substring(0, match.index).lastIndexOf('${') > text.substring(0, match.index).lastIndexOf('}'); + if (inFullInterp) continue; + + const simpleExpr = match[1] ?? ''; + const refType = simpleExpr.includes('.') + ? GradleValueReferenceType.EXT_PROPERTY_ACCESS + : GradleValueReferenceType.GSTRING_SIMPLE; + + const at = this.locateFragment(node, match[0], cursor); + const ref = GradleValueReference.builder( + simpleExpr, + refType, + match[0], + this.scriptHash, + filePath, baseMservPath, + at.startLine, at.endLine, at.startColumn, at.endColumn, + serviceVersionHash + ) + .withOwnerDeclarationHash(ownerDeclarationHash) + .withOwnerBlockHash(ownerBlockHash) + .build(); + this.extractedValueReferences.push(ref); + } + } + + /** Known method-based value reference patterns: receiver.method → type */ + private static readonly METHOD_REF_PATTERNS: { pattern: RegExp; type: GradleValueReferenceType }[] = [ + { pattern: /System\.getProperty\s*\(\s*['"]([^'"]+)['"]\s*\)/, type: GradleValueReferenceType.SYSTEM_PROPERTY }, + { pattern: /System\.getenv\s*\(\s*['"]([^'"]+)['"]\s*\)/, type: GradleValueReferenceType.ENV_VARIABLE }, + { pattern: /System\.env\.([a-zA-Z_][a-zA-Z0-9_]*)/, type: GradleValueReferenceType.ENV_VARIABLE_SHORT }, + { pattern: /(?:project\.)?findProperty\s*\(\s*['"]([^'"]+)['"]\s*\)/, type: GradleValueReferenceType.FIND_PROPERTY }, + { pattern: /project\.property\s*\(\s*['"]([^'"]+)['"]\s*\)/, type: GradleValueReferenceType.PROJECT_PROPERTY }, + { pattern: /project\.hasProperty\s*\(\s*['"]([^'"]+)['"]\s*\)/, type: GradleValueReferenceType.HAS_PROPERTY }, + { pattern: /providers\.gradleProperty\s*\(\s*['"]([^'"]+)['"]\s*\)/, type: GradleValueReferenceType.GRADLE_PROPERTY_PROVIDER }, + { pattern: /providers\.systemProperty\s*\(\s*['"]([^'"]+)['"]\s*\)/, type: GradleValueReferenceType.SYSTEM_PROPERTY_PROVIDER }, + { pattern: /providers\.environmentVariable\s*\(\s*['"]([^'"]+)['"]\s*\)/, type: GradleValueReferenceType.ENV_VARIABLE_PROVIDER }, + { pattern: /file\s*\(\s*['"]([^'"]+)['"]\s*\)\.text/, type: GradleValueReferenceType.FILE_READ }, + ]; + + /** + * Scans an AST node's text for method-based value references + * (System.getenv, findProperty, providers.*, file().text, etc.). + */ + private scanNodeForMethodBasedRefs( + node: Parser.SyntaxNode, + ownerDeclarationHash: string, + ownerBlockHash: string, + filePath: string, + baseMservPath: string, + serviceVersionHash: string + ): void { + const text = node.text; + const cursor = { at: 0, ordinal: 0 }; + + for (const { pattern, type } of GradleFileExtractor.METHOD_REF_PATTERNS) { + // Global, so a block reading three environment variables yields three + // rows. The non-global exec only ever found the first, and the other two + // were simply absent from the relation. + const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'); + let match: RegExpExecArray | null; + while ((match = global.exec(text)) !== null) { + if (match[0].length === 0) { global.lastIndex++; continue; } + const extractedName = match[1] || match[0]; + const defaultValueMatch = text.match(/\?:\s*['"]([^'"]*)['"]/); + const at = this.locateFragment(node, match[0], cursor); + + const ref = GradleValueReference.builder( + extractedName, + type, + match[0], + this.scriptHash, + filePath, baseMservPath, + at.startLine, at.endLine, at.startColumn, at.endColumn, + serviceVersionHash + ) + .withOwnerDeclarationHash(ownerDeclarationHash) + .withOwnerBlockHash(ownerBlockHash); + + if (defaultValueMatch && defaultValueMatch[1]) { + ref.withDefaultValue(defaultValueMatch[1]); + } + + this.extractedValueReferences.push(ref.build()); + } + } + } + + // ─── Dependency Notation Classification ──────────────────────── + + /** + * Classifies the notation of a dependency coordinate. + */ + private classifyDependencyNotation(args: string): string { + const trimmed = args.trim(); + + if (trimmed.startsWith('project(')) return GradleDependencyNotation.PROJECT; + if (trimmed.startsWith('platform(')) return GradleDependencyNotation.PLATFORM; + if (trimmed.startsWith('enforcedPlatform(')) return GradleDependencyNotation.ENFORCED_PLATFORM; + if (trimmed.startsWith('testFixtures(')) return GradleDependencyNotation.TEST_FIXTURES; + if (trimmed.startsWith('files(')) return GradleDependencyNotation.FILES; + if (trimmed.startsWith('fileTree(')) return GradleDependencyNotation.FILE_TREE; + if (trimmed === 'gradleApi()') return GradleDependencyNotation.GRADLE_API; + if (trimmed === 'gradleTestKit()') return GradleDependencyNotation.GRADLE_TEST_KIT; + if (trimmed === 'localGroovy()') return GradleDependencyNotation.LOCAL_GROOVY; + if (trimmed.includes('group:') || trimmed.includes('name:')) return GradleDependencyNotation.MAP_NOTATION; + if (trimmed.startsWith('libs.')) return GradleDependencyNotation.VERSION_CATALOG_ACCESSOR; + + // String notation: 'group:artifact:version' or "group:artifact:version" + const unquoted = this.extractStringLiteral(trimmed); + const colonCount = (unquoted.match(/:/g) || []).length; + if (colonCount >= 2) { + if (unquoted.includes('@')) return GradleDependencyNotation.STRING_WITH_EXTENSION; + // Check for classifier (4th segment) + if (colonCount >= 3) return GradleDependencyNotation.STRING_WITH_CLASSIFIER; + return GradleDependencyNotation.STRING_NOTATION; + } + + return GradleDependencyNotation.STRING_NOTATION; + } +} diff --git a/parser/src/parsers/gradle/extractors/index.ts b/parser/src/parsers/gradle/extractors/index.ts new file mode 100644 index 000000000..53182cdfe --- /dev/null +++ b/parser/src/parsers/gradle/extractors/index.ts @@ -0,0 +1,2 @@ +export { GradleFileExtractor } from './gradle-file-extractor'; +export { GradleCatalogExtractor } from './gradle-catalog-extractor'; diff --git a/parser/src/parsers/gradle/gradle-comment-scanner.ts b/parser/src/parsers/gradle/gradle-comment-scanner.ts new file mode 100644 index 000000000..f18a44d4f --- /dev/null +++ b/parser/src/parsers/gradle/gradle-comment-scanner.ts @@ -0,0 +1,234 @@ +import { GradleCommentKind } from '@/enums/gradle/comments/GradleCommentKind'; + +export interface ScannedComment { + kind: GradleCommentKind; + /** Comment body with the delimiters removed, whitespace-normalised. */ + text: string; + isCommentedOutCode: boolean; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; +} + +/** + * Finds comments in Gradle source. + * + * ## Why this scans the text instead of reading the tree + * + * It runs over the ORIGINAL source, before the extractor's preprocessing + * rewrites anything. That is deliberate twice over. The rewrites delete tokens + * and can turn a construct into an ERROR node, and everything a tree-sitter + * ERROR node swallows — including any comment inside it — is unreachable from + * the tree. On a Kotlin DSL file, where the rewrites are heaviest, that is + * most of the file. A scanner over the raw bytes is the only way to get + * comments off those files at all, and the positions it reports are the real + * ones rather than post-rewrite ones. + * + * ## Known limit + * + * Groovy's slashy strings (`/foo\/bar/`) are not tracked, so a `//` inside one + * reads as the start of a line comment. They are almost unheard of in build + * scripts and tracking them requires distinguishing a division operator from a + * string opener, which needs the very parse this scanner exists to work + * around. The failure mode is a spurious comment row, not a lost declaration. + */ +export class GradleCommentScanner { + /** + * Patterns whose presence in a comment body means the comment is very likely + * disabled code rather than prose. Deliberately narrow: this flag is a hint + * for a consumer, and a false positive is worse than a miss because the + * whole point is to distinguish "the build says nothing about log4j" from + * "the build used to depend on log4j and someone commented it out". + */ + private static readonly CODE_PATTERNS: RegExp[] = [ + /^\s*(implementation|api|compileOnly|runtimeOnly|testImplementation|testCompileOnly|testRuntimeOnly|annotationProcessor|classpath|compile|runtime|kapt|ksp|developmentOnly|providedCompile)\s*[('"]/, + /^\s*apply\s+(plugin|from)\s*:/, + /^\s*id\s*[('"]/, + /^\s*(include|includeBuild)\s*[('"]/, + /^\s*(maven|mavenCentral|mavenLocal|jcenter|google|gradlePluginPortal)\s*[({]/, + /^\s*\w+\s*=\s*['"]/, + ]; + + static scan(source: string): ScannedComment[] { + const out: ScannedComment[] = []; + const lines = source.split('\n'); + + let inBlock = false; + let blockIsDoc = false; + let blockStartLine = 0; + let blockStartCol = 0; + let blockBody: string[] = []; + + for (let li = 0; li < lines.length; li++) { + const line = lines[li] ?? ''; + + if (inBlock) { + const end = line.indexOf('*/'); + if (end < 0) { + blockBody.push(line); + continue; + } + blockBody.push(line.slice(0, end)); + out.push(this.makeBlock( + blockIsDoc, blockBody, blockStartLine, blockStartCol, li + 1, end + 2 + )); + inBlock = false; + blockBody = []; + // Keep scanning after the terminator: `*/ implementation 'x' // note` + // puts a real comment on the same line. + const rest = this.scanLine(line.slice(end + 2), li + 1, end + 2); + for (const c of rest.comments) out.push(c); + if (rest.openBlock) { + inBlock = true; + blockIsDoc = rest.openBlock.isDoc; + blockStartLine = rest.openBlock.startLine; + blockStartCol = rest.openBlock.startColumn; + blockBody = [rest.openBlock.firstLineBody]; + } + continue; + } + + if (li === 0 && line.startsWith('#!')) { + out.push({ + kind: GradleCommentKind.SHEBANG, + text: line.slice(2).trim(), + isCommentedOutCode: false, + startLine: 1, endLine: 1, startColumn: 0, endColumn: line.length, + }); + continue; + } + + const res = this.scanLine(line, li + 1, 0); + for (const c of res.comments) out.push(c); + if (res.openBlock) { + inBlock = true; + blockIsDoc = res.openBlock.isDoc; + blockStartLine = res.openBlock.startLine; + blockStartCol = res.openBlock.startColumn; + blockBody = [res.openBlock.firstLineBody]; + } + } + + // An unterminated block comment runs to end of file. Gradle would reject + // the script, but the row is still emitted so the region is visible. + if (inBlock) { + out.push(this.makeBlock( + blockIsDoc, blockBody, blockStartLine, blockStartCol, + lines.length, (lines[lines.length - 1] ?? '').length + )); + } + + return out; + } + + /** + * Scans one line's worth of text, tracking quote state so a `//` inside a + * string is not mistaken for a comment. `url 'https://repo.example.com'` is + * the case that matters — it appears in nearly every repositories block, and + * a scanner that misses it reports the second half of every repository URL + * as a comment. + */ + private static scanLine( + text: string, + lineNumber: number, + columnOffset: number + ): { + comments: ScannedComment[]; + openBlock?: { isDoc: boolean; startLine: number; startColumn: number; firstLineBody: string }; + } { + const comments: ScannedComment[] = []; + let inSingle = false; + let inDouble = false; + let tripleSingle = false; + let tripleDouble = false; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = text[i + 1]; + + if (ch === '\\') { i++; continue; } + + if (!inDouble && !tripleDouble && text.startsWith("'''", i)) { tripleSingle = !tripleSingle; i += 2; continue; } + if (!inSingle && !tripleSingle && text.startsWith('"""', i)) { tripleDouble = !tripleDouble; i += 2; continue; } + if (tripleSingle || tripleDouble) continue; + + if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } + if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } + if (inSingle || inDouble) continue; + + if (ch === '/' && next === '/') { + const body = text.slice(i + 2); + comments.push({ + kind: GradleCommentKind.LINE, + text: body.trim(), + isCommentedOutCode: this.looksLikeCode(body), + startLine: lineNumber, + endLine: lineNumber, + startColumn: columnOffset + i, + endColumn: columnOffset + text.length, + }); + return { comments }; + } + + if (ch === '/' && next === '*') { + const isDoc = text[i + 2] === '*'; + const bodyStart = i + (isDoc ? 3 : 2); + const close = text.indexOf('*/', bodyStart); + if (close >= 0) { + const body = text.slice(bodyStart, close); + comments.push({ + kind: isDoc ? GradleCommentKind.GROOVYDOC : GradleCommentKind.BLOCK, + text: this.normalise(body), + isCommentedOutCode: this.looksLikeCode(body), + startLine: lineNumber, + endLine: lineNumber, + startColumn: columnOffset + i, + endColumn: columnOffset + close + 2, + }); + i = close + 1; + continue; + } + return { + comments, + openBlock: { + isDoc, + startLine: lineNumber, + startColumn: columnOffset + i, + firstLineBody: text.slice(bodyStart), + }, + }; + } + } + + return { comments }; + } + + private static makeBlock( + isDoc: boolean, + body: string[], + startLine: number, + startColumn: number, + endLine: number, + endColumn: number + ): ScannedComment { + // GroovyDoc continuation asterisks are formatting, not content. + const cleaned = body + .map((l) => l.replace(/^\s*\*\s?/, '')) + .join(' '); + return { + kind: isDoc ? GradleCommentKind.GROOVYDOC : GradleCommentKind.BLOCK, + text: this.normalise(cleaned), + isCommentedOutCode: body.some((l) => this.looksLikeCode(l)), + startLine, endLine, startColumn, endColumn, + }; + } + + private static looksLikeCode(body: string): boolean { + return this.CODE_PATTERNS.some((p) => p.test(body)); + } + + private static normalise(text: string): string { + return text.replace(/\s+/g, ' ').trim(); + } +} diff --git a/parser/src/parsers/gradle/gradle-resolution-linker.ts b/parser/src/parsers/gradle/gradle-resolution-linker.ts new file mode 100644 index 000000000..c7cca249a --- /dev/null +++ b/parser/src/parsers/gradle/gradle-resolution-linker.ts @@ -0,0 +1,464 @@ +import * as path from 'path'; + +import { GradleCatalogEntry } from '@/analysis-types/gradle/GradleCatalogEntry'; +import { GradleDeclaration } from '@/analysis-types/gradle/GradleDeclaration'; +import { GradleDependencyCoordinate } from '@/analysis-types/gradle/GradleDependencyCoordinate'; +import { GradleScript } from '@/analysis-types/gradle/GradleScript'; +import { GradleValueReference } from '@/analysis-types/gradle/GradleValueReference'; +import { GradleCatalogEntryKind } from '@/enums/gradle/catalog/GradleCatalogEntryKind'; +import { GradleDeclarationType } from '@/enums/gradle/declarations/GradleDeclarationType'; +import { GradlePropertyScope } from '@/enums/gradle/declarations/GradlePropertyScope'; +import { GradleScriptKind } from '@/enums/gradle/files/GradleScriptKind'; +import { GradleReferenceResolution } from '@/enums/gradle/value-references/GradleReferenceResolution'; +import { GradleValueReferenceType } from '@/enums/gradle/value-references/GradleValueReferenceType'; + +/** Everything the project pass links across, gathered from every file. */ +export interface GradleProjectFacts { + scripts: GradleScript[]; + declarations: GradleDeclaration[]; + valueReferences: GradleValueReference[]; + coordinates: GradleDependencyCoordinate[]; + catalogEntries: GradleCatalogEntry[]; +} + +/** + * The project pass: fills in every link that one file could not decide alone. + * + * ## Why this cannot be folded into the single-file pass + * + * A single file pass reading `include ':core'` can conclude only that `:core` + * is not in this file. That is the weaker answer, and writing it down as final + * locks out the stronger one — the actual `core/build.gradle`, which is + * sitting right there in the corpus. The same is true of every catalog + * accessor: `implementation libs.spring.core` has no coordinate at all until + * `gradle/libs.versions.toml` has been read. + * + * So the rule is the one the rest of the parser follows: a link a single file + * cannot decide is left empty and retried here, rather than being resolved + * pessimistically the first time. + * + * ## What it deliberately will not do + * + * It does not match a property reference against any declaration anywhere in + * the corpus that happens to share its name. `version` is declared in nearly + * every build file in a multi-project build; matching on name alone would link + * a subproject's reference to an unrelated sibling's declaration and report it + * as resolved. Cross-script matching is restricted to scopes that genuinely + * propagate — the build's own root script, `ext` properties, and scripts + * actually applied by the referring one. + */ +export class GradleResolutionLinker { + link(facts: GradleProjectFacts): void { + const byPath = new Map(); + for (const s of facts.scripts) byPath.set(this.norm(s.getFilePath()), s); + + this.linkIncludes(facts, byPath); + this.demoteUnincludedScripts(facts); + this.linkAppliedScripts(facts, byPath); + this.linkProjectDependencies(facts); + this.linkCatalogs(facts); + this.linkCrossScriptProperties(facts); + this.classifyRemainingReferences(facts); + } + + // ── settings includes → the scripts they name ──────────────────────────── + + /** + * `include ':core:api'` names a project; the script that configures it is + * `core/api/build.gradle` under the settings file's directory. + * + * The included script's own `gradleProjectPath` is overwritten with the path + * the settings file declares, because settings is the authority. Layout is + * only the convention: `include ':core'` may be followed by + * `project(':core').projectDir = file('modules/core-impl')`, and then the + * directory-derived path is simply wrong. + */ + private linkIncludes(facts: GradleProjectFacts, byPath: Map): void { + const scriptsByHash = new Map(facts.scripts.map((s) => [s.getHash(), s])); + const renamingSettings = this.settingsThatRenameBuildFiles(facts); + + for (const decl of facts.declarations) { + if (decl.getDeclarationType() !== GradleDeclarationType.INCLUDE) continue; + if (decl.getQualifier() !== 'include') continue; + + const settings = scriptsByHash.get(decl.getScriptHash()); + if (!settings) continue; + + const projectPath = decl.getName(); + if (!projectPath.startsWith(':')) continue; + + const segments = projectPath.replace(/^:/, '').split(':').filter(Boolean); + const relativeDir = segments.join('/'); + const projectName = segments[segments.length - 1] ?? ''; + const settingsDir = path.dirname(settings.getFilePath()); + + const candidates = ['build.gradle', 'build.gradle.kts']; + + // Gradle lets a settings script rename every project's build file: + // + // rootProject.children.each { it.buildFileName = "${it.name}.gradle" } + // + // Spring Framework does exactly this, and under the conventional names + // alone not one of its 30-odd subprojects links to its own build script. + // + // The extra candidate is only tried when the settings script actually + // contains such an assignment — the evidence is in the emitted rows for + // that file, so this is reading what the build says rather than guessing + // at a layout. + if (projectName && renamingSettings.has(settings.getHash())) { + candidates.push(`${projectName}.gradle`, `${projectName}.gradle.kts`); + } + + for (const base of candidates) { + const target = byPath.get(this.norm(path.join(settingsDir, relativeDir, base))); + if (!target) continue; + + decl.setResolvedTargetHash(target.getHash()); + target.setGradleProjectPath(projectPath); + target.setSettingsScriptHash(settings.getHash()); + // The settings file just said this script configures a project, which + // outranks whatever its filename suggested. + if (target.getScriptKind() === GradleScriptKind.SCRIPT_PLUGIN) { + target.setScriptKind(GradleScriptKind.PROJECT_BUILD); + } + break; + } + } + } + + /** + * Settings scripts that assign `buildFileName`. + * + * The assignment is almost always inside a closure over `rootProject + * .children`, so the value is a template this parser does not evaluate. What + * it can tell is that the build renames its build files at all, which is + * enough to know the conventional name is not the one to look for. + */ + private settingsThatRenameBuildFiles(facts: GradleProjectFacts): Set { + const out = new Set(); + for (const decl of facts.declarations) { + if (!decl.getName().includes('buildFileName') && !decl.getValue().includes('buildFileName')) continue; + out.add(decl.getScriptHash()); + } + return out; + } + + /** + * Strips the provisional project path from a build script that the build's + * own settings file never includes. + * + * The classifier derives a project path from directory layout, which is the + * convention. Once a settings file has been read, the convention is no + * longer the best evidence available: a settings file is the complete and + * authoritative list of a build's projects, so a `build.gradle` sitting in a + * directory it does not name configures nothing. Such directories are + * common — an example, a fixture, an abandoned module, a template. + * + * Left alone, each of those becomes a project the build does not have, and a + * downstream "which projects depend on X" query answers for projects Gradle + * would never create. Gradle's own `projects` task disagreeing with this + * relation is how the case was found. + * + * Only applies where a settings file for that build root is actually in the + * corpus. With no settings file there is no authority to defer to, and the + * layout-derived path remains the best available answer. + */ + private demoteUnincludedScripts(facts: GradleProjectFacts): void { + const settingsDirs = new Set( + facts.scripts + .filter((s) => s.getScriptKind() === GradleScriptKind.SETTINGS) + .map((s) => path.dirname(s.getFilePath())) + ); + if (!settingsDirs.size) return; + + // Every script an include actually reached. + const included = new Set( + facts.declarations + .filter((d) => d.getDeclarationType() === GradleDeclarationType.INCLUDE && d.getResolvedTargetHash()) + .map((d) => d.getResolvedTargetHash()) + ); + + for (const script of facts.scripts) { + if (script.getScriptKind() !== GradleScriptKind.PROJECT_BUILD) continue; + if (included.has(script.getHash())) continue; + + // Is this script under a build root whose settings we actually read? + const governed = [...settingsDirs].some((dir) => this.isUnder(script.getFilePath(), dir)); + if (!governed) continue; + + script.setScriptKind(GradleScriptKind.SCRIPT_PLUGIN); + script.setGradleProjectPath(''); + } + } + + private isUnder(filePath: string, dir: string): boolean { + const rel = path.relative(dir, filePath); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); + } + + // ── apply from: → the script plugin it pulls in ────────────────────────── + + /** + * `apply from: 'gradle/dependencies.gradle'` resolves relative to the + * applying script's own directory, which is how Gradle resolves it. + * + * A remote `apply from: 'https://…'` is left unresolved on purpose. The + * content is not in the corpus and never will be, and pointing the edge at + * anything local would fabricate a link. + */ + private linkAppliedScripts(facts: GradleProjectFacts, byPath: Map): void { + const scriptsByHash = new Map(facts.scripts.map((s) => [s.getHash(), s])); + + for (const decl of facts.declarations) { + if (decl.getDeclarationType() !== GradleDeclarationType.PLUGIN) continue; + const target = decl.getName(); + if (!target || target.startsWith('http://') || target.startsWith('https://')) continue; + if (!target.endsWith('.gradle') && !target.endsWith('.gradle.kts')) continue; + + const owner = scriptsByHash.get(decl.getScriptHash()); + if (!owner) continue; + + const resolved = byPath.get(this.norm(path.resolve(path.dirname(owner.getFilePath()), target))); + if (resolved) decl.setResolvedTargetHash(resolved.getHash()); + } + } + + // ── project(':core') dependencies → the subproject's script ────────────── + + private linkProjectDependencies(facts: GradleProjectFacts): void { + const byProjectPath = new Map(); + const byLooseKey = new Map(); + for (const s of facts.scripts) { + const p = s.getGradleProjectPath(); + if (!p || p === ':') continue; + byProjectPath.set(p, s); + // A type-safe accessor camel-cases the project name, so `:core:data-test` + // is reached as `projects.core.dataTest`. Neither form can be turned + // into the other without knowing which projects exist, so both are + // reduced to a key that ignores case and separators and compared there. + byLooseKey.set(this.looseProjectKey(p), s); + } + if (!byProjectPath.size) return; + + const declByHash = new Map(facts.declarations.map((d) => [d.getHash(), d])); + + for (const coord of facts.coordinates) { + const projectPath = coord.getProjectPath(); + if (!projectPath) continue; + + const target = byProjectPath.get(projectPath) + ?? byLooseKey.get(this.looseProjectKey(projectPath)); + if (!target) continue; + + // Rewrite the provisional path to the one settings actually declared, so + // a project dependency and a settings include join on equal strings. + if (target.getGradleProjectPath() !== projectPath) { + coord.setProjectPath(target.getGradleProjectPath()); + } + + const decl = declByHash.get(coord.getDeclarationHash()); + if (decl) decl.setResolvedTargetHash(target.getHash()); + } + } + + /** `:core:data-test` and `:core:dataTest` reduce to the same key. */ + private looseProjectKey(projectPath: string): string { + return projectPath.replace(/[-_]/g, '').toLowerCase(); + } + + // ── version catalogs → the coordinates and references that name them ───── + + /** + * The join that makes a modern build legible. `implementation + * libs.spring.core` carries no group, no artifact and no version until the + * catalog is read; this is where the row stops being empty. + * + * Accessors are matched on the derived accessor path rather than the raw + * alias, because Gradle turns `-` and `_` into `.` when it generates them: + * `spring-boot-starter-web` in the TOML is `libs.spring.boot.starter.web` in + * the build file, and matching the literal alias finds neither. + */ + private linkCatalogs(facts: GradleProjectFacts): void { + if (!facts.catalogEntries.length) return; + + // Keyed by catalog name so two catalogs cannot answer for each other. + const byCatalog = new Map>(); + for (const entry of facts.catalogEntries) { + const name = entry.getCatalogName() || 'libs'; + let table = byCatalog.get(name); + if (!table) { table = new Map(); byCatalog.set(name, table); } + table.set(`${entry.getEntryKind()}|${entry.getAccessorPath()}`, entry); + } + + const lookup = ( + accessor: string, + kind: GradleCatalogEntryKind + ): GradleCatalogEntry | undefined => { + const normalised = GradleCatalogEntry.toAccessorPath(accessor); + for (const table of byCatalog.values()) { + const hit = table.get(`${kind}|${normalised}`); + if (hit) return hit; + } + return undefined; + }; + + for (const coord of facts.coordinates) { + const alias = coord.getCatalogAlias(); + if (!alias) continue; + + const entry = lookup(alias, GradleCatalogEntryKind.LIBRARY) + ?? lookup(alias, GradleCatalogEntryKind.BUNDLE) + ?? lookup(alias, GradleCatalogEntryKind.PLUGIN); + if (!entry) continue; + + coord.setResolvedFromCatalog({ + hash: entry.getHash(), + group: entry.getGroup(), + artifact: entry.getArtifact(), + // A ref-based entry has its version only after the catalog's own + // version pass ran, so the resolved one is preferred and the literal + // is the fallback. + version: entry.getResolvedVersion() || entry.getVersion(), + }); + } + + for (const ref of facts.valueReferences) { + const type = ref.getReferenceType(); + if (type !== GradleValueReferenceType.VERSION_CATALOG_ACCESSOR + && type !== GradleValueReferenceType.VERSION_CATALOG_BUNDLE + && type !== GradleValueReferenceType.VERSION_CATALOG_PLUGIN) continue; + + const expr = ref.getReferenceExpression(); + const bundle = lookup(expr, GradleCatalogEntryKind.BUNDLE); + if (bundle) { ref.setResolution(GradleReferenceResolution.CATALOG_BUNDLE, bundle.getHash()); continue; } + + const library = lookup(expr, GradleCatalogEntryKind.LIBRARY); + if (library) { ref.setResolution(GradleReferenceResolution.CATALOG_LIBRARY, library.getHash()); continue; } + + const plugin = lookup(expr, GradleCatalogEntryKind.PLUGIN); + if (plugin) { ref.setResolution(GradleReferenceResolution.CATALOG_PLUGIN, plugin.getHash()); continue; } + + const version = lookup(expr, GradleCatalogEntryKind.VERSION); + if (version) ref.setResolution(GradleReferenceResolution.CATALOG_VERSION, version.getHash()); + } + } + + // ── properties declared in another script of the same build ────────────── + + /** + * Resolves a reference against a property declared elsewhere, but only where + * Gradle would actually make that property visible. + * + * Two scopes qualify. An `ext` property on the root project is visible to + * every subproject, which is the whole reason builds put their versions + * there. And a script this one applies contributes its properties directly. + * + * A plain project property in an unrelated sibling does NOT qualify, however + * well its name matches. In a build with forty subprojects, `version` is + * declared forty times, and name-only matching would resolve every reference + * to whichever one happened to be first. + */ + private linkCrossScriptProperties(facts: GradleProjectFacts): void { + const visible = new Map(); + + const rootScriptHashes = new Set( + facts.scripts + .filter((s) => s.getScriptKind() === GradleScriptKind.ROOT_BUILD + || s.getScriptKind() === GradleScriptKind.SETTINGS + || s.getScriptKind() === GradleScriptKind.SCRIPT_PLUGIN + || s.getScriptKind() === GradleScriptKind.INIT) + .map((s) => s.getHash()) + ); + + for (const decl of facts.declarations) { + if (decl.getDeclarationType() !== GradleDeclarationType.PROPERTY) continue; + + const scope = decl.getQualifier(); + const isExt = scope === GradlePropertyScope.EXT_BLOCK + || scope === GradlePropertyScope.EXT_SINGLE + || scope === GradlePropertyScope.EXT_SET + || scope === GradlePropertyScope.BUILDSCRIPT_EXT + || scope === GradlePropertyScope.EXT_MAP_ENTRY; + + // A local `def` never leaves its own script, whatever it is called. + if (scope === GradlePropertyScope.LOCAL_VARIABLE) continue; + if (!isExt && !rootScriptHashes.has(decl.getScriptHash())) continue; + + // First declaration wins, so the result does not depend on file order + // beyond the corpus order the caller already fixed. + if (!visible.has(decl.getName())) visible.set(decl.getName(), decl); + } + + if (!visible.size) return; + + for (const ref of facts.valueReferences) { + if (ref.getResolutionKind() !== GradleReferenceResolution.UNRESOLVED_IN_CORPUS) continue; + + const expr = ref.getReferenceExpression(); + const direct = visible.get(expr); + if (direct) { + ref.setResolution(GradleReferenceResolution.CROSS_SCRIPT_PROPERTY, direct.getHash()); + continue; + } + + // `rootProject.ext.springVersion` and `versions.spring` both name a + // property by their last meaningful segment. + const stripped = expr.replace(/^(rootProject|project)\./, '').replace(/^ext\./, ''); + const viaPrefix = visible.get(stripped); + if (viaPrefix) { + ref.setResolution(GradleReferenceResolution.CROSS_SCRIPT_PROPERTY, viaPrefix.getHash()); + continue; + } + + const head = stripped.split('.')[0] ?? ''; + const viaHead = head ? visible.get(head) : undefined; + if (viaHead) ref.setResolution(GradleReferenceResolution.CROSS_SCRIPT_PROPERTY, viaHead.getHash()); + } + } + + // ── the coverage split ─────────────────────────────────────────────────── + + /** + * Separates the references that are the parser's gap from the ones that are + * not resolvable by anything. + * + * This is the last pass because it needs every other link to have been + * attempted first. A reference whose name matches a PROPERTY declared + * somewhere in the corpus stays UNRESOLVED_IN_CORPUS — something was there + * and the link was still not made, which is a defect worth counting. A + * reference matching nothing anywhere becomes EXTERNAL and drops out of the + * coverage denominator, because a build reading `-PbuildNumber` from the + * command line is not a parser failure and counting it as one makes the + * score track how parameterised the build is. + */ + private classifyRemainingReferences(facts: GradleProjectFacts): void { + const declaredNames = new Set(); + for (const decl of facts.declarations) { + if (decl.getDeclarationType() === GradleDeclarationType.PROPERTY) { + declaredNames.add(decl.getName()); + const head = decl.getName().split('.')[0]; + if (head) declaredNames.add(head); + } + } + for (const entry of facts.catalogEntries) { + declaredNames.add(entry.getAccessorPath()); + declaredNames.add(entry.getAlias()); + } + + for (const ref of facts.valueReferences) { + if (ref.getResolutionKind() !== GradleReferenceResolution.UNRESOLVED_IN_CORPUS) continue; + + const expr = ref.getReferenceExpression(); + const head = expr.split('.')[0] ?? expr; + const known = declaredNames.has(expr) + || declaredNames.has(head) + || declaredNames.has(GradleCatalogEntry.toAccessorPath(expr)); + + if (!known) ref.setResolution(GradleReferenceResolution.EXTERNAL); + } + } + + private norm(p: string): string { + return path.resolve(p); + } +} diff --git a/parser/src/parsers/gradle/gradle-script-classifier.ts b/parser/src/parsers/gradle/gradle-script-classifier.ts new file mode 100644 index 000000000..29ba954a0 --- /dev/null +++ b/parser/src/parsers/gradle/gradle-script-classifier.ts @@ -0,0 +1,182 @@ +import * as path from 'path'; + +import { FILE_EXTENSIONS } from '@/constants/consts'; +import { GradleDSLDialect } from '@/enums/gradle/files/GradleDSLDialect'; +import { GradleScriptKind } from '@/enums/gradle/files/GradleScriptKind'; + +/** Everything the classifier can decide about a file from its path alone. */ +export interface GradleScriptIdentity { + scriptKind: GradleScriptKind; + dialect: GradleDSLDialect; + /** Directory that holds the settings file governing this script, if found. */ + buildRoot: string; + /** Best-effort Gradle project path (`:core:api`). Empty when not a build script. */ + gradleProjectPath: string; + /** Path relative to the build root, always with forward slashes. */ + relativePath: string; + fileName: string; +} + +/** + * Decides what role a file plays in a Gradle build. + * + * Every other Gradle relation depends on this being right, because a build + * file's meaning is positional. `core/build.gradle` configures `:core`. The + * root `build.gradle` beside `settings.gradle` may configure every project in + * the build through `subprojects { }`. `buildSrc/build.gradle` configures the + * machinery that builds the build and contributes nothing to the product's + * dependency graph — counting its `implementation` lines as product + * dependencies is a common and completely silent error. + * + * ## The project path is provisional + * + * The path derived here comes from directory layout, which is the convention + * but not the rule: `include ':core'` can be followed by + * `project(':core').projectDir = file('modules/core-impl')`, and then the + * directory says one thing and the build another. The resolution linker + * overwrites this with the path the settings file actually declares wherever + * a settings file was analysed. This is the fallback, not the answer. + */ +export class GradleScriptClassifier { + private static readonly SETTINGS_BASENAMES = new Set([ + 'settings.gradle', 'settings.gradle.kts', + ]); + + private static readonly BUILD_BASENAMES = new Set([ + 'build.gradle', 'build.gradle.kts', + ]); + + /** Gradle's own conventional catalog name; others are declared in settings. */ + private static readonly CATALOG_BASENAME = 'libs.versions.toml'; + + /** + * @param filePath Absolute path of the file. + * @param settingsDirs Directories in the corpus that hold a settings file. + * Supplying the real set is what separates a ROOT_BUILD + * from a PROJECT_BUILD; without it every build script + * that is not obviously nested reads as a root. + */ + static classify(filePath: string, settingsDirs: ReadonlySet): GradleScriptIdentity { + const fileName = path.basename(filePath); + const dir = path.dirname(filePath); + const dialect = this.detectDialect(filePath); + const inBuildSrc = this.isUnderBuildSrc(filePath); + + const buildRoot = this.findBuildRoot(dir, settingsDirs); + const relativePath = this.toPosix(path.relative(buildRoot, filePath)) || fileName; + + const scriptKind = this.detectKind(fileName, dir, inBuildSrc, settingsDirs); + const gradleProjectPath = this.deriveProjectPath(scriptKind, dir, buildRoot); + + return { scriptKind, dialect, buildRoot, gradleProjectPath, relativePath, fileName }; + } + + /** + * The dialect is the file extension and nothing else. Note that this is a + * claim about the SOURCE, not about the parser: tree-sitter-groovy is the + * only grammar available, so a KOTLIN row means "Kotlin source, read through + * a Groovy grammar after rewriting", which is why the parse-gap relation + * matters most on exactly these files. + */ + static detectDialect(filePath: string): GradleDSLDialect { + if (filePath.endsWith(FILE_EXTENSIONS.TOML)) return GradleDSLDialect.TOML; + return filePath.endsWith(FILE_EXTENSIONS.KOTLIN_SCRIPT) + ? GradleDSLDialect.KOTLIN + : GradleDSLDialect.GROOVY; + } + + /** Whether the path is a Gradle script or a version catalog this parser reads. */ + static isGradleFile(fileName: string): boolean { + return fileName.endsWith(FILE_EXTENSIONS.GRADLE) + || fileName.endsWith(FILE_EXTENSIONS.GRADLE_KTS) + || fileName === this.CATALOG_BASENAME + || (fileName.endsWith('.versions' + FILE_EXTENSIONS.TOML)); + } + + static isSettingsFile(fileName: string): boolean { + return this.SETTINGS_BASENAMES.has(fileName); + } + + static isCatalogFile(fileName: string): boolean { + return fileName === this.CATALOG_BASENAME + || fileName.endsWith('.versions' + FILE_EXTENSIONS.TOML); + } + + private static detectKind( + fileName: string, + dir: string, + inBuildSrc: boolean, + settingsDirs: ReadonlySet + ): GradleScriptKind { + if (this.isCatalogFile(fileName)) { + return GradleScriptKind.VERSION_CATALOG; + } + + if (this.SETTINGS_BASENAMES.has(fileName)) { + return inBuildSrc ? GradleScriptKind.BUILD_SRC_SETTINGS : GradleScriptKind.SETTINGS; + } + + // init.gradle / foo.init.gradle — applied before any project is evaluated, + // so nothing it declares belongs to a project. + if (fileName === 'init.gradle' || fileName === 'init.gradle.kts' + || fileName.endsWith('.init.gradle') || fileName.endsWith('.init.gradle.kts')) { + return GradleScriptKind.INIT; + } + + if (this.BUILD_BASENAMES.has(fileName)) { + if (inBuildSrc) return GradleScriptKind.BUILD_SRC_BUILD; + return settingsDirs.has(dir) ? GradleScriptKind.ROOT_BUILD : GradleScriptKind.PROJECT_BUILD; + } + + // Any other .gradle file is only ever reached through `apply from:`. It + // configures whichever script applied it, which is not knowable from the + // path — the resolution linker fills that edge in from the apply side. + return GradleScriptKind.SCRIPT_PLUGIN; + } + + /** + * Nearest ancestor directory holding a settings file. Falls back to the + * file's own directory so a lone build script still gets a stable relative + * path rather than an absolute one. + */ + private static findBuildRoot(dir: string, settingsDirs: ReadonlySet): string { + let current = dir; + while (true) { + if (settingsDirs.has(current)) return current; + const parent = path.dirname(current); + if (parent === current) return dir; + current = parent; + } + } + + /** + * Directory layout to Gradle path. `core/api/build.gradle` under a root + * holding settings.gradle becomes `:core:api`; the root build itself is `:`. + * + * Only build scripts get a path. A script plugin has no project of its own — + * it takes on the project of whoever applied it, and inventing one from its + * directory would attribute every dependency it declares to a project that + * may never apply it. + */ + private static deriveProjectPath( + kind: GradleScriptKind, + dir: string, + buildRoot: string + ): string { + if (kind === GradleScriptKind.ROOT_BUILD || kind === GradleScriptKind.SETTINGS) return ':'; + if (kind !== GradleScriptKind.PROJECT_BUILD) return ''; + + const rel = this.toPosix(path.relative(buildRoot, dir)); + if (!rel || rel === '.') return ':'; + if (rel.startsWith('..')) return ''; + return ':' + rel.split('/').filter(Boolean).join(':'); + } + + private static isUnderBuildSrc(filePath: string): boolean { + return this.toPosix(filePath).split('/').includes('buildSrc'); + } + + private static toPosix(p: string): string { + return p.split(path.sep).join('/'); + } +} diff --git a/parser/src/parsers/gradle/groovy-parser.ts b/parser/src/parsers/gradle/groovy-parser.ts new file mode 100644 index 000000000..5f82cbdb2 --- /dev/null +++ b/parser/src/parsers/gradle/groovy-parser.ts @@ -0,0 +1,88 @@ +import Parser from 'tree-sitter'; +import Groovy from 'tree-sitter-groovy'; + +import { FILE_EXTENSIONS } from '@/constants/consts'; +import { LanguageParser } from '@/parsers/language-parser'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { withRetry } from '@/utils/retry-decorator'; + +/** + * Groovy-specific tree-sitter parser implementation for Gradle build files. + * + * Uses tree-sitter-groovy to parse .gradle files into syntax trees. + * Also handles .gradle.kts conceptually (Kotlin DSL shares structural patterns), + * though tree-sitter-groovy only parses Groovy syntax. + */ +export class GroovyParser implements LanguageParser { + readonly language = ProjectLanguage.GROOVY; + readonly fileExtension = FILE_EXTENSIONS.GRADLE; + + private parser: Parser; + + constructor() { + this.parser = new Parser(); + this.parser.setLanguage(Groovy); + } + + /** + * Parses Groovy/Gradle source code into a syntax tree. + * Uses callback-based parsing for large files to avoid tree-sitter buffer limits. + * @param sourceCode Groovy source code as string + * @returns Parsed syntax tree + * @throws Error if sourceCode is invalid + */ + parse(sourceCode: string): Parser.Tree { + if (!sourceCode || typeof sourceCode !== 'string') { + throw new Error('Invalid source code: must be a non-empty string'); + } + + const useCallbackParsing = sourceCode.length > 30000; + + const parseWithRetry = withRetry( + (code: string) => { + if (useCallbackParsing) { + return this.parser.parse((index: number) => { + if (index >= code.length) { + return null; + } + const chunkSize = 8192; + const chunk = code.substring(index, Math.min(index + chunkSize, code.length)); + return chunk; + }); + } else { + return this.parser.parse(code); + } + }, + { + maxAttempts: 3, + delayMs: 1500, + exponentialBackoff: true, + onRetry: (attempt, error) => { + console.warn(`[GroovyParser] Parse attempt ${attempt} failed: ${error.message}, retrying...`); + } + } + ); + + return parseWithRetry(sourceCode); + } + + /** + * Gets the root node of a parsed tree. + * @param tree Parsed syntax tree + * @returns Root syntax node + */ + getRootNode(tree: Parser.Tree): Parser.SyntaxNode { + return tree.rootNode; + } + + /** + * Queries the syntax tree using tree-sitter query syntax. + * @param node Starting node for the query + * @param queryString Tree-sitter query string + * @returns Query matches + */ + query(node: Parser.SyntaxNode, queryString: string): Parser.QueryMatch[] { + const query = this.parser.getLanguage().query(queryString); + return query.matches(node); + } +} diff --git a/parser/src/parsers/gradle/index.ts b/parser/src/parsers/gradle/index.ts new file mode 100644 index 000000000..11e713e22 --- /dev/null +++ b/parser/src/parsers/gradle/index.ts @@ -0,0 +1,8 @@ +export { GroovyParser } from './groovy-parser'; +export { GradleFileExtractor } from './extractors/gradle-file-extractor'; +export { GradleCatalogExtractor } from './extractors/gradle-catalog-extractor'; +export { GradleScriptClassifier } from './gradle-script-classifier'; +export { GradleResolutionLinker } from './gradle-resolution-linker'; +export { VersionCatalogParser } from './version-catalog-parser'; +export { DependencyCoordinateParser } from './dependency-coordinate-parser'; +export { GradleCommentScanner } from './gradle-comment-scanner'; diff --git a/parser/src/parsers/gradle/version-catalog-parser.ts b/parser/src/parsers/gradle/version-catalog-parser.ts new file mode 100644 index 000000000..79c6f1ee1 --- /dev/null +++ b/parser/src/parsers/gradle/version-catalog-parser.ts @@ -0,0 +1,326 @@ +/** + * A reader for Gradle version catalog TOML. + * + * ## Why this is hand written rather than a TOML library + * + * A catalog is not arbitrary TOML. Gradle validates it against a fixed shape: + * four known tables, aliases matching a documented pattern, and values that + * are either a string, a string array, or an inline table with a known key + * set. Anything else is a build failure, not a document this parser has to + * understand. Pulling in a general TOML parser would buy support for + * multi-line arrays of tables, datetimes and nested dotted sections that a + * catalog cannot contain, and would still leave the whole classification job — + * which notation is this, is the version a ref or a literal, is it rich — + * to be written by hand afterwards. + * + * What a general parser WOULD buy is a second opinion, and the test suite gets + * that instead: the catalog gate parses the same fixtures with an independent + * TOML implementation and compares. That keeps the oracle outside this file, + * where a shared premise cannot make both sides agree on the same mistake. + * + * ## What it deliberately does not do + * + * It does not evaluate. `version.ref = "spring"` is recorded as a ref, and + * resolving it against `[versions]` is a separate pass, so an entry whose ref + * points at nothing keeps an empty version rather than the ref's own name. + * + * ## Supported shapes + * + * ```toml + * [versions] + * spring = "6.1.3" + * groovy = { strictly = "[3.0, 4.0[", prefer = "3.0.5" } + * + * [libraries] + * a = "com.example:lib:1.0" + * b = { module = "com.example:lib", version = "1.0" } + * c = { module = "com.example:lib", version.ref = "spring" } + * d = { group = "com.example", name = "lib", version.ref = "spring" } + * e = { module = "com.example:lib" } + * + * [bundles] + * both = ["a", "b"] + * + * [plugins] + * boot = { id = "org.springframework.boot", version = "3.2.2" } + * kts = "org.jetbrains.kotlin.jvm:1.9.22" + * ``` + */ + +/** One raw entry, before any classification or resolution. */ +export interface RawCatalogEntry { + table: CatalogTable; + alias: string; + /** Present when the entry is `alias = "..."`. */ + stringValue?: string; + /** Present when the entry is `alias = ["a", "b"]`. */ + arrayValue?: string[]; + /** Present when the entry is `alias = { ... }`; dotted keys kept flat. */ + inlineTable?: Record; + startLine: number; + endLine: number; +} + +export type CatalogTable = 'versions' | 'libraries' | 'bundles' | 'plugins'; + +export interface CatalogParseResult { + entries: RawCatalogEntry[]; + /** Lines that sit inside a known table but matched no entry shape. */ + unparsedLines: { line: number; text: string }[]; +} + +const KNOWN_TABLES: ReadonlySet = new Set([ + 'versions', 'libraries', 'bundles', 'plugins', +]); + +export class VersionCatalogParser { + /** + * Parses catalog TOML into raw entries. + * + * Never throws. A malformed line becomes an entry in `unparsedLines`, which + * the extractor turns into a parse-gap row — the same contract the rest of + * the Gradle front end keeps, where a region that could not be read is + * visible rather than silently absent. + */ + static parse(source: string): CatalogParseResult { + const entries: RawCatalogEntry[] = []; + const unparsedLines: { line: number; text: string }[] = []; + const lines = source.split('\n'); + + let table: CatalogTable | null = null; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] ?? ''; + const line = this.stripComment(raw).trim(); + if (!line) continue; + + // [libraries] — a table header. Unknown tables switch `table` to null so + // their contents are skipped rather than misfiled into the last known one. + const header = /^\[\s*([A-Za-z0-9_.-]+)\s*\]$/.exec(line); + if (header) { + const name = (header[1] ?? '').toLowerCase(); + table = KNOWN_TABLES.has(name) ? (name as CatalogTable) : null; + continue; + } + + if (!table) continue; + + const eq = this.splitOnFirstEquals(line); + if (!eq) { + unparsedLines.push({ line: i + 1, text: raw.trim() }); + continue; + } + + // A bare alias (`spring-core = ...`) is the normal form; a quoted one + // (`"spring-core" = ...`) is legal TOML and appears when an alias would + // otherwise need escaping. + const rawKey = eq.key.trim(); + const alias = this.unquote(rawKey) ?? rawKey; + let valueText = eq.value.trim(); + const startLine = i + 1; + let endLine = i + 1; + + // An inline table or array may wrap. Gradle's own catalogs do this + // constantly once entries carry both a module and a version ref. + if (this.isUnbalanced(valueText)) { + let j = i; + while (j + 1 < lines.length && this.isUnbalanced(valueText)) { + j++; + valueText += ' ' + this.stripComment(lines[j] ?? '').trim(); + } + endLine = j + 1; + i = j; + } + + const entry = this.buildEntry(table, alias, valueText, startLine, endLine); + if (entry) { + entries.push(entry); + } else { + unparsedLines.push({ line: startLine, text: raw.trim() }); + } + } + + return { entries, unparsedLines }; + } + + private static buildEntry( + table: CatalogTable, + alias: string, + valueText: string, + startLine: number, + endLine: number + ): RawCatalogEntry | null { + if (!alias) return null; + + if (valueText.startsWith('{')) { + const inlineTable = this.parseInlineTable(valueText); + if (!inlineTable) return null; + return { table, alias, inlineTable, startLine, endLine }; + } + + if (valueText.startsWith('[')) { + const arrayValue = this.parseStringArray(valueText); + if (!arrayValue) return null; + return { table, alias, arrayValue, startLine, endLine }; + } + + const stringValue = this.unquote(valueText); + if (stringValue === null) return null; + return { table, alias, stringValue, startLine, endLine }; + } + + /** + * `{ module = "a:b", version.ref = "c" }` becomes + * `{ module: 'a:b', 'version.ref': 'c' }`. + * + * Dotted keys are kept flat rather than nested. `version.ref` and + * `version.require` are the only nesting a catalog uses, and flattening + * keeps the classification step a lookup instead of a tree walk. Nested + * rich versions — `version = { strictly = "..." }` — are flattened the same + * way, to `version.strictly`. + */ + private static parseInlineTable(text: string): Record | null { + const body = this.stripOuter(text, '{', '}'); + if (body === null) return null; + + const out: Record = {}; + for (const part of this.splitTopLevel(body, ',')) { + const trimmed = part.trim(); + if (!trimmed) continue; + const eq = this.splitOnFirstEquals(trimmed); + if (!eq) return null; + + const key = this.unquote(eq.key.trim()) ?? eq.key.trim(); + const rawValue = eq.value.trim(); + + if (rawValue.startsWith('{')) { + // version = { strictly = "…", prefer = "…" } → version.strictly, version.prefer + const nested = this.parseInlineTable(rawValue); + if (!nested) return null; + for (const [nk, nv] of Object.entries(nested)) out[`${key}.${nk}`] = nv; + continue; + } + + if (rawValue.startsWith('[')) { + const arr = this.parseStringArray(rawValue); + if (!arr) return null; + out[key] = arr.join(','); + continue; + } + + const value = this.unquote(rawValue); + if (value === null) return null; + out[key] = value; + } + return out; + } + + private static parseStringArray(text: string): string[] | null { + const body = this.stripOuter(text, '[', ']'); + if (body === null) return null; + const out: string[] = []; + for (const part of this.splitTopLevel(body, ',')) { + const trimmed = part.trim(); + if (!trimmed) continue; + const value = this.unquote(trimmed); + if (value === null) return null; + out.push(value); + } + return out; + } + + /** + * Splits on the first `=` that is not inside a string. Naive indexOf breaks + * on `a = "x=y"`, which is legal and appears in real catalogs whenever a + * version carries a range. + */ + private static splitOnFirstEquals(line: string): { key: string; value: string } | null { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (ch === '=' && !inSingle && !inDouble) { + return { key: line.slice(0, i), value: line.slice(i + 1) }; + } + } + return null; + } + + /** Splits on a separator at nesting depth zero and outside strings. */ + private static splitTopLevel(text: string, sep: string): string[] { + const out: string[] = []; + let depth = 0; + let inSingle = false; + let inDouble = false; + let start = 0; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (!inSingle && !inDouble) { + if (ch === '{' || ch === '[') depth++; + else if (ch === '}' || ch === ']') depth--; + else if (ch === sep && depth === 0) { + out.push(text.slice(start, i)); + start = i + 1; + } + } + } + out.push(text.slice(start)); + return out; + } + + private static stripOuter(text: string, open: string, close: string): string | null { + const t = text.trim(); + if (!t.startsWith(open) || !t.endsWith(close)) return null; + return t.slice(1, -1); + } + + /** + * Removes a `#` comment that is not inside a string. A bare indexOf('#') + * would truncate `version = "1.0#build"`, which is unusual but legal. + */ + private static stripComment(line: string): string { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (ch === '#' && !inSingle && !inDouble) return line.slice(0, i); + } + return line; + } + + /** Returns null when the text is not a quoted string, so callers can reject it. */ + private static unquote(text: string): string | null { + const t = text.trim(); + if (t.length >= 2) { + if (t.startsWith('"""') && t.endsWith('"""') && t.length >= 6) return t.slice(3, -3); + if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { + return t.slice(1, -1); + } + } + // Bare tokens are not valid catalog values; every one is a quoted string. + return null; + } + + private static isUnbalanced(text: string): boolean { + let depth = 0; + let inSingle = false; + let inDouble = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (!inSingle && !inDouble) { + if (ch === '{' || ch === '[') depth++; + else if (ch === '}' || ch === ']') depth--; + } + } + return depth > 0; + } +} diff --git a/parser/src/parsers/index.ts b/parser/src/parsers/index.ts new file mode 100644 index 000000000..12af27e81 --- /dev/null +++ b/parser/src/parsers/index.ts @@ -0,0 +1,12 @@ +export type { BaseExtractor } from '@/parsers/base-extractor'; +export type { LanguageParser } from '@/parsers/language-parser'; +export { ParserFactory } from '@/parsers/parser-factory'; +export { CodeExtractor } from '@/parsers/code-extractor'; +export { JavaParser, TypeRegistryExtractor } from '@/parsers/java'; +export { + PythonDeclarationExtractor, + PythonDialectDetector, + PythonFactExtractor, + PythonParser, + PythonScopeExtractor, +} from '@/parsers/python'; diff --git a/parser/src/parsers/java/extractors/annotation-extractor.ts b/parser/src/parsers/java/extractors/annotation-extractor.ts new file mode 100644 index 000000000..2485e6fa4 --- /dev/null +++ b/parser/src/parsers/java/extractors/annotation-extractor.ts @@ -0,0 +1,1779 @@ +import Parser from 'tree-sitter'; + +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { AnnotationContext, AnnotationKind, ArgumentValueType } from '@/enums'; +import { TypeRefKind, TypeRefContext, ReferenceOwnerKind } from '@/enums/java/type-references'; +import { BaseExtractor } from '@/parsers/base-extractor'; + +/** + * Extracts TypeAnnotation entities from Java annotation usage contexts. + * + * Supports all annotation patterns: + * - Simple marker: @Deprecated, @Nullable + * - Single value: @Timeout(1000) + * - Named arguments: @Column(name = "id", nullable = false) + * - Array arguments: @Target({ ElementType.TYPE, ElementType.METHOD }) + * (expanded into multiple AnnotationArgumentReference entries with arrayIndex) + * - Nested: @Something(meta = @Other(x = 1)) + * - Meta-annotations: @Retention on annotation declarations + * - Type parameter annotations: class Box<@NonNull T> (Java 8+) + * - Type-use: List<@NonNull String> (not currently extracted) + * + * ## Extraction Contexts + * + * - **Type declarations**: extractFromTypeDeclaration() + * - **Field declarations**: extractFromFieldDeclaration() + * - **Method declarations**: extractFromMethodDeclaration() + * - **Parameter declarations**: extractFromParameterDeclaration() + * - **Constructor declarations**: extractFromConstructorDeclaration() + * - **Type parameters**: extractFromTypeParameter() - Java 8+ type annotations + * + * ## Type Parameter Annotation Example + * + * ```java + * class Container<@NonNull T, // Marker annotation on type parameter T + * @Validated(validator = SizeValidator.class) U> { // Parameterized annotation on U + * // extractFromTypeParameter() captures these annotations and links them to their type parameters + * // Creates TypeAnnotation entries with typeParameterHash set to link to T and U + * // Also extracts TypeReferences for types in annotation arguments (e.g., SizeValidator) + * } + * ``` + * + * ## Array Expansion + * + * Each array element becomes a separate AnnotationArgumentReference with the same + * argumentName and position, differentiated by arrayIndex (0, 1, 2, ...). + * + * ## Implementation Details + * + * - Handles balanced delimiters: (), {}, <> + * - Parses string literals with special characters + * - Preserves whitespace and formatting variations + * - Processes nested annotations at arbitrary depth + * - Extracts type references from annotation argument values + */ +export class AnnotationExtractor implements BaseExtractor { + private extractedArguments: AnnotationArgumentReference[] = []; + private extractedTypeReferences: TypeReference[] = []; + + /** + * Returns all annotation arguments extracted during the last extraction + */ + getExtractedArguments(): AnnotationArgumentReference[] { + return this.extractedArguments; + } + + /** + * Returns all type references extracted from annotation arguments + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Resets the extracted arguments and type references (called before each extraction) + */ + resetExtractedArguments(): void { + this.extractedArguments = []; + this.extractedTypeReferences = []; + } + + /** + * @deprecated Use context-specific extractors instead + */ + extract(_filePath: string, _fileContent: string, _hash: string): TypeAnnotation[] { + console.warn('AnnotationExtractor.extract() is deprecated. Use context-specific extractors.'); + return []; + } + + /** + * Extract annotations from a type declaration (class, interface, enum, record, annotation). + * + * @param typeNode The type declaration syntax node + * @param typeRegistryHash Hash of the type being declared + * @param isAnnotationDeclaration Whether this is an @interface declaration + */ + extractFromTypeDeclaration( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + isAnnotationDeclaration: boolean + ): TypeAnnotation[] { + const context = isAnnotationDeclaration + ? AnnotationContext.ANNOTATION_TYPE_DECLARATION + : AnnotationContext.TYPE_DECLARATION; + + return this.extractAnnotationsFromNode(typeNode, context, typeRegistryHash, typeRegistryHash, true); + } + + /** + * Extract annotations from a field declaration. + * + * @param fieldNode The field declaration syntax node + * @param fieldHash Hash of the field + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromFieldDeclaration( + fieldNode: Parser.SyntaxNode, + fieldHash: string, + typeRegistryHash: string + ): TypeAnnotation[] { + return this.extractAnnotationsFromNode( + fieldNode, + AnnotationContext.FIELD_DECLARATION, + fieldHash, + typeRegistryHash, + false + ); + } + + /** + * Extract annotations from a method declaration. + * + * @param methodNode The method declaration syntax node + * @param methodHash Hash of the method + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromMethodDeclaration( + methodNode: Parser.SyntaxNode, + methodHash: string, + typeRegistryHash: string + ): TypeAnnotation[] { + return this.extractAnnotationsFromNode( + methodNode, + AnnotationContext.METHOD_DECLARATION, + methodHash, + typeRegistryHash, + false + ); + } + + /** + * Extract annotations from a parameter declaration. + * + * @param paramNode The formal parameter syntax node + * @param paramHash Hash of the parameter + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromParameterDeclaration( + paramNode: Parser.SyntaxNode, + paramHash: string, + typeRegistryHash: string + ): TypeAnnotation[] { + return this.extractAnnotationsFromNode( + paramNode, + AnnotationContext.PARAMETER_DECLARATION, + paramHash, + typeRegistryHash, + false + ); + } + + /** + * Extract annotations from an enum constant. + * + * @param enumConstantNode The enum_constant syntax node + * @param enumConstantHash Hash of the enum constant + * @param typeRegistryHash Hash of the enclosing enum type + */ + extractFromEnumConstant( + enumConstantNode: Parser.SyntaxNode, + enumConstantHash: string, + typeRegistryHash: string + ): TypeAnnotation[] { + return this.extractAnnotationsFromNode( + enumConstantNode, + AnnotationContext.ENUM_CONSTANT, + enumConstantHash, + typeRegistryHash, + false + ); + } + + /** + * Extract annotations from a local variable declaration. + * + * @param localVarDeclNode The local_variable_declaration syntax node + * @param localVariableHash Hash of the local variable + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromLocalVariableDeclaration( + localVarDeclNode: Parser.SyntaxNode, + localVariableHash: string, + typeRegistryHash: string + ): TypeAnnotation[] { + return this.extractAnnotationsFromNode( + localVarDeclNode, + AnnotationContext.LOCAL_VARIABLE, + localVariableHash, + typeRegistryHash, + false + ); + } + + /** + * Extract annotations from a type parameter declaration. + * + * @param typeParamNode The type_parameter syntax node + * @param typeParameterHash Hash of the type parameter + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromTypeParameter( + typeParamNode: Parser.SyntaxNode, + typeParameterHash: string, + typeRegistryHash: string + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + let position = 0; + + for (const child of typeParamNode.children) { + if (child.type === 'marker_annotation') { + const annotation = this.extractMarkerAnnotation( + child, + AnnotationContext.TYPE_PARAMETER, + typeParameterHash, + typeRegistryHash, + 0, + position, + undefined, + false, + typeParameterHash // Pass typeParameterHash to build correctly the first time + ); + if (annotation) { + annotations.push(annotation); + position++; + } + } else if (child.type === 'annotation') { + const extracted = this.extractParameterizedAnnotation( + child, + AnnotationContext.TYPE_PARAMETER, + typeParameterHash, + typeRegistryHash, + 0, + position, + undefined, + false, + typeParameterHash // Pass typeParameterHash to build correctly the first time + ); + annotations.push(...extracted); + position++; + } + } + + return annotations; + } + + /** + * Extract annotations from type parameter bounds. + * + * Handles annotations on bound types like: + * - `` - annotation on class-level type parameter bound + * - Method-level: `>` - annotation on method type parameter bound + * - Multiple bounds: `>` + * + * @param typeParamNode The type_parameter syntax node containing the bounds + * @param typeParameterHash Hash of the type parameter (TypeParameter or MethodTypeParameter) + * @param typeRegistryHash Hash of the enclosing type + * @param isMethodTypeParam Whether this is a method-level type parameter + */ + extractFromTypeBound( + typeParamNode: Parser.SyntaxNode, + typeParameterHash: string, + typeRegistryHash: string, + isMethodTypeParam: boolean = false + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + let position = 0; + + // Find the type_bound node + let typeBoundNode: Parser.SyntaxNode | null = null; + for (const child of typeParamNode.children) { + if (child.type === 'type_bound') { + typeBoundNode = child; + break; + } + } + + if (!typeBoundNode) { + return annotations; + } + + // Process each child of the type_bound looking for annotated_type nodes + for (const child of typeBoundNode.children) { + if (child.type === 'annotated_type') { + // Extract annotations from this annotated type + const boundAnnotations = this.extractAnnotationsFromAnnotatedType( + child, + isMethodTypeParam ? AnnotationContext.METHOD_TYPE_PARAM_BOUND : AnnotationContext.TYPE_PARAM_BOUND, + typeParameterHash, + typeRegistryHash, + position + ); + annotations.push(...boundAnnotations); + position += boundAnnotations.length; + } + } + + return annotations; + } + + /** + * Extract annotations from an annotated_type node. + * + * An annotated_type contains annotations followed by a type, e.g., `@NonNull String` + * + * @param annotatedTypeNode The annotated_type syntax node + * @param context The annotation context + * @param ownerHash Hash of the entity being annotated + * @param typeRegistryHash Hash of the enclosing type + * @param startPosition Starting position for annotation numbering + */ + private extractAnnotationsFromAnnotatedType( + annotatedTypeNode: Parser.SyntaxNode, + context: AnnotationContext, + ownerHash: string, + typeRegistryHash: string, + startPosition: number + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + let position = startPosition; + + for (const child of annotatedTypeNode.children) { + if (child.type === 'marker_annotation') { + const annotation = this.extractMarkerAnnotation( + child, + context, + ownerHash, + typeRegistryHash, + 0, + position, + undefined, + false, + ownerHash + ); + if (annotation) { + annotations.push(annotation); + position++; + } + } else if (child.type === 'annotation') { + const extracted = this.extractParameterizedAnnotation( + child, + context, + ownerHash, + typeRegistryHash, + 0, + position, + undefined, + false, + ownerHash + ); + annotations.push(...extracted); + position++; + } + } + + return annotations; + } + + /** + * Extract TYPE_USE annotations from exception types in a throws clause. + * + * Examples: + * - `throws @Critical IOException` - Single annotated exception + * - `throws @Critical IOException, @NonNull SQLException` - Multiple annotated exceptions + * + * @param methodNode The method declaration syntax node + * @param typeRefHashes Array of TypeReference hashes for each exception type (in order) + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromThrowsClause( + methodNode: Parser.SyntaxNode, + typeRefHashes: string[], + typeRegistryHash: string + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + let position = 0; + let typeRefIndex = 0; + + // Find the throws node + let throwsNode: Parser.SyntaxNode | null = null; + for (const child of methodNode.children) { + if (child.type === 'throws') { + throwsNode = child; + break; + } + } + + if (!throwsNode) { + return annotations; + } + + // Process each exception type in the throws clause + for (const child of throwsNode.children) { + // Skip punctuation + if (child.type === ',' || child.type === 'throws') { + continue; + } + + // Get the corresponding TypeReference hash for this exception type + const ownerHash = typeRefHashes[typeRefIndex] || ''; + + if (child.type === 'annotated_type') { + // Extract annotations from this annotated exception type, link to TypeReference + const exceptionAnnotations = this.extractAnnotationsFromAnnotatedType( + child, + AnnotationContext.TYPE_USE, + ownerHash, // Link to TypeReference hash, not method hash + typeRegistryHash, + position + ); + annotations.push(...exceptionAnnotations); + position += exceptionAnnotations.length; + } + + // Move to next TypeReference (whether annotated or not) + typeRefIndex++; + } + + return annotations; + } + + /** + * Extract TYPE_USE annotations from object/array creation expressions. + * + * Handles type-use annotations on the instantiated type: + * - `new @TA Object()` - Annotation on object creation + * - `new @TA int[3]` - Annotation on array creation + * - `new @TA ArrayList()` - Annotation on generic type creation + * + * In tree-sitter, these annotations are direct children of the creation expression, + * appearing before the type node. + * + * @param creationNode The object_creation_expression or array_creation_expression node + * @param expressionHash Hash of the ExpressionReference (owner of the annotation) + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromCreationExpression( + creationNode: Parser.SyntaxNode, + expressionHash: string, + typeRegistryHash: string + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + let position = 0; + + for (const child of creationNode.children) { + if (child.type === 'marker_annotation') { + const annotation = this.extractMarkerAnnotation( + child, + AnnotationContext.TYPE_USE, + expressionHash, + typeRegistryHash, + 0, + position, + undefined, + false, + expressionHash + ); + if (annotation) { + annotations.push(annotation); + position++; + } + } else if (child.type === 'annotation') { + const extracted = this.extractParameterizedAnnotation( + child, + AnnotationContext.TYPE_USE, + expressionHash, + typeRegistryHash, + 0, + position, + undefined, + false, + expressionHash + ); + annotations.push(...extracted); + position++; + } + } + + return annotations; + } + + /** + * Extract TYPE_USE annotations from field type nodes recursively. + * + * Handles nested generic type arguments with annotations: + * - `List<@NonNull String>` - Annotation on type argument + * - `Map<@NonNull String, @Nullable Integer>` - Multiple annotated type args + * - `List>` - Nested annotated generics + * + * @param typeNode The field type syntax node (generic_type, array_type, etc.) + * @param typeRefHashMap Map of type argument positions to TypeReference hashes + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromFieldType( + typeNode: Parser.SyntaxNode, + typeRefHashMap: Map, + typeRegistryHash: string + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + this.extractTypeUseAnnotationsRecursive( + typeNode, + typeRefHashMap, + typeRegistryHash, + annotations, + 0, // depth + 0 // position + ); + return annotations; + } + + /** + * Recursively extracts TYPE_USE annotations from type nodes. + * Walks through generic type arguments and nested structures. + * + * Position keys match TypeReference format: "${depth}.${position}" + * Example for List>: + * - List: depth=0, position=0, key="0.0" + * - Map: depth=1, position=0, key="1.0" + * - String: depth=2, position=0, key="2.0" + * - Integer: depth=2, position=1, key="2.1" + */ + private extractTypeUseAnnotationsRecursive( + typeNode: Parser.SyntaxNode, + typeRefHashMap: Map, + typeRegistryHash: string, + annotations: TypeAnnotation[], + depth: number, + position: number + ): void { + // If this is an annotated_type, extract the annotation + if (typeNode.type === 'annotated_type') { + // Build position key matching TypeReference format: depth.position + const positionKey = `${depth}.${position}`; + const ownerHash = typeRefHashMap.get(positionKey) || ''; + + if (ownerHash) { + const typeAnnotations = this.extractAnnotationsFromAnnotatedType( + typeNode, + AnnotationContext.TYPE_USE, + ownerHash, + typeRegistryHash, + annotations.length + ); + annotations.push(...typeAnnotations); + } + } + + // For generic types, recurse into type_arguments + const actualType = typeNode.type === 'annotated_type' + ? this.unwrapAnnotatedTypeNode(typeNode) + : typeNode; + + if (actualType.type === 'generic_type') { + const typeArgsNode = actualType.children.find(c => c.type === 'type_arguments'); + if (typeArgsNode) { + let argPosition = 0; + for (const child of typeArgsNode.children) { + if (this.isTypeArgumentNode(child)) { + this.extractTypeUseAnnotationsRecursive( + child, + typeRefHashMap, + typeRegistryHash, + annotations, + depth + 1, + argPosition + ); + argPosition++; + } + } + } + } + + // For array types, recurse into element type + if (actualType.type === 'array_type') { + const elementType = actualType.childForFieldName('element'); + if (elementType) { + this.extractTypeUseAnnotationsRecursive( + elementType, + typeRefHashMap, + typeRegistryHash, + annotations, + depth, + position + ); + } + } + + // For wildcard types, recurse into bound + if (actualType.type === 'wildcard') { + let boundPosition = 0; + for (const child of actualType.children) { + if (this.isTypeArgumentNode(child)) { + this.extractTypeUseAnnotationsRecursive( + child, + typeRefHashMap, + typeRegistryHash, + annotations, + depth + 1, + boundPosition + ); + boundPosition++; + } + } + } + } + + /** + * Unwraps an annotated_type node to get the underlying type. + */ + private unwrapAnnotatedTypeNode(node: Parser.SyntaxNode): Parser.SyntaxNode { + if (node.type === 'annotated_type') { + for (const child of node.children) { + if (this.isTypeArgumentNode(child) && child.type !== 'annotated_type') { + return child; + } + if (child.type === 'annotated_type') { + return this.unwrapAnnotatedTypeNode(child); + } + } + } + return node; + } + + /** + * Checks if a node is a type argument node. + */ + private isTypeArgumentNode(node: Parser.SyntaxNode): boolean { + return [ + 'type_identifier', + 'generic_type', + 'scoped_type_identifier', + 'array_type', + 'annotated_type', + 'wildcard', + 'integral_type', + 'floating_point_type', + 'boolean_type', + ].includes(node.type); + } + + /** + * Extract annotations from a constructor declaration. + * + * @param constructorNode The constructor declaration syntax node + * @param constructorHash Hash of the constructor + * @param typeRegistryHash Hash of the enclosing type + */ + extractFromConstructorDeclaration( + constructorNode: Parser.SyntaxNode, + constructorHash: string, + typeRegistryHash: string + ): TypeAnnotation[] { + return this.extractAnnotationsFromNode( + constructorNode, + AnnotationContext.CONSTRUCTOR_DECLARATION, + constructorHash, + typeRegistryHash, + false + ); + } + + /** + * Core extraction method that processes annotation nodes from a parent node. + * + * @param parentNode The parent syntax node that may contain annotations + * @param context The context where annotations appear + * @param ownerHash Hash of the entity being annotated + * @param typeRegistryHash Hash of the enclosing type + * @param checkMetaAnnotations Whether to mark annotations as meta-annotations + */ + private extractAnnotationsFromNode( + parentNode: Parser.SyntaxNode, + context: AnnotationContext, + ownerHash: string, + typeRegistryHash: string, + checkMetaAnnotations: boolean + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + let position = 0; + + for (const child of parentNode.children) { + if (child.type === 'marker_annotation') { + const annotation = this.extractMarkerAnnotation( + child, + context, + ownerHash, + typeRegistryHash, + 0, + position, + undefined, + checkMetaAnnotations + ); + if (annotation) { + annotations.push(annotation); + position++; + } + } else if (child.type === 'annotation') { + const extracted = this.extractParameterizedAnnotation( + child, + context, + ownerHash, + typeRegistryHash, + 0, + position, + undefined, + checkMetaAnnotations + ); + annotations.push(...extracted); + position++; + } else if (child.type === 'modifiers') { + const modifierAnnotations = this.extractAnnotationsFromNode( + child, + context, + ownerHash, + typeRegistryHash, + checkMetaAnnotations + ); + annotations.push(...modifierAnnotations); + } + } + + return annotations; + } + + /** + * Extract a marker annotation (no arguments). + * + * Example: @Deprecated, @Nullable + */ + private extractMarkerAnnotation( + annotationNode: Parser.SyntaxNode, + context: AnnotationContext, + ownerHash: string, + typeRegistryHash: string, + depth: number, + position: number, + parentAnnotationHash: string | undefined, + checkMetaAnnotations: boolean, + typeParameterHash?: string + ): TypeAnnotation | null { + const nameNode = this.findAnnotationName(annotationNode); + if (!nameNode) return null; + + const name = this.extractAnnotationName(nameNode); + if (!name) return null; + + const isMeta = checkMetaAnnotations && this.isMetaAnnotation(name); + + const builder = TypeAnnotation.builder(name, AnnotationKind.MARKER, context, ownerHash) + .typeRegistry(typeRegistryHash) + .setDepth(depth) + .setPosition(position) + .metaAnnotation(isMeta); + + if (parentAnnotationHash) { + builder.parentAnnotation(parentAnnotationHash); + } + + if (typeParameterHash) { + builder.typeParameter(typeParameterHash); + } + + const startLine = annotationNode.startPosition.row + 1; + const endLine = annotationNode.endPosition.row + 1; + builder.location(startLine, endLine); + + const annotation = builder.build(); + + // Create TypeReference for the annotation type itself + this.createAnnotationTypeReference(annotationNode, annotation.getHash(), typeRegistryHash, nameNode); + + return annotation; + } + + /** + * Extract a parameterized annotation (with arguments). + * + * Examples: + * - @Timeout(1000) + * - @Column(name = "id", nullable = false) + * - @Target({ ElementType.TYPE }) + * - @Something(meta = @Other(x = 1)) + */ + private extractParameterizedAnnotation( + annotationNode: Parser.SyntaxNode, + context: AnnotationContext, + ownerHash: string, + typeRegistryHash: string, + depth: number, + position: number, + parentAnnotationHash: string | undefined, + checkMetaAnnotations: boolean, + typeParameterHash?: string + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + + const nameNode = this.findAnnotationName(annotationNode); + if (!nameNode) return annotations; + + const name = this.extractAnnotationName(nameNode); + if (!name) return annotations; + + const argsNode = this.findAnnotationArgumentReferences(annotationNode); + const argumentsRaw = argsNode ? this.extractArgumentsRaw(argsNode) : undefined; + + const kind = this.determineAnnotationKind(argumentsRaw); + const isMeta = checkMetaAnnotations && this.isMetaAnnotation(name); + + const builder = TypeAnnotation.builder(name, kind, context, ownerHash) + .typeRegistry(typeRegistryHash) + .setDepth(depth) + .setPosition(position) + .metaAnnotation(isMeta); + + if (parentAnnotationHash) { + builder.parentAnnotation(parentAnnotationHash); + } + + if (typeParameterHash) { + builder.typeParameter(typeParameterHash); + } + + const startLine = annotationNode.startPosition.row + 1; + const endLine = annotationNode.endPosition.row + 1; + builder.location(startLine, endLine); + + const mainAnnotation = builder.build(); + annotations.push(mainAnnotation); + + // Create TypeReference for the annotation type itself + this.createAnnotationTypeReference(annotationNode, mainAnnotation.getHash(), typeRegistryHash, nameNode); + + if (argsNode) { + // First, extract nested annotations and build a map from their nodes to hashes + const nestedAnnotationMap = new Map(); + const nestedAnnotations = this.extractNestedAnnotations( + argsNode, + context, + ownerHash, + typeRegistryHash, + depth + 1, + mainAnnotation.getHash(), + false, + nestedAnnotationMap + ); + annotations.push(...nestedAnnotations); + + // Extract individual arguments, using the map to link NESTED_ANNOTATION arguments + const extractedArgs = this.extractArguments(argsNode, mainAnnotation.getHash(), nestedAnnotationMap, context, typeRegistryHash); + this.extractedArguments.push(...extractedArgs); + } + + return annotations; + } + + /** + * Extract nested annotations from annotation arguments. + * + * Example: @Something(meta = @Other(x = 1)) + * The @Other annotation is nested inside @Something + */ + private extractNestedAnnotations( + argsNode: Parser.SyntaxNode, + context: AnnotationContext, + ownerHash: string, + typeRegistryHash: string, + depth: number, + parentAnnotationHash: string, + checkMetaAnnotations: boolean, + nodeToHashMap?: Map + ): TypeAnnotation[] { + const annotations: TypeAnnotation[] = []; + let position = 0; + + for (const child of argsNode.children) { + if (child.type === 'marker_annotation') { + const annotation = this.extractMarkerAnnotation( + child, + context, + ownerHash, + typeRegistryHash, + depth, + position, + parentAnnotationHash, + checkMetaAnnotations + ); + if (annotation && nodeToHashMap) { + nodeToHashMap.set(child, annotation.getHash()); + } + if (annotation) { + annotations.push(annotation); + position++; + } + } else if (child.type === 'annotation') { + const extracted = this.extractParameterizedAnnotation( + child, + context, + ownerHash, + typeRegistryHash, + depth, + position, + parentAnnotationHash, + checkMetaAnnotations + ); + // Map the first annotation (the main one) to its node + if (extracted.length > 0 && nodeToHashMap) { + const firstAnnotation = extracted[0]; + if (firstAnnotation) { + nodeToHashMap.set(child, firstAnnotation.getHash()); + } + } + annotations.push(...extracted); + position++; + } else if (child.children) { + const nested = this.extractNestedAnnotations( + child, + context, + ownerHash, + typeRegistryHash, + depth, + parentAnnotationHash, + checkMetaAnnotations, + nodeToHashMap + ); + annotations.push(...nested); + } + } + + return annotations; + } + + /** + * Find the annotation name node within an annotation node. + */ + private findAnnotationName(annotationNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + for (const child of annotationNode.children) { + if (child.type === 'identifier' || child.type === 'scoped_identifier') { + return child; + } + } + return null; + } + + /** + * Extract the annotation name from a name node. + * + * Handles both simple names (@Deprecated) and qualified names (@javax.annotation.Nullable). + */ + private extractAnnotationName(nameNode: Parser.SyntaxNode): string | null { + return nameNode.text || null; + } + + /** + * Find the annotation arguments node within an annotation node. + */ + private findAnnotationArgumentReferences(annotationNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + for (const child of annotationNode.children) { + if (child.type === 'annotation_argument_list') { + return child; + } + } + return null; + } + + /** + * Extract the raw arguments string from an annotation_argument_list node. + * + * This preserves the exact syntax including: + * - Nested parentheses and braces + * - String literals + * - Nested annotations + * - Arrays + */ + private extractArgumentsRaw(argsNode: Parser.SyntaxNode): string { + return argsNode.text.trim(); + } + + /** + * Determine the annotation kind based on its arguments. + * + * - No arguments → MARKER + * - Contains nested @annotations → NESTED + * - Array syntax with {} → ARRAY_VALUE + * - Named arguments (key = value) → NAMED_ARGUMENTS + * - Single unnamed value → SINGLE_VALUE + */ + private determineAnnotationKind(argumentsRaw: string | undefined): AnnotationKind { + if (!argumentsRaw) { + return AnnotationKind.MARKER; + } + + const trimmed = argumentsRaw.replace(/^\(|\)$/g, '').trim(); + + // Check for nested annotations + if (trimmed.includes('@')) { + return AnnotationKind.NESTED; + } + + // Check for array syntax + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + return AnnotationKind.ARRAY_VALUE; + } + + // Check for named arguments + if (trimmed.includes('=')) { + return AnnotationKind.NAMED_ARGUMENTS; + } + + return AnnotationKind.SINGLE_VALUE; + } + + /** + * Check if an annotation is a meta-annotation (used on annotation declarations). + * + * Common meta-annotations: + * - @Retention + * - @Target + * - @Documented + * - @Inherited + * - @Repeatable + */ + private isMetaAnnotation(name: string): boolean { + const metaAnnotations = new Set([ + 'Retention', + 'Target', + 'Documented', + 'Inherited', + 'Repeatable', + 'Native', + 'java.lang.annotation.Retention', + 'java.lang.annotation.Target', + 'java.lang.annotation.Documented', + 'java.lang.annotation.Inherited', + 'java.lang.annotation.Repeatable', + ]); + + return metaAnnotations.has(name); + } + + /** + * Extract individual arguments from an annotation_argument_list node. + */ + private extractArguments( + argsNode: Parser.SyntaxNode, + parentAnnotationHash: string, + nestedAnnotationMap?: Map, + annotationContext?: AnnotationContext, + typeRegistryHash?: string + ): AnnotationArgumentReference[] { + const args: AnnotationArgumentReference[] = []; + let position = 0; + + for (const child of argsNode.children) { + if (child.type === 'element_value_pair') { + // Named argument: name = value + // Check if this pair contains an array value + const arrayNode = this.findArrayNode(child); + if (arrayNode) { + // Extract argument name from the pair + const argName = this.extractArgumentNameFromPair(child); + const arrayElements = this.extractArrayElements( + argName || 'value', + arrayNode, + position, + parentAnnotationHash, + nestedAnnotationMap, + annotationContext, + typeRegistryHash + ); + args.push(...arrayElements); + } else { + const arg = this.extractElementValuePair(child, position, parentAnnotationHash, nestedAnnotationMap, annotationContext, typeRegistryHash); + if (arg) { + args.push(arg); + } + } + position++; + } else if (this.isValueNode(child)) { + // Single unnamed value (shorthand) + if (child.type === 'element_value_array_initializer') { + // It's an array - expand elements + const arrayElements = this.extractArrayElements( + 'value', + child, + position, + parentAnnotationHash, + nestedAnnotationMap, + annotationContext, + typeRegistryHash + ); + args.push(...arrayElements); + } else { + const arg = this.extractSingleValue(child, position, parentAnnotationHash, annotationContext, typeRegistryHash); + if (arg) { + args.push(arg); + } + } + position++; + } + } + + return args; + } + + /** + * Extract a named argument pair (name = value). + */ + private extractElementValuePair( + pairNode: Parser.SyntaxNode, + position: number, + parentAnnotationHash: string, + nestedAnnotationMap?: Map, + annotationContext?: AnnotationContext, + typeRegistryHash?: string + ): AnnotationArgumentReference | null { + let argumentName = ''; + let valueNode: Parser.SyntaxNode | null = null; + + for (const child of pairNode.children) { + if (child.type === 'identifier') { + argumentName = child.text; + } else if (this.isValueNode(child)) { + valueNode = child; + } else if (child.children && child.children.length > 0) { + // Value might be wrapped in another node, search children + for (const grandchild of child.children) { + if (this.isValueNode(grandchild)) { + valueNode = grandchild; + break; + } + } + } + } + + if (!argumentName || !valueNode) return null; + + // Check if this value node is a nested annotation and get its hash + const nestedAnnotationHash = nestedAnnotationMap?.get(valueNode); + + return this.createAnnotationArgumentReference( + argumentName, + valueNode, + position, + parentAnnotationHash, + nestedAnnotationHash, + annotationContext, + typeRegistryHash + ); + } + + /** + * Extract a single unnamed value (shorthand syntax) + */ + private extractSingleValue( + valueNode: Parser.SyntaxNode, + position: number, + parentAnnotationHash: string, + annotationContext?: AnnotationContext, + typeRegistryHash?: string + ): AnnotationArgumentReference | null { + return this.createAnnotationArgumentReference( + 'value', + valueNode, + position, + parentAnnotationHash, + undefined, + annotationContext, + typeRegistryHash + ); + } + + /** + * Create an AnnotationArgumentReference from a value node + */ + private createAnnotationArgumentReference( + argumentName: string, + valueNode: Parser.SyntaxNode, + position: number, + parentAnnotationHash: string, + nestedAnnotationHash?: string, + annotationContext?: AnnotationContext, + typeRegistryHash?: string + ): AnnotationArgumentReference | null { + let argumentValue = valueNode.text; + const valueType = this.determineValueType(valueNode); + + // Strip .class suffix for CLASS_REFERENCE arguments + if (valueType === ArgumentValueType.CLASS_REFERENCE && argumentValue.endsWith('.class')) { + argumentValue = argumentValue.slice(0, -6); // Remove last 6 characters (".class") + } + + // Extract just annotation name for NESTED_ANNOTATION arguments + if (valueType === ArgumentValueType.NESTED_ANNOTATION) { + const nameNode = this.findAnnotationName(valueNode); + if (nameNode) { + argumentValue = this.extractAnnotationName(nameNode) || argumentValue; + } + } + + const builder = AnnotationArgumentReference.builder( + argumentName, + argumentValue, + valueType, + position, + parentAnnotationHash + ); + + const startLine = valueNode.startPosition.row + 1; + const endLine = valueNode.endPosition.row + 1; + builder.location(startLine, endLine); + + // Link to nested annotation if this is a NESTED_ANNOTATION type + if (nestedAnnotationHash && valueType === ArgumentValueType.NESTED_ANNOTATION) { + builder.nestedAnnotation(nestedAnnotationHash); + } + + const argumentRef = builder.build(); + + // Extract type references for type-related argument values + this.extractTypeReferencesFromArgument(argumentRef, valueNode, annotationContext, typeRegistryHash); + + return argumentRef; + } + + /** + * Determine the type of an annotation argument value + */ + private determineValueType(valueNode: Parser.SyntaxNode): ArgumentValueType { + const nodeType = valueNode.type; + const text = valueNode.text; + + // Check for class reference (.class literal) + if (nodeType === 'class_literal') { + return ArgumentValueType.CLASS_REFERENCE; + } + + // Check for character literal + if (nodeType === 'character_literal') { + return ArgumentValueType.CHAR_LITERAL; + } + + // Check for string literal + if (nodeType === 'string_literal') { + return ArgumentValueType.STRING_LITERAL; + } + + // Check for boolean + if (nodeType === 'true' || nodeType === 'false' || text === 'true' || text === 'false') { + return ArgumentValueType.BOOLEAN_LITERAL; + } + + // Check for null + if (nodeType === 'null_literal' || text === 'null') { + return ArgumentValueType.NULL; + } + + // Check for annotation + if (nodeType === 'annotation' || nodeType === 'marker_annotation') { + return ArgumentValueType.NESTED_ANNOTATION; + } + + // Check for constant expressions (binary and unary operators) + if (nodeType === 'binary_expression') { + // Arithmetic, bitwise, string concatenation: 60 * 1000, 1 << 3, "a" + "b" + return ArgumentValueType.CONSTANT_EXPRESSION; + } + + if (nodeType === 'unary_expression') { + // Check if it's a simple negation of a number literal (treat as NUMBER_LITERAL) + // or other unary ops like ~, ! (treat as CONSTANT_EXPRESSION) + const operator = this.getUnaryOperator(valueNode); + if (operator === '-' || operator === '+') { + // Check if operand is a simple number literal + const operand = this.getUnaryOperand(valueNode); + if (operand && this.isNumberLiteralType(operand.type)) { + return ArgumentValueType.NUMBER_LITERAL; + } + } + // For ~, !, or complex operands, classify as expression + return ArgumentValueType.CONSTANT_EXPRESSION; + } + + // Check for number literals (must come after unary check) + if (this.isNumberLiteralType(nodeType)) { + return ArgumentValueType.NUMBER_LITERAL; + } + + // Note: Arrays are handled separately via extractArrayElements() + // If we reach here with an array node, it's an error in the caller logic + + // Check for enum constant or static final field reference + // Cannot distinguish between enum and constant without type resolution + // Both use field_access pattern: Type.CONSTANT or just CONSTANT (if imported) + if (nodeType === 'identifier' || nodeType === 'scoped_identifier' || nodeType === 'field_access') { + return ArgumentValueType.ENUM_CONSTANT; + } + + return ArgumentValueType.UNKNOWN; + } + + /** + * Check if node type is a number literal + */ + private isNumberLiteralType(nodeType: string): boolean { + return nodeType === 'decimal_integer_literal' || + nodeType === 'hex_integer_literal' || + nodeType === 'octal_integer_literal' || + nodeType === 'binary_integer_literal' || + nodeType === 'decimal_floating_point_literal' || + nodeType === 'hex_floating_point_literal'; + } + + /** + * Get the operator from a unary_expression node + */ + private getUnaryOperator(unaryNode: Parser.SyntaxNode): string | null { + // The first child is usually the operator + if (unaryNode.children.length > 0) { + const firstChild = unaryNode.children[0]; + if (firstChild && !firstChild.isNamed) { + return firstChild.text; + } + } + return null; + } + + /** + * Get the operand from a unary_expression node + */ + private getUnaryOperand(unaryNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + // The operand is usually the last named child + const namedChildren = unaryNode.namedChildren; + if (namedChildren.length > 0) { + return namedChildren[0] ?? null; + } + return null; + } + + /** + * Find an array node within a container node + */ + private findArrayNode(containerNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + if (containerNode.type === 'element_value_array_initializer') { + return containerNode; + } + + for (const child of containerNode.children) { + if (child.type === 'element_value_array_initializer') { + return child; + } + // Recursively search in children + const found = this.findArrayNode(child); + if (found) return found; + } + + return null; + } + + /** + * Extract argument name from an element_value_pair node + */ + private extractArgumentNameFromPair(pairNode: Parser.SyntaxNode): string | null { + for (const child of pairNode.children) { + if (child.type === 'identifier') { + return child.text; + } + } + return null; + } + + /** + * Extract individual elements from an array argument + */ + private extractArrayElements( + argumentName: string, + containerNode: Parser.SyntaxNode, + position: number, + parentAnnotationHash: string, + nestedAnnotationMap?: Map, + annotationContext?: AnnotationContext, + typeRegistryHash?: string + ): AnnotationArgumentReference[] { + const elements: AnnotationArgumentReference[] = []; + + // Find the element_value_array_initializer node + let arrayNode: Parser.SyntaxNode | null = null; + + if (containerNode.type === 'element_value_array_initializer') { + arrayNode = containerNode; + } else { + // Search in children + for (const child of containerNode.children) { + if (child.type === 'element_value_array_initializer') { + arrayNode = child; + break; + } + } + } + + if (!arrayNode) return elements; + + let arrayIndex = 0; + let hasElements = false; + + for (const child of arrayNode.children) { + if (this.isValueNode(child)) { + hasElements = true; + let elementValue = child.text; + const valueType = this.determineValueType(child); + + // Strip .class suffix for CLASS_REFERENCE + if (valueType === ArgumentValueType.CLASS_REFERENCE && elementValue.endsWith('.class')) { + elementValue = elementValue.slice(0, -6); + } + + // Extract annotation name for NESTED_ANNOTATION + if (valueType === ArgumentValueType.NESTED_ANNOTATION) { + const nameNode = this.findAnnotationName(child); + if (nameNode) { + elementValue = this.extractAnnotationName(nameNode) || elementValue; + } + } + + // Check if this array element is a nested annotation and get its hash + const nestedAnnotationHash = nestedAnnotationMap?.get(child); + + const builder = AnnotationArgumentReference.builder( + argumentName, + elementValue, + valueType, + position, + parentAnnotationHash + ); + + builder.arrayPosition(arrayIndex); + + const startLine = child.startPosition.row + 1; + const endLine = child.endPosition.row + 1; + builder.location(startLine, endLine); + + // Link to nested annotation if this is a NESTED_ANNOTATION type + if (nestedAnnotationHash && valueType === ArgumentValueType.NESTED_ANNOTATION) { + builder.nestedAnnotation(nestedAnnotationHash); + } + + const argumentRef = builder.build(); + elements.push(argumentRef); + + // Extract type references from array element values + this.extractTypeReferencesFromArgument(argumentRef, child, annotationContext, typeRegistryHash); + arrayIndex++; + } + } + + // Handle empty arrays: create a marker entry to distinguish from omitted arguments + // Empty array {} is semantically different from omitted argument (which uses default) + if (!hasElements && arrayNode.text === '{}') { + const builder = AnnotationArgumentReference.builder( + argumentName, + '[]', // Marker value to represent explicitly empty array + ArgumentValueType.NULL, // Use NULL type as marker for empty array + position, + parentAnnotationHash + ); + + builder.arrayPosition(0); + + const startLine = arrayNode.startPosition.row + 1; + const endLine = arrayNode.endPosition.row + 1; + builder.location(startLine, endLine); + + elements.push(builder.build()); + } + + return elements; + } + + /** + * Check if a node is a value node (can be used as annotation argument value) + */ + private isValueNode(node: Parser.SyntaxNode): boolean { + const valueNodeTypes = new Set([ + // Literals + 'character_literal', + 'string_literal', + 'decimal_integer_literal', + 'hex_integer_literal', + 'octal_integer_literal', + 'binary_integer_literal', + 'decimal_floating_point_literal', + 'hex_floating_point_literal', + 'true', + 'false', + 'null_literal', + // Expressions + 'binary_expression', + 'unary_expression', + // References + 'identifier', + 'scoped_identifier', + 'field_access', + 'class_literal', + // Nested annotations + 'annotation', + 'marker_annotation', + // Arrays + 'element_value_array_initializer', + ]); + + return valueNodeTypes.has(node.type); + } + + /** + * Create a TypeReference for the annotation type itself. + * E.g., @RequestMapping → TypeReference for "RequestMapping" with ANNOTATION_TYPE context. + */ + private createAnnotationTypeReference( + annotationNode: Parser.SyntaxNode, + annotationHash: string, + typeRegistryHash: string, + nameNode: Parser.SyntaxNode + ): void { + const fullName = nameNode.text; + if (!fullName) return; + + // Extract simple name (last segment for qualified names like javax.annotation.Nullable) + let simpleName = fullName; + const completeTypeName = fullName; + + if (nameNode.type === 'scoped_identifier') { + const lastDot = fullName.lastIndexOf('.'); + if (lastDot >= 0) { + simpleName = fullName.substring(lastDot + 1); + } + } + + const typeRef = TypeReference.builder( + typeRegistryHash, + TypeRefKind.CLASS, + TypeRefContext.ANNOTATION_TYPE, + ReferenceOwnerKind.ANNOTATION, + annotationHash + ) + .setTypeName(simpleName) + .setCompleteTypeName(completeTypeName) + .positionAndDepth(0, 0) + .location(annotationNode.startPosition.row + 1, annotationNode.endPosition.row + 1) + .build(); + + this.extractedTypeReferences.push(typeRef); + } + + /** + * Extract type references from annotation argument values + */ + private extractTypeReferencesFromArgument( + argumentRef: AnnotationArgumentReference, + valueNode: Parser.SyntaxNode, + annotationContext?: AnnotationContext, + typeRegistryHash?: string + ): void { + const valueType = argumentRef.getValueType(); + const argumentHash = argumentRef.getHash(); + const typeRefContext = this.mapAnnotationContextToTypeRefContext(annotationContext); + + // Handle CLASS_REFERENCE (e.g., String.class, String[].class) + if (valueType === ArgumentValueType.CLASS_REFERENCE) { + this.extractClassReferenceType(valueNode, argumentHash, typeRegistryHash || argumentRef.getParentAnnotationHash(), typeRefContext, annotationContext); + } + // Handle ENUM_CONSTANT (e.g., HttpMethod.POST, ElementType.TYPE) + else if (valueType === ArgumentValueType.ENUM_CONSTANT) { + this.extractEnumConstantType(valueNode, argumentHash, typeRegistryHash || argumentRef.getParentAnnotationHash(), typeRefContext, annotationContext); + } + // Handle CONSTANT_EXPRESSION (e.g., AnnotationTestConstants.MAX_RETRIES * 1000) + else if (valueType === ArgumentValueType.CONSTANT_EXPRESSION) { + this.extractConstantExpressionTypes(valueNode, argumentHash, typeRegistryHash || argumentRef.getParentAnnotationHash(), typeRefContext, annotationContext); + } + } + + /** + * Map annotation context to appropriate type reference context + */ + private mapAnnotationContextToTypeRefContext(annotationContext?: AnnotationContext): TypeRefContext { + if (annotationContext === AnnotationContext.TYPE_PARAMETER) { + return TypeRefContext.TYPE_PARAMETER_ANNOTATION; + } + // Default for all other annotation contexts (TYPE_DECLARATION, FIELD_DECLARATION, etc.) + return TypeRefContext.ANNOTATION_PARAM; + } + + /** + * Map annotation context to appropriate reference owner kind + */ + private mapAnnotationContextToOwnerKind(annotationContext?: AnnotationContext): ReferenceOwnerKind { + if (annotationContext === AnnotationContext.TYPE_PARAMETER) { + return ReferenceOwnerKind.TYPE_PARAMETER; + } + // Default for all other annotation contexts - owned by the annotation argument + return ReferenceOwnerKind.ANNOTATION_ARGUMENT; + } + + /** + * Extract type reference from class literal (e.g., String.class) + */ + private extractClassReferenceType( + valueNode: Parser.SyntaxNode, + argumentHash: string, + typeRegistryHash: string, + context: TypeRefContext = TypeRefContext.ANNOTATION_PARAM, + annotationContext?: AnnotationContext + ): void { + // class_literal node structure: type_identifier or qualified_type + ".class" + const typeNode = valueNode.namedChildren[0]; + if (!typeNode) return; + + const typeName = this.extractTypeNameFromNode(typeNode); + if (!typeName) return; + + const ownerKind = this.mapAnnotationContextToOwnerKind(annotationContext); + const typeRef = TypeReference.builder( + typeRegistryHash, + TypeRefKind.CLASS, + context, + ownerKind, + argumentHash + ) + .setTypeName(typeName) + .setCompleteTypeName(typeName) + .positionAndDepth(0, 0) + .location(valueNode.startPosition.row + 1, valueNode.endPosition.row + 1) + .build(); + + this.extractedTypeReferences.push(typeRef); + } + + /** + * Extract type reference from enum constant (e.g., HttpMethod.POST) + */ + private extractEnumConstantType( + valueNode: Parser.SyntaxNode, + argumentHash: string, + typeRegistryHash: string, + context: TypeRefContext = TypeRefContext.ANNOTATION_PARAM, + annotationContext?: AnnotationContext + ): void { + let enumTypeName: string | null = null; + + // Extract enum type name from qualified reference + if (valueNode.type === 'field_access') { + // field_access: object + field + const objectNode = valueNode.childForFieldName('object'); + if (objectNode) { + enumTypeName = objectNode.text; + } + } else if (valueNode.type === 'scoped_identifier') { + // scoped_identifier: scope + name + const scopeNode = valueNode.childForFieldName('scope'); + if (scopeNode) { + enumTypeName = scopeNode.text; + } + } + + if (!enumTypeName) return; + + // Extract simple name (last segment) for typeName; keep full scoped name for completeTypeName + const completeEnumTypeName = enumTypeName; + const lastDot = enumTypeName.lastIndexOf('.'); + const simpleEnumTypeName = lastDot >= 0 ? enumTypeName.substring(lastDot + 1) : enumTypeName; + + const ownerKind = this.mapAnnotationContextToOwnerKind(annotationContext); + const typeRef = TypeReference.builder( + typeRegistryHash, + TypeRefKind.CLASS, // Enums are classes in Java + context, + ownerKind, + argumentHash + ) + .setTypeName(simpleEnumTypeName) + .setCompleteTypeName(completeEnumTypeName) + .positionAndDepth(0, 0) + .location(valueNode.startPosition.row + 1, valueNode.endPosition.row + 1) + .build(); + + this.extractedTypeReferences.push(typeRef); + } + + /** + * Extract type references from constant expression + */ + private extractConstantExpressionTypes( + valueNode: Parser.SyntaxNode, + argumentHash: string, + typeRegistryHash: string, + context: TypeRefContext = TypeRefContext.ANNOTATION_PARAM, + annotationContext?: AnnotationContext + ): void { + // Recursively find all identifiers and field_access nodes + this.extractTypesFromExpression(valueNode, argumentHash, typeRegistryHash, context, annotationContext); + } + + /** + * Recursively extract type references from expression nodes + */ + private extractTypesFromExpression( + node: Parser.SyntaxNode, + argumentHash: string, + typeRegistryHash: string, + context: TypeRefContext = TypeRefContext.ANNOTATION_PARAM, + annotationContext?: AnnotationContext + ): void { + // Handle field_access (e.g., AnnotationTestConstants.MAX_RETRIES) + if (node.type === 'field_access') { + const objectNode = node.childForFieldName('object'); + if (objectNode) { + const completeTypeName = objectNode.text; + const lastDot = completeTypeName.lastIndexOf('.'); + const typeName = lastDot >= 0 ? completeTypeName.substring(lastDot + 1) : completeTypeName; + const ownerKind = this.mapAnnotationContextToOwnerKind(annotationContext); + const typeRef = TypeReference.builder( + typeRegistryHash, + TypeRefKind.CLASS, + context, + ownerKind, + argumentHash + ) + .setTypeName(typeName) + .setCompleteTypeName(completeTypeName) + .positionAndDepth(0, 0) + .location(node.startPosition.row + 1, node.endPosition.row + 1) + .build(); + + this.extractedTypeReferences.push(typeRef); + } + } + + // Recurse into children for binary/unary expressions + for (const child of node.namedChildren) { + this.extractTypesFromExpression(child, argumentHash, typeRegistryHash, context); + } + } + + /** + * Extract type name from various type nodes + */ + private extractTypeNameFromNode(node: Parser.SyntaxNode): string | null { + if (node.type === 'type_identifier') { + return node.text; + } else if (node.type === 'scoped_type_identifier') { + return node.text; + } else if (node.type === 'generic_type') { + const typeNode = node.childForFieldName('type'); + return typeNode ? typeNode.text : null; + } else if (node.type === 'array_type') { + const elementNode = node.childForFieldName('element'); + return elementNode ? elementNode.text : null; + } + return node.text; + } +} diff --git a/parser/src/parsers/java/extractors/comment-extractor.ts b/parser/src/parsers/java/extractors/comment-extractor.ts new file mode 100644 index 000000000..0e777f807 --- /dev/null +++ b/parser/src/parsers/java/extractors/comment-extractor.ts @@ -0,0 +1,253 @@ +import Parser from 'tree-sitter'; + +import { CommentRegistry } from '@/analysis-types/java/CommentRegistry'; +import { CommentKind } from '@/enums/java/comments'; + +/** + * Extracts comments from Java source code and associates them with + * the nearest code entity (type, method, field, variable, expression, annotation). + * + * ## Association Strategy + * + * Uses tree-sitter AST sibling relationships: + * 1. **Next sibling**: Comment directly precedes an entity → linked to that entity + * 2. **Previous sibling (same line)**: End-of-line comment → linked to entity on same line + * 3. **Parent fallback**: Orphan comments → linked to containing entity + * + * ## Comment Grouping + * + * Multiple consecutive comments before the same entity are grouped with + * ascending `commentIndex` values (0, 1, 2, ...). + */ +export class CommentExtractor { + /** + * Extracts all comments from a parsed Java file and links them to entities. + * + * @param rootNode The root AST node of the parsed file + * @param filePath Path of the source file + * @param positionToHash Map of "startLine:startColumn" → entity hash + * for all extracted entities (types, methods, fields, etc.) + * @returns Array of CommentRegistry entries + */ + extract( + rootNode: Parser.SyntaxNode, + filePath: string, + positionToHash: Map + ): CommentRegistry[] { + // Collect all comment nodes from the AST + const commentNodes = this.collectCommentNodes(rootNode); + + if (commentNodes.length === 0) { + return []; + } + + // Build a sorted list of entity positions for closest-entity lookup + const entityPositions = this.buildSortedEntityPositions(positionToHash); + + // Track comment groups: consecutive comments before the same entity + const results: CommentRegistry[] = []; + let lastOwnerHash: string | null = null; + let commentIndex = 0; + + for (const commentNode of commentNodes) { + const kind = this.classifyComment(commentNode); + const text = commentNode.text; + const startLine = commentNode.startPosition.row + 1; + const startColumn = commentNode.startPosition.column; + const endLine = commentNode.endPosition.row + 1; + const endColumn = commentNode.endPosition.column; + + // Determine the owner entity hash + const ownerHash = this.findOwnerHash( + commentNode, + positionToHash, + entityPositions + ); + + if (!ownerHash) { + // No entity found to link this comment to — skip + continue; + } + + // Track comment grouping index + if (ownerHash === lastOwnerHash) { + commentIndex++; + } else { + commentIndex = 0; + lastOwnerHash = ownerHash; + } + + const comment = CommentRegistry.builder( + kind, + text, + filePath, + startLine, + startColumn, + endLine, + endColumn, + ownerHash, + commentIndex + ).build(); + + results.push(comment); + } + + return results; + } + + /** + * Recursively collects all comment nodes from the AST, in source order. + */ + private collectCommentNodes(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const comments: Parser.SyntaxNode[] = []; + const visit = (n: Parser.SyntaxNode) => { + if (n.type === 'line_comment' || n.type === 'block_comment') { + comments.push(n); + return; // Comments don't have meaningful children + } + for (const child of n.children) { + visit(child); + } + }; + visit(node); + return comments; + } + + /** + * Classifies a comment node into LINE_COMMENT, BLOCK_COMMENT, or JAVADOC. + */ + private classifyComment(node: Parser.SyntaxNode): CommentKind { + if (node.type === 'line_comment') { + return CommentKind.LINE_COMMENT; + } + // block_comment: check if it starts with /** (Javadoc) + if (node.text.startsWith('/**')) { + return CommentKind.JAVADOC; + } + return CommentKind.BLOCK_COMMENT; + } + + /** + * Finds the entity hash that should own this comment. + * + * Strategy: + * 1. Look at nextNamedSibling — if it maps to an entity, use that + * 2. If comment is on the same line as previousNamedSibling end, use that (end-of-line) + * 3. Fall back to the closest entity starting after this comment's end + * 4. Fall back to parent entity + */ + private findOwnerHash( + commentNode: Parser.SyntaxNode, + positionToHash: Map, + entityPositions: { line: number; col: number; hash: string }[] + ): string | null { + // Strategy 1: Next named sibling + const nextSibling = commentNode.nextNamedSibling; + if (nextSibling) { + const hash = this.lookupHash(nextSibling, positionToHash); + if (hash) return hash; + } + + // Strategy 2: End-of-line comment — previous sibling on same line + const prevSibling = commentNode.previousNamedSibling; + if (prevSibling) { + const commentStartLine = commentNode.startPosition.row; + const prevEndLine = prevSibling.endPosition.row; + if (commentStartLine === prevEndLine) { + const hash = this.lookupHash(prevSibling, positionToHash); + if (hash) return hash; + } + } + + // Strategy 3: Find closest entity starting after this comment + const commentEndLine = commentNode.endPosition.row + 1; + const commentEndCol = commentNode.endPosition.column; + const closest = this.findClosestEntityAfter( + entityPositions, + commentEndLine, + commentEndCol + ); + if (closest) return closest; + + // Strategy 4: Walk up parents until we find a mapped entity + let parent = commentNode.parent; + while (parent) { + const hash = this.lookupHash(parent, positionToHash); + if (hash) return hash; + parent = parent.parent; + } + + return null; + } + + /** + * Looks up an AST node's position in the entity hash map. + * Tries the node itself and, for declarations with modifiers/annotations, + * tries the declaration keyword child (e.g., class name identifier). + */ + private lookupHash( + node: Parser.SyntaxNode, + positionToHash: Map + ): string | null { + // Direct position lookup + const key = `${node.startPosition.row + 1}:${node.startPosition.column}`; + const hash = positionToHash.get(key); + if (hash) return hash; + + // For annotated declarations, the entity start position may be the + // declaration node itself (which includes annotations/modifiers), + // so also try child named nodes + for (const child of node.namedChildren) { + if (child.type === 'line_comment' || child.type === 'block_comment') continue; + const childKey = `${child.startPosition.row + 1}:${child.startPosition.column}`; + const childHash = positionToHash.get(childKey); + if (childHash) return childHash; + } + + return null; + } + + /** + * Builds a sorted array of entity positions for binary search. + */ + private buildSortedEntityPositions( + positionToHash: Map + ): { line: number; col: number; hash: string }[] { + const positions: { line: number; col: number; hash: string }[] = []; + for (const [key, hash] of positionToHash) { + const [lineStr, colStr] = key.split(':'); + positions.push({ + line: parseInt(lineStr!, 10), + col: parseInt(colStr!, 10), + hash, + }); + } + positions.sort((a, b) => a.line - b.line || a.col - b.col); + return positions; + } + + /** + * Binary search for the closest entity starting at or after the given position. + */ + private findClosestEntityAfter( + positions: { line: number; col: number; hash: string }[], + line: number, + col: number + ): string | null { + let lo = 0; + let hi = positions.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const pos = positions[mid]!; + if (pos.line < line || (pos.line === line && pos.col < col)) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo < positions.length) { + return positions[lo]!.hash; + } + return null; + } +} diff --git a/parser/src/parsers/java/extractors/enum-constant-extractor.ts b/parser/src/parsers/java/extractors/enum-constant-extractor.ts new file mode 100644 index 000000000..bb9199c3d --- /dev/null +++ b/parser/src/parsers/java/extractors/enum-constant-extractor.ts @@ -0,0 +1,327 @@ +import Parser from 'tree-sitter'; + +import { EnumConstant } from '@/analysis-types/java/EnumConstant'; +import { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { ExpressionReferenceExtractor, AnonymousClassInfo } from '@/parsers/java/extractors/expression-reference-extractor'; + +/** + * Extracts EnumConstant entities from Java enum bodies using tree-sitter + * + * Handles extraction of: + * - Enum constant names + * - Enum constant arguments (constructor parameters) + * - Enum constant annotations + * - Detection of anonymous class bodies on enum constants + * + * ## Tree-sitter Node Structure + * + * For an enum like: + * ```java + * public enum Status { + * @Deprecated + * ACTIVE("Active", 1), + * INACTIVE, + * PENDING("Pending", 2) { + * @Override + * public boolean isTransient() { return true; } + * }; + * } + * ``` + * + * The tree-sitter structure is: + * - enum_declaration + * - enum_body + * - enum_constant (ACTIVE) + * - modifiers (contains @Deprecated annotation) + * - identifier: "ACTIVE" + * - argument_list: ("Active", 1) + * - enum_constant (INACTIVE) + * - identifier: "INACTIVE" + * - enum_constant (PENDING) + * - identifier: "PENDING" + * - argument_list: ("Pending", 2) + * - class_body (anonymous class with method overrides) + * + * ## Reusing AnnotationExtractor + * + * This extractor reuses the AnnotationExtractor to extract annotations + * on enum constants. Annotations are linked to the enum constant via + * the enumConstantUniqueHash. + */ +export class EnumConstantExtractor { + private annotationExtractor: AnnotationExtractor; + private expressionExtractor: ExpressionReferenceExtractor; + private extractedAnnotations: TypeAnnotation[] = []; + private extractedAnnotationArguments: AnnotationArgumentReference[] = []; + private extractedExpressions: ExpressionReference[] = []; + private extractedTypeReferences: TypeReference[] = []; + private extractedAnonymousClasses: AnonymousClassInfo[] = []; + + constructor() { + this.annotationExtractor = new AnnotationExtractor(); + this.expressionExtractor = new ExpressionReferenceExtractor(); + } + + /** + * Returns all annotations extracted during the last extraction + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns all annotation arguments extracted during the last extraction + */ + getExtractedAnnotationArguments(): AnnotationArgumentReference[] { + return this.extractedAnnotationArguments; + } + + /** + * Returns all expressions extracted during the last extraction + */ + getExtractedExpressions(): ExpressionReference[] { + return this.extractedExpressions; + } + + /** + * Returns all type references extracted during the last extraction + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all anonymous classes extracted during the last extraction + */ + getExtractedAnonymousClasses(): AnonymousClassInfo[] { + return this.extractedAnonymousClasses; + } + + /** + * Extracts enum constants from an enum declaration node + */ + extractFromEnum( + enumNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null = null, + importMap: Map = new Map(), + hasStarImports: boolean = false + ): EnumConstant[] { + // Reset extracted collections + this.extractedAnnotations = []; + this.extractedAnnotationArguments = []; + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnonymousClasses = []; + + const enumConstants: EnumConstant[] = []; + + // Find the enum_body + const enumBody = enumNode.children.find(child => child.type === 'enum_body'); + if (!enumBody) { + return enumConstants; + } + + let ordinal = 0; + + for (const child of enumBody.children) { + if (child.type === 'enum_constant') { + const enumConstant = this.extractEnumConstant( + child, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + ordinal + ); + + if (enumConstant) { + enumConstants.push(enumConstant); + + // Extract annotations for this enum constant + this.extractEnumConstantAnnotations( + child, + typeRegistryHash, + enumConstant.getEnumConstantUniqueHash() + ); + + // Extract expressions from enum constant arguments + this.extractEnumConstantArgumentExpressions( + child, + typeRegistryHash, + enumConstant.getEnumConstantUniqueHash(), + packageName, + importMap, + hasStarImports + ); + + ordinal++; + } + } + } + + return enumConstants; + } + + /** + * Extracts a single enum constant from an enum_constant node + */ + private extractEnumConstant( + constantNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + ordinal: number + ): EnumConstant | null { + // Extract name from identifier + const identifierNode = constantNode.children.find(child => child.type === 'identifier'); + if (!identifierNode) { + return null; + } + + const name = identifierNode.text; + const qualifiedName = `${ownerQualifiedName}.${name}`; + + // Extract arguments from argument_list + const args = this.extractArguments(constantNode); + + // Check for anonymous class body + const hasBody = constantNode.children.some(child => child.type === 'class_body'); + + const startLine = constantNode.startPosition.row + 1; + const endLine = constantNode.endPosition.row + 1; + + return new EnumConstant( + name, + qualifiedName, + ordinal, + args, + hasBody, + filePath, + startLine, + endLine, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + } + + /** + * Extracts constructor arguments from an enum constant + */ + private extractArguments(constantNode: Parser.SyntaxNode): string[] { + const args: string[] = []; + + const argumentList = constantNode.children.find(child => child.type === 'argument_list'); + if (!argumentList) { + return args; + } + + for (const child of argumentList.children) { + // Skip parentheses and commas + if (child.type === '(' || child.type === ')' || child.type === ',') { + continue; + } + + // Capture the full text of each argument expression + args.push(child.text); + } + + return args; + } + + /** + * Extracts annotations from an enum constant node + * Reuses the AnnotationExtractor with ENUM_CONSTANT context + */ + private extractEnumConstantAnnotations( + constantNode: Parser.SyntaxNode, + typeRegistryHash: string, + enumConstantHash: string + ): void { + // Reset annotation extractor for this enum constant + this.annotationExtractor.resetExtractedArguments(); + + // Use the public extractFromEnumConstant method + const annotations = this.annotationExtractor.extractFromEnumConstant( + constantNode, + enumConstantHash, + typeRegistryHash + ); + + this.extractedAnnotations.push(...annotations); + + // Collect annotation arguments + const annotationArgs = this.annotationExtractor.getExtractedArguments(); + this.extractedAnnotationArguments.push(...annotationArgs); + } + + /** + * Extracts expressions from enum constant arguments. + * + * Example: PENDING(computeCode(), "Pending") + * - computeCode() is extracted as a METHOD_INVOCATION expression + * - "Pending" is extracted as a LITERAL expression + * + * Each argument expression is linked to the enum constant via enumConstantHash. + */ + private extractEnumConstantArgumentExpressions( + constantNode: Parser.SyntaxNode, + typeRegistryHash: string, + enumConstantHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): void { + const argumentList = constantNode.children.find(child => child.type === 'argument_list'); + if (!argumentList) { + return; + } + + let position = 0; + for (const child of argumentList.children) { + // Skip parentheses and commas + if (child.type === '(' || child.type === ')' || child.type === ',') { + continue; + } + + // Extract expressions from this argument + const expressions = this.expressionExtractor.extractFromEnumConstantArgument( + child, + typeRegistryHash, + enumConstantHash, + packageName, + importMap, + hasStarImports, + position + ); + this.extractedExpressions.push(...expressions); + + // Collect type references from expressions + const typeRefs = this.expressionExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...typeRefs); + + // Collect annotations from expressions (e.g., type-use annotations in object creation) + const annotations = this.expressionExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...annotations); + + // Collect anonymous classes from expressions + const anonymousClasses = this.expressionExtractor.getExtractedAnonymousClasses(); + this.extractedAnonymousClasses.push(...anonymousClasses); + + position++; + } + } +} diff --git a/parser/src/parsers/java/extractors/expression-reference-extractor.ts b/parser/src/parsers/java/extractors/expression-reference-extractor.ts new file mode 100644 index 000000000..f999508e7 --- /dev/null +++ b/parser/src/parsers/java/extractors/expression-reference-extractor.ts @@ -0,0 +1,3948 @@ +import Parser from 'tree-sitter'; + +import { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { + ExpressionKind, + EdgeRole, + RootContext, + ExpressionOwnerKind, + LiteralType, + UnaryFixity, + ReferencedEntityKind, + MethodReferenceKind, +} from '@/enums/java/expressions'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { TypeReferenceExtractor } from '@/parsers/java/extractors/type-reference-extractor'; +import { EntityUtils } from '@/utils/entity-utils'; +import { resolveTypeQualifiedName } from '@/utils/java/type-resolution-utils'; + +/** + * Extracts ExpressionReference entities from Java expression trees. + * + * Built incrementally - currently handles: + * - LITERAL expressions (integers, floats, strings, booleans, chars, null) + * - CLASS_LITERAL expressions (String.class, int.class) + * - UNARY_EXPRESSION (-a, !a, ++a, a++) + * - BINARY_EXPRESSION (a + b, a && b, a == b) + * - TERNARY_EXPRESSION (cond ? trueExpr : falseExpr) + * - PARENTHESIZED (expr) - wraps another expression + * - FIELD_ACCESS (obj.field, Type.CONST, Math.PI - qualifier extracted as child) + * - THIS_REFERENCE (this keyword alone) + * - SUPER_REFERENCE (super keyword alone) + * - IDENTIFIER_REFERENCE (simple name without dots: a, DEFAULT_TIMEOUT) + * - FIELD_ACCESS (super.field, this.field, obj.field, getObj().field) + * - METHOD_INVOCATION (obj.method(), method(), Class.method(), obj.method()) + * - CONSTRUCTOR_INVOCATION (this(), super() - explicit constructor invocation in constructor body) + */ +interface PendingChild { + node: Parser.SyntaxNode; + parentHash: string; + edgeRole: EdgeRole; + position: number; + depth: number; + typeRegistryHash: string; + ownerHash: string; + ownerKind: ExpressionOwnerKind; + rootContext: RootContext; + /** Lambda parameter names in scope for this expression (for classifying usages) */ + lambdaParamNames?: Set; + /** Local variable names in scope for this expression (for classifying usages in switch blocks) */ + localVariableNames?: Set; + /** Pattern binding variable names in scope for this expression (for classifying pattern variable usages) */ + patternBindingNames?: Set; +} + +/** + * Information about an anonymous class encountered during expression extraction. + * Used to register the anonymous class as a type at a higher level. + */ +export interface AnonymousClassInfo { + /** The class_body node of the anonymous class */ + classBodyNode: Parser.SyntaxNode; + /** The full object_creation_expression node */ + creationNode: Parser.SyntaxNode; + /** Hash of the expression that creates this anonymous class */ + expressionHash: string; + /** Pre-generated hash for the anonymous type (must be used by TypeRegistryExtractor) */ + anonymousTypeHash: string; + /** Type being extended/implemented (e.g., "Runnable") */ + baseTypeName: string; + /** Context info for type resolution */ + typeRegistryHash: string; + ownerHash: string; + ownerKind: ExpressionOwnerKind; + rootContext: RootContext; + packageName: string | null; + importMap: Map; + hasStarImports: boolean; +} + +export class ExpressionReferenceExtractor { + private extractedExpressions: ExpressionReference[] = []; + private extractedTypeReferences: TypeReference[] = []; + private extractedAnnotations: TypeAnnotation[] = []; + private extractedAnonymousClasses: AnonymousClassInfo[] = []; + private pendingChildren: PendingChild[] = []; + private typeReferenceExtractor: TypeReferenceExtractor; + private annotationExtractor: AnnotationExtractor; + + // Current extraction context + private currentTypeRegistryHash: string = ''; + private currentOwnerHash: string = ''; + private currentOwnerKind: ExpressionOwnerKind = ExpressionOwnerKind.FIELD; + private currentRootContext: RootContext = RootContext.FIELD_INITIALIZER; + + // Type resolution context + private currentPackageName: string | null = null; + private currentImportMap: Map = new Map(); + private currentHasStarImports: boolean = false; + + // Method parameter names for classifying PARAMETER references + private currentMethodParamNames: Set = new Set(); + + // Lambda parameter names in scope for classifying lambda parameter usages + private currentLambdaParamNames: Set = new Set(); + + // Local variable names in scope for classifying LOCAL_VARIABLE references + private currentLocalVariableNames: Set = new Set(); + + // Pattern binding variable names in scope for classifying PATTERN_BINDING references + private currentPatternBindingNames: Set = new Set(); + + // Pattern bindings declared in the method body currently being extracted, each with the byte + // range of the statement that declares it. + // + // `currentPatternBindingNames` is set per call and carries the bindings of the expression tree + // being walked, which is enough for a switch rule whose label and result are one tree. An + // instanceof binding is used in a DIFFERENT statement from the one that declares it - + // `if (o instanceof Target a) { a.hit(); }` - and each statement is extracted by its own call, + // so the binding was out of scope by the time the use site was classified. It then fell through + // to the naming-convention fallback and was tagged FIELD. + // + // The range matters as much as the name. A binding may share a name with a field, which Java + // permits, and then a use OUTSIDE the declaring statement is the field: + // + // void m(Object o) { + // a.hit(); // the field + // if (o instanceof Target a) { a.hit(); } // the binding + // a.hit(); // the field again + // } + // + // Matching on the name alone would call all three the binding, which trades one wrong answer + // for another. A use is the binding only when it falls inside the declaring statement. + private methodPatternBindings: Array<{ name: string; startIndex: number; endIndex: number }> = []; + + // Return statement index for distinguishing multiple returns in a method + private currentReturnStatementIndex?: number; + + constructor() { + this.typeReferenceExtractor = new TypeReferenceExtractor(); + this.annotationExtractor = new AnnotationExtractor(); + } + + /** + * Returns all expressions extracted during the last extraction + */ + getExtractedExpressions(): ExpressionReference[] { + return this.extractedExpressions; + } + + /** + * Returns all type references extracted during the last extraction + * (from method type arguments like in Collections.emptyList()) + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all type-use annotations extracted during the last extraction + * (from object/array creation expressions like new @TA Object()) + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns all anonymous classes encountered during the last extraction. + * These need to be registered as types at a higher level. + */ + getExtractedAnonymousClasses(): AnonymousClassInfo[] { + return this.extractedAnonymousClasses; + } + + /** + * Extracts expressions from a field initializer + */ + extractFromFieldInitializer( + initializerNode: Parser.SyntaxNode, + typeRegistryHash: string, + fieldHash: string, + _serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): ExpressionReference[] { + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnonymousClasses = []; + this.pendingChildren = []; + + // Set context for child extractions + this.currentTypeRegistryHash = typeRegistryHash; + this.currentOwnerHash = fieldHash; + this.currentOwnerKind = ExpressionOwnerKind.FIELD; + this.currentRootContext = RootContext.FIELD_INITIALIZER; + + // Set type resolution context + this.currentPackageName = packageName; + this.currentImportMap = importMap; + this.currentHasStarImports = hasStarImports; + + // Reset scope tracking + this.currentLambdaParamNames = new Set(); + this.currentPatternBindingNames = new Set(); + + this.extractExpression( + initializerNode, + typeRegistryHash, + fieldHash, + ExpressionOwnerKind.FIELD, + RootContext.FIELD_INITIALIZER, + EdgeRole.ROOT, + undefined, + 0, + 0 + ); + + // Process any pending children (from unary/binary expressions) + this.processPendingChildren(); + + return this.extractedExpressions; + } + + /** + * Extracts expressions from a local variable initializer + * @param methodParamNames Names of method parameters for PARAMETER classification + * @param localVariableNames Names of local variables in scope for LOCAL_VARIABLE classification + * @param lambdaParamNames Names of lambda parameters in scope for LAMBDA_PARAMETER classification + * @param patternBindingNames Names of pattern binding variables in scope for PATTERN_BINDING_VARIABLE classification + */ + extractFromLocalVariableInitializer( + initializerNode: Parser.SyntaxNode, + typeRegistryHash: string, + localVariableHash: string, + _serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + methodParamNames: Set = new Set(), + localVariableNames: Set = new Set(), + lambdaParamNames: Set = new Set(), + patternBindingNames: Set = new Set() + ): ExpressionReference[] { + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnonymousClasses = []; + this.pendingChildren = []; + + // Set context for child extractions + this.currentTypeRegistryHash = typeRegistryHash; + // Expressions from local variable initializers are owned by the local variable + // Block context is tracked on the LocalVariableRegistry itself via parentExpressionLinkHash + this.currentOwnerHash = localVariableHash; + this.currentOwnerKind = ExpressionOwnerKind.LOCAL_VARIABLE; + this.currentRootContext = RootContext.LOCAL_VAR_INITIALIZER; + + // Set type resolution context + this.currentPackageName = packageName; + this.currentImportMap = importMap; + this.currentHasStarImports = hasStarImports; + + // Set method parameter names for PARAMETER classification + this.currentMethodParamNames = methodParamNames; + + // Set local variable names for LOCAL_VARIABLE classification + this.currentLocalVariableNames = localVariableNames; + + // Set lambda parameter names for LAMBDA_PARAMETER classification + this.currentLambdaParamNames = lambdaParamNames; + + // Set pattern binding names for PATTERN_BINDING_VARIABLE classification + this.currentPatternBindingNames = patternBindingNames; + + this.extractExpression( + initializerNode, + typeRegistryHash, + localVariableHash, + ExpressionOwnerKind.LOCAL_VARIABLE, + RootContext.LOCAL_VAR_INITIALIZER, + EdgeRole.ROOT, + undefined, + 0, + 0 + ); + + // Process any pending children (from unary/binary expressions) + this.processPendingChildren(); + + return this.extractedExpressions; + } + + /** + * Extracts expressions from an enum constant argument. + * + * Example: PENDING(computeCode(), "Pending") + * - computeCode() is a method invocation expression + * - "Pending" is a literal expression + * + * @param argumentNode The argument expression node + * @param typeRegistryHash Hash of the enclosing enum type + * @param enumConstantHash Hash of the enum constant + * @param packageName Current package name for type resolution + * @param importMap Import map for type resolution + * @param hasStarImports Whether star imports are present + * @param position Position of this argument (0-indexed) + */ + extractFromEnumConstantArgument( + argumentNode: Parser.SyntaxNode, + typeRegistryHash: string, + enumConstantHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + position: number + ): ExpressionReference[] { + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnonymousClasses = []; + this.pendingChildren = []; + + // Set context for child extractions + this.currentTypeRegistryHash = typeRegistryHash; + this.currentOwnerHash = enumConstantHash; + this.currentOwnerKind = ExpressionOwnerKind.ENUM_CONSTANT_ARGUMENT; + this.currentRootContext = RootContext.ENUM_CONSTANT_ARGUMENT; + + // Set type resolution context + this.currentPackageName = packageName; + this.currentImportMap = importMap; + this.currentHasStarImports = hasStarImports; + + this.extractExpression( + argumentNode, + typeRegistryHash, + enumConstantHash, + ExpressionOwnerKind.ENUM_CONSTANT_ARGUMENT, + RootContext.ENUM_CONSTANT_ARGUMENT, + EdgeRole.ROOT, + undefined, + position, // Use the argument position + 0 + ); + + // Process any pending children + this.processPendingChildren(); + + return this.extractedExpressions; + } + + /** + * Extracts expressions from a return statement. + * + * Example: return x * 2; + * - x * 2 is the return value expression + * + * @param returnNode The return_statement node + * @param typeRegistryHash Hash of the enclosing type + * @param methodHash Hash of the method containing this return + * @param packageName Current package name for type resolution + * @param importMap Import map for type resolution + * @param hasStarImports Whether star imports are present + * @param methodParamNames Names of method parameters for PARAMETER classification + * @param returnStatementIndex Index of this return statement within the method (0-based) + * @param localVariableNames Names of local variables in scope for LOCAL_VARIABLE classification + * @param lambdaParamNames Names of lambda parameters in scope for LAMBDA_PARAMETER classification + */ + extractFromReturnStatement( + returnStmtNode: Parser.SyntaxNode, + typeRegistryHash: string, + ownerHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + methodParamNames: Set = new Set(), + returnStatementIndex?: number, + localVariableNames: Set = new Set(), + lambdaParamNames: Set = new Set() + ): ExpressionReference[] { + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnonymousClasses = []; + this.pendingChildren = []; + + // Set context for child extractions + this.currentTypeRegistryHash = typeRegistryHash; + this.currentOwnerHash = ownerHash; + this.currentOwnerKind = ExpressionOwnerKind.RETURN_STATEMENT; + this.currentRootContext = RootContext.RETURN_VALUE; + + // Set type resolution context + this.currentPackageName = packageName; + this.currentImportMap = importMap; + this.currentHasStarImports = hasStarImports; + + // Set method parameter names for PARAMETER classification + this.currentMethodParamNames = methodParamNames; + + // Set local variable names for LOCAL_VARIABLE classification + this.currentLocalVariableNames = localVariableNames; + + // Set return statement index for distinguishing multiple returns + this.currentReturnStatementIndex = returnStatementIndex; + + // Set lambda parameter names for LAMBDA_PARAMETER classification + this.currentLambdaParamNames = lambdaParamNames; + // Pattern bindings do not survive between statements: each is scoped to the statement that + // declares it, and cross-statement uses resolve through methodPatternBindings by range. + this.currentPatternBindingNames = new Set(); + + // Find the expression inside the return statement + // return_statement structure: return ? ; + const returnExpr = ExpressionReferenceExtractor.namedOperands(returnStmtNode)[0]; + + // If there's no expression (bare "return;"), nothing to extract + if (!returnExpr) { + return this.extractedExpressions; + } + + this.extractExpression( + returnExpr, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.RETURN_STATEMENT, + RootContext.RETURN_VALUE, + EdgeRole.ROOT, + undefined, + 0, + 0 + ); + + // Process any pending children + this.processPendingChildren(); + + return this.extractedExpressions; + } + + /** + * Extracts expressions from a throw statement. + * + * Example: throw new RuntimeException("error"); + * - new RuntimeException("error") is the thrown expression + * + * @param throwStmtNode The throw_statement node + * @param typeRegistryHash Hash of the enclosing type + * @param ownerHash Hash of the method/lambda containing this throw + * @param packageName Current package name for type resolution + * @param importMap Import map for type resolution + * @param hasStarImports Whether star imports are present + * @param methodParamNames Names of method parameters for PARAMETER classification + * @param throwStatementIndex Index of this throw statement within the method (0-based) + * @param localVariableNames Names of local variables in scope for LOCAL_VARIABLE classification + * @param lambdaParamNames Names of lambda parameters in scope for LAMBDA_PARAMETER classification + */ + extractFromThrowStatement( + throwStmtNode: Parser.SyntaxNode, + typeRegistryHash: string, + ownerHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + methodParamNames: Set = new Set(), + _throwStatementIndex?: number, + localVariableNames: Set = new Set(), + lambdaParamNames: Set = new Set() + ): ExpressionReference[] { + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnonymousClasses = []; + this.pendingChildren = []; + + // Set context for child extractions + this.currentTypeRegistryHash = typeRegistryHash; + this.currentOwnerHash = ownerHash; + this.currentOwnerKind = ExpressionOwnerKind.THROW_STATEMENT; + this.currentRootContext = RootContext.THROW_VALUE; + + // Set type resolution context + this.currentPackageName = packageName; + this.currentImportMap = importMap; + this.currentHasStarImports = hasStarImports; + + // Set method parameter names for PARAMETER classification + this.currentMethodParamNames = methodParamNames; + + // Set local variable names for LOCAL_VARIABLE classification + this.currentLocalVariableNames = localVariableNames; + + // Set lambda parameter names for LAMBDA_PARAMETER classification + this.currentLambdaParamNames = lambdaParamNames; + // Pattern bindings do not survive between statements: each is scoped to the statement that + // declares it, and cross-statement uses resolve through methodPatternBindings by range. + this.currentPatternBindingNames = new Set(); + + // Find the expression inside the throw statement + // throw_statement structure: throw ; + const thrownExpr = ExpressionReferenceExtractor.namedOperands(throwStmtNode)[0]; + + // If there's no expression, nothing to extract + if (!thrownExpr) { + return this.extractedExpressions; + } + + this.extractExpression( + thrownExpr, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.THROW_STATEMENT, + RootContext.THROW_VALUE, + EdgeRole.ROOT, + undefined, + 0, + 0 + ); + + // Process any pending children + this.processPendingChildren(); + + return this.extractedExpressions; + } + + /** + * Extracts a break statement as a single BREAK_STATEMENT expression entry. + * + * break_statement structure: + * break ; — no named children + * break ; — one named child: the label identifier + * + * The break statement itself is recorded as the expression (no sub-expression to recurse into). + * If a label is present its name is stored in literalValue. + * + * @param breakStmtNode The break_statement syntax node + * @param typeRegistryHash Hash of the containing type + * @param ownerHash Hash of the block (SWITCH_CASE or loop body) containing this statement + */ + extractFromBreakStatement( + breakStmtNode: Parser.SyntaxNode, + typeRegistryHash: string, + ownerHash: string + ): ExpressionReference[] { + const builder = ExpressionReference.builder( + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.BREAK_STATEMENT, + RootContext.BREAK_STATEMENT, + ExpressionKind.BREAK_STATEMENT, + EdgeRole.ROOT + ); + builder.positionAndDepth(0, 0); + builder.location( + breakStmtNode.startPosition.row + 1, + breakStmtNode.startPosition.column, + breakStmtNode.endPosition.row + 1, + breakStmtNode.endPosition.column + ); + + // If the break has a label (e.g. break outer;), store label name in literalValue + const labelNode = ExpressionReferenceExtractor.namedOperands(breakStmtNode)[0]; + if (labelNode && labelNode.type === 'identifier') { + builder.classLiteralTypeName(labelNode.text); + } + + return [builder.build()]; + } + + /** + * Extracts a continue statement as a single CONTINUE_STATEMENT expression entry. + * + * continue_statement structure: + * continue ; — no named children + * continue ; — one named child: the label identifier + * + * If a label is present its name is stored in literalValue. + * + * @param continueStmtNode The continue_statement syntax node + * @param typeRegistryHash Hash of the containing type + * @param ownerHash Hash of the block (loop body) containing this statement + */ + extractFromContinueStatement( + continueStmtNode: Parser.SyntaxNode, + typeRegistryHash: string, + ownerHash: string + ): ExpressionReference[] { + const builder = ExpressionReference.builder( + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.CONTINUE_STATEMENT, + RootContext.CONTINUE_STATEMENT, + ExpressionKind.CONTINUE_STATEMENT, + EdgeRole.ROOT + ); + builder.positionAndDepth(0, 0); + builder.location( + continueStmtNode.startPosition.row + 1, + continueStmtNode.startPosition.column, + continueStmtNode.endPosition.row + 1, + continueStmtNode.endPosition.column + ); + + // If the continue has a label (e.g. continue outer;), store label name in literalValue + const labelNode = ExpressionReferenceExtractor.namedOperands(continueStmtNode)[0]; + if (labelNode && labelNode.type === 'identifier') { + builder.classLiteralTypeName(labelNode.text); + } + + return [builder.build()]; + } + + /** + * Extracts all expressions from an expression statement (e.g., `this.count = count;`). + * Expression statements are standalone expressions used as statements, typically: + * - Assignment expressions: `this.field = value;` + * - Method calls: `System.out.println("hello");` + * - Increment/decrement: `count++;` + * + * @param exprStmtNode The expression_statement syntax node + * @param typeRegistryHash Hash of the containing type + * @param methodHash Hash of the method containing this statement + * @param packageName Current package name for type resolution + * @param importMap Import map for type resolution + * @param hasStarImports Whether star imports are present + * @param methodParamNames Names of method parameters for PARAMETER classification + * @param localVariableNames Names of local variables in scope for LOCAL_VARIABLE classification + * @param lambdaParamNames Names of lambda parameters in scope for LAMBDA_PARAMETER classification + */ + extractFromExpressionStatement( + exprStmtNode: Parser.SyntaxNode, + typeRegistryHash: string, + methodHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + methodParamNames: Set = new Set(), + localVariableNames: Set = new Set(), + lambdaParamNames: Set = new Set() + ): ExpressionReference[] { + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnonymousClasses = []; + this.pendingChildren = []; + + // Set context for child extractions + this.currentTypeRegistryHash = typeRegistryHash; + this.currentOwnerHash = methodHash; + this.currentOwnerKind = ExpressionOwnerKind.EXPRESSION_STATEMENT; + this.currentRootContext = RootContext.EXPRESSION_STATEMENT; + + // Set type resolution context + this.currentPackageName = packageName; + this.currentImportMap = importMap; + this.currentHasStarImports = hasStarImports; + + // Set method parameter names for PARAMETER classification + this.currentMethodParamNames = methodParamNames; + + // Set local variable names for LOCAL_VARIABLE classification + this.currentLocalVariableNames = localVariableNames; + + // No return statement index for expression statements + this.currentReturnStatementIndex = undefined; + + // Set lambda parameter names for LAMBDA_PARAMETER classification + this.currentLambdaParamNames = lambdaParamNames; + // Pattern bindings do not survive between statements: each is scoped to the statement that + // declares it, and cross-statement uses resolve through methodPatternBindings by range. + this.currentPatternBindingNames = new Set(); + + // Find the expression inside the expression statement + // expression_statement structure: ; + const expr = ExpressionReferenceExtractor.namedOperands(exprStmtNode)[0]; + + // If there's no expression, nothing to extract + if (!expr) { + return this.extractedExpressions; + } + + this.extractExpression( + expr, + typeRegistryHash, + methodHash, + ExpressionOwnerKind.EXPRESSION_STATEMENT, + RootContext.EXPRESSION_STATEMENT, + EdgeRole.ROOT, + undefined, + 0, + 0 + ); + + // Process any pending children + this.processPendingChildren(); + + return this.extractedExpressions; + } + + /** + * Extracts all expressions from a control flow condition expression. + * This handles IF conditions, WHILE conditions, FOR conditions/init/update, DO_WHILE conditions, etc. + * + * @param conditionNode The condition expression syntax node + * @param typeRegistryHash Hash of the containing type + * @param ownerHash Hash of the block or method owning this condition + * @param rootContext The context type (IF_CONDITION, WHILE_CONDITION, etc.) + * @param packageName Current package name for type resolution + * @param importMap Import map for type resolution + * @param hasStarImports Whether star imports are present + * @param methodParamNames Names of method parameters for PARAMETER classification + * @param localVariableNames Names of local variables in scope for LOCAL_VARIABLE classification + * @param lambdaParamNames Names of lambda parameters in scope for LAMBDA_PARAMETER classification + */ + extractFromConditionExpression( + conditionNode: Parser.SyntaxNode, + typeRegistryHash: string, + ownerHash: string, + ownerKind: ExpressionOwnerKind, + rootContext: RootContext, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + methodParamNames: Set = new Set(), + localVariableNames: Set = new Set(), + lambdaParamNames: Set = new Set() + ): ExpressionReference[] { + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnonymousClasses = []; + this.pendingChildren = []; + + // Set context for child extractions + this.currentTypeRegistryHash = typeRegistryHash; + this.currentOwnerHash = ownerHash; + this.currentOwnerKind = ownerKind; + this.currentRootContext = rootContext; + + // Set type resolution context + this.currentPackageName = packageName; + this.currentImportMap = importMap; + this.currentHasStarImports = hasStarImports; + + // Set method parameter names for PARAMETER classification + this.currentMethodParamNames = methodParamNames; + + // Set local variable names for LOCAL_VARIABLE classification + this.currentLocalVariableNames = localVariableNames; + + // No return statement index for condition expressions + this.currentReturnStatementIndex = undefined; + + // Set lambda parameter names for LAMBDA_PARAMETER classification + this.currentLambdaParamNames = lambdaParamNames; + // Pattern bindings do not survive between statements: each is scoped to the statement that + // declares it, and cross-statement uses resolve through methodPatternBindings by range. + this.currentPatternBindingNames = new Set(); + + // For parenthesized_expression (if conditions are wrapped), get the inner expression + let expr = conditionNode; + if (conditionNode.type === 'parenthesized_expression' && ExpressionReferenceExtractor.namedOperands(conditionNode).length > 0) { + expr = ExpressionReferenceExtractor.namedOperands(conditionNode)[0]!; + } + + this.extractExpression( + expr, + typeRegistryHash, + ownerHash, + ownerKind, + rootContext, + EdgeRole.ROOT, + undefined, + 0, + 0 + ); + + // Process any pending children + this.processPendingChildren(); + + return this.extractedExpressions; + } + + /** + * Extracts all expressions from an explicit constructor invocation (this() or super()). + * These are special statements that must be the first statement in a constructor body. + * + * @param invocationNode The explicit_constructor_invocation syntax node + * @param typeRegistryHash Hash of the containing type + * @param constructorHash Hash of the constructor containing this invocation + * @param packageName Current package name for type resolution + * @param importMap Import map for type resolution + * @param hasStarImports Whether star imports are present + * @param constructorParamNames Names of constructor parameters for PARAMETER classification + */ + extractFromConstructorInvocation( + invocationNode: Parser.SyntaxNode, + typeRegistryHash: string, + constructorHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + constructorParamNames: Set = new Set() + ): ExpressionReference[] { + this.extractedExpressions = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnonymousClasses = []; + this.pendingChildren = []; + + // Set context for child extractions + this.currentTypeRegistryHash = typeRegistryHash; + this.currentOwnerHash = constructorHash; + this.currentOwnerKind = ExpressionOwnerKind.EXPRESSION_STATEMENT; // Using EXPRESSION_STATEMENT as owner + this.currentRootContext = RootContext.EXPLICIT_CONSTRUCTOR_INVOCATION; + + // Set type resolution context + this.currentPackageName = packageName; + this.currentImportMap = importMap; + this.currentHasStarImports = hasStarImports; + + // Set constructor parameter names for PARAMETER classification + this.currentMethodParamNames = constructorParamNames; + + // No return statement index + this.currentReturnStatementIndex = undefined; + + // Reset lambda scope tracking + this.currentLambdaParamNames = new Set(); + + // Extract the constructor invocation itself as an expression + this.extractExpression( + invocationNode, + typeRegistryHash, + constructorHash, + ExpressionOwnerKind.EXPRESSION_STATEMENT, + RootContext.EXPLICIT_CONSTRUCTOR_INVOCATION, + EdgeRole.ROOT, + undefined, + 0, + 0 + ); + + // Process any pending children + this.processPendingChildren(); + + return this.extractedExpressions; + } + + /** + * Process all pending child expressions + */ + private processPendingChildren(): void { + while (this.pendingChildren.length > 0) { + const child = this.pendingChildren.shift()!; + + // Set lambda param names in scope for this child + if (child.lambdaParamNames) { + this.currentLambdaParamNames = child.lambdaParamNames; + } + + // Set local variable names in scope for this child (for switch block locals) + if (child.localVariableNames) { + for (const name of child.localVariableNames) { + this.currentLocalVariableNames.add(name); + } + } + + // Set pattern binding names in scope for this child (for switch pattern variables) + if (child.patternBindingNames) { + for (const name of child.patternBindingNames) { + this.currentPatternBindingNames.add(name); + } + } + + this.extractExpression( + child.node, + child.typeRegistryHash, + child.ownerHash, + child.ownerKind, + child.rootContext, + child.edgeRole, + child.parentHash, + child.position, + child.depth + ); + } + } + + /** + * Core extraction method - extracts an expression node + */ + private extractExpression( + node: Parser.SyntaxNode, + typeRegistryHash: string, + ownerHash: string, + ownerKind: ExpressionOwnerKind, + rootContext: RootContext, + edgeRole: EdgeRole, + parentHash: string | undefined, + position: number, + depth: number + ): void { + const kind = this.determineExpressionKind(node); + + // Skip unknown expression types for now + if (kind === ExpressionKind.UNKNOWN) { + return; + } + + const builder = ExpressionReference.builder( + typeRegistryHash, + ownerHash, + ownerKind, + rootContext, + kind, + edgeRole + ); + + // Set position and depth + builder.positionAndDepth(position, depth); + + // Set parent if not root + if (parentHash) { + builder.parent(parentHash); + } + + // For anonymous class creation, generate and set the anonymous type hash + // This hash will be used consistently when registering the anonymous type + if (kind === ExpressionKind.ANONYMOUS_CLASS_CREATION) { + const anonymousTypeHash = this.generateAnonymousTypeHash(node); + builder.anonymousType(anonymousTypeHash); + } + + // Add kind-specific data (literals, operators, etc. - but NOT children yet) + this.addKindSpecificData(builder, node, kind, edgeRole); + + // Set location (line and column) + builder.location( + node.startPosition.row + 1, // 1-indexed line + node.startPosition.column, // 0-indexed column + node.endPosition.row + 1, // 1-indexed line + node.endPosition.column // 0-indexed column + ); + + // Set return statement index if this is a return statement expression + if (this.currentReturnStatementIndex !== undefined && rootContext === RootContext.RETURN_VALUE) { + builder.returnIndex(this.currentReturnStatementIndex); + } + + const expression = builder.build(); + this.extractedExpressions.push(expression); + + // Now queue children using the actual built expression hash + this.queueChildExpressions(node, kind, expression.getHash(), depth); + } + + /** + * Queue child expressions for compound expression types + */ + private queueChildExpressions( + node: Parser.SyntaxNode, + kind: ExpressionKind, + parentHash: string, + depth: number + ): void { + if (kind === ExpressionKind.UNARY_EXPRESSION) { + this.queueUnaryOperand(node, parentHash, depth); + } else if (kind === ExpressionKind.BINARY_EXPRESSION) { + this.queueBinaryOperands(node, parentHash, depth); + } else if (kind === ExpressionKind.TERNARY_EXPRESSION) { + this.queueTernaryOperands(node, parentHash, depth); + } else if (kind === ExpressionKind.PARENTHESIZED) { + this.queueParenthesizedChild(node, parentHash, depth); + } else if (kind === ExpressionKind.FIELD_ACCESS) { + this.queueFieldAccessObject(node, parentHash, depth); + } else if (kind === ExpressionKind.METHOD_INVOCATION) { + this.queueMethodInvocationChildren(node, parentHash, depth); + } else if (kind === ExpressionKind.INSTANCEOF_EXPRESSION || kind === ExpressionKind.INSTANCEOF_PATTERN) { + this.queueInstanceofOperand(node, parentHash, depth); + } else if (kind === ExpressionKind.OBJECT_CREATION || kind === ExpressionKind.ANONYMOUS_CLASS_CREATION) { + this.queueObjectCreationChildren(node, parentHash, depth); + } else if (kind === ExpressionKind.ARRAY_CREATION) { + this.queueArrayCreationChildren(node, parentHash, depth); + } else if (kind === ExpressionKind.ARRAY_INITIALIZER) { + this.queueArrayInitializerChildren(node, parentHash, depth); + } else if (kind === ExpressionKind.CONSTRUCTOR_INVOCATION) { + this.queueConstructorInvocationArguments(node, parentHash, depth); + } else if (kind === ExpressionKind.METHOD_REFERENCE) { + this.queueMethodReferenceQualifier(node, parentHash, depth); + } else if (kind === ExpressionKind.ASSIGNMENT_EXPRESSION || kind === ExpressionKind.COMPOUND_ASSIGNMENT) { + this.queueAssignmentOperands(node, parentHash, depth); + } else if (kind === ExpressionKind.CAST_EXPRESSION) { + this.queueCastOperand(node, parentHash, depth); + } else if (kind === ExpressionKind.ARRAY_ACCESS) { + this.queueArrayAccessChildren(node, parentHash, depth); + } else if (kind === ExpressionKind.SWITCH_EXPRESSION) { + this.queueSwitchExpressionChildren(node, parentHash, depth); + } else if (kind === ExpressionKind.STRING_TEMPLATE) { + this.queueStringTemplateChildren(node, parentHash, depth); + } else if (kind === ExpressionKind.RECORD_PATTERN) { + this.queueRecordPatternChildren(node, parentHash, depth); + } else if (kind === ExpressionKind.LAMBDA_EXPRESSION) { + this.queueLambdaChildren(node, parentHash, depth); + } + } + + /** + * Queue the operand of a unary expression + */ + private queueUnaryOperand( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + const { operand } = this.parseUnaryExpression(node); + + this.pendingChildren.push({ + node: operand, + parentHash, + edgeRole: EdgeRole.UNARY_OPERAND, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + /** + * Queue the operands of a binary expression + */ + private queueBinaryOperands( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + const { left, right } = this.parseBinaryExpression(node); + + this.pendingChildren.push({ + node: left, + parentHash, + edgeRole: EdgeRole.LEFT_OPERAND, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + + this.pendingChildren.push({ + node: right, + parentHash, + edgeRole: EdgeRole.RIGHT_OPERAND, + position: 1, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + /** + * Queue the operands of an assignment expression (target and value) + */ + private queueAssignmentOperands( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + const { left, right } = this.parseAssignmentExpression(node); + + this.pendingChildren.push({ + node: left, + parentHash, + edgeRole: EdgeRole.ASSIGNMENT_TARGET, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + + this.pendingChildren.push({ + node: right, + parentHash, + edgeRole: EdgeRole.ASSIGNMENT_VALUE, + position: 1, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + /** + * Parse an assignment expression into its left (target), operator, and right (value) parts + */ + private parseAssignmentExpression(node: Parser.SyntaxNode): { + left: Parser.SyntaxNode; + right: Parser.SyntaxNode; + operator: string; + } { + // Tree-sitter Java assignment_expression has named fields 'left' and 'right' + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + const operator = this.extractAssignmentOperator(node); + + if (!left || !right) { + throw new Error(`Malformed assignment expression: missing left or right operand`); + } + + return { left, right, operator }; + } + + /** + * Queue the operands of a ternary expression (condition, trueExpr, falseExpr) + */ + private queueTernaryOperands( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + const { condition, trueExpr, falseExpr } = this.parseTernaryExpression(node); + + this.pendingChildren.push({ + node: condition, + parentHash, + edgeRole: EdgeRole.TERNARY_CONDITION, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + + this.pendingChildren.push({ + node: trueExpr, + parentHash, + edgeRole: EdgeRole.TERNARY_TRUE, + position: 1, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + + this.pendingChildren.push({ + node: falseExpr, + parentHash, + edgeRole: EdgeRole.TERNARY_FALSE, + position: 2, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + /** + * Queue the inner expression of a parenthesized expression + */ + private queueParenthesizedChild( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Parenthesized expression has one named child - the inner expression + const innerExpr = ExpressionReferenceExtractor.namedOperands(node)[0]; + if (innerExpr) { + this.pendingChildren.push({ + node: innerExpr, + parentHash, + edgeRole: EdgeRole.PARENTHESIZED_INNER, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + + /** + * Queue the object expression of a field access (super.field, this.field, obj.field) + */ + private queueFieldAccessObject( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // field_access structure: object "." field + const object = node.childForFieldName('object'); + if (object) { + this.pendingChildren.push({ + node: object, + parentHash, + edgeRole: EdgeRole.QUALIFIER, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + + /** + * Queue children of a method invocation (receiver and arguments) + * method_invocation structure: + * - object (optional): receiver expression (obj in obj.method()) + * - name: method name identifier + * - arguments: argument list + * - type_arguments (optional): generic type arguments ( in obj.method()) + */ + private queueMethodInvocationChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Queue the receiver/object if present (obj in obj.method()) + const object = node.childForFieldName('object'); + if (object) { + this.pendingChildren.push({ + node: object, + parentHash, + edgeRole: EdgeRole.RECEIVER, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + // Queue all arguments + const args = node.childForFieldName('arguments'); + if (args) { + let position = 0; + for (const arg of ExpressionReferenceExtractor.namedOperands(args)) { + this.pendingChildren.push({ + node: arg, + parentHash, + edgeRole: EdgeRole.ARGUMENT, + position, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + position++; + } + } + + // Extract type arguments using reusable helper (e.g., in Collections.emptyList()) + this.extractTypeArgumentsIfPresent(node, parentHash, true); // useMethodExtractor=true + } + + /** + * Queue the operand of an instanceof expression and extract type reference + * + * instanceof_expression structure: + * - Basic: left instanceof type + * - Pattern (Java 16+): left instanceof type varName + * - Record pattern (Java 21+): left instanceof record_pattern + * + * Named children (basic/pattern): + * [0] = left: expression being tested (obj in obj instanceof String) + * [1] = type: type being tested against (String in obj instanceof String) + * [2] = varName: pattern variable (s in obj instanceof String s) - only for patterns + * + * For record patterns: + * [left] = operand + * [pattern] = record_pattern node + */ + private queueInstanceofOperand( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + const namedChildren = ExpressionReferenceExtractor.namedOperands(node); + + // Queue the left operand (expression being tested) + const leftOperand = node.childForFieldName('left'); + if (leftOperand) { + this.pendingChildren.push({ + node: leftOperand, + parentHash, + edgeRole: EdgeRole.INSTANCEOF_OPERAND, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + // Check for record pattern (Java 21+) + const patternNode = node.childForFieldName('pattern'); + if (patternNode && patternNode.type === 'record_pattern') { + // Queue the record pattern as a child expression + this.pendingChildren.push({ + node: patternNode, + parentHash, + edgeRole: EdgeRole.PATTERN_VARIABLE, // Record pattern acts like a pattern variable + position: 1, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + return; // Record patterns handle their own type extraction + } + + // Extract type reference for the type being tested (basic instanceof) + // The type is the second named child (after the operand) + if (namedChildren.length >= 2) { + const typeNode = namedChildren[1]!; + const typeRefs = this.typeReferenceExtractor.extractFromInstanceof( + typeNode, + this.currentTypeRegistryHash, + parentHash, // expression hash as the owner + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + + // Queue the pattern variable if present (Java 16+ pattern matching) + // The pattern variable is the 3rd named child (identifier after the type) + if (namedChildren.length >= 3) { + const patternVar = namedChildren[2]!; + if (patternVar.type === 'identifier') { + // Track pattern binding name so later references are classified as PATTERN_BINDING_VARIABLE + this.currentPatternBindingNames.add(patternVar.text); + + this.pendingChildren.push({ + node: patternVar, + parentHash, + edgeRole: EdgeRole.PATTERN_VARIABLE, + position: 1, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + } + + /** + * Queue the operand of a cast expression and extract the cast type reference. + * + * cast_expression structure: (type) value + * - type: the target type being cast to (may be intersection type with multiple types) + * - value: the expression being cast + * + * Example: (String) obj -> queue 'obj' as CAST_OPERAND, extract String type reference + * Example: (List) items -> queue 'items', extract List type reference + * Example: (Serializable & Comparable) obj -> extract both Serializable and Comparable + */ + private queueCastOperand( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // cast_expression has named fields 'type' and 'value' + const valueNode = node.childForFieldName('value'); + if (valueNode) { + this.pendingChildren.push({ + node: valueNode, + parentHash, + edgeRole: EdgeRole.CAST_OPERAND, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + // Extract type references for the cast type(s) + // For intersection types (Java 8+), there may be multiple type nodes + // e.g., (Serializable & Comparable) has type_identifier and generic_type as siblings + const typeNodes = this.getCastTypeNodes(node); + let position = 0; + for (const typeNode of typeNodes) { + const typeRefs = this.typeReferenceExtractor.extractFromCast( + typeNode, + this.currentTypeRegistryHash, + parentHash, // expression hash as the owner + this.currentPackageName, + new Set(), // declaredTypeParams + position // position for intersection types + ); + this.extractedTypeReferences.push(...typeRefs); + + // Build typeRefHashMap for linking TYPE_USE annotations to type references + const typeRefHashMap = new Map(); + for (const ref of typeRefs) { + const positionKey = `${ref.getDepth()}.${ref.getPosition()}`; + typeRefHashMap.set(positionKey, ref.getHash()); + } + + // Extract TYPE_USE annotations from annotated types in cast (e.g., (@TA String), (String @TA [])) + const typeUseAnnotations = this.annotationExtractor.extractFromFieldType( + typeNode, + typeRefHashMap, + this.currentTypeRegistryHash + ); + this.extractedAnnotations.push(...typeUseAnnotations); + + position++; + } + } + + /** + * Get all type nodes from a cast expression. + * For simple casts: returns single type node + * For intersection types: returns all type nodes (Serializable & Comparable -> [Serializable, Comparable]) + */ + private getCastTypeNodes(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const typeNodes: Parser.SyntaxNode[] = []; + const typeNodeTypes = new Set([ + 'type_identifier', + 'generic_type', + 'scoped_type_identifier', + 'array_type', + 'integral_type', + 'floating_point_type', + 'boolean_type', + 'void_type', + 'annotated_type', // For TYPE_USE annotations like (@TA String), (String @TA []) + ]); + + for (const child of ExpressionReferenceExtractor.namedOperands(node)) { + if (typeNodeTypes.has(child.type)) { + typeNodes.push(child); + } + } + + return typeNodes; + } + + /** + * Queue children of an array access expression. + * + * array_access structure: array[index] + * - array: the expression being indexed (QUALIFIER) + * - index: the index expression (ARRAY_INDEX) + * + * Example: arr[0] -> queue 'arr' as QUALIFIER, '0' as ARRAY_INDEX + * Example: matrix[i][j] -> queue 'matrix[i]' as QUALIFIER, 'j' as ARRAY_INDEX + */ + private queueArrayAccessChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // array_access has named fields 'array' and 'index' + const arrayNode = node.childForFieldName('array'); + if (arrayNode) { + this.pendingChildren.push({ + node: arrayNode, + parentHash, + edgeRole: EdgeRole.QUALIFIER, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + const indexNode = node.childForFieldName('index'); + if (indexNode) { + this.pendingChildren.push({ + node: indexNode, + parentHash, + edgeRole: EdgeRole.ARRAY_INDEX, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + + /** + * Queue children of a switch expression (Java 14+). + * + * switch_expression structure: + * - condition field: parenthesized_expression containing the selector + * - body field: switch_block containing switch_rule nodes + * + * Each switch_rule contains: + * - switch_label: case labels (constants or default) + * - expression_statement or block: the result expression + * + * Children queued: + * - SWITCH_SELECTOR: the selector expression inside parentheses + * - SWITCH_CASE_LABEL: each constant in case labels (position tracks within case) + * - SWITCH_CASE_RESULT: each case result expression + */ + private queueSwitchExpressionChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Queue the selector expression (inside parenthesized_expression) + const condition = node.childForFieldName('condition'); + if (condition) { + // The condition is a parenthesized_expression, get the inner expression + const selector = ExpressionReferenceExtractor.namedOperands(condition)[0]; + if (selector) { + this.pendingChildren.push({ + node: selector, + parentHash, + edgeRole: EdgeRole.SWITCH_SELECTOR, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + + // Queue case labels and results from switch_block + // Use casePosition for both labels and results so they can be linked + const body = node.childForFieldName('body'); + if (body) { + let casePosition = 0; + + for (const child of ExpressionReferenceExtractor.namedOperands(body)) { + // Handle arrow syntax (switch_rule) + if (child.type === 'switch_rule') { + this.extractSwitchRule(child, parentHash, depth, casePosition); + casePosition++; + } + // Handle colon syntax (switch_block_statement_group) + else if (child.type === 'switch_block_statement_group') { + this.extractSwitchBlockStatementGroup(child, parentHash, depth, casePosition); + casePosition++; + } + } + } + } + + /** + * Extract children from a switch_rule (arrow syntax: case X -> result). + * + * Handles: + * - expression_statement: direct expression result + * - block: look for yield_statement inside + * - throw_statement: extract throw expression + * - guard: extract guard expression (when clause) + */ + private extractSwitchRule( + ruleNode: Parser.SyntaxNode, + parentHash: string, + depth: number, + casePosition: number + ): void { + const switchLabel = ruleNode.children.find(c => c.type === 'switch_label'); + + // Extract pattern binding names from the switch label to pass to result expressions + let patternBindingNames: Set | undefined; + if (switchLabel) { + patternBindingNames = this.extractPatternBindingNames(switchLabel); + this.extractSwitchLabelChildren(switchLabel, parentHash, depth, casePosition); + } + + // Find the result node (after the arrow) + const resultNode = ruleNode.children.find(c => + c.type === 'expression_statement' || + c.type === 'block' || + c.type === 'throw_statement' + ); + + if (resultNode) { + if (resultNode.type === 'expression_statement') { + // Direct expression result + const expr = ExpressionReferenceExtractor.namedOperands(resultNode)[0]; + if (expr) { + this.pendingChildren.push({ + node: expr, + parentHash, + edgeRole: EdgeRole.SWITCH_CASE_RESULT, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + patternBindingNames: patternBindingNames && patternBindingNames.size > 0 ? patternBindingNames : undefined, + }); + } + } else if (resultNode.type === 'block') { + // Block with yield - find yield_statement and extract its expression + this.extractYieldFromBlock(resultNode, parentHash, depth, casePosition, patternBindingNames); + } else if (resultNode.type === 'throw_statement') { + // Throw statement - extract the thrown expression + const thrownExpr = ExpressionReferenceExtractor.namedOperands(resultNode)[0]; + if (thrownExpr) { + this.pendingChildren.push({ + node: thrownExpr, + parentHash, + edgeRole: EdgeRole.SWITCH_CASE_RESULT, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + patternBindingNames: patternBindingNames && patternBindingNames.size > 0 ? patternBindingNames : undefined, + }); + } + } + } + } + + /** + * Extract children from a switch_block_statement_group (colon syntax: case X: result). + * + * Handles: + * - case X: yield expr; (yield_statement) + * - case X: expr; (expression_statement - implicit yield) + * - case X: { ... } (block containing yield) + */ + private extractSwitchBlockStatementGroup( + groupNode: Parser.SyntaxNode, + parentHash: string, + depth: number, + casePosition: number + ): void { + // First, collect pattern binding names from all switch labels in this group + let patternBindingNames: Set | undefined; + for (const child of groupNode.children) { + if (child.type === 'switch_label') { + const labelBindings = this.extractPatternBindingNames(child); + if (labelBindings.size > 0) { + if (!patternBindingNames) { + patternBindingNames = new Set(); + } + for (const name of labelBindings) { + patternBindingNames.add(name); + } + } + } + } + + // Extract labels and results from all children + for (const child of groupNode.children) { + if (child.type === 'switch_label') { + this.extractSwitchLabelChildren(child, parentHash, depth, casePosition); + } + // Handle yield_statement (explicit yield) + else if (child.type === 'yield_statement') { + const yieldExpr = ExpressionReferenceExtractor.namedOperands(child)[0]; + if (yieldExpr) { + this.pendingChildren.push({ + node: yieldExpr, + parentHash, + edgeRole: EdgeRole.SWITCH_CASE_RESULT, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + patternBindingNames: patternBindingNames && patternBindingNames.size > 0 ? patternBindingNames : undefined, + }); + } + } + // Note: expression_statement nodes in colon-syntax switch cases are regular statements + // (e.g., someRandom.forEach(...)), NOT implicit yields. Only yield_statement produces + // a result. These expression statements are extracted by TypeMethodExtractor. + // Handle block (case X: { ... yield ... }) + else if (child.type === 'block') { + this.extractYieldFromBlock(child, parentHash, depth, casePosition, patternBindingNames); + } + } + } + + /** + * Extract children from a switch_label (case constants, patterns, guards). + * + * Handles: + * - Constant expressions (literals, enum constants) + * - Type patterns (case String s) + * - Guards (when clause) + * - Default case (no expression, just "default" keyword) + */ + private extractSwitchLabelChildren( + labelNode: Parser.SyntaxNode, + parentHash: string, + depth: number, + casePosition: number + ): void { + // Check if this is a default case (no named children and text contains "default") + if (ExpressionReferenceExtractor.namedOperands(labelNode).length === 0 && labelNode.text.includes('default')) { + // Create an IDENTIFIER_REFERENCE for the default keyword + // This ensures the default case has a SWITCH_CASE_LABEL entry + const defaultKeyword = labelNode.children.find(c => c.type === 'default'); + if (defaultKeyword) { + const builder = ExpressionReference.builder( + this.currentTypeRegistryHash, + this.currentOwnerHash, + this.currentOwnerKind, + this.currentRootContext, + ExpressionKind.IDENTIFIER_REFERENCE, + EdgeRole.SWITCH_CASE_LABEL + ); + builder.positionAndDepth(casePosition, depth + 1); + builder.parent(parentHash); + builder.classLiteralTypeName('default'); // Store 'default' as the identifier name + builder.location( + defaultKeyword.startPosition.row + 1, + defaultKeyword.startPosition.column, + defaultKeyword.endPosition.row + 1, + defaultKeyword.endPosition.column + ); + this.extractedExpressions.push(builder.build()); + } + return; + } + + for (const labelChild of ExpressionReferenceExtractor.namedOperands(labelNode)) { + if (labelChild.type === 'guard') { + // Extract guard expression (when clause) - e.g., "when s.length() > 5" + // The guard node contains the expression after 'when' + const guardExpr = ExpressionReferenceExtractor.namedOperands(labelChild)[0]; + if (guardExpr) { + this.pendingChildren.push({ + node: guardExpr, + parentHash, + edgeRole: EdgeRole.SWITCH_GUARD, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } else if (labelChild.type === 'pattern') { + // Pattern wrapper node - extract type_pattern or record_pattern inside + const typePattern = ExpressionReferenceExtractor.namedOperands(labelChild).find(c => c.type === 'type_pattern'); + const recordPattern = ExpressionReferenceExtractor.namedOperands(labelChild).find(c => c.type === 'record_pattern'); + if (typePattern) { + this.extractTypePattern(typePattern, parentHash, depth, casePosition); + } else if (recordPattern) { + this.pendingChildren.push({ + node: recordPattern, + parentHash, + edgeRole: EdgeRole.SWITCH_CASE_LABEL, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } else if (labelChild.type === 'type_pattern') { + // Direct type_pattern node (case String s) + this.extractTypePattern(labelChild, parentHash, depth, casePosition); + } else if (labelChild.type === 'record_pattern') { + // Record pattern in switch case (case Customer(String name, Address addr) ->) + this.pendingChildren.push({ + node: labelChild, + parentHash, + edgeRole: EdgeRole.SWITCH_CASE_LABEL, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } else { + // Regular case label constant (literal, identifier, etc.) + this.pendingChildren.push({ + node: labelChild, + parentHash, + edgeRole: EdgeRole.SWITCH_CASE_LABEL, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + } + + /** + * Extract type pattern from switch case (case String s -> ...). + * Extracts the pattern variable as SWITCH_TYPE_PATTERN. + * The type (String) is extracted as a type reference. + * + * Tree structure: + * type_pattern + * ├── type_identifier "String" + * └── identifier "s" + */ + private extractTypePattern( + typePatternNode: Parser.SyntaxNode, + parentHash: string, + depth: number, + casePosition: number + ): void { + // Find the pattern variable identifier (the 's' in 'String s') + const variableIdentifier = ExpressionReferenceExtractor.namedOperands(typePatternNode).find( + c => c.type === 'identifier' + ); + + if (variableIdentifier) { + this.pendingChildren.push({ + node: variableIdentifier, + parentHash, + edgeRole: EdgeRole.SWITCH_TYPE_PATTERN, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + // Extract type reference from the type node (type_identifier, generic_type, array_type, etc.) + const typeIdentifier = ExpressionReferenceExtractor.namedOperands(typePatternNode).find( + c => c.type === 'type_identifier' || c.type === 'generic_type' || c.type === 'scoped_type_identifier' || c.type === 'array_type' + ); + + if (typeIdentifier) { + const typeRefs = this.typeReferenceExtractor.extractFromSwitchTypePattern( + typeIdentifier, + this.currentTypeRegistryHash, + parentHash, // expression hash as the owner + this.currentPackageName, + casePosition // pass case position for linking with pattern binding + ); + this.extractedTypeReferences.push(...typeRefs); + } + } + + /** + * Extract pattern binding variable names from a switch label. + * Used to pass pattern variable names into scope for switch case results. + * + * @param labelNode The switch_label node to extract pattern bindings from + * @returns Set of pattern binding variable names found in the label + */ + private extractPatternBindingNames(labelNode: Parser.SyntaxNode): Set { + const names = new Set(); + + for (const labelChild of ExpressionReferenceExtractor.namedOperands(labelNode)) { + if (labelChild.type === 'pattern') { + // Pattern wrapper node - look for type_pattern or record_pattern inside + const typePattern = ExpressionReferenceExtractor.namedOperands(labelChild).find(c => c.type === 'type_pattern'); + const recordPattern = ExpressionReferenceExtractor.namedOperands(labelChild).find(c => c.type === 'record_pattern'); + if (typePattern) { + const varId = ExpressionReferenceExtractor.namedOperands(typePattern).find(c => c.type === 'identifier'); + if (varId) names.add(varId.text); + } else if (recordPattern) { + this.collectRecordPatternBindingNames(recordPattern, names); + } + } else if (labelChild.type === 'type_pattern') { + // Direct type_pattern node (case String s) + const varId = ExpressionReferenceExtractor.namedOperands(labelChild).find(c => c.type === 'identifier'); + if (varId) names.add(varId.text); + } else if (labelChild.type === 'record_pattern') { + // Record pattern in switch case + this.collectRecordPatternBindingNames(labelChild, names); + } + } + + return names; + } + + /** + * Recursively collect pattern binding names from a record pattern. + * Record patterns can be nested: case Point(int x, int y) or case Pair(Point(int x, int y), String s) + * + * AST structure: + * record_pattern + * -> identifier (type name, e.g., "Point" - NOT a binding!) + * -> record_pattern_body + * -> record_pattern_component (e.g., "int x") + * -> type (integral_type or type_identifier) + * -> identifier (binding name, e.g., "x") + * -> record_pattern_component (e.g., "int y") + * -> type + * -> identifier (binding name, e.g., "y") + */ + private collectRecordPatternBindingNames(recordPatternNode: Parser.SyntaxNode, names: Set): void { + // Find record_pattern_body which contains the actual pattern components + const body = ExpressionReferenceExtractor.namedOperands(recordPatternNode).find(c => c.type === 'record_pattern_body'); + if (body) { + for (const component of ExpressionReferenceExtractor.namedOperands(body)) { + if (component.type === 'record_pattern_component') { + // Each component has a type and an identifier (the binding variable) + const bindingId = ExpressionReferenceExtractor.namedOperands(component).find(c => c.type === 'identifier'); + if (bindingId) { + names.add(bindingId.text); + } + // Check for nested record patterns within the component + const nestedRecordPattern = ExpressionReferenceExtractor.namedOperands(component).find(c => c.type === 'record_pattern'); + if (nestedRecordPattern) { + this.collectRecordPatternBindingNames(nestedRecordPattern, names); + } + // Check for type patterns within the component + const typePattern = ExpressionReferenceExtractor.namedOperands(component).find(c => c.type === 'type_pattern'); + if (typePattern) { + const varId = ExpressionReferenceExtractor.namedOperands(typePattern).find(c => c.type === 'identifier'); + if (varId) names.add(varId.text); + } + } else if (component.type === 'record_pattern') { + // Nested record pattern directly in body (rare but possible) + this.collectRecordPatternBindingNames(component, names); + } + } + } + } + + /** + * Extract yield expressions from a block in a switch arm. + * Finds all yield_statement nodes and extracts their expressions. + * Pre-scans the block for local variable declarations to correctly classify identifiers. + */ + private extractYieldFromBlock( + blockNode: Parser.SyntaxNode, + parentHash: string, + depth: number, + casePosition: number, + patternBindingNames?: Set + ): void { + // Pre-scan block for local variable declarations to add to currentLocalVariableNames + // This ensures identifiers in yield expressions are correctly classified + const blockLocalVarNames = this.findLocalVariableNamesInBlock(blockNode); + const previousLocalVarNames = new Set(this.currentLocalVariableNames); + for (const name of blockLocalVarNames) { + this.currentLocalVariableNames.add(name); + } + + // Find yield_statement recursively (could be nested in if/else) + const findYields = (node: Parser.SyntaxNode): Parser.SyntaxNode[] => { + const yields: Parser.SyntaxNode[] = []; + if (node.type === 'yield_statement') { + yields.push(node); + } + for (const child of ExpressionReferenceExtractor.namedOperands(node)) { + yields.push(...findYields(child)); + } + return yields; + }; + + const yieldStatements = findYields(blockNode); + for (const yieldStmt of yieldStatements) { + const yieldExpr = ExpressionReferenceExtractor.namedOperands(yieldStmt)[0]; + if (yieldExpr) { + this.pendingChildren.push({ + node: yieldExpr, + parentHash, + edgeRole: EdgeRole.SWITCH_CASE_RESULT, + position: casePosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + localVariableNames: new Set(this.currentLocalVariableNames), + patternBindingNames: patternBindingNames && patternBindingNames.size > 0 ? patternBindingNames : undefined, + }); + } + } + + // Restore previous local variable names + this.currentLocalVariableNames = previousLocalVarNames; + } + + /** + * Find all local variable names declared in a block (non-recursively into nested blocks). + */ + private findLocalVariableNamesInBlock(blockNode: Parser.SyntaxNode): Set { + const names = new Set(); + + const scanForDeclarations = (node: Parser.SyntaxNode): void => { + if (node.type === 'local_variable_declaration') { + // Find variable declarators + for (const child of ExpressionReferenceExtractor.namedOperands(node)) { + if (child.type === 'variable_declarator') { + const nameNode = ExpressionReferenceExtractor.namedOperands(child).find(c => c.type === 'identifier'); + if (nameNode) { + names.add(nameNode.text); + } + } + } + } + // Don't recurse into nested blocks, lambdas, or anonymous classes + if (node.type !== 'block' && node.type !== 'lambda_expression' && + node.type !== 'class_body' && node.type !== 'anonymous_class_body') { + for (const child of ExpressionReferenceExtractor.namedOperands(node)) { + scanForDeclarations(child); + } + } + }; + + // Scan the block's direct children + for (const child of ExpressionReferenceExtractor.namedOperands(blockNode)) { + scanForDeclarations(child); + } + + return names; + } + + /** + * Queue children of a string template expression (Java 21+ preview). + * + * AST structure: + * template_expression + * ├── identifier: "STR" (processor) + * ├── .: "." + * └── string_literal + * ├── string_fragment: "Hello, " + * ├── string_interpolation + * │ ├── \{ + * │ ├── [expression] + * │ └── } + * └── string_fragment: "!" + * + * Extracts: + * - TEMPLATE_LITERAL: The full template string (as LITERAL) with placeholders + * - TEMPLATE_EMBEDDED: Each embedded expression in \{...}, with position index + * + * Additional data stored: + * - templateProcessor: The processor name (STR, FMT, RAW, or custom) + */ + private queueStringTemplateChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Find the string_literal child which contains the template + const stringLiteral = ExpressionReferenceExtractor.namedOperands(node).find(c => c.type === 'string_literal'); + if (!stringLiteral) return; + + // Create a synthetic LITERAL for the full template text + // This allows reconstruction of the template structure + this.createTemplateLiteralExpression(stringLiteral, parentHash, depth); + + // Find all string_interpolation nodes (embedded expressions) + let embeddedPosition = 0; + for (const child of stringLiteral.children) { + if (child.type === 'string_interpolation') { + // Find the actual expression inside the interpolation + // Structure: \{ + expression + } + for (const interpChild of ExpressionReferenceExtractor.namedOperands(child)) { + // Skip punctuation, find the actual expression + if (interpChild.type !== '\\{' && interpChild.type !== '}') { + this.pendingChildren.push({ + node: interpChild, + parentHash, + edgeRole: EdgeRole.TEMPLATE_EMBEDDED, + position: embeddedPosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + embeddedPosition++; + } + } + } + + /** + * Creates a synthetic LITERAL expression for the full template string. + * The literal value contains the template with \{...} placeholders preserved. + */ + private createTemplateLiteralExpression( + stringLiteral: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Get the full template text and strip quotes (like other string literals) + const rawText = stringLiteral.text; + let templateText = rawText; + + // Strip surrounding quotes: """...""" for text blocks, "..." for regular strings + if (rawText.startsWith('"""') && rawText.endsWith('"""')) { + templateText = rawText.slice(3, -3); + } else if (rawText.startsWith('"') && rawText.endsWith('"')) { + templateText = rawText.slice(1, -1); + } + + const builder = ExpressionReference.builder( + this.currentTypeRegistryHash, + this.currentOwnerHash, + this.currentOwnerKind, + this.currentRootContext, + ExpressionKind.LITERAL, + EdgeRole.TEMPLATE_LITERAL + ); + builder.parent(parentHash); + builder.positionAndDepth(0, depth + 1); + builder.literal(LiteralType.STRING, templateText); + builder.location( + stringLiteral.startPosition.row + 1, + stringLiteral.startPosition.column, + stringLiteral.endPosition.row + 1, + stringLiteral.endPosition.column + ); + + this.extractedExpressions.push(builder.build()); + } + + /** + * Queue children of a record pattern (Java 21+). + * + * AST structure: + * record_pattern + * ├── identifier: "Person" (record type name) + * └── record_pattern_body + * ├── record_pattern_component: "String n" + * │ ├── type_identifier: "String" + * │ └── identifier: "n" + * └── record_pattern (nested, for nested patterns) + * + * Extracts: + * - Type reference for the record type (Person, Employee, etc.) + * - RECORD_PATTERN_BINDING for each pattern variable binding + * - Nested RECORD_PATTERN for nested record patterns + */ + private queueRecordPatternChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Extract the record type name and create a type reference + // Handle both simple identifiers (Point) and generic types (Pair) + const recordTypeNode = ExpressionReferenceExtractor.namedOperands(node).find(c => + c.type === 'identifier' || c.type === 'generic_type' + ); + if (recordTypeNode) { + const typeRefs = this.typeReferenceExtractor.extractFromRecordPattern( + recordTypeNode, + this.currentTypeRegistryHash, + parentHash, + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + + // Find the record_pattern_body + const patternBody = ExpressionReferenceExtractor.namedOperands(node).find(c => c.type === 'record_pattern_body'); + if (!patternBody) return; + + // Extract each component from the pattern body + let position = 0; + for (const child of ExpressionReferenceExtractor.namedOperands(patternBody)) { + if (child.type === 'record_pattern_component') { + // Extract the binding variable (identifier) and its type + const bindingVar = ExpressionReferenceExtractor.namedOperands(child).find(c => c.type === 'identifier'); + const typeNode = ExpressionReferenceExtractor.namedOperands(child).find(c => + c.type === 'type_identifier' || c.type === 'integral_type' || + c.type === 'floating_point_type' || c.type === 'boolean_type' || + c.type === 'generic_type' || c.type === 'array_type' || + c.type === 'scoped_type_identifier' // For fully qualified types like java.io.Serializable + ); + + // Extract type reference for the component type (pattern binding type) + if (typeNode) { + const typeRefs = this.typeReferenceExtractor.extractFromPatternBinding( + typeNode, + this.currentTypeRegistryHash, + parentHash, + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + + // Queue the binding variable as an expression + if (bindingVar) { + // Track pattern binding name so later references are classified as PATTERN_BINDING_VARIABLE + this.currentPatternBindingNames.add(bindingVar.text); + + this.pendingChildren.push({ + node: bindingVar, + parentHash, + edgeRole: EdgeRole.RECORD_PATTERN_BINDING, + position: position, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + position++; + } else if (child.type === 'record_pattern') { + // Nested record pattern - queue it as a child + this.pendingChildren.push({ + node: child, + parentHash, + edgeRole: EdgeRole.RECORD_PATTERN_BINDING, // Nested pattern at this position + position: position, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + position++; + } + } + } + + /** + * Queue children of a lambda expression. + * + * Lambda AST structure: + * lambda_expression + * ├── identifier (single param without parens): x + * │ OR formal_parameters: (int x, int y) + * │ OR inferred_parameters: (x, y) + * ├── -> (arrow) + * └── body: expression OR block + * + * Examples: + * - x -> x * 2 (identifier param, expression body) + * - (x, y) -> x + y (inferred params, expression body) + * - (int x) -> x * 2 (formal params, expression body) + * - x -> { return x * 2; } (identifier param, block body) + * + * We extract: + * - Lambda parameters as LAMBDA_PARAMETER children + * - The body expression (with LAMBDA_BODY edge role) + * + * Note: Block bodies (statement lambdas) are not extracted as they require + * method body extraction which is not yet implemented. + */ + private queueLambdaChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + let paramPosition = 0; + let bodyPosition = 0; + + // Collect lambda parameter names for scope tracking + const lambdaParamNames = new Set(); + + // Check if there are any formal_parameters or inferred_parameters + // If so, an identifier child is the body, not a parameter + const hasParamList = ExpressionReferenceExtractor.namedOperands(node).some(child => + child.type === 'formal_parameters' || child.type === 'inferred_parameters' + ); + + // Extract lambda parameters as expression children + for (const child of ExpressionReferenceExtractor.namedOperands(node)) { + if (child.type === 'identifier' && !hasParamList) { + // Single parameter without parentheses: x -> x * 2 + // Only treat as parameter if there's no formal_parameters or inferred_parameters + lambdaParamNames.add(child.text); + this.pendingChildren.push({ + node: child, + parentHash, + edgeRole: EdgeRole.LAMBDA_PARAMETER, + position: paramPosition++, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } else if (child.type === 'inferred_parameters') { + // Inferred parameters: (x, y) -> x + y + for (const param of ExpressionReferenceExtractor.namedOperands(child)) { + if (param.type === 'identifier') { + lambdaParamNames.add(param.text); + this.pendingChildren.push({ + node: param, + parentHash, + edgeRole: EdgeRole.LAMBDA_PARAMETER, + position: paramPosition++, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + } else if (child.type === 'formal_parameters') { + // Formal parameters: (int x, int y) -> x + y + // Also handles: (String s) -> s.length(), (var x) -> x * 2 + let typePosition = 0; + for (const param of ExpressionReferenceExtractor.namedOperands(child)) { + if (param.type === 'formal_parameter' || param.type === 'spread_parameter') { + // Extract the parameter type reference + const typeNode = ExpressionReferenceExtractor.namedOperands(param).find(n => + n.type === 'type_identifier' || + n.type === 'integral_type' || + n.type === 'floating_point_type' || + n.type === 'boolean_type' || + n.type === 'generic_type' || + n.type === 'array_type' || + n.type === 'scoped_type_identifier' + ); + if (typeNode) { + const typeRefs = this.typeReferenceExtractor.extractFromLambdaParameter( + typeNode, + this.currentTypeRegistryHash, + parentHash, + this.currentPackageName, + typePosition++ + ); + this.extractedTypeReferences.push(...typeRefs); + } + + // Extract the parameter name + const nameNode = ExpressionReferenceExtractor.namedOperands(param).find(n => n.type === 'identifier'); + if (nameNode) { + lambdaParamNames.add(nameNode.text); + this.pendingChildren.push({ + node: nameNode, + parentHash, + edgeRole: EdgeRole.LAMBDA_PARAMETER, + position: paramPosition++, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + } + } + } + } + + // Find and queue the lambda body + // If hasParamList is true, an identifier can be the body (e.g., () -> capturedValue) + // If hasParamList is false, the identifier is the parameter, so body is something else + const bodyNode = ExpressionReferenceExtractor.namedOperands(node).find(child => + child.type !== 'formal_parameters' && + child.type !== 'inferred_parameters' && + (hasParamList || child.type !== 'identifier') + ); + + if (bodyNode) { + // For expression bodies, queue the expression directly + // For block bodies, we skip (requires method body extraction) + if (bodyNode.type !== 'block') { + // Merge current lambda params with any outer lambda params + const mergedLambdaParams = new Set([...this.currentLambdaParamNames, ...lambdaParamNames]); + this.pendingChildren.push({ + node: bodyNode, + parentHash, + edgeRole: EdgeRole.LAMBDA_BODY, + position: bodyPosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + lambdaParamNames: mergedLambdaParams, + }); + } + } + } + + /** + * Queue children of an object creation expression and extract type reference. + * + * Handles: + * - Simple: new Object() + * - Generic: new ArrayList() + * - Diamond: new ArrayList<>() + * - Qualified inner: outer.new Inner() + * - Anonymous: new Runnable() { ... } + * - Generic constructor: new GenericCtor() + * + * Children: + * - ENCLOSING_INSTANCE: qualifier in outer.new Inner() + * - ARGUMENT: constructor arguments + */ + private queueObjectCreationChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Check for qualified inner class creation (outer.new Inner) + // First named child would be the enclosing instance (not type-related) + const firstNamedChild = ExpressionReferenceExtractor.namedOperands(node)[0]; + if (firstNamedChild && + firstNamedChild.type !== 'type_identifier' && + firstNamedChild.type !== 'generic_type' && + firstNamedChild.type !== 'scoped_type_identifier' && + firstNamedChild.type !== 'type_arguments' && + firstNamedChild.type !== 'annotated_type') { + // This is the enclosing instance (qualifier) + this.pendingChildren.push({ + node: firstNamedChild, + parentHash, + edgeRole: EdgeRole.ENCLOSING_INSTANCE, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + + // Queue constructor arguments + const argsNode = node.childForFieldName('arguments'); + if (argsNode) { + let position = 0; + for (const child of ExpressionReferenceExtractor.namedOperands(argsNode)) { + this.pendingChildren.push({ + node: child, + parentHash, + edgeRole: EdgeRole.ARGUMENT, + position, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + position++; + } + } + + // Extract type reference for the type being instantiated + const typeNode = node.childForFieldName('type'); + if (typeNode) { + const typeRefs = this.typeReferenceExtractor.extractFromObjectCreation( + typeNode, + this.currentTypeRegistryHash, + parentHash, // expression hash as the owner + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + + // Handle generic constructor type arguments (new GenericCtor()) + // These are separate from the type's own type arguments + this.extractTypeArgumentsIfPresent(node, parentHash); + + // Extract type-use annotations (new @TA Object()) + const annotations = this.annotationExtractor.extractFromCreationExpression( + node, + parentHash, + this.currentTypeRegistryHash + ); + this.extractedAnnotations.push(...annotations); + + // Collect anonymous class info if this is an anonymous class creation + const classBodyNode = node.children.find(c => c.type === 'class_body'); + if (classBodyNode && typeNode) { + const baseTypeName = this.extractBaseTypeName(typeNode); + const anonymousTypeHash = this.generateAnonymousTypeHash(node); + this.extractedAnonymousClasses.push({ + classBodyNode, + creationNode: node, + expressionHash: parentHash, + anonymousTypeHash, + baseTypeName, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + packageName: this.currentPackageName, + importMap: new Map(this.currentImportMap), + hasStarImports: this.currentHasStarImports, + }); + } + } + + /** + * Queue arguments for explicit constructor invocation (this() or super()). + * Also extracts type arguments if present (e.g., super(x, "value")). + * + * Example: this(value, null) -> queue 'value' and 'null' as ARGUMENT children + * Example: super(items.size(), items) -> queue method invocation and identifier as ARGUMENT children + * Example: super("test") -> extract String as type reference + */ + private queueConstructorInvocationArguments( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Get the arguments from the argument_list field + const argsNode = node.childForFieldName('arguments'); + if (argsNode) { + let position = 0; + for (const child of ExpressionReferenceExtractor.namedOperands(argsNode)) { + this.pendingChildren.push({ + node: child, + parentHash, + edgeRole: EdgeRole.ARGUMENT, + position, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + position++; + } + } + + // Extract type arguments if present (e.g., in super(x, y)) + this.extractTypeArgumentsIfPresent(node, parentHash); + } + + /** + * Queue the qualifier expression of a method reference. + * The qualifier is the expression before the :: token. + * + * Examples: + * - String::length -> qualifier is type identifier "String" (not queued - not an expression) + * - prefix::concat -> qualifier is identifier "prefix" (queued) + * - this::method -> qualifier is "this" (queued) + * - super::method -> qualifier is "super" (queued) + * - helpers[0]::process -> qualifier is array access (queued) + * - getHelper()::process -> qualifier is method invocation (queued) + * - (obj)::method -> qualifier is parenthesized expression (queued) + * - new Helper()::process -> qualifier is object creation (queued) + * + * Note: Type identifiers (String, Integer) and array types (int[]) are NOT expression nodes + * and are handled differently - they produce type references, not expression children. + * This is modular: if we add support for cast_expression later, the qualifier will + * automatically be extracted when it appears in a method reference like ((Helper) obj)::process + */ + private queueMethodReferenceQualifier( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Find the qualifier - it's the first child before :: + // Structure: [qualifier] :: [type_arguments?] [method_name | new] + const colonColonIndex = node.children.findIndex(c => c.type === '::'); + if (colonColonIndex <= 0) { + return; // No valid qualifier found + } + + const qualifier = node.children[0]; + if (!qualifier) { + return; + } + + // Only queue expression-type qualifiers, not type identifiers + // Type identifiers (for static refs) are handled by type reference extraction + const expressionQualifierTypes = [ + 'identifier', // variable reference: prefix::concat + 'this', // this::method + 'super', // super::method + 'field_access', // qualified this: Outer.this::method, or this.field::method + 'array_access', // array element: helpers[0]::process + 'method_invocation', // method result: getHelper()::process + 'parenthesized_expression', // parenthesized: (obj)::method, ((Helper) obj)::method + 'object_creation_expression', // new expression: new Helper()::process + // Future: These will work automatically once we add their support: + // 'cast_expression', // cast: ((Type) obj)::method - currently returns UNKNOWN + // 'lambda_expression', // rare but valid: ((Supplier) (() -> new Helper())).get()::process + ]; + + if (expressionQualifierTypes.includes(qualifier.type)) { + this.pendingChildren.push({ + node: qualifier, + parentHash, + edgeRole: EdgeRole.QUALIFIER, + position: 0, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + + // For field_access qualifiers that represent type access (e.g., Type1.Type2::staticMethod), + // also extract type references for consistency with type-based qualifiers. + // The expression extraction captures the structure, but we also need type references + // to link to the actual types in the type-references output. + if (qualifier.type === 'field_access') { + this.extractTypeReferencesFromFieldAccessQualifier(qualifier, parentHash); + } + } + + // Extract type references for type-based qualifiers + // These are not expression nodes but type nodes: String::valueOf, String[]::new, List::new + const typeQualifierTypes = [ + 'type_identifier', // Simple types: String::valueOf, Integer::sum + 'array_type', // Array types: String[]::new, int[]::new + 'generic_type', // Generic types: List::new, Map::new + 'scoped_type_identifier', // Scoped types: Map.Entry::comparingByKey, Outer.Inner::new + ]; + + if (typeQualifierTypes.includes(qualifier.type)) { + // For qualified super references (Child.super::method, Outer.Inner.super::method), + // extract the qualifying type(s) but not 'super' itself + let qualifierToExtract = qualifier; + if (qualifier.type === 'scoped_type_identifier') { + const lastChild = qualifier.children[qualifier.children.length - 1]; + if (lastChild && lastChild.type === 'type_identifier' && lastChild.text === 'super') { + // Extract the qualifying type (everything before .super) + // For Child.super, extract Child; for Outer.Inner.super, extract Outer.Inner + const qualifyingType = qualifier.children.find( + c => c.type === 'type_identifier' || c.type === 'scoped_type_identifier' + ); + if (qualifyingType) { + qualifierToExtract = qualifyingType; + } else { + qualifierToExtract = null as any; // No qualifying type to extract + } + } + } + + if (qualifierToExtract) { + const typeRefs = this.typeReferenceExtractor.extractFromMethodReferenceQualifier( + qualifierToExtract, + this.currentTypeRegistryHash, + parentHash, + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + } + + // Extract type arguments if present (e.g., Collections::emptyList) + const typeArgs = node.children.find(c => c.type === 'type_arguments'); + if (typeArgs) { + const typeRefs = this.typeReferenceExtractor.extractFromMethodTypeArguments( + typeArgs, + this.currentTypeRegistryHash, + parentHash, + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + } + + /** + * Extracts type arguments from a node's 'type_arguments' field if present. + * Reusable helper for method invocation, object creation, and constructor invocation. + * + * Example: in Collections.emptyList() or super("test") + * + * @param node The expression node that may have type_arguments + * @param parentHash The expression hash to link type references to + * @param useMethodExtractor Whether to use extractFromMethodTypeArguments (true) or extractFromConstructorTypeArguments (false) + */ + private extractTypeArgumentsIfPresent( + node: Parser.SyntaxNode, + parentHash: string, + useMethodExtractor: boolean = false + ): void { + const typeArgs = node.childForFieldName('type_arguments'); + if (typeArgs) { + const typeRefs = useMethodExtractor + ? this.typeReferenceExtractor.extractFromMethodTypeArguments( + typeArgs, + this.currentTypeRegistryHash, + parentHash, + this.currentPackageName + ) + : this.typeReferenceExtractor.extractFromConstructorTypeArguments( + typeArgs, + this.currentTypeRegistryHash, + parentHash, + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + } + + /** + * Generates a consistent hash for an anonymous type based on its location. + * This hash is used both in the expression and when registering the type. + */ + private generateAnonymousTypeHash(node: Parser.SyntaxNode): string { + const hashInput = `${this.currentTypeRegistryHash}_${node.startPosition.row}_${node.startPosition.column}`; + return EntityUtils.generateEntityHash('ANONYMOUS_TYPE', hashInput); + } + + /** + * Extracts the base type name from a type node (for anonymous class info) + */ + private extractBaseTypeName(typeNode: Parser.SyntaxNode): string { + if (typeNode.type === 'type_identifier') { + return typeNode.text; + } else if (typeNode.type === 'generic_type') { + const baseType = typeNode.children.find(c => c.type === 'type_identifier'); + return baseType?.text ?? typeNode.text; + } else if (typeNode.type === 'scoped_type_identifier') { + return typeNode.text; + } + return typeNode.text; + } + + /** + * Queue children of an array creation expression and extract type reference. + * + * Handles: + * - new int[3] - primitive array with size + * - new String[5] - reference array with size + * - new int[] { 1, 2, 3 } - array with initializer + * - new int[2][3] - multi-dimensional + * + * Children: + * - ARRAY_DIMENSION: size expressions (3 in new int[3]) + * - ARRAY_ELEMENT: elements in initializer (via nested array_initializer) + */ + private queueArrayCreationChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + // Queue dimension size expressions (new int[size1][size2]) + let dimPosition = 0; + for (const child of node.children) { + if (child.type === 'dimensions_expr') { + // The size expression is inside dimensions_expr + for (const dimChild of ExpressionReferenceExtractor.namedOperands(child)) { + this.pendingChildren.push({ + node: dimChild, + parentHash, + edgeRole: EdgeRole.ARRAY_DIMENSION, + position: dimPosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + } + dimPosition++; + } + } + + // Queue array initializer if present (new int[] { 1, 2, 3 }) + const initNode = node.childForFieldName('value'); + if (initNode && initNode.type === 'array_initializer') { + let elemPosition = 0; + for (const elem of ExpressionReferenceExtractor.namedOperands(initNode)) { + this.pendingChildren.push({ + node: elem, + parentHash, + edgeRole: EdgeRole.ARRAY_ELEMENT, + position: elemPosition, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + elemPosition++; + } + } + + // Extract type reference for the element type + const typeNode = node.childForFieldName('type'); + if (typeNode) { + const typeRefs = this.typeReferenceExtractor.extractFromArrayCreation( + typeNode, + this.currentTypeRegistryHash, + parentHash, + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + + // Extract type-use annotations (new @TA int[3]) + const annotations = this.annotationExtractor.extractFromCreationExpression( + node, + parentHash, + this.currentTypeRegistryHash + ); + this.extractedAnnotations.push(...annotations); + } + + /** + * Queue children of a standalone array initializer ({ 1, 2, 3 }). + * + * Children: + * - ARRAY_ELEMENT: each element in the initializer + */ + private queueArrayInitializerChildren( + node: Parser.SyntaxNode, + parentHash: string, + depth: number + ): void { + let position = 0; + for (const elem of ExpressionReferenceExtractor.namedOperands(node)) { + this.pendingChildren.push({ + node: elem, + parentHash, + edgeRole: EdgeRole.ARRAY_ELEMENT, + position, + depth: depth + 1, + typeRegistryHash: this.currentTypeRegistryHash, + ownerHash: this.currentOwnerHash, + ownerKind: this.currentOwnerKind, + rootContext: this.currentRootContext, + }); + position++; + } + } + + /** + * Parses a ternary expression to extract condition, trueExpr, and falseExpr. + * + * Read through the grammar's `condition` / `consequence` / `alternative` fields, never by + * position. `line_comment` and `block_comment` are NAMED nodes in tree-sitter-java, so any + * comment inside the ternary shifts `namedChildren` — an end-of-line `//` before the `?` + * used to hand back the condition, the true branch and the comment as the three operands, + * which emitted the true branch under TERNARY_FALSE and dropped the false branch entirely. + * A field read is immune to the shift because the grammar assigns the field, not the index. + */ + /** + * The named children of a node with comments removed. + * + * tree-sitter models a comment as a NAMED child, so any read that indexes into namedChildren + * shifts when a comment appears. That is not a corner case: `return /* c *\/ f();`, + * `if (/* c *\/ cond)`, `throw /* c *\/ new E()` and `o /* c *\/ instanceof T t` each moved the + * operand out of the slot being read, and the expression was then dropped entirely - a call + * site with no row, which nothing downstream can distinguish from code that makes no call. + * + * A comment is never an operand in any position this extractor reads, so filtering here is + * always correct and is applied wherever named children are indexed. + */ + private static namedOperands(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + return node.namedChildren.filter( + c => c.type !== 'line_comment' && c.type !== 'block_comment' && c.type !== 'comment' + ); + } + + private parseTernaryExpression(node: Parser.SyntaxNode): { + condition: Parser.SyntaxNode; + trueExpr: Parser.SyntaxNode; + falseExpr: Parser.SyntaxNode; + } { + // The grammar labels the three operands `condition`, `consequence` and `alternative`. + // + // Reading namedChildren[0..2] positionally instead assumed the operands are the only named + // children, and a comment is one. tree-sitter attaches a comment to the field it follows, so + // `c // \n ? a() : b()` yields named children [c, comment, a(), b()]: the true branch was read + // as the comment, `a()` was emitted under TERNARY_FALSE, and `b()` was never read at all. + // + // A field can hold several children for that reason, so the operand is the first child of the + // field that is not a comment rather than simply the first. + const operandOfField = (fieldName: string): Parser.SyntaxNode | null => { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child || !child.isNamed) continue; + if (node.fieldNameForChild(i) !== fieldName) continue; + if (child.type === 'line_comment' || child.type === 'block_comment' || child.type === 'comment') continue; + return child; + } + return null; + }; + + const condition = operandOfField('condition'); + const trueExpr = operandOfField('consequence'); + const falseExpr = operandOfField('alternative'); + + if (condition && trueExpr && falseExpr) { + return { condition, trueExpr, falseExpr }; + } + + // Fallback for a malformed ternary, where a field may be absent entirely. + const named = ExpressionReferenceExtractor.namedOperands(node).filter( + c => c.type !== 'line_comment' && c.type !== 'block_comment' && c.type !== 'comment' + ); + return { + condition: condition ?? named[0] ?? node, + trueExpr: trueExpr ?? named[1] ?? node, + falseExpr: falseExpr ?? named[2] ?? node, + }; + } + + /** + * Determines the ExpressionKind from a tree-sitter node + * Currently only handles LITERAL types + */ + private determineExpressionKind(node: Parser.SyntaxNode): ExpressionKind { + switch (node.type) { + // Integer literals + case 'decimal_integer_literal': + case 'hex_integer_literal': + case 'octal_integer_literal': + case 'binary_integer_literal': + return ExpressionKind.LITERAL; + + // Floating point literals + case 'decimal_floating_point_literal': + case 'hex_floating_point_literal': + return ExpressionKind.LITERAL; + + // String literals + case 'string_literal': + return ExpressionKind.LITERAL; + + // Character literal + case 'character_literal': + return ExpressionKind.LITERAL; + + // Boolean literals + case 'true': + case 'false': + return ExpressionKind.LITERAL; + + // Null literal + case 'null_literal': + return ExpressionKind.LITERAL; + + // Class literal (String.class, int.class) + case 'class_literal': + return ExpressionKind.CLASS_LITERAL; + + // Unary expressions + case 'unary_expression': + case 'update_expression': // ++x, x++, --x, x-- + return ExpressionKind.UNARY_EXPRESSION; + + // Binary expressions + case 'binary_expression': + return ExpressionKind.BINARY_EXPRESSION; + + // Ternary expressions + case 'ternary_expression': + return ExpressionKind.TERNARY_EXPRESSION; + + // Parenthesized expressions + case 'parenthesized_expression': + return ExpressionKind.PARENTHESIZED; + + // This reference + case 'this': + return ExpressionKind.THIS_REFERENCE; + + // Super reference + case 'super': + return ExpressionKind.SUPER_REFERENCE; + + // Simple identifier (variable/field/constant name without dots) + case 'identifier': + return ExpressionKind.IDENTIFIER_REFERENCE; + + // Field access - all field_access nodes are treated as FIELD_ACCESS + // The object part (qualifier) will be recursively extracted as a child expression + case 'field_access': + return ExpressionKind.FIELD_ACCESS; + + // Method invocation + case 'method_invocation': + return ExpressionKind.METHOD_INVOCATION; + + // Instanceof expression - check for pattern variable (Java 16+) + case 'instanceof_expression': + return this.determineInstanceofExpressionKind(node); + + // Object creation expression + case 'object_creation_expression': + // Distinguish between regular object creation and anonymous class + // Anonymous classes have a class_body child + // NOTE: Anonymous class creation requires anonymousTypeHash which needs + // separate type registry entry - skip for now and return UNKNOWN + if (node.children.some(c => c.type === 'class_body')) { + return ExpressionKind.ANONYMOUS_CLASS_CREATION; + } + return ExpressionKind.OBJECT_CREATION; + + // Array creation expression (new int[3], new String[] { ... }) + case 'array_creation_expression': + return ExpressionKind.ARRAY_CREATION; + + // Array initializer without explicit new ({ 1, 2, 3 }) + case 'array_initializer': + return ExpressionKind.ARRAY_INITIALIZER; + + // Explicit constructor invocation (this() or super() in constructor body) + case 'explicit_constructor_invocation': + return ExpressionKind.CONSTRUCTOR_INVOCATION; + + // Method reference (Type::method, obj::method, Type::new) + case 'method_reference': + return ExpressionKind.METHOD_REFERENCE; + + // Assignment expression (x = y, x += y, etc.) + case 'assignment_expression': + return this.determineAssignmentExpressionKind(node); + + // Cast expression ((Type) expr) + case 'cast_expression': + return ExpressionKind.CAST_EXPRESSION; + + // Array access (arr[index]) + case 'array_access': + return ExpressionKind.ARRAY_ACCESS; + + // Switch expression (Java 14+) + case 'switch_expression': + return ExpressionKind.SWITCH_EXPRESSION; + + // String template (Java 21+ preview) + case 'template_expression': + return ExpressionKind.STRING_TEMPLATE; + + // Record pattern (Java 21+) - used in instanceof and switch + case 'record_pattern': + return ExpressionKind.RECORD_PATTERN; + + // Lambda expression (x -> x * 2, (a, b) -> a + b) + case 'lambda_expression': + return ExpressionKind.LAMBDA_EXPRESSION; + + default: + return ExpressionKind.UNKNOWN; + } + } + + /** + * Determines if an assignment expression is simple (=) or compound (+=, -=, etc.) + */ + private determineAssignmentExpressionKind(node: Parser.SyntaxNode): ExpressionKind { + const operator = this.extractAssignmentOperator(node); + if (operator === '=') { + return ExpressionKind.ASSIGNMENT_EXPRESSION; + } + return ExpressionKind.COMPOUND_ASSIGNMENT; + } + + /** + * Determines if an instanceof expression is basic, has a pattern variable, or uses a record pattern. + * + * Basic: obj instanceof String -> INSTANCEOF_EXPRESSION + * Pattern: obj instanceof String s -> INSTANCEOF_PATTERN + * Record: obj instanceof Person(String n) -> INSTANCEOF_EXPRESSION (record_pattern is a child) + * + * Note: When a record_pattern is present, it appears in the [pattern] field. + * The record_pattern itself is extracted as a separate RECORD_PATTERN expression. + */ + private determineInstanceofExpressionKind(node: Parser.SyntaxNode): ExpressionKind { + // instanceof_expression structure varies: + // - Basic: [left] instanceof [type] + // - Pattern (Java 16+): [left] instanceof [type] [identifier] + // - Record pattern (Java 21+): [left] instanceof [pattern: record_pattern] + const namedChildren = ExpressionReferenceExtractor.namedOperands(node); + + // Check for record_pattern in the pattern field + const patternNode = node.childForFieldName('pattern'); + if (patternNode && patternNode.type === 'record_pattern') { + // Record pattern is handled separately - instanceof stays INSTANCEOF_EXPRESSION + return ExpressionKind.INSTANCEOF_EXPRESSION; + } + + // If there are 3 or more named children, the 3rd is the pattern variable + if (namedChildren.length >= 3) { + const thirdChild = namedChildren[2]; + // The pattern variable is an identifier + if (thirdChild && thirdChild.type === 'identifier') { + return ExpressionKind.INSTANCEOF_PATTERN; + } + } + + return ExpressionKind.INSTANCEOF_EXPRESSION; + } + + /** + * Extracts the operator from an assignment expression + */ + private extractAssignmentOperator(node: Parser.SyntaxNode): string { + // Assignment expression structure: left operator right + // Operators: =, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=, >>>= + for (const child of node.children) { + if (!child.isNamed) { + const text = child.text.trim(); + if (text.endsWith('=') && text.length >= 1) { + return text; + } + } + } + return '='; + } + + /** + * Adds kind-specific data to the builder + * Currently only handles LITERAL + */ + private addKindSpecificData( + builder: ReturnType, + node: Parser.SyntaxNode, + kind: ExpressionKind, + edgeRole: EdgeRole + ): void { + if (kind === ExpressionKind.LITERAL) { + this.addLiteralData(builder, node); + } else if (kind === ExpressionKind.CLASS_LITERAL) { + this.addClassLiteralData(builder, node); + } else if (kind === ExpressionKind.UNARY_EXPRESSION) { + this.addUnaryData(builder, node); + } else if (kind === ExpressionKind.BINARY_EXPRESSION) { + this.addBinaryData(builder, node); + } else if (kind === ExpressionKind.IDENTIFIER_REFERENCE) { + this.addIdentifierReferenceData(builder, node, edgeRole); + } else if (kind === ExpressionKind.FIELD_ACCESS) { + this.addFieldAccessData(builder, node); + } else if (kind === ExpressionKind.METHOD_INVOCATION) { + this.addMethodInvocationData(builder, node); + } else if (kind === ExpressionKind.OBJECT_CREATION || kind === ExpressionKind.ANONYMOUS_CLASS_CREATION) { + this.addObjectCreationData(builder, node); + } else if (kind === ExpressionKind.CONSTRUCTOR_INVOCATION) { + this.addConstructorInvocationData(builder, node); + } else if (kind === ExpressionKind.METHOD_REFERENCE) { + this.addMethodReferenceData(builder, node); + } else if (kind === ExpressionKind.ASSIGNMENT_EXPRESSION || kind === ExpressionKind.COMPOUND_ASSIGNMENT) { + this.addAssignmentData(builder, node); + } else if (kind === ExpressionKind.STRING_TEMPLATE) { + this.addStringTemplateData(builder, node); + } else if (kind === ExpressionKind.RECORD_PATTERN) { + this.addRecordPatternData(builder, node); + } else if (kind === ExpressionKind.LAMBDA_EXPRESSION) { + this.addLambdaData(builder, node); + } + // THIS_REFERENCE, SUPER_REFERENCE, TERNARY_EXPRESSION, INSTANCEOF_EXPRESSION and PARENTHESIZED have no additional data fields - just structure + } + + /** + * Adds literal-specific data (literalType and literalValue) + */ + private addLiteralData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + const literalType = this.determineLiteralType(node); + const literalValue = this.extractLiteralValue(node, literalType); + builder.literal(literalType, literalValue); + } + + /** + * Extracts the actual value from a literal node, stripping quotes for strings/chars + */ + private extractLiteralValue(node: Parser.SyntaxNode, literalType: LiteralType): string { + const text = node.text; + + switch (literalType) { + case LiteralType.STRING: + // Strip surrounding double quotes: "value" -> value + if (text.startsWith('"') && text.endsWith('"')) { + return text.slice(1, -1); + } + return text; + + case LiteralType.TEXT_BLOCK: + // Strip surrounding triple quotes: """value""" -> value + if (text.startsWith('"""') && text.endsWith('"""')) { + return text.slice(3, -3); + } + return text; + + case LiteralType.CHARACTER: + // Strip surrounding single quotes: 'a' -> a + if (text.startsWith("'") && text.endsWith("'")) { + return text.slice(1, -1); + } + return text; + + default: + // For numbers, booleans, null - return as-is + return text; + } + } + + /** + * Determines the LiteralType from a tree-sitter node + */ + private determineLiteralType(node: Parser.SyntaxNode): LiteralType { + const text = node.text; + + switch (node.type) { + case 'decimal_integer_literal': + case 'hex_integer_literal': + case 'octal_integer_literal': + case 'binary_integer_literal': + // Check for L/l suffix for LONG + if (text.endsWith('L') || text.endsWith('l')) { + return LiteralType.LONG; + } + return LiteralType.INTEGER; + + case 'decimal_floating_point_literal': + case 'hex_floating_point_literal': + // Check for f/F suffix for FLOAT, otherwise DOUBLE + if (text.endsWith('f') || text.endsWith('F')) { + return LiteralType.FLOAT; + } + return LiteralType.DOUBLE; + + case 'string_literal': + // Check for text block (triple quotes) + if (text.startsWith('"""')) { + return LiteralType.TEXT_BLOCK; + } + return LiteralType.STRING; + + case 'character_literal': + return LiteralType.CHARACTER; + + case 'true': + case 'false': + return LiteralType.BOOLEAN; + + case 'null_literal': + return LiteralType.NULL; + + default: + return LiteralType.STRING; // Fallback + } + } + + /** + * Adds class literal specific data (the type name) + * For String.class -> stores "String" + * For int.class -> stores "int" + */ + private addClassLiteralData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + // class_literal structure: type + "." + "class" + // Extract the type part (everything before .class) + const typeName = this.extractClassLiteralTypeName(node); + // Store in literalValue field using dedicated method + builder.classLiteralTypeName(typeName); + + // Extract base type (remove array brackets) for qualified name resolution + // e.g., "String[]" -> "String", "int[][]" -> "int" + const baseType = this.extractBaseType(typeName); + + // Resolve potential qualified name for the base type + const { potentialQualifiedName, isAmbiguous } = resolveTypeQualifiedName( + baseType, + this.currentPackageName, + this.currentImportMap, + this.currentHasStarImports + ); + + if (potentialQualifiedName) { + builder.qualifiedName(potentialQualifiedName, isAmbiguous); + } + } + + /** + * Extracts the base type from a type name by removing array brackets + */ + private extractBaseType(typeName: string): string { + // Remove all array dimension brackets + return typeName.replace(/\[\]/g, '').trim(); + } + + /** + * Extracts the type name from a class literal node + */ + private extractClassLiteralTypeName(node: Parser.SyntaxNode): string { + // The class_literal node contains the type as first child + // e.g., "String.class" -> type_identifier "String" + // e.g., "int.class" -> integral_type "int" + for (const child of node.children) { + // Skip the "." and "class" tokens + if (child.type !== '.' && child.type !== 'class') { + return child.text; + } + } + // Fallback: extract from full text by removing ".class" + const text = node.text; + if (text.endsWith('.class')) { + return text.slice(0, -6); + } + return text; + } + + /** + * Adds unary expression data (operator, fixity) + */ + private addUnaryData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + const { operator, fixity } = this.parseUnaryExpression(node); + builder.operator(operator); + builder.unary(fixity); + } + + /** + * Parses a unary expression to extract operator, fixity, and operand + */ + private parseUnaryExpression(node: Parser.SyntaxNode): { + operator: string; + fixity: UnaryFixity; + operand: Parser.SyntaxNode; + } { + // unary_expression can be: + // - prefix: operator + operand (e.g., -x, !x, ++x, --x) + // - postfix: operand + operator (e.g., x++, x--) + // In tree-sitter Java, update_expression handles ++/-- + + const children = node.children.filter(c => !c.type.match(/^\s*$/)); + + if (children.length >= 2) { + const first = children[0]!; + const last = children[children.length - 1]!; + + // Check if first child is operator (prefix) + if (this.isUnaryOperator(first.type) || this.isUnaryOperator(first.text)) { + return { + operator: first.text, + fixity: UnaryFixity.PREFIX, + operand: last, + }; + } + + // Check if last child is operator (postfix) + if (this.isUnaryOperator(last.type) || this.isUnaryOperator(last.text)) { + return { + operator: last.text, + fixity: UnaryFixity.POSTFIX, + operand: first, + }; + } + } + + // Fallback: use named children + const operand = ExpressionReferenceExtractor.namedOperands(node)[0]; + const operatorText = node.children.find(c => this.isUnaryOperator(c.text))?.text || '?'; + + return { + operator: operatorText, + fixity: UnaryFixity.PREFIX, + operand: operand ?? node, + }; + } + + private isUnaryOperator(text: string): boolean { + return ['-', '+', '!', '~', '++', '--'].includes(text); + } + + /** + * Adds identifier reference data (simple name without dots) + * For IDENTIFIER_REFERENCE like "a", "DEFAULT_TIMEOUT", "count" + * + * We store the identifier name and mark it as potentially referencing a field or type. + * Without full semantic analysis, we can't always determine if it's a local variable, + * parameter, or field - but in field initializer context, it's likely a field. + * + * NOTE: We intentionally do NOT set potentialQualifiedName here because: + * - We can't resolve a simple name to a qualified name without knowing all fields in scope + * - The identifier could be from the same class, inherited, or even a local variable + * - Linking to field hashes would require a second pass after field extraction + */ + private addIdentifierReferenceData( + builder: ReturnType, + node: Parser.SyntaxNode, + edgeRole: EdgeRole + ): void { + // Store the identifier name in literalValue field + const identifierName = node.text; + builder.classLiteralTypeName(identifierName); + + // Pattern binding variables (Java 16+) get special classification + // - RECORD_PATTERN_BINDING: variables in record patterns like Point(int x, int y) + // - PATTERN_VARIABLE: variables in instanceof patterns like obj instanceof String s + // - SWITCH_TYPE_PATTERN: variables in switch type patterns like case String s + if (edgeRole === EdgeRole.RECORD_PATTERN_BINDING || + edgeRole === EdgeRole.PATTERN_VARIABLE || + edgeRole === EdgeRole.SWITCH_TYPE_PATTERN) { + builder.referencesEntity(ReferencedEntityKind.PATTERN_BINDING); + return; + } + + // Lambda parameter declarations (x -> ..., (a, b) -> ...) + if (edgeRole === EdgeRole.LAMBDA_PARAMETER) { + builder.referencesEntity(ReferencedEntityKind.LAMBDA_PARAMETER); + return; + } + + // Lambda parameter usage (identifier in lambda body matching a lambda param) + if (this.currentLambdaParamNames.has(identifierName)) { + builder.referencesEntity(ReferencedEntityKind.LAMBDA_PARAMETER); + return; + } + + // Method parameter references (identifiers matching method param names) + if (this.currentMethodParamNames.has(identifierName)) { + builder.referencesEntity(ReferencedEntityKind.PARAMETER); + return; + } + + // Local variable references (identifiers matching local variable names in scope) + if (this.currentLocalVariableNames.has(identifierName)) { + builder.referencesEntity(ReferencedEntityKind.LOCAL_VARIABLE); + return; + } + + // Pattern binding variable usage (identifier matching a pattern variable from instanceof/switch) + if (this.currentPatternBindingNames.has(identifierName) + || this.isInsidePatternBindingScope(identifierName, node)) { + builder.referencesEntity(ReferencedEntityKind.PATTERN_BINDING_VARIABLE); + return; + } + + // Use shared naming convention logic for other identifiers + this.classifyByNamingConvention(builder, identifierName); + } + + /** + * Records the pattern bindings declared anywhere in the method body about to be extracted. + * + * Set once per method, before its statements are walked, because a binding's declaration and + * its uses are in different statements and therefore different extraction calls. + */ + setMethodPatternBindings(bindings: Array<{ name: string; startIndex: number; endIndex: number }>): void { + this.methodPatternBindings = bindings; + } + + /** + * True when this identifier falls inside the statement that declares a binding of the same name. + * + * The range test is what keeps a field of the same name correct outside that statement. It is + * narrower than Java's own rule, which extends a binding to wherever the pattern definitely + * matched - `if (!(o instanceof T a)) return; a.foo();` puts the binding in scope after the if. + * Such a use falls back to the naming convention, which is the behaviour before any of this, + * so the approximation only declines to improve a case rather than making one worse. + */ + private isInsidePatternBindingScope(identifierName: string, node: Parser.SyntaxNode): boolean { + return this.methodPatternBindings.some( + binding => binding.name === identifierName + && node.startIndex >= binding.startIndex + && node.endIndex <= binding.endIndex + ); + } + + /** + * Adds object creation data (type name and references entity kind) + * For new ClassName(), new ArrayList(), new Runnable() { ... } + */ + private addObjectCreationData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + // Mark as referencing a constructor + builder.referencesEntity(ReferencedEntityKind.CONSTRUCTOR); + + // Extract the type name + const typeNode = node.childForFieldName('type'); + if (typeNode) { + // Get simple type name (for generic_type, get the base type) + let typeName = typeNode.text; + if (typeNode.type === 'generic_type') { + const baseType = typeNode.children.find(c => c.type === 'type_identifier'); + if (baseType) { + typeName = baseType.text; + } + } else if (typeNode.type === 'scoped_type_identifier') { + // For qualified names like java.util.Date, get the full name + typeName = typeNode.text; + } + + // Store type name (using classLiteralTypeName field for consistency) + builder.classLiteralTypeName(typeName); + } + } + + /** + * Adds constructor invocation data for this() and super() calls. + * + * Sets referencedEntityKind to THIS for this() or SUPER for super(). + */ + private addConstructorInvocationData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + // The 'constructor' field contains either 'this' or 'super' node + const constructorNode = node.childForFieldName('constructor'); + if (constructorNode) { + if (constructorNode.type === 'this') { + builder.referencesEntity(ReferencedEntityKind.THIS); + } else if (constructorNode.type === 'super') { + builder.referencesEntity(ReferencedEntityKind.SUPER); + } + } + } + + /** + * Adds method reference data (methodReferenceKind and referenced entity). + * + * Method Reference Structure: + * [qualifier] :: [type_arguments?] [method_name | new] + * + * Examples: + * - String::length -> UNBOUND (or STATIC, requires semantic analysis) + * - prefix::concat -> BOUND + * - this::method -> BOUND + * - super::method -> SUPER + * - ArrayList::new -> CONSTRUCTOR + * - int[]::new -> ARRAY_CONSTRUCTOR + * + * Note: Distinguishing STATIC vs UNBOUND requires knowing if the method is static, + * which we don't have at parse time. We use heuristics based on qualifier type. + */ + private addMethodReferenceData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + const kind = this.determineMethodReferenceKind(node); + builder.methodReference(kind); + + // Store the method name (or 'new' for constructor refs) + // Note: Must cache node.children — tree-sitter creates new wrapper objects on each + // .children access, so indexOf() with a reference from a different access always returns -1. + const children = node.children; + const colonIdx = children.findIndex(c => c.type === '::'); + const methodNameNode = colonIdx >= 0 + ? children.slice(colonIdx + 1).find(c => c.type === 'identifier') + : undefined; + const isConstructorRef = children.some(c => c.type === 'new'); + + if (isConstructorRef) { + builder.classLiteralTypeName('new'); + builder.referencesEntity(ReferencedEntityKind.CONSTRUCTOR); + } else if (methodNameNode) { + builder.classLiteralTypeName(methodNameNode.text); + builder.referencesEntity(ReferencedEntityKind.METHOD); + } + } + + /** + * Determines the MethodReferenceKind based on the qualifier type. + * + * Heuristics (without full semantic analysis): + * - super::method, Child.super::method -> SUPER + * - Type[]::new -> ARRAY_CONSTRUCTOR + * - Type::new -> CONSTRUCTOR + * - All other qualifier::method patterns -> QUALIFIED_METHOD + */ + private determineMethodReferenceKind(node: Parser.SyntaxNode): MethodReferenceKind { + const qualifier = node.children[0]; + if (!qualifier) { + return MethodReferenceKind.QUALIFIED_METHOD; // fallback + } + + const isConstructorRef = node.children.some(c => c.type === 'new'); + + // Check for array constructor reference first: int[]::new, String[]::new + if (qualifier.type === 'array_type' && isConstructorRef) { + return MethodReferenceKind.ARRAY_CONSTRUCTOR; + } + + // Check for constructor reference: ArrayList::new, StringBuilder::new + if (isConstructorRef) { + return MethodReferenceKind.CONSTRUCTOR; + } + + // super::method -> SUPER + if (qualifier.type === 'super') { + return MethodReferenceKind.SUPER; + } + + // scoped_type_identifier ending with 'super': Child.super::method -> SUPER + if (qualifier.type === 'scoped_type_identifier') { + const lastChild = qualifier.children[qualifier.children.length - 1]; + if (lastChild && lastChild.type === 'type_identifier' && lastChild.text === 'super') { + return MethodReferenceKind.SUPER; + } + } + + // All other method references are QUALIFIED_METHOD + // We cannot reliably distinguish between: + // - STATIC (Math::abs) vs UNBOUND (String::length) - requires knowing if method is static + // - BOUND (prefix::length) vs UNBOUND (String::length) - requires knowing if qualifier is variable or type + return MethodReferenceKind.QUALIFIED_METHOD; + } + + /** + * Adds field access data (field name and references entity kind) + * For super.field, this.field, obj.field patterns + * + * Special case: For qualified this expressions (e.g., OuterClass.this), + * the "field" is actually "this" keyword, so we use ReferencedEntityKind.THIS + */ + private addFieldAccessData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + // Extract the field name (right side of the dot) + const field = node.childForFieldName('field'); + if (field) { + const fieldName = field.text; + // Store the field name in literalValue + builder.classLiteralTypeName(fieldName); + + // Check if this is a qualified this expression (e.g., OuterClass.this) + // In this case, the "field" is the 'this' keyword, not an actual field + if (fieldName === 'this') { + builder.referencesEntity(ReferencedEntityKind.THIS); + return; + } + + // Use shared naming convention logic + this.classifyByNamingConvention(builder, fieldName); + return; + } + + // Fallback: Mark as referencing a field + builder.referencesEntity(ReferencedEntityKind.FIELD); + } + + /** + * Classifies an identifier as TYPE or FIELD based on Java naming conventions, + * and attempts type resolution for PascalCase names. + * + * Used by both addIdentifierReferenceData and addFieldAccessData. + * + * - ALL_CAPS with underscores = FIELD (STATIC_FINAL, MAX_VALUE, DEFAULT_TIMEOUT) + * - PascalCase (Uppercase first letter, mixed case) = TYPE (String, Math, Entry in Map.Entry) + * - camelCase (lowercase first letter) = FIELD (count, name, myField) + */ + private classifyByNamingConvention( + builder: ReturnType, + name: string + ): void { + const firstChar = name.charAt(0); + const startsWithUppercase = firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase(); + + // Check if identifier is ALL_CAPS (constant naming convention) + // Pattern: all uppercase letters, digits, and underscores (e.g., STATIC_FINAL, MAX_VALUE, PI) + const isAllCaps = /^[A-Z][A-Z0-9_]*$/.test(name); + + if (isAllCaps) { + // ALL_CAPS = likely a constant field (STATIC_FINAL, MAX_VALUE, etc.) + builder.referencesEntity(ReferencedEntityKind.FIELD); + } else if (startsWithUppercase) { + // PascalCase = likely a type reference + builder.referencesEntity(ReferencedEntityKind.TYPE); + + // Attempt type resolution (e.g., String -> java.lang.String, Entry -> Map.Entry) + const { potentialQualifiedName, isAmbiguous } = resolveTypeQualifiedName( + name, + this.currentPackageName, + this.currentImportMap, + this.currentHasStarImports + ); + + if (potentialQualifiedName) { + builder.qualifiedName(potentialQualifiedName, isAmbiguous); + } + } else { + // Lowercase = likely a field or variable reference + builder.referencesEntity(ReferencedEntityKind.FIELD); + } + } + + /** + * Adds method invocation data (method name, type arguments if present) + * For obj.method(), method(), Class.method() patterns + */ + private addMethodInvocationData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + // Extract the method name + const name = node.childForFieldName('name'); + if (name) { + // Store the method name (reusing classLiteralTypeName field for now) + builder.classLiteralTypeName(name.text); + } + + // Note: Type arguments (e.g., in obj.method()) are intentionally NOT stored. + // They are compile-time generic hints, not dependencies we need to track. + // The actual dependency is captured via the receiver (e.g., Collections -> java.util.Collections). + + // Mark as referencing a method + builder.referencesEntity(ReferencedEntityKind.METHOD); + } + + /** + * Adds binary expression data (operator) + */ + private addBinaryData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + const { operator } = this.parseBinaryExpression(node); + builder.operator(operator); + } + + /** + * Adds assignment expression data (operator: =, +=, -=, etc.) + */ + private addAssignmentData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + const operator = this.extractAssignmentOperator(node); + builder.operator(operator); + } + + /** + * Adds string template data (processor name: STR, FMT, RAW, or custom). + * Uses operatorString field to store the processor name. + * + * AST structure: + * template_expression + * ├── identifier: "STR" (processor) + * ├── .: "." + * └── string_literal (template content) + */ + private addStringTemplateData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + // First child should be the processor identifier (STR, FMT, RAW, etc.) + const processorNode = ExpressionReferenceExtractor.namedOperands(node).find(c => c.type === 'identifier'); + const processor = processorNode?.text || 'STR'; + builder.operator(processor); + } + + /** + * Adds record pattern data (record type name). + * Uses classLiteralTypeName field to store the record type being matched. + * + * AST structure: + * record_pattern + * ├── identifier: "Person" (record type name) + * └── record_pattern_body: (String n, int a) + */ + private addRecordPatternData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + // First identifier or generic_type child is the record type name + // For simple: Point(int x, int y) -> identifier "Point" + // For generic: Pair(Object f, Object s) -> generic_type with identifier "Pair" + const recordTypeNode = ExpressionReferenceExtractor.namedOperands(node).find(c => + c.type === 'identifier' || c.type === 'generic_type' + ); + if (recordTypeNode) { + if (recordTypeNode.type === 'generic_type') { + // Extract the base type name from generic_type (e.g., "Pair" from "Pair") + const baseTypeNode = ExpressionReferenceExtractor.namedOperands(recordTypeNode).find(c => c.type === 'type_identifier'); + if (baseTypeNode) { + builder.classLiteralTypeName(baseTypeNode.text); + } + } else { + builder.classLiteralTypeName(recordTypeNode.text); + } + } + } + + /** + * Adds lambda expression data (parameter names). + * Uses operator field to store comma-separated parameter names. + * + * Lambda parameter forms: + * - Single identifier: x -> x * 2 + * - Inferred parameters: (x, y) -> x + y + * - Formal parameters: (int x, int y) -> x + y + */ + private addLambdaData( + builder: ReturnType, + node: Parser.SyntaxNode + ): void { + const paramNames: string[] = []; + + // Check if there are any formal_parameters or inferred_parameters + // If so, an identifier child is the body, not a parameter + const hasParamList = ExpressionReferenceExtractor.namedOperands(node).some(child => + child.type === 'formal_parameters' || child.type === 'inferred_parameters' + ); + + for (const child of ExpressionReferenceExtractor.namedOperands(node)) { + if (child.type === 'identifier' && !hasParamList) { + // Single parameter without parentheses: x -> x * 2 + // Only treat as parameter if there's no formal_parameters or inferred_parameters + paramNames.push(child.text); + } else if (child.type === 'inferred_parameters') { + // Inferred parameters: (x, y) -> x + y + for (const param of ExpressionReferenceExtractor.namedOperands(child)) { + if (param.type === 'identifier') { + paramNames.push(param.text); + } + } + } else if (child.type === 'formal_parameters') { + // Formal parameters: (int x, int y) -> x + y + for (const param of ExpressionReferenceExtractor.namedOperands(child)) { + if (param.type === 'formal_parameter' || param.type === 'spread_parameter') { + const nameNode = ExpressionReferenceExtractor.namedOperands(param).find(n => n.type === 'identifier'); + if (nameNode) { + paramNames.push(nameNode.text); + } + } + } + } + } + + // Store parameter names in operator field (comma-separated) + if (paramNames.length > 0) { + builder.operator(paramNames.join(',')); + } + } + + /** + * Parses a binary expression to extract operator and operands + */ + private parseBinaryExpression(node: Parser.SyntaxNode): { + operator: string; + left: Parser.SyntaxNode; + right: Parser.SyntaxNode; + } { + // binary_expression structure: left operator right + const namedChildren = ExpressionReferenceExtractor.namedOperands(node); + + if (namedChildren.length >= 2) { + const left = namedChildren[0]!; + const right = namedChildren[namedChildren.length - 1]!; + + // Find operator between left and right + let operator = '?'; + for (const child of node.children) { + if (child.startIndex >= left.endIndex && child.endIndex <= right.startIndex) { + if (!child.isNamed && child.text.trim().length > 0) { + operator = child.text; + break; + } + } + } + + return { operator, left, right }; + } + + // Fallback + return { + operator: '?', + left: node, + right: node, + }; + } + + /** + * Extracts type references from a field_access qualifier that represents type access. + * + * For method references like Type1.Type2::staticMethod, the qualifier is parsed as + * a field_access node (not scoped_type_identifier). We need to extract type references + * for each type component to ensure they appear in the type-references output. + * + * This method recursively processes the field_access chain: + * - Type1.Type2 -> extracts Type2, then recursively processes Type1 + * - Type1.Type2.Type3 -> extracts Type3, Type2, Type1 + * + * Only extracts types (names starting with uppercase) to avoid false positives + * on actual field access like obj.field::method. + * + * @param fieldAccessNode The field_access node representing the qualifier + * @param expressionHash The hash of the method reference expression + */ + private extractTypeReferencesFromFieldAccessQualifier( + fieldAccessNode: Parser.SyntaxNode, + expressionHash: string + ): void { + // Get the field name (the part after the dot) + const fieldNode = fieldAccessNode.childForFieldName('field'); + if (!fieldNode) { + return; + } + + const fieldName = fieldNode.text; + const firstChar = fieldName[0]; + + // Only process if the field name looks like a type (starts with uppercase) + // This distinguishes Type1.Type2::method from obj.field::method + if (fieldName.length === 0 || !firstChar || firstChar !== firstChar.toUpperCase()) { + return; + } + + // Extract type reference for this type component + const typeRefs = this.typeReferenceExtractor.extractFromMethodReferenceQualifier( + fieldNode, + this.currentTypeRegistryHash, + expressionHash, + this.currentPackageName + ); + this.extractedTypeReferences.push(...typeRefs); + + // Recursively process the object part (the part before the dot) + const objectNode = fieldAccessNode.childForFieldName('object'); + if (objectNode) { + if (objectNode.type === 'field_access') { + // Nested field access: Type1.Type2.Type3 -> process Type1.Type2 + this.extractTypeReferencesFromFieldAccessQualifier(objectNode, expressionHash); + } else if (objectNode.type === 'identifier') { + // Base case: simple identifier like Type1 + const objectName = objectNode.text; + const objectFirstChar = objectName[0]; + // Only process if it looks like a type + if (objectName.length > 0 && objectFirstChar && objectFirstChar === objectFirstChar.toUpperCase()) { + const baseTypeRefs = this.typeReferenceExtractor.extractFromMethodReferenceQualifier( + objectNode, + this.currentTypeRegistryHash, + expressionHash, + this.currentPackageName + ); + this.extractedTypeReferences.push(...baseTypeRefs); + } + } + } + } +} diff --git a/parser/src/parsers/java/extractors/field-extractor.ts b/parser/src/parsers/java/extractors/field-extractor.ts new file mode 100644 index 000000000..971fc74a8 --- /dev/null +++ b/parser/src/parsers/java/extractors/field-extractor.ts @@ -0,0 +1,768 @@ +import Parser from 'tree-sitter'; + +import { BlockRegistry } from '@/analysis-types/java/BlockRegistry'; +import { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +import { FieldRegistry } from '@/analysis-types/java/FieldRegistry'; +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { FieldModifier } from '@/enums/java/fields'; +import { TypeAccess } from '@/enums/java/types'; +import { TypeRefKind, TypeRefContext, ReferenceOwnerKind } from '@/enums/java/type-references'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { ExpressionReferenceExtractor, AnonymousClassInfo } from '@/parsers/java/extractors/expression-reference-extractor'; +import { LocalVariableExtractor } from '@/parsers/java/extractors/local-variable-extractor'; +import { TypeReferenceExtractor } from '@/parsers/java/extractors/type-reference-extractor'; +import { EntityUtils } from '@/utils/entity-utils'; +import { LocalVariableRegistry } from '@/analysis-types/java/LocalVariableRegistry'; +import { resolveTypeQualifiedName } from '@/utils/java/type-resolution-utils'; + +/** + * Extracts FieldRegistry entities from Java type bodies using tree-sitter. + * + * Handles extraction of: + * - Field names and types + * - Access modifiers (public, protected, private, package) + * - Field modifiers (static, final, volatile, transient) + * - Generic and wildcard types + * - Array types + * - Field annotations + * - Multi-declaration statements (int x, y, z) + * + * ## Tree-sitter Node Structure + * + * For a class like: + * ```java + * public class Example { + * private String name; + * public static final int COUNT = 10; + * private volatile boolean running; + * private List items; + * private int x, y, z; + * } + * ``` + * + * The tree-sitter structure is: + * - class_declaration + * - class_body + * - field_declaration + * - modifiers (private) + * - type_identifier (String) + * - variable_declarator + * - identifier (name) + * - field_declaration + * - modifiers (public static final) + * - integral_type (int) + * - variable_declarator + * - identifier (COUNT) + * - = (equals) + * - decimal_integer_literal (10) + */ +export class FieldExtractor { + private annotationExtractor: AnnotationExtractor; + private typeReferenceExtractor: TypeReferenceExtractor; + private expressionExtractor: ExpressionReferenceExtractor; + private localVariableExtractor: LocalVariableExtractor; + private extractedAnnotations: TypeAnnotation[] = []; + private extractedAnnotationArguments: AnnotationArgumentReference[] = []; + private extractedTypeReferences: TypeReference[] = []; + private extractedExpressions: ExpressionReference[] = []; + private extractedAnonymousClasses: AnonymousClassInfo[] = []; + private extractedLocalVariables: LocalVariableRegistry[] = []; + private extractedBlocks: BlockRegistry[] = []; + + constructor() { + this.annotationExtractor = new AnnotationExtractor(); + this.typeReferenceExtractor = new TypeReferenceExtractor(); + this.expressionExtractor = new ExpressionReferenceExtractor(); + this.localVariableExtractor = new LocalVariableExtractor(); + } + + /** + * Returns all annotations extracted during the last extraction + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns all annotation arguments extracted during the last extraction + */ + getExtractedAnnotationArguments(): AnnotationArgumentReference[] { + return this.extractedAnnotationArguments; + } + + /** + * Returns all type references extracted during the last extraction + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all expressions extracted during the last extraction + */ + getExtractedExpressions(): ExpressionReference[] { + return this.extractedExpressions; + } + + /** + * Returns all anonymous classes encountered during the last extraction. + * These need to be registered as types at a higher level. + */ + getExtractedAnonymousClasses(): AnonymousClassInfo[] { + return this.extractedAnonymousClasses; + } + + /** + * Returns all local variables extracted from lambda bodies in field initializers + */ + getExtractedLocalVariables(): LocalVariableRegistry[] { + return this.extractedLocalVariables; + } + + /** + * Returns all blocks extracted from lambda bodies in field initializers + */ + getExtractedBlocks(): BlockRegistry[] { + return this.extractedBlocks; + } + + /** + * Extracts fields from a type body (class_body, interface_body, enum_body) + */ + extractFromTypeBody( + bodyNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + isInterface: boolean = false + ): FieldRegistry[] { + // Reset extracted collections + this.extractedAnnotations = []; + this.extractedAnnotationArguments = []; + this.extractedTypeReferences = []; + this.extractedExpressions = []; + this.extractedAnonymousClasses = []; + this.extractedLocalVariables = []; + this.extractedBlocks = []; + + const fields: FieldRegistry[] = []; + + // Helper to process field declarations from a node + const processFieldDeclarations = (node: Parser.SyntaxNode) => { + for (const child of node.children) { + // Handle regular field declarations (classes, enums) + // and constant declarations (interfaces use constant_declaration) + if (child.type === 'field_declaration' || child.type === 'constant_declaration') { + const extractedFields = this.extractFieldDeclaration( + child, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + isInterface || child.type === 'constant_declaration' + ); + fields.push(...extractedFields); + } + // Enum bodies have nested enum_body_declarations containing field declarations + else if (child.type === 'enum_body_declarations') { + processFieldDeclarations(child); + } + // Enum constants may have anonymous class bodies with fields + else if (child.type === 'enum_constant') { + for (const constantChild of child.children) { + if (constantChild.type === 'class_body') { + // Extract fields from enum constant anonymous body + const enumConstantFields = this.extractFromEnumConstantBody( + constantChild, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + fields.push(...enumConstantFields); + } + } + } + } + }; + + processFieldDeclarations(bodyNode); + + // JLS 8.10.1: each record component implicitly declares a private final field of the same + // name and type. The components sit on the record_declaration, not in the body, so nothing + // in the loop above can see them. + if (bodyNode.parent?.type === 'record_declaration') { + fields.push(...this.extractRecordComponentFields( + bodyNode.parent, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName + )); + } + + return fields; + } + + /** + * Builds the private final field each record component implicitly declares. + * + * A record cannot declare an instance field of its own (JLS 8.10.1), so there is nothing here + * to collide with what the body loop already produced. + */ + private extractRecordComponentFields( + recordNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null + ): FieldRegistry[] { + const fields: FieldRegistry[] = []; + + const formalParams = recordNode.children.find(c => c.type === 'formal_parameters'); + if (!formalParams) return fields; + + for (const component of formalParams.children) { + if (component.type !== 'formal_parameter' && component.type !== 'spread_parameter') continue; + + // A spread_parameter carries no `name`/`type` fields - it is + // "..." - so both shapes are read positionally. + const isVarargs = component.type === 'spread_parameter'; + const typeNode = isVarargs + ? component.children.find(c => c.type !== '...' && c.type !== 'variable_declarator' && c.type !== 'modifiers') + : component.childForFieldName('type'); + const nameNode = isVarargs + ? component.children.find(c => c.type === 'variable_declarator') + : component.childForFieldName('name'); + if (!nameNode || !typeNode) continue; + + // A varargs component's field type is the array type it erases to. + const baseTypeName = EntityUtils.normalizeWhitespace(typeNode.text); + const fieldTypeName = isVarargs ? `${baseTypeName}[]` : baseTypeName; + const line = component.startPosition.row + 1; + + const field = FieldRegistry.builder( + EntityUtils.normalizeWhitespace(nameNode.text), + fieldTypeName, + this.extractBaseType(fieldTypeName), + filePath, + line, + line, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + TypeAccess.PRIVATE_ACCESS, + serviceVersionHash + ) + .withModifiers([FieldModifier.FINAL]) + .build(); + + fields.push(field); + + this.extractFieldTypeReferences(typeNode, typeRegistryHash, field.getHash(), packageName); + } + + return fields; + } + + /** + * Extracts fields from an enum constant's anonymous class body. + * Example: enum Status { ACTIVE { private int priority = 1; } } + */ + extractFromEnumConstantBody( + classBodyNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): FieldRegistry[] { + const fields: FieldRegistry[] = []; + + for (const child of classBodyNode.children) { + if (child.type === 'field_declaration') { + const extractedFields = this.extractFieldDeclaration( + child, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + false // enum constant bodies are not interfaces + ); + fields.push(...extractedFields); + } + } + + return fields; + } + + /** + * Extracts fields from an anonymous class body. + * This is a public method specifically for anonymous class field extraction. + */ + extractFromAnonymousClassBody( + classBodyNode: Parser.SyntaxNode, + filePath: string, + anonymousTypeHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): FieldRegistry[] { + // Reset extracted collections for this anonymous class + this.extractedAnnotations = []; + this.extractedAnnotationArguments = []; + this.extractedTypeReferences = []; + this.extractedExpressions = []; + this.extractedLocalVariables = []; + this.extractedBlocks = []; + // Note: We don't reset extractedAnonymousClasses here to allow nested anonymous classes + + const fields: FieldRegistry[] = []; + + for (const child of classBodyNode.children) { + if (child.type === 'field_declaration') { + const extractedFields = this.extractFieldDeclaration( + child, + filePath, + anonymousTypeHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + false // anonymous classes are not interfaces + ); + fields.push(...extractedFields); + } + } + + return fields; + } + + /** + * Extracts fields from a field_declaration node. + * Handles multi-declaration statements like: private int x, y, z; + */ + private extractFieldDeclaration( + fieldNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + isInterface: boolean + ): FieldRegistry[] { + const fields: FieldRegistry[] = []; + + // Extract modifiers + const { access, modifiers } = this.extractModifiers(fieldNode, isInterface); + + // Extract the type node + const typeNode = this.findTypeNode(fieldNode); + if (!typeNode) { + return fields; + } + + // Get the full type name as written + const fieldTypeName = EntityUtils.normalizeWhitespace(typeNode.text); + const fieldBaseType = this.extractBaseType(fieldTypeName); + + // Resolve qualified name + const { potentialQualifiedName, isAmbiguous } = resolveTypeQualifiedName( + fieldBaseType, + packageName, + importMap, + hasStarImports + ); + + // Find all variable declarators (handles multi-declaration: int x, y, z) + const declarators = fieldNode.children.filter( + child => child.type === 'variable_declarator' + ); + + for (const declarator of declarators) { + const nameNode = declarator.children.find(c => c.type === 'identifier'); + if (!nameNode) continue; + + const fieldName = nameNode.text; + const startLine = declarator.startPosition.row + 1; + const endLine = declarator.endPosition.row + 1; + + // Handle C-style array declarations (int myArray[] vs int[] myArray) + // C-style puts dimensions in the variable_declarator, not the type node + const cStyleDimensions = declarator.children.find(c => c.type === 'dimensions'); + let actualFieldTypeName = fieldTypeName; + let actualFieldBaseType = fieldBaseType; + if (cStyleDimensions) { + // Append C-style dimensions to the type name + actualFieldTypeName = fieldTypeName + cStyleDimensions.text; + actualFieldBaseType = this.extractBaseType(actualFieldTypeName); + } + + // Build the field entity + const fieldBuilder = FieldRegistry.builder( + fieldName, + actualFieldTypeName, + actualFieldBaseType, + filePath, + startLine, + endLine, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + access, + serviceVersionHash + ); + + if (potentialQualifiedName) { + fieldBuilder.withPotentialQualifiedName(potentialQualifiedName); + } + fieldBuilder.withIsAmbiguous(isAmbiguous); + fieldBuilder.withModifiers(modifiers); + + const field = fieldBuilder.build(); + fields.push(field); + + // Extract annotations for this field + this.extractFieldAnnotations(fieldNode, typeRegistryHash, field.getHash()); + + // Extract type references for the field type + // Pass C-style dimensions if present for proper ARRAY type reference creation + this.extractFieldTypeReferences( + typeNode, + typeRegistryHash, + field.getHash(), + packageName, + cStyleDimensions + ); + + // Extract expressions from field initializer (if present) + const equalsIndex = declarator.children.findIndex(c => c.type === '='); + if (equalsIndex >= 0 && equalsIndex < declarator.children.length - 1) { + const initializerNode = declarator.children[equalsIndex + 1]; + if (initializerNode) { + const expressions = this.expressionExtractor.extractFromFieldInitializer( + initializerNode, + typeRegistryHash, + field.getHash(), + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + this.extractedExpressions.push(...expressions); + + // Collect type references from method type arguments in expressions + const expressionTypeRefs = this.expressionExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...expressionTypeRefs); + + // Collect type-use annotations from object/array creation expressions + const expressionAnnotations = this.expressionExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...expressionAnnotations); + + // Collect anonymous classes from field initializers + const anonymousClasses = this.expressionExtractor.getExtractedAnonymousClasses(); + this.extractedAnonymousClasses.push(...anonymousClasses); + + // Extract local variables and return statements from lambda block bodies + const lambdaLocalVars = this.localVariableExtractor.extractFromFieldInitializerLambdas( + initializerNode, + filePath, + typeRegistryHash, + field.getHash(), + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + this.extractedExpressions + ); + this.extractedLocalVariables.push(...lambdaLocalVars); + + // Collect blocks from lambda bodies (try/catch/finally) + const lambdaBlocks = this.localVariableExtractor.getExtractedBlocks(); + this.extractedBlocks.push(...lambdaBlocks); + + // Collect expressions from lambda body statements + const lambdaExpressions = this.localVariableExtractor.getExtractedExpressions(); + this.extractedExpressions.push(...lambdaExpressions); + + // Collect type references from lambda body local variables + const lambdaTypeRefs = this.localVariableExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...lambdaTypeRefs); + + // Collect annotations from lambda body local variables + const lambdaAnnotations = this.localVariableExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...lambdaAnnotations); + } + } + + } + + return fields; + } + + /** + * Extracts access and modifier information from a field declaration + */ + private extractModifiers( + fieldNode: Parser.SyntaxNode, + isInterface: boolean + ): { access: TypeAccess; modifiers: FieldModifier[] } { + let access = TypeAccess.PACKAGE_ACCESS; + const modifiers: FieldModifier[] = []; + + // Interface fields are implicitly public static final + if (isInterface) { + access = TypeAccess.PUBLIC_ACCESS; + modifiers.push(FieldModifier.STATIC, FieldModifier.FINAL); + } + + const modifiersNode = fieldNode.children.find(c => c.type === 'modifiers'); + if (!modifiersNode) { + return { access, modifiers }; + } + + for (const mod of modifiersNode.children) { + switch (mod.type) { + case 'public': + access = TypeAccess.PUBLIC_ACCESS; + break; + case 'protected': + access = TypeAccess.PROTECTED_ACCESS; + break; + case 'private': + access = TypeAccess.PRIVATE_ACCESS; + break; + case 'static': + if (!modifiers.includes(FieldModifier.STATIC)) { + modifiers.push(FieldModifier.STATIC); + } + break; + case 'final': + if (!modifiers.includes(FieldModifier.FINAL)) { + modifiers.push(FieldModifier.FINAL); + } + break; + case 'volatile': + modifiers.push(FieldModifier.VOLATILE); + break; + case 'transient': + modifiers.push(FieldModifier.TRANSIENT); + break; + } + } + + return { access, modifiers }; + } + + /** + * Finds the type node in a field declaration + */ + private findTypeNode(fieldNode: Parser.SyntaxNode): Parser.SyntaxNode | undefined { + // Type nodes can be various types depending on the Java type + const typeNodeTypes = [ + 'type_identifier', // Simple types: String, MyClass + 'scoped_type_identifier', // Qualified types: java.util.List + 'generic_type', // Generic types: List + 'array_type', // Array types: String[], int[][] + 'integral_type', // int, long, short, byte + 'floating_point_type', // float, double + 'boolean_type', // boolean + 'void_type', // void (shouldn't appear in fields) + ]; + + for (const child of fieldNode.children) { + if (typeNodeTypes.includes(child.type)) { + return child; + } + } + + return undefined; + } + + /** + * Extracts the base type from a full type name (strips generics and annotations) + * + * Examples: + * - "List" -> "List" + * - "String[]" -> "String" + * - "String @NonNull []" -> "String" + * - "@NonNull String @NonNull []" -> "String" + * - "Map<@NonNull String, Integer>" -> "Map" + */ + private extractBaseType(fullTypeName: string): string { + // Handle array types first - get the component type + let baseType = fullTypeName; + + // Strip array brackets (with optional annotations before them) + // Pattern: optional whitespace, optional @annotation, optional whitespace, [] + baseType = baseType.replace(/(\s*@\w+\s*)?\[\]/g, ''); + + // Strip generics + const genericStart = baseType.indexOf('<'); + if (genericStart !== -1) { + baseType = baseType.substring(0, genericStart); + } + + // Strip any remaining annotations (like @NonNull before type name) + baseType = baseType.replace(/@\w+\s*/g, ''); + + return baseType.trim(); + } + + /** + * Counts the number of array dimensions from a dimensions node. + * For C-style arrays like `int myArray[][]`, the dimensions node contains `[][]` + */ + private countDimensions(dimensionsNode: Parser.SyntaxNode): number { + // Count '[' characters in the dimensions text + const text = dimensionsNode.text; + let count = 0; + for (const char of text) { + if (char === '[') count++; + } + return count; + } + + /** + * Extracts annotations from a field declaration + */ + private extractFieldAnnotations( + fieldNode: Parser.SyntaxNode, + typeRegistryHash: string, + fieldHash: string + ): void { + const modifiersNode = fieldNode.children.find(c => c.type === 'modifiers'); + if (!modifiersNode) return; + + // Reset annotation extractor + this.annotationExtractor.resetExtractedArguments(); + + for (const child of modifiersNode.children) { + if (child.type === 'marker_annotation' || child.type === 'annotation') { + // Extract the annotation - signature is (fieldNode, fieldHash, typeRegistryHash) + const annotations = this.annotationExtractor.extractFromFieldDeclaration( + fieldNode, + fieldHash, + typeRegistryHash + ); + this.extractedAnnotations.push(...annotations); + + // Collect annotation arguments + const args = this.annotationExtractor.getExtractedArguments(); + this.extractedAnnotationArguments.push(...args); + break; // extractFromFieldDeclaration handles all annotations + } + } + } + + /** + * Extracts type references from the field type (for generics, wildcards, etc.) + * + * Handles complex types like: + * - Generic types: List, Map + * - Wildcard types: List, Map + * - Array types: String[], int[][] + * - Nested generics: Map> + * - Type-use annotations: List<@NonNull String>, Map<@NonNull String, @Nullable Integer> + * - C-style arrays: int myArray[] (dimensions in variable_declarator) + */ + private extractFieldTypeReferences( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + fieldHash: string, + packageName: string | null, + cStyleDimensions?: Parser.SyntaxNode + ): void { + // Handle C-style array declarations (int myArray[] instead of int[] myArray) + // For C-style, the typeNode is just the element type, dimensions are separate + if (cStyleDimensions) { + const dimensionCount = this.countDimensions(cStyleDimensions); + const elementTypeName = typeNode.text; + + // Create ARRAY type reference for C-style arrays + const arrayRef = TypeReference.builder( + typeRegistryHash, + TypeRefKind.ARRAY, + TypeRefContext.FIELD_TYPE, + ReferenceOwnerKind.FIELD, + fieldHash + ) + .setTypeName(elementTypeName) + .setCompleteTypeName(elementTypeName) + .array(dimensionCount) + .positionAndDepth(0, 0) + .build(); + + this.extractedTypeReferences.push(arrayRef); + return; + } + + // Extract type references for complex types (generics, arrays, wildcards) + const typeRefs = this.typeReferenceExtractor.extractFromField( + typeNode, + typeRegistryHash, + fieldHash, + packageName + ); + this.extractedTypeReferences.push(...typeRefs); + + // Build a map of position keys to TypeReference hashes for annotation linking + const typeRefHashMap = new Map(); + for (const ref of typeRefs) { + // Create position key based on depth and position + const positionKey = `${ref.getDepth()}.${ref.getPosition()}`; + typeRefHashMap.set(positionKey, ref.getHash()); + } + + // Reset before extracting TYPE_USE annotations to avoid duplicates + this.annotationExtractor.resetExtractedArguments(); + + // Extract TYPE_USE annotations from annotated type arguments (e.g., List<@NonNull String>) + const typeUseAnnotations = this.annotationExtractor.extractFromFieldType( + typeNode, + typeRefHashMap, + typeRegistryHash + ); + this.extractedAnnotations.push(...typeUseAnnotations); + + // Collect annotation arguments from TYPE_USE annotations + const typeUseArgs = this.annotationExtractor.getExtractedArguments(); + this.extractedAnnotationArguments.push(...typeUseArgs); + } +} diff --git a/parser/src/parsers/java/extractors/import-extractor.ts b/parser/src/parsers/java/extractors/import-extractor.ts new file mode 100644 index 000000000..d6d98f026 --- /dev/null +++ b/parser/src/parsers/java/extractors/import-extractor.ts @@ -0,0 +1,353 @@ +import Parser from 'tree-sitter'; + +import { ImportRegistry } from '@/analysis-imports/java/ImportRegistry'; +import { EntityUtils } from '@/utils/entity-utils'; +import { ImportKind } from '@/enums/java/imports'; +import { BaseExtractor } from '@/parsers/base-extractor'; +import { JavaParser } from '@/parsers/java/java-parser'; + +/** + * Extracts ImportRegistry entities from Java source files using tree-sitter. + * + * Supports all 5 Java import types (as of Java 23): + * - SINGLE_TYPE: import java.util.List; + * - TYPE_ON_DEMAND: import java.util.*; + * - SINGLE_STATIC: import static java.lang.Math.PI; + * - STATIC_ON_DEMAND: import static java.lang.Math.*; + * - MODULE: import module java.base; (Java 23+) + * + * Tree-sitter AST structure for imports: + * ``` + * (import_declaration + * ["static"]? ; optional static keyword + * (scoped_identifier) ; package/type path + * (identifier)? ; final identifier (for single imports) + * (asterisk)? ; for on-demand imports + * ) + * ``` + * + * Note: Module imports (Java 23+) have a different structure: + * ``` + * (module_import_declaration ; or similar node type + * "module" + * (module_name) + * ) + * ``` + */ +export class ImportExtractor implements BaseExtractor { + private javaParser: JavaParser; + + constructor() { + this.javaParser = new JavaParser(); + } + + /** + * Extracts all import declarations from a Java source file + */ + extract(filePath: string, fileContent: string, serviceVersionHash: string): ImportRegistry[] { + const imports: ImportRegistry[] = []; + + try { + if (!fileContent || typeof fileContent !== 'string') { + console.warn(`Skipping ${filePath}: invalid content`); + return imports; + } + + const tree = this.javaParser.parse(fileContent); + const rootNode = this.javaParser.getRootNode(tree); + + this.extractImportsFromRoot(rootNode, filePath, serviceVersionHash, imports); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn(`Failed to parse imports from ${filePath}: ${errorMessage}`); + } + + return imports; + } + + /** + * Extracts imports directly from a pre-parsed root node. + * Use this when you already have a parsed tree (e.g., from TypeRegistryExtractor). + */ + extractFromRootNode( + rootNode: Parser.SyntaxNode, + filePath: string, + serviceVersionHash: string + ): ImportRegistry[] { + const imports: ImportRegistry[] = []; + this.extractImportsFromRoot(rootNode, filePath, serviceVersionHash, imports); + return imports; + } + + /** + * Internal method to extract imports from the root node + */ + private extractImportsFromRoot( + rootNode: Parser.SyntaxNode, + filePath: string, + serviceVersionHash: string, + imports: ImportRegistry[] + ): void { + for (const child of rootNode.children) { + // A module import is checked first, and the two branches are exclusive: a declaration that + // is a module import must not also be recorded as a single-type import of the same text. + if (child.type === 'module_import_declaration' || + (child.type === 'import_declaration' && this.isModuleImport(child))) { + const moduleImport = this.createModuleImportRegistry(child, filePath, serviceVersionHash); + if (moduleImport) { + imports.push(moduleImport); + } + } else if (child.type === 'import_declaration') { + const importRegistry = this.createImportRegistry(child, filePath, serviceVersionHash); + if (importRegistry) { + imports.push(importRegistry); + } + } + } + } + + /** + * Creates an ImportRegistry from an import_declaration node + */ + private createImportRegistry( + node: Parser.SyntaxNode, + filePath: string, + serviceVersionHash: string + ): ImportRegistry | null { + const lineNumber = node.startPosition.row + 1; + + // Determine if static import + const isStatic = this.hasStaticKeyword(node); + + // Determine if on-demand (wildcard) import + const isOnDemand = this.hasAsterisk(node); + + // Extract the import path + const importedPath = this.extractImportPath(node); + if (!importedPath) { + return null; + } + + // Determine import kind + const importKind = this.determineImportKind(isStatic, isOnDemand); + + // Extract package/type name and simple name + const { packageOrTypeName, simpleName } = this.parseImportPath(importedPath, isStatic, isOnDemand); + + return new ImportRegistry( + importKind, + importedPath, + packageOrTypeName, + simpleName, + filePath, + lineNumber, + isStatic, + isOnDemand, + false, // isModuleImport + serviceVersionHash + ); + } + + /** + * Creates an ImportRegistry for module imports (Java 23+) + */ + private createModuleImportRegistry( + node: Parser.SyntaxNode, + filePath: string, + serviceVersionHash: string + ): ImportRegistry | null { + const lineNumber = node.startPosition.row + 1; + + // Extract module name + const moduleName = this.extractModuleName(node); + if (!moduleName) { + return null; + } + + return new ImportRegistry( + ImportKind.MODULE, + moduleName, + '', // packageOrTypeName not applicable for module imports + moduleName, // simpleName is the module name + filePath, + lineNumber, + false, // isStatic + false, // isOnDemand (module imports are effectively "super wildcards" but classified separately) + true, // isModuleImport + serviceVersionHash + ); + } + + /** + * Checks if the import declaration has the 'static' keyword + */ + private hasStaticKeyword(node: Parser.SyntaxNode): boolean { + for (const child of node.children) { + if (child.type === 'static') { + return true; + } + } + return false; + } + + /** + * Checks if the import declaration has an asterisk (on-demand import) + */ + private hasAsterisk(node: Parser.SyntaxNode): boolean { + for (const child of node.children) { + if (child.type === 'asterisk') { + return true; + } + } + return false; + } + + /** + * Checks if this is a module import (Java 23+) + */ + private isModuleImport(node: Parser.SyntaxNode): boolean { + // No tree-sitter-java release parses `import module M;` (checked through 0.23.5). What it + // produces is a malformed import_declaration, with `module` swallowed into the qualified + // name and an ERROR beside it: + // + // import_declaration + // import + // scoped_identifier <- text is "module java.base" + // identifier = "module" + // ERROR + // identifier = "java" + // . + // identifier = "base" + // + // Read as a normal import that is a single-type import of a type named `base`, in a package + // named `module java`. Nothing else in Java produces a qualified name whose first segment is + // the identifier `module`, because `module` is a restricted keyword there, so this shape + // identifies a module import declaration unambiguously. + for (const child of node.children) { + if (child.type === 'module' || child.text === 'module') { + return true; + } + if (child.type === 'scoped_identifier') { + const first = child.children[0]; + if (first?.type === 'identifier' && first.text === 'module') { + return true; + } + } + } + return false; + } + + /** + * Extracts the full import path from an import_declaration node + */ + private extractImportPath(node: Parser.SyntaxNode): string | null { + let path = ''; + + for (const child of node.children) { + if (child.type === 'scoped_identifier') { + path = this.normalizeQualifiedName(child.text); + } else if (child.type === 'identifier' && child.text !== 'import' && child.text !== 'static') { + if (path) { + path += '.' + child.text; + } else { + path = child.text; + } + } else if (child.type === 'asterisk') { + if (path) { + path += '.*'; + } else { + path = '*'; + } + } + } + + return path || null; + } + + /** + * Collapses the whitespace a qualified name is allowed to contain. + * + * JLS 3.6 permits whitespace, including a line terminator, between the identifiers and dots of + * a qualified name, so this is legal and compiles: + * + * import java.util. + * Optional; + * + * The name is `java.util.Optional`. Taking the node text verbatim keeps the line break inside + * the value, which is wrong before it ever reaches a writer. + * + * Only whitespace adjacent to a dot is removed, rather than all whitespace, because the space + * in `module java.base` separates two tokens and is not part of the name. Any run that survives + * is collapsed to a single space so a value can never carry a line terminator. + */ + private normalizeQualifiedName(text: string): string { + return text + .replace(/\s*\.\s*/g, '.') + .replace(/\s+/g, ' ') + .trim(); + } + + /** + * Extracts module name from a module import declaration + */ + private extractModuleName(node: Parser.SyntaxNode): string | null { + for (const child of node.children) { + if (child.type === 'module_name') { + return child.text; + } + if (child.type === 'scoped_identifier' || child.type === 'identifier') { + if (child.text === 'import' || child.text === 'module') { + continue; + } + // On the malformed shape the `module` keyword is the first segment of the qualified + // name, so it is dropped here rather than reported as part of the module name. + const name = EntityUtils.normalizeWhitespace(child.text).replace(/^module\s+/, ''); + return name.length > 0 ? name : null; + } + } + return null; + } + + /** + * Determines the ImportKind based on static and on-demand flags + */ + private determineImportKind(isStatic: boolean, isOnDemand: boolean): ImportKind { + if (isStatic) { + return isOnDemand ? ImportKind.STATIC_ON_DEMAND : ImportKind.SINGLE_STATIC; + } + return isOnDemand ? ImportKind.TYPE_ON_DEMAND : ImportKind.SINGLE_TYPE; + } + + /** + * Parses the import path to extract packageOrTypeName and simpleName + * + * Examples: + * - "java.util.List" → { packageOrTypeName: "java.util", simpleName: "List" } + * - "java.util.*" → { packageOrTypeName: "java.util", simpleName: "*" } + * - "java.lang.Math.PI" (static) → { packageOrTypeName: "java.lang.Math", simpleName: "PI" } + * - "java.lang.Math.*" (static) → { packageOrTypeName: "java.lang.Math", simpleName: "*" } + */ + private parseImportPath( + importedPath: string, + _isStatic: boolean, + isOnDemand: boolean + ): { packageOrTypeName: string; simpleName: string } { + if (isOnDemand) { + // Remove trailing ".*" to get package/type name + const packageOrTypeName = importedPath.replace(/\.\*$/, ''); + return { packageOrTypeName, simpleName: '*' }; + } + + // Split on last dot to separate package from simple name + const lastDotIndex = importedPath.lastIndexOf('.'); + if (lastDotIndex === -1) { + // Single identifier (rare case) + return { packageOrTypeName: '', simpleName: importedPath }; + } + + const packageOrTypeName = importedPath.substring(0, lastDotIndex); + const simpleName = importedPath.substring(lastDotIndex + 1); + + return { packageOrTypeName, simpleName }; + } +} diff --git a/parser/src/parsers/java/extractors/index.ts b/parser/src/parsers/java/extractors/index.ts new file mode 100644 index 000000000..e21e084d4 --- /dev/null +++ b/parser/src/parsers/java/extractors/index.ts @@ -0,0 +1,7 @@ +export { TypeRegistryExtractor } from '@/parsers/java/extractors/type-registry-extractor'; +export { TypeReferenceExtractor } from '@/parsers/java/extractors/type-reference-extractor'; +export { TypeMethodExtractor } from '@/parsers/java/extractors/type-method-extractor'; +export { MethodTypeParameterExtractor } from '@/parsers/java/extractors/method-type-parameter-extractor'; +export { ImportExtractor } from '@/parsers/java/extractors/import-extractor'; +export { ExpressionReferenceExtractor } from '@/parsers/java/extractors/expression-reference-extractor'; +export { ModuleExtractor } from '@/parsers/java/extractors/module-extractor'; diff --git a/parser/src/parsers/java/extractors/local-variable-extractor.ts b/parser/src/parsers/java/extractors/local-variable-extractor.ts new file mode 100644 index 000000000..953f5701f --- /dev/null +++ b/parser/src/parsers/java/extractors/local-variable-extractor.ts @@ -0,0 +1,4094 @@ +import Parser from 'tree-sitter'; + +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { BlockRegistry } from '@/analysis-types/java/BlockRegistry'; +import { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +import { LocalVariableRegistry } from '@/analysis-types/java/LocalVariableRegistry'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { BlockKind } from '@/enums/java/blocks'; +import { LocalVariableScopeKind } from '@/enums/java/local-variables'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { ExpressionReferenceExtractor, AnonymousClassInfo } from '@/parsers/java/extractors/expression-reference-extractor'; +import { ScopeContext, extractLambdaParameterNames } from '@/parsers/java/extractors/scope-context'; +import { TypeReferenceExtractor } from '@/parsers/java/extractors/type-reference-extractor'; +import { EntityUtils } from '@/utils/entity-utils'; +import { JavaTreeSitterUtils } from '@/utils/java/java-tree-sitter-utils'; +import { resolveTypeQualifiedName } from '@/utils/java/type-resolution-utils'; + +/** + * Extracts LocalVariableRegistry entities from Java method bodies using tree-sitter. + * + * Handles extraction of: + * - Local variable declarations in method bodies + * - Variables in lambda expressions (including nested lambdas) + * - Variables in static/instance initializer blocks + * - For loop and enhanced-for loop variables + * - Try-with-resources variables + * - Catch clause exception variables + * - Pattern binding variables (instanceof, switch, record patterns) + * + * ## Tree-sitter Node Structure + * + * For a method like: + * ```java + * public void method() { + * int count = 10; // local_variable_declaration + * final String name = "test"; // with final modifier + * var inferred = 42; // var inference + * + * for (int i = 0; i < 10; i++) { ... } // for_statement + * for (String item : items) { ... } // enhanced_for_statement + * + * try (var reader = new FileReader(...)) { ... } // try_with_resources + * catch (IOException e) { ... } // catch_clause + * + * Supplier s = () -> { + * int lambdaVar = 5; // nested in lambda + * return lambdaVar; + * }; + * + * if (obj instanceof String s) { ... } // instanceof pattern + * } + * ``` + */ +export class LocalVariableExtractor { + private expressionExtractor: ExpressionReferenceExtractor; + private typeReferenceExtractor: TypeReferenceExtractor; + private annotationExtractor: AnnotationExtractor; + private extractedTypeReferences: TypeReference[] = []; + private extractedExpressions: ExpressionReference[] = []; + private extractedAnnotations: TypeAnnotation[] = []; + private extractedAnnotationArguments: AnnotationArgumentReference[] = []; + private extractedAnonymousClasses: AnonymousClassInfo[] = []; + private extractedBlocks: BlockRegistry[] = []; + + // Unified scope context for tracking hierarchy (method → lambda → block) + private scopeContext: ScopeContext = new ScopeContext(); + + // Method parameter names for PARAMETER classification in expressions + private currentMethodParamNames: Set = new Set(); + // Local variable names seen so far for LOCAL_VARIABLE classification in expressions + private currentLocalVariableNames: Set = new Set(); + // Pattern binding variable names for PATTERN_BINDING_VARIABLE classification in expressions + private currentPatternBindingNames: Set = new Set(); + // Expressions for lambda hash lookup (separate from extractedExpressions to avoid duplicates) + private expressionsForHashLookup: ExpressionReference[] = []; + + // When true, BlockRegistry entries are created in extractFrom*Statement methods. + // True for method bodies (Phase 2) and field initializer lambdas. + // False for static/instance initializers (no block extraction there). + private shouldCreateBlockEntries: boolean = false; + // When true, we're extracting from field initializer lambdas (no TypeMethodExtractor involvement). + // Statements already extracted by extractLambdaBodyStatementExpressions, keyed by byte range. + // + // A brace-less control-flow body is reached twice: once as the body itself, and once through + // the child loop's recursion that looks for nested lambdas. Extracting a statement is + // idempotent here rather than relying on every caller knowing which path it is on, because + // getting that wrong duplicates a call site rather than dropping one, and a duplicate is the + // harder failure to notice. + private extractedLambdaStatements: Set = new Set(); + + // Controls expression extraction bypass in extractLambdaBodyStatementExpressions. + // Separate from shouldCreateBlockEntries which only controls block creation. + private isFieldInitializerContext: boolean = false; + // The hash to use for block hash computation (methodRegistryHash for methods, fieldHash for field lambdas) + // Separate from methodRegistryHash parameter which is used for local variable method linkage + private blockMethodHash: string = ''; + // Tracks actual block nesting depth for BlockRegistry entries. + // Incremented when entering a block scope (for/if/try/etc), NOT for lambdas. + private blockNestingDepth: number = 0; + + constructor() { + this.expressionExtractor = new ExpressionReferenceExtractor(); + this.typeReferenceExtractor = new TypeReferenceExtractor(); + this.annotationExtractor = new AnnotationExtractor(); + } + + /** + * Returns all type references extracted during the last extraction + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all expressions extracted during the last extraction + */ + getExtractedExpressions(): ExpressionReference[] { + return this.extractedExpressions; + } + + /** + * Returns all annotations extracted during the last extraction + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns all annotation arguments extracted during the last extraction + */ + getExtractedAnnotationArguments(): AnnotationArgumentReference[] { + return this.extractedAnnotationArguments; + } + + /** + * Returns all anonymous classes encountered during the last extraction + */ + getExtractedAnonymousClasses(): AnonymousClassInfo[] { + return this.extractedAnonymousClasses; + } + + /** + * Returns all blocks extracted during the last extraction (from field initializer lambdas) + */ + getExtractedBlocks(): BlockRegistry[] { + return this.extractedBlocks; + } + + /** + * Resets all extracted collections + */ + resetExtractedCollections(): void { + this.extractedTypeReferences = []; + this.extractedExpressions = []; + this.extractedAnnotations = []; + this.extractedAnnotationArguments = []; + this.extractedAnonymousClasses = []; + this.extractedBlocks = []; + this.blockNestingDepth = 0; + this.extractedLambdaStatements.clear(); + } + + /** + * Extracts local variables from a method body + * @param methodParamNames Names of method parameters for PARAMETER classification in expressions + * @param extractedExpressions Expressions extracted from method body for hash lookup + */ + extractFromMethodBody( + bodyNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + scopeKind: LocalVariableScopeKind = LocalVariableScopeKind.METHOD_BODY, + methodParamNames: Set = new Set(), + extractedExpressions: ExpressionReference[] = [] + ): LocalVariableRegistry[] { + this.resetExtractedCollections(); + this.shouldCreateBlockEntries = true; + this.isFieldInitializerContext = false; + this.blockMethodHash = methodRegistryHash; + + // Store extracted expressions for hash lookup (switch expressions, lambdas, etc.) + this.expressionsForHashLookup = extractedExpressions; + + // Set method parameters for expression classification + this.currentMethodParamNames = methodParamNames; + // Reset local variable names - will be populated as we extract + this.currentLocalVariableNames = new Set(); + + const variables: LocalVariableRegistry[] = []; + const lambdaDepth = scopeKind === LocalVariableScopeKind.LAMBDA_BODY ? 1 : 0; + + this.extractFromBlock( + bodyNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + + return variables; + } + + /** + * Extracts local variables and return statements from lambda block bodies in field initializers + * @param extractedExpressions Already extracted expressions from the field initializer (to find lambda hashes) + */ + extractFromFieldInitializerLambdas( + initializerNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + fieldHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + extractedExpressions: ExpressionReference[] + ): LocalVariableRegistry[] { + this.resetExtractedCollections(); + this.shouldCreateBlockEntries = true; + this.isFieldInitializerContext = true; + this.blockMethodHash = fieldHash; + // Field initializer lambdas start at nesting depth 1 to match legacy lambdaDepth behavior + // (method bodies start at 0; the lambda entry itself counts as one level for field initializers) + this.blockNestingDepth = 1; + + // Store extracted expressions for lambda hash lookup (separate from extractedExpressions to avoid duplicates) + this.expressionsForHashLookup = extractedExpressions; + + // Reset tracking sets + this.currentMethodParamNames = new Set(); + this.currentLocalVariableNames = new Set(); + // ScopeContext handles lambda params and hash tracking + + const variables: LocalVariableRegistry[] = []; + + // Recursively find and process lambda expressions in the initializer + this.extractLambdasFromFieldInitializer( + initializerNode, + filePath, + typeRegistryHash, + fieldHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + 1, // lambdaDepth starts at 1 for field lambdas + variables + ); + + return variables; + } + + /** + * Recursively finds and extracts local variables from lambda block bodies in a field initializer + */ + private extractLambdasFromFieldInitializer( + node: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + fieldHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + if (node.type === 'lambda_expression') { + // Process this lambda's block body using the unified extractFromLambdaExpression path + // (shouldCreateBlockEntries + blockMethodHash are set at entry point, so block entries + // and expression extraction will be handled correctly for the field context) + this.extractFromLambdaExpression( + node, + filePath, + typeRegistryHash, + undefined, // no method for field lambdas + ownerTypeName, + ownerQualifiedName, + undefined, // no method name + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } else if (node.type === 'switch_expression') { + // Process switch expression blocks (case -> { ... yield ... }) + this.extractFromFieldSwitchExpressionBlocks( + node, + filePath, + typeRegistryHash, + fieldHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } else { + // Recursively search children for lambda expressions and switch expressions + for (const child of node.children) { + this.extractLambdasFromFieldInitializer( + child, + filePath, + typeRegistryHash, + fieldHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } + } + } + + + /** + * Extracts local variables from a static initializer block + */ + extractFromStaticInitializer( + bodyNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): LocalVariableRegistry[] { + this.resetExtractedCollections(); + this.shouldCreateBlockEntries = true; + this.blockMethodHash = methodRegistryHash; + + const variables: LocalVariableRegistry[] = []; + + this.extractFromBlock( + bodyNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + '', + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.STATIC_INITIALIZER, + 0, + variables, + 0, + methodRegistryHash // parentExpressionLinkHash for initializer blocks + ); + + return variables; + } + + /** + * Extracts local variables from an instance initializer block + */ + extractFromInstanceInitializer( + bodyNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): LocalVariableRegistry[] { + this.resetExtractedCollections(); + this.shouldCreateBlockEntries = true; + this.blockMethodHash = methodRegistryHash; + + const variables: LocalVariableRegistry[] = []; + + this.extractFromBlock( + bodyNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + '', + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.INSTANCE_INITIALIZER, + 0, + variables, + 0, + methodRegistryHash // parentExpressionLinkHash for initializer blocks + ); + + return variables; + } + + /** + * Recursively extracts local variables from a block and its nested statements + * @param parentExpressionLinkHash - Optional hash to use for parentExpressionLinkHash when ScopeContext is not set up (e.g., initializer blocks) + */ + private extractFromBlock( + node: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + scopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[], + blockDepth: number = 0, + parentExpressionLinkHash?: string + ): void { + // A brace-less control-flow body IS the statement, not a `block` wrapping it: `if (c) x();` + // hands this method the `expression_statement` itself. These statement types used to be + // recognised only where they appeared as a CHILD of a block, so a lambda that initializes a + // local variable or a field lost every statement in an unbraced `if` / `for` / `while` / + // `do` body — no row at all, which a consumer cannot tell from a lambda body that really is + // empty. The same lambda passed as an ARGUMENT was always extracted, which is what made + // this easy to miss. + // + // Handled here rather than only in the child walk, and the child walk below now delegates + // to this one path, so a statement is extracted exactly once however it was reached. + // Execution falls through to that walk afterwards, which is what finds lambdas nested + // inside the statement. + if (node.type === 'expression_statement' || node.type === 'return_statement' || + node.type === 'yield_statement' || node.type === 'throw_statement') { + this.extractLambdaBodyStatementExpressions( + node, typeRegistryHash, serviceVersionHash, packageName, importMap, hasStarImports); + } + + // When called on a non-block statement node (e.g., a while_statement that is + // the brace-less body of a for loop), dispatch to its dedicated handler so it + // creates the proper block entry and processes its contents correctly. + switch (node.type) { + case 'if_statement': + this.extractPatternsFromStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, lambdaDepth, variables); + this.extractFromIfStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, scopeKind, lambdaDepth, variables); + return; + case 'for_statement': + this.extractPatternsFromStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, lambdaDepth, variables); + this.extractFromForStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, scopeKind, lambdaDepth, variables); + return; + case 'enhanced_for_statement': + this.extractFromEnhancedForStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, scopeKind, lambdaDepth, variables); + return; + case 'while_statement': + case 'do_statement': + this.extractPatternsFromStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, lambdaDepth, variables); + this.extractFromWhileStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, scopeKind, lambdaDepth, variables); + return; + case 'try_statement': + case 'try_with_resources_statement': + this.extractFromTryStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, scopeKind, lambdaDepth, variables); + return; + case 'switch_expression': + case 'switch_statement': + this.extractFromSwitchStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, scopeKind, lambdaDepth, variables); + return; + case 'synchronized_statement': + this.extractPatternsFromStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, lambdaDepth, variables); + this.extractFromSynchronizedStatement(node, filePath, typeRegistryHash, methodRegistryHash, ownerTypeName, ownerQualifiedName, ownerMethodName, serviceVersionHash, packageName, importMap, hasStarImports, scopeKind, lambdaDepth, variables); + return; + } + + for (const child of node.children) { + switch (child.type) { + case 'local_variable_declaration': + this.extractLocalVariableDeclaration( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables, + blockDepth, + parentExpressionLinkHash + ); + break; + + case 'for_statement': + this.extractFromForStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + break; + + case 'enhanced_for_statement': + this.extractFromEnhancedForStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + break; + + case 'try_statement': + case 'try_with_resources_statement': + this.extractFromTryStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + break; + + case 'catch_clause': + this.extractFromCatchClause( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + break; + + case 'lambda_expression': + this.extractFromLambdaExpression( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth + 1, + variables + ); + break; + + case 'if_statement': + // Check for instanceof pattern in condition + this.extractPatternsFromStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + // Handle IF block with proper scope context + this.extractFromIfStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + break; + + case 'while_statement': + case 'do_statement': + // Check for instanceof pattern in condition + this.extractPatternsFromStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + // Handle WHILE/DO_WHILE block with proper scope context + this.extractFromWhileStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + break; + + case 'switch_expression': + case 'switch_statement': + // Use dedicated method with proper block scope tracking + this.extractFromSwitchStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + break; + + case 'synchronized_statement': + // Check for instanceof pattern in condition + this.extractPatternsFromStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + // Handle synchronized block with proper scope context + this.extractFromSynchronizedStatement( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + break; + + case 'block': + // Recurse into nested blocks with incremented block depth + this.extractFromBlock( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables, + blockDepth + 1 + ); + break; + + case 'return_statement': + case 'expression_statement': + case 'throw_statement': + case 'yield_statement': + // Recurse into the single path at the top of this method, which extracts the + // statement's own expressions and then descends for nested lambdas (e.g. + // `return y -> { int sum = ...; }`). Extracting here as well would double every row. + this.extractFromBlock( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + break; + + case 'class_body': { + // Anonymous class bodies (parent is object_creation_expression) are + // fully handled by TypeMethodExtractor.extractFromAnonymousClassBody, + // which extracts methods, local variables, blocks, and expressions + // with the correct anonymous-class method hash. Recursing here would + // produce duplicates linked to the OUTER method with phantom block hashes. + // + // Local class bodies (parent is class_declaration) are NOT handled + // elsewhere, so we must recurse to extract their variables/expressions. + const isAnonymousClassBody = child.parent?.type === 'object_creation_expression'; + if (!isAnonymousClassBody) { + const savedCreateBlocks = this.shouldCreateBlockEntries; + this.shouldCreateBlockEntries = false; + this.extractFromBlock( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + this.shouldCreateBlockEntries = savedCreateBlocks; + } + break; + } + + case 'method_declaration': + case 'constructor_declaration': + // Method/constructor declarations inside class bodies are fully handled + // by TypeMethodExtractor. Recursing here would create duplicate local + // variables linked to the OUTER method with phantom block hashes. + // These nodes only appear as children of class_body during local class + // recursion — never directly inside a method body block. + break; + + default: + // For other statement types, recurse to find nested declarations + if (child.children.length > 0) { + this.extractFromBlock( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + } + break; + } + } + } + + /** + * Extracts a local variable declaration + * Handles multi-declaration: int x, y, z; + * @param fallbackParentExpressionLinkHash - Optional hash to use for parentExpressionLinkHash when ScopeContext is not set up + */ + private extractLocalVariableDeclaration( + declNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + scopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[], + blockDepth: number = 0, + fallbackParentExpressionLinkHash?: string + ): void { + // Check for final modifier + const isFinal = this.hasFinalModifier(declNode); + + // Extract the type node + const typeNode = this.findTypeNode(declNode); + if (!typeNode) return; + + // Check for var inference + const isVarInferred = typeNode.type === 'type_identifier' && typeNode.text === 'var'; + + // Get the full type name as written + const variableTypeName = EntityUtils.normalizeWhitespace(typeNode.text); + const variableBaseType = this.extractBaseType(variableTypeName); + + // Resolve qualified name (skip for var - type is inferred) + let potentialQualifiedName: string | undefined; + let isAmbiguous = false; + if (!isVarInferred) { + const resolved = resolveTypeQualifiedName( + variableBaseType, + packageName, + importMap, + hasStarImports + ); + potentialQualifiedName = resolved.potentialQualifiedName ?? undefined; + isAmbiguous = resolved.isAmbiguous; + } + + // Find all variable declarators + const declarators = declNode.children.filter( + child => child.type === 'variable_declarator' + ); + + for (const declarator of declarators) { + const nameNode = declarator.children.find(c => c.type === 'identifier'); + if (!nameNode) continue; + + const varName = nameNode.text; + const startLine = declarator.startPosition.row + 1; + const endLine = declarator.endPosition.row + 1; + + // Handle C-style array declarations + const cStyleDimensions = declarator.children.find(c => c.type === 'dimensions'); + let actualTypeName = variableTypeName; + let actualBaseType = variableBaseType; + if (cStyleDimensions) { + actualTypeName = variableTypeName + cStyleDimensions.text; + actualBaseType = this.extractBaseType(actualTypeName); + } + + // Determine effective scope kind using ScopeContext + const scopeContextKind = this.scopeContext.getCurrentScopeKind(); + let effectiveScopeKind: LocalVariableScopeKind; + + // Check if the passed scopeKind is an explicit loop/special scope that should be preserved + const isExplicitLoopScope = scopeKind === LocalVariableScopeKind.FOR_LOOP || + scopeKind === LocalVariableScopeKind.ENHANCED_FOR_LOOP || + scopeKind === LocalVariableScopeKind.TRY_WITH_RESOURCES; + + if (isExplicitLoopScope) { + // For loop variables and try-with-resources - use the explicitly passed scope kind + effectiveScopeKind = scopeKind; + } else if (this.scopeContext.isInsideBlock()) { + // Inside a block (try/catch/finally/if/for/etc.) - use block scope kind from ScopeContext + effectiveScopeKind = scopeContextKind; + } else if (lambdaDepth > 0) { + // Inside lambda but not in a block within lambda + effectiveScopeKind = LocalVariableScopeKind.LAMBDA_BODY; + } else { + // Use the passed scope kind (METHOD_BODY, etc.) + effectiveScopeKind = scopeKind; + } + + // Build the local variable entity + const varBuilder = LocalVariableRegistry.builder( + varName, + actualTypeName, + actualBaseType, + filePath, + startLine, + endLine, + effectiveScopeKind, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + + if (potentialQualifiedName) { + varBuilder.withPotentialQualifiedName(potentialQualifiedName); + } + varBuilder.withIsAmbiguous(isAmbiguous); + varBuilder.withScopeDepth(lambdaDepth + blockDepth); + varBuilder.withIsFinal(isFinal); + varBuilder.withIsVarInferred(isVarInferred); + + if (methodRegistryHash) { + varBuilder.withMethodRegistryLinkHash(methodRegistryHash); + } + if (ownerMethodName) { + varBuilder.withOwnerMethodName(ownerMethodName); + } + // Link to containing scope (block > lambda > method) using ScopeContext + // For explicit loop scopes (FOR_LOOP, ENHANCED_FOR_LOOP), prefer the explicitly passed block hash + // Fall back to ScopeContext for regular variables, or explicit hash for initializer blocks + let ownerHash: string | undefined; + if (isExplicitLoopScope && fallbackParentExpressionLinkHash) { + // Loop variables should be linked to their loop block, not the containing lambda + ownerHash = fallbackParentExpressionLinkHash; + } else { + ownerHash = this.scopeContext.getCurrentOwnerHash() || fallbackParentExpressionLinkHash; + } + if (ownerHash) { + varBuilder.withParentExpressionLinkHash(ownerHash); + } + + const localVar = varBuilder.build(); + variables.push(localVar); + + // Add variable name to tracking set for expression classification + this.currentLocalVariableNames.add(varName); + + // Extract annotations from the local variable declaration + this.extractLocalVariableAnnotations( + declNode, + typeRegistryHash, + localVar.getHash() + ); + + // Extract type references for the variable type + this.extractVariableTypeReferences( + typeNode, + typeRegistryHash, + localVar.getHash(), + packageName + ); + + // Extract expressions from variable initializer (if present) + // Skip comment nodes (line_comment, block_comment) that may appear between = and the actual initializer + // e.g.: List verifiers = // comment\n new ArrayList<>(...); + const equalsIndex = declarator.children.findIndex(c => c.type === '='); + if (equalsIndex >= 0 && equalsIndex < declarator.children.length - 1) { + const initializerNode = declarator.children.slice(equalsIndex + 1) + .find(c => c.type !== 'line_comment' && c.type !== 'block_comment'); + if (initializerNode) { + this.extractInitializerExpressions( + initializerNode, + typeRegistryHash, + localVar.getHash(), + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + + // Extract local variables from lambda block bodies in the initializer + this.extractLambdasFromInitializer( + initializerNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables, + localVar.getHash() + ); + } + } + } + } + + /** + * Extracts variables from a for statement + * Example: for (int i = 0; i < 10; i++) + */ + private extractFromForStatement( + forNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + _parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Compute block hash for the for loop body (both braced and brace-less) + const forBody = forNode.childForFieldName('body'); + let forBlockHash: string | null = null; + if (forBody && this.blockMethodHash) { + forBlockHash = BlockRegistry.computeHash( + BlockKind.FOR, + filePath, + forBody.startPosition.row + 1, + forBody.endPosition.row + 1, + forBody.startPosition.column, + forBody.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + + // Create BlockRegistry entry when block creation is enabled + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.FOR, this.extractedBlocks.length, filePath, + forBody.startPosition.row + 1, forBody.endPosition.row + 1, + forBody.startPosition.column, forBody.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const parentHash = this.scopeContext.getCurrentOwnerHash(); + if (parentHash) blockEntry.withParentContainerHash(parentHash); + this.extractedBlocks.push(blockEntry.build()); + } + } + + // Find the init part of for loop (local_variable_declaration) + for (const child of forNode.children) { + if (child.type === 'local_variable_declaration') { + // For loop variable declaration - use FOR_LOOP scope and link to FOR block + this.extractLocalVariableDeclaration( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.FOR_LOOP, + lambdaDepth, + variables, + 0, // blockDepth + forBlockHash ?? undefined // Link for loop variable to FOR block + ); + } + } + + // Recurse into the loop body + const body = forNode.childForFieldName('body'); + if (body && body.type === 'block') { + // Enter FOR block scope before recursing + if (forBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(forBlockHash, BlockKind.FOR, LocalVariableScopeKind.FOR_BLOCK); + } + this.extractFromBlock( + body, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.FOR_BLOCK, + lambdaDepth, + variables + ); + if (forBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } else if (body) { + // Non-block body (single statement) - still enter FOR scope so expressions link to FOR block hash + if (forBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(forBlockHash, BlockKind.FOR, LocalVariableScopeKind.FOR_BLOCK); + } + this.extractFromBlock( + body, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.FOR_BLOCK, + lambdaDepth, + variables + ); + if (forBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + } + + /** + * Extracts variables from an if statement with proper block scope tracking + */ + private extractFromIfStatement( + ifNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Handle consequence (then branch) - both braced and brace-less + const consequence = ifNode.childForFieldName('consequence'); + if (consequence && this.blockMethodHash) { + const ifBlockHash = BlockRegistry.computeHash( + BlockKind.IF, + filePath, + consequence.startPosition.row + 1, + consequence.endPosition.row + 1, + consequence.startPosition.column, + consequence.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.IF, this.extractedBlocks.length, filePath, + consequence.startPosition.row + 1, consequence.endPosition.row + 1, + consequence.startPosition.column, consequence.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const parentHash = this.scopeContext.getCurrentOwnerHash(); + if (parentHash) blockEntry.withParentContainerHash(parentHash); + this.extractedBlocks.push(blockEntry.build()); + } + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(ifBlockHash, BlockKind.IF, LocalVariableScopeKind.IF_BLOCK); + this.extractFromBlock( + consequence, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.IF_BLOCK, + lambdaDepth, + variables + ); + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } else if (consequence) { + // No blockMethodHash available - still recurse without scope tracking + this.extractFromBlock( + consequence, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + } + + // Handle alternative (else branch) - use while loop to iterate through all else-ifs + let currentAlt = ifNode.childForFieldName('alternative'); + while (currentAlt && currentAlt.type === 'if_statement') { + // else if - handle with ELSE_IF block kind + const elseIfConsequence = currentAlt.childForFieldName('consequence'); + if (elseIfConsequence && this.blockMethodHash) { + const elseIfBlockHash = BlockRegistry.computeHash( + BlockKind.ELSE_IF, + filePath, + elseIfConsequence.startPosition.row + 1, + elseIfConsequence.endPosition.row + 1, + elseIfConsequence.startPosition.column, + elseIfConsequence.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.ELSE_IF, this.extractedBlocks.length, filePath, + elseIfConsequence.startPosition.row + 1, elseIfConsequence.endPosition.row + 1, + elseIfConsequence.startPosition.column, elseIfConsequence.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const parentHash = this.scopeContext.getCurrentOwnerHash(); + if (parentHash) blockEntry.withParentContainerHash(parentHash); + this.extractedBlocks.push(blockEntry.build()); + } + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(elseIfBlockHash, BlockKind.ELSE_IF, LocalVariableScopeKind.IF_BLOCK); + this.extractFromBlock( + elseIfConsequence, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.IF_BLOCK, + lambdaDepth, + variables + ); + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } else if (elseIfConsequence) { + // No blockMethodHash available - still recurse without scope tracking + this.extractFromBlock( + elseIfConsequence, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + } + + // Move to the next alternative in the chain + const nextAlt = currentAlt.childForFieldName('alternative'); + if (nextAlt && nextAlt.type === 'if_statement') { + currentAlt = nextAlt; + } else if (nextAlt && this.blockMethodHash) { + // Final else (braced or brace-less) + const elseBlockHash = BlockRegistry.computeHash( + BlockKind.ELSE, + filePath, + nextAlt.startPosition.row + 1, + nextAlt.endPosition.row + 1, + nextAlt.startPosition.column, + nextAlt.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.ELSE, this.extractedBlocks.length, filePath, + nextAlt.startPosition.row + 1, nextAlt.endPosition.row + 1, + nextAlt.startPosition.column, nextAlt.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const parentHash = this.scopeContext.getCurrentOwnerHash(); + if (parentHash) blockEntry.withParentContainerHash(parentHash); + this.extractedBlocks.push(blockEntry.build()); + } + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(elseBlockHash, BlockKind.ELSE, LocalVariableScopeKind.ELSE_BLOCK); + this.extractFromBlock( + nextAlt, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.ELSE_BLOCK, + lambdaDepth, + variables + ); + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + currentAlt = null; + } else if (nextAlt) { + // No blockMethodHash available - still recurse without scope tracking + this.extractFromBlock( + nextAlt, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + currentAlt = null; + } else { + currentAlt = null; + } + } + + // Handle direct else only if alternative is NOT an if_statement (else-if chain handles that case) + const directAlt = ifNode.childForFieldName('alternative'); + if (directAlt && directAlt.type !== 'if_statement') { + if (this.blockMethodHash) { + const elseBlockHash = BlockRegistry.computeHash( + BlockKind.ELSE, + filePath, + directAlt.startPosition.row + 1, + directAlt.endPosition.row + 1, + directAlt.startPosition.column, + directAlt.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.ELSE, this.extractedBlocks.length, filePath, + directAlt.startPosition.row + 1, directAlt.endPosition.row + 1, + directAlt.startPosition.column, directAlt.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const parentHash = this.scopeContext.getCurrentOwnerHash(); + if (parentHash) blockEntry.withParentContainerHash(parentHash); + this.extractedBlocks.push(blockEntry.build()); + } + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(elseBlockHash, BlockKind.ELSE, LocalVariableScopeKind.ELSE_BLOCK); + this.extractFromBlock( + directAlt, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.ELSE_BLOCK, + lambdaDepth, + variables + ); + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } else { + // No blockMethodHash available - still recurse without scope tracking + this.extractFromBlock( + directAlt, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + } + } + } + + /** + * Extracts variables from a while/do-while statement with proper block scope tracking + */ + private extractFromWhileStatement( + whileNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + const isDoWhile = whileNode.type === 'do_statement'; + const blockKind = isDoWhile ? BlockKind.DO_WHILE : BlockKind.WHILE; + const scopeKind = isDoWhile ? LocalVariableScopeKind.DO_WHILE_BLOCK : LocalVariableScopeKind.WHILE_BLOCK; + + const body = whileNode.childForFieldName('body'); + if (body && this.blockMethodHash) { + const blockHash = BlockRegistry.computeHash( + blockKind, + filePath, + body.startPosition.row + 1, + body.endPosition.row + 1, + body.startPosition.column, + body.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + blockKind, this.extractedBlocks.length, filePath, + body.startPosition.row + 1, body.endPosition.row + 1, + body.startPosition.column, body.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const whileParentHash = this.scopeContext.getCurrentOwnerHash(); + if (whileParentHash) blockEntry.withParentContainerHash(whileParentHash); + this.extractedBlocks.push(blockEntry.build()); + } + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(blockHash, blockKind, scopeKind); + this.extractFromBlock( + body, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + lambdaDepth, + variables + ); + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } else if (body) { + // No blockMethodHash available - still recurse without scope tracking + this.extractFromBlock( + body, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + } + } + + /** + * Extracts variables from a synchronized statement with proper block scope tracking + */ + private extractFromSynchronizedStatement( + syncNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + const body = syncNode.childForFieldName('body'); + if (body && body.type === 'block' && this.blockMethodHash) { + const blockHash = BlockRegistry.computeHash( + BlockKind.SYNCHRONIZED, + filePath, + body.startPosition.row + 1, + body.endPosition.row + 1, + body.startPosition.column, + body.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.SYNCHRONIZED, this.extractedBlocks.length, filePath, + body.startPosition.row + 1, body.endPosition.row + 1, + body.startPosition.column, body.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const syncParentHash2 = this.scopeContext.getCurrentOwnerHash(); + if (syncParentHash2) blockEntry.withParentContainerHash(syncParentHash2); + this.extractedBlocks.push(blockEntry.build()); + } + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(blockHash, BlockKind.SYNCHRONIZED, LocalVariableScopeKind.SYNCHRONIZED_BLOCK); + this.extractFromBlock( + body, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.SYNCHRONIZED_BLOCK, + lambdaDepth, + variables + ); + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } else if (body) { + // Single statement without braces - use parent scope kind + this.extractFromBlock( + body, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + } + } + + /** + * Extracts variables from a switch statement/expression with proper block scope tracking. + * + * Handles both syntaxes: + * - Colon syntax: switch_block_statement_group nodes → SWITCH_CASE blocks + * - Arrow syntax: switch_rule nodes with block body → SWITCH_EXPRESSION_CASE blocks + * + * Each case group/rule gets its own BlockRegistry entry so that local variables + * and expressions inside it link to the case block, not the method directly. + */ + private extractFromSwitchStatement( + switchNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Extract patterns from switch (e.g., case Integer i ->) + this.extractPatternsFromStatement( + switchNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + + // Find the switch_block containing case groups or rules + const switchBlock = switchNode.children.find(c => c.type === 'switch_block'); + if (!switchBlock) { + // Fallback: recurse generically if no switch_block found + this.extractFromBlock( + switchNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + return; + } + + for (const child of switchBlock.children) { + if (child.type === 'switch_block_statement_group') { + // Traditional colon syntax: case 1: ... break; + // Use the entire group node position for the SWITCH_CASE block + this.extractFromSwitchCaseGroup( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + } else if (child.type === 'switch_rule') { + // Arrow syntax: case 1 -> { ... } or case 1 -> expr; + this.extractFromSwitchRule( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + } + } + } + + /** + * Extracts variables from a traditional switch case group (colon syntax). + * Creates a SWITCH_CASE BlockRegistry entry for the group. + * + * AST structure: + * switch_block_statement_group + * switch_label ("case 1") + * : (colon) + * local_variable_declaration / expression_statement / break_statement ... + */ + private extractFromSwitchCaseGroup( + groupNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + _parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + let caseBlockHash: string | null = null; + + if (this.blockMethodHash) { + caseBlockHash = BlockRegistry.computeHash( + BlockKind.SWITCH_CASE, + filePath, + groupNode.startPosition.row + 1, + groupNode.endPosition.row + 1, + groupNode.startPosition.column, + groupNode.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.SWITCH_CASE, this.extractedBlocks.length, filePath, + groupNode.startPosition.row + 1, groupNode.endPosition.row + 1, + groupNode.startPosition.column, groupNode.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const parentHash = this.scopeContext.getCurrentOwnerHash(); + if (parentHash) blockEntry.withParentContainerHash(parentHash); + this.extractedBlocks.push(blockEntry.build()); + } + } + + // Enter SWITCH_CASE scope + if (caseBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(caseBlockHash, BlockKind.SWITCH_CASE, LocalVariableScopeKind.SWITCH_BLOCK); + } + + // Extract patterns from the case group (e.g., case Integer i:) + this.extractPatternsFromStatement( + groupNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + + // Process statements within the case group + this.extractFromBlock( + groupNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.SWITCH_BLOCK, + lambdaDepth, + variables + ); + + // Exit SWITCH_CASE scope + if (caseBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + + /** + * Extracts variables from a switch expression rule (arrow syntax). + * Creates a SWITCH_EXPRESSION_CASE BlockRegistry entry when the rule has a block body. + * + * AST structure: + * switch_rule + * switch_label ("case 2") + * -> (arrow) + * block { ... } / expression_statement "expr;" + */ + private extractFromSwitchRule( + ruleNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + const block = ruleNode.children.find(c => c.type === 'block'); + if (!block) { + // No block body (e.g., case 1 -> "one";) — recurse generically for lambdas + this.extractFromBlock( + ruleNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + return; + } + + // Block body: case 2 -> { ... yield ...; } + let caseBlockHash: string | null = null; + + if (this.blockMethodHash) { + caseBlockHash = BlockRegistry.computeHash( + BlockKind.SWITCH_EXPRESSION_CASE, + filePath, + block.startPosition.row + 1, + block.endPosition.row + 1, + block.startPosition.column, + block.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.SWITCH_EXPRESSION_CASE, this.extractedBlocks.length, filePath, + block.startPosition.row + 1, block.endPosition.row + 1, + block.startPosition.column, block.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const parentHash = this.scopeContext.getCurrentOwnerHash(); + if (parentHash) blockEntry.withParentContainerHash(parentHash); + this.extractedBlocks.push(blockEntry.build()); + } + } + + // Enter SWITCH_EXPRESSION_CASE scope + if (caseBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(caseBlockHash, BlockKind.SWITCH_EXPRESSION_CASE, LocalVariableScopeKind.SWITCH_BLOCK); + } + + // Extract and add pattern binding names from this switch rule + const patternBindingNames = this.extractSwitchRulePatternBindingNames(ruleNode); + for (const name of patternBindingNames) { + this.currentPatternBindingNames.add(name); + } + + // Process the block body + this.extractFromBlock( + block, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.SWITCH_BLOCK, + lambdaDepth, + variables + ); + + // Remove this rule's pattern bindings after processing + for (const name of patternBindingNames) { + this.currentPatternBindingNames.delete(name); + } + + // Exit SWITCH_EXPRESSION_CASE scope + if (caseBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + + /** + * Extracts variables from an enhanced for statement + * Example: for (String item : items) + */ + private extractFromEnhancedForStatement( + forNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + _parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Enhanced for has structure: for ( [modifiers] type identifier : expression ) statement + const typeNode = this.findTypeNode(forNode); + const nameNode = forNode.children.find(c => c.type === 'identifier'); + + // Compute block hash for enhanced_for body FIRST so we can link the loop variable to it + // Works for both braced and brace-less bodies + const body = forNode.childForFieldName('body'); + let enhancedForBlockHash: string | null = null; + if (body && this.blockMethodHash) { + enhancedForBlockHash = BlockRegistry.computeHash( + BlockKind.ENHANCED_FOR, + filePath, + body.startPosition.row + 1, + body.endPosition.row + 1, + body.startPosition.column, + body.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.ENHANCED_FOR, this.extractedBlocks.length, filePath, + body.startPosition.row + 1, body.endPosition.row + 1, + body.startPosition.column, body.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const enhForParentHash = this.scopeContext.getCurrentOwnerHash(); + if (enhForParentHash) blockEntry.withParentContainerHash(enhForParentHash); + this.extractedBlocks.push(blockEntry.build()); + } + } + + if (typeNode && nameNode) { + const isFinal = this.hasFinalModifier(forNode); + const isVarInferred = typeNode.type === 'type_identifier' && typeNode.text === 'var'; + + const variableTypeName = EntityUtils.normalizeWhitespace(typeNode.text); + const variableBaseType = this.extractBaseType(variableTypeName); + + let potentialQualifiedName: string | undefined; + let isAmbiguous = false; + if (!isVarInferred) { + const resolved = resolveTypeQualifiedName( + variableBaseType, + packageName, + importMap, + hasStarImports + ); + potentialQualifiedName = resolved.potentialQualifiedName ?? undefined; + isAmbiguous = resolved.isAmbiguous; + } + + const startLine = nameNode.startPosition.row + 1; + const endLine = nameNode.endPosition.row + 1; + + const varBuilder = LocalVariableRegistry.builder( + nameNode.text, + variableTypeName, + variableBaseType, + filePath, + startLine, + endLine, + LocalVariableScopeKind.ENHANCED_FOR_LOOP, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + + if (potentialQualifiedName) { + varBuilder.withPotentialQualifiedName(potentialQualifiedName); + } + varBuilder.withIsAmbiguous(isAmbiguous); + varBuilder.withScopeDepth(lambdaDepth); + varBuilder.withIsFinal(isFinal); + varBuilder.withIsVarInferred(isVarInferred); + + if (methodRegistryHash) { + varBuilder.withMethodRegistryLinkHash(methodRegistryHash); + } + if (ownerMethodName) { + varBuilder.withOwnerMethodName(ownerMethodName); + } + // Link enhanced_for loop variable to its block + if (enhancedForBlockHash) { + varBuilder.withParentExpressionLinkHash(enhancedForBlockHash); + } + + const localVar = varBuilder.build(); + variables.push(localVar); + + // Add enhanced for-loop variable name to tracking set for expression classification + this.currentLocalVariableNames.add(nameNode.text); + + // Extract type references + this.extractVariableTypeReferences( + typeNode, + typeRegistryHash, + localVar.getHash(), + packageName + ); + } + + // Recurse into the loop body + const enhForBody = forNode.childForFieldName('body'); + if (enhForBody && enhForBody.type === 'block') { + // Enter ENHANCED_FOR block scope before recursing + if (enhancedForBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(enhancedForBlockHash, BlockKind.ENHANCED_FOR, LocalVariableScopeKind.FOR_BLOCK); + } + this.extractFromBlock( + enhForBody, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.FOR_BLOCK, + lambdaDepth, + variables + ); + if (enhancedForBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } else if (enhForBody) { + // Non-block body (single statement) - still enter ENHANCED_FOR scope so expressions link to block hash + if (enhancedForBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(enhancedForBlockHash, BlockKind.ENHANCED_FOR, LocalVariableScopeKind.FOR_BLOCK); + } + this.extractFromBlock( + enhForBody, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.FOR_BLOCK, + lambdaDepth, + variables + ); + if (enhancedForBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + } + + /** + * Extracts variables from a try statement (including try-with-resources) + */ + private extractFromTryStatement( + tryNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + parentScopeKind: LocalVariableScopeKind, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Determine block kind and compute hash for try block + const isTryWithResources = tryNode.type === 'try_with_resources_statement'; + const tryKind = isTryWithResources ? BlockKind.TRY_WITH_RESOURCES : BlockKind.TRY; + + // Find the try body block to compute its hash + const tryBodyBlock = tryNode.children.find(c => c.type === 'block'); + let tryBlockHash: string | undefined; + if (tryBodyBlock && this.blockMethodHash) { + tryBlockHash = BlockRegistry.computeHash( + tryKind, filePath, + tryBodyBlock.startPosition.row + 1, tryBodyBlock.endPosition.row + 1, + tryBodyBlock.startPosition.column, tryBodyBlock.endPosition.column, + typeRegistryHash, this.blockMethodHash + ); + + if (this.shouldCreateBlockEntries) { + const tryBlockBuilder = BlockRegistry.builder( + tryKind, this.extractedBlocks.length, filePath, + tryBodyBlock.startPosition.row + 1, tryBodyBlock.endPosition.row + 1, + tryBodyBlock.startPosition.column, tryBodyBlock.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth) + .withTryStatementHash(tryBlockHash); + const tryParentHash = this.scopeContext.getCurrentOwnerHash(); + if (tryParentHash) tryBlockBuilder.withParentContainerHash(tryParentHash); + + // Count resources for try-with-resources + if (isTryWithResources) { + const resourceSpec = tryNode.children.find(c => c.type === 'resource_specification'); + if (resourceSpec) { + const resourceCount = resourceSpec.children.filter(c => c.type === 'resource').length; + tryBlockBuilder.withResourceCount(resourceCount); + } + } + + this.extractedBlocks.push(tryBlockBuilder.build()); + } + } + + for (const child of tryNode.children) { + // Resource specification in try-with-resources + if (child.type === 'resource_specification') { + // Enter try block scope using ScopeContext + if (tryBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(tryBlockHash, tryKind, LocalVariableScopeKind.TRY_BLOCK); + } + this.extractFromResourceSpecification( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + // Exit resource spec scope + if (tryBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + // Try body block + else if (child.type === 'block') { + // Enter try block scope using ScopeContext + if (tryBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(tryBlockHash, tryKind, LocalVariableScopeKind.TRY_BLOCK); + } + this.extractFromBlock( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + // Exit try block scope + if (tryBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + // Catch clauses + else if (child.type === 'catch_clause') { + // Compute catch block hash + const catchBody = child.children.find(c => c.type === 'block'); + let catchBlockHash: string | undefined; + if (catchBody && this.blockMethodHash) { + catchBlockHash = BlockRegistry.computeHash( + BlockKind.CATCH, filePath, + catchBody.startPosition.row + 1, catchBody.endPosition.row + 1, + catchBody.startPosition.column, catchBody.endPosition.column, + typeRegistryHash, this.blockMethodHash + ); + + if (this.shouldCreateBlockEntries) { + const catchFormalParam = child.children.find(c => c.type === 'catch_formal_parameter'); + let caughtTypes = ''; + if (catchFormalParam) { + const catchType = catchFormalParam.children.find(c => c.type === 'catch_type' || c.type === 'type_identifier'); + if (catchType) caughtTypes = EntityUtils.normalizeWhitespace(catchType.text); + } + const catchBlockBuilder = BlockRegistry.builder( + BlockKind.CATCH, this.extractedBlocks.length, filePath, + catchBody.startPosition.row + 1, catchBody.endPosition.row + 1, + catchBody.startPosition.column, catchBody.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const catchParentHash = this.scopeContext.getCurrentOwnerHash(); + if (catchParentHash) catchBlockBuilder.withParentContainerHash(catchParentHash); + if (tryBlockHash) catchBlockBuilder.withTryStatementHash(tryBlockHash); + if (caughtTypes) catchBlockBuilder.withCaughtExceptionTypes(caughtTypes); + this.extractedBlocks.push(catchBlockBuilder.build()); + } + + // Enter catch block scope using ScopeContext + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(catchBlockHash, BlockKind.CATCH, LocalVariableScopeKind.CATCH_BLOCK); + } + this.extractFromCatchClause( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + // Exit catch block scope + if (catchBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + // Finally block + else if (child.type === 'finally_clause') { + const finallyBlock = child.children.find(c => c.type === 'block'); + if (finallyBlock) { + // Compute finally block hash + const finallyBlockHash = this.blockMethodHash ? BlockRegistry.computeHash( + BlockKind.FINALLY, filePath, + finallyBlock.startPosition.row + 1, finallyBlock.endPosition.row + 1, + finallyBlock.startPosition.column, finallyBlock.endPosition.column, + typeRegistryHash, this.blockMethodHash + ) : undefined; + + if (this.shouldCreateBlockEntries && finallyBlockHash) { + const finallyBlockBuilder = BlockRegistry.builder( + BlockKind.FINALLY, this.extractedBlocks.length, filePath, + finallyBlock.startPosition.row + 1, finallyBlock.endPosition.row + 1, + finallyBlock.startPosition.column, finallyBlock.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + const finallyParentHash = this.scopeContext.getCurrentOwnerHash(); + if (finallyParentHash) finallyBlockBuilder.withParentContainerHash(finallyParentHash); + if (tryBlockHash) finallyBlockBuilder.withTryStatementHash(tryBlockHash); + this.extractedBlocks.push(finallyBlockBuilder.build()); + } + + // Enter finally block scope using ScopeContext + if (finallyBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(finallyBlockHash, BlockKind.FINALLY, LocalVariableScopeKind.FINALLY_BLOCK); + } + this.extractFromBlock( + finallyBlock, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + parentScopeKind, + lambdaDepth, + variables + ); + // Exit finally block scope + this.scopeContext.exit(); + if (finallyBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + } + } + } + + /** + * Extracts variables from try-with-resources specification + * Example: try (var reader = new BufferedReader(...)) + */ + private extractFromResourceSpecification( + resourceSpec: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + for (const child of resourceSpec.children) { + if (child.type === 'resource') { + // Resource has structure: [final] type identifier = expression + const typeNode = this.findTypeNode(child); + const nameNode = child.children.find(c => c.type === 'identifier'); + + if (typeNode && nameNode) { + const isFinal = this.hasFinalModifier(child); + const isVarInferred = typeNode.type === 'type_identifier' && typeNode.text === 'var'; + + const variableTypeName = typeNode.text; + const variableBaseType = this.extractBaseType(variableTypeName); + + let potentialQualifiedName: string | undefined; + let isAmbiguous = false; + if (!isVarInferred) { + const resolved = resolveTypeQualifiedName( + variableBaseType, + packageName, + importMap, + hasStarImports + ); + potentialQualifiedName = resolved.potentialQualifiedName ?? undefined; + isAmbiguous = resolved.isAmbiguous; + } + + const startLine = nameNode.startPosition.row + 1; + const endLine = nameNode.endPosition.row + 1; + + const varBuilder = LocalVariableRegistry.builder( + nameNode.text, + variableTypeName, + variableBaseType, + filePath, + startLine, + endLine, + LocalVariableScopeKind.TRY_WITH_RESOURCES, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + + if (potentialQualifiedName) { + varBuilder.withPotentialQualifiedName(potentialQualifiedName); + } + varBuilder.withIsAmbiguous(isAmbiguous); + varBuilder.withScopeDepth(lambdaDepth); + varBuilder.withIsFinal(isFinal || true); // Resources are effectively final + varBuilder.withIsVarInferred(isVarInferred); + + if (methodRegistryHash) { + varBuilder.withMethodRegistryLinkHash(methodRegistryHash); + } + if (ownerMethodName) { + varBuilder.withOwnerMethodName(ownerMethodName); + } + // Link to containing try block using ScopeContext + const ownerHash = this.scopeContext.getCurrentOwnerHash(); + if (ownerHash) { + varBuilder.withParentExpressionLinkHash(ownerHash); + } + + const localVar = varBuilder.build(); + variables.push(localVar); + + // Add resource variable name to tracking set for expression classification + this.currentLocalVariableNames.add(nameNode.text); + + // Extract type references + this.extractVariableTypeReferences( + typeNode, + typeRegistryHash, + localVar.getHash(), + packageName + ); + + // Extract initializer expressions + const equalsIndex = child.children.findIndex(c => c.type === '='); + if (equalsIndex >= 0 && equalsIndex < child.children.length - 1) { + const initializerNode = child.children[equalsIndex + 1]; + if (initializerNode) { + this.extractInitializerExpressions( + initializerNode, + typeRegistryHash, + localVar.getHash(), + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + + // Extract local variables from lambda block bodies in the initializer + this.extractLambdasFromInitializer( + initializerNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables, + localVar.getHash() + ); + } + } + } + } + } + } + + /** + * Extracts the exception variable from a catch clause + * Example: catch (IOException e) + */ + private extractFromCatchClause( + catchNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Find catch_formal_parameter + const catchParam = catchNode.children.find(c => c.type === 'catch_formal_parameter'); + if (catchParam) { + // Can be single type or multi-catch: IOException | SQLException + const catchType = catchParam.children.find(c => + c.type === 'catch_type' || + c.type === 'type_identifier' || + c.type === 'scoped_type_identifier' + ); + const nameNode = catchParam.children.find(c => c.type === 'identifier'); + + if (catchType && nameNode) { + // For multi-catch, type could be "IOException | SQLException" + const variableTypeName = EntityUtils.normalizeWhitespace(catchType.text); + const variableBaseType = this.extractBaseType((variableTypeName.split('|')[0] ?? '').trim()); + + const resolved = resolveTypeQualifiedName( + variableBaseType, + packageName, + importMap, + hasStarImports + ); + + const startLine = nameNode.startPosition.row + 1; + const endLine = nameNode.endPosition.row + 1; + + const varBuilder = LocalVariableRegistry.builder( + nameNode.text, + variableTypeName, + variableBaseType, + filePath, + startLine, + endLine, + LocalVariableScopeKind.CATCH_CLAUSE, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + + if (resolved.potentialQualifiedName) { + varBuilder.withPotentialQualifiedName(resolved.potentialQualifiedName); + } + varBuilder.withIsAmbiguous(resolved.isAmbiguous); + varBuilder.withScopeDepth(lambdaDepth); + varBuilder.withIsFinal(true); // Catch variables are effectively final + + if (methodRegistryHash) { + varBuilder.withMethodRegistryLinkHash(methodRegistryHash); + } + if (ownerMethodName) { + varBuilder.withOwnerMethodName(ownerMethodName); + } + // Link to containing catch block using ScopeContext + const ownerHash = this.scopeContext.getCurrentOwnerHash(); + if (ownerHash) { + varBuilder.withParentExpressionLinkHash(ownerHash); + } + + const localVar = varBuilder.build(); + variables.push(localVar); + + // Add exception variable name to tracking set for expression classification + this.currentLocalVariableNames.add(nameNode.text); + + // Extract type references for the exception type(s) + if (catchType.type === 'catch_type') { + // Multi-catch: extract a type reference for each exception type + let pos = 0; + for (const typeChild of catchType.children) { + if (typeChild.type === 'type_identifier' || typeChild.type === 'scoped_type_identifier' || typeChild.type === 'annotated_type') { + const typeRefs = this.typeReferenceExtractor.extractFromLocalVariable( + typeChild, + typeRegistryHash, + localVar.getHash(), + packageName, + new Set(), + pos + ); + this.extractedTypeReferences.push(...typeRefs); + pos++; + } + } + } else { + // Single catch type + this.extractVariableTypeReferences( + catchType, + typeRegistryHash, + localVar.getHash(), + packageName + ); + } + } + } + + // Extract from catch block body + const catchBody = catchNode.children.find(c => c.type === 'block'); + if (catchBody) { + this.extractFromBlock( + catchBody, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.METHOD_BODY, + lambdaDepth, + variables + ); + } + } + + /** + * Extracts variables from a lambda expression body + */ + private extractFromLambdaExpression( + lambdaNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Extract lambda parameter names for LAMBDA_PARAMETER classification + const lambdaParamNames = extractLambdaParameterNames(lambdaNode); + + // Find the lambda expression hash from already-extracted expressions + // Match by position (startLine, startColumn, endLine, endColumn) + const lambdaStartLine = lambdaNode.startPosition.row + 1; + const lambdaStartCol = lambdaNode.startPosition.column; + const lambdaEndLine = lambdaNode.endPosition.row + 1; + const lambdaEndCol = lambdaNode.endPosition.column; + + // Search in expressionsForHashLookup (passed from parent extractor) first, + // then in extractedExpressions (from local variable initializers in current extraction) + let lambdaExpression = this.expressionsForHashLookup.find(expr => + expr.getStartLine() === lambdaStartLine && + expr.getStartColumn() === lambdaStartCol && + expr.getEndLine() === lambdaEndLine && + expr.getEndColumn() === lambdaEndCol + ); + // If not found in parent's array, search in our own extracted expressions + // (lambdas from local variable initializers are added here) + if (!lambdaExpression) { + lambdaExpression = this.extractedExpressions.find(expr => + expr.getStartLine() === lambdaStartLine && + expr.getStartColumn() === lambdaStartCol && + expr.getEndLine() === lambdaEndLine && + expr.getEndColumn() === lambdaEndCol + ); + } + const lambdaHash = lambdaExpression?.getHash(); + + // Check if this lambda is inside a local_variable_declaration by walking up AST + // This determines if we should extract return/expression statements + // (TypeMethodExtractor skips lambdas in local var declarations) + const isFromLocalVarInitializer = this.isLambdaInLocalVarDeclaration(lambdaNode); + + // Use ScopeContext to enter lambda - this automatically: + // 1. Saves and restores lambda params + // 2. Resets block context (lambda creates scope boundary) + // 3. Tracks the lambda hash for child variable/expression linking + // 4. Tracks if this is a local var initializer lambda (for statement extraction) + this.scopeContext.enterLambda(lambdaHash, lambdaParamNames, isFromLocalVarInitializer); + + // Lambda has structure: parameters -> body + // Body can be block or expression + for (const child of lambdaNode.children) { + if (child.type === 'block') { + this.extractFromBlock( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.LAMBDA_BODY, + lambdaDepth, + variables + ); + } else if (child.type === 'lambda_expression') { + // Nested lambda + this.extractFromLambdaExpression( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth + 1, + variables + ); + } else if (child.type !== '->' && child.type !== 'formal_parameters' && + child.type !== 'inferred_parameters' && child.type !== 'identifier') { + // Expression-body lambda (no {}): the body is an arbitrary expression that may + // contain nested block lambdas or control flow. Recurse via extractFromBlock so + // nested lambdas, blocks, and local variables are properly discovered. + // e.g.: list.forEach(x -> otherList.removeIf(y -> { if (...) { ... } })) + this.extractFromBlock( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.LAMBDA_BODY, + lambdaDepth, + variables + ); + } + } + + // Exit lambda scope - ScopeContext restores previous state + this.scopeContext.exit(); + } + + /** + * Extracts pattern binding variables from instanceof and switch patterns + */ + private extractPatternsFromStatement( + statementNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Recursively search for instanceof_expression with pattern + this.findAndExtractPatterns( + statementNode, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } + + /** + * Recursively finds and extracts pattern bindings + */ + private findAndExtractPatterns( + node: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + if (node.type === 'instanceof_expression') { + // Check for pattern: obj instanceof String s + // Pattern is typically a type_pattern or record_pattern child + for (const child of node.children) { + if (child.type === 'type_pattern' || child.type === 'binding_pattern') { + this.extractPatternBinding( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + LocalVariableScopeKind.INSTANCEOF_PATTERN, + variables + ); + } else if (child.type === 'record_pattern') { + this.extractRecordPatternBindings( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } + } + } else if (node.type === 'switch_label') { + // Check for case patterns in switch + // Tree-sitter wraps patterns in a 'pattern' node: switch_label -> pattern -> type_pattern/record_pattern + for (const child of node.children) { + if (child.type === 'pattern') { + // Unwrap the pattern node + for (const patternChild of child.children) { + if (patternChild.type === 'type_pattern' || patternChild.type === 'binding_pattern') { + this.extractPatternBinding( + patternChild, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + LocalVariableScopeKind.SWITCH_PATTERN, + variables + ); + } else if (patternChild.type === 'record_pattern') { + this.extractRecordPatternBindings( + patternChild, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } + } + } else if (child.type === 'type_pattern' || child.type === 'binding_pattern') { + // Direct pattern (fallback for older tree-sitter versions) + this.extractPatternBinding( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + LocalVariableScopeKind.SWITCH_PATTERN, + variables + ); + } else if (child.type === 'record_pattern') { + this.extractRecordPatternBindings( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } + } + } + + // Recurse into children + for (const child of node.children) { + this.findAndExtractPatterns( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } + } + + /** + * Extracts a single pattern binding variable + */ + private extractPatternBinding( + patternNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + scopeKind: LocalVariableScopeKind, + variables: LocalVariableRegistry[] + ): void { + const typeNode = this.findTypeNode(patternNode); + const nameNode = patternNode.children.find(c => c.type === 'identifier'); + + if (typeNode && nameNode) { + const variableTypeName = EntityUtils.normalizeWhitespace(typeNode.text); + const variableBaseType = this.extractBaseType(variableTypeName); + + const resolved = resolveTypeQualifiedName( + variableBaseType, + packageName, + importMap, + hasStarImports + ); + + const startLine = nameNode.startPosition.row + 1; + const endLine = nameNode.endPosition.row + 1; + + const varBuilder = LocalVariableRegistry.builder( + nameNode.text, + variableTypeName, + variableBaseType, + filePath, + startLine, + endLine, + scopeKind, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + + if (resolved.potentialQualifiedName) { + varBuilder.withPotentialQualifiedName(resolved.potentialQualifiedName); + } + varBuilder.withIsAmbiguous(resolved.isAmbiguous); + varBuilder.withScopeDepth(lambdaDepth); + varBuilder.withIsFinal(true); // Pattern bindings are effectively final + + if (methodRegistryHash) { + varBuilder.withMethodRegistryLinkHash(methodRegistryHash); + } + if (ownerMethodName) { + varBuilder.withOwnerMethodName(ownerMethodName); + } + + const localVar = varBuilder.build(); + variables.push(localVar); + } + } + + /** + * Extracts bindings from record patterns (deconstruction) + * Example: if (obj instanceof Point(int x, int y) p) + * + * Tree-sitter AST structure: + * record_pattern + * identifier: "Point" + * record_pattern_body + * record_pattern_component + * integral_type / type_identifier + * identifier: "x" + * record_pattern_component + * integral_type / type_identifier + * identifier: "y" + */ + private extractRecordPatternBindings( + recordPatternNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + // Record pattern: RecordType(pattern1, pattern2, ...) [identifier] + for (const child of recordPatternNode.children) { + if (child.type === 'record_pattern_body') { + // Extract components from the record pattern body + for (const component of child.children) { + if (component.type === 'record_pattern_component') { + this.extractRecordPatternComponent( + component, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } else if (component.type === 'record_pattern') { + // A component that is itself a deconstruction, as in + // `Pair(Pair(Leaf x, Node i2), Node i3)`. + // + // The nested pattern is a direct child of the record_pattern_body, not wrapped in a + // record_pattern_component, so a loop that matched only components skipped it and + // every binding below the top level was recorded by nothing. There was a recursive + // branch already, but on the children of record_pattern rather than of its body, + // where a nested pattern never appears. + // + // Matching a shape more than one level deep is the point of JEP 440, so this is the + // ordinary case rather than an edge one. + this.extractRecordPatternBindings( + component, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } + } + } else if (child.type === 'type_pattern' || child.type === 'binding_pattern') { + this.extractPatternBinding( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + LocalVariableScopeKind.RECORD_PATTERN, + variables + ); + } else if (child.type === 'record_pattern') { + // Nested record pattern + this.extractRecordPatternBindings( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables + ); + } else if (child.type === 'identifier') { + // The outer binding name (e.g., 'p' in Point(int x, int y) p) + // Need to get the record type from earlier sibling + const typeNode = recordPatternNode.children.find(c => + c.type === 'type_identifier' || + c.type === 'scoped_type_identifier' || + c.type === 'generic_type' + ); + + if (typeNode) { + const variableTypeName = EntityUtils.normalizeWhitespace(typeNode.text); + const variableBaseType = this.extractBaseType(variableTypeName); + + const resolved = resolveTypeQualifiedName( + variableBaseType, + packageName, + importMap, + hasStarImports + ); + + const startLine = child.startPosition.row + 1; + const endLine = child.endPosition.row + 1; + + const varBuilder = LocalVariableRegistry.builder( + child.text, + variableTypeName, + variableBaseType, + filePath, + startLine, + endLine, + LocalVariableScopeKind.RECORD_PATTERN, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + + if (resolved.potentialQualifiedName) { + varBuilder.withPotentialQualifiedName(resolved.potentialQualifiedName); + } + varBuilder.withIsAmbiguous(resolved.isAmbiguous); + varBuilder.withScopeDepth(lambdaDepth); + varBuilder.withIsFinal(true); + + if (methodRegistryHash) { + varBuilder.withMethodRegistryLinkHash(methodRegistryHash); + } + if (ownerMethodName) { + varBuilder.withOwnerMethodName(ownerMethodName); + } + + const localVar = varBuilder.build(); + variables.push(localVar); + } + } + } + } + + /** + * Extracts a single binding from a record_pattern_component + * Structure: record_pattern_component -> type (integral_type/type_identifier) + identifier + */ + private extractRecordPatternComponent( + componentNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[] + ): void { + const typeNode = this.findTypeNode(componentNode); + const nameNode = componentNode.children.find(c => c.type === 'identifier'); + + if (typeNode && nameNode) { + const variableTypeName = EntityUtils.normalizeWhitespace(typeNode.text); + const variableBaseType = this.extractBaseType(variableTypeName); + + const resolved = resolveTypeQualifiedName( + variableBaseType, + packageName, + importMap, + hasStarImports + ); + + const startLine = nameNode.startPosition.row + 1; + const endLine = nameNode.endPosition.row + 1; + + const varBuilder = LocalVariableRegistry.builder( + nameNode.text, + variableTypeName, + variableBaseType, + filePath, + startLine, + endLine, + LocalVariableScopeKind.RECORD_PATTERN, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + + if (resolved.potentialQualifiedName) { + varBuilder.withPotentialQualifiedName(resolved.potentialQualifiedName); + } + varBuilder.withIsAmbiguous(resolved.isAmbiguous); + varBuilder.withScopeDepth(lambdaDepth); + varBuilder.withIsFinal(true); // Pattern bindings are effectively final + + if (methodRegistryHash) { + varBuilder.withMethodRegistryLinkHash(methodRegistryHash); + } + if (ownerMethodName) { + varBuilder.withOwnerMethodName(ownerMethodName); + } + + const localVar = varBuilder.build(); + variables.push(localVar); + } + } + + // === Helper Methods === + + /** + * Checks if a declaration has a final modifier + */ + private hasFinalModifier(declNode: Parser.SyntaxNode): boolean { + const modifiersNode = declNode.children.find(c => c.type === 'modifiers'); + if (!modifiersNode) return false; + + return modifiersNode.children.some(c => c.type === 'final'); + } + + /** + * Finds the type node in a declaration + */ + private findTypeNode(declNode: Parser.SyntaxNode): Parser.SyntaxNode | undefined { + const typeNodeTypes = [ + 'type_identifier', + 'scoped_type_identifier', + 'generic_type', + 'array_type', + 'integral_type', + 'floating_point_type', + 'boolean_type', + ]; + + for (const child of declNode.children) { + if (typeNodeTypes.includes(child.type)) { + return child; + } + } + + return undefined; + } + + /** + * Checks if a lambda node is inside a local_variable_declaration by walking up the AST. + * This is more robust than tracking where the lambda was found during extraction. + * Stops at method body boundary (block that's a direct child of method/constructor). + */ + private isLambdaInLocalVarDeclaration(lambdaNode: Parser.SyntaxNode): boolean { + let current: Parser.SyntaxNode | null = lambdaNode.parent; + while (current) { + // Found a local variable declaration or resource (try-with-resources) - this lambda is in a local var initializer + if (current.type === 'local_variable_declaration' || current.type === 'resource') { + return true; + } + // Stop at method body boundary + if (current.type === 'block' && current.parent && + (current.parent.type === 'method_declaration' || + current.parent.type === 'constructor_declaration' || + current.parent.type === 'lambda_expression')) { + return false; + } + current = current.parent; + } + return false; + } + + /** + * Extracts the base type from a full type name + */ + private extractBaseType(fullTypeName: string): string { + let baseType = fullTypeName; + + // Strip array brackets + baseType = baseType.replace(/(\s*@\w+\s*)?\[\]/g, ''); + + // Strip generics + const genericStart = baseType.indexOf('<'); + if (genericStart !== -1) { + baseType = baseType.substring(0, genericStart); + } + + // Strip annotations + baseType = baseType.replace(/@\w+\s*/g, ''); + + return baseType.trim(); + } + + /** + * Extracts type references from a variable type node + */ + private extractVariableTypeReferences( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + localVariableHash: string, + packageName: string | null + ): void { + // Skip 'var' inferred types - no type reference to extract + if (typeNode.type === 'var') { + return; + } + + // Extract type references for the variable type (handles generics, nested types) + const typeRefs = this.typeReferenceExtractor.extractFromLocalVariable( + typeNode, + typeRegistryHash, + localVariableHash, + packageName + ); + this.extractedTypeReferences.push(...typeRefs); + } + + /** + * Extracts expressions from a variable initializer + */ + private extractInitializerExpressions( + initializerNode: Parser.SyntaxNode, + typeRegistryHash: string, + localVariableHash: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): void { + const expressions = this.expressionExtractor.extractFromLocalVariableInitializer( + initializerNode, + typeRegistryHash, + localVariableHash, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + this.currentMethodParamNames, + this.currentLocalVariableNames, + this.scopeContext.getLambdaParamNames(), + this.currentPatternBindingNames + ); + this.extractedExpressions.push(...expressions); + // Note: Lambdas from local variable initializers are now found by searching + // extractedExpressions in extractFromLambdaExpression, so no need to add to + // expressionsForHashLookup (which would cause duplicates when collected later) + + // Collect type references from expressions + const expressionTypeRefs = this.expressionExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...expressionTypeRefs); + + // Collect annotations from expressions + const expressionAnnotations = this.expressionExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...expressionAnnotations); + + // Collect anonymous classes from expressions + const anonymousClasses = this.expressionExtractor.getExtractedAnonymousClasses(); + this.extractedAnonymousClasses.push(...anonymousClasses); + } + + /** + * Extracts annotations from a local variable declaration + */ + private extractLocalVariableAnnotations( + declNode: Parser.SyntaxNode, + typeRegistryHash: string, + localVariableHash: string + ): void { + const modifiersNode = declNode.children.find(c => c.type === 'modifiers'); + if (!modifiersNode) return; + + // Reset annotation extractor to avoid duplicate arguments + this.annotationExtractor.resetExtractedArguments(); + + for (const child of modifiersNode.children) { + if (child.type === 'marker_annotation' || child.type === 'annotation') { + const annotations = this.annotationExtractor.extractFromLocalVariableDeclaration( + declNode, + localVariableHash, + typeRegistryHash + ); + this.extractedAnnotations.push(...annotations); + + // Collect annotation arguments + const args = this.annotationExtractor.getExtractedArguments(); + this.extractedAnnotationArguments.push(...args); + break; // extractFromLocalVariableDeclaration handles all annotations + } + } + } + + /** + * Extracts expressions from return/expression/yield statements inside METHOD BODY lambda bodies. + * This handles lambdas inside local variable declarations which TypeMethodExtractor skips + * (TypeMethodExtractor only handles lambdas at expression_statement level, not in local var initializers). + */ + private extractLambdaBodyStatementExpressions( + statementNode: Parser.SyntaxNode, + typeRegistryHash: string, + _serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): void { + // Must be inside a lambda to extract these statements + const lambdaHash = this.scopeContext.getCurrentLambdaHash(); + if (!lambdaHash) return; + + // An arrow arm of a switch used as a VALUE is not a statement of the enclosing body, even + // though it is written as one. The method walk already declines to collect these; a field + // initializer is walked here instead, and without the same guard every arm of a switch inside + // an initializer lambda was emitted a second time under EXPRESSION_STATEMENT/ROOT - a root + // context the source does not have. + if (JavaTreeSitterUtils.isValueProducingSwitchArm(statementNode)) return; + + const statementKey = `${statementNode.startIndex}:${statementNode.endIndex}`; + if (this.extractedLambdaStatements.has(statementKey)) return; + this.extractedLambdaStatements.add(statementKey); + + // In field initializer context, extract ALL lambda statements (no TypeMethodExtractor involvement). + // In method body context, only extract for lambdas from local var initializers + // (TypeMethodExtractor handles lambdas at expression_statement level). + if (!this.isFieldInitializerContext && !this.scopeContext.isCurrentLambdaFromLocalVarInitializer()) return; + + // Use ScopeContext to get the correct owner hash (block > lambda) + const ownerHash = this.scopeContext.getCurrentOwnerHash(); + if (!ownerHash) return; + + let expressions: ExpressionReference[]; + + if (statementNode.type === 'return_statement') { + // Extract return statement expressions - TypeMethodExtractor skips lambdas in local var decls + expressions = this.expressionExtractor.extractFromReturnStatement( + statementNode, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + this.currentMethodParamNames, + undefined, // returnStatementIndex - not tracked for local var lambda returns + this.currentLocalVariableNames, + this.scopeContext.getLambdaParamNames() + ); + } else if (statementNode.type === 'expression_statement') { + // Extract expression statement expressions - TypeMethodExtractor skips lambdas in local var decls + expressions = this.expressionExtractor.extractFromExpressionStatement( + statementNode, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + this.currentMethodParamNames, + this.currentLocalVariableNames, + this.scopeContext.getLambdaParamNames() + ); + } else if (statementNode.type === 'throw_statement') { + // A `throw`-only lambda is the standard "disabled implementation" constant on an + // interface, and in a FIELD initializer nothing else extracts it: TypeMethodExtractor's + // throw walk covers method bodies, including lambdas inside a local-variable + // declaration, but never reaches a field initializer. Restricted to that context for + // exactly that reason — running it for a method-body lambda would emit every throw twice. + if (!this.isFieldInitializerContext) return; + expressions = this.expressionExtractor.extractFromThrowStatement( + statementNode, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + this.currentMethodParamNames, + undefined, // throwStatementIndex - not tracked for field initializer lambdas + this.currentLocalVariableNames, + this.scopeContext.getLambdaParamNames() + ); + } else if (statementNode.type === 'yield_statement') { + // yield statements in switch expression blocks are extracted by + // ExpressionReferenceExtractor as SWITCH_CASE_RESULT - skip here to avoid duplication + return; + } else { + return; + } + + this.extractedExpressions.push(...expressions); + + // Collect type references from expressions + const expressionTypeRefs = this.expressionExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...expressionTypeRefs); + + // Collect annotations from expressions + const expressionAnnotations = this.expressionExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...expressionAnnotations); + } + + + /** + * Recursively finds and extracts local variables from lambda block bodies + * and switch expression yield blocks in an initializer + */ + private extractLambdasFromInitializer( + node: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[], + parentContainerHash?: string + ): void { + if (node.type === 'lambda_expression') { + // Process this lambda's block body if it has one + this.extractFromLambdaExpression( + node, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth + 1, + variables + ); + } else if (node.type === 'switch_expression') { + // Process switch expression blocks (case -> { ... yield ... }) + this.extractFromSwitchExpressionBlocks( + node, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth + 1, + variables, + parentContainerHash + ); + } else { + // Recursively search children for lambda expressions and switch expressions + for (const child of node.children) { + // An anonymous class body is extracted separately, as the anonymous class's own methods, + // with the correct method hash. Descending into one here found the lambdas inside its + // methods a second time, so every local declared in such a lambda was recorded twice: + // once under the anonymous method and once under the enclosing method's initializer walk. + // + // `extractFromBlock` already declines to cross this boundary for the same reason. Only + // locals INSIDE a lambda duplicated, because the plain locals of an anonymous method are + // not reached by this initializer search at all. + // + // A local class body is not skipped: it is not extracted anywhere else. + if (child.type === 'class_body' && child.parent?.type === 'object_creation_expression') { + continue; + } + + this.extractLambdasFromInitializer( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + lambdaDepth, + variables, + parentContainerHash + ); + } + } + } + + /** + * Extracts local variables and yield expressions from switch expression blocks + */ + private extractFromSwitchExpressionBlocks( + switchExprNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodRegistryHash: string | undefined, + ownerTypeName: string, + ownerQualifiedName: string, + ownerMethodName: string | undefined, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[], + parentContainerHash?: string + ): void { + // Save previous pattern binding names + const previousPatternBindingNames = new Set(this.currentPatternBindingNames); + + // Find switch_block containing switch_rule or switch_block_statement_group nodes + const switchBlock = switchExprNode.children.find(c => c.type === 'switch_block'); + if (switchBlock) { + for (const child of switchBlock.children) { + if (child.type === 'switch_rule') { + // Arrow syntax: case X -> { ... } or case X -> expr; + const block = child.children.find(c => c.type === 'block'); + if (block) { + // Compute BlockRegistry hash for SWITCH_EXPRESSION_CASE + let caseBlockHash: string | null = null; + if (this.blockMethodHash) { + caseBlockHash = BlockRegistry.computeHash( + BlockKind.SWITCH_EXPRESSION_CASE, + filePath, + block.startPosition.row + 1, + block.endPosition.row + 1, + block.startPosition.column, + block.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.SWITCH_EXPRESSION_CASE, this.extractedBlocks.length, filePath, + block.startPosition.row + 1, block.endPosition.row + 1, + block.startPosition.column, block.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + // Use explicit parentContainerHash (from local variable) if provided, + // otherwise fall back to scope context (for non-initializer contexts) + const resolvedParentHash = parentContainerHash || this.scopeContext.getCurrentOwnerHash(); + if (resolvedParentHash) blockEntry.withParentContainerHash(resolvedParentHash); + this.extractedBlocks.push(blockEntry.build()); + } + } + + // Enter SWITCH_EXPRESSION_CASE scope + if (caseBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(caseBlockHash, BlockKind.SWITCH_EXPRESSION_CASE, LocalVariableScopeKind.SWITCH_BLOCK); + } + + // Extract and add pattern binding names from this switch rule + const patternBindingNames = this.extractSwitchRulePatternBindingNames(child); + for (const name of patternBindingNames) { + this.currentPatternBindingNames.add(name); + } + + this.extractFromBlock( + block, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.SWITCH_BLOCK, + lambdaDepth, + variables + ); + + // Remove this rule's pattern bindings after processing + for (const name of patternBindingNames) { + this.currentPatternBindingNames.delete(name); + } + + // Exit SWITCH_EXPRESSION_CASE scope + if (caseBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + } else if (child.type === 'switch_block_statement_group') { + // Colon syntax: case X: ... yield ...; + let caseBlockHash: string | null = null; + if (this.blockMethodHash) { + caseBlockHash = BlockRegistry.computeHash( + BlockKind.SWITCH_CASE, + filePath, + child.startPosition.row + 1, + child.endPosition.row + 1, + child.startPosition.column, + child.endPosition.column, + typeRegistryHash, + this.blockMethodHash + ); + + if (this.shouldCreateBlockEntries) { + const blockEntry = BlockRegistry.builder( + BlockKind.SWITCH_CASE, this.extractedBlocks.length, filePath, + child.startPosition.row + 1, child.endPosition.row + 1, + child.startPosition.column, child.endPosition.column, + typeRegistryHash, this.blockMethodHash, + ownerTypeName, ownerQualifiedName, ownerMethodName || '' + ) + .withNestingDepth(this.blockNestingDepth); + // Use explicit parentContainerHash (from local variable) if provided, + // otherwise fall back to scope context (for non-initializer contexts) + const resolvedParentHash = parentContainerHash || this.scopeContext.getCurrentOwnerHash(); + if (resolvedParentHash) blockEntry.withParentContainerHash(resolvedParentHash); + this.extractedBlocks.push(blockEntry.build()); + } + } + + // Enter SWITCH_CASE scope + if (caseBlockHash) { + if (!this.isFieldInitializerContext) this.blockNestingDepth++; + this.scopeContext.enterBlock(caseBlockHash, BlockKind.SWITCH_CASE, LocalVariableScopeKind.SWITCH_BLOCK); + } + + // Extract and add pattern binding names + const patternBindingNames = this.extractSwitchRulePatternBindingNames(child); + for (const name of patternBindingNames) { + this.currentPatternBindingNames.add(name); + } + + this.extractFromBlock( + child, + filePath, + typeRegistryHash, + methodRegistryHash, + ownerTypeName, + ownerQualifiedName, + ownerMethodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.SWITCH_BLOCK, + lambdaDepth, + variables + ); + + // Remove pattern bindings after processing + for (const name of patternBindingNames) { + this.currentPatternBindingNames.delete(name); + } + + // Exit SWITCH_CASE scope + if (caseBlockHash) { + this.scopeContext.exit(); + if (!this.isFieldInitializerContext) this.blockNestingDepth--; + } + } + } + } + + // Restore previous pattern binding names + this.currentPatternBindingNames = previousPatternBindingNames; + } + + /** + * Finds the expression hash for a switch rule/case arm. + * Looks for the pattern binding expression (e.g., 's' in 'case String s') + * or the first expression in the case label. + */ + private findSwitchRuleExpressionHash(switchRuleNode: Parser.SyntaxNode): string | undefined { + // Find the switch_label child + const switchLabel = switchRuleNode.children.find(c => c.type === 'switch_label'); + if (!switchLabel) return undefined; + + // Look for a pattern (type_pattern, record_pattern) in the switch_label + // Structure: switch_label -> pattern -> type_pattern/record_pattern + for (const child of switchLabel.children) { + if (child.type === 'pattern') { + // Find the pattern variable identifier inside + const patternNode = child.children.find(c => + c.type === 'type_pattern' || c.type === 'record_pattern' || c.type === 'binding_pattern' + ); + if (patternNode) { + // For type_pattern (case String s), find the identifier + const identifier = patternNode.children.find(c => c.type === 'identifier'); + if (identifier) { + // Look up the expression for this pattern binding + const startLine = identifier.startPosition.row + 1; + const startCol = identifier.startPosition.column; + const endLine = identifier.endPosition.row + 1; + const endCol = identifier.endPosition.column; + + // Search in both expression sources + let patternExpr = this.expressionsForHashLookup.find(expr => + expr.getStartLine() === startLine && + expr.getStartColumn() === startCol && + expr.getEndLine() === endLine && + expr.getEndColumn() === endCol + ); + if (!patternExpr) { + patternExpr = this.extractedExpressions.find(expr => + expr.getStartLine() === startLine && + expr.getStartColumn() === startCol && + expr.getEndLine() === endLine && + expr.getEndColumn() === endCol + ); + } + if (patternExpr) { + return patternExpr.getHash(); + } + } + + // For record_pattern (case Point(int x, int y)), use the record pattern expression + if (patternNode.type === 'record_pattern') { + const startLine = patternNode.startPosition.row + 1; + const startCol = patternNode.startPosition.column; + const endLine = patternNode.endPosition.row + 1; + const endCol = patternNode.endPosition.column; + + let recordExpr = this.expressionsForHashLookup.find(expr => + expr.getStartLine() === startLine && + expr.getStartColumn() === startCol && + expr.getEndLine() === endLine && + expr.getEndColumn() === endCol + ); + if (!recordExpr) { + recordExpr = this.extractedExpressions.find(expr => + expr.getStartLine() === startLine && + expr.getStartColumn() === startCol && + expr.getEndLine() === endLine && + expr.getEndColumn() === endCol + ); + } + if (recordExpr) { + return recordExpr.getHash(); + } + } + } + } + } + + return undefined; + } + + /** + * Extracts pattern binding variable names from a switch rule. + * E.g., for 'case String s -> { ... }' returns ['s'] + * For 'case Point(int x, int y) -> { ... }' returns ['x', 'y'] + */ + private extractSwitchRulePatternBindingNames(switchRuleNode: Parser.SyntaxNode): Set { + const names = new Set(); + + // Find the switch_label child + const switchLabel = switchRuleNode.children.find(c => c.type === 'switch_label'); + if (!switchLabel) return names; + + // Look for patterns in the switch_label + for (const child of switchLabel.children) { + if (child.type === 'pattern') { + this.extractPatternBindingNamesRecursive(child, names); + } + } + + return names; + } + + /** + * Recursively extracts pattern binding variable names from a pattern node. + * Handles type_pattern, record_pattern, and nested patterns. + */ + private extractPatternBindingNamesRecursive(node: Parser.SyntaxNode, names: Set): void { + if (node.type === 'type_pattern' || node.type === 'binding_pattern') { + // For type_pattern (case String s), find the identifier + const identifier = node.children.find(c => c.type === 'identifier'); + if (identifier) { + names.add(identifier.text); + } + } else if (node.type === 'record_pattern') { + // For record_pattern (case Point(int x, int y)), extract nested bindings + for (const child of node.children) { + if (child.type === 'record_pattern_body') { + for (const bodyChild of child.children) { + if (bodyChild.type === 'pattern') { + this.extractPatternBindingNamesRecursive(bodyChild, names); + } else if (bodyChild.type === 'type_pattern' || bodyChild.type === 'binding_pattern') { + this.extractPatternBindingNamesRecursive(bodyChild, names); + } else if (bodyChild.type === 'record_pattern') { + this.extractPatternBindingNamesRecursive(bodyChild, names); + } + } + } + } + } else if (node.type === 'pattern') { + // Unwrap pattern node + for (const child of node.children) { + this.extractPatternBindingNamesRecursive(child, names); + } + } + } + + /** + * Extracts local variables from switch expression blocks in field initializers + */ + private extractFromFieldSwitchExpressionBlocks( + switchExprNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + _fieldHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + lambdaDepth: number, + variables: LocalVariableRegistry[], + blockDepth: number = 0 + ): void { + // Save previous pattern binding names + const previousPatternBindingNames = new Set(this.currentPatternBindingNames); + + // Find switch_block containing switch_rule or switch_block_statement_group nodes + const switchBlock = switchExprNode.children.find(c => c.type === 'switch_block'); + if (switchBlock) { + for (const child of switchBlock.children) { + // Handle both arrow syntax (switch_rule) and colon syntax (switch_block_statement_group) + if (child.type === 'switch_rule' || child.type === 'switch_block_statement_group') { + const block = child.children.find(c => c.type === 'block'); + if (block) { + // Find the switch_label for this case arm to link local variables to the specific case + const switchRuleExpressionHash = this.findSwitchRuleExpressionHash(child); + + // Enter switch block scope using ScopeContext + if (switchRuleExpressionHash) { + this.scopeContext.enterBlock(switchRuleExpressionHash, BlockKind.SWITCH_EXPRESSION_CASE, LocalVariableScopeKind.SWITCH_BLOCK); + } + + // Extract and add pattern binding names from this switch rule + const patternBindingNames = this.extractSwitchRulePatternBindingNames(child); + for (const name of patternBindingNames) { + this.currentPatternBindingNames.add(name); + } + + this.extractFromBlock( + block, + filePath, + typeRegistryHash, + undefined, // No methodRegistryHash for field initializers + ownerTypeName, + ownerQualifiedName, + undefined, // No ownerMethodName for field initializers + serviceVersionHash, + packageName, + importMap, + hasStarImports, + LocalVariableScopeKind.LAMBDA_BODY, + lambdaDepth, + variables, + blockDepth + ); + + // Remove this rule's pattern bindings after processing + for (const name of patternBindingNames) { + this.currentPatternBindingNames.delete(name); + } + + // Exit switch block scope + if (switchRuleExpressionHash) { + this.scopeContext.exit(); + } + } + } + } + } + + // Restore previous pattern binding names + this.currentPatternBindingNames = previousPatternBindingNames; + } + +} diff --git a/parser/src/parsers/java/extractors/method-parameter-extractor.ts b/parser/src/parsers/java/extractors/method-parameter-extractor.ts new file mode 100644 index 000000000..e026fb790 --- /dev/null +++ b/parser/src/parsers/java/extractors/method-parameter-extractor.ts @@ -0,0 +1,347 @@ +import Parser from 'tree-sitter'; + +import { MethodParameter } from '@/analysis-methods/java/MethodParameter'; +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { BaseExtractor } from '@/parsers/base-extractor'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { TypeReferenceExtractor } from '@/parsers/java/extractors/type-reference-extractor'; +import { JavaTreeSitterUtils } from '@/utils/java/java-tree-sitter-utils'; +import { EntityUtils } from '@/utils/entity-utils'; +import { resolveTypeQualifiedName } from '@/utils/java/type-resolution-utils'; + +/** + * Extracts MethodParameter entities and their associated TypeReferences from Java method declarations. + * + * Handles all parameter types: + * - Regular parameters: `String name, int count` + * - Final parameters: `final User user` + * - Varargs parameters: `String... messages` + * - Receiver parameters: `OuterClass.this` + * - Generic parameters: `List items` + * - Complex wildcards: `Map> data` + * + * ## Strategy + * + * This extractor creates: + * 1. MethodParameter entities (metadata: name, position, modifiers) + * 2. TypeReference entities (type structure: wildcards, generics, arrays) + * + * TypeReferences are linked via: + * - referenceOwnerKind = METHOD_PARAM + * - typeReferenceOwnerHash = methodParameterHash + * - context = METHOD_PARAM + * + * ## Example + * + * ```java + * public void process(final List numbers, String... messages) { } + * ``` + * + * Creates: + * - 2 MethodParameter entities (numbers, messages) + * - Multiple TypeReference entities: + * - For `numbers`: List → ? extends Number → Number + * - For `messages`: String (with isVarArgs=true on MethodParameter) + */ +export class MethodParameterExtractor implements BaseExtractor { + private typeReferenceExtractor: TypeReferenceExtractor; + private extractedTypeReferences: TypeReference[] = []; + private extractedAnnotations: TypeAnnotation[] = []; + private extractedAnnotationArguments: AnnotationArgumentReference[] = []; + + constructor() { + this.typeReferenceExtractor = new TypeReferenceExtractor(); + } + + /** + * Returns all type references extracted during the last extraction + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all annotations extracted during the last extraction + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns all annotation arguments extracted during the last extraction + */ + getExtractedAnnotationArguments(): AnnotationArgumentReference[] { + return this.extractedAnnotationArguments; + } + + /** + * @deprecated Use extractFromMethod instead + */ + extract(_filePath: string, _fileContent: string, _hash: string): MethodParameter[] { + console.warn('MethodParameterExtractor.extract() is deprecated. Use extractFromMethod() instead.'); + return []; + } + + /** + * Extracts method parameters from a method declaration node. + * + * @param methodNode Method, constructor, or compact constructor declaration node + * @param methodRegistryHash Hash of the owning method + * @param typeRegistryHash Hash of the owning type (for TypeReference linkage) + * @param packageName Package name for type resolution + * @param declaredTypeParams Set of all type parameter names (class-level + method-level) + * @param annotationExtractor Extractor for parameter annotations + */ + extractFromMethod( + methodNode: Parser.SyntaxNode, + methodRegistryHash: string, + typeRegistryHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + declaredTypeParams: Set, + annotationExtractor: AnnotationExtractor + ): MethodParameter[] { + const parameters: MethodParameter[] = []; + this.extractedTypeReferences = []; // Reset for each method + this.extractedAnnotations = []; // Reset for each method + this.extractedAnnotationArguments = []; // Reset for each method + + // Find formal_parameters node + const formalParamsNode = this.findFormalParameters(methodNode); + if (!formalParamsNode) { + return parameters; + } + + let position = 0; + for (const child of formalParamsNode.children) { + if (child.type === 'formal_parameter' || + child.type === 'spread_parameter' || + child.type === 'receiver_parameter') { + + const param = this.createMethodParameter( + child, + annotationExtractor, + position++, + methodRegistryHash, + typeRegistryHash, + packageName, + importMap, + hasStarImports, + declaredTypeParams + ); + + if (param) { + parameters.push(param); + } + } + } + + return parameters; + } + + /** + * Finds the formal_parameters node in a method declaration + */ + private findFormalParameters(methodNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + // A compact constructor declares no parameter list, but it IS the record's canonical + // constructor and its parameters are the record's components (JLS 8.10.4). Without this it + // reports a parameterCount taken from the components and no parameter rows to match. + const source = methodNode.type === 'compact_constructor_declaration' + ? this.findEnclosingRecord(methodNode) ?? methodNode + : methodNode; + + for (const child of source.children) { + if (child.type === 'formal_parameters') { + return child; + } + } + return null; + } + + /** + * Walks up to the record_declaration a compact constructor belongs to. + */ + private findEnclosingRecord(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let current = node.parent; + while (current) { + if (current.type === 'record_declaration') return current; + current = current.parent; + } + return null; + } + + /** + * Creates a MethodParameter entity and extracts its TypeReferences and annotations + */ + private createMethodParameter( + paramNode: Parser.SyntaxNode, + annotationExtractor: AnnotationExtractor, + position: number, + methodRegistryHash: string, + typeRegistryHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + declaredTypeParams: Set + ): MethodParameter | null { + // Extract parameter name + const paramName = this.extractParameterName(paramNode); + if (!paramName) return null; + + // Extract modifiers first (needed for type adjustment) + const isFinal = this.isParameterFinal(paramNode); + const isVarArgs = paramNode.type === 'spread_parameter'; + const isReceiver = paramNode.type === 'receiver_parameter'; + + // Extract parameter type names + const paramTypeNode = this.extractParameterTypeNode(paramNode); + let parameterTypeName = paramTypeNode ? EntityUtils.normalizeWhitespace(paramTypeNode.text) : 'Unknown'; + + // Handle C-style array declarations (int values[] vs int[] values) + // C-style puts dimensions in the formal_parameter, not the type node + const cStyleDimensions = paramNode.children.find(c => c.type === 'dimensions'); + if (cStyleDimensions) { + parameterTypeName = parameterTypeName + cStyleDimensions.text; + } + + // For varargs (String... args), the underlying type is actually String[] + // Append [] to make the type accurate + if (isVarArgs && !parameterTypeName.endsWith('[]')) { + parameterTypeName = parameterTypeName + '[]'; + } + + const parameterBaseType = this.extractBaseType(parameterTypeName); + + // Resolve qualified name from imports + const { potentialQualifiedName, isAmbiguous } = resolveTypeQualifiedName( + parameterBaseType, + packageName, + importMap, + hasStarImports, + declaredTypeParams + ); + + // Get line numbers + const startLine = paramNode.startPosition.row + 1; + const endLine = paramNode.endPosition.row + 1; + + // Create MethodParameter entity + const methodParameter = new MethodParameter( + paramName, + position, + methodRegistryHash, + parameterBaseType, + parameterTypeName, + potentialQualifiedName, + isAmbiguous, + isFinal, + isVarArgs, + isReceiver, + startLine, + endLine + ); + + // Extract annotations for this parameter + annotationExtractor.resetExtractedArguments(); + const paramAnnotations = annotationExtractor.extractFromParameterDeclaration( + paramNode, + methodParameter.getHash(), + typeRegistryHash + ); + this.extractedAnnotations.push(...paramAnnotations); + + // Collect annotation arguments from parameter annotations + const paramAnnotationArgs = annotationExtractor.getExtractedArguments(); + this.extractedAnnotationArguments.push(...paramAnnotationArgs); + + // Collect type references from parameter annotation arguments + const paramAnnotationTypeRefs = annotationExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...paramAnnotationTypeRefs); + + // Extract TypeReferences for this parameter's type (reuse paramTypeNode from above) + if (paramTypeNode) { + const typeRefs = this.typeReferenceExtractor.extractFromMethodParameter( + paramTypeNode, + typeRegistryHash, + methodParameter.getHash(), // Link TypeReferences to this MethodParameter + packageName, + declaredTypeParams + ); + this.extractedTypeReferences.push(...typeRefs); + } + + return methodParameter; + } + + /** + * Extracts parameter name from parameter node + */ + private extractParameterName(paramNode: Parser.SyntaxNode): string | null { + for (const child of paramNode.children) { + if (child.type === 'identifier') { + return child.text; + } + // For receiver parameters, it's always 'this' + if (child.type === 'this') { + return 'this'; + } + // For varargs (spread_parameter), name is inside variable_declarator + if (child.type === 'variable_declarator') { + const identifierNode = child.childForFieldName('name'); + if (identifierNode) { + return identifierNode.text; + } + } + } + return null; + } + + /** + * Checks if parameter has 'final' modifier + */ + private isParameterFinal(paramNode: Parser.SyntaxNode): boolean { + for (const child of paramNode.children) { + if (child.type === 'modifiers') { + for (const modifier of child.children) { + if (modifier.text === 'final') { + return true; + } + } + } + } + return false; + } + + /** + * Extracts the type node from a parameter node + */ + private extractParameterTypeNode(paramNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + for (const child of paramNode.children) { + if (JavaTreeSitterUtils.isTypeNode(child)) { + return child; + } + } + return null; + } + + /** + * Extracts base type by stripping generics + * Examples: + * - "List" → "List" + * - "Map" → "Map" + * - "List" → "List" + * - "String" → "String" + * - "int[]" → "int[]" + */ + private extractBaseType(fullTypeName: string): string { + // Find first '<' to strip generics + const genericStart = fullTypeName.indexOf('<'); + if (genericStart === -1) { + return fullTypeName; // No generics + } + return fullTypeName.substring(0, genericStart); + } +} diff --git a/parser/src/parsers/java/extractors/method-type-parameter-extractor.ts b/parser/src/parsers/java/extractors/method-type-parameter-extractor.ts new file mode 100644 index 000000000..77678d843 --- /dev/null +++ b/parser/src/parsers/java/extractors/method-type-parameter-extractor.ts @@ -0,0 +1,286 @@ +import Parser from 'tree-sitter'; + +import { MethodTypeParameter } from '@/analysis-methods/java/MethodTypeParameter'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { BaseExtractor } from '@/parsers/base-extractor'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { TypeReferenceExtractor } from '@/parsers/java/extractors/type-reference-extractor'; + +/** + * Extracts MethodTypeParameter entities from Java method declarations. + * + * Handles: + * - Simple method type parameters: ``, ``, `` + * - Bounded method type parameters: ``, `>` + * - Multiple bounds: `` + * - Annotations on method type parameters: `<@NonNull T>` (Java 8+) + * - Recursive bounds: `>` + * + * ## Extraction Flow + * + * For each method type parameter declaration: + * 1. Create MethodTypeParameter entity with name, position, and owner method information + * 2. Extract annotations on the type parameter (e.g., `@NonNull`, `@Validated`) + * 3. Extract type references from annotation arguments (if annotations have arguments) + * 4. Extract type references from bounds using METHOD_TYPE_PARAM_BOUND context + * + * ## Complete Example + * + * ```java + * public class Service { + * // Simple unbounded method type parameter + * public T process(T item) { } + * + * // Single bounded type parameter + * public static double totalArea(List shapes) { } + * + * // Multiple type parameters with bounds + * public > void compare(T t, U u) { } + * + * // Multiple bounds (intersection types) + * public void execute(T task) { } + * + * // With annotations + * public <@NonNull T extends Serializable> void save(T data) { } + * } + * ``` + * + * **Results for ` double totalArea(List shapes)`:** + * - 1 MethodTypeParameter entry (T with hasBounds=true, position=0) + * - 1 TypeReference entry: + * - Shape (context: METHOD_TYPE_PARAM_BOUND, owned by METHOD_TYPE_PARAM) + * + * ## Difference from TypeParameterExtractor + * + * - **TypeParameterExtractor**: Extracts class/interface-level type parameters + * - Context: `TYPE_PARAM_BOUND` + * - Links to: `typeRegistryLinkHash` + * + * - **MethodTypeParameterExtractor**: Extracts method-level type parameters + * - Context: `METHOD_TYPE_PARAM_BOUND` + * - Links to: `methodRegistryLinkHash` + * + * ## Getters for Extracted Data + * + * - `getExtractedAnnotations()` - Returns annotations on method type parameters + * - `getExtractedTypeReferences()` - Returns type references from bounds AND annotation arguments + * - `getAnnotationExtractor()` - Direct access to annotation extractor for argument data + */ +export class MethodTypeParameterExtractor implements BaseExtractor { + private typeReferenceExtractor: TypeReferenceExtractor; + private annotationExtractor: AnnotationExtractor; + private extractedTypeReferences: TypeReference[] = []; + private extractedAnnotations: TypeAnnotation[] = []; + + constructor() { + this.typeReferenceExtractor = new TypeReferenceExtractor(); + this.annotationExtractor = new AnnotationExtractor(); + } + + /** + * Returns all type references extracted during the last extractFromMethod() call + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all annotations extracted from method type parameters during the last extractFromMethod() call + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns the TypeReferenceExtractor instance for direct extraction + */ + getTypeReferenceExtractor(): TypeReferenceExtractor { + return this.typeReferenceExtractor; + } + + /** + * Returns the AnnotationExtractor instance for direct extraction + */ + getAnnotationExtractor(): AnnotationExtractor { + return this.annotationExtractor; + } + + /** + * Extracts method type parameters from a specific method declaration node + * @param methodNode The method declaration node + * @param methodRegistryHash Hash of the owner method + * @param ownerMethodName Name of the owner method + * @param ownerMethodSignature Signature of the owner method + * @param ownerQualifiedMethodName Qualified name of the owner method + * @param filePath Path to the Java file + * @param startLine Start line of the method declaration + * @param typeRegistryHash Hash of the type containing this method + * @param packageName Package name for import resolution + * @param classTypeParams Set of class-level type parameter names (for context) + */ + extractFromMethod( + methodNode: Parser.SyntaxNode, + methodRegistryHash: string, + ownerMethodName: string, + ownerMethodSignature: string, + ownerQualifiedMethodName: string, + filePath: string, + startLine: number, + typeRegistryHash: string, + packageName: string | null, + classTypeParams: Set + ): MethodTypeParameter[] { + const methodTypeParameters: MethodTypeParameter[] = []; + this.extractedTypeReferences = []; // Reset for each extraction + this.extractedAnnotations = []; // Reset for each extraction + this.annotationExtractor.resetExtractedArguments(); // Reset annotation arguments + + const typeParamsNode = methodNode.childForFieldName('type_parameters'); + if (typeParamsNode) { + this.processMethodTypeParametersList( + typeParamsNode, + methodRegistryHash, + ownerMethodName, + ownerMethodSignature, + ownerQualifiedMethodName, + filePath, + startLine, + typeRegistryHash, + packageName, + classTypeParams, + methodTypeParameters + ); + } + + return methodTypeParameters; + } + + /** + * @deprecated Use extractFromMethod instead - this method is kept for interface compatibility + */ + extract(_filePath: string, _fileContent: string, _methodRegistryHash: string): MethodTypeParameter[] { + console.warn('MethodTypeParameterExtractor.extract() is deprecated. Use extractFromMethod() instead.'); + return []; + } + + /** + * Process type_parameters node and extract individual method type parameters + * Example: or or + */ + private processMethodTypeParametersList( + typeParamsNode: Parser.SyntaxNode, + methodRegistryHash: string, + ownerMethodName: string, + ownerMethodSignature: string, + ownerQualifiedMethodName: string, + filePath: string, + startLine: number, + typeRegistryHash: string, + packageName: string | null, + classTypeParams: Set, + methodTypeParameters: MethodTypeParameter[] + ): void { + // First pass: collect all method type parameter names + const declaredMethodTypeParams = new Set(); + for (const child of typeParamsNode.children) { + if (child.type === 'type_parameter') { + const name = this.extractTypeParameterName(child); + if (name) { + declaredMethodTypeParams.add(name); + } + } + } + + // Combine with class-level type parameters for bound resolution + const allTypeParams = new Set([...classTypeParams, ...declaredMethodTypeParams]); + + // Second pass: create MethodTypeParameter entities and extract bounds + let position = 0; + for (const child of typeParamsNode.children) { + if (child.type === 'type_parameter') { + const name = this.extractTypeParameterName(child); + if (name) { + // Check if this type parameter has bounds + const hasBounds = this.hasTypeBounds(child); + + const methodTypeParam = new MethodTypeParameter( + name, + position, + ownerMethodName, + ownerMethodSignature, + ownerQualifiedMethodName, + filePath, + startLine, + methodRegistryHash, + hasBounds + ); + methodTypeParam.generateHash(); + methodTypeParameters.push(methodTypeParam); + + // Extract annotations from method type parameter (e.g., <@NonNull T>) + const annotations = this.annotationExtractor.extractFromTypeParameter( + child, + methodTypeParam.getHash(), + typeRegistryHash + ); + this.extractedAnnotations.push(...annotations); + + // Extract annotations from method type parameter bounds (e.g., ) + const boundAnnotations = this.annotationExtractor.extractFromTypeBound( + child, + methodTypeParam.getHash(), + typeRegistryHash, + true // isMethodTypeParam = true for method-level type parameters + ); + this.extractedAnnotations.push(...boundAnnotations); + + // Extract TypeReferences from bounds (if any) with METHOD_TYPE_PARAM_BOUND context + const boundRefs = this.typeReferenceExtractor.extractFromMethodTypeParameterBounds( + child, + typeRegistryHash, + methodRegistryHash, + methodTypeParam.getHash(), + packageName, + allTypeParams + ); + this.extractedTypeReferences.push(...boundRefs); + + position++; + } + } + } + } + + /** + * Extract the name of a method type parameter + * Example: In , extracts "T" + */ + private extractTypeParameterName(node: Parser.SyntaxNode): string | null { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + return nameNode.text; + } + + // Fallback: look for type_identifier child + for (const child of node.children) { + if (child.type === 'type_identifier') { + return child.text; + } + } + + return null; + } + + /** + * Check if a type parameter has bounds (extends clause) + */ + private hasTypeBounds(node: Parser.SyntaxNode): boolean { + for (const child of node.children) { + if (child.type === 'type_bound') { + return true; + } + } + return false; + } +} diff --git a/parser/src/parsers/java/extractors/module-extractor.ts b/parser/src/parsers/java/extractors/module-extractor.ts new file mode 100644 index 000000000..dfee9726b --- /dev/null +++ b/parser/src/parsers/java/extractors/module-extractor.ts @@ -0,0 +1,145 @@ +import Parser from 'tree-sitter'; + +import { ModuleDirective } from '@/analysis-types/java/ModuleDirective'; +import { ModuleRegistry } from '@/analysis-types/java/ModuleRegistry'; +import { ModuleDirectiveKind, ModuleDirectiveModifier } from '@/enums/java/modules'; + +/** + * Extracts the module declaration in a `module-info.java` (JLS 7.7). + * + * The grammar already types every directive - `requires_module_directive`, + * `exports_module_directive`, and so on - so this walks the `module_body` and reads them off. + * Nothing here has to recover structure from raw text. + */ +export class ModuleExtractor { + private extractedDirectives: ModuleDirective[] = []; + + /** + * Returns the directives extracted during the last extract() call. + */ + getExtractedDirectives(): ModuleDirective[] { + return this.extractedDirectives; + } + + /** + * Extracts the module declaration from a parsed compilation unit, or null when the file + * declares no module. Only `module-info.java` may contain one. + */ + extract( + rootNode: Parser.SyntaxNode, + filePath: string, + serviceVersionHash: string + ): ModuleRegistry | null { + this.extractedDirectives = []; + + const moduleNode = rootNode.children.find(c => c.type === 'module_declaration'); + if (!moduleNode) return null; + + const name = this.moduleName(moduleNode); + if (!name) return null; + + // `open module M { }` opens every package, so such a module may legitimately declare no + // `opens` directive at all. + const isOpen = moduleNode.children.some(c => c.type === 'open' || c.text === 'open'); + + const module = new ModuleRegistry( + name, + isOpen, + filePath, + moduleNode.startPosition.row + 1, + moduleNode.endPosition.row + 1, + serviceVersionHash + ); + + const body = moduleNode.children.find(c => c.type === 'module_body'); + if (body) { + for (const directive of body.children) { + this.extractDirective(directive, module, filePath, serviceVersionHash); + } + } + + return module; + } + + /** + * The module name is the scoped_identifier directly under module_declaration - not one of the + * scoped_identifiers inside the body, which name packages and services. + */ + private moduleName(moduleNode: Parser.SyntaxNode): string | null { + const nameNode = moduleNode.children.find( + c => c.type === 'scoped_identifier' || c.type === 'identifier' + ); + return nameNode ? nameNode.text : null; + } + + private extractDirective( + node: Parser.SyntaxNode, + module: ModuleRegistry, + filePath: string, + serviceVersionHash: string + ): void { + const kind = this.directiveKind(node.type); + if (!kind) return; + + // Every name the directive mentions, in source order. The first is the subject; any that + // follow are the `to` / `with` targets. + const names = node.children + .filter(c => c.type === 'scoped_identifier' || c.type === 'identifier') + .map(c => c.text); + + const subject = names[0]; + if (!subject) return; + + const modifiers = this.requiresModifiers(node); + const targets = names.slice(1); + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + + const push = (targetName: string, position: number) => { + this.extractedDirectives.push(new ModuleDirective( + module.getHash(), + kind, + subject, + targetName, + modifiers, + position, + filePath, + startLine, + endLine, + serviceVersionHash + )); + }; + + if (targets.length === 0) { + // Unqualified: `exports p;`, `requires m;`, `uses s;`. One row, empty target. + push('', 0); + return; + } + + targets.forEach((target, index) => push(target, index)); + } + + private directiveKind(nodeType: string): ModuleDirectiveKind | null { + switch (nodeType) { + case 'requires_module_directive': return ModuleDirectiveKind.REQUIRES; + case 'exports_module_directive': return ModuleDirectiveKind.EXPORTS; + case 'opens_module_directive': return ModuleDirectiveKind.OPENS; + case 'uses_module_directive': return ModuleDirectiveKind.USES; + case 'provides_module_directive': return ModuleDirectiveKind.PROVIDES; + default: return null; + } + } + + /** + * `transitive` and `static` appear as requires_modifier children. Only `requires` takes them. + */ + private requiresModifiers(node: Parser.SyntaxNode): ModuleDirectiveModifier[] { + const modifiers: ModuleDirectiveModifier[] = []; + for (const child of node.children) { + if (child.type !== 'requires_modifier') continue; + if (child.text.includes('transitive')) modifiers.push(ModuleDirectiveModifier.TRANSITIVE); + if (child.text.includes('static')) modifiers.push(ModuleDirectiveModifier.STATIC); + } + return modifiers; + } +} diff --git a/parser/src/parsers/java/extractors/scope-context.ts b/parser/src/parsers/java/extractors/scope-context.ts new file mode 100644 index 000000000..06ea3de4b --- /dev/null +++ b/parser/src/parsers/java/extractors/scope-context.ts @@ -0,0 +1,833 @@ +import Parser from 'tree-sitter'; +import { BlockKind } from '@/enums/java/blocks'; +import { LocalVariableScopeKind } from '@/enums/java/local-variables'; +import { ScopeType } from '@/enums/java/scopes'; + +// Re-export ScopeType for convenience +export { ScopeType }; + +/** + * Represents a single scope level in the hierarchy. + * + * The scope stack tracks the nesting of constructs as we traverse the AST: + * - method → lambda → try → catch → nested lambda → etc. + * + * Each scope level knows: + * - Its type (method, lambda, block) + * - Its unique hash (for linking children) + * - The appropriate scope kind for local variables declared within it + */ +export interface ScopeLevel { + /** The type of scope */ + type: ScopeType; + + /** Unique hash for this scope (method hash, lambda expression hash, or block hash) */ + hash: string | undefined; + + /** The scope kind for local variables declared directly in this scope */ + scopeKind: LocalVariableScopeKind; + + /** For block scopes, the block kind */ + blockKind?: BlockKind; + + /** Lambda depth at this level (0 = not in lambda) */ + lambdaDepth: number; + + /** Block depth at this level (nested blocks within same scope) */ + blockDepth: number; + + /** For lambda scopes, whether this lambda is from a local variable initializer */ + isFromLocalVarInitializer?: boolean; +} + +/** + * Mapping from AST node types to their scope behavior. + * This defines what happens when we encounter each node type during traversal. + */ +export interface NodeScopeRule { + /** Does this node create a new scope boundary (lambda, method)? */ + createsScopeBoundary: boolean; + + /** Does this node create a block within the current scope? */ + createsBlock: boolean; + + /** The block kind if this creates a block */ + blockKind?: BlockKind; + + /** The scope kind for variables declared in this construct */ + scopeKind?: LocalVariableScopeKind; + + /** Does this node extract a catch parameter? */ + extractsCatchParameter?: boolean; + + /** Does this node extract pattern bindings? */ + extractsPatternBindings?: boolean; + + /** Does this node extract for loop variables? */ + extractsForLoopVariable?: boolean; + + /** Does this node extract try-with-resources variables? */ + extractsResourceVariables?: boolean; +} + +/** + * Rules for how each AST node type affects scope tracking. + * This eliminates the need for manual permutation handling. + */ +export const NODE_SCOPE_RULES: Map = new Map([ + // === Scope Boundaries (reset block context) === + ['lambda_expression', { + createsScopeBoundary: true, + createsBlock: false, + scopeKind: LocalVariableScopeKind.LAMBDA_BODY, + }], + + // === Exception Handling Blocks === + ['try_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.TRY, + scopeKind: LocalVariableScopeKind.TRY_BLOCK, + }], + ['try_with_resources_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.TRY_WITH_RESOURCES, + scopeKind: LocalVariableScopeKind.TRY_BLOCK, + extractsResourceVariables: true, + }], + ['catch_clause', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.CATCH, + scopeKind: LocalVariableScopeKind.CATCH_BLOCK, + extractsCatchParameter: true, + }], + ['finally_clause', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.FINALLY, + scopeKind: LocalVariableScopeKind.FINALLY_BLOCK, + }], + + // === Loop Blocks === + ['for_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.FOR, + scopeKind: LocalVariableScopeKind.FOR_BLOCK, + extractsForLoopVariable: true, + }], + ['enhanced_for_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.ENHANCED_FOR, + scopeKind: LocalVariableScopeKind.FOR_BLOCK, + extractsForLoopVariable: true, + }], + ['while_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.WHILE, + scopeKind: LocalVariableScopeKind.WHILE_BLOCK, + }], + ['do_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.DO_WHILE, + scopeKind: LocalVariableScopeKind.DO_WHILE_BLOCK, + }], + + // === Conditional Blocks === + ['if_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.IF, + scopeKind: LocalVariableScopeKind.IF_BLOCK, + extractsPatternBindings: true, + }], + + // === Switch Constructs === + ['switch_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.SWITCH_CASE, + scopeKind: LocalVariableScopeKind.SWITCH_BLOCK, + extractsPatternBindings: true, + }], + ['switch_expression', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.SWITCH_EXPRESSION_CASE, + scopeKind: LocalVariableScopeKind.SWITCH_BLOCK, + extractsPatternBindings: true, + }], + + // === Synchronization === + ['synchronized_statement', { + createsScopeBoundary: false, + createsBlock: true, + blockKind: BlockKind.SYNCHRONIZED, + scopeKind: LocalVariableScopeKind.SYNCHRONIZED_BLOCK, + }], +]); + +/** + * Manages scope context as we traverse the AST. + * + * This class maintains a stack of scope levels, automatically handling: + * - Lambda expressions creating new scope boundaries + * - Block constructs (try/catch/if/for) creating blocks within scopes + * - Proper parent hash resolution for variables and expressions + * + * ## Usage + * + * ```typescript + * const scopeContext = new ScopeContext(); + * + * // Enter a method + * scopeContext.enterMethod(methodHash, LocalVariableScopeKind.METHOD_BODY); + * + * // When encountering any node, check if it needs scope handling + * const rule = scopeContext.getRuleForNode(node.type); + * if (rule) { + * if (rule.createsScopeBoundary) { + * scopeContext.enterLambda(lambdaHash); + * } else if (rule.createsBlock) { + * scopeContext.enterBlock(blockHash, rule.blockKind!, rule.scopeKind!); + * } + * } + * + * // Get the owner hash for a variable/expression + * const ownerHash = scopeContext.getCurrentOwnerHash(); + * const scopeKind = scopeContext.getCurrentScopeKind(); + * + * // Exit the scope when done + * scopeContext.exit(); + * ``` + */ +export class ScopeContext { + private stack: ScopeLevel[] = []; + + /** Names of variables in scope (for expression classification) */ + private localVariableNames: Set = new Set(); + private lambdaParamNames: Set = new Set(); + private methodParamNames: Set = new Set(); + private patternBindingNames: Set = new Set(); + + /** Saved variable name sets for restoration on scope exit */ + private savedVariableStates: Array<{ + localVariableNames: Set; + lambdaParamNames: Set; + patternBindingNames: Set; + }> = []; + + /** + * Get the rule for a given AST node type. + */ + getRuleForNode(nodeType: string): NodeScopeRule | undefined { + return NODE_SCOPE_RULES.get(nodeType); + } + + /** + * Enter a method scope. + */ + enterMethod( + methodHash: string | undefined, + scopeKind: LocalVariableScopeKind, + methodParamNames: Set = new Set() + ): void { + this.stack.push({ + type: ScopeType.METHOD, + hash: methodHash, + scopeKind, + lambdaDepth: 0, + blockDepth: 0, + }); + this.methodParamNames = new Set(methodParamNames); + this.localVariableNames = new Set(); + this.lambdaParamNames = new Set(); + this.patternBindingNames = new Set(); + } + + /** + * Enter a static initializer scope. + */ + enterStaticInitializer(typeHash: string): void { + this.stack.push({ + type: ScopeType.STATIC_INITIALIZER, + hash: typeHash, + scopeKind: LocalVariableScopeKind.STATIC_INITIALIZER, + lambdaDepth: 0, + blockDepth: 0, + }); + this.localVariableNames = new Set(); + this.lambdaParamNames = new Set(); + this.patternBindingNames = new Set(); + } + + /** + * Enter an instance initializer scope. + */ + enterInstanceInitializer(typeHash: string): void { + this.stack.push({ + type: ScopeType.INSTANCE_INITIALIZER, + hash: typeHash, + scopeKind: LocalVariableScopeKind.INSTANCE_INITIALIZER, + lambdaDepth: 0, + blockDepth: 0, + }); + this.localVariableNames = new Set(); + this.lambdaParamNames = new Set(); + this.patternBindingNames = new Set(); + } + + /** + * Enter a lambda expression scope. + * This creates a scope boundary - block context is reset. + * @param isFromLocalVarInitializer Whether this lambda is from a local variable initializer + * (affects whether LocalVariableExtractor should extract return/expression statements) + */ + enterLambda( + lambdaHash: string | undefined, + lambdaParamNames: Set = new Set(), + isFromLocalVarInitializer: boolean = false + ): void { + // Save current variable state + this.savedVariableStates.push({ + localVariableNames: new Set(this.localVariableNames), + lambdaParamNames: new Set(this.lambdaParamNames), + patternBindingNames: new Set(this.patternBindingNames), + }); + + const currentDepth = this.getCurrentLambdaDepth(); + + this.stack.push({ + type: ScopeType.LAMBDA, + hash: lambdaHash, + scopeKind: LocalVariableScopeKind.LAMBDA_BODY, + lambdaDepth: currentDepth + 1, + blockDepth: 0, // Reset block depth for lambda + isFromLocalVarInitializer, + }); + + // Add lambda params to tracking + for (const name of lambdaParamNames) { + this.lambdaParamNames.add(name); + } + // Reset pattern bindings for new lambda scope + this.patternBindingNames = new Set(); + } + + /** + * Enter a block scope (try, catch, if, for, while, etc.) + */ + enterBlock( + blockHash: string | undefined, + blockKind: BlockKind, + scopeKind: LocalVariableScopeKind + ): void { + const current = this.getCurrentLevel(); + const currentBlockDepth = current?.blockDepth ?? 0; + const currentLambdaDepth = current?.lambdaDepth ?? 0; + + this.stack.push({ + type: ScopeType.BLOCK, + hash: blockHash, + scopeKind, + blockKind, + lambdaDepth: currentLambdaDepth, + blockDepth: currentBlockDepth + 1, + }); + } + + /** + * Exit the current scope level. + */ + exit(): void { + const exited = this.stack.pop(); + + // Restore variable state if exiting a lambda + if (exited?.type === ScopeType.LAMBDA && this.savedVariableStates.length > 0) { + const saved = this.savedVariableStates.pop()!; + this.localVariableNames = saved.localVariableNames; + this.lambdaParamNames = saved.lambdaParamNames; + this.patternBindingNames = saved.patternBindingNames; + } + } + + /** + * Get the current scope level (top of stack). + */ + getCurrentLevel(): ScopeLevel | undefined { + return this.stack[this.stack.length - 1]; + } + + /** + * Get the owner hash for linking variables/expressions. + * Returns the hash of the most specific containing scope (block > lambda > method). + */ + getCurrentOwnerHash(): string | undefined { + // Walk up the stack to find the most specific owner + for (let i = this.stack.length - 1; i >= 0; i--) { + const level = this.stack[i]; + if (level && level.hash) { + return level.hash; + } + } + return undefined; + } + + /** + * Get the block hash if currently inside a block, otherwise undefined. + */ + getCurrentBlockHash(): string | undefined { + const current = this.getCurrentLevel(); + if (current?.type === ScopeType.BLOCK) { + return current.hash; + } + return undefined; + } + + /** + * Get the lambda expression hash if currently inside a lambda. + */ + getCurrentLambdaHash(): string | undefined { + // Find the nearest lambda in the stack + for (let i = this.stack.length - 1; i >= 0; i--) { + const level = this.stack[i]; + if (level && level.type === ScopeType.LAMBDA) { + return level.hash; + } + } + return undefined; + } + + /** + * Check if the current lambda is from a local variable initializer. + * Used to determine if LocalVariableExtractor should extract return/expression statements + * (TypeMethodExtractor skips lambdas in local var initializers). + */ + isCurrentLambdaFromLocalVarInitializer(): boolean { + // Find the nearest lambda in the stack + for (let i = this.stack.length - 1; i >= 0; i--) { + const level = this.stack[i]; + if (level && level.type === ScopeType.LAMBDA) { + return level.isFromLocalVarInitializer ?? false; + } + } + return false; + } + + /** + * Get the appropriate scope kind for variables declared at the current level. + */ + getCurrentScopeKind(): LocalVariableScopeKind { + const current = this.getCurrentLevel(); + return current?.scopeKind ?? LocalVariableScopeKind.METHOD_BODY; + } + + /** + * Get the current lambda depth (0 = not in lambda). + */ + getCurrentLambdaDepth(): number { + const current = this.getCurrentLevel(); + return current?.lambdaDepth ?? 0; + } + + /** + * Get the current block depth. + */ + getCurrentBlockDepth(): number { + const current = this.getCurrentLevel(); + return current?.blockDepth ?? 0; + } + + /** + * Get the total scope depth (lambda + block). + */ + getCurrentScopeDepth(): number { + return this.getCurrentLambdaDepth() + this.getCurrentBlockDepth(); + } + + /** + * Check if we're currently inside a lambda. + */ + isInsideLambda(): boolean { + return this.getCurrentLambdaDepth() > 0; + } + + /** + * Check if we're currently inside a block (try/catch/if/for/etc.) + */ + isInsideBlock(): boolean { + const current = this.getCurrentLevel(); + return current?.type === ScopeType.BLOCK; + } + + // === Variable Name Tracking === + + /** + * Add a local variable name to the current scope. + */ + addLocalVariableName(name: string): void { + this.localVariableNames.add(name); + } + + /** + * Add a pattern binding name to the current scope. + */ + addPatternBindingName(name: string): void { + this.patternBindingNames.add(name); + } + + /** + * Get all local variable names in scope. + */ + getLocalVariableNames(): Set { + return this.localVariableNames; + } + + /** + * Get all lambda parameter names in scope. + */ + getLambdaParamNames(): Set { + return this.lambdaParamNames; + } + + /** + * Get all method parameter names. + */ + getMethodParamNames(): Set { + return this.methodParamNames; + } + + /** + * Get all pattern binding names in scope. + */ + getPatternBindingNames(): Set { + return this.patternBindingNames; + } + + /** + * Check if a name is a local variable. + */ + isLocalVariable(name: string): boolean { + return this.localVariableNames.has(name); + } + + /** + * Check if a name is a lambda parameter. + */ + isLambdaParam(name: string): boolean { + return this.lambdaParamNames.has(name); + } + + /** + * Check if a name is a method parameter. + */ + isMethodParam(name: string): boolean { + return this.methodParamNames.has(name); + } + + /** + * Check if a name is a pattern binding. + */ + isPatternBinding(name: string): boolean { + return this.patternBindingNames.has(name); + } + + /** + * Get the parent container hash for creating blocks. + * This is the hash of the containing scope (lambda expression or outer block). + */ + getParentContainerHash(): string | undefined { + // Skip the current level and find the parent + for (let i = this.stack.length - 2; i >= 0; i--) { + const level = this.stack[i]; + if (level && level.hash) { + return level.hash; + } + } + return undefined; + } + + /** + * Clear all state (for reuse). + */ + reset(): void { + this.stack = []; + this.localVariableNames = new Set(); + this.lambdaParamNames = new Set(); + this.methodParamNames = new Set(); + this.patternBindingNames = new Set(); + this.savedVariableStates = []; + } + + /** + * Get a debug string representation of the current stack. + */ + debugStack(): string { + return this.stack.map((level, i) => + `${i}: ${level.type} (${level.scopeKind}) hash=${level.hash?.substring(0, 20)}...` + ).join('\n'); + } +} + +/** + * Configuration for AST traversal with scope tracking. + */ +export interface TraversalConfig { + /** Called when entering a node. Return false to skip children. */ + onEnter?: (node: Parser.SyntaxNode, context: ScopeContext) => boolean | void; + + /** Called when exiting a node (after children processed). */ + onExit?: (node: Parser.SyntaxNode, context: ScopeContext) => void; + + /** Function to resolve a lambda expression hash from position. */ + resolveLambdaHash?: (node: Parser.SyntaxNode) => string | undefined; + + /** Function to resolve a block hash from position. */ + resolveBlockHash?: (node: Parser.SyntaxNode, blockKind: BlockKind) => string | undefined; + + /** Function to extract lambda parameter names. */ + extractLambdaParams?: (node: Parser.SyntaxNode) => Set; +} + +/** + * Traverses an AST node tree with automatic scope management. + * + * This function walks the AST and automatically pushes/pops scope context + * based on the NODE_SCOPE_RULES mapping. Extractors can use the callbacks + * to perform their extraction logic without worrying about scope management. + * + * ## Example + * + * ```typescript + * const context = new ScopeContext(); + * context.enterMethod(methodHash, LocalVariableScopeKind.METHOD_BODY); + * + * traverseWithScope(methodBody, context, { + * onEnter: (node, ctx) => { + * if (node.type === 'local_variable_declaration') { + * // Extract variable with correct owner from ctx.getCurrentOwnerHash() + * } + * }, + * resolveLambdaHash: (node) => findLambdaExpressionHash(node), + * resolveBlockHash: (node, kind) => createBlockHash(node, kind), + * }); + * ``` + */ +export function traverseWithScope( + node: Parser.SyntaxNode, + context: ScopeContext, + config: TraversalConfig +): void { + const rule = context.getRuleForNode(node.type); + let scopePushed = false; + + // Handle scope entry based on node type + if (rule) { + if (rule.createsScopeBoundary) { + // Lambda expression - creates new scope boundary + const lambdaHash = config.resolveLambdaHash?.(node); + const lambdaParams = config.extractLambdaParams?.(node) ?? new Set(); + context.enterLambda(lambdaHash, lambdaParams); + scopePushed = true; + } else if (rule.createsBlock && rule.blockKind && rule.scopeKind) { + // Block construct (try, catch, if, for, etc.) + const blockHash = config.resolveBlockHash?.(node, rule.blockKind); + context.enterBlock(blockHash, rule.blockKind, rule.scopeKind); + scopePushed = true; + } + } + + // Call onEnter callback + const shouldProcessChildren = config.onEnter?.(node, context) !== false; + + // Process children if allowed + if (shouldProcessChildren) { + for (const child of node.children) { + traverseWithScope(child, context, config); + } + } + + // Call onExit callback + config.onExit?.(node, context); + + // Exit scope if we pushed one + if (scopePushed) { + context.exit(); + } +} + +/** + * Gets the body node for a given construct (lambda, try, if, etc.) + */ +export function getBodyNode(node: Parser.SyntaxNode): Parser.SyntaxNode | undefined { + switch (node.type) { + case 'lambda_expression': + return node.children.find(c => c.type === 'block' || !['identifier', 'inferred_parameters', 'formal_parameters', '->'].includes(c.type)); + + case 'try_statement': + case 'try_with_resources_statement': + return node.children.find(c => c.type === 'block'); + + case 'catch_clause': + return node.children.find(c => c.type === 'block'); + + case 'finally_clause': + return node.children.find(c => c.type === 'block'); + + case 'if_statement': + return node.children.find(c => c.type === 'block' || c.type === 'expression_statement'); + + case 'for_statement': + case 'enhanced_for_statement': + case 'while_statement': + case 'do_statement': + return node.children.find(c => c.type === 'block'); + + case 'synchronized_statement': + return node.children.find(c => c.type === 'block'); + + default: + return undefined; + } +} + +/** + * Extracts catch parameter info from a catch clause. + */ +export function extractCatchParameter(catchClause: Parser.SyntaxNode): { + name: string; + types: string[]; + node: Parser.SyntaxNode; +} | undefined { + const catchFormalParam = catchClause.children.find(c => c.type === 'catch_formal_parameter'); + if (!catchFormalParam) return undefined; + + const nameNode = catchFormalParam.children.find(c => c.type === 'identifier'); + if (!nameNode) return undefined; + + const types: string[] = []; + const catchType = catchFormalParam.children.find(c => c.type === 'catch_type'); + if (catchType) { + for (const child of catchType.children) { + if (child.type === 'type_identifier' || child.type === 'scoped_type_identifier') { + types.push(child.text); + } + } + } + + return { + name: nameNode.text, + types, + node: catchFormalParam, + }; +} + +/** + * Extracts for loop variable info. + */ +export function extractForLoopVariable(forStatement: Parser.SyntaxNode): { + name: string; + type: string; + node: Parser.SyntaxNode; +} | undefined { + if (forStatement.type === 'enhanced_for_statement') { + const typeNode = forStatement.children.find(c => + c.type === 'type_identifier' || c.type === 'generic_type' || c.type === 'array_type' + ); + const nameNode = forStatement.children.find(c => c.type === 'identifier'); + + if (typeNode && nameNode) { + return { + name: nameNode.text, + type: typeNode.text, + node: forStatement, + }; + } + } else if (forStatement.type === 'for_statement') { + const init = forStatement.children.find(c => c.type === 'local_variable_declaration'); + if (init) { + const typeNode = init.children.find(c => + c.type === 'type_identifier' || c.type === 'generic_type' || c.type === 'integral_type' + ); + const declarator = init.children.find(c => c.type === 'variable_declarator'); + const nameNode = declarator?.children.find(c => c.type === 'identifier'); + + if (typeNode && nameNode) { + return { + name: nameNode.text, + type: typeNode.text, + node: init, + }; + } + } + } + return undefined; +} + +/** + * Extracts try-with-resources variable info. + */ +export function extractResourceVariables(tryStatement: Parser.SyntaxNode): Array<{ + name: string; + type: string; + node: Parser.SyntaxNode; +}> { + const resources: Array<{ name: string; type: string; node: Parser.SyntaxNode }> = []; + + const resourceSpec = tryStatement.children.find(c => c.type === 'resource_specification'); + if (!resourceSpec) return resources; + + for (const child of resourceSpec.children) { + if (child.type === 'resource') { + const typeNode = child.children.find(c => + c.type === 'type_identifier' || c.type === 'generic_type' + ); + const nameNode = child.children.find(c => c.type === 'identifier'); + + if (typeNode && nameNode) { + resources.push({ + name: nameNode.text, + type: typeNode.text, + node: child, + }); + } + } + } + + return resources; +} + +/** + * Extracts lambda parameter names from a lambda expression. + */ +export function extractLambdaParameterNames(lambdaNode: Parser.SyntaxNode): Set { + const names = new Set(); + + for (const child of lambdaNode.children) { + if (child.type === 'identifier') { + names.add(child.text); + } else if (child.type === 'inferred_parameters') { + for (const param of child.children) { + if (param.type === 'identifier') { + names.add(param.text); + } + } + } else if (child.type === 'formal_parameters') { + for (const param of child.children) { + if (param.type === 'formal_parameter') { + const nameNode = param.children.find(c => c.type === 'identifier'); + if (nameNode) { + names.add(nameNode.text); + } + } + } + } + } + + return names; +} diff --git a/parser/src/parsers/java/extractors/type-method-extractor.ts b/parser/src/parsers/java/extractors/type-method-extractor.ts new file mode 100644 index 000000000..e51f89469 --- /dev/null +++ b/parser/src/parsers/java/extractors/type-method-extractor.ts @@ -0,0 +1,3725 @@ +import Parser from 'tree-sitter'; + +import { MethodParameter } from '@/analysis-methods/java/MethodParameter'; +import { MethodRegistry } from '@/analysis-methods/java/MethodRegistry'; +import { MethodTypeParameter } from '@/analysis-methods/java/MethodTypeParameter'; +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { MethodAccess, MethodKind, MethodModifier } from '@/enums/java/methods'; +import { EdgeRole, ExpressionKind, ExpressionOwnerKind, RootContext } from '@/enums/java/expressions'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { ExpressionReferenceExtractor, AnonymousClassInfo } from '@/parsers/java/extractors/expression-reference-extractor'; +import { MethodParameterExtractor } from '@/parsers/java/extractors/method-parameter-extractor'; +import { MethodTypeParameterExtractor } from '@/parsers/java/extractors/method-type-parameter-extractor'; +import { TypeReferenceExtractor } from '@/parsers/java/extractors/type-reference-extractor'; +import { LocalVariableExtractor } from '@/parsers/java/extractors/local-variable-extractor'; +import { LocalVariableRegistry } from '@/analysis-types/java/LocalVariableRegistry'; +import { BlockRegistry } from '@/analysis-types/java/BlockRegistry'; +import { LocalVariableScopeKind } from '@/enums/java/local-variables'; +import { BlockKind } from '@/enums/java/blocks'; +import { EntityUtils } from '@/utils/entity-utils'; +import { JavaTreeSitterUtils } from '@/utils/java/java-tree-sitter-utils'; + +/** + * Information about a statement found inside a method body, including + * its containing lambda and block (if any) for proper ownership tracking. + */ +interface StatementWithContext { + node: Parser.SyntaxNode; + /** Position key of the containing lambda expression (startLine:startCol:endLine:endCol), or null if directly in method body */ + containingLambdaPosition: string | null; + /** Hash of the innermost containing block (try/catch/for/etc), or null if not in any */ + containingBlockHash: string | null; + /** Lambda parameter names in scope for this statement */ + lambdaParamNames: Set; +} + +/** + * Extracts MethodRegistry entities from Java type bodies using tree-sitter + * + * Handles extraction of: + * - Regular methods (instance and static) + * - Abstract methods + * - Constructors (regular and compact) + * - Default interface methods + * - Static and instance initializers + * - Annotation elements + * + * ## Signature Generation + * + * Two types of signatures are generated: + * + * **signature** (canonical): + * - Generics stripped: `List` not `List` + * - Varargs normalized: `String[]` not `String...` + * - No parameter names: `method(String,int):void` + * - Used for method identity and overload resolution + * + * **detailedSignature** (display): + * - Full generics preserved: `List` + * - Varargs preserved: `String...` + * - Parameter names included: `method(String name, int age):void` + * - Used for display and exact source matching + */ +export class TypeMethodExtractor { + private methodParameterExtractor: MethodParameterExtractor; + private methodTypeParameterExtractor: MethodTypeParameterExtractor; + private annotationExtractor: AnnotationExtractor; + private typeReferenceExtractor: TypeReferenceExtractor; + private expressionExtractor: ExpressionReferenceExtractor; + private localVariableExtractor: LocalVariableExtractor; + private extractedMethodParameters: MethodParameter[] = []; + private extractedMethodTypeParameters: MethodTypeParameter[] = []; + private extractedTypeReferences: TypeReference[] = []; + private extractedAnnotations: TypeAnnotation[] = []; + private extractedAnnotationArguments: AnnotationArgumentReference[] = []; + private extractedExpressions: ExpressionReference[] = []; + private extractedAnonymousClasses: AnonymousClassInfo[] = []; + private extractedLocalVariables: LocalVariableRegistry[] = []; + private extractedBlocks: BlockRegistry[] = []; + + constructor() { + this.methodParameterExtractor = new MethodParameterExtractor(); + this.methodTypeParameterExtractor = new MethodTypeParameterExtractor(); + this.annotationExtractor = new AnnotationExtractor(); + this.typeReferenceExtractor = new TypeReferenceExtractor(); + this.expressionExtractor = new ExpressionReferenceExtractor(); + this.localVariableExtractor = new LocalVariableExtractor(); + } + + /** + * Returns all method parameters extracted during the last extraction + */ + getExtractedMethodParameters(): MethodParameter[] { + return this.extractedMethodParameters; + } + + /** + * Returns all method type parameters extracted during the last extraction + */ + getExtractedMethodTypeParameters(): MethodTypeParameter[] { + return this.extractedMethodTypeParameters; + } + + /** + * Returns all annotations extracted during the last extraction + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns all type references extracted from method parameters + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all annotation arguments extracted during the last extraction + */ + getExtractedAnnotationArguments(): AnnotationArgumentReference[] { + return this.extractedAnnotationArguments; + } + + /** + * Returns all expressions extracted from method bodies during the last extraction + */ + getExtractedExpressions(): ExpressionReference[] { + return this.extractedExpressions; + } + + /** + * Returns all anonymous classes encountered during the last extraction. + * These need to be registered as types at a higher level. + */ + getExtractedAnonymousClasses(): AnonymousClassInfo[] { + return this.extractedAnonymousClasses; + } + + /** + * Returns all local variables extracted from method bodies during the last extraction + */ + getExtractedLocalVariables(): LocalVariableRegistry[] { + return this.extractedLocalVariables; + } + + /** + * Returns all blocks extracted from method bodies during the last extraction + */ + getExtractedBlocks(): BlockRegistry[] { + return this.extractedBlocks; + } + + /** + * Helper to collect type references and anonymous classes after expression extraction. + * Consolidates the repeated pattern of getting and pushing these collections. + */ + private collectExpressionExtractorResults(): void { + const typeRefs = this.expressionExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...typeRefs); + + const anonymousClasses = this.expressionExtractor.getExtractedAnonymousClasses(); + this.extractedAnonymousClasses.push(...anonymousClasses); + } + + /** + * Extracts methods from a type body + * @param typeNode The type declaration node (class_declaration, interface_declaration, etc.) + * @param typeRegistryHash The hash of the owning type + * @param serviceVersionHash The hash of the service version + * @param packageName Package name for import resolution + * @param importMap Map of simple type names to fully qualified names from imports + * @param hasStarImports Whether file contains star imports + */ + extractFromType( + typeNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): MethodRegistry[] { + // Extract class-level type parameters + const classTypeParams = this.extractClassTypeParameters(typeNode); + const methods: MethodRegistry[] = []; + this.extractedMethodParameters = []; // Reset for each type + this.extractedMethodTypeParameters = []; // Reset for each type + this.extractedTypeReferences = []; // Reset for each type + this.extractedAnnotations = []; // Reset for each type + this.extractedAnnotationArguments = []; // Reset for each type + this.extractedExpressions = []; // Reset for each type + this.extractedAnonymousClasses = []; // Reset for each type + this.extractedLocalVariables = []; // Reset for each type + this.extractedBlocks = []; // Reset for each type + + // For records, create a canonical constructor from record components + if (typeNode.type === 'record_declaration') { + this.processRecordCanonicalConstructor( + typeNode, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + classTypeParams, + methods + ); + } + + const bodyNode = this.findTypeBody(typeNode); + if (!bodyNode) return methods; + + // Process all method declarations in the type body + this.extractMethodsFromBody( + bodyNode, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + classTypeParams, + methods + ); + + // Synthesised after the body scan, so the type's own declarations are already in `methods` + // and can suppress the implicit member javac would not declare either. + if (typeNode.type === 'record_declaration') { + this.synthesizeRecordImplicitMembers( + typeNode, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + methods + ); + } else if (typeNode.type === 'enum_declaration') { + this.synthesizeEnumImplicitMembers( + typeNode, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + methods + ); + } else if (typeNode.type === 'class_declaration') { + this.synthesizeDefaultConstructor( + typeNode, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + methods + ); + } + + return methods; + } + + /** + * Extracts methods from an anonymous class body. + * Used when the class_body node is already available (e.g., from field initializers). + */ + extractFromAnonymousClassBody( + classBodyNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): MethodRegistry[] { + const methods: MethodRegistry[] = []; + this.extractedMethodParameters = []; + this.extractedMethodTypeParameters = []; + this.extractedTypeReferences = []; + this.extractedAnnotations = []; + this.extractedAnnotationArguments = []; + this.extractedExpressions = []; + this.extractedAnonymousClasses = []; + this.extractedLocalVariables = []; + this.extractedBlocks = []; + + // Anonymous classes don't have their own type parameters + const classTypeParams = new Set(); + + this.extractMethodsFromBody( + classBodyNode, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + classTypeParams, + methods + ); + + return methods; + } + + /** + * Recursively extracts methods from a class/interface/enum body + */ + private extractMethodsFromBody( + bodyNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + classTypeParams: Set, + methods: MethodRegistry[], + isInEnumConstantBody: boolean = false, + enclosingMemberLinkHash?: string + ): void { + for (const child of bodyNode.children) { + if (this.isMethodDeclaration(child)) { + this.processMethodDeclaration( + child, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + classTypeParams, + methods, + isInEnumConstantBody, + enclosingMemberLinkHash + ); + } else if (this.isInitializerBlock(child)) { + const method = this.createInitializerMethod( + child, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + if (method) { + methods.push(method); + + // Extract expressions from initializer block body + const initializerExpressions = this.extractInitializerBlockExpressions( + child, + typeRegistryHash, + method.getHash(), + packageName, + importMap, + hasStarImports, + filePath + ); + this.extractedExpressions.push(...initializerExpressions); + + // Extract local variables from initializer block + // For static_initializer, the block is a child; for instance initializer, the node IS the block + const bodyBlock = child.type === 'static_initializer' + ? child.children.find(c => c.type === 'block') + : child; + + if (bodyBlock) { + const isStatic = child.type === 'static_initializer'; + const localVariables = isStatic + ? this.localVariableExtractor.extractFromStaticInitializer( + bodyBlock, + filePath, + typeRegistryHash, + method.getHash(), + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports + ) + : this.localVariableExtractor.extractFromInstanceInitializer( + bodyBlock, + filePath, + typeRegistryHash, + method.getHash(), + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + this.extractedLocalVariables.push(...localVariables); + + // Collect expressions from local variable initializers + const localVarExpressions = this.localVariableExtractor.getExtractedExpressions(); + this.extractedExpressions.push(...localVarExpressions); + + // Collect type references from local variable types + const localVarTypeRefs = this.localVariableExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...localVarTypeRefs); + + // Collect anonymous classes from local variable initializers + const localVarAnonClasses = this.localVariableExtractor.getExtractedAnonymousClasses(); + this.extractedAnonymousClasses.push(...localVarAnonClasses); + + // Collect annotations from local variable declarations + const localVarAnnotations = this.localVariableExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...localVarAnnotations); + + // Collect annotation arguments from local variable declarations + const localVarAnnotationArgs = this.localVariableExtractor.getExtractedAnnotationArguments(); + this.extractedAnnotationArguments.push(...localVarAnnotationArgs); + + // Collect blocks from initializer (try/catch/for/if etc inside static{} or {}) + const initializerBlocks = this.localVariableExtractor.getExtractedBlocks(); + this.extractedBlocks.push(...initializerBlocks); + } + } + } else if (child.type === 'enum_body_declarations') { + // Enum methods are nested inside enum_body_declarations + this.extractMethodsFromBody( + child, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + classTypeParams, + methods + ); + } else if (child.type === 'enum_constant') { + // Check for anonymous class methods in enum constants + // Generate enum constant hash for linking + const constantHash = this.generateEnumConstantHash(child, filePath, typeRegistryHash); + + for (const constantChild of child.children) { + if (constantChild.type === 'class_body') { + // Extract methods from the anonymous class body (mark as enum constant methods) + this.extractMethodsFromBody( + constantChild, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + classTypeParams, + methods, + true, // isInEnumConstantBody = true + constantHash // Pass enum constant hash + ); + } + } + } + } + } + + /** + * Processes a single method declaration and extracts its metadata + */ + private processMethodDeclaration( + methodNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + classTypeParams: Set, + methods: MethodRegistry[], + isInEnumConstantBody: boolean = false, + enclosingMemberLinkHash?: string + ): void { + const method = this.createMethodRegistry( + methodNode, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash, + isInEnumConstantBody, + enclosingMemberLinkHash + ); + if (method) { + methods.push(method); + + // Extract method annotations + this.annotationExtractor.resetExtractedArguments(); + const methodAnnotations = this.annotationExtractor.extractFromMethodDeclaration( + methodNode, + method.getHash(), + typeRegistryHash + ); + this.extractedAnnotations.push(...methodAnnotations); + + // Collect annotation arguments from method annotations + const methodAnnotationArgs = this.annotationExtractor.getExtractedArguments(); + this.extractedAnnotationArguments.push(...methodAnnotationArgs); + + // Collect type references from method annotation arguments + const methodAnnotationTypeRefs = this.annotationExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...methodAnnotationTypeRefs); + + // Extract method-level type parameters and combine with class-level + const methodTypeParams = this.extractMethodTypeParameters(methodNode); + const allTypeParams = new Set([...classTypeParams, ...methodTypeParams]); + + // Extract MethodTypeParameter entities with bounds + const methodTypeParameters = this.methodTypeParameterExtractor.extractFromMethod( + methodNode, + method.getHash(), + method.getName(), + method.getSignature(), + method.getQualifiedName(), + filePath, + method.getStartLine(), + typeRegistryHash, + packageName, + classTypeParams + ); + this.extractedMethodTypeParameters.push(...methodTypeParameters); + + // Collect annotations from method type parameters + const methodTypeParamAnnotations = this.methodTypeParameterExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...methodTypeParamAnnotations); + + // Collect annotation arguments from method type parameter annotations (including bound annotations) + const methodTypeParamAnnotationArgs = this.methodTypeParameterExtractor.getAnnotationExtractor().getExtractedArguments(); + this.extractedAnnotationArguments.push(...methodTypeParamAnnotationArgs); + + // Collect type references from method type parameter bounds and annotation arguments + const methodTypeParamBounds = this.methodTypeParameterExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...methodTypeParamBounds); + + // Collect type references from method type parameter annotation arguments + const methodTypeParamAnnotationTypeRefs = this.methodTypeParameterExtractor.getAnnotationExtractor().getExtractedTypeReferences(); + this.extractedTypeReferences.push(...methodTypeParamAnnotationTypeRefs); + + // Extract parameters for this method + const parameters = this.methodParameterExtractor.extractFromMethod( + methodNode, + method.getHash(), + typeRegistryHash, + packageName, + importMap, + hasStarImports, + allTypeParams, + this.annotationExtractor + ); + this.extractedMethodParameters.push(...parameters); + + // Collect annotations from parameters + const paramAnnotations = this.methodParameterExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...paramAnnotations); + + // Collect annotation arguments from parameter annotations + const paramAnnotationArgs = this.methodParameterExtractor.getExtractedAnnotationArguments(); + this.extractedAnnotationArguments.push(...paramAnnotationArgs); + + // Collect type references from parameters + const paramTypeRefs = this.methodParameterExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...paramTypeRefs); + + // Extract return type references for non-constructor methods + const returnTypeRefs = this.extractReturnTypeReferences( + methodNode, + method.getHash(), + typeRegistryHash, + packageName, + allTypeParams + ); + this.extractedTypeReferences.push(...returnTypeRefs); + + // Extract throws clause type references + const throwsClauseRefs = this.typeReferenceExtractor.extractFromThrowsClause( + methodNode, + typeRegistryHash, + method.getHash(), + packageName, + allTypeParams + ); + this.extractedTypeReferences.push(...throwsClauseRefs); + + // Extract TYPE_USE annotations from throws clause exception types (e.g., throws @Critical IOException) + // Pass the TypeReference hashes so annotations link to them instead of the method + const throwsTypeRefHashes = throwsClauseRefs + .filter(ref => ref.getDepth() === 0) // Only top-level exception types + .map(ref => ref.getHash()); + const throwsAnnotations = this.annotationExtractor.extractFromThrowsClause( + methodNode, + throwsTypeRefHashes, + typeRegistryHash + ); + this.extractedAnnotations.push(...throwsAnnotations); + + // Build position-to-hash map from AST for block ownership lookups in expression extraction. + // This lightweight traversal replaces duplicated BlockRegistry.computeHash() calls in + // findExpressionStatements, findThrowStatements, and findReturnStatementsAtLevel. + // Find the method body block for map building + const methodBodyBlock = methodNode.children.find(child => + child.type === 'block' || child.type === 'constructor_body' + ); + const blockPositionToHash = methodBodyBlock + ? this.buildBlockPositionMapFromAST(methodBodyBlock, typeRegistryHash, method.getHash(), filePath) + : undefined; + + // Extract expressions from method body (return statements, throw statements, expression statements) + const bodyExpressions = this.extractMethodBodyExpressions( + methodNode, + typeRegistryHash, + method.getHash(), + packageName, + importMap, + hasStarImports, + filePath, + blockPositionToHash + ); + this.extractedExpressions.push(...bodyExpressions); + // Note: Type references and anonymous classes are collected inside extractMethodBodyExpressions + // for each expression statement and constructor invocation, so we don't collect them here again. + + // Extract local variables from method body + const localVariables = this.extractMethodBodyLocalVariables( + methodNode, + filePath, + typeRegistryHash, + method.getHash(), + ownerTypeName, + ownerQualifiedName, + method.getName(), + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + this.extractedLocalVariables.push(...localVariables); + + // Collect blocks created by LocalVariableExtractor during extractFromMethodBody + // Only collect if the method had a body (extractFromMethodBody was actually called), + // otherwise getExtractedBlocks() returns stale blocks from a previous method + const bodyBlock = methodNode.children.find(child => + child.type === 'block' || child.type === 'constructor_body' + ); + if (bodyBlock) { + const localVarBlocks = this.localVariableExtractor.getExtractedBlocks(); + this.extractedBlocks.push(...localVarBlocks); + } + } + } + + /** + * Extracts expressions from a method body. + * Currently extracts return statement expressions. + */ + private extractMethodBodyExpressions( + methodNode: Parser.SyntaxNode, + typeRegistryHash: string, + methodHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + filePath: string, + blockPositionToHash?: Map + ): ExpressionReference[] { + const expressions: ExpressionReference[] = []; + + // Find the method body (block or constructor_body) + const bodyBlock = methodNode.children.find(child => + child.type === 'block' || child.type === 'constructor_body' + ); + if (!bodyBlock) { + return expressions; // Abstract methods, interface methods without body + } + + // Extract method parameter names for PARAMETER classification + const methodParamNames = this.extractMethodParameterNames(methodNode); + + // Collect local variable names from the method body for LOCAL_VARIABLE classification + const localVariableNames = this.collectLocalVariableNames(bodyBlock); + + // A pattern binding is declared in one statement and used in another, so the extractor is + // told the whole body's bindings once rather than per statement. + this.expressionExtractor.setMethodPatternBindings(this.collectPatternBindings(bodyBlock)); + + // IMPORTANT: Extract expression statements FIRST to get actual lambda hashes, + // then use those hashes when processing return statements inside those lambdas. + // This ensures return statements inside expression_statement lambdas have the correct owner hash. + + // Find all expression statements in the method body (with containing lambda and block tracking) + const expressionStatements = this.findExpressionStatements(bodyBlock, typeRegistryHash, methodHash, filePath, blockPositionToHash); + + // Build a map of lambda position -> actual expressionUniqueHash + // This will be populated as we extract expression statements containing lambdas + const lambdaPositionToHash = new Map(); + + // Two-pass extraction for expression statements: + // Pass 1: Extract statements NOT inside lambdas (to collect lambda hashes) + // Pass 2: Extract statements inside lambdas (using resolved actual hashes) + + const statementsNotInLambda = expressionStatements.filter(s => !s.containingLambdaPosition); + const statementsInLambda = expressionStatements.filter(s => s.containingLambdaPosition); + + // Pass 1: Extract statements not inside lambdas + for (const exprStmtCtx of statementsNotInLambda) { + const ownerHash = exprStmtCtx.containingBlockHash || methodHash; + const exprStmtExpressions = this.expressionExtractor.extractFromExpressionStatement( + exprStmtCtx.node, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + exprStmtCtx.lambdaParamNames + ); + expressions.push(...exprStmtExpressions); + this.collectExpressionExtractorResults(); + + // Collect lambda hashes for position-based lookup + for (const expr of exprStmtExpressions) { + if (expr.getKind().toString() === 'LAMBDA_EXPRESSION') { + const startLine = expr.getStartLine(); + const startCol = expr.getStartColumn(); + const endLine = expr.getEndLine(); + const endCol = expr.getEndColumn(); + if (startLine !== undefined && startCol !== undefined && + endLine !== undefined && endCol !== undefined) { + const posKey = `${startLine}:${startCol}:${endLine}:${endCol}`; + lambdaPositionToHash.set(posKey, expr.getHash()); + } + } + } + } + + // Pass 2: Extract statements inside lambdas (resolve actual hash from position) + for (const exprStmtCtx of statementsInLambda) { + // Resolve actual lambda hash from position key + const actualLambdaHash = exprStmtCtx.containingLambdaPosition + ? lambdaPositionToHash.get(exprStmtCtx.containingLambdaPosition) + : null; + const ownerHash = exprStmtCtx.containingBlockHash || actualLambdaHash || methodHash; + const exprStmtExpressions = this.expressionExtractor.extractFromExpressionStatement( + exprStmtCtx.node, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + exprStmtCtx.lambdaParamNames + ); + expressions.push(...exprStmtExpressions); + this.collectExpressionExtractorResults(); + + // Collect any nested lambda hashes + for (const expr of exprStmtExpressions) { + if (expr.getKind().toString() === 'LAMBDA_EXPRESSION') { + const startLine = expr.getStartLine(); + const startCol = expr.getStartColumn(); + const endLine = expr.getEndLine(); + const endCol = expr.getEndColumn(); + if (startLine !== undefined && startCol !== undefined && + endLine !== undefined && endCol !== undefined) { + const posKey = `${startLine}:${startCol}:${endLine}:${endCol}`; + lambdaPositionToHash.set(posKey, expr.getHash()); + } + } + } + } + + // Extract return statements iteratively to handle nested lambdas correctly. + // We must extract returns wave by wave because lambdas inside return statements + // need to be extracted FIRST before we can get their actual hash for nested returns. + let returnIndex = 0; + const processedLambdaPositions = new Set(); + + // Helper to collect lambda hashes from extracted expressions + const collectLambdaHashes = (exprs: ExpressionReference[]): void => { + for (const expr of exprs) { + if (expr.getKind() === ExpressionKind.LAMBDA_EXPRESSION) { + const sl = expr.getStartLine(); + const sc = expr.getStartColumn(); + const el = expr.getEndLine(); + const ec = expr.getEndColumn(); + if (sl !== undefined && sc !== undefined && el !== undefined && ec !== undefined) { + const posKey = `${sl}:${sc}:${el}:${ec}`; + lambdaPositionToHash.set(posKey, expr.getHash()); + } + } + } + }; + + // Wave 1: Extract returns at method level (not inside lambdas) + const topLevelReturns = this.findReturnStatementsAtLevel(bodyBlock, typeRegistryHash, methodHash, filePath, null, new Set(), blockPositionToHash); + for (const returnStmtCtx of topLevelReturns) { + const ownerHash = returnStmtCtx.containingBlockHash || methodHash; + const returnExpressions = this.expressionExtractor.extractFromReturnStatement( + returnStmtCtx.node, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + methodParamNames, + returnIndex++, + localVariableNames + ); + expressions.push(...returnExpressions); + this.collectExpressionExtractorResults(); + collectLambdaHashes(returnExpressions); + } + + // Wave 2+: Process returns inside lambdas iteratively + let hasNewLambdas = true; + while (hasNewLambdas) { + hasNewLambdas = false; + const lambdasToProcess: Array<{posKey: string, hash: string}> = []; + + for (const [posKey, hash] of lambdaPositionToHash.entries()) { + if (!processedLambdaPositions.has(posKey)) { + lambdasToProcess.push({posKey, hash}); + processedLambdaPositions.add(posKey); + } + } + + if (lambdasToProcess.length === 0) break; + + for (const {posKey, hash: lambdaHash} of lambdasToProcess) { + const [startLine, startCol, endLine, endCol] = posKey.split(':').map(Number); + const lambdaNode = this.findLambdaNodeByPosition(bodyBlock, startLine!, startCol!, endLine!, endCol!); + if (!lambdaNode) continue; + + const lambdaBody = lambdaNode.children.find((c: Parser.SyntaxNode) => c.type === 'block'); + if (!lambdaBody) continue; + + const lambdaReturns = this.findReturnStatementsAtLevel(lambdaBody, typeRegistryHash, methodHash, filePath, lambdaHash, new Set(), blockPositionToHash); + for (const returnStmtCtx of lambdaReturns) { + const ownerHash = returnStmtCtx.containingBlockHash || lambdaHash; + const returnExpressions = this.expressionExtractor.extractFromReturnStatement( + returnStmtCtx.node, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + methodParamNames, + returnIndex++, + localVariableNames + ); + expressions.push(...returnExpressions); + this.collectExpressionExtractorResults(); + + // Check for new lambdas + for (const expr of returnExpressions) { + if (expr.getKind() === ExpressionKind.LAMBDA_EXPRESSION) { + const sl = expr.getStartLine(); + const sc = expr.getStartColumn(); + const el = expr.getEndLine(); + const ec = expr.getEndColumn(); + if (sl !== undefined && sc !== undefined && el !== undefined && ec !== undefined) { + const newPosKey = `${sl}:${sc}:${el}:${ec}`; + if (!lambdaPositionToHash.has(newPosKey)) { + lambdaPositionToHash.set(newPosKey, expr.getHash()); + hasNewLambdas = true; + } + } + } + } + } + } + } + + // Find all throw statements in the method body (with containing lambda and block tracking) + const throwStatements = this.findThrowStatements(bodyBlock, typeRegistryHash, methodHash, filePath, blockPositionToHash); + + for (let throwIndex = 0; throwIndex < throwStatements.length; throwIndex++) { + const throwStmtCtx = throwStatements[throwIndex]!; + // Owner hierarchy: block > lambda > method + // Resolve actual lambda hash from position key + const actualLambdaHash = throwStmtCtx.containingLambdaPosition + ? lambdaPositionToHash.get(throwStmtCtx.containingLambdaPosition) + : null; + const ownerHash = throwStmtCtx.containingBlockHash || actualLambdaHash || methodHash; + const throwExpressions = this.expressionExtractor.extractFromThrowStatement( + throwStmtCtx.node, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + methodParamNames, + throwIndex, + localVariableNames, + throwStmtCtx.lambdaParamNames + ); + expressions.push(...throwExpressions); + this.collectExpressionExtractorResults(); + } + + // Find all break statements in the method body + const breakStatements = this.findBreakStatements(bodyBlock, blockPositionToHash); + for (const breakStmtCtx of breakStatements) { + const ownerHash = breakStmtCtx.containingBlockHash || methodHash; + const breakExpressions = this.expressionExtractor.extractFromBreakStatement( + breakStmtCtx.node, + typeRegistryHash, + ownerHash + ); + expressions.push(...breakExpressions); + } + + // Find all continue statements in the method body + const continueStatements = this.findContinueStatements(bodyBlock, blockPositionToHash); + for (const continueStmtCtx of continueStatements) { + const ownerHash = continueStmtCtx.containingBlockHash || methodHash; + const continueExpressions = this.expressionExtractor.extractFromContinueStatement( + continueStmtCtx.node, + typeRegistryHash, + ownerHash + ); + expressions.push(...continueExpressions); + } + + // Extract control flow condition expressions (IF, WHILE, FOR, etc.) + const conditionExpressions = this.extractConditionExpressions( + bodyBlock, + typeRegistryHash, + methodHash, + filePath, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + blockPositionToHash + ); + expressions.push(...conditionExpressions); + + // Find explicit constructor invocations (this() or super() calls) + const constructorInvocations = this.findConstructorInvocations(bodyBlock); + + for (const invocation of constructorInvocations) { + const invocationExpressions = this.expressionExtractor.extractFromConstructorInvocation( + invocation, + typeRegistryHash, + methodHash, + packageName, + importMap, + hasStarImports, + methodParamNames + ); + expressions.push(...invocationExpressions); + this.collectExpressionExtractorResults(); + } + + return expressions; + } + + + /** + * Collects all local variable names from a method body for LOCAL_VARIABLE classification. + * This includes variables from local_variable_declaration, for loops, enhanced for loops, + * try-with-resources, and catch clauses. + */ + /** + * Collects every pattern binding declared in a method body, with the byte range of the + * statement that declares it: `o instanceof Target a`, `case String s`, and the components of + * a record pattern. + * + * The range is collected alongside the name because a binding may share a name with a field, + * which Java permits. A use outside the declaring statement is then the field, and matching on + * the name alone would call it the binding - trading one wrong answer for another. + * + * The declaring statement is the nearest enclosing statement rather than the pattern node + * itself, because the binding is used in the statement's body, not inside the pattern. + */ + private collectPatternBindings( + bodyBlock: Parser.SyntaxNode + ): Array<{ name: string; startIndex: number; endIndex: number }> { + const bindings: Array<{ name: string; startIndex: number; endIndex: number }> = []; + + const enclosingStatement = (node: Parser.SyntaxNode): Parser.SyntaxNode => { + const statementTypes = [ + 'if_statement', 'while_statement', 'do_statement', 'for_statement', + 'enhanced_for_statement', 'switch_expression', 'switch_rule', 'switch_block_statement_group', + 'local_variable_declaration', 'expression_statement', 'return_statement', 'assert_statement', + ]; + let current: Parser.SyntaxNode | null = node; + while (current) { + if (statementTypes.includes(current.type)) return current; + current = current.parent; + } + return node; + }; + + const record = (nameNode: Parser.SyntaxNode, declaringNode: Parser.SyntaxNode): void => { + const scope = enclosingStatement(declaringNode); + bindings.push({ name: nameNode.text, startIndex: scope.startIndex, endIndex: scope.endIndex }); + }; + + const walk = (node: Parser.SyntaxNode): void => { + if (node.type === 'type_pattern' || node.type === 'instanceof_expression') { + const named = node.namedChildren; + const last = named[named.length - 1]; + if (last?.type === 'identifier' && named.length >= 2) { + record(last, node); + } + } + + if (node.type === 'pattern' || node.type === 'record_pattern_component') { + for (const child of node.namedChildren) { + if (child.type === 'identifier') record(child, node); + } + } + + node.children.forEach(walk); + }; + + walk(bodyBlock); + return bindings; + } + + private collectLocalVariableNames(bodyBlock: Parser.SyntaxNode): Set { + const names = new Set(); + this.collectLocalVariableNamesRecursive(bodyBlock, names); + return names; + } + + /** + * Recursively collects local variable names from a node and its children. + */ + private collectLocalVariableNamesRecursive(node: Parser.SyntaxNode, names: Set): void { + // Local variable declarations: int x = 1, y = 2; + if (node.type === 'local_variable_declaration') { + for (const child of node.children) { + if (child.type === 'variable_declarator') { + const nameNode = child.childForFieldName('name'); + if (nameNode) { + names.add(nameNode.text); + } + } + } + } + // For loop variables: for (int i = 0; ...) + else if (node.type === 'for_statement') { + const init = node.childForFieldName('init'); + if (init && init.type === 'local_variable_declaration') { + for (const child of init.children) { + if (child.type === 'variable_declarator') { + const nameNode = child.childForFieldName('name'); + if (nameNode) { + names.add(nameNode.text); + } + } + } + } + } + // Enhanced for loop: for (String s : list) + else if (node.type === 'enhanced_for_statement') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + names.add(nameNode.text); + } + } + // Catch clause: catch (Exception e) + else if (node.type === 'catch_clause') { + const formalParam = node.children.find(c => c.type === 'catch_formal_parameter'); + if (formalParam) { + const nameNode = formalParam.childForFieldName('name'); + if (nameNode) { + names.add(nameNode.text); + } + } + } + // Try-with-resources: try (Resource r = ...) + else if (node.type === 'try_with_resources_statement') { + const resources = node.children.find(c => c.type === 'resource_specification'); + if (resources) { + for (const resource of resources.children) { + if (resource.type === 'resource') { + const nameNode = resource.childForFieldName('name'); + if (nameNode) { + names.add(nameNode.text); + } + } + } + } + } + + // Don't recurse into nested class bodies or lambda bodies (they have separate scope) + if (node.type === 'class_body') { + return; + } + + // Recurse into children + for (const child of node.children) { + this.collectLocalVariableNamesRecursive(child, names); + } + } + + /** + * Extracts local variables from a method body. + */ + private extractMethodBodyLocalVariables( + methodNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + methodHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + methodName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean + ): LocalVariableRegistry[] { + // Find the method body (block or constructor_body) + const bodyBlock = methodNode.children.find(child => + child.type === 'block' || child.type === 'constructor_body' + ); + if (!bodyBlock) { + return []; // Abstract methods, interface methods without body + } + + // Determine scope kind based on method type + let scopeKind = LocalVariableScopeKind.METHOD_BODY; + const methodKind = this.determineMethodKind(methodNode); + if (methodKind === MethodKind.CONSTRUCTOR || methodKind === MethodKind.COMPACT_CONSTRUCTOR) { + scopeKind = LocalVariableScopeKind.CONSTRUCTOR_BODY; + } + + // Extract method parameter names for PARAMETER classification in expressions + const methodParamNames = this.extractMethodParameterNames(methodNode); + + // Extract local variables from the method body + // Pass extracted expressions for hash lookup (switch expressions, lambdas, etc.) + const localVariables = this.localVariableExtractor.extractFromMethodBody( + bodyBlock, + filePath, + typeRegistryHash, + methodHash, + ownerTypeName, + ownerQualifiedName, + methodName, + serviceVersionHash, + packageName, + importMap, + hasStarImports, + scopeKind, + methodParamNames, + this.extractedExpressions + ); + + // Collect expressions from local variable initializers + const localVarExpressions = this.localVariableExtractor.getExtractedExpressions(); + this.extractedExpressions.push(...localVarExpressions); + + // Collect type references from local variable types + const localVarTypeRefs = this.localVariableExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...localVarTypeRefs); + + // Collect anonymous classes from local variable initializers + const localVarAnonClasses = this.localVariableExtractor.getExtractedAnonymousClasses(); + this.extractedAnonymousClasses.push(...localVarAnonClasses); + + // Collect annotations from local variable declarations + const localVarAnnotations = this.localVariableExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...localVarAnnotations); + + // Collect annotation arguments from local variable declarations + const localVarAnnotationArgs = this.localVariableExtractor.getExtractedAnnotationArguments(); + this.extractedAnnotationArguments.push(...localVarAnnotationArgs); + + return localVariables; + } + + /** + * Extracts expressions from an initializer block (static or instance). + * Initializer blocks can contain expression statements like assignments, method calls, etc. + */ + private extractInitializerBlockExpressions( + initializerNode: Parser.SyntaxNode, + typeRegistryHash: string, + methodHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + filePath: string + ): ExpressionReference[] { + const expressions: ExpressionReference[] = []; + + // For static_initializer, the block is a child; for instance initializer, the node IS the block + const bodyBlock = initializerNode.type === 'static_initializer' + ? initializerNode.children.find(child => child.type === 'block') + : initializerNode; + + if (!bodyBlock) { + return expressions; + } + + // Initializers have no parameters + const methodParamNames = new Set(); + + // Collect local variable names from the initializer block for LOCAL_VARIABLE classification + const localVariableNames = this.collectLocalVariableNames(bodyBlock); + + // A pattern binding is declared in one statement and used in another, so the extractor is + // told the whole body's bindings once rather than per statement. + this.expressionExtractor.setMethodPatternBindings(this.collectPatternBindings(bodyBlock)); + + // Build position-to-hash map for block ownership lookups in initializer expression extraction + const blockPositionToHash = this.buildBlockPositionMapFromAST(bodyBlock, typeRegistryHash, methodHash, filePath); + + // Find all expression statements in the initializer block (with containing lambda and block tracking) + const expressionStatements = this.findExpressionStatements(bodyBlock, typeRegistryHash, methodHash, filePath, blockPositionToHash); + + // Build lambda position to hash map for initializer block + const lambdaPositionToHash = new Map(); + + // Two-pass extraction for expression statements in initializer + const statementsNotInLambda = expressionStatements.filter(s => !s.containingLambdaPosition); + const statementsInLambda = expressionStatements.filter(s => s.containingLambdaPosition); + + // Pass 1: Extract statements not inside lambdas + for (const exprStmtCtx of statementsNotInLambda) { + const ownerHash = exprStmtCtx.containingBlockHash || methodHash; + const exprStmtExpressions = this.expressionExtractor.extractFromExpressionStatement( + exprStmtCtx.node, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + exprStmtCtx.lambdaParamNames + ); + expressions.push(...exprStmtExpressions); + this.collectExpressionExtractorResults(); + + // Collect lambda hashes for position-based lookup + for (const expr of exprStmtExpressions) { + if (expr.getKind().toString() === 'LAMBDA_EXPRESSION') { + const startLine = expr.getStartLine(); + const startCol = expr.getStartColumn(); + const endLine = expr.getEndLine(); + const endCol = expr.getEndColumn(); + if (startLine !== undefined && startCol !== undefined && + endLine !== undefined && endCol !== undefined) { + const posKey = `${startLine}:${startCol}:${endLine}:${endCol}`; + lambdaPositionToHash.set(posKey, expr.getHash()); + } + } + } + } + + // Pass 2: Extract statements inside lambdas (resolve actual hash from position) + for (const exprStmtCtx of statementsInLambda) { + const actualLambdaHash = exprStmtCtx.containingLambdaPosition + ? lambdaPositionToHash.get(exprStmtCtx.containingLambdaPosition) + : null; + const ownerHash = exprStmtCtx.containingBlockHash || actualLambdaHash || methodHash; + const exprStmtExpressions = this.expressionExtractor.extractFromExpressionStatement( + exprStmtCtx.node, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + exprStmtCtx.lambdaParamNames + ); + expressions.push(...exprStmtExpressions); + this.collectExpressionExtractorResults(); + } + + // Throw, break and continue statements, as a method body gets them. + // + // `return` is deliberately not among them: JLS 8.6 and 8.7 forbid a return statement in an + // initializer, so there is nothing to extract. + const throwStatements = this.findThrowStatements(bodyBlock, typeRegistryHash, methodHash, filePath, blockPositionToHash); + for (let throwIndex = 0; throwIndex < throwStatements.length; throwIndex++) { + const throwStmtCtx = throwStatements[throwIndex]!; + const actualLambdaHash = throwStmtCtx.containingLambdaPosition + ? lambdaPositionToHash.get(throwStmtCtx.containingLambdaPosition) + : null; + const ownerHash = throwStmtCtx.containingBlockHash || actualLambdaHash || methodHash; + const throwExpressions = this.expressionExtractor.extractFromThrowStatement( + throwStmtCtx.node, + typeRegistryHash, + ownerHash, + packageName, + importMap, + hasStarImports, + methodParamNames, + throwIndex, + localVariableNames, + throwStmtCtx.lambdaParamNames + ); + expressions.push(...throwExpressions); + this.collectExpressionExtractorResults(); + } + + const breakStatements = this.findBreakStatements(bodyBlock, blockPositionToHash); + for (const breakStmtCtx of breakStatements) { + expressions.push(...this.expressionExtractor.extractFromBreakStatement( + breakStmtCtx.node, + typeRegistryHash, + breakStmtCtx.containingBlockHash || methodHash + )); + } + + const continueStatements = this.findContinueStatements(bodyBlock, blockPositionToHash); + for (const continueStmtCtx of continueStatements) { + expressions.push(...this.expressionExtractor.extractFromContinueStatement( + continueStmtCtx.node, + typeRegistryHash, + continueStmtCtx.containingBlockHash || methodHash + )); + } + + + // Control-flow condition expressions, exactly as a method body gets them. + // + // Only expression statements were extracted here, so every expression in a control-flow + // POSITION was dropped: an if or while condition, an enhanced-for iterable, a throw value. + // The bodies of those statements survived, because their contents are expression statements + // in their own right, which is why an initializer reached the fact set looking like a + // straight-line block rather than an empty one. + // + // An initializer body is an ordinary block, so the same walk applies unchanged; only the + // owner differs, and it is the initializer's own method hash. + const conditionExpressions = this.extractConditionExpressions( + bodyBlock, + typeRegistryHash, + methodHash, + filePath, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + blockPositionToHash + ); + expressions.push(...conditionExpressions); + + return expressions; + } + + /** + * Extracts parameter names from a method declaration. + */ + private extractMethodParameterNames(methodNode: Parser.SyntaxNode): Set { + const paramNames = new Set(); + + // Find formal_parameters node + const formalParams = methodNode.children.find(child => child.type === 'formal_parameters'); + if (!formalParams) { + return paramNames; + } + + // Iterate through parameters + for (const child of formalParams.namedChildren) { + if (child.type === 'formal_parameter') { + // Find the identifier (parameter name) + const nameNode = child.childForFieldName('name'); + if (nameNode) { + paramNames.add(nameNode.text); + } + } else if (child.type === 'spread_parameter') { + // Varargs parameter: spread_parameter -> variable_declarator -> identifier + const varDecl = child.children.find(c => c.type === 'variable_declarator'); + if (varDecl) { + const nameNode = varDecl.childForFieldName('name'); + if (nameNode) { + paramNames.add(nameNode.text); + } + } + } + } + + return paramNames; + } + + /** + * Builds a position-to-hash map by traversing a block AST once. + * Finds all control flow block bodies and computes their hashes. + * This consolidates hash computation that was previously duplicated + * in findExpressionStatements, findThrowStatements, and findReturnStatementsAtLevel. + * Works for both method bodies and initializer blocks. + */ + private buildBlockPositionMapFromAST( + bodyBlock: Parser.SyntaxNode, + typeRegistryHash: string, + methodHash: string, + filePath: string + ): Map { + const map = new Map(); + + const addBlock = (kind: BlockKind, blockNode: Parser.SyntaxNode): void => { + const hash = BlockRegistry.computeHash( + kind, filePath, + blockNode.startPosition.row + 1, blockNode.endPosition.row + 1, + blockNode.startPosition.column, blockNode.endPosition.column, + typeRegistryHash, methodHash + ); + map.set(TypeMethodExtractor.blockPosKey(blockNode), hash); + }; + + // processNode handles a single node by dispatching to the appropriate control + // flow handler using field names. This ensures correct handling even when a + // brace-less body IS itself a control flow statement (e.g., for(...) if(...) X; else if(...) Y;). + // After handling the node's own structure, it recurses into body/consequence nodes. + const processNode = (node: Parser.SyntaxNode): void => { + if (node.type === 'class_body') return; + + if (node.type === 'if_statement') { + const consequence = node.childForFieldName('consequence'); + if (consequence) { + addBlock(BlockKind.IF, consequence); + processNode(consequence); + } + // Handle else-if chains and else + let alt = node.childForFieldName('alternative'); + while (alt) { + if (alt.type === 'if_statement') { + const elseIfCons = alt.childForFieldName('consequence'); + if (elseIfCons) { + addBlock(BlockKind.ELSE_IF, elseIfCons); + processNode(elseIfCons); + } + alt = alt.childForFieldName('alternative'); + } else { + addBlock(BlockKind.ELSE, alt); + processNode(alt); + alt = null; + } + } + } else if (node.type === 'for_statement') { + const body = node.childForFieldName('body'); + if (body) { addBlock(BlockKind.FOR, body); processNode(body); } + } else if (node.type === 'enhanced_for_statement') { + const body = node.childForFieldName('body'); + if (body) { addBlock(BlockKind.ENHANCED_FOR, body); processNode(body); } + } else if (node.type === 'while_statement') { + const body = node.childForFieldName('body'); + if (body) { addBlock(BlockKind.WHILE, body); processNode(body); } + } else if (node.type === 'do_statement') { + const body = node.childForFieldName('body'); + if (body) { addBlock(BlockKind.DO_WHILE, body); processNode(body); } + } else if (node.type === 'try_statement' || node.type === 'try_with_resources_statement') { + const isTryWithResources = node.type === 'try_with_resources_statement'; + const kind = isTryWithResources ? BlockKind.TRY_WITH_RESOURCES : BlockKind.TRY; + const tryBody = node.children.find(c => c.type === 'block'); + if (tryBody) { + addBlock(kind, tryBody); + traverseChildren(tryBody); + } + for (const clause of node.children) { + if (clause.type === 'catch_clause') { + const catchBody = clause.children.find(c => c.type === 'block'); + if (catchBody) { addBlock(BlockKind.CATCH, catchBody); traverseChildren(catchBody); } + } else if (clause.type === 'finally_clause') { + const finallyBody = clause.children.find(c => c.type === 'block'); + if (finallyBody) { addBlock(BlockKind.FINALLY, finallyBody); traverseChildren(finallyBody); } + } + } + } else if (node.type === 'synchronized_statement') { + const body = node.childForFieldName('body'); + if (body && body.type === 'block') { addBlock(BlockKind.SYNCHRONIZED, body); traverseChildren(body); } + } else if (node.type === 'switch_expression' || node.type === 'switch_statement') { + // Handle switch cases: both colon syntax (switch_block_statement_group) and arrow syntax (switch_rule) + const switchBlock = node.children.find(c => c.type === 'switch_block'); + if (switchBlock) { + for (const caseChild of switchBlock.children) { + if (caseChild.type === 'switch_block_statement_group') { + // Traditional colon syntax: use group node position for SWITCH_CASE + addBlock(BlockKind.SWITCH_CASE, caseChild); + traverseChildren(caseChild); + } else if (caseChild.type === 'switch_rule') { + // Arrow syntax: use block child position for SWITCH_EXPRESSION_CASE + const ruleBlock = caseChild.children.find(c => c.type === 'block'); + if (ruleBlock) { + addBlock(BlockKind.SWITCH_EXPRESSION_CASE, ruleBlock); + traverseChildren(ruleBlock); + } else { + traverseChildren(caseChild); + } + } + } + } else { + traverseChildren(node); + } + } else { + // Default: not a recognized control flow node, iterate children + traverseChildren(node); + } + }; + + // traverseChildren iterates a node's children and dispatches each via processNode + const traverseChildren = (n: Parser.SyntaxNode): void => { + for (const child of n.children) { + if (child.type === 'class_body') continue; + if (child.type === 'lambda_expression') { + // Traverse into lambda bodies to find nested blocks + traverseChildren(child); + } else { + processNode(child); + } + } + }; + + traverseChildren(bodyBlock); + return map; + } + + /** + * Looks up a block hash from the position map for a given AST node. + * Returns the hash if the node's position matches a known block, null otherwise. + */ + private static blockPosKey(node: Parser.SyntaxNode): string { + return `${node.startPosition.row + 1}:${node.startPosition.column}:${node.endPosition.row + 1}:${node.endPosition.column}`; + } + + /** + * Finds return statements at a single level, NOT traversing into lambdas. + * Used for iterative return extraction where we process lambdas wave by wave. + */ + private findReturnStatementsAtLevel( + node: Parser.SyntaxNode, + _typeRegistryHash: string, + _methodHash: string, + _filePath: string, + containingLambdaPosition: string | null, + lambdaParamNames: Set = new Set(), + blockPositionToHash?: Map + ): StatementWithContext[] { + const returns: StatementWithContext[] = []; + + const lookupHash = (blockNode: Parser.SyntaxNode): string | null => { + if (!blockPositionToHash) return null; + return blockPositionToHash.get(TypeMethodExtractor.blockPosKey(blockNode)) || null; + }; + + const traverse = (n: Parser.SyntaxNode, currentBlockHash: string | null, inLocalVarDecl: boolean): void => { + // Collect return statements (skip those in local var decl lambdas - handled by LocalVariableExtractor) + if (n.type === 'return_statement' && !(containingLambdaPosition && inLocalVarDecl)) { + returns.push({ node: n, containingLambdaPosition, containingBlockHash: currentBlockHash, lambdaParamNames }); + } + // Don't traverse into class bodies or lambda expressions (lambdas handled in separate waves) + if (n.type !== 'class_body' && n.type !== 'lambda_expression') { + const isLocalVarDecl = n.type === 'local_variable_declaration' || n.type === 'resource'; + for (const child of n.children) { + if (child.type === 'try_statement' || child.type === 'try_with_resources_statement') { + // Only try/catch/finally blocks update block hash context for return statements + const tryBody = child.children.find((c: Parser.SyntaxNode) => c.type === 'block'); + if (tryBody) { + traverse(tryBody, lookupHash(tryBody) || currentBlockHash, inLocalVarDecl || isLocalVarDecl); + } + for (const clause of child.children) { + if (clause.type === 'catch_clause') { + const catchBody = clause.children.find((c: Parser.SyntaxNode) => c.type === 'block'); + if (catchBody) traverse(catchBody, lookupHash(catchBody) || currentBlockHash, inLocalVarDecl || isLocalVarDecl); + } else if (clause.type === 'finally_clause') { + const finallyBody = clause.children.find((c: Parser.SyntaxNode) => c.type === 'block'); + if (finallyBody) traverse(finallyBody, lookupHash(finallyBody) || currentBlockHash, inLocalVarDecl || isLocalVarDecl); + } + } + } else if (child.type !== 'lambda_expression') { + // Look up block hash from pre-computed map for any node that corresponds to a control flow body + const childBlockHash = lookupHash(child) || currentBlockHash; + traverse(child, childBlockHash, inLocalVarDecl || isLocalVarDecl); + } + } + } + }; + + traverse(node, null, false); + return returns; + } + + /** + * Finds a lambda expression node by its position in the AST. + */ + private findLambdaNodeByPosition( + root: Parser.SyntaxNode, + startLine: number, + startCol: number, + endLine: number, + endCol: number + ): Parser.SyntaxNode | null { + const search = (n: Parser.SyntaxNode): Parser.SyntaxNode | null => { + if (n.type === 'lambda_expression') { + const sl = n.startPosition.row + 1; + const sc = n.startPosition.column; + const el = n.endPosition.row + 1; + const ec = n.endPosition.column; + if (sl === startLine && sc === startCol && el === endLine && ec === endCol) { + return n; + } + } + for (const child of n.children) { + const found = search(child); + if (found) return found; + } + return null; + }; + return search(root); + } + + /** + * Recursively finds all throw_statement nodes in a block. + * Traverses into nested blocks (try/catch/finally) and lambda bodies. + * Tracks the containing lambda and block (if any) for each throw statement. + * + * @param node The block to search + * @param typeRegistryHash Hash of the containing type (for lambda hash generation) + */ + private findThrowStatements( + node: Parser.SyntaxNode, + _typeRegistryHash: string, + _methodHash: string, + _filePath: string, + blockPositionToHash?: Map + ): StatementWithContext[] { + const throws: StatementWithContext[] = []; + + const traverse = (n: Parser.SyntaxNode, currentLambdaPosition: string | null, currentBlockHash: string | null, inLocalVarDecl: boolean, currentLambdaParams: Set): void => { + // Skip throw statements inside lambdas that are in local variable declarations + // Those are handled by LocalVariableExtractor with proper local variable linking + if (n.type === 'throw_statement' + && !(currentLambdaPosition && inLocalVarDecl) + && !JavaTreeSitterUtils.isValueProducingSwitchArm(n)) { + throws.push({ node: n, containingLambdaPosition: currentLambdaPosition, containingBlockHash: currentBlockHash, lambdaParamNames: currentLambdaParams }); + } + // Don't traverse into nested class bodies (anonymous classes have separate methods) + // DO traverse into lambda bodies to extract their throw statements + if (n.type !== 'class_body') { + const isLocalVarDecl = n.type === 'local_variable_declaration' || n.type === 'resource'; + for (const child of n.children) { + if (child.type === 'lambda_expression') { + const lambdaPosKey = TypeMethodExtractor.blockPosKey(child); + const lambdaParams = this.extractLambdaParameterNames(child); + const mergedParams = new Set([...currentLambdaParams, ...lambdaParams]); + // When entering lambda, reset block and local var decl context (lambda body is new scope) + traverse(child, lambdaPosKey, null, false, mergedParams); + } else { + // Look up block hash from pre-computed map for any node that corresponds to a control flow body + let childBlockHash = currentBlockHash; + if (blockPositionToHash) { + const mapped = blockPositionToHash.get(TypeMethodExtractor.blockPosKey(child)); + if (mapped) childBlockHash = mapped; + } + traverse(child, currentLambdaPosition, childBlockHash, inLocalVarDecl || isLocalVarDecl, currentLambdaParams); + } + } + } + }; + + traverse(node, null, null, false, new Set()); + return throws; + } + + /** + * Finds all continue_statement nodes in a method body, tracking their containing block hash. + * Continue statements appear in loops. Lambda boundaries are respected. + */ + private findContinueStatements( + node: Parser.SyntaxNode, + blockPositionToHash?: Map + ): StatementWithContext[] { + const continues: StatementWithContext[] = []; + + const traverse = (n: Parser.SyntaxNode, currentBlockHash: string | null): void => { + if (n.type === 'continue_statement') { + continues.push({ node: n, containingLambdaPosition: null, containingBlockHash: currentBlockHash, lambdaParamNames: new Set() }); + } + if (n.type !== 'class_body' && n.type !== 'lambda_expression') { + for (const child of n.children) { + let childBlockHash = currentBlockHash; + if (blockPositionToHash) { + const mapped = blockPositionToHash.get(TypeMethodExtractor.blockPosKey(child)); + if (mapped) childBlockHash = mapped; + } + traverse(child, childBlockHash); + } + } + }; + + traverse(node, null); + return continues; + } + + /** + * Finds all break_statement nodes in a method body, tracking their containing block hash. + * Break statements appear in switch cases and loops. Lambda boundaries are respected. + */ + private findBreakStatements( + node: Parser.SyntaxNode, + blockPositionToHash?: Map + ): StatementWithContext[] { + const breaks: StatementWithContext[] = []; + + const traverse = (n: Parser.SyntaxNode, currentBlockHash: string | null): void => { + if (n.type === 'break_statement') { + breaks.push({ node: n, containingLambdaPosition: null, containingBlockHash: currentBlockHash, lambdaParamNames: new Set() }); + } + if (n.type !== 'class_body' && n.type !== 'lambda_expression') { + for (const child of n.children) { + let childBlockHash = currentBlockHash; + if (blockPositionToHash) { + const mapped = blockPositionToHash.get(TypeMethodExtractor.blockPosKey(child)); + if (mapped) childBlockHash = mapped; + } + traverse(child, childBlockHash); + } + } + }; + + traverse(node, null); + return breaks; + } + + /** + * Extracts lambda parameter names from a lambda expression node. + */ + private extractLambdaParameterNames(lambdaNode: Parser.SyntaxNode): Set { + const paramNames = new Set(); + + for (const child of lambdaNode.children) { + if (child.type === 'identifier') { + // Single parameter without parentheses: x -> ... + // Check if this is before the arrow (parameter) vs after (body) + const arrow = lambdaNode.children.find(c => c.type === '->'); + if (arrow && child.startPosition.column < arrow.startPosition.column) { + paramNames.add(child.text); + } + } else if (child.type === 'inferred_parameters') { + // Inferred parameters: (x, y) -> ... + for (const param of child.namedChildren) { + if (param.type === 'identifier') { + paramNames.add(param.text); + } + } + } else if (child.type === 'formal_parameters') { + // Formal parameters: (String x, int y) -> ... + for (const param of child.namedChildren) { + if (param.type === 'formal_parameter') { + const nameNode = param.childForFieldName('name'); + if (nameNode) { + paramNames.add(nameNode.text); + } + } + } + } + } + + return paramNames; + } + + /** + * Recursively finds all expression_statement nodes in a block. + * Expression statements are standalone expressions like assignments, method calls, increments. + * Traverses into lambda block bodies to find nested expression statements. + * Tracks the containing lambda (if any) for each expression statement. + * + * @param node The block to search + * @param typeRegistryHash Hash of the containing type (for lambda hash generation) + */ + /** + * True when this statement is the body of an arrow arm belonging to a switch used as a VALUE. + * + * `case 1 -> t();` is written as an expression_statement, and `case 1 -> throw e;` as a + * throw_statement, whichever form the switch takes, so + * collecting every expression_statement picked the arm up a second time. The arm's value is the + * switch's value, not a statement in the enclosing method, so the second row asserted a root + * context the source does not have and turned one written call site into two. + * + * The two forms are distinguished by what the switch is attached to. tree-sitter models both as + * `switch_expression`; a switch used as a statement sits directly in a `block`, while one used + * as a value sits under whatever consumes it - a return, a variable_declarator, an + * argument_list, an assignment. So a `block` parent means statement, and anything else means + * value. + * + * A statement switch is left alone: there the arm really is a statement, and its single row is + * correct. + */ + private findExpressionStatements( + node: Parser.SyntaxNode, + _typeRegistryHash: string, + _methodHash: string, + _filePath: string, + blockPositionToHash?: Map + ): StatementWithContext[] { + const statements: StatementWithContext[] = []; + + const traverse = (n: Parser.SyntaxNode, currentLambdaPosition: string | null, currentBlockHash: string | null, inLocalVarDecl: boolean, currentLambdaParams: Set): void => { + // Skip expression statements inside lambdas that are in local variable declarations + // Those are handled by LocalVariableExtractor with proper local variable linking + if (n.type === 'expression_statement' + && !(currentLambdaPosition && inLocalVarDecl) + && !JavaTreeSitterUtils.isValueProducingSwitchArm(n)) { + statements.push({ node: n, containingLambdaPosition: currentLambdaPosition, containingBlockHash: currentBlockHash, lambdaParamNames: currentLambdaParams }); + } + // Don't traverse into nested class bodies (anonymous classes have separate methods) + // DO traverse into lambda bodies to extract their expression statements + if (n.type !== 'class_body') { + const isLocalVarDecl = n.type === 'local_variable_declaration' || n.type === 'resource'; + for (const child of n.children) { + if (child.type === 'lambda_expression') { + const lambdaPosKey = TypeMethodExtractor.blockPosKey(child); + const lambdaParams = this.extractLambdaParameterNames(child); + const mergedParams = new Set([...currentLambdaParams, ...lambdaParams]); + // When entering lambda, reset block context but preserve inLocalVarDecl if this lambda is in a local var decl + // or try-with-resources resource (expression/return statements inside those lambdas are handled by LocalVariableExtractor) + traverse(child, lambdaPosKey, null, inLocalVarDecl || isLocalVarDecl, mergedParams); + } else { + // Look up block hash from pre-computed map for any node that corresponds to a control flow body + let childBlockHash = currentBlockHash; + if (blockPositionToHash) { + const mapped = blockPositionToHash.get(TypeMethodExtractor.blockPosKey(child)); + if (mapped) childBlockHash = mapped; + } + traverse(child, currentLambdaPosition, childBlockHash, inLocalVarDecl || isLocalVarDecl, currentLambdaParams); + } + } + } + }; + + traverse(node, null, null, false, new Set()); + return statements; + } + + /** + * Finds explicit constructor invocations (this() or super() calls) in a block. + * These are special statements that can only appear as the first statement in a constructor. + */ + private findConstructorInvocations(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const invocations: Parser.SyntaxNode[] = []; + + const traverse = (n: Parser.SyntaxNode): void => { + if (n.type === 'explicit_constructor_invocation') { + invocations.push(n); + } + // Don't traverse into nested class/lambda bodies - they have their own scope + if (n.type !== 'class_body' && n.type !== 'lambda_expression') { + for (const child of n.children) { + traverse(child); + } + } + }; + + traverse(node); + return invocations; + } + + /** + * Extracts condition expressions from control flow statements (IF, WHILE, FOR, DO_WHILE, SWITCH, SYNCHRONIZED). + * These are expressions in the condition/selector positions of control flow statements. + */ + private extractConditionExpressions( + bodyBlock: Parser.SyntaxNode, + typeRegistryHash: string, + methodHash: string, + filePath: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + methodParamNames: Set, + localVariableNames: Set, + blockPositionToHash?: Map + ): ExpressionReference[] { + const expressions: ExpressionReference[] = []; + + // handleIfStatement: extracts conditions from an if_statement and its else-if chain, + // then traverses into body nodes only (not the entire if_statement node, which would + // cause alternatives to be re-processed as standalone IFs). + const handleIfStatement = (ifNode: Parser.SyntaxNode, currentLambdaParams: Set, insideLambda: boolean, currentBlockHash: string): void => { + const condition = ifNode.childForFieldName('condition'); + if (condition) { + const consequence = ifNode.childForFieldName('consequence'); + let ownerHash = currentBlockHash; + if (consequence) { + ownerHash = BlockRegistry.computeHash( + BlockKind.IF, filePath, + consequence.startPosition.row + 1, consequence.endPosition.row + 1, + consequence.startPosition.column, consequence.endPosition.column, + typeRegistryHash, methodHash + ); + } + const condExprs = this.expressionExtractor.extractFromConditionExpression( + condition, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.IF_STATEMENT, + RootContext.IF_CONDITION, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...condExprs); + this.collectExpressionExtractorResults(); + } + + // Handle else-if conditions and traverse their bodies (not the if_statement nodes to avoid duplication) + let currentAlt: Parser.SyntaxNode | null = ifNode.childForFieldName('alternative'); + while (currentAlt && currentAlt.type === 'if_statement') { + const elseIfCondition = currentAlt.childForFieldName('condition'); + if (elseIfCondition) { + const elseIfConsequence = currentAlt.childForFieldName('consequence'); + let elseIfOwnerHash = currentBlockHash; + if (elseIfConsequence) { + elseIfOwnerHash = BlockRegistry.computeHash( + BlockKind.ELSE_IF, filePath, + elseIfConsequence.startPosition.row + 1, elseIfConsequence.endPosition.row + 1, + elseIfConsequence.startPosition.column, elseIfConsequence.endPosition.column, + typeRegistryHash, methodHash + ); + } + const elseIfCondExprs = this.expressionExtractor.extractFromConditionExpression( + elseIfCondition, + typeRegistryHash, + elseIfOwnerHash, + ExpressionOwnerKind.IF_STATEMENT, + RootContext.IF_CONDITION, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...elseIfCondExprs); + this.collectExpressionExtractorResults(); + + // Traverse the else-if body (consequence), not the if_statement node + if (elseIfConsequence) { + traverse(elseIfConsequence, currentLambdaParams, insideLambda, currentBlockHash); + } + } + + // Move to next alternative + const nextAlt = currentAlt.childForFieldName('alternative'); + if (nextAlt && nextAlt.type === 'if_statement') { + currentAlt = nextAlt; + } else { + // Final else or end of chain - traverse if it's a block + if (nextAlt) { + traverse(nextAlt, currentLambdaParams, insideLambda, currentBlockHash); + } + currentAlt = null; + } + } + + // Recurse into if body only (else-if chain already handled above) + const ifConsequence = ifNode.childForFieldName('consequence'); + if (ifConsequence) { + traverse(ifConsequence, currentLambdaParams, insideLambda, currentBlockHash); + } + // Handle direct else (when alternative is not if_statement) + const ifAlternative = ifNode.childForFieldName('alternative'); + if (ifAlternative && ifAlternative.type !== 'if_statement') { + traverse(ifAlternative, currentLambdaParams, insideLambda, currentBlockHash); + } + }; + + const traverse = (node: Parser.SyntaxNode, currentLambdaParams: Set, insideLambda: boolean, currentBlockHash: string): void => { + // When entering a block with a known hash, update the fallback for descendant control flow. + // This ensures brace-less control flow inside try/catch/if/for blocks uses the block hash + // as owner instead of the outer method hash. + if ((node.type === 'block' || node.type === 'switch_block_statement_group') && blockPositionToHash) { + const blockHash = blockPositionToHash.get(TypeMethodExtractor.blockPosKey(node)); + if (blockHash) currentBlockHash = blockHash; + } + + // Self-dispatch: if this node IS an if_statement (happens when a brace-less + // body is an if_statement), handle it via the dedicated handler to correctly + // walk else-if chains instead of iterating children blindly. + if (node.type === 'if_statement') { + handleIfStatement(node, currentLambdaParams, insideLambda, currentBlockHash); + return; + } + + for (const child of node.children) { + // Skip class bodies (anonymous classes have their own extraction) + if (child.type === 'class_body') continue; + + // Handle lambda expressions - collect their params and traverse body. + // Block hashes inside lambdas are computed with methodHash, which matches + // both buildBlockPositionMapFromAST and LVE's this.blockMethodHash. + if (child.type === 'lambda_expression') { + const lambdaParams = this.extractLambdaParameterNames(child); + const mergedParams = new Set([...currentLambdaParams, ...lambdaParams]); + traverse(child, mergedParams, true, methodHash); + continue; + } + + // IF statement condition (and else-if conditions) + if (child.type === 'if_statement') { + handleIfStatement(child, currentLambdaParams, insideLambda, currentBlockHash); + continue; + } + + // ASSERT statement condition and detail message + if (child.type === 'assert_statement') { + // `assert cond;` and `assert cond : detail;`. The node has no field names, so the + // two halves have to be found in the child list rather than asked for by name. + // + // `line_comment` and `block_comment` are NAMED nodes in tree-sitter-java, so taking + // the first two named children takes any comment inside the assert as an operand. A + // block comment before the condition was the worst case: the comment became the + // condition and the condition call became the MESSAGE, so the real message was + // dropped and a once-per-assert call was reported in the wrong clause — a wrong + // context, not merely a missing row. Filter the comments out, and split the remainder + // on the `:` token the grammar actually uses to separate the halves, rather than on a + // position in a list a comment can shift. + const colon = child.children.find(c => c.type === ':'); + const operands = child.children.filter(c => + c.isNamed && c.type !== 'line_comment' && c.type !== 'block_comment'); + const isCondition = (c: Parser.SyntaxNode) => !colon || c.startIndex < colon.startIndex; + + const parts = [ + operands.find(isCondition), + colon ? operands.find(c => !isCondition(c)) : undefined, + ]; + const contexts = [RootContext.ASSERT_CONDITION, RootContext.ASSERT_MESSAGE]; + + parts.forEach((part, index) => { + if (!part) return; + const assertExprs = this.expressionExtractor.extractFromConditionExpression( + part, + typeRegistryHash, + currentBlockHash, + ExpressionOwnerKind.ASSERT_STATEMENT, + contexts[index]!, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...assertExprs); + this.collectExpressionExtractorResults(); + }); + + // An assert has no body of its own, so there is nothing further to descend into: + // both halves are expressions and were just handled. + continue; + } + + // WHILE statement condition + if (child.type === 'while_statement') { + const condition = child.childForFieldName('condition'); + if (condition) { + const body = child.childForFieldName('body'); + let ownerHash = currentBlockHash; + if (body) { + ownerHash = BlockRegistry.computeHash( + BlockKind.WHILE, filePath, + body.startPosition.row + 1, body.endPosition.row + 1, + body.startPosition.column, body.endPosition.column, + typeRegistryHash, methodHash + ); + } + const condExprs = this.expressionExtractor.extractFromConditionExpression( + condition, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.WHILE_STATEMENT, + RootContext.WHILE_CONDITION, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...condExprs); + this.collectExpressionExtractorResults(); + } + traverse(child, currentLambdaParams, insideLambda, currentBlockHash); + continue; + } + + // DO-WHILE statement condition + if (child.type === 'do_statement') { + const condition = child.childForFieldName('condition'); + if (condition) { + const body = child.childForFieldName('body'); + let ownerHash = currentBlockHash; + if (body) { + ownerHash = BlockRegistry.computeHash( + BlockKind.DO_WHILE, filePath, + body.startPosition.row + 1, body.endPosition.row + 1, + body.startPosition.column, body.endPosition.column, + typeRegistryHash, methodHash + ); + } + const condExprs = this.expressionExtractor.extractFromConditionExpression( + condition, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.DO_WHILE_STATEMENT, + RootContext.DO_WHILE_CONDITION, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...condExprs); + this.collectExpressionExtractorResults(); + } + traverse(child, currentLambdaParams, insideLambda, currentBlockHash); + continue; + } + + // FOR statement - init, condition, update + if (child.type === 'for_statement') { + const body = child.childForFieldName('body'); + let ownerHash = currentBlockHash; + if (body) { + ownerHash = BlockRegistry.computeHash( + BlockKind.FOR, filePath, + body.startPosition.row + 1, body.endPosition.row + 1, + body.startPosition.column, body.endPosition.column, + typeRegistryHash, methodHash + ); + } + + // FOR condition + const condition = child.childForFieldName('condition'); + if (condition) { + const condExprs = this.expressionExtractor.extractFromConditionExpression( + condition, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.FOR_STATEMENT, + RootContext.FOR_CONDITION, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...condExprs); + this.collectExpressionExtractorResults(); + } + + // FOR init and update clauses, read by FIELD rather than by node type. + // + // Matching on node type scanned every child of the for_statement, so a condition that + // happened to be a method_invocation, a unary or an assignment matched the update set + // as well and was emitted a second time with FOR_UPDATE. An expression init clause + // matched too, so `for (init(); cond(); step())` produced three FOR_UPDATE rows for one + // update clause, and the once-per-loop call in the init clause was reported in the + // clause that runs every iteration. + // + // The grammar labels these: `init`, `condition` and `update` are field names on + // for_statement, and a clause may repeat (`for (i = 0, j = 1; …; i++, j--)`), so every + // child carrying the field is taken rather than the first. + for (let clauseIndex = 0; clauseIndex < child.childCount; clauseIndex++) { + const clause = child.child(clauseIndex); + if (!clause) continue; + + const fieldName = child.fieldNameForChild(clauseIndex); + if (fieldName !== 'init' && fieldName !== 'update') continue; + + // A declaration init clause (`for (int i = 0; …)`) belongs to the local variable: + // its initializer is already extracted as LOCAL_VAR_INITIALIZER, and re-reading it + // here would duplicate the row under a second context. + if (fieldName === 'init' && clause.type === 'local_variable_declaration') continue; + + const clauseExprs = this.expressionExtractor.extractFromConditionExpression( + clause, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.FOR_STATEMENT, + fieldName === 'init' ? RootContext.FOR_INIT : RootContext.FOR_UPDATE, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...clauseExprs); + this.collectExpressionExtractorResults(); + } + + traverse(child, currentLambdaParams, insideLambda, currentBlockHash); + continue; + } + + // ENHANCED FOR - iterable expression + if (child.type === 'enhanced_for_statement') { + const value = child.childForFieldName('value'); + if (value) { + const body = child.childForFieldName('body'); + let ownerHash = currentBlockHash; + if (body) { + ownerHash = BlockRegistry.computeHash( + BlockKind.ENHANCED_FOR, filePath, + body.startPosition.row + 1, body.endPosition.row + 1, + body.startPosition.column, body.endPosition.column, + typeRegistryHash, methodHash + ); + } + const iterExprs = this.expressionExtractor.extractFromConditionExpression( + value, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.ENHANCED_FOR_STATEMENT, + RootContext.ENHANCED_FOR_ITERABLE, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...iterExprs); + this.collectExpressionExtractorResults(); + } + traverse(child, currentLambdaParams, insideLambda, currentBlockHash); + continue; + } + + // SYNCHRONIZED - lock expression + if (child.type === 'synchronized_statement') { + // The lock expression is in parentheses after 'synchronized' + const parenExpr = child.children.find(c => c.type === 'parenthesized_expression'); + if (parenExpr) { + const body = child.childForFieldName('body'); + let ownerHash = currentBlockHash; + if (body) { + ownerHash = BlockRegistry.computeHash( + BlockKind.SYNCHRONIZED, filePath, + body.startPosition.row + 1, body.endPosition.row + 1, + body.startPosition.column, body.endPosition.column, + typeRegistryHash, methodHash + ); + } + const lockExprs = this.expressionExtractor.extractFromConditionExpression( + parenExpr, + typeRegistryHash, + ownerHash, + ExpressionOwnerKind.SYNCHRONIZED_STATEMENT, + RootContext.SYNCHRONIZED_LOCK, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...lockExprs); + this.collectExpressionExtractorResults(); + } + traverse(child, currentLambdaParams, insideLambda, currentBlockHash); + continue; + } + + // SWITCH statement - selector expression + case labels + // Only extract for statement switches (parent is a block-level container). + // Expression switches (in local var initializers, return statements, etc.) + // have their selector/labels extracted by the expression-reference-extractor. + if (child.type === 'switch_expression' || child.type === 'switch_statement') { + const parentType = child.parent?.type; + const isStatementSwitch = !parentType || + parentType === 'block' || + parentType === 'constructor_body' || + parentType === 'switch_block_statement_group' || + parentType === 'labeled_statement'; + + if (isStatementSwitch) { + // Extract selector expression (e.g., 'value' in 'switch (value)') + const condition = child.childForFieldName('condition'); + if (condition) { + const condExprs = this.expressionExtractor.extractFromConditionExpression( + condition, + typeRegistryHash, + currentBlockHash, + ExpressionOwnerKind.SWITCH_STATEMENT, + RootContext.SWITCH_SELECTOR, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...condExprs); + this.collectExpressionExtractorResults(); + } + + // Extract case label constants, linked to their SWITCH_CASE block hash + const switchBlock = child.childForFieldName('body'); + if (switchBlock) { + for (const caseChild of switchBlock.namedChildren) { + let caseBlockHash = currentBlockHash; + + if (caseChild.type === 'switch_block_statement_group') { + // Colon syntax: use group node position for SWITCH_CASE hash + if (!insideLambda) { + caseBlockHash = BlockRegistry.computeHash( + BlockKind.SWITCH_CASE, filePath, + caseChild.startPosition.row + 1, caseChild.endPosition.row + 1, + caseChild.startPosition.column, caseChild.endPosition.column, + typeRegistryHash, methodHash + ); + } + } else if (caseChild.type === 'switch_rule') { + // Arrow syntax: use block child position for SWITCH_EXPRESSION_CASE hash + if (!insideLambda) { + const ruleBlock = caseChild.children.find(c => c.type === 'block'); + if (ruleBlock) { + caseBlockHash = BlockRegistry.computeHash( + BlockKind.SWITCH_EXPRESSION_CASE, filePath, + ruleBlock.startPosition.row + 1, ruleBlock.endPosition.row + 1, + ruleBlock.startPosition.column, ruleBlock.endPosition.column, + typeRegistryHash, methodHash + ); + } + } + } else { + continue; + } + + // Find switch_label(s) in this case and extract each label constant + for (const labelOrStmt of caseChild.children) { + if (labelOrStmt.type !== 'switch_label') continue; + + // Handle default case (no named children, just "default" keyword) + if (labelOrStmt.namedChildren.length === 0 && labelOrStmt.text.includes('default')) { + const defaultKeyword = labelOrStmt.children.find(c => c.type === 'default'); + if (defaultKeyword) { + const builder = ExpressionReference.builder( + typeRegistryHash, + caseBlockHash, + ExpressionOwnerKind.SWITCH_STATEMENT, + RootContext.SWITCH_CASE_LABEL, + ExpressionKind.IDENTIFIER_REFERENCE, + EdgeRole.ROOT + ); + builder.positionAndDepth(0, 0); + builder.classLiteralTypeName('default'); + builder.location( + defaultKeyword.startPosition.row + 1, + defaultKeyword.startPosition.column, + defaultKeyword.endPosition.row + 1, + defaultKeyword.endPosition.column + ); + expressions.push(builder.build()); + } + continue; + } + + for (const labelChild of labelOrStmt.namedChildren) { + if (labelChild.type === 'guard') continue; // skip 'when' clauses + const labelExprs = this.expressionExtractor.extractFromConditionExpression( + labelChild, + typeRegistryHash, + caseBlockHash, + ExpressionOwnerKind.SWITCH_STATEMENT, + RootContext.SWITCH_CASE_LABEL, + packageName, + importMap, + hasStarImports, + methodParamNames, + localVariableNames, + currentLambdaParams + ); + expressions.push(...labelExprs); + this.collectExpressionExtractorResults(); + } + } + } + } + } + traverse(child, currentLambdaParams, insideLambda, currentBlockHash); + continue; + } + + // Recurse into other nodes + traverse(child, currentLambdaParams, insideLambda, currentBlockHash); + } + }; + + traverse(bodyBlock, new Set(), false, methodHash); + return expressions; + } + + /** + * Extracts TypeReference entries for a method's return type + */ + private extractReturnTypeReferences( + methodNode: Parser.SyntaxNode, + methodHash: string, + typeRegistryHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Find the return type node + // For method_declaration, the type comes before the method name + // For constructor_declaration and compact_constructor_declaration, there is no return type + if (methodNode.type === 'constructor_declaration' || + methodNode.type === 'compact_constructor_declaration') { + return references; // Constructors don't have return types + } + + // Find the type node (return type) + let returnTypeNode: Parser.SyntaxNode | null = null; + for (const child of methodNode.children) { + // The return type is typically before the identifier (method name) + // It can be: void, primitive, class type, generic type, array type, etc. + if (child.type === 'void_type' || + child.type === 'integral_type' || + child.type === 'floating_point_type' || + child.type === 'boolean_type' || + child.type === 'type_identifier' || + child.type === 'generic_type' || + child.type === 'array_type' || + child.type === 'scoped_type_identifier') { + returnTypeNode = child; + break; + } + } + + if (!returnTypeNode) { + return references; + } + + // Use TypeReferenceExtractor to extract the return type and any nested types (including void) + const returnTypeRefs = this.typeReferenceExtractor.extractFromMethodReturnType( + returnTypeNode, + typeRegistryHash, + methodHash, + packageName, + declaredTypeParams + ); + + references.push(...returnTypeRefs); + return references; + } + + /** + * Extracts class-level type parameter names (e.g., from class Foo) + */ + private extractClassTypeParameters(typeNode: Parser.SyntaxNode): Set { + const typeParams = new Set(); + + // Try to find type_parameters child + for (const child of typeNode.children) { + if (child.type === 'type_parameters') { + // Extract all type parameter identifiers + for (const paramChild of child.children) { + if (paramChild.type === 'type_parameter') { + // The first child is the type_identifier with the name + const identifierNode = paramChild.children.find(c => c.type === 'type_identifier'); + if (identifierNode) { + typeParams.add(identifierNode.text); + } + } + } + } + } + + return typeParams; + } + + /** + * Extracts method-level type parameter names (e.g., from void foo()) + */ + private extractMethodTypeParameters(methodNode: Parser.SyntaxNode): Set { + const typeParams = new Set(); + + // Try to find type_parameters child + for (const child of methodNode.children) { + if (child.type === 'type_parameters') { + // Extract all type parameter identifiers + for (const paramChild of child.children) { + if (paramChild.type === 'type_parameter') { + // The first child is the type_identifier with the name + const identifierNode = paramChild.children.find(c => c.type === 'type_identifier'); + if (identifierNode) { + typeParams.add(identifierNode.text); + } + } + } + } + } + + return typeParams; + } + + /** + * Finds the body node of a type declaration + */ + private findTypeBody(typeNode: Parser.SyntaxNode): Parser.SyntaxNode | null { + for (const child of typeNode.children) { + if (child.type === 'class_body' || + child.type === 'interface_body' || + child.type === 'enum_body' || + child.type === 'annotation_type_body' || + child.type === 'record_declaration') { + return child; + } + } + return null; + } + + /** + * Checks if a node represents a method declaration (including constructors and annotation elements) + */ + private isMethodDeclaration(node: Parser.SyntaxNode): boolean { + return [ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', + 'annotation_type_element_declaration', + ].includes(node.type); + } + + /** + * Checks if a node is an initializer block + */ + private isInitializerBlock(node: Parser.SyntaxNode): boolean { + if (node.type === 'static_initializer') return true; + // Instance initializer is just a block in class body + if (node.type === 'block') { + // Check if it's a direct child of class_body (not in a method) + return node.parent?.type === 'class_body'; + } + return false; + } + + /** + * Generates an enum constant hash for linking methods to their owning enum constant. + * Uses the same hash generation logic as EnumConstant entity. + */ + private generateEnumConstantHash( + enumConstantNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string + ): string { + // Extract the constant name from the identifier child + const identifierNode = enumConstantNode.children.find(child => child.type === 'identifier'); + const name = identifierNode?.text || ''; + + // Count ordinal by finding position among sibling enum constants + let ordinal = 0; + if (enumConstantNode.parent) { + for (const sibling of enumConstantNode.parent.children) { + if (sibling === enumConstantNode) break; + if (sibling.type === 'enum_constant') ordinal++; + } + } + + const startLine = enumConstantNode.startPosition.row + 1; + const endLine = enumConstantNode.endPosition.row + 1; + + const content = + filePath + + '||' + + typeRegistryHash + + '||' + + name + + '||' + + ordinal + + '||' + + startLine + + '||' + + endLine; + + return EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.ENUM_CONSTANT, + content + ); + } + + /** + * Creates a MethodRegistry instance from a method declaration node + */ + private createMethodRegistry( + node: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + isInEnumConstantBody: boolean = false, + enclosingMemberLinkHash?: string + ): MethodRegistry | null { + const name = this.extractMethodName(node); + if (!name) return null; + + const methodKind = this.determineMethodKind(node, isInEnumConstantBody); + const methodAccess = this.extractMethodAccess(node, methodKind); + const signature = this.buildSignature(node, name); + const detailedSignature = this.buildDetailedSignature(node, name); + const qualifiedName = `${ownerQualifiedName}.${name}`; + + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + + // Extract all optional fields + const modifiers = this.extractMethodModifiers(node); + const returnType = this.extractReturnType(node) ?? undefined; + const hasVarArgs = this.hasVarArgs(node); + const hasReceiver = this.hasReceiverParameter(node); + const paramCount = this.countParameters(node); + const hasTypeParams = this.hasTypeParameters(node); + const hasThrows = this.hasThrowsClause(node); + + let defaultValue: string | undefined; + if (node.type === 'annotation_type_element_declaration') { + const extracted = this.extractDefaultValue(node); + defaultValue = extracted ?? undefined; + } + + return new MethodRegistry( + name, + signature, + detailedSignature, + qualifiedName, + filePath, + startLine, + endLine, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + methodAccess, + methodKind, + serviceVersionHash, + paramCount, + hasVarArgs, + hasReceiver, + hasTypeParams, + hasThrows, + modifiers, + returnType, + defaultValue, + enclosingMemberLinkHash + ); + } + + /** + * Creates a MethodRegistry for initializer blocks + */ + private createInitializerMethod( + node: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string + ): MethodRegistry | null { + const isStatic = node.type === 'static_initializer'; + const methodKind = isStatic ? MethodKind.STATIC_INITIALIZER : MethodKind.INSTANCE_INITIALIZER; + const name = isStatic ? '' : ''; + const signature = `${name}():void`; + const qualifiedName = `${ownerQualifiedName}.${name}`; + + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + + return new MethodRegistry( + name, + signature, + signature, // detailedSignature same as signature for initializers + qualifiedName, + filePath, + startLine, + endLine, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + MethodAccess.PACKAGE, // Initializers have no explicit access modifier + methodKind, + serviceVersionHash, + 0, // parameterCount + false, // isVarArgs + false, // hasReceiverParameter + false, // hasTypeParameters + false, // throwsExceptions + undefined, // methodModifier + 'void', // returnTypeName + undefined // defaultValueExpression + ); + } + + /** + * Processes the canonical constructor for a record declaration. + * + * Records in Java have an implicit canonical constructor that takes all record components + * as parameters. This method creates a MethodRegistry for that constructor and extracts + * the record components as MethodParameters. + * + * Example: `record Point(int x, int y)` creates a canonical constructor `Point(int, int)` + */ + private processRecordCanonicalConstructor( + recordNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + classTypeParams: Set, + methods: MethodRegistry[] + ): void { + // Find formal_parameters (record components) directly on record_declaration + let formalParamsNode: Parser.SyntaxNode | null = null; + for (const child of recordNode.children) { + if (child.type === 'formal_parameters') { + formalParamsNode = child; + break; + } + } + + // JLS 8.10.4: a record has exactly ONE canonical constructor, and it is implicitly declared + // only when the record declares neither an explicit canonical constructor nor a compact one. + // Synthesising unconditionally produced a second constructor row for the one real constructor. + if (this.declaresCanonicalConstructor(recordNode, formalParamsNode)) { + return; + } + + // Create the canonical constructor MethodRegistry + const method = this.createRecordCanonicalConstructor( + recordNode, + formalParamsNode, + filePath, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + serviceVersionHash + ); + + if (method) { + methods.push(method); + + // Extract annotations from the canonical constructor (from record modifiers) + this.annotationExtractor.resetExtractedArguments(); + // Note: Record-level annotations are handled by the type extractor, not here + + // Extract parameters from record components + if (formalParamsNode) { + const parameters = this.extractRecordComponentParameters( + formalParamsNode, + method.getHash(), + typeRegistryHash, + packageName, + importMap, + hasStarImports, + classTypeParams + ); + this.extractedMethodParameters.push(...parameters); + + // Collect annotations from parameters + const paramAnnotations = this.methodParameterExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...paramAnnotations); + + // Collect annotation arguments from parameter annotations + const paramAnnotationArgs = this.methodParameterExtractor.getExtractedAnnotationArguments(); + this.extractedAnnotationArguments.push(...paramAnnotationArgs); + + // Collect type references from parameters + const paramTypeRefs = this.methodParameterExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...paramTypeRefs); + } + } + } + + /** + * True when the record declares its canonical constructor itself, in either form. + * + * A compact constructor always IS the canonical constructor. An explicit constructor is the + * canonical one when its parameter types match the record's component types (JLS 8.10.4) - + * parameter NAMES need not match, so only the types are compared. Any other constructor is a + * non-canonical one that delegates via this(...), and does not suppress the implicit member. + */ + private declaresCanonicalConstructor( + recordNode: Parser.SyntaxNode, + formalParamsNode: Parser.SyntaxNode | null + ): boolean { + const body = this.findTypeBody(recordNode); + if (!body) return false; + + const componentTypes = this.recordComponentTypes(formalParamsNode); + + for (const child of body.children) { + if (child.type === 'compact_constructor_declaration') { + return true; + } + if (child.type === 'constructor_declaration') { + const params = this.findFormalParameters(child); + const paramTypes = this.recordComponentTypes(params); + if (paramTypes.length === componentTypes.length && + paramTypes.every((t, i) => t === componentTypes[i])) { + return true; + } + } + } + return false; + } + + /** + * The erased type text of each entry in a formal_parameters node, in declaration order. + */ + private recordComponentTypes(formalParamsNode: Parser.SyntaxNode | null): string[] { + if (!formalParamsNode) return []; + + const types: string[] = []; + for (const child of formalParamsNode.children) { + if (child.type === 'formal_parameter' || child.type === 'spread_parameter') { + const t = this.extractParameterType(child, false); + if (t) types.push(t); + } + } + return types; + } + + /** + * Synthesises the members JLS 8.10.3 declares implicitly on a record: one accessor per + * component, plus equals/hashCode/toString. + * + * javac declares each of these only if the record does not declare it, so this mirrors that + * rule against the members already extracted from the body. `alreadyDeclared` therefore reads + * the real declarations rather than a second scan of the tree. + */ + private synthesizeRecordImplicitMembers( + recordNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + methods: MethodRegistry[] + ): void { + let formalParamsNode: Parser.SyntaxNode | null = null; + for (const child of recordNode.children) { + if (child.type === 'formal_parameters') { + formalParamsNode = child; + break; + } + } + + // Names of members the record declares itself, keyed name/arity. + const declared = new Set( + methods + .filter(m => m.getTypeRegistryLinkHash() === typeRegistryHash) + .map(m => `${m.getName()}/${m.getParameterCount()}`) + ); + + const recordLine = recordNode.startPosition.row + 1; + + // One accessor per component. + if (formalParamsNode) { + for (const child of formalParamsNode.children) { + if (child.type !== 'formal_parameter' && child.type !== 'spread_parameter') continue; + + const componentName = this.extractParameterName(child); + if (!componentName || declared.has(`${componentName}/0`)) continue; + + // The accessor returns the component's declared type. For a varargs component the + // component type is the array type, which extractParameterTypeNameFull already yields. + const componentType = this.extractParameterTypeNameFull(child); + if (!componentType) continue; + const returnType = child.type === 'spread_parameter' ? `${componentType}[]` : componentType; + + methods.push(this.createImplicitMethod( + componentName, returnType, [], MethodKind.RECORD_ACCESSOR, MethodAccess.PUBLIC, + filePath, child.startPosition.row + 1, typeRegistryHash, + ownerTypeName, ownerQualifiedName, serviceVersionHash + )); + } + } + + // equals / hashCode / toString. + const objectMethods: Array<[string, string, string[], MethodKind]> = [ + ['equals', 'boolean', ['Object'], MethodKind.RECORD_EQUALS], + ['hashCode', 'int', [], MethodKind.RECORD_HASH_CODE], + ['toString', 'String', [], MethodKind.RECORD_TO_STRING], + ]; + + for (const [name, returnType, paramTypes, kind] of objectMethods) { + if (declared.has(`${name}/${paramTypes.length}`)) continue; + + const method = this.createImplicitMethod( + name, returnType, paramTypes, kind, MethodAccess.PUBLIC, + filePath, recordLine, typeRegistryHash, + ownerTypeName, ownerQualifiedName, serviceVersionHash + ); + methods.push(method); + + // Keep parameterCount and the emitted parameter rows in agreement. + paramTypes.forEach((paramType, index) => { + this.extractedMethodParameters.push(new MethodParameter( + 'o', index, method.getHash(), paramType, paramType, + `java.lang.${paramType}`, false, false, false, false, + recordLine, recordLine + )); + }); + } + } + + /** + * Builds one implicitly declared member. These have no declaration node, so position is the + * construct that induces them - the component for a record accessor, the type header otherwise. + * + * None of them can be generic, varargs, or declare a throws clause, so those flags are fixed. + */ + private createImplicitMethod( + name: string, + returnType: string, + paramTypes: string[], + methodKind: MethodKind, + methodAccess: MethodAccess, + filePath: string, + line: number, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + methodModifier?: MethodModifier + ): MethodRegistry { + const signature = `${name}(${paramTypes.join(',')}):${returnType}`; + return new MethodRegistry( + name, + signature, + signature, + `${ownerQualifiedName}.${name}`, + filePath, + line, + line, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + methodAccess, + methodKind, + serviceVersionHash, + paramTypes.length, + false, // isVarArgs + false, // hasReceiverParameter + false, // hasTypeParameters + false, // throwsExceptions + methodModifier, + returnType + ); + } + + /** + * Synthesises the members JLS 8.9 declares implicitly on an enum: `values()`, `valueOf(String)` + * and, when the enum declares no constructor, a private default constructor. + * + * `values()` and `valueOf(String)` differ from a record's implicit members in that they can + * never be written by hand - declaring either in an enum body is a compile error - so they are + * unconditional. The declared-member check is kept anyway so that source which does not compile + * cannot produce two rows for one name. + * + * The compiler artifacts `$VALUES` and `$values()` are deliberately NOT emitted: they are + * class-file implementation details, not members the language declares. + */ + private synthesizeEnumImplicitMembers( + enumNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + methods: MethodRegistry[] + ): void { + const declared = this.declaredMemberKeys(methods, typeRegistryHash); + const line = enumNode.startPosition.row + 1; + + if (!declared.has('values/0')) { + methods.push(this.createImplicitMethod( + 'values', `${ownerTypeName}[]`, [], MethodKind.ENUM_VALUES, MethodAccess.PUBLIC, + filePath, line, typeRegistryHash, ownerTypeName, ownerQualifiedName, serviceVersionHash, + MethodModifier.STATIC_MODIFIER + )); + } + + if (!declared.has('valueOf/1')) { + const valueOf = this.createImplicitMethod( + 'valueOf', ownerTypeName, ['String'], MethodKind.ENUM_VALUE_OF, MethodAccess.PUBLIC, + filePath, line, typeRegistryHash, ownerTypeName, ownerQualifiedName, serviceVersionHash, + MethodModifier.STATIC_MODIFIER + ); + methods.push(valueOf); + this.extractedMethodParameters.push(new MethodParameter( + 'name', 0, valueOf.getHash(), 'String', 'String', + 'java.lang.String', false, false, false, false, line, line + )); + } + + // JLS 8.9.2: an enum with no declared constructor gets a private one. + if (!this.declaresAnyConstructor(enumNode)) { + methods.push(this.createImplicitMethod( + ownerTypeName, 'void', [], MethodKind.DEFAULT_CONSTRUCTOR, MethodAccess.PRIVATE, + filePath, line, typeRegistryHash, ownerTypeName, ownerQualifiedName, serviceVersionHash + )); + } + } + + /** + * Synthesises the default constructor JLS 8.8.9 declares on a class that declares none. + * + * The default constructor takes the access of the class itself, so a package-private class does + * not get a public constructor. Interfaces and annotation types are excluded because they have + * no constructors at all; records and enums are handled by their own rules. + * + * Anonymous classes are deliberately excluded, and this is a known gap rather than an + * oversight. JLS 15.9.5.1 does declare an anonymous constructor implicitly, and javac emits it + * without ACC_SYNTHETIC - but its parameter list is chosen by the compiler, not written in the + * source: it carries the enclosing instance and every captured local, neither of which is + * recoverable here. Emitting a guessed signature would be worse than emitting nothing, because + * a wrong arity resolves to the wrong constructor rather than to none. An anonymous class is + * reached through its OBJECT_CREATION expression regardless, so nothing else keys on this. + */ + private synthesizeDefaultConstructor( + classNode: Parser.SyntaxNode, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string, + methods: MethodRegistry[] + ): void { + if (this.declaresAnyConstructor(classNode)) return; + + methods.push(this.createImplicitMethod( + ownerTypeName, 'void', [], MethodKind.DEFAULT_CONSTRUCTOR, + this.extractRecordAccess(classNode), // same rule: the type's own access modifier + filePath, classNode.startPosition.row + 1, typeRegistryHash, + ownerTypeName, ownerQualifiedName, serviceVersionHash + )); + } + + /** + * True when the type body declares a constructor in any form. + * + * An enum body holds its members one level down, under `enum_body_declarations`, after the + * constant list - so a direct-children scan would miss an enum's constructor and wrongly + * conclude the default one is implicitly declared. + */ + private declaresAnyConstructor(typeNode: Parser.SyntaxNode): boolean { + const body = this.findTypeBody(typeNode); + if (!body) return false; + + const isConstructor = (node: Parser.SyntaxNode) => + node.type === 'constructor_declaration' || node.type === 'compact_constructor_declaration'; + + for (const child of body.children) { + if (isConstructor(child)) return true; + if (child.type === 'enum_body_declarations' && child.children.some(isConstructor)) { + return true; + } + } + return false; + } + + /** + * The name/arity keys of the members already extracted for this type. + */ + private declaredMemberKeys(methods: MethodRegistry[], typeRegistryHash: string): Set { + return new Set( + methods + .filter(m => m.getTypeRegistryLinkHash() === typeRegistryHash) + .map(m => `${m.getName()}/${m.getParameterCount()}`) + ); + } + + /** + * Creates a MethodRegistry for a record's canonical constructor. + */ + private createRecordCanonicalConstructor( + recordNode: Parser.SyntaxNode, + formalParamsNode: Parser.SyntaxNode | null, + filePath: string, + typeRegistryHash: string, + ownerTypeName: string, + ownerQualifiedName: string, + serviceVersionHash: string + ): MethodRegistry | null { + // Record constructor name is the record name + const name = ownerTypeName; + + // Build signature from record components + const signature = this.buildRecordConstructorSignature(formalParamsNode, name); + const detailedSignature = this.buildRecordConstructorDetailedSignature(formalParamsNode, name); + const qualifiedName = `${ownerQualifiedName}.${name}`; + + // Use the record declaration's position + const startLine = recordNode.startPosition.row + 1; + const endLine = recordNode.startPosition.row + 1; // Constructor is implicit, use record line + + // Count parameters from record components + const paramCount = this.countRecordComponents(formalParamsNode); + const hasVarArgs = this.hasRecordVarArgs(formalParamsNode); + + // Extract access modifier from record declaration + const recordAccess = this.extractRecordAccess(recordNode); + + return new MethodRegistry( + name, + signature, + detailedSignature, + qualifiedName, + filePath, + startLine, + endLine, + typeRegistryHash, + ownerTypeName, + ownerQualifiedName, + recordAccess, // Canonical constructor has same access as record + MethodKind.CONSTRUCTOR, // Canonical constructor is a regular constructor + serviceVersionHash, + paramCount, + hasVarArgs, + false, // hasReceiverParameter - records don't have receiver params + false, // hasTypeParameters - canonical constructor doesn't have its own type params + false, // throwsExceptions - canonical constructor doesn't throw + undefined, // methodModifier + undefined, // returnTypeName - constructors have no return type + undefined // defaultValueExpression + ); + } + + /** + * Builds the canonical signature for a record constructor + */ + private buildRecordConstructorSignature(formalParamsNode: Parser.SyntaxNode | null, name: string): string { + if (!formalParamsNode) { + return `${name}():void`; + } + + const params: string[] = []; + for (const child of formalParamsNode.children) { + if (child.type === 'formal_parameter' || child.type === 'spread_parameter') { + const paramType = this.extractParameterType(child, false); + if (paramType) { + params.push(paramType); + } + } + } + + return `${name}(${params.join(',')}):void`; + } + + /** + * Builds the detailed signature for a record constructor (with full type info and names) + */ + private buildRecordConstructorDetailedSignature(formalParamsNode: Parser.SyntaxNode | null, name: string): string { + if (!formalParamsNode) { + return `${name}():void`; + } + + const params: string[] = []; + for (const child of formalParamsNode.children) { + if (child.type === 'formal_parameter' || child.type === 'spread_parameter') { + const paramType = this.extractParameterTypeNameFull(child); + const paramName = this.extractParameterName(child); + + if (paramType && paramName) { + let finalType = paramType; + if (child.type === 'spread_parameter') { + finalType = paramType + '...'; + } + params.push(`${finalType} ${paramName}`); + } + } + } + + return `${name}(${params.join(', ')}):void`; + } + + /** + * Counts the number of record components + */ + private countRecordComponents(formalParamsNode: Parser.SyntaxNode | null): number { + if (!formalParamsNode) return 0; + + let count = 0; + for (const child of formalParamsNode.children) { + if (child.type === 'formal_parameter' || child.type === 'spread_parameter') { + count++; + } + } + return count; + } + + /** + * Checks if record has varargs component + */ + private hasRecordVarArgs(formalParamsNode: Parser.SyntaxNode | null): boolean { + if (!formalParamsNode) return false; + + for (const child of formalParamsNode.children) { + if (child.type === 'spread_parameter') { + return true; + } + } + return false; + } + + /** + * Extracts access modifier from record declaration + */ + private extractRecordAccess(recordNode: Parser.SyntaxNode): MethodAccess { + const modifiers = this.getModifierList(recordNode); + + if (modifiers.includes('public')) return MethodAccess.PUBLIC; + if (modifiers.includes('private')) return MethodAccess.PRIVATE; + if (modifiers.includes('protected')) return MethodAccess.PROTECTED; + + return MethodAccess.PACKAGE; + } + + /** + * Extracts method parameters from record components (formal_parameters node on record_declaration) + */ + private extractRecordComponentParameters( + formalParamsNode: Parser.SyntaxNode, + methodRegistryHash: string, + typeRegistryHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + declaredTypeParams: Set + ): MethodParameter[] { + // Use the existing MethodParameterExtractor but pass the record's formal_parameters node + // We create a synthetic "method node" wrapper that contains the formal_parameters + // Actually, we can directly use extractFromMethod since it looks for formal_parameters child + + // Create a wrapper object that mimics a method node with children containing formal_parameters + const syntheticMethodNode = { + children: [formalParamsNode], + type: 'constructor_declaration' + } as unknown as Parser.SyntaxNode; + + return this.methodParameterExtractor.extractFromMethod( + syntheticMethodNode, + methodRegistryHash, + typeRegistryHash, + packageName, + importMap, + hasStarImports, + declaredTypeParams, + this.annotationExtractor + ); + } + + /** + * Determines the MethodKind based on AST node type and modifiers + */ + private determineMethodKind(node: Parser.SyntaxNode, isInEnumConstantBody: boolean = false): MethodKind { + // 1. Special AST node types (highest priority) + if (node.type === 'constructor_declaration') return MethodKind.CONSTRUCTOR; + if (node.type === 'compact_constructor_declaration') return MethodKind.COMPACT_CONSTRUCTOR; + if (node.type === 'annotation_type_element_declaration') return MethodKind.ANNOTATION_ELEMENT; + + // 2. Check if this is a method in an enum constant's anonymous class body + if (isInEnumConstantBody) return MethodKind.ENUM_CONSTANT_METHOD; + + // 3. Check for abstract (no method body) - BUT NOT if native! + const hasBody = this.hasMethodBody(node); + const modifiers = this.getModifierList(node); + const isNative = modifiers.includes('native'); + + if (!hasBody && !isNative) return MethodKind.ABSTRACT_METHOD; + + // 4. Check for default modifier (interface default methods) + if (modifiers.includes('default')) return MethodKind.DEFAULT_METHOD; + + // 5. Check for static modifier + if (modifiers.includes('static')) return MethodKind.STATIC_METHOD; + + // 6. Fallback: instance method + return MethodKind.INSTANCE_METHOD; + } + + /** + * Extracts method access level + */ + private extractMethodAccess(node: Parser.SyntaxNode, methodKind: MethodKind): MethodAccess { + const modifiers = this.getModifierList(node); + + if (modifiers.includes('public')) return MethodAccess.PUBLIC; + if (modifiers.includes('private')) return MethodAccess.PRIVATE; + if (modifiers.includes('protected')) return MethodAccess.PROTECTED; + + // Handle implicit access modifiers + if (methodKind === MethodKind.ANNOTATION_ELEMENT) { + return MethodAccess.PUBLIC; // Annotation elements are always public + } + + // Check if in interface + if (this.isInInterface(node)) { + return MethodAccess.PUBLIC; // Interface methods default to public + } + + // Check if enum constructor + if (methodKind === MethodKind.CONSTRUCTOR && this.isInEnum(node)) { + return MethodAccess.PRIVATE; // Enum constructors are always private + } + + return MethodAccess.PACKAGE; // Default: package-private + } + + /** + * Extracts method modifiers as comma-separated string + */ + private extractMethodModifiers(node: Parser.SyntaxNode): string | undefined { + const modifiers = this.getModifierList(node); + const methodModifiers: string[] = []; + + if (modifiers.includes('static')) methodModifiers.push(MethodModifier.STATIC_MODIFIER); + if (modifiers.includes('abstract')) methodModifiers.push(MethodModifier.ABSTRACT_MODIFIER); + if (modifiers.includes('final')) methodModifiers.push(MethodModifier.FINAL_MODIFIER); + if (modifiers.includes('synchronized')) methodModifiers.push(MethodModifier.SYNCHRONIZED_MODIFIER); + if (modifiers.includes('native')) methodModifiers.push(MethodModifier.NATIVE_MODIFIER); + if (modifiers.includes('strictfp')) methodModifiers.push(MethodModifier.STRICTFP_MODIFIER); + if (modifiers.includes('default')) methodModifiers.push(MethodModifier.DEFAULT_MODIFIER); + + return methodModifiers.length > 0 ? methodModifiers.join(',') : undefined; + } + + /** + * Gets list of modifier keywords from node + */ + private getModifierList(node: Parser.SyntaxNode): string[] { + const modifiers: string[] = []; + + for (const child of node.children) { + if (child.type === 'modifiers') { + for (const modifier of child.children) { + if (modifier.text) { + modifiers.push(modifier.text); + } + } + } + } + + return modifiers; + } + + /** + * Extracts method name + */ + private extractMethodName(node: Parser.SyntaxNode): string | null { + for (const child of node.children) { + if (child.type === 'identifier') { + return child.text; + } + } + return null; + } + + /** + * Extracts return type name + */ + private extractReturnType(node: Parser.SyntaxNode): string | null { + // Constructors have no return type + if (node.type === 'constructor_declaration' || + node.type === 'compact_constructor_declaration') { + return null; + } + + for (const child of node.children) { + if (child.type === 'void_type') { + return 'void'; + } + if (this.isTypeNode(child)) { + return EntityUtils.normalizeWhitespace(child.text); + } + } + return null; + } + + /** + * Checks if node represents a type + */ + private isTypeNode(node: Parser.SyntaxNode): boolean { + return [ + 'type_identifier', + 'generic_type', + 'array_type', + 'integral_type', + 'floating_point_type', + 'boolean_type', + 'scoped_type_identifier', + ].includes(node.type); + } + + /** + * Builds canonical signature (generics stripped, varargs normalized) + */ + private buildSignature(node: Parser.SyntaxNode, methodName: string): string { + const params = this.extractParameterTypesForSignature(node, false); + const returnType = this.extractReturnType(node) || 'void'; + return `${methodName}(${params.join(',')}):${returnType}`; + } + + /** + * Builds detailed signature with full type information and parameter names + * Example: "process(List users, String... args):Map" + * - Preserves generics: List + * - Preserves varargs: String... + * - Includes parameter names: users, args + */ + private buildDetailedSignature(node: Parser.SyntaxNode, methodName: string): string { + const params = this.extractParametersWithNames(node); + const returnType = this.extractReturnType(node) || 'void'; + return `${methodName}(${params.join(', ')}):${returnType}`; + } + + /** + * Extracts parameters with names for detailed signature (e.g., "String name, int age") + */ + private extractParametersWithNames(node: Parser.SyntaxNode): string[] { + const params: string[] = []; + const formalParams = this.findFormalParameters(node); + + if (!formalParams) return params; + + for (const child of formalParams.children) { + if (child.type === 'formal_parameter' || child.type === 'spread_parameter') { + const paramType = this.extractParameterTypeNameFull(child); + const paramName = this.extractParameterName(child); + + if (paramType && paramName) { + // Handle varargs: add ... suffix for spread_parameter + let finalType = paramType; + if (child.type === 'spread_parameter') { + // Type is already without [], just add ... + finalType = paramType + '...'; + } + params.push(`${finalType} ${paramName}`); + } + } + // Skip receiver parameters in detailed signature + } + + return params; + } + + /** + * Extracts parameter types for signature building + */ + private extractParameterTypesForSignature(node: Parser.SyntaxNode, preserveGenerics: boolean): string[] { + const params: string[] = []; + const formalParams = this.findFormalParameters(node); + + if (!formalParams) return params; + + for (const child of formalParams.children) { + if (child.type === 'formal_parameter' || child.type === 'spread_parameter') { + const paramType = this.extractParameterType(child, preserveGenerics); + if (paramType) { + params.push(paramType); + } + } + } + + return params; + } + + /** + * Extracts parameter type from formal_parameter node + */ + private extractParameterType(paramNode: Parser.SyntaxNode, preserveGenerics: boolean): string | null { + for (const child of paramNode.children) { + if (this.isTypeNode(child)) { + let typeText = EntityUtils.normalizeWhitespace(child.text); + + // Strip generics if not preserving + if (!preserveGenerics && typeText.includes('<')) { + typeText = typeText.substring(0, typeText.indexOf('<')); + } + + return typeText; + } + } + return null; + } + + /** + * Finds formal_parameters node. + * + * A compact constructor has no parameter list of its own, but it IS the record's canonical + * constructor (JLS 8.10.4) and its parameters are the record's components. Reading them off + * the enclosing record here keeps signature, detailedSignature, parameterCount and isVarArgs + * consistent for every caller, instead of each reporting arity 0. + */ + private findFormalParameters(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + const source = node.type === 'compact_constructor_declaration' + ? this.findEnclosingRecord(node) ?? node + : node; + + for (const child of source.children) { + if (child.type === 'formal_parameters') { + return child; + } + } + return null; + } + + /** + * Walks up to the record_declaration a compact constructor belongs to. + */ + private findEnclosingRecord(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let current = node.parent; + while (current) { + if (current.type === 'record_declaration') return current; + current = current.parent; + } + return null; + } + + /** + * Checks if method has a body + */ + private hasMethodBody(node: Parser.SyntaxNode): boolean { + for (const child of node.children) { + if (child.type === 'block') { + return true; + } + } + return false; + } + + /** + * Checks if method has varargs parameters + */ + private hasVarArgs(node: Parser.SyntaxNode): boolean { + const formalParams = this.findFormalParameters(node); + if (!formalParams) return false; + + for (const child of formalParams.children) { + if (child.type === 'spread_parameter') { + return true; + } + } + return false; + } + + /** + * Checks if method has receiver parameter + */ + private hasReceiverParameter(node: Parser.SyntaxNode): boolean { + const formalParams = this.findFormalParameters(node); + if (!formalParams) return false; + + for (const child of formalParams.children) { + if (child.type === 'receiver_parameter') { + return true; + } + } + return false; + } + + /** + * Extracts default value for annotation elements + */ + private extractDefaultValue(node: Parser.SyntaxNode): string | null { + for (const child of node.children) { + if (child.type === 'default_value') { + // Get the value after 'default' keyword + for (const valueChild of child.children) { + if (valueChild.type !== 'default') { + return valueChild.text; + } + } + } + } + return null; + } + + /** + * Counts the number of parameters (excluding receiver parameters) + */ + private countParameters(node: Parser.SyntaxNode): number { + const formalParams = this.findFormalParameters(node); + if (!formalParams) return 0; + + let count = 0; + for (const child of formalParams.children) { + if (child.type === 'formal_parameter' || child.type === 'spread_parameter') { + count++; + } + // Exclude receiver_parameter from count + } + return count; + } + + /** + * Checks if method has type parameters + */ + private hasTypeParameters(node: Parser.SyntaxNode): boolean { + for (const child of node.children) { + if (child.type === 'type_parameters') { + return true; + } + } + return false; + } + + /** + * Checks if method has throws clause + */ + private hasThrowsClause(node: Parser.SyntaxNode): boolean { + for (const child of node.children) { + if (child.type === 'throws') { + return true; + } + } + return false; + } + + /** + * Checks if node is in an interface + */ + private isInInterface(node: Parser.SyntaxNode): boolean { + let current = node.parent; + while (current) { + if (current.type === 'interface_declaration' || current.type === 'annotation_type_declaration') { + return true; + } + current = current.parent; + } + return false; + } + + /** + * Checks if node is in an enum + */ + private isInEnum(node: Parser.SyntaxNode): boolean { + let current = node.parent; + while (current) { + if (current.type === 'enum_declaration') { + return true; + } + current = current.parent; + } + return false; + } + + /** + * Extracts parameter name (used for detailedSignature) + */ + private extractParameterName(paramNode: Parser.SyntaxNode): string | null { + for (const child of paramNode.children) { + if (child.type === 'identifier') { + return child.text; + } + // For receiver parameters, it's always 'this' + if (child.type === 'this') { + return 'this'; + } + // For varargs (spread_parameter), name is inside variable_declarator + if (child.type === 'variable_declarator') { + const identifierNode = child.childForFieldName('name'); + if (identifierNode) { + return identifierNode.text; + } + } + } + return null; + } + + /** + * Extracts full parameter type name (preserving generics, used for detailedSignature) + */ + private extractParameterTypeNameFull(paramNode: Parser.SyntaxNode): string | null { + for (const child of paramNode.children) { + if (this.isTypeNode(child)) { + return EntityUtils.normalizeWhitespace(child.text); + } + } + return null; + } + +} diff --git a/parser/src/parsers/java/extractors/type-parameter-extractor.ts b/parser/src/parsers/java/extractors/type-parameter-extractor.ts new file mode 100644 index 000000000..dea3baa4f --- /dev/null +++ b/parser/src/parsers/java/extractors/type-parameter-extractor.ts @@ -0,0 +1,239 @@ +import Parser from 'tree-sitter'; + +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeParameter } from '@/analysis-types/java/TypeParameter'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { BaseExtractor } from '@/parsers/base-extractor'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { TypeReferenceExtractor } from '@/parsers/java/extractors/type-reference-extractor'; + +/** + * Extracts TypeParameter entities from Java type declarations. + * + * Handles: + * - Simple type parameters: ``, ``, `` + * - Bounded type parameters: ``, `>` + * - Multiple bounds: `` + * - Annotations on type parameters: `<@NonNull T>` (Java 8+) + * - Recursive bounds: `>` + * + * ## Extraction Flow + * + * For each type parameter declaration: + * 1. Create TypeParameter entity with name, position, and owner information + * 2. Extract annotations on the type parameter (e.g., `@NonNull`, `@Validated`) + * 3. Extract type references from annotation arguments (if annotations have arguments) + * 4. Extract type references from bounds (e.g., `extends BaseEntity & Auditable`) + * + * ## Complete Example + * + * ```java + * public class Container< + * @NonNull T extends Serializable, // Extracts: @NonNull annotation, Serializable bound + * @Validated(validator = SizeValidator.class) U, // Extracts: @Validated annotation, SizeValidator type reference + * V extends Comparable & Cloneable // Extracts: Comparable, Cloneable bounds + * > { } + * ``` + * + * **Results in:** + * - 3 TypeParameter entries (T, U, V with positions 0, 1, 2) + * - 2 TypeAnnotation entries (@NonNull on T, @Validated on U) + * - 1 AnnotationArgumentReference (validator = SizeValidator.class) + * - 4 TypeReference entries: + * - SizeValidator (context: TYPE_PARAMETER_ANNOTATION, owned by U) + * - Serializable (context: TYPE_PARAM_BOUND, owned by T) + * - Comparable (context: TYPE_PARAM_BOUND, owned by V) + * - Cloneable (context: TYPE_PARAM_BOUND, owned by V) + * + * ## Getters for Extracted Data + * + * - `getExtractedAnnotations()` - Returns annotations on type parameters + * - `getExtractedTypeReferences()` - Returns type references from bounds AND annotation arguments + * - `getAnnotationExtractor()` - Direct access to annotation extractor for argument data + */ +export class TypeParameterExtractor implements BaseExtractor { + private typeReferenceExtractor: TypeReferenceExtractor; + private annotationExtractor: AnnotationExtractor; + private extractedTypeReferences: TypeReference[] = []; + private extractedAnnotations: TypeAnnotation[] = []; + + constructor() { + this.typeReferenceExtractor = new TypeReferenceExtractor(); + this.annotationExtractor = new AnnotationExtractor(); + } + + /** + * Returns all type references extracted during the last extractFromNode() call + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all annotations extracted from type parameters during the last extractFromNode() call + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns the TypeReferenceExtractor instance for direct extraction + */ + getTypeReferenceExtractor(): TypeReferenceExtractor { + return this.typeReferenceExtractor; + } + + /** + * Returns the AnnotationExtractor instance for direct extraction + */ + getAnnotationExtractor(): AnnotationExtractor { + return this.annotationExtractor; + } + + /** + * Extracts type parameters from a specific type declaration node + * @param typeDeclarationNode The specific type declaration node (class/interface/record) + * @param ownerTypeName Name of the owner type + * @param ownerQualifiedName Qualified name of the owner type + * @param filePath Path to the Java file + * @param startLine Start line of the type declaration + * @param typeRegistryHash Hash of the parent type (class/interface/etc) + */ + extractFromNode( + typeDeclarationNode: Parser.SyntaxNode, + ownerTypeName: string, + ownerQualifiedName: string, + filePath: string, + startLine: number, + typeRegistryHash: string, + packageName: string | null + ): TypeParameter[] { + const typeParameters: TypeParameter[] = []; + this.extractedTypeReferences = []; // Reset for each extraction + this.extractedAnnotations = []; // Reset for each extraction + this.annotationExtractor.resetExtractedArguments(); // Reset annotation arguments for each type declaration + + const typeParamsNode = typeDeclarationNode.childForFieldName('type_parameters'); + if (typeParamsNode) { + this.processTypeParametersList( + typeParamsNode, + ownerTypeName, + ownerQualifiedName, + filePath, + startLine, + typeRegistryHash, + packageName, + typeParameters + ); + } + + return typeParameters; + } + + /** + * @deprecated Use extractFromNode instead - this method is kept for interface compatibility + */ + extract(_filePath: string, _fileContent: string, _typeRegistryHash: string): TypeParameter[] { + console.warn('TypeParameterExtractor.extract() is deprecated. Use extractFromNode() instead.'); + return []; + } + + /** + * Process type_parameters node and extract individual type parameter + * Example: or or > + */ + private processTypeParametersList( + typeParamsNode: Parser.SyntaxNode, + ownerTypeName: string, + ownerQualifiedName: string, + filePath: string, + startLine: number, + typeRegistryHash: string, + packageName: string | null, + typeParameters: TypeParameter[] + ): void { + // First pass: collect all type parameter names + const declaredTypeParams = new Set(); + for (const child of typeParamsNode.children) { + if (child.type === 'type_parameter') { + const name = this.extractTypeParameterName(child); + if (name) { + declaredTypeParams.add(name); + } + } + } + + // Second pass: create TypeParameter entities and extract bounds + let position = 0; + for (const child of typeParamsNode.children) { + if (child.type === 'type_parameter') { + const name = this.extractTypeParameterName(child); + if (name) { + const typeParam = new TypeParameter( + name, + position, + ownerTypeName, + ownerQualifiedName, + filePath, + startLine, + typeRegistryHash + ); + typeParam.generateHash(); + typeParameters.push(typeParam); + + // Extract annotations from type parameter (e.g., class Box<@NonNull T>) + const annotations = this.annotationExtractor.extractFromTypeParameter( + child, + typeParam.getHash(), + typeRegistryHash + ); + this.extractedAnnotations.push(...annotations); + + // Extract annotations from type parameter bounds (e.g., ) + const boundAnnotations = this.annotationExtractor.extractFromTypeBound( + child, + typeParam.getHash(), + typeRegistryHash, + false // isMethodTypeParam = false for class-level type parameters + ); + this.extractedAnnotations.push(...boundAnnotations); + + // Note: Type references from annotation arguments are collected by TypeRegistryExtractor + // to avoid duplication. Only collect type references from bounds here. + + // Extract TypeReferences from bounds (if any) + const boundRefs = this.typeReferenceExtractor.extractFromBounds( + child, + typeRegistryHash, + typeParam.getHash(), + packageName, + declaredTypeParams + ); + this.extractedTypeReferences.push(...boundRefs); + + position++; + } + } + } + } + + /** + * Extract the name of a type parameter + * Example: In >, extracts "T" + */ + private extractTypeParameterName(node: Parser.SyntaxNode): string | null { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + return nameNode.text; + } + + // Fallback: look for type_identifier child + for (const child of node.children) { + if (child.type === 'type_identifier') { + return child.text; + } + } + + return null; + } +} diff --git a/parser/src/parsers/java/extractors/type-reference-extractor.ts b/parser/src/parsers/java/extractors/type-reference-extractor.ts new file mode 100644 index 000000000..456cab007 --- /dev/null +++ b/parser/src/parsers/java/extractors/type-reference-extractor.ts @@ -0,0 +1,1736 @@ +import Parser from 'tree-sitter'; + +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { TypeRefKind, TypeRefContext, ReferenceOwnerKind, WildcardVariance } from '@/enums'; +import { BaseExtractor } from '@/parsers/base-extractor'; +import { EntityUtils } from '@/utils/entity-utils'; +import { JavaTreeSitterUtils } from '@/utils/java/java-tree-sitter-utils'; + +/** + * Extracts TypeReference entities from Java type usage contexts across the codebase. + * + * This extractor handles type references in ALL contexts including: + * - **Type Parameter Bounds**: `>` + * - **Field Types**: `private List items;` + * - **Method Return Types**: `public Optional findById(...)` + * - **Method Parameters**: `void process(Map data)` + * - **Local Variables**: `List results = new ArrayList<>();` + * - **Superclass**: `extends AbstractService` + * - **Implemented Interfaces**: `implements Comparable, Serializable` + * - **Cast Expressions**: `(T) repository.findById(id)` + * - **Throws Clauses**: `throws ServiceException` + * + * Handles complex nested generics like: + * - Simple: `List` + * - Nested: `Map>` + * - Wildcards: `List`, `Map` + * - Complex: `Map>>` + * - Arrays: `String[]`, `List[]` + * + * The extractor recursively parses nested type structures, maintaining: + * - Parent-child relationships via `parentReferenceHash` + * - Depth tracking for nested generics (0 = top-level) + * - Position ordering for multiple references in same context + */ +export class TypeReferenceExtractor implements BaseExtractor { + + /** + * @deprecated Use extractFromBounds instead - this method is kept for interface compatibility + */ + extract(_filePath: string, _fileContent: string, _hash: string): TypeReference[] { + console.warn('TypeReferenceExtractor.extract() is deprecated. Use extractFromBounds() instead.'); + return []; + } + + /** + * Extracts TypeReferences from type parameter bounds + * Example: > creates 2 TypeReferences + * + * @param typeParameterNode The type_parameter syntax node + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param typeParameterHash Hash of the type parameter itself + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names for this type + */ + extractFromBounds( + typeParameterNode: Parser.SyntaxNode, + typeRegistryHash: string, + typeParameterHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Look for type_bound node + let typeBoundNode: Parser.SyntaxNode | null = null; + for (const child of typeParameterNode.children) { + if (child.type === 'type_bound') { + typeBoundNode = child; + break; + } + } + + if (!typeBoundNode) { + return references; + } + + let position = 0; + for (const child of typeBoundNode.children) { + // Skip punctuation like '&' + if (child.type === '&') { + continue; + } + + // Process each bound type + if (JavaTreeSitterUtils.isTypeNode(child)) { + const boundRefs = this.extractTypeReference( + child, + typeRegistryHash, + typeParameterHash, + TypeRefContext.TYPE_PARAM_BOUND, + ReferenceOwnerKind.TYPE, + typeRegistryHash, // owner is the type itself for TYPE_PARAM_BOUND + packageName, + position, + 0, // depth starts at 0 for top-level bound + undefined, // no parent for top-level bounds + declaredTypeParams + ); + references.push(...boundRefs); + position++; + } + } + + return references; + } + + /** + * Extracts TypeReferences from method-level type parameter bounds + * Example: creates 2 TypeReferences with METHOD_TYPE_PARAM_BOUND context + * + * @param typeParameterNode The type_parameter syntax node from method declaration + * @param typeRegistryHash Hash of the type containing this method + * @param methodRegistryHash Hash of the owning method + * @param methodTypeParameterHash Hash of the method type parameter itself + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names (class + method level) + */ + extractFromMethodTypeParameterBounds( + typeParameterNode: Parser.SyntaxNode, + typeRegistryHash: string, + methodRegistryHash: string, + methodTypeParameterHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Look for type_bound node + let typeBoundNode: Parser.SyntaxNode | null = null; + for (const child of typeParameterNode.children) { + if (child.type === 'type_bound') { + typeBoundNode = child; + break; + } + } + + if (!typeBoundNode) { + return references; + } + + // Detect the bound variance from the type_bound node (extends/super keyword is inside type_bound) + const variance = JavaTreeSitterUtils.extractWildcardVariance(typeBoundNode); + + let position = 0; + for (const child of typeBoundNode.children) { + // Skip punctuation like '&' and keywords like 'extends'/'super' + if (child.type === '&' || child.type === 'extends' || child.type === 'super') { + continue; + } + + // Process each bound type with variance information + // The variance (EXTENDS/SUPER) is passed to the bound type + if (JavaTreeSitterUtils.isTypeNode(child)) { + const boundRefs = this.extractTypeReference( + child, + typeRegistryHash, + methodTypeParameterHash, + TypeRefContext.METHOD_TYPE_PARAM_BOUND, + ReferenceOwnerKind.METHOD_PARAM, + methodRegistryHash, + packageName, + position, + 0, // depth starts at 0 for top-level bound + undefined, // no parent for top-level bounds + declaredTypeParams, + variance // pass the variance to be stored on the bound type + ); + references.push(...boundRefs); + position++; + } + } + + return references; + } + + /** + * Extracts TypeReferences from superclass (extends clause) + * Example: class MyClass extends BaseClass creates TypeReferences for BaseClass and T + * + * @param classNode The class_declaration or interface_declaration syntax node + * @param typeRegistryHash Hash of the type being declared + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names for this type + */ + extractFromSuperclass( + classNode: Parser.SyntaxNode, + typeRegistryHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Find superclass node - this is a wrapper containing 'extends' keyword and the actual type + const superclassNode = classNode.childForFieldName('superclass'); + if (!superclassNode) { + return references; + } + + // Find the actual type node within superclass (skip 'extends' keyword) + let typeNode: Parser.SyntaxNode | null = null; + for (const child of superclassNode.children) { + if (JavaTreeSitterUtils.isTypeNode(child)) { + typeNode = child; + break; + } + } + + if (!typeNode) { + return references; + } + + // Extract the type reference (will recursively handle generic arguments) + const superRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for extends clause + TypeRefContext.SUPER_TYPE, + ReferenceOwnerKind.TYPE, + typeRegistryHash, + packageName, + 0, // position + 0, // depth starts at 0 + undefined, // no parent + declaredTypeParams + ); + references.push(...superRefs); + + return references; + } + + /** + * Extract type references from implemented interfaces (implements clause). + * + * @param classNode The class/interface declaration node + * @param typeRegistryHash Hash of the declaring type + * @param packageName Package name context (currently unused) + * @param declaredTypeParams Set of all declared type parameter names for this type + */ + extractFromInterfaces( + classNode: Parser.SyntaxNode, + typeRegistryHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Find interfaces node - search by type since field name may vary + let interfacesNode: Parser.SyntaxNode | null = null; + for (const child of classNode.children) { + if (child.type === 'super_interfaces') { + interfacesNode = child; + break; + } + } + + if (!interfacesNode) { + return references; + } + + // Find type_list inside super_interfaces + let typeListNode: Parser.SyntaxNode | null = null; + for (const child of interfacesNode.children) { + if (child.type === 'type_list') { + typeListNode = child; + break; + } + } + + if (!typeListNode) { + return references; + } + + // Extract each interface type reference (skip commas) + let position = 0; + for (const child of typeListNode.children) { + if (JavaTreeSitterUtils.isTypeNode(child)) { + const interfaceRefs = this.extractTypeReference( + child, + typeRegistryHash, + '', // no typeParameterHash for implements clause + TypeRefContext.IMPLEMENTS_INTERFACE, + ReferenceOwnerKind.TYPE, + typeRegistryHash, + packageName, + position, + 0, // depth starts at 0 + undefined, // no parent + declaredTypeParams + ); + references.push(...interfaceRefs); + position++; + } + } + + return references; + } + + /** + * Extract type references from extended interfaces (extends clause for interfaces). + * + * @param interfaceNode The interface declaration node + * @param typeRegistryHash Hash of the declaring interface + * @param packageName Package name context (currently unused) + * @param declaredTypeParams Set of all declared type parameter names for this interface + */ + extractFromInterfaceExtension( + interfaceNode: Parser.SyntaxNode, + typeRegistryHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Find extends_interfaces node + let extendsInterfacesNode: Parser.SyntaxNode | null = null; + for (const child of interfaceNode.children) { + if (child.type === 'extends_interfaces') { + extendsInterfacesNode = child; + break; + } + } + + if (!extendsInterfacesNode) { + return references; + } + + // Find type_list inside extends_interfaces + let typeListNode: Parser.SyntaxNode | null = null; + for (const child of extendsInterfacesNode.children) { + if (child.type === 'type_list') { + typeListNode = child; + break; + } + } + + if (!typeListNode) { + return references; + } + + // Extract each extended interface type reference (skip commas) + let position = 0; + for (const child of typeListNode.children) { + if (JavaTreeSitterUtils.isTypeNode(child)) { + const extendedInterfaceRefs = this.extractTypeReference( + child, + typeRegistryHash, + '', // no typeParameterHash for extends clause + TypeRefContext.SUPER_TYPE, + ReferenceOwnerKind.TYPE, + typeRegistryHash, + packageName, + position, + 0, // depth starts at 0 + undefined, // no parent + declaredTypeParams + ); + references.push(...extendedInterfaceRefs); + position++; + } + } + + return references; + } + + /** + * Extracts TypeReferences from an anonymous class base type. + * This captures what interface/class the anonymous class implements/extends. + * + * Example: new Runnable() { ... } → creates TypeReference for Runnable with SUPER_TYPE context + * Example: new Comparator() { ... } → creates TypeReferences for Comparator and String + * + * @param creationNode The object_creation_expression node + * @param anonymousTypeHash Hash of the anonymous type being created + * @param packageName Package name for resolving types + */ + extractFromAnonymousClassBase( + creationNode: Parser.SyntaxNode, + anonymousTypeHash: string, + packageName: string | null + ): TypeReference[] { + const references: TypeReference[] = []; + + // Find the type node in the object creation expression + const typeNode = creationNode.childForFieldName('type'); + if (!typeNode) { + return references; + } + + // Extract with SUPER_TYPE context (anonymous class extends/implements this type) + const baseTypeRefs = this.extractTypeReference( + typeNode, + anonymousTypeHash, // The anonymous type is the type registry + '', // no typeParameterHash + TypeRefContext.SUPER_TYPE, + ReferenceOwnerKind.TYPE, + anonymousTypeHash, // owner is the anonymous type + packageName, + 0, // position + 0, // depth starts at 0 + undefined, // no parent + new Set() // anonymous classes don't have type parameters + ); + references.push(...baseTypeRefs); + + return references; + } + + /** + * Extracts TypeReferences from a method parameter type. + * Handles complex types including wildcards, nested generics, arrays. + * + * Example: Map> param + * Creates TypeReferences: + * - Map (PARAMETERIZED_TYPE, depth=0) + * - String (CLASS_TYPE, depth=1, parent=Map) + * - List (PARAMETERIZED_TYPE, depth=1, parent=Map) + * - ? extends Number (WILDCARD, EXTENDS, depth=2, parent=List) + * - Number (CLASS_TYPE, depth=3, parent=wildcard) + * + * @param paramTypeNode The type node from formal_parameter (e.g., generic_type, array_type) + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param methodParameterHash Hash of the MethodParameter entity + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names (class + method level) + */ + extractFromMethodParameter( + paramTypeNode: Parser.SyntaxNode, + typeRegistryHash: string, + methodParameterHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + const paramRefs = this.extractTypeReference( + paramTypeNode, + typeRegistryHash, + '', // no typeParameterHash for method parameters + TypeRefContext.METHOD_PARAM, + ReferenceOwnerKind.METHOD_PARAM, + methodParameterHash, // owner is the MethodParameter entity + packageName, + 0, // position (single parameter type, always 0) + 0, // depth starts at 0 for top-level parameter type + undefined, // no parent for top-level parameter type + declaredTypeParams + ); + references.push(...paramRefs); + + return references; + } + + /** + * Extracts TypeReferences from method return types. + * + * Example: `public Map> getMap()` + * Creates TypeReferences: + * - Map (PARAMETERIZED_TYPE, depth=0, owner=METHOD) + * - String (CLASS_TYPE, depth=1, parent=Map) + * - List (PARAMETERIZED_TYPE, depth=1, parent=Map) + * - Integer (CLASS_TYPE, depth=2, parent=List) + * + * @param returnTypeNode The return type node from method_declaration + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param methodRegistryHash Hash of the MethodRegistry entity + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names (class + method level) + */ + extractFromMethodReturnType( + returnTypeNode: Parser.SyntaxNode, + typeRegistryHash: string, + methodRegistryHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + const returnRefs = this.extractTypeReference( + returnTypeNode, + typeRegistryHash, + '', // no typeParameterHash for method return types + TypeRefContext.METHOD_RETURN, + ReferenceOwnerKind.METHOD, + methodRegistryHash, // owner is the MethodRegistry entity + packageName, + 0, // position (single return type, always 0) + 0, // depth starts at 0 for top-level return type + undefined, // no parent for top-level return type + declaredTypeParams + ); + references.push(...returnRefs); + + return references; + } + + /** + * Extract type references from permits clause (sealed types). + * + * @param typeNode The sealed class or interface declaration node + * @param typeRegistryHash Hash of the declaring sealed type + * @param packageName Package name context (currently unused) + * @param declaredTypeParams Set of all declared type parameter names for this type + */ + extractFromPermits( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Find permits node + let permitsNode: Parser.SyntaxNode | null = null; + for (const child of typeNode.children) { + if (child.type === 'permits') { + permitsNode = child; + break; + } + } + + if (!permitsNode) { + return references; + } + + // Find type_list inside permits + let typeListNode: Parser.SyntaxNode | null = null; + for (const child of permitsNode.children) { + if (child.type === 'type_list') { + typeListNode = child; + break; + } + } + + if (!typeListNode) { + return references; + } + + // Extract each permitted subtype type reference (skip commas) + let position = 0; + for (const child of typeListNode.children) { + if (JavaTreeSitterUtils.isTypeNode(child)) { + const permittedTypeRefs = this.extractTypeReference( + child, + typeRegistryHash, + '', // no typeParameterHash for permits clause + TypeRefContext.PERMITS, + ReferenceOwnerKind.TYPE, + typeRegistryHash, + packageName, + position, + 0, // depth starts at 0 + undefined, // no parent + declaredTypeParams + ); + references.push(...permittedTypeRefs); + position++; + } + } + + return references; + } + + /** + * Extracts TypeReferences from method/constructor throws clause. + * + * Example: `public void process() throws IOException, SQLException` + * Creates TypeReferences: + * - IOException (CLASS_TYPE, position=0, owner=METHOD) + * - SQLException (CLASS_TYPE, position=1, owner=METHOD) + * + * Also handles generic exception types: + * Example: ` void mayThrow() throws E` + * Creates TypeReference: + * - E (TYPE_VARIABLE, position=0, owner=METHOD) + * + * @param methodNode The method_declaration or constructor_declaration node + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param methodRegistryHash Hash of the MethodRegistry entity + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names (class + method level) + */ + extractFromThrowsClause( + methodNode: Parser.SyntaxNode, + typeRegistryHash: string, + methodRegistryHash: string, + packageName: string | null, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Find the throws node + let throwsNode: Parser.SyntaxNode | null = null; + for (const child of methodNode.children) { + if (child.type === 'throws') { + throwsNode = child; + break; + } + } + + if (!throwsNode) { + return references; + } + + // Extract each exception type from the throws clause + // The throws node contains exception types (type_identifier, generic_type, scoped_type_identifier) + let position = 0; + for (const child of throwsNode.children) { + // Skip 'throws' keyword and commas + if (child.type === 'throws' || child.type === ',') { + continue; + } + + // Process each exception type + if (JavaTreeSitterUtils.isTypeNode(child)) { + const exceptionRefs = this.extractTypeReference( + child, + typeRegistryHash, + '', // no typeParameterHash for throws clause + TypeRefContext.THROWS_CLAUSE, + ReferenceOwnerKind.METHOD, + methodRegistryHash, // owner is the MethodRegistry entity + packageName, + position, + 0, // depth starts at 0 for top-level exception type + undefined, // no parent for top-level exception type + declaredTypeParams + ); + references.push(...exceptionRefs); + position++; + } + } + + return references; + } + + /** + * Extracts TypeReferences from method invocation type arguments. + * + * Example: `Collections.emptyMap()` + * Creates TypeReferences: + * - String (CLASS_TYPE, position=0, context=METHOD_TYPE_ARGUMENT) + * - Integer (CLASS_TYPE, position=1, context=METHOD_TYPE_ARGUMENT) + * + * Also handles complex type arguments: + * Example: `obj.>method()` + * Creates TypeReferences: + * - List (PARAMETERIZED, depth=0, position=0) + * - ? extends Number (WILDCARD, depth=1, parent=List) + * - Number (CLASS, depth=2, parent=wildcard) + * + * @param typeArgumentsNode The type_arguments node from method_invocation + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (method invocation) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromMethodTypeArguments( + typeArgumentsNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + let position = 0; + for (const child of typeArgumentsNode.namedChildren) { + // Process each type argument + if (JavaTreeSitterUtils.isTypeNode(child)) { + const typeArgRefs = this.extractTypeReference( + child, + typeRegistryHash, + '', // no typeParameterHash for method type arguments + TypeRefContext.METHOD_TYPE_ARGUMENT, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (method invocation) + packageName, + position, + 0, // depth starts at 0 for top-level type argument + undefined, // no parent for top-level type argument + declaredTypeParams + ); + references.push(...typeArgRefs); + position++; + } + } + + return references; + } + + /** + * Extracts TypeReferences from instanceof expressions. + * + * Example: `obj instanceof Map>` + * Creates TypeReferences: + * - Map (PARAMETERIZED_TYPE, context=INSTANCEOF_TYPE, owner=EXPRESSION) + * - String (CLASS_TYPE, depth=1, parent=Map) + * - List (PARAMETERIZED_TYPE, depth=1, parent=Map) + * - Integer (CLASS_TYPE, depth=2, parent=List) + * + * @param typeNode The type node from instanceof_expression + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (instanceof expression) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromInstanceof( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for instanceof + TypeRefContext.INSTANCEOF_TYPE, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (instanceof expression) + packageName, + 0, // position + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from switch type patterns. + * + * Example: `case String s -> ...` + * Creates TypeReferences: + * - String (CLASS_TYPE, context=SWITCH_TYPE_PATTERN, owner=EXPRESSION) + * + * Example: `case List list -> ...` + * Creates TypeReferences: + * - List (PARAMETERIZED_TYPE, context=SWITCH_TYPE_PATTERN, owner=EXPRESSION) + * - String (CLASS_TYPE, depth=1, parent=List) + * + * @param typeNode The type node from type_pattern + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (switch expression) + * @param packageName Package name for resolving types + * @param casePosition Position of the case arm within the switch (for linking with pattern binding) + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromSwitchTypePattern( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + casePosition: number, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for switch type pattern + TypeRefContext.SWITCH_TYPE_PATTERN, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (switch expression) + packageName, + casePosition, // position matches case arm for linking with pattern binding + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from record pattern types. + * + * Example: `obj instanceof Person(String name, int age)` + * Creates TypeReferences: + * - Person (CLASS_TYPE, context=RECORD_PATTERN_TYPE, owner=EXPRESSION) + * + * @param typeNode The type identifier node from record_pattern + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (record pattern expression) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromRecordPattern( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for record pattern + TypeRefContext.RECORD_PATTERN_TYPE, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (record pattern expression) + packageName, + 0, // position + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from pattern binding types in record patterns. + * + * Example: `Person(String name, int age)` + * Creates TypeReferences: + * - String (CLASS_TYPE, context=PATTERN_BINDING_TYPE, owner=EXPRESSION) + * - int (PRIMITIVE, context=PATTERN_BINDING_TYPE, owner=EXPRESSION) + * + * @param typeNode The type node from record_pattern_component + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (parent record pattern) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromPatternBinding( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for pattern binding + TypeRefContext.PATTERN_BINDING_TYPE, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (record pattern) + packageName, + 0, // position + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from lambda parameter types. + * + * Example: `(String s) -> s.length()` + * Creates TypeReferences: + * - String (CLASS_TYPE, context=LAMBDA_PARAMETER_TYPE, owner=EXPRESSION) + * + * Example: `(List items) -> items.size()` + * Creates TypeReferences: + * - List (PARAMETERIZED_TYPE, context=LAMBDA_PARAMETER_TYPE, owner=EXPRESSION) + * - String (CLASS_TYPE, depth=1, parent=List) + * + * @param typeNode The type node from formal_parameter in lambda + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (lambda expression) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromLambdaParameter( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + position: number = 0, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for lambda parameter + TypeRefContext.LAMBDA_PARAMETER_TYPE, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (lambda expression) + packageName, + position, // parameter position + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from cast expressions. + * + * Example: `(String) obj` + * Creates TypeReferences: + * - String (CLASS_TYPE, context=CAST_EXPRESSION, owner=EXPRESSION) + * + * Example: `(List) items` + * Creates TypeReferences: + * - List (PARAMETERIZED_TYPE, context=CAST_EXPRESSION, owner=EXPRESSION) + * - String (CLASS_TYPE, depth=1, parent=List) + * + * @param typeNode The type node from cast_expression + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (cast expression) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromCast( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set(), + position: number = 0 // position for intersection types (0 for first type, 1 for second, etc.) + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for cast + TypeRefContext.CAST_EXPRESSION, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (cast expression) + packageName, + position, // position for intersection types + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from object creation expressions. + * + * Example: `new HashMap>()` + * Creates TypeReferences: + * - HashMap (PARAMETERIZED_TYPE, context=OBJECT_CREATION_TYPE, owner=EXPRESSION) + * - String (CLASS_TYPE, depth=1, parent=HashMap) + * - List (PARAMETERIZED_TYPE, depth=1, parent=HashMap) + * - Integer (CLASS_TYPE, depth=2, parent=List) + * + * @param typeNode The type node from object_creation_expression + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (object creation expression) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromObjectCreation( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for object creation + TypeRefContext.OBJECT_CREATION_TYPE, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (object creation expression) + packageName, + 0, // position + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from array creation expressions. + * + * Example: `new String[3]`, `new int[] { 1, 2, 3 }`, `new int[2][3]` + * Creates TypeReferences for the element type: + * - String (CLASS_TYPE, context=ARRAY_CREATION_TYPE, owner=EXPRESSION) + * - int (PRIMITIVE, context=ARRAY_CREATION_TYPE, owner=EXPRESSION) + * + * @param typeNode The type node from array_creation_expression (element type) + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (array creation expression) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromArrayCreation( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + typeNode, + typeRegistryHash, + '', // no typeParameterHash for array creation + TypeRefContext.ARRAY_CREATION_TYPE, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (array creation expression) + packageName, + 0, // position + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from generic constructor type arguments. + * + * Example: `new GenericCtor("test")` + * The `` is a constructor type argument, separate from the class type. + * + * @param typeArgsNode The type_arguments node from object_creation_expression + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromConstructorTypeArguments( + typeArgsNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + let position = 0; + for (const child of typeArgsNode.children) { + if (JavaTreeSitterUtils.isTypeNode(child)) { + const typeRefs = this.extractTypeReference( + child, + typeRegistryHash, + '', + TypeRefContext.METHOD_TYPE_ARGUMENT, // Reuse METHOD_TYPE_ARGUMENT for constructor type args + ReferenceOwnerKind.EXPRESSION, + expressionHash, + packageName, + position, + 0, + undefined, + declaredTypeParams + ); + references.push(...typeRefs); + position++; + } + } + + return references; + } + + /** + * Extracts TypeReferences from method reference qualifier types. + * + * This handles type-based qualifiers in method references where the qualifier + * is a type (not an expression). These include: + * - Simple types: `String::valueOf`, `Integer::parseInt` + * - Array types: `String[]::new`, `int[]::new` + * - Generic types: `List::new`, `Map::new` + * - Scoped types: `Map.Entry::comparingByKey` + * + * Example: `String[]::new` + * Creates TypeReference: + * - String[] (ARRAY, context=METHOD_REFERENCE_QUALIFIER, owner=EXPRESSION) + * + * Example: `List::new` + * Creates TypeReferences: + * - List (PARAMETERIZED, depth=0, context=METHOD_REFERENCE_QUALIFIER) + * - String (CLASS, depth=1, parent=List) + * + * @param qualifierNode The type node that is the qualifier of the method reference + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param expressionHash Hash of the ExpressionReference (method reference expression) + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromMethodReferenceQualifier( + qualifierNode: Parser.SyntaxNode, + typeRegistryHash: string, + expressionHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeRefs = this.extractTypeReference( + qualifierNode, + typeRegistryHash, + '', // no typeParameterHash for method reference qualifiers + TypeRefContext.METHOD_REFERENCE_QUALIFIER, + ReferenceOwnerKind.EXPRESSION, + expressionHash, // owner is the expression (method reference expression) + packageName, + 0, // position + 0, // depth starts at 0 + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...typeRefs); + + return references; + } + + /** + * Extracts TypeReferences from field types. + * + * Example: `private Map> data;` + * Creates TypeReferences: + * - Map (PARAMETERIZED_TYPE, depth=0, owner=FIELD) + * - String (CLASS_TYPE, depth=1, parent=Map) + * - List (PARAMETERIZED_TYPE, depth=1, parent=Map) + * - Integer (CLASS_TYPE, depth=2, parent=List) + * + * @param fieldTypeNode The type node from field_declaration + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param fieldRegistryHash Hash of the FieldRegistry entity + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromField( + fieldTypeNode: Parser.SyntaxNode, + typeRegistryHash: string, + fieldRegistryHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + + const fieldRefs = this.extractTypeReference( + fieldTypeNode, + typeRegistryHash, + '', // no typeParameterHash for fields + TypeRefContext.FIELD_TYPE, + ReferenceOwnerKind.FIELD, + fieldRegistryHash, // owner is the FieldRegistry entity + packageName, + 0, // position (single field type, always 0) + 0, // depth starts at 0 for top-level field type + undefined, // no parent for top-level field type + declaredTypeParams + ); + references.push(...fieldRefs); + + return references; + } + + /** + * Extracts type references from a local variable type declaration. + * + * Example: `List items = new ArrayList<>();` + * Returns TypeReferences for: List, String + * + * @param localVarTypeNode The AST node of the local variable type + * @param typeRegistryHash Hash of the owning type (class/interface) + * @param localVariableHash Hash of the LocalVariableRegistry entity + * @param packageName Package name for resolving types + * @param declaredTypeParams Set of all declared type parameter names + */ + extractFromLocalVariable( + localVarTypeNode: Parser.SyntaxNode, + typeRegistryHash: string, + localVariableHash: string, + packageName: string | null, + declaredTypeParams: Set = new Set(), + position: number = 0 + ): TypeReference[] { + const references: TypeReference[] = []; + + const localVarRefs = this.extractTypeReference( + localVarTypeNode, + typeRegistryHash, + '', // no typeParameterHash for local variables + TypeRefContext.LOCAL_VARIABLE, + ReferenceOwnerKind.LOCAL_VARIABLE, + localVariableHash, // owner is the LocalVariableRegistry entity + packageName, + position, // position among sibling types (e.g., multi-catch) + 0, // depth starts at 0 for top-level type + undefined, // no parent for top-level type + declaredTypeParams + ); + references.push(...localVarRefs); + + return references; + } + + /** + * Recursively extracts a type reference and all its nested children. + * + * **Core extraction method** used by all context-specific extractors: + * - `extractFromBounds()` for type parameter bounds + * - `extractFromSuperclass()` for extends clauses (SUPER_TYPE) + * - `extractFromInterfaces()` for implements clauses (IMPLEMENTS_INTERFACE) + * - `extractFromField()` for field types + * - Future: `extractFromMethod()` for method return/parameter types + * + * This method handles nested generics by: + * 1. Creating a TypeReference for the current type + * 2. Recursively processing any generic arguments + * 3. Maintaining parent-child relationships via parentReferenceHash + * 4. Tracking depth for nested structures + * + * Example: List + * - Depth 0: PARAMETERIZED "List" + * - Depth 1: WILDCARD "? extends" (parent = List) + * - Depth 2: TYPE_VARIABLE "T" (parent = wildcard) + * + * @param typeNode The syntax node representing the type + * @param typeRegistryHash Hash of the owning TypeRegistry + * @param typeParameterHash Hash of the associated TypeParameter (if applicable) + * @param context The context where this type reference appears (TYPE_PARAM_BOUND, FIELD_TYPE, etc.) + * @param ownerKind The kind of entity that owns this reference (TYPE, FIELD, METHOD, etc.) + * @param ownerHash Hash of the specific owner entity + * @param packageName Package name for resolving type references + * @param position Position among sibling references in the same context + * @param depth Nesting depth (0 = top-level, increases for each level of nesting) + * @param parentReferenceHash Hash of the parent TypeReference (for nested generics) + * @param declaredTypeParams Set of declared type parameter names + * @param boundVariance Optional variance for bound contexts (EXTENDS/SUPER) - only applies to top-level bound types + */ + private extractTypeReference( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + typeParameterHash: string, + context: TypeRefContext, + ownerKind: ReferenceOwnerKind, + ownerHash: string, + packageName: string | null, + position: number, + depth: number, + parentReferenceHash: string | undefined, + declaredTypeParams: Set, + boundVariance?: WildcardVariance + ): TypeReference[] { + const references: TypeReference[] = []; + + // Determine the kind and extract accordingly + const kind = JavaTreeSitterUtils.determineTypeKind(typeNode, declaredTypeParams); + + switch (kind) { + case TypeRefKind.PARAMETERIZED: + references.push(...this.extractParameterizedType( + typeNode, typeRegistryHash, typeParameterHash, context, ownerKind, + ownerHash, packageName, position, depth, parentReferenceHash, declaredTypeParams, boundVariance + )); + break; + + case TypeRefKind.WILDCARD: + references.push(...this.extractWildcardType( + typeNode, typeRegistryHash, typeParameterHash, context, ownerKind, + ownerHash, packageName, position, depth, parentReferenceHash, declaredTypeParams + )); + break; + + case TypeRefKind.TYPE_VARIABLE: + references.push(this.extractTypeVariable( + typeNode, typeRegistryHash, typeParameterHash, context, ownerKind, + ownerHash, position, depth, parentReferenceHash, boundVariance + )); + break; + + case TypeRefKind.ARRAY: + const arrayRefs = this.extractArrayType( + typeNode, typeRegistryHash, typeParameterHash, context, ownerKind, + ownerHash, packageName, position, depth, parentReferenceHash, declaredTypeParams + ); + references.push(...arrayRefs); + break; + + case TypeRefKind.PRIMITIVE: + const primitiveRef = this.extractPrimitiveType( + typeNode, typeRegistryHash, typeParameterHash, context, ownerKind, + ownerHash, packageName, position, depth, parentReferenceHash + ); + if (primitiveRef) references.push(primitiveRef); + break; + + case TypeRefKind.CLASS: + default: + const classRefs = this.extractClassType( + typeNode, typeRegistryHash, typeParameterHash, context, ownerKind, + ownerHash, packageName, position, depth, parentReferenceHash, declaredTypeParams, boundVariance + ); + references.push(...classRefs); + break; + } + + return references; + } + + /** + * Extracts a parameterized type (generic type with arguments). + * + * **Used across all contexts**: type parameter bounds, field types, method signatures, etc. + * + * Examples: + * - Type parameter bound: `>` + * - Field type: `private Map cache;` + * - Method return: `public Optional find(...)` + * + * This creates: + * 1. Parent PARAMETERIZED reference for the generic type + * 2. Child references for each type argument (recursively) + */ + private extractParameterizedType( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + typeParameterHash: string, + context: TypeRefContext, + ownerKind: ReferenceOwnerKind, + ownerHash: string, + packageName: string | null, + position: number, + depth: number, + parentReferenceHash: string | undefined, + declaredTypeParams: Set, + boundVariance?: WildcardVariance + ): TypeReference[] { + const references: TypeReference[] = []; + + const typeName = JavaTreeSitterUtils.extractTypeName(typeNode); + if (!typeName) return references; + const completeTypeName = JavaTreeSitterUtils.extractCompleteTypeName(typeNode) || typeName; + + // Build the parent parameterized type + const builder = TypeReference.builder( + typeRegistryHash, + TypeRefKind.PARAMETERIZED, + context, + ownerKind, + ownerHash + ) + .typeParameter(typeParameterHash) + .setTypeName(typeName) + .setCompleteTypeName(completeTypeName) + .positionAndDepth(position, depth); + + if (parentReferenceHash) { + builder.parent(parentReferenceHash); + } + + // Add variance for bound contexts (e.g., > where List has EXTENDS variance) + if (boundVariance) { + builder.wildcard(boundVariance); + } + + const parentRef = builder.build(); + references.push(parentRef); + + // Unwrap annotated_type to find the actual generic_type containing type_arguments + const actualType = JavaTreeSitterUtils.unwrapAnnotatedType(typeNode); + + // Check for qualified inner class types like Outer.Inner + // The scoped_type_identifier may contain a nested generic_type for the qualifier + const scopedTypeId = actualType.children.find(c => c.type === 'scoped_type_identifier'); + if (scopedTypeId) { + // Look for nested generic_type in the scoped identifier (e.g., Outer in Outer.Inner) + const nestedGenericType = scopedTypeId.children.find(c => c.type === 'generic_type'); + if (nestedGenericType) { + // Recursively extract the qualifier type (Outer) + const qualifierRefs = this.extractTypeReference( + nestedGenericType, + typeRegistryHash, + typeParameterHash, + context, + ownerKind, + ownerHash, + packageName, + 0, // qualifier position + depth + 1, // Increase depth for qualifier + parentRef.getHash(), // Parent is the inner type + declaredTypeParams + ); + references.push(...qualifierRefs); + } + } + + // Extract type arguments recursively + const typeArgsNode = actualType.children.find(c => c.type === 'type_arguments'); + if (typeArgsNode) { + let argPosition = 0; + for (const child of typeArgsNode.children) { + if (JavaTreeSitterUtils.isTypeNode(child) || child.type === 'wildcard') { + const childRefs = this.extractTypeReference( + child, + typeRegistryHash, + typeParameterHash, + context, + ownerKind, + ownerHash, + packageName, + argPosition, + depth + 1, // Increase depth for children + parentRef.getHash(), // Set parent + declaredTypeParams + ); + references.push(...childRefs); + argPosition++; + } + } + } + + return references; + } + + /** + * Extracts a wildcard type (?, ? extends T, ? super T). + * + * **Used across all contexts**: type parameter bounds, field types, method signatures, etc. + * + * Creates a WILDCARD reference with appropriate variance (UNBOUNDED, EXTENDS, SUPER). + */ + private extractWildcardType( + wildcardNode: Parser.SyntaxNode, + typeRegistryHash: string, + typeParameterHash: string, + context: TypeRefContext, + ownerKind: ReferenceOwnerKind, + ownerHash: string, + packageName: string | null, + position: number, + depth: number, + parentReferenceHash: string | undefined, + declaredTypeParams: Set + ): TypeReference[] { + const references: TypeReference[] = []; + + // Determine variance + const variance = JavaTreeSitterUtils.extractWildcardVariance(wildcardNode); + + // Build wildcard reference + const builder = TypeReference.builder( + typeRegistryHash, + TypeRefKind.WILDCARD, + context, + ownerKind, + ownerHash + ) + .typeParameter(typeParameterHash) + .wildcard(variance) + .positionAndDepth(position, depth); + + if (parentReferenceHash) { + builder.parent(parentReferenceHash); + } + + const wildcardRef = builder.build(); + references.push(wildcardRef); + + // If bounded wildcard, extract the bound type + if (variance !== WildcardVariance.UNBOUNDED) { + const boundNode = JavaTreeSitterUtils.findBoundNode(wildcardNode); + if (boundNode) { + const boundRefs = this.extractTypeReference( + boundNode, + typeRegistryHash, + typeParameterHash, + context, + ownerKind, + ownerHash, + packageName, + position, // inherit parent wildcard's position for better traceability + depth + 1, + wildcardRef.getHash(), + declaredTypeParams + ); + references.push(...boundRefs); + } + } + + return references; + } + + /** + * Extracts a type variable reference (T, K, V, etc.). + * + * **Used across all contexts**: type parameter bounds, field types, method signatures, etc. + */ + private extractTypeVariable( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + typeParameterHash: string, + context: TypeRefContext, + ownerKind: ReferenceOwnerKind, + ownerHash: string, + position: number, + depth: number, + parentReferenceHash: string | undefined, + boundVariance?: WildcardVariance + ): TypeReference { + const typeVarName = EntityUtils.normalizeWhitespace(typeNode.text); + + const builder = TypeReference.builder( + typeRegistryHash, + TypeRefKind.TYPE_VARIABLE, + context, + ownerKind, + ownerHash + ) + .typeParameter(typeParameterHash) + .typeVariable(typeVarName) + .positionAndDepth(position, depth); + + if (parentReferenceHash) { + builder.parent(parentReferenceHash); + } + + // Add variance for bound contexts (e.g., where T has EXTENDS variance) + if (boundVariance) { + builder.wildcard(boundVariance); + } + + return builder.build(); + } + + /** + * Extracts an array type reference. + * + * **Used across all contexts**: type parameter bounds, field types, method signatures, etc. + * + * Examples: `String[]`, `T[]`, `List[]` + */ + private extractArrayType( + arrayNode: Parser.SyntaxNode, + typeRegistryHash: string, + typeParameterHash: string, + context: TypeRefContext, + ownerKind: ReferenceOwnerKind, + ownerHash: string, + packageName: string | null, + position: number, + depth: number, + parentReferenceHash: string | undefined, + declaredTypeParams: Set = new Set() + ): TypeReference[] { + const references: TypeReference[] = []; + // Unwrap annotated_type (e.g., @Nullable Annotation[]) to get the actual array_type node + const actualArrayNode = JavaTreeSitterUtils.unwrapAnnotatedType(arrayNode); + const dimensions = JavaTreeSitterUtils.countArrayDimensions(actualArrayNode); + const elementTypeNode = JavaTreeSitterUtils.findArrayElementType(actualArrayNode); + + // For array types, we need to get the base type name (not the full generic text) + let elementTypeName = 'unknown'; + if (elementTypeNode) { + if (elementTypeNode.type === 'generic_type') { + // For generic element types like List, get just the base type name + const baseTypeNode = elementTypeNode.children.find( + c => c.type === 'type_identifier' || c.type === 'scoped_type_identifier' + ); + elementTypeName = EntityUtils.normalizeWhitespace(baseTypeNode ? baseTypeNode.text : elementTypeNode.text); + } else { + elementTypeName = EntityUtils.normalizeWhitespace(elementTypeNode.text); + } + } + + // For arrays, completeTypeName matches elementTypeName (arrays don't have scoped qualifiers) + const builder = TypeReference.builder( + typeRegistryHash, + TypeRefKind.ARRAY, + context, + ownerKind, + ownerHash + ) + .typeParameter(typeParameterHash) + .setTypeName(elementTypeName) + .setCompleteTypeName(elementTypeName) + .array(dimensions) + .positionAndDepth(position, depth); + + if (parentReferenceHash) { + builder.parent(parentReferenceHash); + } + + const arrayRef = builder.build(); + references.push(arrayRef); + + // If the element type is a generic type, recursively extract its type arguments + if (elementTypeNode && elementTypeNode.type === 'generic_type') { + const typeArgsNode = elementTypeNode.children.find(c => c.type === 'type_arguments'); + if (typeArgsNode) { + let argPosition = 0; + for (const child of typeArgsNode.namedChildren) { + const childRefs = this.extractTypeReference( + child, + typeRegistryHash, + typeParameterHash, + context, + ownerKind, + ownerHash, + packageName, + argPosition, + depth + 1, + arrayRef.getHash(), + declaredTypeParams + ); + references.push(...childRefs); + argPosition++; + } + } + } + + return references; + } + + /** + * Extracts a primitive type reference. + * + * **Used across all contexts**: field types, method signatures, local variables, etc. + * + * Examples: `int`, `boolean`, `double` + */ + private extractPrimitiveType( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + typeParameterHash: string, + context: TypeRefContext, + ownerKind: ReferenceOwnerKind, + ownerHash: string, + _packageName: string | null, + position: number, + depth: number, + parentReferenceHash: string | undefined + ): TypeReference | null { + const typeName = EntityUtils.normalizeWhitespace(typeNode.text); + if (!typeName) return null; + + // For primitives, completeTypeName is the same as typeName + const builder = TypeReference.builder( + typeRegistryHash, + TypeRefKind.PRIMITIVE, + context, + ownerKind, + ownerHash + ) + .typeParameter(typeParameterHash) + .setTypeName(typeName) + .setCompleteTypeName(typeName) + .positionAndDepth(position, depth); + + if (parentReferenceHash) { + builder.parent(parentReferenceHash); + } + + return builder.build(); + } + + /** + * Extracts a simple class/interface type reference (non-generic). + * + * **Used across all contexts**: type parameter bounds, field types, method signatures, etc. + * + * Examples: `String`, `Number`, `Serializable` + */ + private extractClassType( + typeNode: Parser.SyntaxNode, + typeRegistryHash: string, + typeParameterHash: string, + context: TypeRefContext, + ownerKind: ReferenceOwnerKind, + ownerHash: string, + _packageName: string | null, + position: number, + depth: number, + parentReferenceHash: string | undefined, + _declaredTypeParams: Set, + boundVariance?: WildcardVariance + ): TypeReference[] { + const references: TypeReference[] = []; + const typeName = JavaTreeSitterUtils.extractTypeName(typeNode); + if (!typeName) return references; + const completeTypeName = JavaTreeSitterUtils.extractCompleteTypeName(typeNode) || typeName; + + const builder = TypeReference.builder( + typeRegistryHash, + TypeRefKind.CLASS, + context, + ownerKind, + ownerHash + ) + .typeParameter(typeParameterHash) + .setTypeName(typeName) + .setCompleteTypeName(completeTypeName) + .positionAndDepth(position, depth); + + if (parentReferenceHash) { + builder.parent(parentReferenceHash); + } + + // Add variance for bound contexts (e.g., where Number has EXTENDS variance) + if (boundVariance) { + builder.wildcard(boundVariance); + } + + const parentRef = builder.build(); + references.push(parentRef); + + // NOTE: We intentionally do NOT recursively extract qualifier parts of scoped_type_identifier. + // For package-qualified types like java.util.Random, "java" and "util" are package names, + // not types, and should not be extracted as type references. + // Only the actual class name (e.g., "Random") is a type reference. + + return references; + } + +} diff --git a/parser/src/parsers/java/extractors/type-registry-extractor.ts b/parser/src/parsers/java/extractors/type-registry-extractor.ts new file mode 100644 index 000000000..ad4f94050 --- /dev/null +++ b/parser/src/parsers/java/extractors/type-registry-extractor.ts @@ -0,0 +1,1095 @@ +import * as path from 'path'; + +import Parser from 'tree-sitter'; + +import { MethodParameter } from '@/analysis-methods/java/MethodParameter'; +import { MethodRegistry } from '@/analysis-methods/java/MethodRegistry'; +import { MethodTypeParameter } from '@/analysis-methods/java/MethodTypeParameter'; +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { EnumConstant } from '@/analysis-types/java/EnumConstant'; +import { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +import { FieldRegistry } from '@/analysis-types/java/FieldRegistry'; +import { LocalVariableRegistry } from '@/analysis-types/java/LocalVariableRegistry'; +import { BlockRegistry } from '@/analysis-types/java/BlockRegistry'; +import { CommentRegistry } from '@/analysis-types/java/CommentRegistry'; +import { ModuleDirective } from '@/analysis-types/java/ModuleDirective'; +import { ModuleRegistry } from '@/analysis-types/java/ModuleRegistry'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeParameter } from '@/analysis-types/java/TypeParameter'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { TypeRegistry } from '@/analysis-types/java/TypeRegistry'; +import { TypeAccess, TypeCategory, TypeModifier, TypePlacement } from '@/enums'; +import { BaseExtractor } from '@/parsers/base-extractor'; +import { AnnotationExtractor } from '@/parsers/java/extractors/annotation-extractor'; +import { EnumConstantExtractor } from '@/parsers/java/extractors/enum-constant-extractor'; +import { FieldExtractor } from '@/parsers/java/extractors/field-extractor'; +import { ModuleExtractor } from '@/parsers/java/extractors/module-extractor'; +import { AnonymousClassInfo } from '@/parsers/java/extractors/expression-reference-extractor'; +import { TypeMethodExtractor } from '@/parsers/java/extractors/type-method-extractor'; +import { TypeParameterExtractor } from '@/parsers/java/extractors/type-parameter-extractor'; +import { CommentExtractor } from '@/parsers/java/extractors/comment-extractor'; +import { JavaParser } from '@/parsers/java/java-parser'; + +/** + * Extracts TypeRegistry entities from Java source files using tree-sitter + */ +export class TypeRegistryExtractor implements BaseExtractor { + private javaParser: JavaParser; + private typeParameterExtractor: TypeParameterExtractor; + private annotationExtractor: AnnotationExtractor; + private methodExtractor: TypeMethodExtractor; + private enumConstantExtractor: EnumConstantExtractor; + private fieldExtractor: FieldExtractor; + private moduleExtractor: ModuleExtractor; + private extractedTypeParameters: TypeParameter[] = []; + private extractedTypeReferences: TypeReference[] = []; + private extractedAnnotations: TypeAnnotation[] = []; + private extractedAnnotationArguments: AnnotationArgumentReference[] = []; + private extractedMethods: MethodRegistry[] = []; + private extractedMethodParameters: MethodParameter[] = []; + private extractedMethodTypeParameters: MethodTypeParameter[] = []; + private extractedEnumConstants: EnumConstant[] = []; + private extractedFields: FieldRegistry[] = []; + private extractedExpressions: ExpressionReference[] = []; + private extractedLocalVariables: LocalVariableRegistry[] = []; + private extractedBlocks: BlockRegistry[] = []; + private extractedComments: CommentRegistry[] = []; + private extractedModules: ModuleRegistry[] = []; + private extractedModuleDirectives: ModuleDirective[] = []; + private commentExtractor: CommentExtractor; + + constructor() { + this.javaParser = new JavaParser(); + this.typeParameterExtractor = new TypeParameterExtractor(); + this.annotationExtractor = new AnnotationExtractor(); + this.methodExtractor = new TypeMethodExtractor(); + this.enumConstantExtractor = new EnumConstantExtractor(); + this.fieldExtractor = new FieldExtractor(); + this.moduleExtractor = new ModuleExtractor(); + this.commentExtractor = new CommentExtractor(); + } + + /** + * Returns all type parameters extracted during the last extract() call + */ + getExtractedTypeParameters(): TypeParameter[] { + return this.extractedTypeParameters; + } + + /** + * Returns all type references extracted during the last extract() call + */ + getExtractedTypeReferences(): TypeReference[] { + return this.extractedTypeReferences; + } + + /** + * Returns all annotations extracted during the last extract() call + */ + getExtractedAnnotations(): TypeAnnotation[] { + return this.extractedAnnotations; + } + + /** + * Returns all annotation arguments extracted during the last extract() call + */ + getExtractedAnnotationArguments(): AnnotationArgumentReference[] { + return this.extractedAnnotationArguments; + } + + /** + * Returns all methods extracted during the last extract() call + */ + getExtractedMethods(): MethodRegistry[] { + return this.extractedMethods; + } + + /** + * Returns all method parameters extracted during the last extract() call + */ + getExtractedMethodParameters(): MethodParameter[] { + return this.extractedMethodParameters; + } + + /** + * Returns all method type parameters extracted during the last extract() call + */ + getExtractedMethodTypeParameters(): MethodTypeParameter[] { + return this.extractedMethodTypeParameters; + } + + /** + * Returns all enum constants extracted during the last extract() call + */ + getExtractedEnumConstants(): EnumConstant[] { + return this.extractedEnumConstants; + } + + /** + * Returns all fields extracted during the last extract() call + */ + getExtractedFields(): FieldRegistry[] { + return this.extractedFields; + } + + /** + * Returns all expressions extracted during the last extract() call + */ + getExtractedExpressions(): ExpressionReference[] { + return this.extractedExpressions; + } + + /** + * Returns all local variables extracted during the last extract() call + */ + getExtractedLocalVariables(): LocalVariableRegistry[] { + return this.extractedLocalVariables; + } + + /** + * Returns all blocks extracted during the last extract() call + */ + getExtractedBlocks(): BlockRegistry[] { + return this.extractedBlocks; + } + + /** + * Returns all comments extracted during the last extract() call + */ + /** + * Returns the module declaration extracted during the last extract() call, if the file was a + * module-info.java. At most one per file. + */ + getExtractedModules(): ModuleRegistry[] { + return this.extractedModules; + } + + /** + * Returns the module directives extracted during the last extract() call + */ + getExtractedModuleDirectives(): ModuleDirective[] { + return this.extractedModuleDirectives; + } + + getExtractedComments(): CommentRegistry[] { + return this.extractedComments; + } + + /** + * Extracts type definitions (classes, interfaces, enums, annotations) from Java source + */ + extract(filePath: string, fileContent: string, serviceVersionHash: string): TypeRegistry[] { + const types: TypeRegistry[] = []; + this.extractedTypeParameters = []; // Reset for each file + this.extractedTypeReferences = []; // Reset for each file + this.extractedAnnotations = []; // Reset for each file + this.extractedAnnotationArguments = []; // Reset for each file + this.extractedMethods = []; // Reset for each file + this.extractedMethodParameters = []; // Reset for each file + this.extractedMethodTypeParameters = []; // Reset for each file + this.extractedEnumConstants = []; // Reset for each file + this.extractedFields = []; // Reset for each file + this.extractedExpressions = []; // Reset for each file + this.extractedLocalVariables = []; // Reset for each file + this.extractedBlocks = []; // Reset for each file + this.extractedComments = []; // Reset for each file + this.extractedModules = []; // Reset for each file + this.extractedModuleDirectives = []; // Reset for each file + + try { + if (!fileContent || typeof fileContent !== 'string') { + console.warn(`Skipping ${filePath}: invalid content`); + return types; + } + + const tree = this.javaParser.parse(fileContent); + const rootNode = this.javaParser.getRootNode(tree); + + const basePath = this.extractBasePath(filePath); + const fileName = path.basename(filePath); + + // A module-info.java declares a module and no types, so this is the only thing in it. + const module = this.moduleExtractor.extract(rootNode, filePath, serviceVersionHash); + if (module) { + this.extractedModules.push(module); + this.extractedModuleDirectives.push(...this.moduleExtractor.getExtractedDirectives()); + } + + // Extract imports and package at file level + const { importMap, hasStarImports } = this.extractImports(rootNode); + const packageName = this.extractPackageDeclaration(rootNode); + + this.extractTypes(rootNode, filePath, basePath, fileName, serviceVersionHash, packageName, importMap, hasStarImports, types); + + // Extract comments after all entities are available for position mapping + const positionToHash = this.buildPositionToHashMap(types); + const comments = this.commentExtractor.extract(rootNode, filePath, positionToHash); + this.extractedComments.push(...comments); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn(`Failed to parse ${filePath}: ${errorMessage}`); + } + + return types; + } + + /** + * Recursively extracts type definitions from syntax nodes + */ + private extractTypes( + node: Parser.SyntaxNode, + filePath: string, + basePath: string, + fileName: string, + serviceVersionHash: string, + packageName: string | null, + importMap: Map, + hasStarImports: boolean, + types: TypeRegistry[] + ): void { + if (this.isTypeDeclaration(node)) { + const typeRegistry = this.createTypeRegistry( + node, + filePath, + basePath, + fileName, + serviceVersionHash + ); + if (typeRegistry) { + types.push(typeRegistry); + + // Package name already extracted at file level + + // Extract annotations from this type declaration + // Reset the extractor's argument array before each type to prevent accumulation + this.annotationExtractor.resetExtractedArguments(); + + const isAnnotationDeclaration = node.type === 'annotation_type_declaration'; + const annotations = this.annotationExtractor.extractFromTypeDeclaration( + node, + typeRegistry.getHash(), + isAnnotationDeclaration + ); + this.extractedAnnotations.push(...annotations); + + // Collect annotation arguments from the extractor + const annotationArgs = this.annotationExtractor.getExtractedArguments(); + this.extractedAnnotationArguments.push(...annotationArgs); + + // Collect type references from annotation arguments + const annotationTypeRefs = this.annotationExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...annotationTypeRefs); + + // Extract type parameters from this specific type declaration node + const typeParams = this.typeParameterExtractor.extractFromNode( + node, + typeRegistry.getName(), + typeRegistry.getQualifiedName(), + typeRegistry.getFilePath(), + typeRegistry.getStartLine(), + typeRegistry.getHash(), + packageName + ); + this.extractedTypeParameters.push(...typeParams); + + // Collect annotations from type parameters + const typeParamAnnotations = this.typeParameterExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...typeParamAnnotations); + + // Collect type references from type parameter bounds + const typeRefs = this.typeParameterExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...typeRefs); + + // Collect type references from type parameter annotation arguments + const typeParamAnnotationTypeRefs = this.typeParameterExtractor.getAnnotationExtractor().getExtractedTypeReferences(); + this.extractedTypeReferences.push(...typeParamAnnotationTypeRefs); + + // Collect annotation arguments from type parameter annotations + const typeParamAnnotationArgs = this.typeParameterExtractor.getAnnotationExtractor().getExtractedArguments(); + this.extractedAnnotationArguments.push(...typeParamAnnotationArgs); + + // Collect declared type parameter names for proper classification + const declaredTypeParams = new Set(typeParams.map(tp => tp.getName())); + + const typeRefExtractor = this.typeParameterExtractor.getTypeReferenceExtractor(); + + // Extract type references based on type category + if (node.type === 'interface_declaration') { + // For interfaces: extract from extends clause (interface extending other interfaces) + const interfaceExtensionRefs = typeRefExtractor.extractFromInterfaceExtension( + node, + typeRegistry.getHash(), + packageName, + declaredTypeParams + ); + this.extractedTypeReferences.push(...interfaceExtensionRefs); + } else { + // For classes: extract from superclass (extends clause) + const superRefs = typeRefExtractor.extractFromSuperclass( + node, + typeRegistry.getHash(), + packageName, + declaredTypeParams + ); + this.extractedTypeReferences.push(...superRefs); + + // For classes: extract from interfaces (implements clause) + const interfaceRefs = typeRefExtractor.extractFromInterfaces( + node, + typeRegistry.getHash(), + packageName, + declaredTypeParams + ); + this.extractedTypeReferences.push(...interfaceRefs); + } + + // Extract permits clause (for sealed types - both classes and interfaces) + const permitsRefs = typeRefExtractor.extractFromPermits( + node, + typeRegistry.getHash(), + packageName, + declaredTypeParams + ); + this.extractedTypeReferences.push(...permitsRefs); + + // Extract methods from this type + const methods = this.methodExtractor.extractFromType( + node, + filePath, + typeRegistry.getHash(), + typeRegistry.getName(), + typeRegistry.getQualifiedName(), + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + this.extractedMethods.push(...methods); + + // Collect method parameters extracted during method extraction + const methodParams = this.methodExtractor.getExtractedMethodParameters(); + this.extractedMethodParameters.push(...methodParams); + + // Collect method type parameters extracted during method extraction + const methodTypeParams = this.methodExtractor.getExtractedMethodTypeParameters(); + this.extractedMethodTypeParameters.push(...methodTypeParams); + + // Collect annotations from methods and parameters + const methodAnnotations = this.methodExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...methodAnnotations); + + // Collect annotation arguments from method and parameter annotations + const methodAnnotationArgs = this.methodExtractor.getExtractedAnnotationArguments(); + this.extractedAnnotationArguments.push(...methodAnnotationArgs); + + // Collect type references from method parameters + const paramTypeRefs = this.methodExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...paramTypeRefs); + + // Collect expressions from method bodies (return statements, etc.) + const methodBodyExpressions = this.methodExtractor.getExtractedExpressions(); + this.extractedExpressions.push(...methodBodyExpressions); + + // Collect local variables from method bodies + const methodLocalVariables = this.methodExtractor.getExtractedLocalVariables(); + this.extractedLocalVariables.push(...methodLocalVariables); + + // Collect blocks from method bodies + const methodBlocks = this.methodExtractor.getExtractedBlocks(); + this.extractedBlocks.push(...methodBlocks); + + // Collect anonymous classes from method body expressions + const methodBodyAnonymousClasses = this.methodExtractor.getExtractedAnonymousClasses(); + + // Extract enum constants if this is an enum declaration + if (node.type === 'enum_declaration') { + const enumConstants = this.enumConstantExtractor.extractFromEnum( + node, + filePath, + typeRegistry.getHash(), + typeRegistry.getName(), + typeRegistry.getQualifiedName(), + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + this.extractedEnumConstants.push(...enumConstants); + + // Collect annotations from enum constants + const enumConstantAnnotations = this.enumConstantExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...enumConstantAnnotations); + + // Collect annotation arguments from enum constant annotations + const enumConstantAnnotationArgs = this.enumConstantExtractor.getExtractedAnnotationArguments(); + this.extractedAnnotationArguments.push(...enumConstantAnnotationArgs); + + // Collect expressions from enum constant arguments + const enumConstantExpressions = this.enumConstantExtractor.getExtractedExpressions(); + this.extractedExpressions.push(...enumConstantExpressions); + + // Collect type references from enum constant argument expressions + const enumConstantTypeRefs = this.enumConstantExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...enumConstantTypeRefs); + } + + // Extract fields from the type body + const bodyNode = this.findTypeBody(node); + if (bodyNode) { + const isInterface = node.type === 'interface_declaration'; + const fields = this.fieldExtractor.extractFromTypeBody( + bodyNode, + filePath, + typeRegistry.getHash(), + typeRegistry.getName(), + typeRegistry.getQualifiedName(), + serviceVersionHash, + packageName, + importMap, + hasStarImports, + isInterface + ); + this.extractedFields.push(...fields); + + // Collect annotations from fields + const fieldAnnotations = this.fieldExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...fieldAnnotations); + + // Collect annotation arguments from field annotations + const fieldAnnotationArgs = this.fieldExtractor.getExtractedAnnotationArguments(); + this.extractedAnnotationArguments.push(...fieldAnnotationArgs); + + // Collect type references from field types + const fieldTypeRefs = this.fieldExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...fieldTypeRefs); + + // Collect expressions from field initializers + const fieldExpressions = this.fieldExtractor.getExtractedExpressions(); + this.extractedExpressions.push(...fieldExpressions); + + // Collect local variables from lambda block bodies in field initializers + const fieldLocalVariables = this.fieldExtractor.getExtractedLocalVariables(); + this.extractedLocalVariables.push(...fieldLocalVariables); + + // Collect blocks from lambda block bodies in field initializers + const fieldBlocks = this.fieldExtractor.getExtractedBlocks(); + this.extractedBlocks.push(...fieldBlocks); + + // Process anonymous classes from field initializers, enum constant arguments, and method bodies + // Use index-based loop to handle nested anonymous classes added during iteration + const anonymousClasses = [ + ...this.fieldExtractor.getExtractedAnonymousClasses(), + ...this.enumConstantExtractor.getExtractedAnonymousClasses(), + ...methodBodyAnonymousClasses + ]; + // Track processed anonymous class nodes by their position to prevent infinite loops + const processedAnonPositions = new Set(); + let anonIndex = 0; + while (anonIndex < anonymousClasses.length) { + const anonClass = anonymousClasses[anonIndex]!; + anonIndex++; + + // Generate unique position key for this anonymous class node + const posKey = `${anonClass.classBodyNode.startPosition.row}:${anonClass.classBodyNode.startPosition.column}:${anonClass.classBodyNode.endPosition.row}:${anonClass.classBodyNode.endPosition.column}`; + if (processedAnonPositions.has(posKey)) { + // Skip already processed anonymous class + continue; + } + processedAnonPositions.add(posKey); + + const anonymousType = this.createAnonymousTypeRegistry( + anonClass, + filePath, + basePath, + fileName, + serviceVersionHash, + typeRegistry.getQualifiedName() + ); + if (anonymousType) { + types.push(anonymousType); + + // Extract type reference for the base type (extends/implements) + // This links the anonymous type to what it extends/implements + const typeRefExtractor = this.typeParameterExtractor.getTypeReferenceExtractor(); + const baseTypeRefs = typeRefExtractor.extractFromAnonymousClassBase( + anonClass.creationNode, + anonymousType.getHash(), + packageName + ); + this.extractedTypeReferences.push(...baseTypeRefs); + + // Extract methods from anonymous class body + const anonMethods = this.methodExtractor.extractFromAnonymousClassBody( + anonClass.classBodyNode, + filePath, + anonymousType.getHash(), + anonymousType.getName(), + anonymousType.getQualifiedName(), + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + this.extractedMethods.push(...anonMethods); + + // Collect method parameters + const anonMethodParams = this.methodExtractor.getExtractedMethodParameters(); + this.extractedMethodParameters.push(...anonMethodParams); + + // Collect method type parameters + const anonMethodTypeParams = this.methodExtractor.getExtractedMethodTypeParameters(); + this.extractedMethodTypeParameters.push(...anonMethodTypeParams); + + // Collect annotations from methods + const anonMethodAnnotations = this.methodExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...anonMethodAnnotations); + + // Collect type references from methods + const anonMethodTypeRefs = this.methodExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...anonMethodTypeRefs); + + // Collect expressions from anonymous class method bodies + const anonMethodExpressions = this.methodExtractor.getExtractedExpressions(); + this.extractedExpressions.push(...anonMethodExpressions); + + // Collect local variables from anonymous class method bodies + const anonMethodLocalVariables = this.methodExtractor.getExtractedLocalVariables(); + this.extractedLocalVariables.push(...anonMethodLocalVariables); + + // Collect blocks from anonymous class method bodies + const anonMethodBlocks = this.methodExtractor.getExtractedBlocks(); + this.extractedBlocks.push(...anonMethodBlocks); + + // Collect nested anonymous classes from anonymous class method bodies + // These get added to the queue for processing in subsequent iterations + const nestedAnonClasses = this.methodExtractor.getExtractedAnonymousClasses(); + anonymousClasses.push(...nestedAnonClasses); + + // Extract fields from anonymous class body + const anonFields = this.fieldExtractor.extractFromAnonymousClassBody( + anonClass.classBodyNode, + filePath, + anonymousType.getHash(), + anonymousType.getName(), + anonymousType.getQualifiedName(), + serviceVersionHash, + packageName, + importMap, + hasStarImports + ); + this.extractedFields.push(...anonFields); + + // Collect field annotations + const anonFieldAnnotations = this.fieldExtractor.getExtractedAnnotations(); + this.extractedAnnotations.push(...anonFieldAnnotations); + + // Collect field type references + const anonFieldTypeRefs = this.fieldExtractor.getExtractedTypeReferences(); + this.extractedTypeReferences.push(...anonFieldTypeRefs); + + // Collect field expressions (initializers) + const anonFieldExpressions = this.fieldExtractor.getExtractedExpressions(); + this.extractedExpressions.push(...anonFieldExpressions); + + // Collect local variables from lambda block bodies in anonymous class field initializers + const anonFieldLocalVars = this.fieldExtractor.getExtractedLocalVariables(); + this.extractedLocalVariables.push(...anonFieldLocalVars); + + // Collect nested anonymous classes from anonymous class field initializers + const nestedFieldAnonClasses = this.fieldExtractor.getExtractedAnonymousClasses(); + anonymousClasses.push(...nestedFieldAnonClasses); + } + } + } + } + } + + for (const child of node.children) { + this.extractTypes(child, filePath, basePath, fileName, serviceVersionHash, packageName, importMap, hasStarImports, types); + } + } + + /** + * Extracts imports from the file and builds an import map + * Returns map of simple type names to fully qualified names and whether star imports exist + */ + private extractImports(rootNode: Parser.SyntaxNode): { + importMap: Map; + hasStarImports: boolean; + } { + const importMap = new Map(); + let hasStarImports = false; + + for (const child of rootNode.children) { + if (child.type === 'import_declaration') { + const importPath = this.extractImportPath(child); + if (importPath) { + // Check if star import + if (importPath.endsWith('.*')) { + hasStarImports = true; + } else { + // Extract simple name from qualified import + // e.g., "org.keycloak.models.User" -> "User" + const parts = importPath.split('.'); + const simpleName = parts[parts.length - 1]; + if (simpleName) { + importMap.set(simpleName, importPath); + } + } + } + } + } + + return { importMap, hasStarImports }; + } + + /** + * Extracts the import path from an import_declaration node + * Handles: import java.util.List; → "java.util.List" + * Handles: import java.util.*; → "java.util.*" + */ + private extractImportPath(importNode: Parser.SyntaxNode): string | null { + // Build the full path from all relevant children + let path = ''; + + for (const child of importNode.children) { + if (child.type === 'scoped_identifier') { + // This is the package part (e.g., "java.util") + path = child.text; + } else if (child.type === 'identifier') { + // This is the class name (e.g., "List") + // Append to existing path + if (path) { + path += '.' + child.text; + } else { + path = child.text; + } + } else if (child.type === 'asterisk') { + // Star import + if (path) { + path += '.*'; + } else { + path = '*'; + } + } + } + + return path || null; + } + + /** + * Extracts package declaration from root node + */ + private extractPackageDeclaration(rootNode: Parser.SyntaxNode): string | null { + for (const child of rootNode.children) { + if (child.type === 'package_declaration') { + // Find scoped_identifier child + for (const pkgChild of child.children) { + if (pkgChild.type === 'scoped_identifier' || pkgChild.type === 'identifier') { + return pkgChild.text; + } + } + } + } + return null; + } + + /** + * Checks if a node is a type declaration + */ + /** + * True for the scopes a local class can be declared in: a method or constructor body, either + * kind of initializer block, or a lambda body (JLS 14.3). + * + * An instance initializer has no node type of its own - it is a bare `block` sitting directly + * in a `class_body` - which is what distinguishes it from a method's own block, whose parent is + * the method declaration. + */ + private isLocalDeclarationScope(node: Parser.SyntaxNode): boolean { + if ([ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', + 'static_initializer', + 'lambda_expression', + ].includes(node.type)) { + return true; + } + + return node.type === 'block' && node.parent?.type === 'class_body'; + } + + private isTypeDeclaration(node: Parser.SyntaxNode): boolean { + return [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'annotation_type_declaration', + 'record_declaration', + ].includes(node.type); + } + + /** + * Creates a TypeRegistry instance from a type declaration node + */ + private createTypeRegistry( + node: Parser.SyntaxNode, + filePath: string, + basePath: string, + fileName: string, + serviceVersionHash: string + ): TypeRegistry | null { + const name = this.extractTypeName(node); + if (!name) return null; + + const qualifiedName = this.extractQualifiedName(node); + const typeCategory = this.extractTypeCategory(node); + const typeAccess = this.extractTypeAccess(node); + const typePlacement = this.extractTypePlacement(node); + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + const isExternal = false; + + const typeRegistry = new TypeRegistry( + name, + qualifiedName, + fileName, + typeCategory, + typeAccess, + typePlacement, + filePath, + basePath, + startLine, + endLine, + isExternal, + serviceVersionHash + ); + + const modifiers = this.extractModifiers(node); + modifiers.forEach((modifier) => typeRegistry.addModifier(modifier)); + + typeRegistry.generateHash(); + + return typeRegistry; + } + + /** + * The supertype name used to key an anonymous class: the simple name, without type arguments. + * + * `new java.util.Comparator() {...}` keys as `Comparator`, not + * `java.util.Comparator`, so that the key does not change when an import is rewritten + * to a qualified reference or a type argument is added. + */ + private anonymousSupertypeKey(baseTypeName: string | undefined): string { + const raw = baseTypeName?.trim(); + if (!raw) return 'Object'; + + const withoutTypeArguments = raw.includes('<') ? raw.slice(0, raw.indexOf('<')) : raw; + const simpleName = withoutTypeArguments.split('.').pop()?.trim(); + return simpleName && simpleName.length > 0 ? simpleName : 'Object'; + } + + /** + * Creates a TypeRegistry instance for an anonymous class + */ + private createAnonymousTypeRegistry( + anonClass: AnonymousClassInfo, + filePath: string, + basePath: string, + fileName: string, + serviceVersionHash: string, + enclosingQualifiedName: string + ): TypeRegistry | null { + // An anonymous class is keyed by the type it extends or implements, as `Outer$anon:Runnable`. + // + // The previous form was `Outer$N`, numbered from the running count of every type row emitted + // for the file. That had two problems, neither about identity: the count included the + // enclosing type, so the first anonymous class was `Outer$2`, and adding an unrelated NAMED + // nested type above renumbered every anonymous type below it. A label that moves when + // unrelated code is added is not usable as a label. + // + // Keying on the supertype is stable under those edits by construction. It also stops the + // value resembling a javac binary name, which `Outer$N` did without ever equalling one - + // a shape that invites a join no rule can satisfy. + // + // This is the label only. Identity is the position-derived hash assigned by the caller from + // `anonClass.anonymousTypeHash`, so two anonymous classes sharing a supertype remain distinct + // rows, exactly as two nested types sharing a flattened name do. + const supertype = this.anonymousSupertypeKey(anonClass.baseTypeName); + const enclosingSimpleName = enclosingQualifiedName.split('.').pop() || 'Anonymous'; + const name = `${enclosingSimpleName}$anon:${supertype}`; + const qualifiedName = `${enclosingQualifiedName}$anon:${supertype}`; + + const startLine = anonClass.creationNode.startPosition.row + 1; + const endLine = anonClass.creationNode.endPosition.row + 1; + + const typeRegistry = new TypeRegistry( + name, + qualifiedName, + fileName, + TypeCategory.CLASS_TYPE, + TypeAccess.PACKAGE_ACCESS, // Anonymous classes have no explicit access modifier + TypePlacement.ANONYMOUS_PLACEMENT, + filePath, + basePath, + startLine, + endLine, + false, // isExternal + serviceVersionHash + ); + + // Use the pre-generated hash from the expression extractor for consistency + typeRegistry.setHash(anonClass.anonymousTypeHash); + + return typeRegistry; + } + + /** + * Extracts the type name from a declaration node + */ + private extractTypeName(node: Parser.SyntaxNode): string | null { + const nameNode = node.childForFieldName('name'); + return nameNode ? nameNode.text : null; + } + + /** + * Extracts the fully qualified name of a type + */ + private extractQualifiedName(node: Parser.SyntaxNode): string { + const packageName = this.extractPackageName(node); + const typeName = this.extractTypeName(node) || 'Unknown'; + return packageName ? `${packageName}.${typeName}` : typeName; + } + + /** + * Extracts the package name from the file + */ + private extractPackageName(node: Parser.SyntaxNode): string | null { + // Traverse to the root node + let current = node; + while (current.parent) { + current = current.parent; + } + + // Look for package_declaration in root's children + for (const child of current.children) { + if (child.type === 'package_declaration') { + // Try field name first (some versions use this) + const packageNode = child.childForFieldName('name'); + if (packageNode) { + return packageNode.text; + } + + // Otherwise, look for scoped_identifier or identifier child + for (const pkgChild of child.children) { + if (pkgChild.type === 'scoped_identifier' || pkgChild.type === 'identifier') { + return pkgChild.text; + } + } + } + } + + return null; + } + + /** + * Determines the type category based on node type + */ + private extractTypeCategory(node: Parser.SyntaxNode): TypeCategory { + switch (node.type) { + case 'class_declaration': + return TypeCategory.CLASS_TYPE; + case 'interface_declaration': + return TypeCategory.INTERFACE_TYPE; + case 'enum_declaration': + return TypeCategory.ENUM_TYPE; + case 'annotation_type_declaration': + // Check if the annotation declaration contains @interface syntax + if (node.text.includes('@interface')) { + return TypeCategory.ANNOTATION_INTERFACE_TYPE; + } + return TypeCategory.ANNOTATION_TYPE; + case 'record_declaration': + return TypeCategory.RECORD_TYPE; + default: + return TypeCategory.CLASS_TYPE; + } + } + + /** + * Extracts the access modifier (public, private, etc.) + */ + private extractTypeAccess(node: Parser.SyntaxNode): TypeAccess { + const modifiers = this.extractModifierTexts(node); + + if (modifiers.includes('public')) return TypeAccess.PUBLIC_ACCESS; + if (modifiers.includes('private')) return TypeAccess.PRIVATE_ACCESS; + if (modifiers.includes('protected')) return TypeAccess.PROTECTED_ACCESS; + + return TypeAccess.PACKAGE_ACCESS; + } + + /** + * Extracts modifier text values from a node + */ + private extractModifierTexts(node: Parser.SyntaxNode): string[] { + const modifierTexts: string[] = []; + + for (const child of node.children) { + if (child.type === 'modifiers') { + for (const mod of child.children) { + if (mod.type === 'marker_annotation' || mod.type === 'annotation') continue; + modifierTexts.push(mod.text); + } + } + } + + return modifierTexts; + } + + /** + * Extracts type modifiers (abstract, final, static, etc.) + */ + private extractModifiers(node: Parser.SyntaxNode): TypeModifier[] { + const modifiers: TypeModifier[] = []; + const modifierTexts = this.extractModifierTexts(node); + + for (const modText of modifierTexts) { + switch (modText) { + case 'abstract': + modifiers.push(TypeModifier.ABSTRACT_MODIFIER); + break; + case 'final': + modifiers.push(TypeModifier.FINAL_MODIFIER); + break; + case 'static': + modifiers.push(TypeModifier.STATIC_MODIFIER); + break; + case 'strictfp': + modifiers.push(TypeModifier.STRICTFP_MODIFIER); + break; + case 'sealed': + modifiers.push(TypeModifier.SEALED_MODIFIER); + break; + case 'non-sealed': + modifiers.push(TypeModifier.NON_SEALED_MODIFIER); + break; + case 'deprecated': + modifiers.push(TypeModifier.DEPRECATED_MODIFIER); + break; + } + } + + return modifiers; + } + + /** + * Determines type placement (top-level, nested, etc.) + */ + private extractTypePlacement(node: Parser.SyntaxNode): TypePlacement { + let parent = node.parent; + while (parent) { + // Whichever comes first going up decides. A method, constructor, initializer or lambda + // body reached before any enclosing type declaration makes this a local class (JLS 14.3), + // and a local class is not a member of the enclosing type at all. + // + // Order matters here rather than being an implementation detail: reaching the type + // declaration first is what makes a class a member class, and reaching an executable body + // first is what makes it local. A nested type inside a local class still resolves to the + // member branch, correctly, because its nearest enclosing scope really is a class body. + if (this.isLocalDeclarationScope(parent)) { + return TypePlacement.LOCAL_PLACEMENT; + } + if (this.isTypeDeclaration(parent)) { + const modifierTexts = this.extractModifierTexts(node); + // Explicit static modifier + if (modifierTexts.includes('static')) { + return TypePlacement.STATIC_NESTED_PLACEMENT; + } + // Records, interfaces, and enums are implicitly static when nested + if (node.type === 'record_declaration' || + node.type === 'interface_declaration' || + node.type === 'enum_declaration') { + return TypePlacement.STATIC_NESTED_PLACEMENT; + } + return TypePlacement.INNER_PLACEMENT; + } + parent = parent.parent; + } + return TypePlacement.TOP_LEVEL_PLACEMENT; + } + + /** + * Extracts the base path (project root) from file path + */ + private extractBasePath(filePath: string): string { + const srcIndex = filePath.indexOf('/src/'); + if (srcIndex !== -1) { + return filePath.substring(0, srcIndex); + } + return path.dirname(filePath); + } + + /** + * Finds the body node for a type declaration + */ + /** + * Builds a map of "startLine:startColumn" → entity hash from all extracted entities. + * Used by the CommentExtractor to associate comments with their nearest entity. + */ + private buildPositionToHashMap(types: TypeRegistry[]): Map { + const map = new Map(); + + // Types + for (const t of types) { + map.set(`${t.getStartLine()}:0`, t.getHash()); + } + + // Methods + for (const m of this.extractedMethods) { + map.set(`${m.getStartLine()}:0`, m.getHash()); + } + + // Fields + for (const f of this.extractedFields) { + map.set(`${f.getStartLine()}:0`, f.getHash()); + } + + // Local variables + for (const lv of this.extractedLocalVariables) { + map.set(`${lv.getStartLine()}:0`, lv.getHash()); + } + + // Annotations + for (const a of this.extractedAnnotations) { + const line = a.getStartLine(); + if (line !== undefined) { + map.set(`${line}:0`, a.getHash()); + } + } + + // Expressions (ROOT only — expression statements) + for (const e of this.extractedExpressions) { + if (e.getEdgeRole().toString() === 'ROOT') { + const startLine = e.getStartLine(); + const startCol = e.getStartColumn(); + if (startLine !== undefined && startCol !== undefined) { + map.set(`${startLine}:${startCol}`, e.getHash()); + } + } + } + + return map; + } + + private findTypeBody(node: Parser.SyntaxNode): Parser.SyntaxNode | undefined { + const bodyTypes = ['class_body', 'interface_body', 'enum_body', 'annotation_type_body', 'record_body']; + for (const child of node.children) { + if (bodyTypes.includes(child.type)) { + return child; + } + } + return undefined; + } + +} diff --git a/parser/src/parsers/java/index.ts b/parser/src/parsers/java/index.ts new file mode 100644 index 000000000..e0920a145 --- /dev/null +++ b/parser/src/parsers/java/index.ts @@ -0,0 +1,2 @@ +export { JavaParser } from '@/parsers/java/java-parser'; +export { TypeRegistryExtractor } from '@/parsers/java/extractors'; diff --git a/parser/src/parsers/java/java-parser.ts b/parser/src/parsers/java/java-parser.ts new file mode 100644 index 000000000..1ec23cc56 --- /dev/null +++ b/parser/src/parsers/java/java-parser.ts @@ -0,0 +1,89 @@ +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; + +import { FILE_EXTENSIONS } from '@/constants/consts'; +import { LanguageParser } from '@/parsers/language-parser'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { withRetry } from '@/utils/retry-decorator'; + +/** + * Java-specific tree-sitter parser implementation + */ +export class JavaParser implements LanguageParser { + readonly language = ProjectLanguage.JAVA; + readonly fileExtension = FILE_EXTENSIONS.JAVA; + + private parser: Parser; + + constructor() { + this.parser = new Parser(); + this.parser.setLanguage(Java); + } + + /** + * Parses Java source code into a syntax tree + * Uses callback-based parsing for large files to avoid tree-sitter buffer limits + * @param sourceCode Java source code as string + * @returns Parsed syntax tree + * @throws Error if sourceCode is invalid + */ + parse(sourceCode: string): Parser.Tree { + if (!sourceCode || typeof sourceCode !== 'string') { + throw new Error('Invalid source code: must be a non-empty string'); + } + + // Use callback-based parsing for files > 30KB to avoid tree-sitter internal buffer limits + // This is a known limitation: direct string parsing fails around 32-35KB + const useCallbackParsing = sourceCode.length > 30000; + + const parseWithRetry = withRetry( + (code: string) => { + if (useCallbackParsing) { + // Callback-based streaming parser (works for large files) + return this.parser.parse((index: number) => { + if (index >= code.length) { + return null; + } + // Return chunks of 8KB for efficient parsing + const chunkSize = 8192; + const chunk = code.substring(index, Math.min(index + chunkSize, code.length)); + return chunk; + }); + } else { + // Direct string parsing (faster for small files) + return this.parser.parse(code); + } + }, + { + maxAttempts: 3, + delayMs: 1500, + exponentialBackoff: true, + onRetry: (attempt, error) => { + console.warn(`[JavaParser] Parse attempt ${attempt} failed: ${error.message}, retrying...`); + } + } + ); + + return parseWithRetry(sourceCode); + } + + /** + * Gets the root node of a parsed tree + * @param tree Parsed syntax tree + * @returns Root syntax node + */ + getRootNode(tree: Parser.Tree): Parser.SyntaxNode { + return tree.rootNode; + } + + /** + * Queries the syntax tree using tree-sitter query syntax + * @param node Starting node for the query + * @param queryString Tree-sitter query string + * @returns Query matches + */ + query(node: Parser.SyntaxNode, queryString: string): Parser.QueryMatch[] { + const query = this.parser.getLanguage().query(queryString); + return query.matches(node); + } +} diff --git a/parser/src/parsers/javascript/extractors/js-comment-extractor.ts b/parser/src/parsers/javascript/extractors/js-comment-extractor.ts new file mode 100644 index 000000000..1dc2f04be --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-comment-extractor.ts @@ -0,0 +1,285 @@ +import * as ts from 'typescript'; + +import { JS_COMMENT_TEXT_LIMIT } from '@/constants/javascript-constants'; +import { JsCommentRegistry } from '@/analysis-types/javascript/JsCommentRegistry'; +import { + JsCommentAttachmentKind, + JsCommentKind, + JsDirectiveKind, +} from '@/enums/javascript/comments'; +import { EntityUtils } from '@/utils/entity-utils'; +import { pointAtOffset } from '@/utils/javascript'; + +/** + * `js_comment` — schema §3.15. + * + * ## In this language a comment can be a DECLARATION + * + * That is the reason this relation is not decoration. 1,825 `@typedef` and 103 + * `@callback` tags declare **types with no declaration syntax anywhere**, so a + * `js_type` row can carry `evidenceKind = COMMENT_ONLY` and a `startLine` inside + * a comment. `declaresType` is the corroborating column, and the gate asserts + * the two relations agree — every `COMMENT_ONLY` type points at a comment whose + * `declaresType` is true — so neither can assert it alone. + * + * More broadly: **37.9% of parameters get their declared type from a JSDoc tag + * and 0.165% from syntax.** A schema that treats JSDoc as trivia has no declared + * type channel at all. + * + * ## Comments are scanned, not walked + * + * A comment is trivia: not in the AST, and no `forEachChild` reaches one. + * Scanning the full text is the only way to see them all, including the ones + * attached to nothing — a licence header, a trailing block at end of file, a + * suppression above a statement the extractor does not model. + */ +export interface CommentExtractionOptions { + readonly sourceFile: ts.SourceFile; + readonly moduleHash: string; + readonly serviceVersionLinkHash: string; + /** + * Byte offset of a declaration's start -> its row and kind. + * + * Keyed on the START OFFSET because that is what a trivia scan knows about the + * node a comment precedes. Recorded first-wins, because several nodes begin at + * one offset — a declaration and its own name — and the **outermost** is the + * one a preceding comment documents. + */ + readonly ownerByStart: ReadonlyMap< + number, { kind: JsCommentAttachmentKind; hash: string } + >; +} + +export interface CommentExtractionResult { + readonly comments: readonly JsCommentRegistry[]; + /** Comment row by its start offset, so a `@typedef` type can cite its evidence. */ + readonly commentByStart: ReadonlyMap; +} + +export function extractComments( + options: CommentExtractionOptions +): CommentExtractionResult { + const sourceFile = options.sourceFile; + const text = sourceFile.getFullText(); + const comments: JsCommentRegistry[] = []; + const commentByStart = new Map(); + const seen = new Set(); + // Where the file's own code begins. A comment ENTIRELY above it documents the + // MODULE — that is the enum's own definition of the value: "a file-level + // comment: a licence header, a `@flow` pragma, a shebang". + // + // All three were emitting `attachedToKind = NONE`, so MODULE was 0 rows on a + // 4,529-file corpus while every file that had a licence header had one. NONE + // means "attached to nothing", and a licence header is not attached to + // nothing — it is attached to the file. An engine asking "what documents this + // module" got an empty answer that looked like an answer. + // + // Measured from the first STATEMENT, not from the first node: a shebang at + // offset 0 is trivia, and `sourceFile.getStart()` skips past it, which would + // exclude the one case the enum names first. + const firstStatementStart = sourceFile.statements.length > 0 + ? sourceFile.statements[0]!.getStart(sourceFile) + : sourceFile.end; + + const emitRange = (range: ts.CommentRange, ownerStart: number | undefined): void => { + const key = `${range.pos}:${range.end}`; + if (seen.has(key)) { + // A comment between two declarations is BOTH the leading trivia of one and + // the trailing trivia of the other, so the scan reaches it twice. The + // primary key would be identical, and a duplicate key **doubles** a count + // rather than colliding. + return; + } + seen.add(key); + const body = text.slice(range.pos, range.end); + const start = pointAtOffset(range.pos, sourceFile); + const end = pointAtOffset(range.end, sourceFile); + const tags = jsdocTagsOf(body); + const isJsdoc = body.startsWith('/**'); + const owner = ownerStart === undefined + ? undefined + : options.ownerByStart.get(ownerStart); + const row = new JsCommentRegistry({ + commentKind: commentKindOf(body, range), + text: EntityUtils.normalizeWhitespace(body).slice(0, JS_COMMENT_TEXT_LIMIT), + isJsdoc, + // A comma LIST, ordered and with repeats — `param,param,returns`. The + // repetition and the order are both information, so this is not a set. + jsdocTagNames: tags.join(','), + jsdocTagCount: tags.length, + // The column that makes "a comment can be a declaration" checkable. + declaresType: tags.includes('typedef') || tags.includes('callback'), + directiveKind: directiveKindOf(body), + attachedToKind: owner?.kind + ?? (range.end <= firstStatementStart + ? JsCommentAttachmentKind.MODULE + : JsCommentAttachmentKind.NONE), + ownerModuleLinkHash: options.moduleHash, + startLine: start.startLine, + startColumn: start.startColumn, + endLine: end.startLine, + serviceVersionLinkHash: options.serviceVersionLinkHash, + }); + if (owner !== undefined) { + row.setAttachedToLinkHash(owner.hash); + } + comments.push(row); + commentByStart.set(range.pos, row); + }; + + // A shebang is legal only on line 1 and is not a comment to the grammar, so + // no comment scan reaches it. Emitted anyway: it decides whether the file is + // an executable entry point, which nothing else in the fact base records. + if (text.startsWith('#!')) { + const end = text.indexOf('\n'); + emitRange( + { pos: 0, end: end < 0 ? text.length : end, kind: ts.SyntaxKind.SingleLineCommentTrivia }, + undefined + ); + } + + const visit = (node: ts.Node): void => { + const start = node.getFullStart(); + for (const range of ts.getLeadingCommentRanges(text, start) ?? []) { + emitRange(range, node.getStart(sourceFile)); + } + for (const range of ts.getTrailingCommentRanges(text, node.end) ?? []) { + emitRange(range, undefined); + } + // AFTER A SEPARATOR, which belongs to no node. + // + // `1, // note` in an array, `x: 1, // note` in an object literal, `p, // + // note` in a parameter list: 2,211 of 64,157 comments emitted no row, with + // the identical comment after a STATEMENT emitting fine beside them. The + // comma is not part of the element, so the comment is not the element's + // trailing trivia — and the compiler does not collect a same-line comment + // as the NEXT element's leading trivia either. It is the trailing trivia of + // the comma token, and a walk over nodes never visits a token. + // + // A statement is unaffected because its `;` is inside the statement node. + // The `seen` set makes this safe against double emission when the same + // range is reached from two directions. + if (text[node.end] === ',') { + for (const range of ts.getTrailingCommentRanges(text, node.end + 1) ?? []) { + emitRange(range, undefined); + } + } + // AFTER ANY OTHER TOKEN, for the same reason. The comma was one instance + // of a class: `f(/* why */ 60)` sits after `(`, `case 403: // forbidden` + // after the `:`, `{ // opening` after the brace, `x = // note` after `=`. + // None is a node's trailing trivia, and the compiler does not collect a + // same-line comment as the next node's leading trivia. The parser's own + // token children — `getChildren()` includes punctuation the AST walk + // skips — are visited here, so regexes and templates are already decided + // by the parser and no second scanner is needed. 87 of 14,935 comments on + // the development corpus, 9 on the holdout, all of this class. + // + // AFTER the children, so a comment that is also some node's leading trivia + // is emitted by that node's visit, WITH its attachment; the token pass + // catches only what no node claimed (`seen` makes the order the whole rule). + ts.forEachChild(node, visit); + for (const child of node.getChildren(sourceFile)) { + if (child.kind >= ts.SyntaxKind.FirstNode) { + continue; + } + for (const range of ts.getTrailingCommentRanges(text, child.end) ?? []) { + emitRange(range, undefined); + } + for (const range of ts.getLeadingCommentRanges(text, child.getFullStart()) ?? []) { + emitRange(range, undefined); + } + } + }; + ts.forEachChild(sourceFile, visit); + // The end of the file is not the trailing trivia of any node, so a comment + // there is reached by neither loop above. + for (const range of ts.getLeadingCommentRanges(text, sourceFile.endOfFileToken.getFullStart()) + ?? []) { + emitRange(range, undefined); + } + + // Source order, so two runs are byte-identical: `forEachChild` order is + // deterministic but is not source order once trailing trivia is involved. + comments.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn); + return { comments, commentByStart }; +} + +function commentKindOf(body: string, range: ts.CommentRange): JsCommentKind { + if (body.startsWith('#!')) { + return JsCommentKind.SHEBANG; + } + if (body.startsWith('/**')) { + // A type annotation, not a comment: the compiler parses these into + // `node.jsDoc` and uses them for inference under `checkJs`. + return JsCommentKind.JSDOC; + } + if (directiveKindOf(body) !== JsDirectiveKind.NONE) { + return JsCommentKind.DIRECTIVE; + } + return range.kind === ts.SyntaxKind.MultiLineCommentTrivia + ? JsCommentKind.BLOCK + : JsCommentKind.LINE; +} + +/** + * The directive a comment carries, if any. + * + * `FLOW_PRAGMA` is the one with downstream consequences: it corroborates + * `declaredTypeSource = SYNTACTIC_FLOW`, and without it a Flow annotation in the + * AST is indistinguishable from a TypeScript one — which matters because + * `ts.createSourceFile` parses the overlapping grammar happily and **mis-parses + * the rest silently**. + */ +function directiveKindOf(body: string): JsDirectiveKind { + if (/@flow\b/.test(body)) { + return JsDirectiveKind.FLOW_PRAGMA; + } + if (/@ts-nocheck\b/.test(body)) { + return JsDirectiveKind.TS_NOCHECK; + } + if (/@ts-check\b/.test(body)) { + return JsDirectiveKind.TS_CHECK; + } + // ANCHORED at the comment's start, as ESLint itself requires: a directive is + // `/* eslint-disable */`, `// eslint-disable-next-line x`, `/* eslint rule: 0 */`, + // `/* eslint-env node */`. The old `\beslint\s` branch matched the WORD + // followed by a space anywhere, so the prose `…and every eslint config + // resolver.` was a DIRECTIVE row — found by js-fixtures when scrubbing that + // sentence made a directive vanish. + if (/^\/[/*]\s*eslint(-[a-z-]+)?(\s|\*\/|$)/.test(body)) { + return JsDirectiveKind.ESLINT; + } + if (/sourceMappingURL=/.test(body)) { + return JsDirectiveKind.SOURCE_MAP; + } + if (/^\/[/*]\s*['"]use strict['"]/.test(body)) { + return JsDirectiveKind.USE_STRICT; + } + return JsDirectiveKind.NONE; +} + +/** + * Tag names in source order, repeats kept: `param,param,returns`. + * + * Matched on "whitespace or a star, then `@`", not on line-start. A single-line + * `/** @type {Array} *\/` puts its tag straight after the opening + * delimiter, so a line-anchored pattern misses it entirely — and a one-line + * `@type` is the normal way to annotate a variable, which made `declaresType` + * and `jsdocTagCount` silently empty for every one of them. + * + * The boundary requirement is what keeps an email address in a description from + * reading as a tag: `see foo@bar` has no space before the `@`. + */ +function jsdocTagsOf(body: string): string[] { + if (!body.startsWith('/**')) { + return []; + } + const out: string[] = []; + const pattern = /(?:^|[\s*])@(\w+)/g; + let match = pattern.exec(body); + while (match !== null) { + out.push(match[1]!); + match = pattern.exec(body); + } + return out; +} diff --git a/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts b/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts new file mode 100644 index 000000000..c7b4dce15 --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts @@ -0,0 +1,3130 @@ +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { + JS_ANONYMOUS_METHOD_NAMES, + JS_ANONYMOUS_TYPE_NAME, + JS_MODULE_INITIALIZER_NAME, +} from '@/constants/javascript-constants'; +import { JsBlockRegistry } from '@/analysis-types/javascript/JsBlockRegistry'; +import { JsFieldRegistry } from '@/analysis-types/javascript/JsFieldRegistry'; +import { JsMethodParameterRegistry } from + '@/analysis-types/javascript/JsMethodParameterRegistry'; +import { JsMethodRegistry } from '@/analysis-types/javascript/JsMethodRegistry'; +import { JsTypeHeritageRegistry } from + '@/analysis-types/javascript/JsTypeHeritageRegistry'; +import { JsTypeRegistry } from '@/analysis-types/javascript/JsTypeRegistry'; +import { JsVariableRegistry } from '@/analysis-types/javascript/JsVariableRegistry'; +import { JsBlockKind } from '@/enums/javascript/blocks'; +import { JsDeclaredTypeSource } from '@/enums/javascript/common'; +import { JsAccessorPairKind, JsFieldDeclarationForm } from '@/enums/javascript/fields'; +import { JsHeritageForm } from '@/enums/javascript/heritage'; +import { JsParameterBindingForm } from '@/enums/javascript/method-parameters'; +import { + JsBodyPresence, + JsHoisting, + JsMethodDeclarationForm, + JsMethodKind, + JsThisBinding, +} from '@/enums/javascript/methods'; +import { + JsEvidenceKind, + JsTypeCategory, + JsTypeDeclarationForm, +} from '@/enums/javascript/types'; +import { + JsBindingRegime, + JsInitializerKind, + JsVariableBindingForm, +} from '@/enums/javascript/variables'; +import { + JsTypeReferenceContextKind, + JsTypeReferenceOwnerKind, +} from '@/enums/javascript/type-references'; +import { ScopeBuildResult } from '@/parsers/javascript/extractors/js-scope-builder'; +import { JsBinding, JsScopeNode, nodeKey, resolveName } from + '@/parsers/javascript/extractors/js-symbol-table'; +import { + enclosingStatement, + isCallableExpression, + isRequireCall, + jsDocTagsOfAllBlocks, + lastLineOf, + rangeOf, jsDocParameterTagFor, +} from '@/utils/javascript'; + +/** + * Types, methods, parameters, fields, variables, blocks and heritage, in one + * walk. + * + * ## One walk, and every construct on exactly one path + * + * §2 of `BUILDING-A-PARSER.md`: **duplicate keys do not collide, they DOUBLE.** + * A construct reached by two visit paths mints an identical primary key and the + * row count quietly doubles with nothing looking wrong — no error, no warning, + * and every count still plausible. A member decorator reached twice did exactly + * that in TypeScript. + * + * So this is a single recursive dispatch with an explicit context, rather than + * several passes each looking for what it cares about. Where a construct is + * genuinely two things — `Foo.prototype.bar = function () {}` is a method + * declaration *and* an assignment that executes — it is visited once here and + * once by the expression walker, into **two different relations**, and the two + * rows point at each other. + * + * ## Owner derivation is carried, never re-derived + * + * The enclosing type, method, scope and block travel in {@link WalkContext}. The + * alternative — walking `.parent` pointers or comparing positions at each row — + * picks the wrong owner whenever two candidates begin at the same offset, which + * in JavaScript is constant: an IIFE's parenthesis, its function and its call + * all start together. + * + * ## Child keys chain off the parent hash + * + * A method's qualified name is built from its owner's, never re-derived from the + * source. `module.exports = class {}` yields a type whose only name is its + * file's, and two such files in one directory would collide on any name-derived + * key. + */ +export interface DeclarationExtractionOptions { + readonly sourceFile: ts.SourceFile; + readonly binder: ScopeBuildResult; + readonly filePath: string; + readonly fileName: string; + readonly baseMservPath: string; + readonly moduleHash: string; + readonly moduleQualifiedName: string; + readonly serviceVersionLinkHash: string; + /** Scope hash by the binder's scope key, from `js-scope-extractor`. */ + readonly hashOfScope: (scope: JsScopeNode) => string; +} + +/** + * A link that cannot be made until a later pass has minted the hash it needs. + * + * Kept as data rather than as a callback closure over a node, because the node + * is the lookup key and storing state on it is forbidden: node wrappers get + * evicted and the failure is **silent at scale** — right on ten files, dropping + * rows on ten thousand. + */ +export interface PendingExpressionLink { + /** `nodeKey` of the expression whose hash is wanted. */ + readonly nodeIdentity: string; + readonly link: (hash: string) => void; +} + +export class JsDeclarationExtractor { + readonly types: JsTypeRegistry[] = []; + readonly methods: JsMethodRegistry[] = []; + readonly methodParameters: JsMethodParameterRegistry[] = []; + readonly fields: JsFieldRegistry[] = []; + readonly variables: JsVariableRegistry[] = []; + readonly heritages: JsTypeHeritageRegistry[] = []; + readonly blocks: JsBlockRegistry[] = []; + + /** `nodeKey` -> hash, for the passes that follow. */ + readonly typeHashByNode = new Map(); + readonly methodHashByNode = new Map(); + readonly fieldHashByNode = new Map(); + readonly variableHashByNode = new Map(); + readonly parameterHashByNode = new Map(); + readonly blockHashByNode = new Map(); + + /** + * Declarations minted FROM an assignment, by the `nodeKey` of that + * assignment. + * + * Read by the expression walker to set `isDeclarationBearing` and + * `declarationLinkHash`, and by the linking step to fill + * `sourceExpressionLinkHash` the other way. Gate 7.3.7 asserts the round trip. + */ + readonly declarationByAssignment = new Map(); + + /** Links deferred until the expression pass has run. */ + readonly pendingExpressionLinks: PendingExpressionLink[] = []; + + /** + * Start offsets a comment may attach to, recorded where the node is known. + * + * A comment scan knows only the offset of the node it precedes, and for + * `/** @type {T} *\/ const cache = []` that node is the **statement** — while + * the variable's own row is keyed on the identifier `cache`, several tokens + * later. Registering both offsets is what lets the comment find its owner; + * deriving it later would mean re-walking ancestors from an offset, which is + * the position comparison this front end avoids everywhere else. + */ + readonly commentOwnerStarts = new Map(); + + /** + * `@typedef`/`@callback` tags and the `js_type` rows they declared. + * + * Handed to the JSDoc pass, which emits the type EXPRESSION tree, and to the + * comment linking step, which fills `jsdocCommentLinkHash` — the evidence for + * a `COMMENT_ONLY` row, and what the gate checks against `declaresType`. + */ + readonly jsDocTypeTags: { + tag: ts.JSDocTypedefTag | ts.JSDocCallbackTag; row: JsTypeRegistry; + }[] = []; + + /** + * Positions whose declared type has a TREE to emit, collected as the rows are + * minted. + * + * The owner kind and the AST node are both known here and nowhere else, so + * collecting them at mint time is what stops a later pass re-deriving an owner + * by walking ancestors — which picks the wrong one whenever two candidates + * begin at the same offset. + */ + readonly pendingTypeReferences: { + node: ts.Node; + ownerKind: JsTypeReferenceOwnerKind; + ownerHash: string; + /** + * For an EXPRESSION owner: the expression node whose row is the owner. The + * row does not exist until the expression pass runs, so the hash is + * resolved by the fact extractor at link time rather than here. + */ + ownerNode?: ts.Node; + contextKind: JsTypeReferenceContextKind; + functionNode?: ts.Node; + parameterName?: string; + link: (hash: string) => void; + }[] = []; + + /** + * Statements whose `@type` a declaration path already claimed. + * + * The EXPRESSION owner is the owner of last resort. `this.x = …` in a + * constructor gives its `@type` to the field, `ret = …` to the variable, + * `Foo.prototype.m = …` to the assigned member. A statement in this set is + * not offered to the expression owner as well, which is the two-paths-to-one- + * construct hazard applied to a type reference: one annotation, one tree. + */ + private readonly typeClaimedStatements = new Set(); + /** EXPRESSION-owner candidates, offered only what no declaration path claimed. */ + private readonly expressionOwnerCandidates: { + statement: ts.Node; ownerNode: ts.Node; contextKind: JsTypeReferenceContextKind; + }[] = []; + + /** `@type` over `identifier = …`, resolved to its binding; linked after variables exist. */ + private readonly typedAssignments: { statement: ts.Node; binding: JsBinding }[] = []; + + /** The synthetic `` method that owns top-level executable code. */ + moduleInitMethodHash = ''; + + private readonly options: DeclarationExtractionOptions; + private readonly sourceFile: ts.SourceFile; + /** Binding -> its emitted row, so a later pass can link without re-deriving. */ + private readonly variableRowByBinding = new Map(); + /** + * Type row by the name it is bound to in this file. + * + * The index that makes `Foo.prototype.bar = …` findable: the assignment names + * `Foo`, and the declaration it belongs to is whatever `Foo` was declared as. + * Same-file only, by construction — reaching into another file to find `Foo` + * would be cross-file resolution. + */ + /** + * Types by name — **all of them**, in declaration order, with their nodes. + * + * ## A name is not an identity, and this is the sixth instance of that + * + * It was `Map` and first-wins. A module holding two + * declarations named `Parser` — which is what a bundle IS — gave every + * `Parser.prototype.x = …` after the second one to the first. 252 colliding + * names over 4,561 files, 659 shadowed types. + * + * The same failure as `js_parse_gap`'s key, `boundVariableLinkHash`'s, + * `importByLocalName`'s, `declarationTargetByName`'s, + * `accessorFieldByOwnerAndName`'s, and the reverse import link that resolved + * a variable's NAME to an import and wrote itself onto it: a key that is a + * TUPLE OF NAMES is unique only as long as the names are, and JavaScript + * promises that nowhere. §2 already says it — node identity is the BYTE + * RANGE — and these were seven places that used a name instead. + * + * THE CLASS IS CLOSED AT SEVEN. Every name-keyed index in this front end has + * been enumerated and re-keyed on a node, a position or a hash. The next one + * is a regression, not a discovery, and `javascript-key-collisions.ts` is the + * sweep that finds it. + * + * ## Resolved at the REFERENCE, by position + * + * A prototype assignment names its owner with an identifier, so there is no + * byte range at the reference to key on — the range that decides is the + * DECLARATION's. `Foo.prototype.m = …` belongs to the nearest `Foo` declared + * at or before it, which is what a reader sees and what fixes the bundle case + * without changing any single-declaration file. + */ + private readonly typesByName = new Map>(); + /** Accessor pairs, so a getter and a setter for one name produce ONE field row. */ + private readonly accessorFieldByOwnerAndName = new Map(); + /** + * Members counted per type, as they are discovered. + * + * Kept here rather than read back off the row. A constructor function's + * members arrive one assignment at a time across the whole file, so the count + * is not knowable when the row is minted — and a registry is a ROW, not a + * counter. Adding a getter to it would put the tally in a generated file, + * where the next regeneration deletes it: that already happened once, and the + * fix is to move the state rather than re-apply the patch. + */ + private readonly memberCountByType = new Map(); + /** First bound name of each destructuring, so its siblings can point at it. */ + private readonly patternRootByDeclaration = new Map(); + + constructor(options: DeclarationExtractionOptions) { + this.options = options; + this.sourceFile = options.sourceFile; + } + + run(): void { + // The module initializer first: every top-level statement needs an owning + // method, and in CommonJS that is not a fiction — Node wraps the file in a + // function, so the top level genuinely IS a function body. + const moduleInit = this.mintModuleInitializer(); + this.moduleInitMethodHash = moduleInit.getHash(); + + const moduleBlock = this.mintModuleBlock(); + + const context: WalkContext = { + scope: this.options.binder.moduleScope, + ownerType: undefined, + ownerMethod: moduleInit, + ownerMethodQualifiedName: this.options.moduleQualifiedName, + block: moduleBlock, + childIndexByBlock: new Map(), + }; + + // Types first, so an assignment-borne member can find the type it belongs + // to however the file orders them: `Foo.prototype.m = …` above + // `function Foo() {}` is legal and common, because the declaration hoists. + this.indexDeclaredTypes(); + + for (const statement of this.sourceFile.statements) { + this.visit(statement, context); + } + + // `@typedef` and `@callback`: types whose ONLY evidence is a comment. Minted + // before variables so an exported name can find one. + this.emitJsDocTypedefs(); + + // Variables last among the declaration relations: they come from the + // BINDER's table rather than from a syntax walk, which is what lets a `var` + // written in a block be emitted against the function scope it hoists to. + this.emitVariables(); + + // EXPRESSION owners of last resort: every candidate whose statement no + // declaration path claimed during the walk. + for (const candidate of this.expressionOwnerCandidates) { + if (this.typeClaimedStatements.has(nodeKey(candidate.statement))) { + continue; + } + this.pendingTypeReferences.push({ + node: candidate.statement, + ownerKind: JsTypeReferenceOwnerKind.EXPRESSION, + ownerHash: '', + ownerNode: candidate.ownerNode, + contextKind: candidate.contextKind, + link: () => { /* the expression row has no type column to fill */ }, + }); + } + + // The `@type`-over-assignment references, now that their variables exist. + // The tree is OWNED by the variable, which is what makes it reachable; the + // variable's own typeReferenceLinkHash keeps its declaration's tree if it + // has one, because the declaration is the primary statement of its type. + for (const { statement, binding } of this.typedAssignments) { + const row = this.variableRowByBinding.get(binding); + if (row === undefined) { + continue; + } + this.pendingTypeReferences.push({ + node: statement, + ownerKind: JsTypeReferenceOwnerKind.VARIABLE, + ownerHash: row.getHash(), + contextKind: JsTypeReferenceContextKind.VARIABLE, + link: (hash) => { + if (row.typeReferenceLinkHashValue() === '') { + row.setTypeReferenceLinkHash(hash); + } + }, + }); + } + } + + // ------------------------------------------------------------------------- + // the module's own rows + // ------------------------------------------------------------------------- + + private mintModuleInitializer(): JsMethodRegistry { + const moduleScope = this.options.binder.moduleScope; + const row = new JsMethodRegistry({ + name: JS_MODULE_INITIALIZER_NAME, + qualifiedName: `${this.options.moduleQualifiedName}.${JS_MODULE_INITIALIZER_NAME}`, + fileName: this.options.fileName, + filePath: this.options.filePath, + baseMservPath: this.options.baseMservPath, + startLine: 1, + endLine: lastLineOf(this.sourceFile), + startColumn: 1, + methodKind: JsMethodKind.MODULE_INITIALIZER, + declarationForm: JsMethodDeclarationForm.SYNTACTIC, + hoisting: JsHoisting.NOT_APPLICABLE, + isAsync: false, + isGenerator: false, + isStatic: false, + parameterCount: 0, + hasRestParameter: false, + usesArguments: false, + // `this` at a CommonJS top level is `module.exports`; in an ES module it + // is `undefined`. Two different values from one syntax, so the honest + // answer is to decline rather than pick one. + thisBinding: JsThisBinding.NONE, + returnTypeName: '', + declaredTypeSource: JsDeclaredTypeSource.NONE, + bodyPresence: JsBodyPresence.HAS_BODY, + ownerTypeLinkHash: '', + ownerModuleLinkHash: this.options.moduleHash, + ownerScopeLinkHash: this.options.hashOfScope(moduleScope), + bodyScopeLinkHash: this.options.hashOfScope(moduleScope), + enclosingMethodLinkHash: '', + isEntryPoint: false, + methodReferenceKind: '', + modifiers: '', + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.methods.push(row); + return row; + } + + private mintModuleBlock(): JsBlockRegistry { + const end = this.sourceFile.getLineAndCharacterOfPosition(this.sourceFile.end); + const row = new JsBlockRegistry({ + blockKind: JsBlockKind.MODULE_BODY, + label: '', + parentBlockLinkHash: '', + depth: 0, + childIndex: 0, + scopeLinkHash: this.options.hashOfScope(this.options.binder.moduleScope), + opensScope: true, + ownerMethodLinkHash: this.moduleInitMethodHash, + ownerModuleLinkHash: this.options.moduleHash, + startLine: 1, + startColumn: 1, + endLine: end.line + 1, + endColumn: end.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.blocks.push(row); + return row; + } + + // ------------------------------------------------------------------------- + // the walk + // ------------------------------------------------------------------------- + + /** + * Nodes a declaring construct has already handled in full. + * + * ## The rule this makes mechanical + * + * §6 says a tree rooted at a non-emitting node dies before its children are + * enqueued; constraint 4 says a construct reached by two visit paths mints an + * identical key and DOUBLES. The two pull in opposite directions, and the walk + * resolved them by having every declaring construct `return` — which satisfies + * the second and violates the first. + * + * It cost 116 callables over 4,561 files, in four shapes that all look + * different and are one bug: + * + * - `Query.prototype._find = wrapThunk(function (cb) { … })` — the value is a + * CALL, so it takes the field branch and the function inside it vanishes. + * 26 in one file of one package. + * - `Object.assign(Foo.prototype, mixin(), { … })` — a non-literal argument is + * skipped and never walked. + * - `Object.defineProperty(F.prototype, 'x', { value: wrap(function () {}) })` + * — only `get` and `set` are consumed. + * - `util.inherits(Child, makeBase())` — the arguments are read as a heritage + * expression and never walked for declarations. + * + * Every one of them lost a js_method row, its parameters, its body and its + * scope, with nothing missing from any count anyone was looking at. + * + * So the descent is now UNCONDITIONAL and the CONSUMPTION is explicit: a + * construct that fully handles a subtree says so, and the generic walk skips + * exactly that subtree and nothing else. Both rules hold at once, and neither + * depends on remembering to write a descent by hand at a new call site. + * + * ## Per FILE, and that is load-bearing + * + * `nodeKey` is `kind:start:end`, which is unique within a source file and + * **not across one** — two files have a node of the same kind at the same + * offsets constantly. This extractor is constructed once per file inside + * `extractJavaScriptFile`, so the set dies with the file. A longer-lived one + * would silently suppress emission in the second file of every pair that + * happened to collide, which is constraint 1's failure mode exactly: right on + * ten files, dropping rows on ten thousand. + */ + private readonly consumedNodes = new Set(); + + private visit(node: ts.Node, context: WalkContext): void { + // Already minted, with its whole subtree, by a declaring construct that + // reached it directly. Descending again would visit it on a SECOND path and + // mint an identical key — and a duplicate key does not collide, it DOUBLES. + if (this.consumedNodes.has(nodeKey(node))) { + return; + } + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + this.visitClass(node, context); + return; + } + if (ts.isFunctionDeclaration(node)) { + const row = this.visitFunctionLike(node, context, JsMethodKind.FUNCTION_DECLARATION, + JsHoisting.HOISTED_FULLY, JsMethodDeclarationForm.SYNTACTIC, undefined); + // A constructor FUNCTION is a constructor, and its body declares instance + // state exactly as a class constructor's does. `function Router(o) { + // this.options = o }` is the dominant pre-ES6 way of declaring a member, + // and looking only inside class constructors misses every one of them. + // BY NODE, not by name. Two declarations can share a name in one file — + // `class Parser` and a later `function Parser` in a bundle — and a + // name lookup is first-wins, so the second function's `this.x =` members + // were attributed to the first declaration, outside its own line span. + const declaredType = this.typeByDeclarationNode.get(nodeKey(node)); + if (declaredType !== undefined) { + declaredType.setConstructorMethodLinkHash(row.getHash()); + this.constructorByTypeHash.set(declaredType.getHash(), row.getHash()); + if (node.body !== undefined) { + this.emitThisAssignedFields(node.body, declaredType); + } + } + return; + } + if (ts.isFunctionExpression(node)) { + const row = this.visitFunctionLike(node, context, JsMethodKind.FUNCTION_EXPRESSION, + JsHoisting.NOT_HOISTED, JsMethodDeclarationForm.SYNTACTIC, undefined); + // The same treatment a constructor FUNCTION DECLARATION gets, for one + // bound by assignment. Without it `var Logger = function (o) { this.x = o }` + // has a js_type and a js_method that know nothing about each other: no + // constructorMethodLinkHash, and every `this.x =` field unattributed. + const declaredType = this.typeByDeclarationNode.get(nodeKey(node)); + if (declaredType !== undefined) { + declaredType.setConstructorMethodLinkHash(row.getHash()); + this.constructorByTypeHash.set(declaredType.getHash(), row.getHash()); + if (node.body !== undefined) { + this.emitThisAssignedFields(node.body, declaredType); + } + } + return; + } + if (ts.isArrowFunction(node)) { + this.visitFunctionLike(node, context, JsMethodKind.ARROW, + JsHoisting.NOT_HOISTED, JsMethodDeclarationForm.SYNTACTIC, undefined); + return; + } + // `/** @type {T} *\/ (expr)` — an inline JSDoc CAST, the PARENTHESISED form + // and only that form, which is the one the compiler treats as an assertion. + // A parenthesis emits no expression row (§6 unwraps it), so the owner is + // the expression it wraps, resolved after the expression pass like every + // EXPRESSION owner. 1,170 of these had no context to be emitted under. + // ON THE PARENTHESIS ITSELF — `node.jsDoc`, not `ts.getJSDocTypeTag`, which + // walks up to the enclosing declaration and would read + // `/** @type {T} *\/ const x = (raw)` as a cast of `raw`. js-fixtures' 5b is + // that exact control, and the first cut emitted CAST and VARIABLE for it. + if (ts.isParenthesizedExpression(node) + && jsDocTagsOfAllBlocks(node).some((tag) => tag.kind === ts.SyntaxKind.JSDocTypeTag + && (tag as ts.JSDocTypeTag).typeExpression !== undefined)) { + let inner: ts.Expression = node.expression; + while (ts.isParenthesizedExpression(inner)) { + inner = inner.expression; + } + this.pendingTypeReferences.push({ + node, + ownerKind: JsTypeReferenceOwnerKind.EXPRESSION, + ownerHash: '', + ownerNode: inner, + contextKind: JsTypeReferenceContextKind.CAST, + link: () => { /* an expression row has no type column to fill */ }, + }); + } + // `{ /** @type {T} *\/ items: [] }` — an annotated OBJECT-LITERAL PROPERTY. + // An object literal is a value, not a type (§3.2), so there is no js_field + // to own this; the property's VALUE expression is the owner. 268 over the + // corpus with nowhere to go before EXPRESSION was ruled. + if (ts.isPropertyAssignment(node) + && ts.getJSDocTypeTag(node)?.typeExpression !== undefined) { + this.pendingTypeReferences.push({ + node, + ownerKind: JsTypeReferenceOwnerKind.EXPRESSION, + ownerHash: '', + ownerNode: node.initializer, + contextKind: JsTypeReferenceContextKind.FIELD, + link: () => { /* an expression row has no type column to fill */ }, + }); + } + // A METHOD OR ACCESSOR IN AN OBJECT LITERAL: `{ m(x) { … }, get g() { … } }`. + // + // Reached only from the generic descent — a CLASS member never gets here, + // because `visitClassMember` is called directly by `visitClass`. So this + // branch is exactly the object-literal case. + // + // It was missing, and it was the single largest source of lost facts in the + // parser: 3,210 of 3,264 call-site recall misses over 4,529 files, 98.3% of + // them, 1.94% of all shipped-source call sites. the five worst + // packages between 290 and 757 each. Seven forms all lost — shorthand, + // get, set, async, generator, computed-name and nested — while the LONGHAND + // `fnExpr: function (x) { … }` and the arrow beside them were walked + // correctly, which is what says it is this node kind and not a policy. + // + // §6 in its purest form: the tree was rooted at a node neither walker + // recognised, so the body died before its children were enqueued. Nothing + // was miscounted — the calls simply were not there. + // + // The kind is what the LONGHAND already emits, because the two are the same + // function written two ways. No new vocabulary: an object literal is not a + // js_type, so there is no owner and the member is not a CLASS_METHOD. + if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) + || ts.isSetAccessorDeclaration(node)) { + this.visitFunctionLike(node, context, + ts.isGetAccessorDeclaration(node) ? JsMethodKind.GETTER + : ts.isSetAccessorDeclaration(node) ? JsMethodKind.SETTER + : JsMethodKind.FUNCTION_EXPRESSION, + JsHoisting.NOT_HOISTED, JsMethodDeclarationForm.SYNTACTIC, undefined); + if (ts.isComputedPropertyName(node.name)) { + this.visit(node.name.expression, this.ordinaryCode(node.name.expression, context)); + } + return; + } + if (ts.isExpressionStatement(node) && ts.isBinaryExpression(node.expression) + && node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + // `/** @type {T} */ ret = …` — an @type over an assignment to a BARE + // IDENTIFIER. The identifier resolves to a variable declared elsewhere, + // and that variable is what the annotation is ABOUT, exactly as + // `/** @type {T} */ this.x = …` is about the field. The member form + // reached; this one produced nothing, and it is the shape a + // route-everything-to-CAST fix would misclassify first, because it is the + // one currently silent (js-fixtures 5h and 5k). + // + // Recorded now and linked after `emitVariables`, because the variable rows + // come from the binder's table and do not exist yet during the walk. + // Narrow on purpose: an identifier target only. `items.push(4)` under an + // @type attaches to nothing the parser can type, and must stay nothing. + if (ts.isIdentifier(node.expression.left) + && ts.getJSDocTypeTag(node)?.typeExpression !== undefined) { + const resolved = resolveName(context.scope, node.expression.left.text); + if (resolved !== undefined) { + this.typedAssignments.push({ statement: node, binding: resolved.binding }); + this.typeClaimedStatements.add(nodeKey(node)); + } + } + // `/** @type {T} *\/ exports.X = …` and any other annotated assignment no + // declaration path claims: the EXPRESSION is the owner of last resort. + // Decided AFTER the declaring paths below have run, at the end of this + // branch, so a prototype member or a constructor field keeps its own. + const annotatedAssignment = ts.getJSDocTypeTag(node)?.typeExpression !== undefined + ? node : undefined; + // The assignment forms that are DECLARATIONS. Minted here, once, and then + // the walk CONTINUES into the subtree: whatever the declaring construct + // handled in full has marked itself consumed, and anything it did not is + // ordinary code that still contains declarations. + const declaredMember = this.visitDeclaringAssignment(node.expression, context); + if (declaredMember) { + this.typeClaimedStatements.add(nodeKey(node)); + } + if (annotatedAssignment !== undefined) { + // A CANDIDATE, decided after the walk. The claim from a constructor's + // `this.x =` is made by emitThisAssignedFields, which runs AFTER the + // body statements are visited — so a claim checked here, at push time, + // was checked too early and js-fixtures' 5c emitted a FIELD tree and an + // EXPRESSION tree for one annotation. Candidates are resolved against + // the claimed set once every path has had its say. + this.expressionOwnerCandidates.push({ + statement: node, + ownerNode: node.expression, + // A property of an object — `exports.X`, `obj.k` — is the position + // being typed, and FIELD is the vocabulary for "the declared type of + // a property". The owner says which object; this says what kind of + // position. + contextKind: JsTypeReferenceContextKind.FIELD, + }); + } + } + if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) { + this.visitDeclaringCall(node.expression, context); + } + if (ts.isIfStatement(node)) { + // The `else` branch is its OWN block. Emitting only IF loses which arm a + // statement is in — the two are mutually exclusive control flow, and an + // engine joining on the IF block alone reads both arms as reachable + // together. The audit found this: ELSE was declared and never emitted. + this.visitIfStatement(node, context); + return; + } + if (ts.isTryStatement(node)) { + this.visitTryStatement(node, context); + return; + } + if (isBlockLike(node)) { + this.visitBlock(node, context); + return; + } + // Everything else: descend in the same context. Unconditional, because a + // subtree rooted at a node that emits nothing still contains declarations — + // §6's parenthesis and JSX-brace failures were both a subtree dying before + // its children were enqueued. + ts.forEachChild(node, (child) => { + this.visit(child, this.contextFor(child, context)); + }); + } + + /** + * The context for descending into code that is NOT a member of anything. + * + * ## One rule, because clearing it by hand is how the bug keeps coming back + * + * `ownerType` means "the type whose member I am about to emit". It is valid + * for exactly as long as the walk is looking at a member, and the moment it + * descends into ordinary code that code is NOT a member — an arrow inside a + * method body, inside a field initializer, inside a parameter default. + * + * Inheriting it made 2,422 anonymous callables read as members of classes + * they were merely nested in. That was fixed by clearing the field at the one + * place it leaked. Then a new descent into class-field initializers was added + * and **it came straight back**, 30 of them, because + * `static descriptors = { _scriptable: (name) => … }` is a field initializer + * carrying arrows and the class context was passed straight through. + * + * So the rule is a function with a name, used at every descent into ordinary + * code, rather than a `ownerType: undefined` that has to be remembered. + */ + private ordinaryCode(node: ts.Node, context: WalkContext): WalkContext { + return { ...this.contextFor(node, context), ownerType: undefined }; + } + + /** The scope a child sits in, if the binder opened one for it. */ + private contextFor(node: ts.Node, context: WalkContext): WalkContext { + const scope = this.options.binder.enclosingScopeOf.get(nodeKey(node)); + if (scope === undefined || scope === context.scope) { + return context; + } + return { ...context, scope }; + } + + // ------------------------------------------------------------------------- + // classes + // ------------------------------------------------------------------------- + + private visitClass(node: ts.ClassLikeDeclaration, context: WalkContext): void { + const at = this.positionOf(node); + const name = node.name?.text ?? ''; + const isDeclaration = ts.isClassDeclaration(node); + const qualifiedName = name === '' + ? `${context.ownerMethodQualifiedName}.${JS_ANONYMOUS_TYPE_NAME}` + : `${this.options.moduleQualifiedName}.${name}`; + // The scope the class OPENS — `enclosingScopeOf` is the one it sits in. + const classScope = this.options.binder.scopeOpenedBy.get(nodeKey(node)) ?? context.scope; + + const row = new JsTypeRegistry({ + name: name === '' ? JS_ANONYMOUS_TYPE_NAME : name, + qualifiedName, + fileName: this.options.fileName, + filePath: this.options.filePath, + baseMservPath: this.options.baseMservPath, + startLine: at.startLine, + endLine: at.endLine, + startColumn: at.startColumn, + typeCategory: name === '' ? JsTypeCategory.ANONYMOUS_CLASS : JsTypeCategory.CLASS, + declarationForm: isDeclaration + ? JsTypeDeclarationForm.CLASS_DECLARATION + : JsTypeDeclarationForm.CLASS_EXPRESSION, + // Parity slot: JavaScript has no `abstract`. + isAbstract: false, + modifiers: '', + evidenceKind: JsEvidenceKind.SYNTAX, + isTypeOnly: false, + ownerModuleLinkHash: this.options.moduleHash, + ownerScopeLinkHash: this.options.hashOfScope(context.scope), + enclosingMethodLinkHash: context.ownerMethod?.getHash() ?? '', + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.memberCountByType.set(row, node.members.length); + row.setDeclaredMemberCount(node.members.length); + this.types.push(row); + this.typeHashByNode.set(nodeKey(node), row.getHash()); + this.typeNodeByHash.set(row.getHash(), node); + if (name !== '') { + this.recordTypeName(name, row, node); + } + + this.emitExtendsClause(node, row, context); + + const childContext: WalkContext = { + ...context, + scope: classScope, + ownerType: row, + ownerMethodQualifiedName: qualifiedName, + }; + const classBlock = this.mintBlock(node, JsBlockKind.CLASS_BODY, classScope, context, true); + for (const member of node.members) { + this.visitClassMember(member, { ...childContext, block: classBlock }); + } + } + + /** + * `class Child extends Parent` — the one heritage form syntax states directly. + * + * The three call- and assignment-borne forms are handled where the assignment + * is, in {@link visitDeclaringAssignment} and {@link visitDeclaringCall}, + * because there is no class node to hang them off. + */ + private emitExtendsClause( + node: ts.ClassLikeDeclaration, + type: JsTypeRegistry, + context: WalkContext + ): void { + for (const clause of node.heritageClauses ?? []) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue; + } + for (const expression of clause.types) { + this.emitHeritage({ + ownerType: type, + form: JsHeritageForm.EXTENDS_CLAUSE, + superExpression: expression.expression, + context, + sourceNode: undefined, + }); + } + } + } + + private emitHeritage(init: { + ownerType: JsTypeRegistry; + form: JsHeritageForm; + superExpression: ts.Expression; + context: WalkContext; + sourceNode: ts.Node | undefined; + }): void { + const at = this.positionOf(init.superExpression); + // The NAME as written, and nothing resolved. `EventEmitter` stays + // `EventEmitter`; `require('events').EventEmitter` keeps its whole + // expression text. Those two plus `importLinkHash` are the three things §0 + // says make a row complete, and `resolvedTypeLinkHash` stays tier 3. + const written = init.superExpression.getText(this.sourceFile); + const simpleName = ts.isIdentifier(init.superExpression) + ? init.superExpression.text + : ts.isPropertyAccessExpression(init.superExpression) + ? init.superExpression.name.text + : ''; + const row = new JsTypeHeritageRegistry({ + ownerTypeLinkHash: init.ownerType.getHash(), + // Always 0: JavaScript is single-inheritance. + position: 0, + heritageForm: init.form, + superTypeName: simpleName === '' ? written : simpleName, + superTypeExpressionText: written, + isComputedSuperclass: simpleName === '', + // Always true: JavaScript has no `implements`, so TypeScript's + // extends/implements distinction collapses. + inheritsMembers: true, + startLine: at.startLine, + ownerModuleLinkHash: this.options.moduleHash, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.heritages.push(row); + if (init.sourceNode !== undefined) { + const identity = nodeKey(init.sourceNode); + this.pendingExpressionLinks.push({ + nodeIdentity: identity, + link: (hash) => { + row.setSourceExpressionLinkHash(hash); + }, + }); + this.declarationByAssignment.set(identity, row.getHash()); + } + } + + private visitClassMember(member: ts.ClassElement, context: WalkContext): void { + if (ts.isConstructorDeclaration(member)) { + const row = this.visitFunctionLike(member, context, JsMethodKind.CONSTRUCTOR, + JsHoisting.NOT_APPLICABLE, JsMethodDeclarationForm.SYNTACTIC, undefined); + context.ownerType?.setConstructorMethodLinkHash(row.getHash()); + if (context.ownerType !== undefined) { + this.constructorByTypeHash.set(context.ownerType.getHash(), row.getHash()); + } + // `this.x = …` in a constructor DECLARES a member, and it is the dominant + // way pre-ES6 code declares instance state — so the field extractor has to + // look inside a function body, not only at a class body's members. + if (member.body !== undefined && context.ownerType !== undefined) { + this.emitThisAssignedFields(member.body, context.ownerType); + } + return; + } + if (ts.isMethodDeclaration(member)) { + this.visitFunctionLike(member, context, JsMethodKind.CLASS_METHOD, + JsHoisting.TDZ, JsMethodDeclarationForm.SYNTACTIC, undefined); + return; + } + if (ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) { + const isGetter = ts.isGetAccessorDeclaration(member); + const row = this.visitFunctionLike(member, context, + isGetter ? JsMethodKind.GETTER : JsMethodKind.SETTER, + JsHoisting.TDZ, JsMethodDeclarationForm.SYNTACTIC, undefined); + this.recordAccessor(member, row, isGetter, context); + return; + } + if (ts.isClassStaticBlockDeclaration(member)) { + this.visitFunctionLike(member, context, JsMethodKind.STATIC_BLOCK, + JsHoisting.NOT_APPLICABLE, JsMethodDeclarationForm.SYNTACTIC, undefined); + return; + } + if (ts.isPropertyDeclaration(member)) { + this.emitClassField(member, context); + // `handleClick = () => { … }` — a class field holding an arrow is the + // standard way to write an auto-bound method, and it IS a callable member. + // Without a js_method row the arrow's body had no owner, so every + // expression inside it was attributed to the `` initializer, and + // c32 had nothing to point at. CLASS_METHOD for the role, and the arrow's + // own thisBinding = LEXICAL carries the difference from a prototype + // method — which is the reason people write it this way. + if (member.initializer !== undefined && isCallableExpression(member.initializer)) { + this.visitFunctionLike( + member.initializer as ts.FunctionLikeDeclaration, context, + JsMethodKind.CLASS_METHOD, JsHoisting.NOT_APPLICABLE, + JsMethodDeclarationForm.SYNTACTIC, undefined, + ts.isComputedPropertyName(member.name) ? '' : propertyNameText(member.name), + // NO assignedOwner: the owner resolves through `context.ownerType`, + // which is this class, and passing it explicitly would ALSO increment + // `declaredMemberCount` — which already counted this member as part of + // `node.members.length`. The field row and the method row describe ONE + // member, and the count must say one. Verified: a five-member class + // with two arrow fields reported seven. `countMember` exists for + // members discovered by assignment OUTSIDE the class body, which are + // not in `node.members` and genuinely need counting. + undefined, + hasModifier(member, ts.SyntaxKind.StaticKeyword) + ); + } else if (member.initializer !== undefined) { + // A field initializer that is NOT itself a callable can still CONTAIN + // one: `static descriptors = { _scriptable: (name) => name !== 'x' }` + // is how one charting library writes its per-option predicates, and every arrow in + // there had no js_method row. The field is a declaration; what it is + // initialised to is ordinary code and has to be walked. + this.visit(member.initializer, this.ordinaryCode(member.initializer, context)); + } + if (ts.isComputedPropertyName(member.name)) { + this.visit(member.name.expression, this.ordinaryCode(member.name.expression, context)); + } + return; + } + // Anything else in a class body — an index signature parsed out of Flow, a + // semicolon. Descend so nothing inside is lost, and NOT as a member: this + // branch is reached precisely because the node is not one. + ts.forEachChild(member, (child) => { + this.visit(child, this.ordinaryCode(child, context)); + }); + } + + // ------------------------------------------------------------------------- + // callables + // ------------------------------------------------------------------------- + + private visitFunctionLike( + node: ts.FunctionLikeDeclaration | ts.ClassStaticBlockDeclaration, + context: WalkContext, + kind: JsMethodKind, + hoisting: JsHoisting, + declarationForm: JsMethodDeclarationForm, + /** For an assignment-declared method, the assignment node. */ + sourceNode: ts.Node | undefined, + /** For an assignment-declared method, the name it was given. */ + assignedName?: string, + assignedOwner?: JsTypeRegistry, + isStaticMember?: boolean + ): JsMethodRegistry { + // CONSUMED. This callable and everything inside it is handled here, so the + // generic descent must not reach it again — see {@link consumedNodes}. + this.consumedNodes.add(nodeKey(node)); + const at = this.positionOf(node); + // The scope the callable OPENS. It was `enclosingScopeOf`, which is the scope the + // callable is declared IN, so every method's bodyScopeLinkHash pointed at + // its enclosing scope — populated, valid, and wrong on every row. + const bodyScope = this.options.binder.scopeOpenedBy.get(nodeKey(node)) ?? context.scope; + const name = assignedName ?? this.nameOfCallable(node, kind); + const ownerType = assignedOwner ?? context.ownerType; + const qualifiedName = ownerType !== undefined + ? `${ownerType.qualifiedName}.${name}` + : `${context.ownerMethodQualifiedName}.${name}`; + + const parameters = ts.isClassStaticBlockDeclaration(node) + ? ([] as readonly ts.ParameterDeclaration[]) + : node.parameters; + const returnType = declaredTypeFromJsDoc(node, this.sourceFile, 'returns'); + const body = ts.isClassStaticBlockDeclaration(node) ? node.body : node.body; + + const row = new JsMethodRegistry({ + name, + qualifiedName, + fileName: this.options.fileName, + filePath: this.options.filePath, + baseMservPath: this.options.baseMservPath, + startLine: at.startLine, + endLine: at.endLine, + startColumn: at.startColumn, + methodKind: kind, + declarationForm, + hoisting, + isAsync: hasModifier(node, ts.SyntaxKind.AsyncKeyword), + isGenerator: !ts.isClassStaticBlockDeclaration(node) + && node.asteriskToken !== undefined, + isStatic: isStaticMember ?? hasModifier(node, ts.SyntaxKind.StaticKeyword), + parameterCount: parameters.length, + hasRestParameter: parameters.some((p) => p.dotDotDotToken !== undefined), + // `arguments` is a parameter list nobody declared. An engine modelling + // only named parameters loses the whole channel, so the flag is read from + // the body rather than inferred from the parameter count. + usesArguments: body !== undefined && referencesArguments(body), + thisBinding: thisBindingFor(kind), + returnTypeName: returnType.name, + declaredTypeSource: returnType.source, + bodyPresence: bodyPresenceOf(body), + ownerTypeLinkHash: ownerType?.getHash() ?? '', + ownerModuleLinkHash: this.options.moduleHash, + ownerScopeLinkHash: this.options.hashOfScope(context.scope), + bodyScopeLinkHash: this.options.hashOfScope(bodyScope), + enclosingMethodLinkHash: context.ownerMethod?.getHash() ?? '', + isEntryPoint: false, + // Parity slot with Java, always `""` — JavaScript has no `::`. + methodReferenceKind: '', + modifiers: modifiersOf(node), + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.methods.push(row); + this.methodHashByNode.set(nodeKey(node), row.getHash()); + this.methodNodeByIdentity.set(nodeKey(node), node); + if (assignedOwner !== undefined) { + this.countMember(assignedOwner); + } + + if (sourceNode !== undefined) { + const identity = nodeKey(sourceNode); + this.pendingExpressionLinks.push({ + nodeIdentity: identity, + link: (hash) => { + row.setSourceExpressionLinkHash(hash); + }, + }); + this.declarationByAssignment.set(identity, row.getHash()); + } + + for (let index = 0; index < parameters.length; index += 1) { + this.emitParameter(parameters[index]!, index, row, bodyScope); + } + + const childContext: WalkContext = { + ...context, + scope: bodyScope, + ownerMethod: row, + ownerMethodQualifiedName: qualifiedName, + // CLEARED. Anything declared inside a method BODY is not a member of the + // enclosing type — an arrow inside `startWorking()` is a local callable, + // and `enclosingMethodLinkHash` is the link that says where it lives. + // Inheriting the owner made 2,422 anonymous callables over 816 real files + // read as members of a class, so an engine listing a type's methods got + // phantom `` entries. Found by following js-oracle's + // declaredMemberCount check one step further. + ownerType: undefined, + }; + + // A PARAMETER'S DEFAULT VALUE is code, and `emitParameter` emits a row for + // the parameter without walking it. + // + // `({ shouldUseFileNameAsKey = () => true } = {}) => { … }` and + // `function f(contexts, node, modifier = (node) => node)` are the shapes — + // a callable that exists only as a default, which had no js_method row at + // all. The same §6 loss as the body, one level to the left, and easy to + // miss because the parameter row IS emitted and looks complete. + // + // `forEachChild` rather than just `.initializer`, because a destructured + // parameter carries its defaults on the BINDING ELEMENTS inside the + // pattern, not on the parameter itself. + for (const parameter of parameters) { + ts.forEachChild(parameter, (child) => { + this.visit(child, this.ordinaryCode(child, { ...context, scope: bodyScope, + ownerMethod: row, ownerMethodQualifiedName: qualifiedName })); + }); + } + + // THE DESCENT. §6: the worklist stops at function boundaries and must be + // descended EXPLICITLY — `return function () { … }` once emitted the + // function and nothing inside it, costing 45 of 691 call sites, and every + // row that WAS emitted was correct. There were simply fewer of them. + if (body !== undefined) { + if (ts.isBlock(body)) { + // A `static { … }` body is a CLASS_STATIC_BLOCK, not a function body. + // It reaches this path because a static block is modelled as a callable + // — it has its own scope and its own `this` — and the block row was + // taking the kind of the path rather than the kind of the construct. + // CLASS_STATIC_BLOCK was 0 of 148,000 block rows while the matching + // js_scope row was emitted correctly beside it, which is what said the + // construct was reached and only the label was wrong. + const bodyBlock = this.mintBlock(body, + kind === JsMethodKind.STATIC_BLOCK + ? JsBlockKind.CLASS_STATIC_BLOCK + : JsBlockKind.FUNCTION_BODY, + bodyScope, context, true); + for (const statement of body.statements) { + this.visit(statement, { ...childContext, block: bodyBlock }); + } + } else { + this.visit(body, childContext); + } + } + // NO second pass over `parameter.initializer` here. One stood after the + // body descent from the day prototype assignments landed, and the + // `forEachChild` walk above was added later without removing it — so every + // default value was visited on TWO paths. Nothing showed for as long as + // every construct a default can hold was deduplicated elsewhere (callables + // by `consumedNodes`, expressions by their own walk); the first thing that + // was not, a JSDoc cast in a default, doubled its key on the day it was + // emitted. Constraint 4: visit each construct on exactly one path. + return row; + } + + private nameOfCallable( + node: ts.FunctionLikeDeclaration | ts.ClassStaticBlockDeclaration, + kind: JsMethodKind + ): string { + if (kind === JsMethodKind.CONSTRUCTOR) { + return JS_ANONYMOUS_METHOD_NAMES.CONSTRUCTOR; + } + if (kind === JsMethodKind.STATIC_BLOCK) { + return JS_ANONYMOUS_METHOD_NAMES.STATIC_BLOCK; + } + if (ts.isClassStaticBlockDeclaration(node)) { + return JS_ANONYMOUS_METHOD_NAMES.STATIC_BLOCK; + } + const name = node.name; + if (name === undefined) { + return kind === JsMethodKind.ARROW + ? JS_ANONYMOUS_METHOD_NAMES.ARROW + : JS_ANONYMOUS_METHOD_NAMES.FUNCTION_EXPRESSION; + } + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + if (ts.isPrivateIdentifier(name)) { + return name.text; + } + // A computed member name. Syntax does not fix it, so the row says so rather + // than guessing — 715 computed member names were measured. + return ''; + } + + private emitParameter( + parameter: ts.ParameterDeclaration, + position: number, + owner: JsMethodRegistry, + scope: JsScopeNode + ): void { + const at = this.positionOf(parameter); + const declaredType = declaredTypeFromJsDoc(parameter, this.sourceFile, 'param'); + // `@param {T} [x]` / `[x=y]` — the bracket IS the optionality, and the + // schema says so ("JSDoc [x] or a default value"). Both columns were + // derived from the code alone and the bracket discarded on the way past: + // a silent loss, since the type still came out right. The SAME selection + // that typed the parameter, so a bracket on a tag that names another + // parameter cannot leak here. hasDefault/defaultValueText stay the code's: + // whether a comment-only `[x=y]` populates them is filed, not decided. + const documentingTag = jsDocParameterTagFor(parameter); + // TWO JSDoc optional markers, and the checker treats them identically + // (§3.5a): `[x]` is `isBracketed`, `{T=}` is a JSDocOptionalType at the + // root of the type expression — 29% of documented optionals, and a + // parameter whose own type tree says OPTIONAL while c6 says false is the + // populated-and-wrong shape. Both public tests; `ts.isOptionalDeclaration` + // is internal and throws on ordinary input. + const bracketed = documentingTag !== undefined && ts.isJSDocParameterTag(documentingTag) + && documentingTag.isBracketed; + const optionalTyped = documentingTag?.typeExpression?.type !== undefined + && ts.isJSDocOptionalType(documentingTag.typeExpression.type); + const isIdentifier = ts.isIdentifier(parameter.name); + const bindingForm = isIdentifier + ? (parameter.initializer !== undefined + ? JsParameterBindingForm.ASSIGNMENT_PATTERN + : JsParameterBindingForm.IDENTIFIER) + : ts.isObjectBindingPattern(parameter.name) + ? JsParameterBindingForm.OBJECT_PATTERN + : JsParameterBindingForm.ARRAY_PATTERN; + const row = new JsMethodParameterRegistry({ + // `""` for a destructuring pattern: the parameter binds N names and none + // of them is the parameter's own name. The names are js_variable rows. + name: isIdentifier ? (parameter.name as ts.Identifier).text : '', + position, + ownerMethodLinkHash: owner.getHash(), + declaredTypeName: declaredType.name, + declaredTypeSource: declaredType.source, + isOptional: parameter.questionToken !== undefined + || parameter.initializer !== undefined + || bracketed + || optionalTyped, + hasDefault: parameter.initializer !== undefined, + defaultValueText: parameter.initializer?.getText(this.sourceFile) ?? '', + isRest: parameter.dotDotDotToken !== undefined, + bindingForm, + patternBindingCount: isIdentifier ? 0 : countPatternBindings(parameter.name), + bindingRegime: JsBindingRegime.PARAMETER, + scopeLinkHash: this.options.hashOfScope(scope), + startLine: at.startLine, + startColumn: at.startColumn, + // Parity slot with TypeScript, always `false`. + isParameterProperty: false, + ownerModuleLinkHash: this.options.moduleHash, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.methodParameters.push(row); + this.parameterHashByNode.set(nodeKey(parameter), row.getHash()); + if (declaredType.source !== JsDeclaredTypeSource.NONE) { + this.pendingTypeReferences.push({ + node: parameter, + ownerKind: JsTypeReferenceOwnerKind.METHOD_PARAMETER, + ownerHash: row.getHash(), + contextKind: JsTypeReferenceContextKind.PARAM, + functionNode: parameter.parent, + parameterName: isIdentifier ? (parameter.name as ts.Identifier).text : '', + link: (hash) => { + row.setTypeReferenceLinkHash(hash); + }, + }); + } + } + + // ------------------------------------------------------------------------- + // fields + // ------------------------------------------------------------------------- + + private emitClassField(member: ts.PropertyDeclaration, context: WalkContext): void { + if (context.ownerType === undefined) { + return; + } + const at = this.positionOf(member); + const fieldType = declaredTypeFromJsDoc(member, this.sourceFile, 'type'); + const computed = ts.isComputedPropertyName(member.name); + const name = computed ? '' : propertyNameText(member.name); + const row = new JsFieldRegistry({ + name, + qualifiedName: `${context.ownerType.qualifiedName}.${name}`, + ownerTypeLinkHash: context.ownerType.getHash(), + declarationForm: JsFieldDeclarationForm.CLASS_FIELD, + isStatic: hasModifier(member, ts.SyntaxKind.StaticKeyword), + // `#x` specifically, not `_x`: the first is an access boundary the runtime + // enforces, the second is a naming convention. + isPrivateName: ts.isPrivateIdentifier(member.name), + declaredTypeName: fieldType.name, + declaredTypeSource: fieldType.source, + hasInitializer: member.initializer !== undefined, + accessorPairKind: JsAccessorPairKind.NONE, + isComputedName: computed, + startLine: at.startLine, + startColumn: at.startColumn, + ownerModuleLinkHash: this.options.moduleHash, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.fields.push(row); + this.fieldHashByNode.set(nodeKey(member), row.getHash()); + if (fieldType.source !== JsDeclaredTypeSource.NONE) { + this.pendingTypeReferences.push({ + node: member, + ownerKind: JsTypeReferenceOwnerKind.FIELD, + ownerHash: row.getHash(), + contextKind: JsTypeReferenceContextKind.FIELD, + link: (hash) => { + row.setTypeReferenceLinkHash(hash); + }, + }); + } + if (member.initializer !== undefined) { + const identity = nodeKey(member.initializer); + this.pendingExpressionLinks.push({ + nodeIdentity: identity, + link: (hash) => { + row.setInitializerExpressionLinkHash(hash); + }, + }); + } + } + + /** + * `this.x = …` inside a constructor or constructor function. + * + * Walked with its own recursion rather than the main dispatch, because it must + * NOT descend into nested functions: `this` inside a nested `function` is a + * different receiver entirely, so `function () { this.x = 1 }` inside a + * constructor declares a member of something else. An arrow IS descended into, + * because an arrow's `this` is the constructor's. + */ + private emitThisAssignedFields(body: ts.Node, ownerType: JsTypeRegistry): void { + const visit = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) + || ts.isClassLike(node)) { + return; + } + if (ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(node.left) + && node.left.expression.kind === ts.SyntaxKind.ThisKeyword) { + this.emitAssignedField({ + ownerType, + name: node.left.name.text, + form: JsFieldDeclarationForm.CONSTRUCTOR_THIS_ASSIGNMENT, + isStatic: false, + isComputedName: false, + at: this.positionOf(node.left), + assignment: node, + hasInitializer: true, + }); + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + } + + private emitAssignedField(init: { + ownerType: JsTypeRegistry; + name: string; + form: JsFieldDeclarationForm; + isStatic: boolean; + isComputedName: boolean; + at: Position; + assignment: ts.Node; + hasInitializer: boolean; + }): JsFieldRegistry { + // `/** @type {T} */ this.x = …` — the JSDoc sits on the STATEMENT, not on + // the assignment expression, so the lookup walks out to it. Without this + // every member declared by assignment lost its declared type, which in a + // pre-ES6 codebase is every member there is. + const declaredType = declaredTypeFromJsDoc( + enclosingStatement(init.assignment) ?? init.assignment, this.sourceFile, 'type' + ); + const row = new JsFieldRegistry({ + name: init.name, + qualifiedName: `${init.ownerType.qualifiedName}.${init.name}`, + ownerTypeLinkHash: init.ownerType.getHash(), + declarationForm: init.form, + isStatic: init.isStatic, + isPrivateName: init.name.startsWith('#'), + declaredTypeName: declaredType.name, + declaredTypeSource: declaredType.source, + hasInitializer: init.hasInitializer, + accessorPairKind: JsAccessorPairKind.NONE, + isComputedName: init.isComputedName, + startLine: init.at.startLine, + startColumn: init.at.startColumn, + ownerModuleLinkHash: this.options.moduleHash, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.fields.push(row); + if (declaredType.source !== JsDeclaredTypeSource.NONE) { + const statement = enclosingStatement(init.assignment) ?? init.assignment; + this.typeClaimedStatements.add(nodeKey(statement)); + this.pendingTypeReferences.push({ + node: statement, + ownerKind: JsTypeReferenceOwnerKind.FIELD, + ownerHash: row.getHash(), + contextKind: JsTypeReferenceContextKind.FIELD, + link: (hash) => { + row.setTypeReferenceLinkHash(hash); + }, + }); + } + this.countMember(init.ownerType); + const identity = nodeKey(init.assignment); + this.pendingExpressionLinks.push({ + nodeIdentity: identity, + link: (hash) => { + row.setSourceExpressionLinkHash(hash); + }, + }); + this.declarationByAssignment.set(identity, row.getHash()); + // `this.x = …` inside a class constructor is an OWN property per instance, + // not a prototype member. Setting the flag for it would claim every ES6 + // class in the corpus has prototype-assigned members, which is the opposite + // of what the column is for — it exists to identify pre-ES6 shapes. + if (init.form !== JsFieldDeclarationForm.CONSTRUCTOR_THIS_ASSIGNMENT) { + init.ownerType.setHasPrototypeMembers(); + } + return row; + } + + /** + * A getter and a setter for one name produce ONE field row. + * + * Keyed on owner plus name, so the second accessor upgrades the pair kind + * rather than minting a second row — which would **double** the field count + * for every accessor pair in the corpus and look entirely plausible. + */ + private recordAccessor( + member: ts.AccessorDeclaration, + method: JsMethodRegistry, + isGetter: boolean, + context: WalkContext + ): void { + if (context.ownerType === undefined) { + return; + } + const name = ts.isComputedPropertyName(member.name) ? '' : propertyNameText(member.name); + // THE PAIRING KEY, and both of its discriminators were missing. + // + // It was `ownerHash:name`. Two defects in one key, and the measured one is + // not the obvious one. + // + // (1) NO `isStatic`. `class C { static get x(){} get x(){} }` declares two + // different members and they shared a key, so a static getter could pair + // with an instance setter. + // + // (2) A COMPUTED name reports `''`, so `get [a]()` and `get [b]()` on one + // class were indistinguishable — and staticness would not separate them + // either. Every collision actually measured over 4,561 files was this, not + // the static case: 4 keys, 5 shadowed accessors. + // + // For a computed key the honest answer is to REFUSE TO PAIR. Whether + // `get [a]()` and `set [a]()` are one property depends on what `a` evaluates + // to at class-definition time, which is a runtime fact — the same reason + // INDEX_CALL is reserved. So a computed accessor keys on its own BYTE RANGE + // and therefore pairs with nothing, which loses a pairing that was never + // knowable and prevents inventing one that is wrong. + const discriminator = ts.isComputedPropertyName(member.name) + ? `computed@${member.getStart(this.sourceFile)}:${member.getEnd()}` + : `${name}:${hasModifier(member, ts.SyntaxKind.StaticKeyword)}`; + const key = `${context.ownerType.getHash()}:${discriminator}`; + const existing = this.accessorFieldByOwnerAndName.get(key); + if (existing !== undefined) { + existing.setAccessorPairKind(JsAccessorPairKind.GETTER_SETTER); + if (isGetter) { + existing.setGetterMethodLinkHash(method.getHash()); + } else { + existing.setSetterMethodLinkHash(method.getHash()); + } + // The pair's @type may sit on THIS accessor, the second one: the row + // was minted at the first with no type and the tag had no row to land + // on — `get cancelBubble()` then `/** @type {boolean} */ set + // cancelBubble(v)`, from the recall residue. + if (existing.declaredTypeNameValue() === '') { + const secondType = declaredTypeFromJsDoc(member, this.sourceFile, 'type'); + if (secondType.source !== JsDeclaredTypeSource.NONE) { + existing.setDeclaredType(secondType.name, secondType.source); + this.pendingTypeReferences.push({ + node: member, + ownerKind: JsTypeReferenceOwnerKind.FIELD, + ownerHash: existing.getHash(), + contextKind: JsTypeReferenceContextKind.FIELD, + link: (hash) => { + existing.setTypeReferenceLinkHash(hash); + }, + }); + } + } + return; + } + const at = this.positionOf(member); + // `/** @type {T} */ get x() { … }` — the type of the PROPERTY the accessor + // pair presents, which is this field. It was hard-coded to none: 70 of 70 + // getter `@type` tags over the corpus emitted no reference, while the same + // tag on a class field beside them did. Read from the getter (a setter's + // `@type` is unusual and describes the same property), through the same + // selection every other field uses. + const accessorType = declaredTypeFromJsDoc(member, this.sourceFile, 'type'); + const row = new JsFieldRegistry({ + name, + qualifiedName: `${context.ownerType.qualifiedName}.${name}`, + ownerTypeLinkHash: context.ownerType.getHash(), + declarationForm: JsFieldDeclarationForm.CLASS_FIELD, + isStatic: hasModifier(member, ts.SyntaxKind.StaticKeyword), + isPrivateName: ts.isPrivateIdentifier(member.name), + declaredTypeName: accessorType.name, + declaredTypeSource: accessorType.source, + hasInitializer: false, + accessorPairKind: isGetter + ? JsAccessorPairKind.GETTER_ONLY + : JsAccessorPairKind.SETTER_ONLY, + isComputedName: ts.isComputedPropertyName(member.name), + startLine: at.startLine, + startColumn: at.startColumn, + ownerModuleLinkHash: this.options.moduleHash, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + if (isGetter) { + row.setGetterMethodLinkHash(method.getHash()); + } else { + row.setSetterMethodLinkHash(method.getHash()); + } + this.fields.push(row); + if (accessorType.source !== JsDeclaredTypeSource.NONE) { + this.pendingTypeReferences.push({ + node: member, + ownerKind: JsTypeReferenceOwnerKind.FIELD, + ownerHash: row.getHash(), + contextKind: JsTypeReferenceContextKind.FIELD, + link: (hash) => { + row.setTypeReferenceLinkHash(hash); + }, + }); + } + this.accessorFieldByOwnerAndName.set(key, row); + } + + // ------------------------------------------------------------------------- + // declarations written as assignments + // ------------------------------------------------------------------------- + + /** + * Recognises an assignment that is really a declaration, and returns whether + * it was one. + * + * ## The §3 defect class, and the reason this method exists + * + * `Foo.prototype.bar = function () {}` emits trivially as an assignment with a + * function on the right. **The structure is entirely absent** from that + * emission: nothing says `bar` is a member of `Foo`. Get it wrong and the + * engine sees no methods at all in any pre-ES6 codebase. + * + * Four shapes, all measured, and `STATIC_ASSIGNMENT` at 521 is more common + * than the famous prototype one at 361. + */ + private visitDeclaringAssignment( + assignment: ts.BinaryExpression, + context: WalkContext + ): boolean { + const target = assignment.left; + if (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) { + return false; + } + + // `Child.prototype = ` — either a heritage edge or a bulk member + // install, decided by what is on the right. + if (ts.isPropertyAccessExpression(target) && target.name.text === 'prototype') { + const ownerName = typeNameOfExpression(target.expression); + if (ownerName !== undefined) { + return this.visitPrototypeReplacement(assignment, ownerName, context); + } + } + + // `Foo.prototype.bar = …` — an instance member. + if (ts.isPropertyAccessExpression(target) + && ts.isPropertyAccessExpression(target.expression) + && target.expression.name.text === 'prototype' + && typeNameOfExpression(target.expression.expression) !== undefined) { + return this.emitAssignedMember({ + assignment, + ownerName: typeNameOfExpression(target.expression.expression)!, + memberName: target.name.text, + isStatic: false, + methodForm: JsMethodDeclarationForm.PROTOTYPE_ASSIGNMENT, + fieldForm: JsFieldDeclarationForm.PROTOTYPE_ASSIGNMENT, + nameNode: target.name, + context, + }); + } + + // `Foo.staticM = …` — the static counterpart, and the more common one. + // Only when `Foo` is a type declared in THIS file: `exports.x = …` and + // `module.exports.x = …` are module edges, not members, and `obj.x = …` on + // an ordinary object is neither. + if (ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.expression)) { + const owner = this.typeNamedAt(target.expression.text, target); + if (owner !== undefined) { + return this.emitAssignedMember({ + assignment, + ownerName: target.expression.text, + memberName: target.name.text, + isStatic: true, + methodForm: JsMethodDeclarationForm.STATIC_ASSIGNMENT, + fieldForm: JsFieldDeclarationForm.STATIC_ASSIGNMENT, + nameNode: target.name, + context, + }); + } + } + return false; + } + + /** + * `Child.prototype = `. + * + * Three different constructs share this syntax and only the right-hand side + * tells them apart: + * + * - `= Object.create(Parent.prototype)` — an **inheritance edge**. + * - `= new Parent()` — an inheritance edge, and a broken one: it runs the + * parent constructor at definition time. + * - `= { m() {}, n() {} }` — a **bulk member install**, which also discards + * whatever was on the prototype before, `constructor` included. + */ + private visitPrototypeReplacement( + assignment: ts.BinaryExpression, + ownerName: string, + context: WalkContext + ): boolean { + const owner = this.typeNamedAt(ownerName, assignment); + if (owner === undefined) { + return false; + } + const value = assignment.right; + + if (ts.isCallExpression(value) && isObjectCreate(value) && value.arguments.length > 0) { + this.emitHeritage({ + ownerType: owner, + form: JsHeritageForm.OBJECT_CREATE_PROTOTYPE, + superExpression: prototypeOwnerOf(value.arguments[0]!) ?? value.arguments[0]!, + context, + sourceNode: assignment, + }); + return true; + } + if (ts.isNewExpression(value)) { + this.emitHeritage({ + ownerType: owner, + form: JsHeritageForm.PROTOTYPE_ASSIGNMENT, + superExpression: value.expression, + context, + sourceNode: assignment, + }); + return true; + } + if (ts.isObjectLiteralExpression(value)) { + this.emitPrototypeObjectLiteral(value, owner, assignment, context); + return true; + } + if (ts.isPropertyAccessExpression(value) && value.name.text === 'prototype') { + this.emitHeritage({ + ownerType: owner, + form: JsHeritageForm.PROTOTYPE_ASSIGNMENT, + superExpression: value.expression, + context, + sourceNode: assignment, + }); + return true; + } + return false; + } + + private emitPrototypeObjectLiteral( + literal: ts.ObjectLiteralExpression, + owner: JsTypeRegistry, + assignment: ts.Node, + context: WalkContext + ): void { + owner.setHasPrototypeMembers(); + for (const property of literal.properties) { + if (ts.isMethodDeclaration(property)) { + this.visitFunctionLike(property, context, JsMethodKind.CLASS_METHOD, + JsHoisting.NOT_APPLICABLE, JsMethodDeclarationForm.PROTOTYPE_OBJECT_LITERAL, + assignment, propertyNameText(property.name), owner, false); + continue; + } + if (ts.isPropertyAssignment(property)) { + const name = ts.isComputedPropertyName(property.name) + ? '' : propertyNameText(property.name); + if (isCallableExpression(property.initializer)) { + this.visitFunctionLike( + property.initializer as ts.FunctionLikeDeclaration, context, + JsMethodKind.CLASS_METHOD, + JsHoisting.NOT_HOISTED, JsMethodDeclarationForm.PROTOTYPE_OBJECT_LITERAL, + assignment, name, owner, false); + continue; + } + this.emitAssignedField({ + ownerType: owner, + name, + form: JsFieldDeclarationForm.PROTOTYPE_ASSIGNMENT, + isStatic: false, + isComputedName: ts.isComputedPropertyName(property.name), + at: this.positionOf(property), + assignment, + hasInitializer: true, + }); + } + } + } + + private emitAssignedMember(init: { + assignment: ts.BinaryExpression; + ownerName: string; + memberName: string; + isStatic: boolean; + methodForm: JsMethodDeclarationForm; + fieldForm: JsFieldDeclarationForm; + nameNode: ts.Node; + context: WalkContext; + }): boolean { + const owner = this.typeNamedAt(init.ownerName, init.assignment); + if (owner === undefined) { + return false; + } + owner.setHasPrototypeMembers(); + const value = init.assignment.right; + if (isCallableExpression(value)) { + // CLASS_METHOD, not FUNCTION_EXPRESSION. The syntax is a function + // expression and the ROLE is a member of `owner` — `declarationForm` + // already records the syntax, and `methodKind` records what the thing is. + // Emitting the syntax here would make every prototype method read as a + // free function to an engine filtering on kind, and §4's lesson is that a + // correctly-positioned row with the wrong kind is invisible to recall, + // completeness and oracle adjudication alike. + this.visitFunctionLike(value as ts.FunctionLikeDeclaration, init.context, + JsMethodKind.CLASS_METHOD, + JsHoisting.NOT_HOISTED, init.methodForm, init.assignment, + init.memberName, owner, init.isStatic); + return true; + } + this.emitAssignedField({ + ownerType: owner, + name: init.memberName, + form: init.fieldForm, + isStatic: init.isStatic, + isComputedName: false, + at: this.positionOf(init.nameNode), + assignment: init.assignment, + hasInitializer: true, + }); + // The value is NOT walked here. It is left to the generic descent, which + // now runs for every declaring construct — the field row describes the + // member, and whatever the value contains is ordinary code. + return true; + } + + /** + * A CALL that is really a declaration. + * + * `util.inherits(Child, Parent)` is an **extends edge expressed as a call**, + * and `Object.assign(Foo.prototype, { … })` and + * `Object.defineProperty(Foo.prototype, 'x', { … })` are bulk member installs. + * + * Recognition deliberately does not require the receiver to be literally named + * `util` or `Object`: `require('util').inherits(…)` and a destructured + * `const { inherits } = require('util')` are both normal spellings, so the + * member name plus the argument shape is what identifies the construct. + */ + private visitDeclaringCall(call: ts.CallExpression, context: WalkContext): boolean { + const callee = call.expression; + const memberName = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ts.isIdentifier(callee) ? callee.text : ''; + + if (memberName === 'inherits' && call.arguments.length >= 2 + && ts.isIdentifier(call.arguments[0]!)) { + const owner = this.typeNamedAt((call.arguments[0] as ts.Identifier).text, call); + if (owner !== undefined) { + this.emitHeritage({ + ownerType: owner, + form: JsHeritageForm.UTIL_INHERITS, + superExpression: call.arguments[1]!, + context, + sourceNode: call, + }); + return true; + } + } + + if (memberName === 'assign' && call.arguments.length >= 2) { + const target = call.arguments[0]!; + const owner = this.typeOfPrototypeExpression(target); + if (owner !== undefined) { + for (let i = 1; i < call.arguments.length; i += 1) { + const argument = call.arguments[i]!; + if (ts.isObjectLiteralExpression(argument)) { + this.emitObjectAssignMembers(argument, owner, call, context); + } + } + return true; + } + } + + if (memberName === 'defineProperty' && call.arguments.length >= 3) { + const owner = this.typeOfPrototypeExpression(call.arguments[0]!); + const nameArgument = call.arguments[1]!; + if (owner !== undefined && ts.isStringLiteralLike(nameArgument)) { + this.emitDefineProperty(call, owner, nameArgument.text, + call.arguments[2]!, context); + return true; + } + } + return false; + } + + /** + * The type an `X.prototype` or bare `X` expression names, if this file + * declares one. + * + * Same-file only, by construction. `Object.assign(other.prototype, …)` where + * `other` came from another module names a type this parser cannot see, and + * reaching through the import to find it would be cross-file resolution — the + * engine's work. The row is simply not minted, which is the honest answer. + */ + private typeOfPrototypeExpression(node: ts.Expression): JsTypeRegistry | undefined { + if (ts.isPropertyAccessExpression(node) && node.name.text === 'prototype' + && ts.isIdentifier(node.expression)) { + return this.typeNamedAt(node.expression.text, node); + } + if (ts.isIdentifier(node)) { + return this.typeNamedAt(node.text, node); + } + return undefined; + } + + private emitObjectAssignMembers( + literal: ts.ObjectLiteralExpression, + owner: JsTypeRegistry, + call: ts.Node, + context: WalkContext + ): void { + owner.setHasPrototypeMembers(); + for (const property of literal.properties) { + if (ts.isMethodDeclaration(property)) { + this.visitFunctionLike(property, context, JsMethodKind.CLASS_METHOD, + JsHoisting.NOT_APPLICABLE, JsMethodDeclarationForm.OBJECT_ASSIGN_PROTOTYPE, + call, propertyNameText(property.name), owner, false); + continue; + } + if (ts.isPropertyAssignment(property) && isCallableExpression(property.initializer)) { + this.visitFunctionLike( + property.initializer as ts.FunctionLikeDeclaration, context, + JsMethodKind.CLASS_METHOD, + JsHoisting.NOT_HOISTED, JsMethodDeclarationForm.OBJECT_ASSIGN_PROTOTYPE, + call, propertyNameText(property.name), owner, false); + continue; + } + if (ts.isPropertyAssignment(property)) { + this.emitAssignedField({ + ownerType: owner, + name: propertyNameText(property.name), + form: JsFieldDeclarationForm.PROTOTYPE_ASSIGNMENT, + isStatic: false, + isComputedName: ts.isComputedPropertyName(property.name), + at: this.positionOf(property), + assignment: call, + hasInitializer: true, + }); + } + } + } + + /** + * `Object.defineProperty(Foo.prototype, 'x', { get() {}, set() {} })`. + * + * The only form that can declare a member non-writable, and the only one where + * a getter and a setter arrive in one statement — so the field row's + * `accessorPairKind` is decided from the descriptor literal rather than by + * pairing two separate declarations. + */ + private emitDefineProperty( + call: ts.CallExpression, + owner: JsTypeRegistry, + name: string, + descriptor: ts.Expression, + context: WalkContext + ): void { + owner.setHasPrototypeMembers(); + const field = this.emitAssignedField({ + ownerType: owner, + name, + form: JsFieldDeclarationForm.OBJECT_DEFINE_PROPERTY, + isStatic: false, + isComputedName: false, + at: this.positionOf(call), + assignment: call, + hasInitializer: true, + }); + if (!ts.isObjectLiteralExpression(descriptor)) { + return; + } + let hasGetter = false; + let hasSetter = false; + for (const property of descriptor.properties) { + const propertyName = ts.isPropertyAssignment(property) + || ts.isMethodDeclaration(property) || ts.isGetAccessorDeclaration(property) + ? propertyNameText(property.name) + : ''; + if (propertyName === 'writable' && ts.isPropertyAssignment(property) + && property.initializer.kind === ts.SyntaxKind.FalseKeyword) { + field.setIsReadonly(); + } + if (propertyName !== 'get' && propertyName !== 'set') { + continue; + } + // `get: function () {}` and the shorthand `get() {}` are the same + // descriptor. The shorthand is a MethodDeclaration, which the callable + // test did not admit, so it fell through to the generic walk as an + // unowned FUNCTION_EXPRESSION while the field row beside it said + // accessorPairKind NONE — found by the prototypes torture script. + const callable = ts.isPropertyAssignment(property) + ? property.initializer + : ts.isMethodDeclaration(property) ? property : undefined; + if (callable === undefined + || !(isCallableExpression(callable) || ts.isMethodDeclaration(callable))) { + continue; + } + const method = this.visitFunctionLike( + callable as ts.FunctionLikeDeclaration, context, + propertyName === 'get' ? JsMethodKind.GETTER : JsMethodKind.SETTER, + JsHoisting.NOT_HOISTED, JsMethodDeclarationForm.OBJECT_DEFINE_PROPERTY, + call, name, owner, false); + if (propertyName === 'get') { + hasGetter = true; + field.setGetterMethodLinkHash(method.getHash()); + } else { + hasSetter = true; + field.setSetterMethodLinkHash(method.getHash()); + } + } + field.setAccessorPairKind( + hasGetter && hasSetter ? JsAccessorPairKind.GETTER_SETTER + : hasGetter ? JsAccessorPairKind.GETTER_ONLY + : hasSetter ? JsAccessorPairKind.SETTER_ONLY + : JsAccessorPairKind.NONE + ); + } + + // ------------------------------------------------------------------------- + // constructor functions + // ------------------------------------------------------------------------- + + /** + * Indexes every type this file declares, BEFORE the walk. + * + * Two reasons it cannot be done lazily during the walk: + * + * 1. **Function declarations hoist.** `Foo.prototype.m = …` above + * `function Foo() {}` is legal and common, and a lazy index would not have + * `Foo` yet. + * 2. **A constructor function is recognised by evidence, not by syntax.** The + * evidence is a `new Foo()`, a `Foo.prototype.x = …`, or a `this.x = …` in + * the body — all of which may appear anywhere in the file. A capital-letter + * heuristic would classify every capitalised import as a constructor. + */ + private indexDeclaredTypes(): void { + const constructorNames = this.findConstructorFunctionNames(); + const visit = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name !== undefined + && constructorNames.has(node.name.text)) { + this.mintConstructorFunctionType(node, node.name.text); + } + // A CONSTRUCTOR FUNCTION BOUND BY ASSIGNMENT, which is how every + // 2010-2015 library spells one. + // + // The recogniser matched `function A(x) {}` and nothing else, so + // `var B = function (x) {}`, `var C = function C(x) {}`, + // `exports.F = function (x) {}` and `var E = exports.E = function (x) {}` + // minted NO js_type at all — and with it went every prototype method, + // every `this.x =` field and every heritage edge hanging off that name. + // one 2015-era logging library lost 56 of 57 prototype members and all 6 of its + // `util.inherits` edges: 98% of its inheritance model absent, because + // `var Logger = exports.Logger = function (options) { … }` is the spelling + // it uses. + // + // The evidence is unchanged and still syntactic — `new X`, `X.prototype`, + // `util.inherits(X, …)` or a body assigning to `this`. Only the shapes the + // NAME can be bound by are widened, which is why this cannot start + // classifying ordinary functions as types. + for (const bound of constructorBindingsOf(node)) { + if (constructorNames.has(bound.name)) { + this.mintConstructorFunctionType(bound.callable, bound.name); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(this.sourceFile, visit); + } + + private findConstructorFunctionNames(): Set { + const names = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) { + names.add(node.expression.text); + } + if (ts.isPropertyAccessExpression(node) && node.name.text === 'prototype' + && ts.isIdentifier(node.expression)) { + names.add(node.expression.text); + } + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) + && node.expression.name.text === 'inherits' && node.arguments.length >= 2) { + for (const argument of node.arguments) { + if (ts.isIdentifier(argument)) { + names.add(argument.text); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(this.sourceFile, visit); + // A function whose body assigns to `this` is a constructor too — the + // pre-ES6 way of declaring instance state, and the only evidence when the + // type is exported and never `new`-ed in its own file. + const assignsThis = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name !== undefined + && node.body !== undefined && bodyAssignsToThis(node.body)) { + names.add(node.name.text); + } + // The same evidence, for a callable bound by assignment. Without this + // `var Stream = function () { this.x = 1; }` is a constructor with no + // evidence in its own file unless something happens to `new` it there. + for (const bound of constructorBindingsOf(node)) { + if (bound.callable.body !== undefined && bodyAssignsToThis(bound.callable.body)) { + names.add(bound.name); + } + } + ts.forEachChild(node, assignsThis); + }; + ts.forEachChild(this.sourceFile, assignsThis); + return names; + } + + private mintConstructorFunctionType( + node: ts.FunctionDeclaration | ts.FunctionExpression, + name: string + ): void { + // Guarded on the NODE rather than on the name: two declarations sharing a + // name are two entities, and `js_type`'s key already carries startLine and + // startColumn so both are representable. Guarding on the name collapsed them + // and put one's members on the other. + if (this.typeByDeclarationNode.has(nodeKey(node))) { + return; + } + const at = this.positionOf(node); + // The scope the constructor function is declared IN. `enclosingScopeOf` already + // answers that; the old code read it as the function's own scope and took + // `.parent`, which overshot to GLOBAL on every constructor function. + const scope = this.options.binder.enclosingScopeOf.get(nodeKey(node)) + ?? this.options.binder.moduleScope; + const row = new JsTypeRegistry({ + name, + qualifiedName: `${this.options.moduleQualifiedName}.${name}`, + fileName: this.options.fileName, + filePath: this.options.filePath, + baseMservPath: this.options.baseMservPath, + startLine: at.startLine, + endLine: at.endLine, + startColumn: at.startColumn, + typeCategory: JsTypeCategory.CONSTRUCTOR_FUNCTION, + declarationForm: JsTypeDeclarationForm.PROTOTYPE_CONSTRUCTOR, + isAbstract: false, + modifiers: '', + evidenceKind: JsEvidenceKind.SYNTAX, + isTypeOnly: false, + ownerModuleLinkHash: this.options.moduleHash, + ownerScopeLinkHash: this.options.hashOfScope(scope), + enclosingMethodLinkHash: '', + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.types.push(row); + this.recordTypeName(name, row, node); + this.typeNodeByHash.set(row.getHash(), node); + this.typeByDeclarationNode.set(nodeKey(node), row); + // Deliberately NOT recorded in typeHashByNode: the NODE is a function + // declaration, and it already maps to a js_method row there. One node, two + // relations, two indices — conflating them would make a later lookup return + // whichever was written last. + } + + // ------------------------------------------------------------------------- + // blocks + // ------------------------------------------------------------------------- + + /** + * `if (c) A else B` — three rows, not one. + * + * The condition belongs to the IF block; `A` and `B` are separate blocks + * because they are mutually exclusive. A single block for both arms tells an + * engine that a statement in the `then` and one in the `else` are reachable + * together, which is the opposite of what the syntax says. + */ + private visitIfStatement(node: ts.IfStatement, context: WalkContext): void { + const ifBlock = this.mintBlock(node, JsBlockKind.IF, undefined, context, false); + this.linkCondition(ifBlock, node.expression); + const thenContext: WalkContext = { + ...context, block: ifBlock, + }; + this.visit(node.expression, thenContext); + this.visit(node.thenStatement, thenContext); + if (node.elseStatement === undefined) { + return; + } + // An `else if` chain: the else arm IS another if statement, and giving it an + // ELSE block of its own as well would mint a block per level of a chain that + // has no braces. The nested `if` is visited directly and gets its own IF row. + if (ts.isIfStatement(node.elseStatement)) { + this.visit(node.elseStatement, thenContext); + return; + } + const elseBlock = this.mintBlock(node.elseStatement, JsBlockKind.ELSE, undefined, + { ...context, block: ifBlock }, false); + this.visit(node.elseStatement, { + ...context, block: elseBlock, + }); + } + + /** + * `try A catch (e) B finally C` — four rows. + * + * `FINALLY` was declared and never emitted: a finally body is a plain `Block`, + * so it came out as a generic BLOCK and the fact that it runs on **every** + * path — including the throwing one — was lost. That is a control-flow claim, + * not a label. + */ + private visitTryStatement(node: ts.TryStatement, context: WalkContext): void { + const tryBlock = this.mintBlock(node, JsBlockKind.TRY, undefined, context, false); + const inner: WalkContext = { + ...context, block: tryBlock, + }; + this.visit(node.tryBlock, inner); + if (node.catchClause !== undefined) { + this.visit(node.catchClause, inner); + } + if (node.finallyBlock !== undefined) { + const finallyBlock = this.mintBlock(node.finallyBlock, JsBlockKind.FINALLY, + undefined, inner, false); + for (const statement of node.finallyBlock.statements) { + this.visit(statement, { + ...inner, block: finallyBlock, + }); + } + } + } + + private visitBlock(node: ts.Node, context: WalkContext): void { + const kind = blockKindOf(node); + const condition = conditionOf(node); + // Does this block OPEN a scope? `enclosingScopeOf` is the one it sits in, which + // is `context.scope` by construction, so the old test could never be true + // for a block the binder gave its own scope. + const scope = this.options.binder.scopeOpenedBy.get(nodeKey(node)); + const opensScope = scope !== undefined; + const block = this.mintBlock(node, kind, opensScope ? scope : undefined, context, + opensScope); + if (condition !== undefined) { + this.linkCondition(block, condition); + } + const childContext: WalkContext = { + ...context, + scope: opensScope ? scope! : context.scope, + block, + }; + if (ts.isLabeledStatement(node)) { + this.visit(node.statement, childContext); + return; + } + ts.forEachChild(node, (child) => { + this.visit(child, childContext); + }); + } + + private mintBlock( + node: ts.Node, + kind: JsBlockKind, + scope: JsScopeNode | undefined, + context: WalkContext, + opensScope: boolean + ): JsBlockRegistry { + const at = this.positionOf(node); + const parentHash = context.block?.getHash() ?? ''; + const childIndex = context.childIndexByBlock.get(parentHash) ?? 0; + context.childIndexByBlock.set(parentHash, childIndex + 1); + const row = new JsBlockRegistry({ + blockKind: kind, + // `outer:`. TypeScript emitted the loop and DROPPED the label, so a + // `break outer` named a target nothing in the fact base identified. + label: ts.isLabeledStatement(node) ? node.label.text : '', + parentBlockLinkHash: parentHash, + // DERIVED from the parent, not threaded through the context. It was + // `context.blockDepth`, incremented by hand at each site — `+1` for a then + // branch, `+2` for an else, nothing at all for a function or class body — + // and 41 of 66 scaffold blocks disagreed with their parent by the time + // anything asserted the relation. A depth that is the parent's plus one by + // construction cannot be threaded wrong. + depth: context.block === undefined ? 0 : context.block.depth + 1, + childIndex, + scopeLinkHash: scope === undefined ? '' : this.options.hashOfScope(scope), + opensScope, + ownerMethodLinkHash: context.ownerMethod?.getHash() ?? this.moduleInitMethodHash, + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + endLine: at.endLine, + endColumn: at.endColumn, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.blocks.push(row); + this.blockHashByNode.set(nodeKey(node), row.getHash()); + return row; + } + + // ------------------------------------------------------------------------- + // variables, from the BINDER rather than from syntax + // ------------------------------------------------------------------------- + + /** + * Emits one `js_variable` row per binding the binder recorded. + * + * ## Why this reads the binder's table and not the syntax + * + * The two scope columns are the entire point of this relation, and only the + * binder knows both: a `var` written inside a block has that block as its + * syntactic scope and the enclosing **function** scope as its declaration + * scope. A syntax walk sees one of those and would have to re-implement + * JavaScript's hoisting rules to find the other. + * + * It also means `GLOBAL_IMPLICIT` — a binding with no declaration syntax + * anywhere — arrives through the same path as every other binding, rather than + * needing a special case that could be forgotten. + */ + private emitVariables(): void { + const reassigned = this.collectReassignedNames(); + for (const binding of this.options.binder.bindings) { + if (binding.regime === JsBindingRegime.PARAMETER) { + // A parameter's NAME is a variable row, but its position and default + // belong to js_method_parameter, which emitParameter already minted. + // Emitting it here too would be the same construct on two paths. + continue; + } + const node = binding.declarationNode; + const at = node === null + ? { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1 } + : this.positionOf(node); + const declaration = node === null ? undefined : declarationOwning(node); + // endLine is the DECLARATOR's last line, not the name's. + // + // The binder records a binding's declarationNode as the name Identifier, + // and an identifier is always one line — so endLine equalled startLine on + // all 140,302 rows, by construction, since the first commit. js-oracle + // ruled it the declarator's extent: `const x = 1` still ends on its own + // line, `const x = {` … `}` ends where the object literal does. That + // bounds where the value is CONSTRUCTED, which an engine cannot re-derive + // from a start line and a name. Redefined rather than removed, because + // column order is frozen and removal would shift four columns and the PK. + // + // The declarator is the nearest declaration the name belongs to: a + // VariableDeclaration (whose end is its initialiser's), a function or + // class declaration (its body's end), an import (the specifier's). A + // parameter never reaches here. + const declarator = node === null ? undefined : declaratorOf(node); + const endLine = declarator === undefined + ? at.endLine + : this.sourceFile.getLineAndCharacterOfPosition(declarator.getEnd()).line + 1; + const initializer = declaration !== undefined + && ts.isVariableDeclaration(declaration) + ? declaration.initializer + : undefined; + // A destructuring declaration's `@type` is the PATTERN's type — `const + // [{ _instance }, forceUpdate] = …` under `@type {[StoreRef, Fn]}` types + // the pair, not each name — so it belongs to the pattern ROOT binding + // alone, as the multi-declarator rule gives a statement's type to its + // first declarator (§3.14 ruling). Every binding of the pattern took it: + // one comment, N trees, each with its own owner, so the PK gate saw N + // distinct keys. The non-root bindings read NONE: their own type is a + // component the tree does not decompose, and `[StoreRef, Fn]` on + // `forceUpdate` is a wrong type, not a conservative one. + const isPatternMember = declaration !== undefined && ts.isVariableDeclaration(declaration) + && !ts.isIdentifier(declaration.name) + && this.patternRootByDeclaration.has(nodeKey(declaration)); + const variableType = declaration === undefined || isPatternMember + ? { name: '', source: JsDeclaredTypeSource.NONE } + : declaredTypeFromJsDoc(declaration, this.sourceFile, 'type'); + const row = new JsVariableRegistry({ + name: binding.name, + qualifiedName: `${this.options.moduleQualifiedName}.${binding.name}`, + bindingRegime: binding.regime, + declarationScopeLinkHash: this.options.hashOfScope(binding.declarationScope), + syntacticScopeLinkHash: this.options.hashOfScope(binding.syntacticScope), + hasTemporalDeadZone: binding.hasTemporalDeadZone, + bindingForm: bindingFormOf(declaration), + declaredTypeName: variableType.name, + declaredTypeSource: variableType.source, + hasInitializer: initializer !== undefined, + // The ESM route to "this name is a module alias". + // + // `const x = require('y')` gave REQUIRE_CALL 12,345 times corpus-wide; + // `import { x } from 'y'` gave NONE every time, because an import + // binding has no VariableDeclaration above it and so no initializer to + // classify. The enum documents the pair in as many words — REQUIRE_CALL + // is "the name is a module alias", IMPORT_BINDING is "also a module + // alias, by the other route" — and only one route was wired. + // + // An engine following module aliases therefore saw every CommonJS one + // and no ESM one: not a wrong answer, a silently half-sized one. + initializerKind: node !== null && isImportBindingName(node) + ? JsInitializerKind.IMPORT_BINDING + : initializerKindOf(initializer), + ownerMethodLinkHash: this.methodHashForScope(binding.declarationScope), + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + endLine, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + if (reassigned.has(binding.name)) { + row.setIsReassigned(); + } + this.variables.push(row); + this.variableRowByBinding.set(binding, row); + // Names bound by ONE destructuring point at a shared root, so + // `const { a, b } = o` is recoverable as one construct rather than two + // unrelated bindings that happen to share a line. + if (declaration !== undefined && ts.isVariableDeclaration(declaration) + && !ts.isIdentifier(declaration.name)) { + const rootHash = this.patternRootByDeclaration.get(nodeKey(declaration)); + if (rootHash === undefined) { + this.patternRootByDeclaration.set(nodeKey(declaration), row.getHash()); + } else { + row.setPatternRootVariableLinkHash(rootHash); + } + } + if (node !== null) { + this.registerCommentOwner(enclosingStatement(node), 'VARIABLE', row.getHash()); + } + if (variableType.source !== JsDeclaredTypeSource.NONE && declaration !== undefined) { + this.pendingTypeReferences.push({ + node: declaration, + ownerKind: JsTypeReferenceOwnerKind.VARIABLE, + ownerHash: row.getHash(), + contextKind: JsTypeReferenceContextKind.VARIABLE, + link: (hash) => { + row.setTypeReferenceLinkHash(hash); + }, + }); + } + if (node !== null) { + this.variableHashByNode.set(nodeKey(node), row.getHash()); + } + if (initializer !== undefined) { + const identity = nodeKey(initializer); + this.pendingExpressionLinks.push({ + nodeIdentity: identity, + link: (hash) => { + row.setInitializerExpressionLinkHash(hash); + }, + }); + } + } + } + + /** The row a binding produced, for the passes that link against it. */ + rowForBinding(binding: JsBinding): JsVariableRegistry | undefined { + return this.variableRowByBinding.get(binding); + } + + /** + * The AST node a method row was minted from. + * + * Needed by the JSDoc pass, which reads `@returns` and `@template` off the + * node. The index is inverted here rather than stored on the row, because + * nothing may be keyed on a parser node object and a row is a ROW. + */ + nodeForMethod(method: JsMethodRegistry): ts.Node | undefined { + if (this.methodNodeByHash.size === 0) { + for (const [identity, hash] of this.methodHashByNode) { + const node = this.methodNodeByIdentity.get(identity); + if (node !== undefined) { + this.methodNodeByHash.set(hash, node); + } + } + } + return this.methodNodeByHash.get(method.getHash()); + } + + /** The AST node a type row was minted from, for the JSDoc heritage pass. */ + nodeForType(type: JsTypeRegistry): ts.Node | undefined { + return this.typeNodeByHash.get(type.getHash()); + } + + private readonly methodNodeByHash = new Map(); + private readonly methodNodeByIdentity = new Map(); + private readonly typeNodeByHash = new Map(); + /** + * Constructor-function types by the NODE that declares them. + * + * `typesByName` resolves a reference to the nearest PRECEDING declaration, + * which is right for `Foo.prototype.m = …` reaching back to `Foo`. It is + * still the wrong index for attributing a constructor's own `this.x =` + * members, because those belong to the declaration they are written INSIDE + * and not to whichever one precedes them — so that attribution keys on the + * declaring NODE and needs no name at all. Nine fields over 816 real files, + * found by asking whether every member sits inside its owner's line span. + */ + private readonly typeByDeclarationNode = new Map(); + + /** + * The constructor a type declares, by the type's hash. + * + * For `js_expression.introducesDeclarationLinkHash`, which is declared + * FK→`js_method`: a class expression introduces a TYPE, and the callable it + * introduces is that type's constructor. + */ + constructorMethodOf(typeHash: string): string | undefined { + return this.constructorByTypeHash.get(typeHash); + } + + private readonly constructorByTypeHash = new Map(); + + /** The type declared under `name` in this file, for same-file one-hop linking. */ + typeNamed(name: string): JsTypeRegistry | undefined { + return this.typesByName.get(name)?.[0]?.row; + } + + /** Records a declaration under its name, keeping every one of them. */ + private recordTypeName(name: string, row: JsTypeRegistry, node: ts.Node): void { + const existing = this.typesByName.get(name) ?? []; + existing.push({ row, start: node.getStart(this.sourceFile) }); + // Declaration order, so the nearest-preceding search below is a scan of a + // sorted list rather than a search of whatever order the walk happened to + // reach them in. + existing.sort((a, b) => a.start - b.start); + this.typesByName.set(name, existing); + } + + /** + * The type `name` refers to AT this reference, not the first one in the file. + * + * Nearest-preceding: among the declarations of that name, the last one + * starting at or before the reference. A reference that precedes every + * declaration falls back to the first, which is the hoisting case — a + * `Foo.prototype.m =` above `function Foo(){}` still means that `Foo`. + * + * With one declaration, which is almost every file, this returns exactly what + * the first-wins map returned. It differs only where the name is reused, which + * is precisely where first-wins was wrong. + */ + private typeNamedAt(name: string, reference: ts.Node): JsTypeRegistry | undefined { + const candidates = this.typesByName.get(name); + if (candidates === undefined || candidates.length === 0) { + return undefined; + } + if (candidates.length === 1) { + return candidates[0]!.row; + } + const at = reference.getStart(this.sourceFile); + let chosen = candidates[0]!; + for (const candidate of candidates) { + if (candidate.start <= at) { + chosen = candidate; + } + } + return chosen.row; + } + + /** + * `@typedef {{a: string}} Foo` and `@callback Handler`. + * + * **1,825 and 103 measured — types with no declaration syntax anywhere.** The + * row carries `evidenceKind = COMMENT_ONLY` and a `startLine` inside a + * comment, and an FK from a `js_variable` to one of them is an ordinary FK. + * + * `isTypeOnly` is true and the gate asserts no call site resolves into one — + * `@callback` names a callable shape and is exactly the row most likely to be + * mistaken for a call target. It is not one. + * + * The JSDoc nodes are reached through `node.jsDoc`, where the COMPILER put + * them: these tags are parsed into the AST and used for inference under + * `checkJs`, which is why treating JSDoc as trivia would leave this language + * with no declared-type channel at all. + */ + private emitJsDocTypedefs(): void { + const visit = (node: ts.Node): void => { + // `ts.getJSDocTags` surfaces only the LAST attached block's tags, and a + // file that opens with two `@typedef` comments before its first statement + // attaches all three blocks to that one statement — measured: three blocks + // in, one tag out. Reading `node.jsDoc` directly is the only way to see + // them all, and it is the same internal property `ts-fact-extractor.ts` + // reads `parseDiagnostics` from. + for (const tag of jsDocTagsOfAllBlocks(node)) { + if (!ts.isJSDocTypedefTag(tag) && !ts.isJSDocCallbackTag(tag)) { + continue; + } + const name = tag.name === undefined ? '' : tag.name.getText(this.sourceFile); + if (name === '') { + continue; + } + if (this.typesByName.has(name)) { + // A `@typedef` documenting a class that also exists in syntax. The + // syntax row wins; minting a second would DOUBLE the type rather than + // collide, and the two would disagree about `evidenceKind`. + continue; + } + const at = this.positionOf(tag); + const row = new JsTypeRegistry({ + name, + qualifiedName: `${this.options.moduleQualifiedName}.${name}`, + fileName: this.options.fileName, + filePath: this.options.filePath, + baseMservPath: this.options.baseMservPath, + startLine: at.startLine, + endLine: at.endLine, + startColumn: at.startColumn, + typeCategory: ts.isJSDocCallbackTag(tag) + ? JsTypeCategory.JSDOC_CALLBACK + : JsTypeCategory.JSDOC_TYPEDEF, + declarationForm: JsTypeDeclarationForm.JSDOC_TYPEDEF, + isAbstract: false, + modifiers: '', + evidenceKind: JsEvidenceKind.COMMENT_ONLY, + isTypeOnly: true, + ownerModuleLinkHash: this.options.moduleHash, + ownerScopeLinkHash: this.options.hashOfScope(this.options.binder.moduleScope), + enclosingMethodLinkHash: '', + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.types.push(row); + this.recordTypeName(name, row, tag); + this.jsDocTypeTags.push({ tag, row }); + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(this.sourceFile, visit); + } + + /** + * Names written to after they are declared. + * + * A same-file scan, and deliberately syntactic: it asks whether the name + * appears as an assignment target anywhere in the file, not whether the write + * is reachable. For a `let` or a `var` that is the difference between a + * binding an engine may treat as constant and one it may not. + */ + private collectReassignedNames(): Set { + const names = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isBinaryExpression(node) && ts.isIdentifier(node.left) + && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment + && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment) { + names.add(node.left.text); + } + if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) + && ts.isIdentifier(node.operand) + && (node.operator === ts.SyntaxKind.PlusPlusToken + || node.operator === ts.SyntaxKind.MinusMinusToken)) { + names.add(node.operand.text); + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(this.sourceFile, visit); + return names; + } + + /** + * The method whose body scope this is, walking out to the nearest one. + * + * A module-level binding gets `""`, which is the honest answer: the + * `` initializer owns top-level STATEMENTS, and a name declared at the + * top level belongs to the module rather than to a method. + */ + private methodHashForScope(scope: JsScopeNode): string { + for (let current: JsScopeNode | null = scope; current !== null; + current = current.parent) { + if (current.ownerNode === null) { + continue; + } + const hash = this.methodHashByNode.get(nodeKey(current.ownerNode)); + if (hash !== undefined) { + return hash; + } + } + return ''; + } + + /** + * The GUARD of a block, linked once the expression pass has minted its row. + * + * The narrowing lever: a block whose condition is not reachable as a row is a + * block an engine cannot reason about entering. TypeScript found the same — + * 440 type predicates measured, all useless until the guard had a hash. + */ + private linkCondition(block: JsBlockRegistry, condition: ts.Expression): void { + this.pendingExpressionLinks.push({ + nodeIdentity: nodeKey(condition), + link: (hash) => { + block.setConditionExpressionLinkHash(hash); + }, + }); + } + + /** First-wins: the OUTERMOST node at an offset is what a comment documents. */ + private registerCommentOwner( + node: ts.Node | undefined, + kind: string, + hash: string + ): void { + if (node === undefined) { + return; + } + const start = node.getStart(this.sourceFile); + if (!this.commentOwnerStarts.has(start)) { + this.commentOwnerStarts.set(start, { kind, hash }); + } + } + + /** One more member on this type, counted and written back to the row. */ + private countMember(type: JsTypeRegistry): void { + const next = (this.memberCountByType.get(type) ?? 0) + 1; + this.memberCountByType.set(type, next); + type.setDeclaredMemberCount(next); + } + + private positionOf(node: ts.Node): Position { + return rangeOf(node, this.sourceFile); + } +} + +interface Position { + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + readonly endColumn: number; +} + +/** + * What the walk carries down instead of re-deriving at each row. + * + * Owner derivation by walking `.parent` or by comparing positions picks the + * wrong owner whenever two candidates begin at the same offset — constant in + * JavaScript, where an IIFE's parenthesis, its function and its call all start + * together. + */ +interface WalkContext { + readonly scope: JsScopeNode; + readonly ownerType: JsTypeRegistry | undefined; + readonly ownerMethod: JsMethodRegistry | undefined; + readonly ownerMethodQualifiedName: string; + readonly block: JsBlockRegistry | undefined; + /** Next child index per parent block hash. Shared, so ordering is global. */ + readonly childIndexByBlock: Map; +} + +/** + * Block forms handled by the generic path. + * + * `if` and `try` are deliberately ABSENT: each mints more than one row — an + * `else` arm and a `finally` body are blocks of their own — so they have + * dedicated visitors. Leaving them here as well would visit them on two paths, + * and a construct visited twice DOUBLES its rows rather than colliding. + */ +function isBlockLike(node: ts.Node): boolean { + return ts.isBlock(node) || ts.isForStatement(node) + || ts.isForInStatement(node) || ts.isForOfStatement(node) + || ts.isWhileStatement(node) || ts.isDoStatement(node) + || ts.isCatchClause(node) + || ts.isSwitchStatement(node) || ts.isCaseClause(node) + || ts.isDefaultClause(node) || ts.isLabeledStatement(node); +} + +function blockKindOf(node: ts.Node): JsBlockKind { + if (ts.isForStatement(node)) { + return JsBlockKind.FOR; + } + if (ts.isForInStatement(node)) { + return JsBlockKind.FOR_IN; + } + if (ts.isForOfStatement(node)) { + return JsBlockKind.FOR_OF; + } + if (ts.isWhileStatement(node)) { + return JsBlockKind.WHILE; + } + if (ts.isDoStatement(node)) { + return JsBlockKind.DO; + } + if (ts.isCatchClause(node)) { + return JsBlockKind.CATCH; + } + if (ts.isSwitchStatement(node)) { + return JsBlockKind.SWITCH; + } + if (ts.isCaseClause(node) || ts.isDefaultClause(node)) { + return JsBlockKind.SWITCH_CASE; + } + if (ts.isLabeledStatement(node)) { + return JsBlockKind.LABELED; + } + return JsBlockKind.BLOCK; +} + +/** The expression a block is guarded by, where it has one. */ +function conditionOf(node: ts.Node): ts.Expression | undefined { + if (ts.isWhileStatement(node) || ts.isDoStatement(node) + || ts.isSwitchStatement(node)) { + return node.expression; + } + if (ts.isForStatement(node)) { + return node.condition; + } + if (ts.isForInStatement(node) || ts.isForOfStatement(node)) { + return node.expression; + } + if (ts.isCaseClause(node)) { + return node.expression; + } + return undefined; +} + +function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { + return ts.canHaveModifiers(node) + && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === kind); +} + +function modifiersOf(node: ts.Node): string { + if (!ts.canHaveModifiers(node)) { + return ''; + } + return (ts.getModifiers(node) ?? []) + .map((modifier) => ts.tokenToString(modifier.kind) ?? '') + .filter((text) => text !== '') + .sort() + .join(','); +} + +/** + * `this` inside this callable. + * + * An arrow is `LEXICAL` — it inherits `this` from where it was written, which is + * what makes `this.f()` work inside a callback. A class method is `DYNAMIC` + * despite appearing bound, because detaching it (`const m = obj.method`) loses + * the receiver, and that detachment is the single most common source of + * `undefined` receivers in real code. + */ +/** + * The type NAME an owner expression refers to, for a prototype assignment. + * + * `Foo.prototype.m = …` names `Foo` with an identifier. `exports.F.prototype.m = …` + * names it with a property access, and that spelling is how a library that + * assigns its constructor straight onto `exports` writes every one of its + * methods. Matching only the identifier left those members as anonymous function + * expressions owned by nothing. + * + * The TAIL name is what is used, and only the tail: `exports.F` and `Foo.F` both + * name `F`. That is the same lookup `typesByName` already performs for the + * identifier case — resolved to the nearest preceding declaration — so it + * introduces no new ambiguity, and the caller still has to find a type actually + * declared in this file before anything is emitted. + */ +function typeNameOfExpression(node: ts.Expression): string | undefined { + if (ts.isIdentifier(node)) { + return node.text; + } + if (ts.isPropertyAccessExpression(node)) { + return node.name.text; + } + return undefined; +} + +/** + * The NAMES a function expression is bound to, for every shape that binds one. + * + * ## Why this is a list of shapes rather than a rule + * + * A constructor function is recognised from evidence — `new X`, `X.prototype`, + * `util.inherits(X, …)`, a body assigning to `this`. That evidence names an + * IDENTIFIER. Finding the callable that identifier refers to is the other half, + * and before this the only shape it could follow was `function X() {}`. + * + * Four shapes, all measured in the corpus and all previously lost: + * + * ```js + * var B = function (x) {}; // anonymous, bound by a declaration + * var C = function C(x) {}; // named function expression + * exports.F = function (x) {}; // bound to a property, never to a local + * var E = exports.E = function (x) {}; // both at once — the 2015-era spelling + * ``` + * + * The last is why the chain is followed rather than matched: `var E = ` + * has a BinaryExpression initializer whose right side is the callable, and both + * `E` and the property name are names the same function answers to. + * + * ## An arrow is deliberately absent + * + * `var B = () => {}` cannot be a constructor: an arrow has no `[[Construct]]` + * and `new B()` is a TypeError. Including it would mint a js_type for something + * the language forbids instantiating. + */ +function constructorBindingsOf( + node: ts.Node +): ReadonlyArray<{ name: string; callable: ts.FunctionExpression }> { + const out: { name: string; callable: ts.FunctionExpression }[] = []; + + // `var B = ` and `var E = exports.E = `. + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) + && node.initializer !== undefined) { + const callable = callableThroughAssignments(node.initializer); + if (callable !== undefined) { + out.push({ name: node.name.text, callable }); + } + } + + // `exports.F = `, `Foo.Bar = `, and the inner half of the + // chained form above. + if (ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const callable = callableThroughAssignments(node.right); + if (callable !== undefined && ts.isPropertyAccessExpression(node.left)) { + out.push({ name: node.left.name.text, callable }); + } + if (callable !== undefined && ts.isIdentifier(node.left)) { + out.push({ name: node.left.text, callable }); + } + } + return out; +} + +/** Follows `a = b = ` to the callable, if one is at the end. */ +function callableThroughAssignments( + node: ts.Expression +): ts.FunctionExpression | undefined { + let current: ts.Expression = node; + for (;;) { + if (ts.isFunctionExpression(current)) { + return current; + } + if (ts.isBinaryExpression(current) + && current.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + current = current.right; + continue; + } + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + return undefined; + } +} + +function thisBindingFor(kind: JsMethodKind): JsThisBinding { + if (kind === JsMethodKind.ARROW) { + return JsThisBinding.LEXICAL; + } + if (kind === JsMethodKind.MODULE_INITIALIZER) { + return JsThisBinding.NONE; + } + return JsThisBinding.DYNAMIC; +} + +function bodyPresenceOf(body: ts.Node | undefined): JsBodyPresence { + if (body === undefined) { + return JsBodyPresence.NO_BODY; + } + // A concise arrow's body is an EXPRESSION, so its implicit return has no + // `return` statement to find. An extractor looking for ReturnStatement nodes + // finds none and reports a function returning nothing. + return ts.isBlock(body) ? JsBodyPresence.HAS_BODY : JsBodyPresence.EXPRESSION_BODY; +} + +/** + * Does this body reference `arguments`? + * + * Stops at every nested `function`, because `arguments` inside one refers to + * *that* function's arguments. It does **not** stop at an arrow, because an + * arrow has no `arguments` of its own and reading it there reaches the + * enclosing function's — which means the enclosing function really does use it. + */ +function referencesArguments(body: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found) { + return; + } + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) + || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node)) { + return; + } + if (ts.isIdentifier(node) && node.text === 'arguments') { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + return found; +} + +/** Does this body contain a `this.x = …`? Evidence of a constructor function. */ +function bodyAssignsToThis(body: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found) { + return; + } + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) + || ts.isClassLike(node)) { + return; + } + if (ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(node.left) + && node.left.expression.kind === ts.SyntaxKind.ThisKeyword) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + return found; +} + +/** + * The declared type of a position, from JSDoc — the only declared-type channel + * JavaScript has. + * + * 37.9% of parameters carry one and effectively none carry a syntactic one, all 64 + * of which are Flow. The NAME is read here as a string, because a declaration + * row needs it at construction; the TREE it describes is emitted separately by + * `js-jsdoc-extractor.ts` and linked back, because a type expression is three + * rows and not a string. + */ +export function declaredTypeFromJsDoc( + node: ts.Node, + sourceFile: ts.SourceFile, + kind: 'type' | 'returns' | 'param' +): { name: string; source: JsDeclaredTypeSource } { + const tag = kind === 'returns' + ? ts.getJSDocReturnTag(node) + : kind === 'type' + ? ts.getJSDocTypeTag(node) + // The SHARED selection, not `getJSDocParameterTags(node)[0]`. That took + // the first tag the compiler associated with the parameter, which on a + // nested `@param {object} ctx.model` was the wrong tag entirely — and it + // disagreed with the path that builds the type-reference tree, 300 times + // over the corpus. + : jsDocParameterTagFor(node as ts.ParameterDeclaration); + const type = tag?.typeExpression?.type; + if (type !== undefined) { + return { + name: typeNameAsWritten(type, sourceFile), + source: JsDeclaredTypeSource.JSDOC, + }; + } + // A SYNTACTIC annotation in a .js file is Flow, not TypeScript — all 64 + // measured are. `ts.createSourceFile` parses the overlapping grammar into real + // `.type` nodes and mis-parses the rest SILENTLY, so recording which grammar + // it came from is what stops a later reader treating one as the other. + const syntactic = (node as { type?: ts.TypeNode }).type; + if (syntactic !== undefined) { + return { + name: typeNameAsWritten(syntactic, sourceFile), + source: JsDeclaredTypeSource.SYNTACTIC_FLOW, + }; + } + return { name: '', source: JsDeclaredTypeSource.NONE }; +} + +/** + * The name WITHOUT type arguments and without JSDoc's modifier syntax. + * + * `@param {number=}` names `number`, not `number=`; `@param {...Options}` names + * `Options`; `@type {?Options}` names `Options`. The optionality, the rest-ness + * and the nullability are all modelled as `js_type_reference` rows of their own, + * so carrying them in the NAME too would mean a consumer doing string surgery to + * recover a name the fact base already holds — and getting it wrong on the first + * `Array`. + */ +function typeNameAsWritten(type: ts.Node, sourceFile: ts.SourceFile): string { + let current: ts.Node = type; + for (;;) { + if (ts.isJSDocOptionalType(current) || ts.isJSDocVariadicType(current) + || ts.isJSDocNullableType(current) || ts.isJSDocNonNullableType(current)) { + current = current.type; + continue; + } + break; + } + if (ts.isTypeReferenceNode(current)) { + return current.typeName.getText(sourceFile); + } + if (ts.isJSDocTypeLiteral(current)) { + // `@param {object} ctx` followed by `@param {string} ctx.model`: the + // compiler REPLACES the parent's `{object}` with a synthesised type + // literal whose text is the child tags — so getText() here was the raw + // remaining comment, sibling tags and asterisks included, on every dotted + // parent (js-fixtures' nested-params, six shapes). The name as WRITTEN is + // the first brace group of the tag that carries the literal. + let tag: ts.Node | undefined = current.parent; + while (tag !== undefined && !ts.isJSDocParameterTag(tag) && !ts.isJSDocPropertyTag(tag) + && !ts.isJSDocTypedefTag(tag) && !ts.isJSDocTypeTag(tag)) { + tag = tag.parent; + } + const written = tag === undefined ? undefined : /\{([^}]*)\}/.exec(tag.getText(sourceFile)); + return written?.[1]?.trim() ?? ''; + } + // An import type keeps its FULL text here — `import("./x").Y` — because the + // declaration row has one name column and the specifier is the part that + // says which file. The reference row names the qualifier and links the + // specifier's js_import row (§3.14.4). + return current.getText(sourceFile); +} + +function propertyNameText(name: ts.PropertyName | undefined): string { + if (name === undefined) { + return ''; + } + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) + || ts.isPrivateIdentifier(name)) { + return name.text; + } + return ''; +} + +function isObjectCreate(call: ts.CallExpression): boolean { + return ts.isPropertyAccessExpression(call.expression) + && call.expression.name.text === 'create' + && ts.isIdentifier(call.expression.expression) + && call.expression.expression.text === 'Object'; +} + +/** `Parent.prototype` -> `Parent`; anything else stays as it is. */ +function prototypeOwnerOf(node: ts.Expression): ts.Expression | undefined { + if (ts.isPropertyAccessExpression(node) && node.name.text === 'prototype') { + return node.expression; + } + return undefined; +} + +function countPatternBindings(name: ts.BindingName): number { + if (ts.isIdentifier(name)) { + return 1; + } + let total = 0; + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) { + continue; + } + total += countPatternBindings(element.name); + } + return total; +} + +function bindingFormOf(declaration: ts.Node | undefined): JsVariableBindingForm { + if (declaration === undefined) { + return JsVariableBindingForm.IDENTIFIER; + } + const name = ts.isVariableDeclaration(declaration) || ts.isParameter(declaration) + ? declaration.name + : undefined; + if (name === undefined || ts.isIdentifier(name)) { + return JsVariableBindingForm.IDENTIFIER; + } + return ts.isObjectBindingPattern(name) + ? JsVariableBindingForm.OBJECT_PATTERN + : JsVariableBindingForm.ARRAY_PATTERN; +} + +/** + * What a binding was initialised with, coarsely. + * + * `REQUIRE_CALL` is the value that matters — 34.4% of all oracle declines are + * calls through a name bound this way, and this column plus `importLinkHash` is + * what turns them from unresolvable into reconstructable. + */ +function initializerKindOf(initializer: ts.Expression | undefined): JsInitializerKind { + if (initializer === undefined) { + return JsInitializerKind.NONE; + } + if (isRequireCall(initializer)) { + return JsInitializerKind.REQUIRE_CALL; + } + if (ts.isFunctionExpression(initializer) || ts.isArrowFunction(initializer)) { + return JsInitializerKind.FUNCTION; + } + if (ts.isClassExpression(initializer)) { + return JsInitializerKind.CLASS; + } + if (ts.isObjectLiteralExpression(initializer)) { + return JsInitializerKind.OBJECT_LITERAL; + } + return JsInitializerKind.OTHER; +} + +/** + * Is this bound name introduced by an `import` declaration? + * + * Covers all three spellings, because all three bind a module alias: + * `import d from 'm'`, `import { n } from 'm'` and `import * as ns from 'm'`. + * The walk stops at the declaration rather than running to the source file, so + * a name inside an imported function's body cannot be mistaken for one. + */ +function isImportBindingName(nameNode: ts.Node): boolean { + let current: ts.Node | undefined = nameNode; + while (current !== undefined) { + if (ts.isImportSpecifier(current) || ts.isImportClause(current) + || ts.isNamespaceImport(current) || ts.isImportEqualsDeclaration(current)) { + return true; + } + if (ts.isVariableDeclaration(current) || ts.isParameter(current) + || ts.isSourceFile(current) || ts.isStatement(current)) { + return false; + } + current = current.parent; + } + return false; +} + +/** + * The DECLARATOR a bound name belongs to: the node whose end bounds where the + * value is constructed. Walks up out of any destructuring pattern first, so + * `const {a, b} = make()` ends where `make()` does for both names. + */ +function declaratorOf(nameNode: ts.Node): ts.Node | undefined { + let current: ts.Node | undefined = nameNode; + while (current !== undefined) { + if (ts.isVariableDeclaration(current) || ts.isFunctionDeclaration(current) + || ts.isClassDeclaration(current) || ts.isImportSpecifier(current) + || ts.isImportClause(current) || ts.isNamespaceImport(current) + || ts.isCatchClause(current)) { + return current; + } + if (ts.isSourceFile(current) || ts.isStatement(current)) { + return undefined; + } + current = current.parent; + } + return undefined; +} + +/** + * The `VariableDeclaration` or parameter a bound name belongs to — and + * `undefined` for a name that belongs to something else. + * + * ## It climbed past the name's own declarator, and everything it found was + * someone else's + * + * This walked up to the NEAREST VariableDeclaration with no stop, so a + * `function inner() {}` declared inside `const controller = { … }` was + * handed `controller`'s declaration: its `@type` (a JSDoc import type, + * minted once per nested declaration — the held-back corpus's first + * duplicate js_import key, and three js_type_reference rows for one comment + * that the PK and FK gates both passed because each had a distinct owner), + * its initialiser (`hasInitializer = true` on 972 function declarations and + * 20 classes in the development corpus, with the enclosing initialiser's + * expression as their link), and its binding form (11 declarations reading + * OBJECT_PATTERN). A comment re-hosted DOWNWARD; every prior host question + * was about missing rows. The walk now stops at the first declarator the + * name has — a function or class declaration, a catch clause, an import — + * and answers only when that declarator is a variable or a parameter. + */ +function declarationOwning(nameNode: ts.Node): ts.Node | undefined { + let current: ts.Node | undefined = nameNode; + while (current !== undefined) { + if (ts.isVariableDeclaration(current) || ts.isParameter(current)) { + return current; + } + if (ts.isFunctionDeclaration(current) || ts.isClassDeclaration(current) + || ts.isCatchClause(current) || ts.isImportSpecifier(current) || ts.isImportClause(current) + || ts.isNamespaceImport(current) || ts.isSourceFile(current) || ts.isStatement(current) + || ts.isFunctionLike(current) || ts.isClassLike(current)) { + return undefined; + } + current = current.parent; + } + return undefined; +} + +/** Kept so a caller can name a file without re-deriving it. */ +export function fileNameOf(filePath: string): string { + return path.basename(filePath); +} diff --git a/parser/src/parsers/javascript/extractors/js-expression-extractor.ts b/parser/src/parsers/javascript/extractors/js-expression-extractor.ts new file mode 100644 index 000000000..f89b54c7e --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-expression-extractor.ts @@ -0,0 +1,1771 @@ +import * as ts from 'typescript'; + +import { JS_EXPRESSION_MAX_DEPTH } from '@/constants/javascript-constants'; +import { JsCallSiteRegistry } from '@/analysis-types/javascript/JsCallSiteRegistry'; +import { JsExpressionRegistry } from '@/analysis-types/javascript/JsExpressionRegistry'; +import { + JsCallKind, + JsCallResolutionOutcome, + JsReceiverPosition, + JsReceiverTypeSource, +} from '@/enums/javascript/call-sites'; +import { + JsBindingResolution, + JsEdgeRole, + JsExpressionKind, + JsLiteralKind, + JsReferenceKind, + JsRootContext, +} from '@/enums/javascript/expressions'; +import { JsBindingRegime } from '@/enums/javascript/variables'; +import { ScopeBuildResult } from '@/parsers/javascript/extractors/js-scope-builder'; +import { + GLOBAL_BUILTIN_NAMES, + JsScopeNode, + nodeKey, + resolveName, +} from '@/parsers/javascript/extractors/js-symbol-table'; +import { + isDynamicImportCall, + isModuleEdgeCall, + isNodeBuiltinSpecifier, + isRequireCall, + rangeOf, +} from '@/utils/javascript'; + +/** + * `js_expression` and `js_call_site` — the spine. + * + * ## Three failures this file is shaped by, all of them silent + * + * **1. A tree rooted at a non-emitting node dies before its children are + * enqueued.** Parentheses produce no row of their own, so + * `return ( a && b.c() )` lost the *whole* tree — 1,808 expressions on one + * TypeScript corpus. JSX braces produce no row, so every call inside one + * vanished: **4,488 of admin-ui's 14,335 call sites.** The fix is not to special + * case each position but to **unwrap at the root, in one place** — see + * {@link unwrap} — which fixed every position at once. + * + * **2. The worklist stops at function boundaries.** `return function () { … }` + * emitted the function and nothing inside it. Every row that *was* emitted was + * correct; there were simply fewer of them, which is why no check caught it. + * Descent into a callable's body is explicit here. + * + * **3. Flat emission invites a repair that manufactures wrong edges.** `x += 1` + * must be **one** `ASSIGNMENT` row with its target and value parented under + * `ASSIGNMENT_TARGET`/`ASSIGNMENT_VALUE` and `+=` in `operatorString`. Emitted + * flat, the engine-side workaround pairs on `(scope, line, rootContext)` and on + * `a += 1; b += 2` yields four pairs, two of them pairing `a` with `2`. That is + * worse than dropping the rows. + * + * ## An allowlist of positions, never a generic walk + * + * §6: a generic tree walk puts JSDoc type names into this relation, and + * type-only constructs then reach the call graph. The root positions are + * enumerated in `js-expression-walker.ts`; this file only ever descends from + * one of them. + */ +export interface ExpressionExtractionOptions { + readonly sourceFile: ts.SourceFile; + readonly binder: ScopeBuildResult; + readonly moduleHash: string; + readonly serviceVersionLinkHash: string; + readonly hashOfScope: (scope: JsScopeNode) => string; + /** `nodeKey` of an assignment -> the declaration it mints. */ + readonly declarationByAssignment: ReadonlyMap; + /** Name -> the `js_variable` hash it binds, for same-file one-hop resolution. */ + readonly variableHashByBindingKey: ReadonlyMap; + /** Parameter row by the key of its ParameterDeclaration node, for c33. */ + readonly parameterHashByNode: ReadonlyMap; + /** + * Does this file declare a type of that name? + * + * Same-file only, and a predicate rather than a lookup: the caller asks "is + * `Foo` declared here", and reaching into another file to answer would be the + * cross-file resolution this parser does not do. + */ + readonly declaresTypeNamed: (name: string) => boolean; +} + +/** What the caller supplies about the statement a tree hangs under. */ +export interface RootPosition { + readonly node: ts.Expression; + readonly rootContext: JsRootContext; + readonly ownerMethodHash: string; +} + +export class JsExpressionExtractor { + readonly expressions: JsExpressionRegistry[] = []; + readonly callSites: JsCallSiteRegistry[] = []; + + /** Row by `nodeKey`, for the passes that link against a specific node. */ + readonly rowByNode = new Map(); + /** The ROOT row's hash by the root node's key, for declaration back-patching. */ + readonly rootHashByNode = new Map(); + /** + * Every expression that IS a module edge, in source order. + * + * The second pass reads exactly these. Kept as a list rather than recomputed + * by scanning the relation, so gate 7.3.1's "exactly one import/export per + * module-edge expression" is checkable against what was actually found. + */ + readonly moduleEdgeNodes: { node: ts.CallExpression; row: JsExpressionRegistry }[] = []; + + private readonly options: ExpressionExtractionOptions; + private readonly sourceFile: ts.SourceFile; + /** Guards against emitting one node twice, which would DOUBLE rather than collide. */ + private readonly emitted = new Set(); + + constructor(options: ExpressionExtractionOptions) { + this.options = options; + this.sourceFile = options.sourceFile; + } + + /** Emits the tree rooted at one allowlisted position. */ + emitRoot(root: RootPosition): void { + const unwrapped = unwrap(root.node); + if (unwrapped === undefined) { + return; + } + const row = this.emit(unwrapped, undefined, JsEdgeRole.OPERAND, 0, 0, + root.rootContext, root.ownerMethodHash); + if (row !== undefined) { + this.rootHashByNode.set(nodeKey(root.node), row.getHash()); + // Also under the UNWRAPPED node's key: a declaration linking to "the + // initializer expression" may hold either the parenthesis or what is + // inside it, depending on which pass found it first. + this.rootHashByNode.set(nodeKey(unwrapped), row.getHash()); + } + } + + // ------------------------------------------------------------------------- + + private emit( + node: ts.Expression, + parent: JsExpressionRegistry | undefined, + edgeRole: JsEdgeRole, + childIndex: number, + depth: number, + rootContext: JsRootContext, + ownerMethodHash: string + ): JsExpressionRegistry | undefined { + const identity = nodeKey(node); + if (this.emitted.has(identity)) { + // Reached twice. Emitting again would mint an identical primary key and + // DOUBLE the row rather than colliding — the failure §2 names, where + // nothing looks wrong and the count is quietly twice what it should be. + return this.rowByNode.get(identity); + } + + if (depth > JS_EXPRESSION_MAX_DEPTH) { + // The cap is 32, not TypeScript's effective 20: the corpus reaches depth + // 67 with a p99 of 26. The parent is marked rather than the subtree + // silently vanishing. + parent?.setIsTruncated(); + return undefined; + } + + const kind = expressionKindOf(node); + if (kind === undefined) { + return undefined; + } + + const at = this.positionOf(node); + const scope = this.scopeAt(node); + const row = new JsExpressionRegistry({ + expressionKind: kind, + text: node.getText(this.sourceFile), + name: nameOf(node), + isComputedName: ts.isElementAccessExpression(node), + operatorString: operatorOf(node), + depth, + parentExpressionLinkHash: parent?.getHash() ?? '', + edgeRole, + childIndex, + rootContext, + // A private name in reference position (`#brand in o`) is a reference + // too, spelled with its `#` — a resolution with no name would be a row + // that says "resolved" and not what. + referencedName: ts.isIdentifier(node) || ts.isPrivateIdentifier(node) + ? node.text + : ts.isMetaProperty(node) + ? `${ts.tokenToString(node.keywordToken) ?? ''}.${node.name.text}` + : '', + // `delete obj.x` and `typeof obj.x` operate on a PROPERTY ACCESS, not on + // an identifier, so restricting this to identifiers left DELETE declared + // and never emitted — and a delete recorded as a READ says the property is + // consulted when in fact it is removed. + referenceKind: ts.isIdentifier(node) || ts.isPropertyAccessExpression(node) + || ts.isElementAccessExpression(node) + ? referenceKindOf(node) + : '', + // Must be false in every row; the gate asserts it. A type-only construct + // reaching this relation is how a `@typedef` becomes a call target. + isTypeOnlyReachable: false, + literalKind: literalKindOf(node), + ownerScopeLinkHash: this.options.hashOfScope(scope), + ownerMethodLinkHash: ownerMethodHash, + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + endLine: at.endLine, + endColumn: at.endColumn, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.expressions.push(row); + this.emitted.add(identity); + this.rowByNode.set(identity, row); + + if (ts.isIdentifier(node)) { + this.resolveIdentifier(node, row, scope); + } else if (ts.isPrivateIdentifier(node)) { + // `#brand in obj` — a class-private name in a reference position, and + // the only way one gets here (`this.#x` is a property access whose NAME + // is private, and never reaches this branch). No scope chain resolves a + // private name: it is a slot on the class whose body encloses the + // reference. CLASS_PRIVATE is the one honest value (§3.10.1); the 17 rows + // carried an empty c18 until it existed. + row.setBindingResolution(JsBindingResolution.CLASS_PRIVATE); + } + if (isModuleEdgeCall(node) && ts.isCallExpression(node)) { + // Recorded for the second pass, which is what SETS `isModuleEdge` — the + // flag belongs to the pass that mints the edge row, so gate 7.3.1's + // one-edge-per-flagged-expression holds by construction. + this.moduleEdgeNodes.push({ node, row }); + } + // An assignment that DECLARES a member points at the declaration it mints, + // and the declaration points back. Gate 7.3.7 asserts the round trip. + const declaration = this.options.declarationByAssignment.get(identity); + if (declaration !== undefined) { + row.setIsDeclarationBearing(true); + row.setDeclarationLinkHash(declaration); + } + + this.emitChildren(node, row, depth, rootContext, ownerMethodHash); + + // `require('x')` gets NO call-site row: it is a module edge by ruling, and + // counting its 9,055 sites as unresolved calls is what made the raw + // resolution figure look worse than it is. `import('x')` gets BOTH, because + // unlike `require` it is a genuine expression whose promise flows somewhere + // — the call really happens and its result is used. + if (isCallLike(node) && (!isModuleEdgeCall(node) || isDynamicImportCall(node))) { + this.emitCallSite(node, row, scope, ownerMethodHash); + } + return row; + } + + /** + * Enqueues a node's children with their edge roles. + * + * Every branch is braced and every child goes through {@link emitChild}, which + * unwraps. A child enqueued without unwrapping is the failure this file opens + * with, and it is invisible: the parent row is present and correct, and the + * subtree simply is not there. + */ + private emitChildren( + node: ts.Expression, + row: JsExpressionRegistry, + depth: number, + rootContext: JsRootContext, + ownerMethodHash: string + ): void { + const next = depth + 1; + const child = (target: ts.Expression | undefined, role: JsEdgeRole, index: number): void => { + if (target === undefined) { + return; + } + this.emitChild(target, row, role, index, next, rootContext, ownerMethodHash); + }; + + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.CommaToken) { + child(node.left, JsEdgeRole.ELEMENT, 0); + child(node.right, JsEdgeRole.ELEMENT, 1); + return; + } + if (isAssignmentOperator(node.operatorToken.kind)) { + // ONE wrapper, two labelled children, the operator in a column. `=`, + // `+=`, `??=`, `||=` and every other compound form share this path. + child(node.left, JsEdgeRole.ASSIGNMENT_TARGET, 0); + child(node.right, JsEdgeRole.ASSIGNMENT_VALUE, 1); + return; + } + child(node.left, JsEdgeRole.OPERAND, 0); + child(node.right, JsEdgeRole.OPERAND, 1); + return; + } + if (ts.isCallExpression(node)) { + this.emitCallChildren(node, row, next, rootContext, ownerMethodHash); + return; + } + if (ts.isNewExpression(node)) { + // The SAME labels a call gets. `new ueberDB.Database()` names its receiver + // exactly as `ueberDB.Database()` does, and the call site row links + // `receiverExpressionLinkHash` at it either way — but this branch emitted + // the callee whole and let the generic descent label `ueberDB` as an + // ACCESS_TARGET, so the link pointed at a row whose role said it was not + // a receiver. 445 constructor calls over the corpus, found by asserting + // what the link column MEANS rather than that it resolves. + const callee = node.expression; + if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + child(callee.expression, JsEdgeRole.RECEIVER, 0); + if (ts.isElementAccessExpression(callee)) { + child(callee.argumentExpression, JsEdgeRole.COMPUTED_KEY, 1); + } + } else { + child(callee, JsEdgeRole.CALLEE, 0); + } + const args = node.arguments ?? []; + const first = ts.isElementAccessExpression(callee) ? 2 : 1; + for (let i = 0; i < args.length; i += 1) { + child(args[i]!, JsEdgeRole.ARGUMENT, first + i); + } + return; + } + if (ts.isTaggedTemplateExpression(node)) { + // The same labels a call gets. `String.raw\`…\`` names its receiver as + // `String.raw(…)` does, and the call site links receiverExpressionLinkHash + // at it — but the tag was emitted whole as CALLEE, so `String` became an + // ACCESS_TARGET under it and the link pointed at a row whose role said it + // was not a receiver. The `new` branch above had the identical defect + // (445 sites); this one was found by the call-forms torture script. + const tag = node.tag; + if (ts.isPropertyAccessExpression(tag) || ts.isElementAccessExpression(tag)) { + child(tag.expression, JsEdgeRole.RECEIVER, 0); + if (ts.isElementAccessExpression(tag)) { + child(tag.argumentExpression, JsEdgeRole.COMPUTED_KEY, 1); + } + child(node.template, JsEdgeRole.ARGUMENT, ts.isElementAccessExpression(tag) ? 2 : 1); + } else { + child(tag, JsEdgeRole.CALLEE, 0); + child(node.template, JsEdgeRole.ARGUMENT, 1); + } + return; + } + if (ts.isPropertyAccessExpression(node)) { + child(node.expression, JsEdgeRole.ACCESS_TARGET, 0); + return; + } + if (ts.isElementAccessExpression(node)) { + child(node.expression, JsEdgeRole.ACCESS_TARGET, 0); + child(node.argumentExpression, JsEdgeRole.COMPUTED_KEY, 1); + return; + } + if (ts.isConditionalExpression(node)) { + child(node.condition, JsEdgeRole.CONDITION, 0); + child(node.whenTrue, JsEdgeRole.OPERAND, 1); + child(node.whenFalse, JsEdgeRole.OPERAND, 2); + return; + } + if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { + child(node.operand, JsEdgeRole.OPERAND, 0); + return; + } + if (ts.isTypeOfExpression(node) || ts.isVoidExpression(node) + || ts.isDeleteExpression(node) || ts.isAwaitExpression(node)) { + child(node.expression, JsEdgeRole.OPERAND, 0); + return; + } + if (ts.isYieldExpression(node)) { + child(node.expression, JsEdgeRole.OPERAND, 0); + return; + } + if (ts.isSpreadElement(node)) { + child(node.expression, JsEdgeRole.SPREAD_OPERAND, 0); + return; + } + if (ts.isArrayLiteralExpression(node)) { + for (let i = 0; i < node.elements.length; i += 1) { + child(node.elements[i]!, JsEdgeRole.ELEMENT, i); + } + return; + } + if (ts.isObjectLiteralExpression(node)) { + this.emitObjectLiteralMembers(node, row, next, rootContext, ownerMethodHash); + return; + } + if (ts.isTemplateExpression(node)) { + for (let i = 0; i < node.templateSpans.length; i += 1) { + child(node.templateSpans[i]!.expression, JsEdgeRole.TEMPLATE_SUBSTITUTION, i); + } + return; + } + if (ts.isParenthesizedExpression(node)) { + // Cannot be reached: unwrap() removes these before a row is ever minted. + // Kept as a branch so a future change that stops unwrapping fails loudly + // rather than dropping the subtree. + child(node.expression, JsEdgeRole.OPERAND, 0); + return; + } + if (ts.isCommaListExpression(node)) { + for (let i = 0; i < node.elements.length; i += 1) { + child(node.elements[i]!, JsEdgeRole.ELEMENT, i); + } + return; + } + if (isJsxNode(node)) { + this.emitJsxChildren(node, row, next, ownerMethodHash); + return; + } + // A callable in expression position. Its BODY belongs to its own method + // row, so nothing is enqueued here — the declaration walk owns it. What + // this row does is keep the expression tree unbroken around it. + } + + private emitCallChildren( + node: ts.CallExpression, + row: JsExpressionRegistry, + depth: number, + rootContext: JsRootContext, + ownerMethodHash: string + ): void { + const callee = node.expression; + // The RECEIVER is labelled as such wherever it sits. For `obj.m()` that is + // the member expression's object; for `f.call(obj, …)` it is argument 0, and + // `receiverPosition = FIRST_ARGUMENT` on the call site agrees. + const displaced = displacedReceiverKind(node); + if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + if (displaced === undefined) { + this.emitChild(callee.expression, row, JsEdgeRole.RECEIVER, 0, depth, + rootContext, ownerMethodHash); + } else { + // `f` in `f.call(obj)` is the FUNCTION being invoked, not the receiver. + this.emitChild(callee.expression, row, JsEdgeRole.CALLEE, 0, depth, + rootContext, ownerMethodHash); + } + } else { + this.emitChild(callee, row, JsEdgeRole.CALLEE, 0, depth, rootContext, + ownerMethodHash); + } + // THE INDEX EXPRESSION OF `obj[expr](args)`. + // + // It was dropped. The receiver was emitted and the arguments were emitted + // and `expr` — which is ordinary code that can contain anything — was + // enqueued by nobody, so `obj[getName()]()` lost `getName()` entirely and + // `obj[a ? b() : c()]()` lost both. + // + // What makes it a plain oversight rather than a decision: an element access + // that is NOT a callee already emits its key as a COMPUTED_KEY child. The + // machinery is the same three lines; it just was not reached on this path. + // + // Ordered between the receiver and the arguments because that is the + // evaluation order — `obj` then `expr` then the arguments — and childIndex + // is what an engine reconstructs order from. + let nextIndex = 1; + if (ts.isElementAccessExpression(callee)) { + this.emitChild(callee.argumentExpression, row, JsEdgeRole.COMPUTED_KEY, + nextIndex, depth, rootContext, ownerMethodHash); + nextIndex += 1; + } + for (let i = 0; i < node.arguments.length; i += 1) { + const role = displaced !== undefined && i === 0 + ? JsEdgeRole.RECEIVER + : JsEdgeRole.ARGUMENT; + this.emitChild(node.arguments[i]!, row, role, nextIndex + i, depth, rootContext, + ownerMethodHash); + } + } + + /** + * An object literal's members, keys included. + * + * The key gets its own row. TypeScript's audit found that omitting it made a + * literal's shape unrecoverable — a consumer could see the values and not + * which name each belonged to — and a computed key is emitted with + * `isComputedName` rather than a guessed name. + */ + private emitObjectLiteralMembers( + node: ts.ObjectLiteralExpression, + row: JsExpressionRegistry, + depth: number, + rootContext: JsRootContext, + ownerMethodHash: string + ): void { + let index = 0; + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + if (ts.isComputedPropertyName(property.name)) { + this.emitChild(property.name.expression, row, JsEdgeRole.COMPUTED_KEY, + index, depth, rootContext, ownerMethodHash); + } else { + // The KEY gets its own row. Without it a literal's shape is + // unrecoverable — a consumer sees the values and not which name each + // belongs to — and the pre-ES6 module pattern is exactly an object + // literal of functions, so the key is the method name. + this.emitPropertyKey(property.name, row, index, depth, rootContext, + ownerMethodHash); + } + this.emitChild(property.initializer, row, JsEdgeRole.PROPERTY_VALUE, + index, depth, rootContext, ownerMethodHash); + index += 1; + continue; + } + if (ts.isShorthandPropertyAssignment(property)) { + // `{ value }` — the name is both key and value, and the VALUE is what a + // consumer follows, so the identifier is emitted as a property value. + this.emitChild(property.name, row, JsEdgeRole.PROPERTY_VALUE, index, + depth, rootContext, ownerMethodHash); + // `({ buffer = Buffer.alloc(16384) } = params)` — a DEFAULT inside a + // destructuring ASSIGNMENT. + // + // The target of an assignment is parsed as an object LITERAL, not a + // binding pattern, and a default on it lands on + // `objectAssignmentInitializer` — a property nothing here read. So the + // expression was never emitted and any call inside it never existed. + // + // Found by AST recall rather than by any row count: 2 of 195,781 call + // sites and 28 of 75,349 variables, all of them this one shape, all in + // the platform runtime's own library. The DECLARATION forms beside it — `const {a = mk()} = s`, + // `function f({b = mk()} = {})`, `const [c = mk()] = arr` — were all + // walked correctly, which is what says it is this node and not a policy. + if (property.objectAssignmentInitializer !== undefined) { + this.emitChild(property.objectAssignmentInitializer, row, + JsEdgeRole.PROPERTY_VALUE, index, depth, rootContext, ownerMethodHash); + } + index += 1; + continue; + } + if (ts.isSpreadAssignment(property)) { + this.emitChild(property.expression, row, JsEdgeRole.SPREAD_OPERAND, index, + depth, rootContext, ownerMethodHash); + index += 1; + continue; + } + // A method or accessor in a literal. Its body belongs to its own method + // row; the literal's shape is what this relation records. + index += 1; + } + } + + /** + * JSX children, with the **brace** unwrapped. + * + * `{t(msg)}` is a `JsxExpression` that produces no row of its own, so a + * subtree rooted at it dies — and that cost TypeScript 4,488 of 14,335 call + * sites. {@link unwrap} handles it, and this method exists to reach the + * containers in the first place. + */ + private emitJsxChildren( + node: ts.Expression, + row: JsExpressionRegistry, + depth: number, + ownerMethodHash: string + ): void { + // No `rootContext` parameter: everything inside JSX gets + // `JSX_EXPRESSION` regardless of the statement the markup hangs under, + // because markup-embedded code is not the enclosing statement's value. + let index = 0; + // THE TAG NAME FIRST, as child 0, when it references something (§2.5a). + // `` reads the binding `Foo`; `` is a property + // access whose root identifier falls out of the subtree. An intrinsic tag + // emits nothing here. The closing tag is skipped below: it repeats the + // same reference and would double it. + const tag = jsxTagReference(node); + if (tag !== undefined) { + this.emitChild(tag, row, JsEdgeRole.JSX_TAG_NAME, index, depth, + JsRootContext.JSX_EXPRESSION, ownerMethodHash); + index += 1; + } + const visitJsx = (current: ts.Node, parentRow: JsExpressionRegistry, + currentDepth: number): void => { + if (ts.isJsxClosingElement(current) || current === tag) { + return; + } + // An ATTRIBUTE is a wrapper: a name and a value. Without a row for it the + // fact base holds `cls` with no record that the prop is called + // `className` — the §3 defect class, parts emitted and structure absent. + // `onClick={handler}` hands a function to a component, and which prop it + // was handed as is the whole content of the edge. + if (ts.isJsxAttribute(current) && current.initializer !== undefined) { + const attributeRow = this.emitJsxAttribute(current, parentRow, index, + currentDepth, ownerMethodHash); + index += 1; + if (attributeRow !== undefined) { + if (ts.isStringLiteralLike(current.initializer)) { + this.emitChild(current.initializer, attributeRow, + JsEdgeRole.PROPERTY_VALUE, 0, currentDepth + 1, + JsRootContext.JSX_EXPRESSION, ownerMethodHash); + } else { + // The INITIALIZER itself, not its children. Descending into a + // `JsxExpression`'s children reaches the expression as a bare node + // that no branch here claims, so the value was dropped and the + // attribute row came out childless — the same subtree-death this + // file exists to prevent, reintroduced one level down. + this.emitAttributeValue(current.initializer, attributeRow, + currentDepth + 1, ownerMethodHash); + } + } + return; + } + // A SPREAD ATTRIBUTE — `` — hands a whole object to the + // component. It is neither an attribute with an initializer nor a brace + // container, so the generic descent reached its expression as a bare + // node no branch claimed: `sequence[0]`, `this.props`, `Theme.Consumer` + // were 24 expressions missing from the tree on the development corpus, + // found by the AST recall's residue once the by-rule exclusions were + // named. The same edge a spread argument gets. + if (ts.isJsxSpreadAttribute(current)) { + this.emitChild(current.expression, parentRow, JsEdgeRole.SPREAD_OPERAND, + index, currentDepth, JsRootContext.JSX_EXPRESSION, ownerMethodHash); + index += 1; + return; + } + // A NESTED JSX ELEMENT gets its own row, and becomes the parent of its + // own children. + // + // Without this it was walked THROUGH: attributes and the calls inside + // them were emitted, correctly, and hung off the outermost element — so + // the parts were all present and the structure was absent, which is §3's + // defect class exactly. An engine asking what a component renders saw the + // root tag and nothing below it. + // + // `emitChild` re-enters the main path, which dispatches JSX nodes back + // into this method with the new row as parent, so the recursion is the + // ordinary one and the depth cap applies as everywhere else. + if (isJsxNode(current) && current !== node) { + this.emitChild(current as ts.Expression, parentRow, JsEdgeRole.JSX_CHILD, + index, currentDepth, JsRootContext.JSX_EXPRESSION, ownerMethodHash); + index += 1; + return; + } + if (ts.isJsxExpression(current)) { + if (current.expression !== undefined) { + // JSX_EXPRESSION as the root context for everything inside a brace: + // markup-embedded code is not the enclosing statement's return value, + // and a consumer that cannot tell them apart reads a render callback + // as a returned expression. + this.emitChild(current.expression, parentRow, JsEdgeRole.OPERAND, + index, currentDepth, JsRootContext.JSX_EXPRESSION, ownerMethodHash); + index += 1; + } + return; + } + ts.forEachChild(current, (child) => { + visitJsx(child, parentRow, currentDepth); + }); + }; + ts.forEachChild(node, (child) => { + visitJsx(child, row, depth); + }); + } + + /** + * A property key, as its own row. + * + * Never bound as a scope reference: `{ x: 1 }` mentions `x` and refers to + * nothing — treating the key as an identifier reference would resolve it + * against whatever `x` happens to be in scope and invent an edge. So the row + * is `PROPERTY_KEY` with `referencedName` empty, and only its `name` carries + * the text. + */ + private emitPropertyKey( + name: ts.PropertyName, + parent: JsExpressionRegistry, + childIndex: number, + depth: number, + rootContext: JsRootContext, + ownerMethodHash: string + ): void { + const identity = nodeKey(name); + if (this.emitted.has(identity)) { + return; + } + const at = this.positionOf(name); + const row = new JsExpressionRegistry({ + expressionKind: JsExpressionKind.PROPERTY_KEY, + text: name.getText(this.sourceFile), + name: propertyKeyText(name), + isComputedName: false, + operatorString: '', + depth, + parentExpressionLinkHash: parent.getHash(), + edgeRole: JsEdgeRole.PROPERTY_KEY, + childIndex, + rootContext, + referencedName: '', + referenceKind: '', + isTypeOnlyReachable: false, + literalKind: ts.isStringLiteral(name) + ? JsLiteralKind.STRING + : ts.isNumericLiteral(name) ? JsLiteralKind.NUMBER : JsLiteralKind.NONE, + ownerScopeLinkHash: this.options.hashOfScope(this.scopeAt(name)), + ownerMethodLinkHash: ownerMethodHash, + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + endLine: at.endLine, + endColumn: at.endColumn, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.expressions.push(row); + this.emitted.add(identity); + this.rowByNode.set(identity, row); + } + + /** + * An attribute's value, under its attribute row. + * + * `unwrap` removes the JSX brace, which produces no row of its own — the + * wrapper that cost TypeScript 4,488 of 14,335 call sites when a subtree + * rooted at one died before its children were enqueued. + */ + private emitAttributeValue( + initializer: ts.Node, + attributeRow: JsExpressionRegistry, + depth: number, + ownerMethodHash: string + ): void { + if (!ts.isJsxExpression(initializer) && !ts.isJsxElement(initializer) + && !ts.isJsxSelfClosingElement(initializer) && !ts.isJsxFragment(initializer)) { + return; + } + this.emitChild(initializer as ts.Expression, attributeRow, + JsEdgeRole.PROPERTY_VALUE, 0, depth, JsRootContext.JSX_EXPRESSION, + ownerMethodHash); + } + + /** + * A JSX attribute, as a wrapper row carrying the prop's NAME. + * + * The value is parented to it under `PROPERTY_VALUE`, so `onClick={handler}` + * says which prop `handler` was passed as. Emitting the value alone is the §3 + * defect class: the parts are there and the structure is not. + */ + private emitJsxAttribute( + attribute: ts.JsxAttribute, + parent: JsExpressionRegistry, + childIndex: number, + depth: number, + ownerMethodHash: string + ): JsExpressionRegistry | undefined { + const identity = nodeKey(attribute); + if (this.emitted.has(identity)) { + return this.rowByNode.get(identity); + } + const at = this.positionOf(attribute); + const row = new JsExpressionRegistry({ + expressionKind: JsExpressionKind.JSX_ATTRIBUTE_VALUE, + text: attribute.getText(this.sourceFile), + name: attribute.name.getText(this.sourceFile), + isComputedName: false, + operatorString: '', + depth, + parentExpressionLinkHash: parent.getHash(), + edgeRole: JsEdgeRole.PROPERTY_KEY, + childIndex, + rootContext: JsRootContext.JSX_EXPRESSION, + referencedName: '', + referenceKind: '', + isTypeOnlyReachable: false, + literalKind: JsLiteralKind.NONE, + ownerScopeLinkHash: this.options.hashOfScope(this.scopeAt(attribute)), + ownerMethodLinkHash: ownerMethodHash, + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + endLine: at.endLine, + endColumn: at.endColumn, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.expressions.push(row); + this.emitted.add(identity); + this.rowByNode.set(identity, row); + return row; + } + + /** Unwraps, then emits. The single place a child is enqueued. */ + private emitChild( + node: ts.Expression, + parent: JsExpressionRegistry, + role: JsEdgeRole, + childIndex: number, + depth: number, + rootContext: JsRootContext, + ownerMethodHash: string + ): void { + const unwrapped = unwrap(node); + if (unwrapped === undefined) { + return; + } + this.emit(unwrapped, parent, role, childIndex, depth, rootContext, ownerMethodHash); + } + + // ------------------------------------------------------------------------- + // call sites + // ------------------------------------------------------------------------- + + private emitCallSite( + node: ts.Expression, + expression: JsExpressionRegistry, + scope: JsScopeNode, + ownerMethodHash: string + ): void { + const at = this.positionOf(node); + const kind = callKindOf(node); + const callee = ts.isTaggedTemplateExpression(node) + ? node.tag + : (node as ts.CallExpression | ts.NewExpression).expression; + const args = ts.isTaggedTemplateExpression(node) + ? ([] as readonly ts.Expression[]) + : ((node as ts.CallExpression | ts.NewExpression).arguments ?? []); + const displaced = ts.isCallExpression(node) ? displacedReceiverKind(node) : undefined; + + // The receiver, wherever it actually is. For `.call`/`.apply` it is argument + // 0, and reading the syntactic receiver instead yields + // `Function.prototype.call` as the target with the real receiver never + // consulted — a WRONG edge, not a missing one. + const receiver = displaced !== undefined + ? args[0] + : (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) + ? callee.expression + : undefined; + const receiverPosition = displaced !== undefined + ? JsReceiverPosition.FIRST_ARGUMENT + : receiver !== undefined ? JsReceiverPosition.SYNTACTIC : JsReceiverPosition.NONE; + + const calleeName = displaced !== undefined + ? nameOfCallee((callee as ts.PropertyAccessExpression).expression) + : nameOfCallee(callee); + + const receiverSource = this.receiverTypeSourceFor(receiver, scope); + // The receiver's DECLARED type, when JSDoc supplies one. One of the three + // things §0 says makes a row complete, and the only one this language has a + // declaration-site channel for at all — 37.9% of parameters carry a JSDoc + // type against 0.165% carrying a syntactic one. + const declaredReceiverTypeName = receiverSource === JsReceiverTypeSource.JSDOC + && receiver !== undefined && ts.isIdentifier(receiver) + ? this.declaredTypeNameFor(receiver, scope) + : ''; + // When there is no receiver there is still a CALLEE, and what the callee + // resolves to decides just as much: `helper()` calling a function declared + // in this file is SAME_FILE_RESOLVED, and `new Date()` targets the ambient + // population. Reporting RECEIVER_UNTYPED for both would say the parser knows + // nothing about either, which is false for the first and misleading for the + // second. + const calleeSource = receiver === undefined + ? this.calleeTypeSourceFor(callee, scope) + : JsReceiverTypeSource.NONE; + const row = new JsCallSiteRegistry({ + callKind: kind, + calleeText: callee.getText(this.sourceFile), + calleeName, + receiverText: receiver?.getText(this.sourceFile) ?? '', + receiverPosition, + argumentCount: args.length, + hasSpreadArgument: args.some((argument) => ts.isSpreadElement(argument)), + // ASKED OF THE CALL, not derived from the callee's kind. + // + // It was `kind === OPTIONAL_CALL`, and OPTIONAL_CALL is only ever returned + // for a PROPERTY-ACCESS callee. So the optional token was read off the + // member access and never off the call, and four shapes came out asserting + // the opposite of the truth while their control passed in the same file: + // + // maybe?.get?.('/f') true (control — a property-access callee) + // callback?.() FALSE — the call's own `?.(`, no member at all + // maybe?.[method]?.() FALSE — element access AND an optional call + // (fn)?.() FALSE — parenthesised callee + // obj?.[key]() FALSE — the RECEIVER short-circuits, so the call + // is conditional though it has no `?.` + // + // 13 of 127 optional call sites corpus-wide, 10% of the construct. It + // loses nothing and is the §4 defect class at its worst: this is a + // REACHABILITY column, the one thing telling an engine the edge is + // conditional on the callee not being nullish. `false` there is not a + // weaker answer, it is a wrong one — and recall, completeness and oracle + // adjudication are all green either way. + // + // `ts.isOptionalChain` is the compiler's own answer and it is exact: the + // OptionalChain flag propagates to every node after a `?.`, so it is true + // for `a?.b.c()` — where the call short-circuits with no token of its own + // — and false for `a.b()`. Verified over all eight shapes. + // + // `callKind` is deliberately NOT changed. It classifies the callee's + // SHAPE, and whether OPTIONAL_CALL should displace COMPUTED_CALL for + // `maybe?.[m]?.()` is a vocabulary question, not mine. Raised, not + // improvised; the two columns are now orthogonal and both true. + isOptionalCall: ts.isOptionalChain(node), + // The 52.6% ceiling made explicit: the parser will not name the target for + // roughly half of all calls, and this column says how much the engine has. + declaredReceiverTypeName, + receiverTypeSource: receiverSource, + resolutionOutcome: resolutionOutcomeFor(kind, receiverSource, calleeSource), + isDynamicCode: kind === JsCallKind.DYNAMIC_CODE_CALL, + enclosingMethodLinkHash: ownerMethodHash, + expressionLinkHash: expression.getHash(), + ownerScopeLinkHash: this.options.hashOfScope(scope), + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + // Must be false in every row; the gate asserts it. + isTypeOnlyTarget: false, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.callSites.push(row); + expression.setCallSiteLinkHash(row.getHash()); + if (receiver !== undefined) { + const receiverRow = this.rowByNode.get(nodeKey(unwrap(receiver) ?? receiver)); + if (receiverRow !== undefined) { + row.setReceiverExpressionLinkHash(receiverRow.getHash()); + } + } + } + + /** + * How much type information the receiver carries, from syntax alone. + * + * `IMPORT_ALIAS` is the one that matters: `const x = require('y'); x.foo()` is + * 34.4% of all oracle declines — the largest single cause — and every one is + * *reconstructable* rather than unresolvable, because the binding names an + * import and the import row carries `resolvedFilePath`. + */ + private receiverTypeSourceFor( + receiver: ts.Expression | undefined, + scope: JsScopeNode + ): JsReceiverTypeSource { + if (receiver === undefined || !ts.isIdentifier(receiver)) { + return JsReceiverTypeSource.NONE; + } + const resolution = resolveName(scope, receiver.text); + if (resolution === undefined) { + return GLOBAL_BUILTIN_NAMES.has(receiver.text) + ? JsReceiverTypeSource.NODE_BUILTIN + : JsReceiverTypeSource.NONE; + } + if (resolution.binding.regime === JsBindingRegime.IMPORT_BINDING) { + return this.importSourceFor(resolution.binding.declarationNode); + } + if (resolution.binding.regime === JsBindingRegime.CLASS_TDZ) { + return JsReceiverTypeSource.LOCAL_CLASS; + } + if (resolution.binding.regime === JsBindingRegime.GLOBAL_IMPLICIT) { + return JsReceiverTypeSource.NONE; + } + // A `const x = require('y')` binding. The declaration extractor recorded + // `initializerKind = REQUIRE_CALL` on the variable; the module-edge pass + // fills `importLinkHash` on both rows, which is the hop the engine walks. + if (isRequireBound(resolution.binding.declarationNode)) { + return this.importSourceFor(resolution.binding.declarationNode); + } + // `const x = new Foo(); x.m()` — the receiver's type is a class declared in + // THIS file, which is the one case the parser can very nearly finish and the + // whole reason `SAME_FILE_RESOLVED` exists. Matching only on the receiver + // BEING the class name missed every instance of one, which is the common + // shape by a wide margin. + const constructed = constructedTypeNameOf(resolution.binding.declarationNode); + if (constructed !== undefined && this.options.declaresTypeNamed(constructed)) { + return JsReceiverTypeSource.LOCAL_CLASS; + } + // A JSDoc `@type {Foo}` on the binding. The only declared-type channel the + // language has, and it carries 37.9% of parameters — so a receiver typed by + // one is a genuinely different report from one typed by nothing, and + // folding them together says the parser knows less than it does. + if (hasJsDocType(resolution.binding.declarationNode)) { + return JsReceiverTypeSource.JSDOC; + } + return JsReceiverTypeSource.NONE; + } + + /** + * The JSDoc-declared type of a name, as written. + * + * Without type arguments, so a scope lookup needs no string surgery: + * `@type {Array}` names `Array`, and `Foo` is a child row in + * `js_type_reference`. + */ + private declaredTypeNameFor(name: ts.Identifier, scope: JsScopeNode): string { + const resolution = resolveName(scope, name.text); + const declaration = resolution?.binding.declarationNode ?? null; + if (declaration === null) { + return ''; + } + let current: ts.Node | undefined = declaration; + while (current !== undefined && !ts.isSourceFile(current)) { + const tag = ts.getJSDocTypeTag(current); + const type = tag?.typeExpression?.type; + if (type !== undefined) { + return ts.isTypeReferenceNode(type) + ? type.typeName.getText(this.sourceFile) + : type.getText(this.sourceFile); + } + if (ts.isStatement(current)) { + break; + } + current = current.parent; + } + return ''; + } + + /** + * An imported name, split by WHERE the module resolved to. + * + * `require('path')` and `require('./router')` are both import aliases and are + * not the same report: the first targets the `lib_*` population, which is not + * in this project and which no import hop reaches, and the second targets a + * file the engine can open. Calling both `IMPORT_ALIAS` made every builtin + * look like an import hop the parser had failed to complete — 739 of them on + * one corpus, all of them correct and all counted as a gap. + */ + private importSourceFor(declarationNode: ts.Node | null): JsReceiverTypeSource { + const specifier = requireSpecifierOf(declarationNode); + if (specifier !== undefined && isNodeBuiltinSpecifier(specifier)) { + return JsReceiverTypeSource.NODE_BUILTIN; + } + return JsReceiverTypeSource.IMPORT_ALIAS; + } + + /** + * What the CALLEE resolves to, for a call with no receiver. + * + * Same-file one hop, and nothing more. A bare `fn()` whose name is bound by a + * function declaration in this file has a target the parser really can name — + * which is the one case `SAME_FILE_RESOLVED` exists for. Everything else + * reports the channel and stops. + */ + private calleeTypeSourceFor( + callee: ts.Expression, + scope: JsScopeNode + ): JsReceiverTypeSource { + const unwrapped = unwrap(callee) ?? callee; + if (!ts.isIdentifier(unwrapped)) { + return JsReceiverTypeSource.NONE; + } + const resolution = resolveName(scope, unwrapped.text); + if (resolution === undefined) { + return GLOBAL_BUILTIN_NAMES.has(unwrapped.text) + ? JsReceiverTypeSource.NODE_BUILTIN + : JsReceiverTypeSource.NONE; + } + const regime = resolution.binding.regime; + if (regime === JsBindingRegime.IMPORT_BINDING) { + return JsReceiverTypeSource.IMPORT_ALIAS; + } + if (isRequireBound(resolution.binding.declarationNode)) { + return JsReceiverTypeSource.IMPORT_ALIAS; + } + if (regime === JsBindingRegime.CLASS_TDZ + || regime === JsBindingRegime.FUNCTION_DECLARATION_HOISTED) { + return JsReceiverTypeSource.LOCAL_CLASS; + } + return JsReceiverTypeSource.NONE; + } + + // ------------------------------------------------------------------------- + // name resolution — SAME FILE, ONE HOP + // ------------------------------------------------------------------------- + + /** + * Resolves an identifier against the binder, and never across a file. + * + * `bindingResolution` records **which scope** the name came from, which is a + * different fact from *which row*: a name found in the current scope and the + * same name found three closures up point at one `js_variable` row and mean + * very different things about the code — the second says the value outlives + * its frame. + * + * `resolvedBindingLinkHash` is tier 2 and **same-file one-hop only**. Following + * an import to the declaring file is `type-resolution.dl` rewritten in + * TypeScript, which was written once and then deleted. + */ + private resolveIdentifier( + node: ts.Identifier, + row: JsExpressionRegistry, + scope: JsScopeNode + ): void { + const resolution = resolveName(scope, node.text); + if (resolution === undefined) { + row.setBindingResolution( + GLOBAL_BUILTIN_NAMES.has(node.text) + ? JsBindingResolution.GLOBAL_BUILTIN + : JsBindingResolution.UNRESOLVED_FREE + ); + return; + } + const binding = resolution.binding; + const where = binding.regime === JsBindingRegime.IMPORT_BINDING + ? JsBindingResolution.IMPORTED + : resolution.foundIn === this.options.binder.globalScope + ? JsBindingResolution.GLOBAL_BUILTIN + : resolution.foundIn === this.options.binder.moduleScope + ? JsBindingResolution.MODULE + : resolution.functionBoundariesCrossed > 0 + ? JsBindingResolution.CLOSURE + : JsBindingResolution.LOCAL; + row.setBindingResolution(where); + if (binding.declarationNode === null) { + return; + } + if (binding.regime === JsBindingRegime.PARAMETER) { + // c33/c34. A parameter has no js_variable row — its name, position and + // default belong to js_method_parameter, and minting a second row would + // be one construct on two paths — so c17 could never carry this. The + // binding's declaration node is the bound NAME, possibly deep inside a + // destructuring pattern; the parameter row is keyed on the + // ParameterDeclaration above it, and the path between the two is c34. + const located = parameterOfBoundName(binding.declarationNode); + if (located !== undefined) { + const hash = this.options.parameterHashByNode.get(nodeKey(located.parameter)); + if (hash !== undefined) { + row.setResolvedParameterLinkHash(hash, located.path); + } + } + return; + } + const hash = this.options.variableHashByBindingKey.get(nodeKey(binding.declarationNode)); + if (hash !== undefined) { + row.setResolvedBindingLinkHash(hash); + } + } + + /** + * The innermost scope containing a node. + * + * The binder recorded one for every node it visited; a node it did not visit — + * a binding pattern's inner name, say — walks up to the nearest ancestor that + * has one. Never by comparing positions: two scopes beginning at the same + * offset is the normal case in JavaScript, not an edge case. + */ + private scopeAt(node: ts.Node): JsScopeNode { + let current: ts.Node | undefined = node; + while (current !== undefined) { + const scope = this.options.binder.enclosingScopeOf.get(nodeKey(current)); + if (scope !== undefined) { + return scope; + } + current = current.parent; + } + return this.options.binder.moduleScope; + } + + private positionOf(node: ts.Node): { + startLine: number; startColumn: number; endLine: number; endColumn: number; + } { + return rangeOf(node, this.sourceFile); + } +} + +/** + * Removes every wrapper that produces no row, in ONE place. + * + * This is the fix for §6's most expensive failure, and the reason it is one + * function rather than a check at each call site: *unwrapping at the root fixed + * every position at once*, taking one TypeScript corpus from 55,683 to 57,491 + * expressions. + * + * Four wrappers, and the list is deliberately closed: + * + * - **Parentheses.** `( a && b.c() )` — the tree died entirely. + * - **JSX expression containers.** `{t(msg)}` — 4,488 of 14,335 call sites. + * - **Non-null assertions** and **type assertions.** TypeScript syntax that + * `ts.createSourceFile` will happily parse out of a `.js` file carrying Flow, + * and a wrapper either way. + * + * Returns `undefined` for an empty JSX brace — `{}` wraps nothing, so there is + * no expression to emit and no subtree to lose. + */ +function unwrap(node: ts.Expression): ts.Expression | undefined { + let current: ts.Expression = node; + for (;;) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isJsxExpression(current)) { + if (current.expression === undefined) { + return undefined; + } + current = current.expression; + continue; + } + return current; + } +} + +/** + * The expression kinds this relation emits, as an allowlist. + * + * `undefined` means the node is not an expression position this schema records. + * An allowlist rather than a default case, because a default case is how JSDoc + * type names end up in the expression relation and type-only constructs reach + * the call graph. + */ +function expressionKindOf(node: ts.Expression): JsExpressionKind | undefined { + if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) { + return JsExpressionKind.IDENTIFIER; + } + if (node.kind === ts.SyntaxKind.ThisKeyword) { + return JsExpressionKind.THIS; + } + if (node.kind === ts.SyntaxKind.SuperKeyword) { + return JsExpressionKind.SUPER; + } + if (ts.isMetaProperty(node)) { + return JsExpressionKind.META_PROPERTY; + } + if (isModuleEdgeCall(node)) { + return JsExpressionKind.MODULE_EDGE_CALL; + } + if (ts.isCallExpression(node)) { + return JsExpressionKind.CALL; + } + if (ts.isNewExpression(node)) { + return JsExpressionKind.NEW; + } + if (ts.isTaggedTemplateExpression(node)) { + return JsExpressionKind.TAGGED_TEMPLATE; + } + if (ts.isPropertyAccessExpression(node)) { + return node.questionDotToken !== undefined + ? JsExpressionKind.OPTIONAL_ACCESS + : JsExpressionKind.PROPERTY_ACCESS; + } + if (ts.isElementAccessExpression(node)) { + return node.questionDotToken !== undefined + ? JsExpressionKind.OPTIONAL_ACCESS + : JsExpressionKind.ELEMENT_ACCESS; + } + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.CommaToken) { + return JsExpressionKind.SEQUENCE; + } + return isAssignmentOperator(node.operatorToken.kind) + ? JsExpressionKind.ASSIGNMENT + : JsExpressionKind.BINARY; + } + if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node) + || ts.isTypeOfExpression(node) || ts.isVoidExpression(node) + || ts.isDeleteExpression(node)) { + return JsExpressionKind.UNARY; + } + if (ts.isConditionalExpression(node)) { + return JsExpressionKind.CONDITIONAL; + } + if (ts.isCommaListExpression(node)) { + return JsExpressionKind.SEQUENCE; + } + // `ts.CommaListExpression` is a node the TRANSFORMER creates; the parser emits + // `a, b` as a BinaryExpression whose operator is a comma. Checking only the + // former is why SEQUENCE was declared and never emitted — and a comma operator + // classified as BINARY reads as an arithmetic operation, when what it actually + // does is evaluate the left operand and discard it. + if (ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.CommaToken) { + return JsExpressionKind.SEQUENCE; + } + if (ts.isObjectLiteralExpression(node)) { + return JsExpressionKind.OBJECT_LITERAL; + } + if (ts.isArrayLiteralExpression(node)) { + return JsExpressionKind.ARRAY_LITERAL; + } + if (ts.isSpreadElement(node)) { + return JsExpressionKind.SPREAD; + } + if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return JsExpressionKind.TEMPLATE; + } + if (ts.isFunctionExpression(node) || ts.isArrowFunction(node) + || ts.isClassExpression(node)) { + return JsExpressionKind.FUNCTION_EXPRESSION; + } + if (ts.isAwaitExpression(node)) { + return JsExpressionKind.AWAIT; + } + if (ts.isYieldExpression(node)) { + return JsExpressionKind.YIELD; + } + if (isJsxNode(node)) { + return jsxTagReference(node) === undefined + ? JsExpressionKind.JSX_INTRINSIC_ELEMENT + : JsExpressionKind.JSX_ELEMENT; + } + if (isLiteral(node)) { + return JsExpressionKind.LITERAL; + } + if (ts.isRegularExpressionLiteral(node)) { + return JsExpressionKind.LITERAL; + } + return undefined; +} + +function isCallLike(node: ts.Expression): boolean { + return ts.isCallExpression(node) || ts.isNewExpression(node) + || ts.isTaggedTemplateExpression(node); +} + +/** + * The call kind, from syntax and nothing else. + * + * Every value here is readable from the expression in front of you. The five + * that are not — `INDEX_CALL`, `GETTER_INVOCATION`, `SETTER_INVOCATION`, + * `PROXY_TRAP_CALL`, `GENERATOR_RESUME` — are reserved with a zero-row + * assertion, because each is a fact about a value's runtime identity and + * guessing is wrong more often than right. + */ +function callKindOf(node: ts.Expression): JsCallKind { + if (ts.isTaggedTemplateExpression(node)) { + return JsCallKind.TAGGED_TEMPLATE_CALL; + } + if (ts.isNewExpression(node)) { + return JsCallKind.CONSTRUCTOR_CALL; + } + const call = node as ts.CallExpression; + // Unwrapped: `(function () { … })()` puts a parenthesis between the call and + // its function, so a check against the raw callee sees a wrapper and reports + // FUNCTION_CALL with no name. The call, the parenthesis and the function all + // begin at the same offset, which is why this is the one place it matters. + const callee = unwrap(call.expression) ?? call.expression; + if (callee.kind === ts.SyntaxKind.SuperKeyword) { + return JsCallKind.SUPER_CALL; + } + if (callee.kind === ts.SyntaxKind.ImportKeyword) { + return JsCallKind.DYNAMIC_IMPORT_CALL; + } + const displaced = displacedReceiverKind(call); + if (displaced !== undefined) { + return displaced; + } + if (ts.isIdentifier(callee)) { + if (callee.text === 'eval') { + return JsCallKind.DYNAMIC_CODE_CALL; + } + return JsCallKind.FUNCTION_CALL; + } + if (ts.isElementAccessExpression(callee)) { + // `obj[expr]()`. The name is NOT fixed by syntax, so `calleeName` is `""`. + // That is an honest terminal rather than a gap — the IR-completeness gate + // treats it as complete *because* it says so. + return JsCallKind.COMPUTED_CALL; + } + if (ts.isPropertyAccessExpression(callee)) { + return call.questionDotToken !== undefined || callee.questionDotToken !== undefined + ? JsCallKind.OPTIONAL_CALL + : JsCallKind.METHOD_CALL; + } + if (ts.isFunctionExpression(callee) || ts.isArrowFunction(callee)) { + // An IIFE. The pre-ES6 module pattern, and the reason unwrap() matters: the + // call, the parenthesis and the function all begin at the same offset. + return JsCallKind.IIFE_CALL; + } + return JsCallKind.FUNCTION_CALL; +} + +/** + * `.call` / `.apply` / `.bind` — the three that move the receiver into an + * argument. + * + * 1,048 sites measured. Recognised by the member name plus a call shape, which + * is all syntax offers: whether `x.call(…)` is `Function.prototype.call` or a + * method named `call` on some object is a fact about `x`. The schema accepts + * that: naming the receiver's real position is strictly better than reading + * `Function.prototype.call` as the target, and the cases where an object has its + * own `call` method are rare enough that the trade is stated rather than hidden. + */ +function displacedReceiverKind(call: ts.CallExpression): JsCallKind | undefined { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) { + return undefined; + } + if (callee.name.text === 'call' && call.arguments.length >= 1) { + return JsCallKind.FUNCTION_CALL_CALL; + } + if (callee.name.text === 'apply' && call.arguments.length >= 1) { + return JsCallKind.FUNCTION_CALL_APPLY; + } + if (callee.name.text === 'bind' && call.arguments.length >= 1) { + // Produces a function; does not invoke one. An engine treating it as an + // invocation of the target reports an edge that does not happen here. + return JsCallKind.FUNCTION_CALL_BIND; + } + return undefined; +} + +/** + * What the engine will have to do with this call site. + * + * The receiver decides when there is one; the callee decides when there is not. + * `COMPUTED_NAME` and `DYNAMIC_CODE` win over both, because a row that says the + * name is not fixed by syntax is **complete because it says so** — the + * IR-completeness gate treats those as honest terminals rather than misses. + */ +function resolutionOutcomeFor( + kind: JsCallKind, + receiverSource: JsReceiverTypeSource, + calleeSource: JsReceiverTypeSource +): JsCallResolutionOutcome { + if (kind === JsCallKind.COMPUTED_CALL) { + return JsCallResolutionOutcome.COMPUTED_NAME; + } + if (kind === JsCallKind.DYNAMIC_CODE_CALL) { + return JsCallResolutionOutcome.DYNAMIC_CODE; + } + const source = receiverSource === JsReceiverTypeSource.NONE + ? calleeSource + : receiverSource; + if (source === JsReceiverTypeSource.IMPORT_ALIAS) { + return JsCallResolutionOutcome.IMPORT_HOP_AVAILABLE; + } + if (source === JsReceiverTypeSource.LOCAL_CLASS) { + return JsCallResolutionOutcome.SAME_FILE_RESOLVED; + } + if (source === JsReceiverTypeSource.NODE_BUILTIN) { + return JsCallResolutionOutcome.AMBIENT_BUILTIN_TARGET; + } + return JsCallResolutionOutcome.RECEIVER_UNTYPED; +} + +function nameOfCallee(callee: ts.Expression): string { + if (ts.isIdentifier(callee)) { + return callee.text; + } + if (ts.isPropertyAccessExpression(callee)) { + return callee.name.text; + } + // An element-access callee: the name is not fixed by syntax. + return ''; +} + +function nameOf(node: ts.Expression): string { + if (ts.isIdentifier(node)) { + return node.text; + } + if (ts.isPropertyAccessExpression(node)) { + return node.name.text; + } + return ''; +} + +function operatorOf(node: ts.Expression): string { + if (ts.isBinaryExpression(node)) { + return ts.tokenToString(node.operatorToken.kind) ?? ''; + } + if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { + return ts.tokenToString(node.operator) ?? ''; + } + if (ts.isTypeOfExpression(node)) { + return 'typeof'; + } + if (ts.isVoidExpression(node)) { + return 'void'; + } + if (ts.isDeleteExpression(node)) { + return 'delete'; + } + if (ts.isPropertyAccessExpression(node) && node.questionDotToken !== undefined) { + return '?.'; + } + if (ts.isElementAccessExpression(node) && node.questionDotToken !== undefined) { + return '?.'; + } + return ''; +} + +/** + * What is being done to this name. + * + * `READ_WRITE` for `x += 1` and `x++`, which do both: collapsing it into `WRITE` + * loses the read a data-flow analysis needs and collapsing it into `READ` loses + * the write. + */ +function referenceKindOf(node: ts.Expression): string { + const parent = node.parent; + if (parent === undefined) { + return JsReferenceKind.READ; + } + if (ts.isBinaryExpression(parent) && parent.left === node + && isAssignmentOperator(parent.operatorToken.kind)) { + return parent.operatorToken.kind === ts.SyntaxKind.EqualsToken + ? JsReferenceKind.WRITE + : JsReferenceKind.READ_WRITE; + } + if ((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) + && (parent.operator === ts.SyntaxKind.PlusPlusToken + || parent.operator === ts.SyntaxKind.MinusMinusToken)) { + return JsReferenceKind.READ_WRITE; + } + if (ts.isDeleteExpression(parent)) { + return JsReferenceKind.DELETE; + } + if (ts.isTypeOfExpression(parent)) { + // The one reference form that tolerates an unbound name, so an unresolved + // TYPEOF is not evidence of a missing binding. + return JsReferenceKind.TYPEOF; + } + return JsReferenceKind.READ; +} + +function literalKindOf(node: ts.Expression): JsLiteralKind { + if (ts.isStringLiteral(node)) { + return JsLiteralKind.STRING; + } + if (ts.isNumericLiteral(node)) { + return JsLiteralKind.NUMBER; + } + if (ts.isBigIntLiteral(node)) { + return JsLiteralKind.BIGINT; + } + if (ts.isRegularExpressionLiteral(node)) { + return JsLiteralKind.REGEX; + } + if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) { + return JsLiteralKind.TEMPLATE; + } + if (node.kind === ts.SyntaxKind.NullKeyword) { + return JsLiteralKind.NULL; + } + if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) { + return JsLiteralKind.BOOLEAN; + } + // `undefined` is an identifier in the grammar, not a literal. Recorded as one + // because every consumer wants it to be, and nothing is lost: the IDENTIFIER + // row it also produces carries bindingResolution = GLOBAL_BUILTIN. + if (ts.isIdentifier(node) && node.text === 'undefined') { + return JsLiteralKind.UNDEFINED; + } + return JsLiteralKind.NONE; +} + +function isLiteral(node: ts.Expression): boolean { + return ts.isStringLiteral(node) || ts.isNumericLiteral(node) + || ts.isBigIntLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) + || node.kind === ts.SyntaxKind.NullKeyword + || node.kind === ts.SyntaxKind.TrueKeyword + || node.kind === ts.SyntaxKind.FalseKeyword; +} + +function propertyKeyText(name: ts.PropertyName): string { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) + || ts.isPrivateIdentifier(name)) { + return name.text; + } + return ''; +} + +function isJsxNode(node: ts.Node): boolean { + return ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) + || ts.isJsxFragment(node); +} + +/** + * The tag expression a JSX element REFERENCES, or `undefined` for an intrinsic + * element and a fragment. + * + * Schema §2.5a, stated exactly because the folk rule ("capitalised or dotted") + * is wrong in both directions: a tag is a reference iff it is a property + * access, or a simple identifier that is a VALID ECMAScript identifier whose + * first character is not a lowercase letter. `` parses as an + * Identifier and is not one — no `const` can bind it — so it is intrinsic + * regardless of case (js-fixtures' boundary case). `_` and `$` are not + * lowercase letters: a lowercase letter CHANGES under `toUpperCase`, and they + * do not. A namespaced name (`svg:circle`) binds nothing. + */ +function isValidIdentifierText(text: string): boolean { + if (text.length === 0) { + return false; + } + const points = [...text].map((c) => c.codePointAt(0) ?? 0); + return ts.isIdentifierStart(points[0]!, ts.ScriptTarget.Latest) + && points.slice(1).every((point) => ts.isIdentifierPart(point, ts.ScriptTarget.Latest)); +} + +function jsxTagReference(node: ts.Node): ts.Expression | undefined { + const tagName = ts.isJsxElement(node) + ? node.openingElement.tagName + : ts.isJsxSelfClosingElement(node) + ? node.tagName + : undefined; + if (tagName === undefined || ts.isJsxNamespacedName(tagName)) { + return undefined; + } + if (ts.isPropertyAccessExpression(tagName)) { + return tagName; + } + if (ts.isIdentifier(tagName)) { + const text = tagName.text; + const first = text.charAt(0); + const isLowercaseLetter = first.toUpperCase() !== first; + return isValidIdentifierText(text) && !isLowercaseLetter ? tagName : undefined; + } + // `` arrives as a property access; a bare `` is not + // valid JSX and the compiler reports it. Anything else is unknown syntax + // and references nothing this parser can name. + return undefined; +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +/** + * The type name a binding was constructed from: `const x = new Foo()` -> `Foo`. + * + * Reads the declaration in front of it and follows nothing. `new pkg.Thing()` + * yields `Thing`, which the caller then checks against this file's own + * declarations — so a same-file answer is given only when the name really is + * declared here. + */ +function constructedTypeNameOf(declarationNode: ts.Node | null): string | undefined { + if (declarationNode === null) { + return undefined; + } + let current: ts.Node | undefined = declarationNode; + while (current !== undefined && !ts.isSourceFile(current)) { + if (ts.isVariableDeclaration(current)) { + const initializer = current.initializer; + if (initializer === undefined || !ts.isNewExpression(initializer)) { + return undefined; + } + const callee = initializer.expression; + if (ts.isIdentifier(callee)) { + return callee.text; + } + if (ts.isPropertyAccessExpression(callee)) { + return callee.name.text; + } + return undefined; + } + current = current.parent; + } + return undefined; +} + +/** Does this binding's declaration carry a JSDoc `@type`? */ +function hasJsDocType(declarationNode: ts.Node | null): boolean { + if (declarationNode === null) { + return false; + } + let current: ts.Node | undefined = declarationNode; + while (current !== undefined && !ts.isSourceFile(current)) { + if (ts.getJSDocTypeTag(current) !== undefined) { + return true; + } + if (ts.isStatement(current)) { + break; + } + current = current.parent; + } + return false; +} + +/** + * The specifier a name was bound from, for `require` and for `import`. + * + * Same-file only: it reads the declaration in front of it and follows nothing. + */ +function requireSpecifierOf(declarationNode: ts.Node | null): string | undefined { + if (declarationNode === null) { + return undefined; + } + let current: ts.Node | undefined = declarationNode; + while (current !== undefined && !ts.isSourceFile(current)) { + if (ts.isImportDeclaration(current) + && ts.isStringLiteralLike(current.moduleSpecifier)) { + return current.moduleSpecifier.text; + } + if (ts.isVariableDeclaration(current)) { + const initializer = current.initializer; + if (initializer === undefined) { + return undefined; + } + const root = ts.isPropertyAccessExpression(initializer) + ? initializer.expression + : initializer; + // `ts.isCallExpression` first, for the NARROWING; `isRequireCall` for the + // question. The shared predicate returns a plain boolean on purpose — see + // its comment — so the caller does its own narrowing where it needs one. + if (ts.isCallExpression(root) && isRequireCall(root)) { + const first = root.arguments[0]; + if (first !== undefined && ts.isStringLiteralLike(first)) { + return first.text; + } + } + return undefined; + } + current = current.parent; + } + return undefined; +} + +/** Was this name bound by `const x = require('y')`? */ +function isRequireBound(declarationNode: ts.Node | null): boolean { + if (declarationNode === null) { + return false; + } + let current: ts.Node | undefined = declarationNode; + while (current !== undefined && !ts.isVariableDeclaration(current)) { + if (ts.isSourceFile(current)) { + return false; + } + current = current.parent; + } + if (current === undefined || !ts.isVariableDeclaration(current)) { + return false; + } + const initializer = current.initializer; + if (initializer === undefined) { + return false; + } + // `require('x')`, and also `require('x').Thing` — the second is how a named + // export is pulled out in CommonJS and it is just as much a module alias. + const root = ts.isPropertyAccessExpression(initializer) + ? initializer.expression + : initializer; + return isRequireCall(root); +} + +/** + * The ParameterDeclaration a bound name belongs to, and the path to it. + * + * c34's rule, as ruled: the path within the pattern AS WRITTEN. Walking up from + * the name Identifier through BindingElements and their patterns to the + * parameter, each level contributes one segment — the property name for an + * object pattern (the `propertyName` if the element renames, else the name), + * the index for an array pattern — and the segments are joined innermost-last: + * + * function f({a, b}) a -> "a" + * function f({b: {c}}) c -> "b.c" + * function f([x]) x -> "0" + * function f([, {name}]) name -> "1.name" + * function f(x) x -> "" not destructured + */ +function parameterOfBoundName( + name: ts.Node +): { parameter: ts.ParameterDeclaration; path: string } | undefined { + const segments: string[] = []; + let current: ts.Node = name; + for (;;) { + const parent: ts.Node | undefined = current.parent; + if (parent === undefined) { + return undefined; + } + if (ts.isParameter(parent)) { + return { parameter: parent, path: segments.join('.') }; + } + if (ts.isBindingElement(parent)) { + const pattern = parent.parent; + if (ts.isArrayBindingPattern(pattern)) { + segments.unshift(String(pattern.elements.indexOf(parent))); + } else if (ts.isObjectBindingPattern(pattern)) { + const key = parent.propertyName ?? parent.name; + segments.unshift(ts.isIdentifier(key) || ts.isStringLiteral(key) || ts.isNumericLiteral(key) + ? key.text + : key.getText()); + } + current = pattern; + continue; + } + if (ts.isObjectBindingPattern(parent) || ts.isArrayBindingPattern(parent)) { + current = parent; + continue; + } + // Not a parameter's binding at all (a `var` in a body, a catch clause). + return undefined; + } +} diff --git a/parser/src/parsers/javascript/extractors/js-expression-walker.ts b/parser/src/parsers/javascript/extractors/js-expression-walker.ts new file mode 100644 index 000000000..75cf36c81 --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-expression-walker.ts @@ -0,0 +1,352 @@ +import * as ts from 'typescript'; + +import { JsRootContext } from '@/enums/javascript/expressions'; +import { JsExpressionExtractor } from + '@/parsers/javascript/extractors/js-expression-extractor'; +import { nodeKey } from '@/parsers/javascript/extractors/js-symbol-table'; + +/** + * Finds every position an expression tree may be rooted at, and nothing else. + * + * ## An allowlist, not a tree walk + * + * §6 of `BUILDING-A-PARSER.md`: *use an allowlist of expression positions, never + * a generic tree walk.* A generic walk reaches JSDoc type nodes, binding + * patterns and type annotations, puts their names into `js_expression`, and + * type-only constructs then reach the call graph — which the schema forbids and + * a gate asserts. Every position below is enumerated because a statement form + * puts an expression there, and the `rootContext` names which. + * + * ## The walk descends into function bodies EXPLICITLY + * + * The other half of §6. A callable's body is a statement list that belongs to + * that callable's own `js_method` row, and a walker that treats a function as a + * leaf emits the function and nothing inside it — which cost 45 of 691 call + * sites in one TypeScript corpus, with every emitted row correct. There were + * simply fewer of them, so no count-based check could see it. + * + * In JavaScript that boundary is not an edge case. The pre-ES6 module pattern + * puts an entire file inside an IIFE, and 13.6% of `require()` calls live inside + * a function body. A walker that stops at the boundary misses the module graph. + */ +export interface ExpressionWalkOptions { + readonly sourceFile: ts.SourceFile; + readonly extractor: JsExpressionExtractor; + /** `nodeKey` of a callable -> its `js_method` hash, from the declaration pass. */ + readonly methodHashByNode: ReadonlyMap; + /** The `` initializer, which owns every top-level expression. */ + readonly moduleInitMethodHash: string; +} + +export class JsExpressionWalker { + private readonly options: ExpressionWalkOptions; + + constructor(options: ExpressionWalkOptions) { + this.options = options; + } + + run(): void { + for (const statement of this.options.sourceFile.statements) { + this.visitStatement(statement, this.options.moduleInitMethodHash); + } + } + + /** + * One statement, in the method that owns it. + * + * `ownerMethodHash` is threaded rather than derived, because deriving it would + * mean walking ancestors at every expression — and an IIFE's parenthesis, its + * function and its call all begin at the same offset, so an ancestor walk that + * compares positions picks the wrong owner constantly. + */ + private visitStatement(node: ts.Node, ownerMethodHash: string): void { + // Every branch braced: a dangling `else` in dispatch-heavy extractor code is + // nearly invisible and silently doubles or drops output. + if (ts.isExpressionStatement(node)) { + this.root(node.expression, JsRootContext.EXPRESSION_STATEMENT, ownerMethodHash); + return; + } + if (ts.isVariableStatement(node)) { + this.visitVariableDeclarationList(node.declarationList, ownerMethodHash); + return; + } + if (ts.isReturnStatement(node)) { + this.root(node.expression, JsRootContext.RETURN, ownerMethodHash); + return; + } + if (ts.isThrowStatement(node)) { + this.root(node.expression, JsRootContext.THROW, ownerMethodHash); + return; + } + if (ts.isIfStatement(node)) { + this.root(node.expression, JsRootContext.CONDITION, ownerMethodHash); + this.visitStatement(node.thenStatement, ownerMethodHash); + if (node.elseStatement !== undefined) { + this.visitStatement(node.elseStatement, ownerMethodHash); + } + return; + } + if (ts.isWhileStatement(node) || ts.isDoStatement(node)) { + this.root(node.expression, JsRootContext.CONDITION, ownerMethodHash); + this.visitStatement(node.statement, ownerMethodHash); + return; + } + if (ts.isForStatement(node)) { + if (node.initializer !== undefined) { + if (ts.isVariableDeclarationList(node.initializer)) { + this.visitVariableDeclarationList(node.initializer, ownerMethodHash); + } else { + this.root(node.initializer, JsRootContext.FOR_HEADER, ownerMethodHash); + } + } + this.root(node.condition, JsRootContext.FOR_HEADER, ownerMethodHash); + this.root(node.incrementor, JsRootContext.FOR_HEADER, ownerMethodHash); + this.visitStatement(node.statement, ownerMethodHash); + return; + } + if (ts.isForInStatement(node) || ts.isForOfStatement(node)) { + if (ts.isVariableDeclarationList(node.initializer)) { + this.visitVariableDeclarationList(node.initializer, ownerMethodHash); + } else { + this.root(node.initializer, JsRootContext.FOR_HEADER, ownerMethodHash); + } + this.root(node.expression, JsRootContext.ITERABLE, ownerMethodHash); + this.visitStatement(node.statement, ownerMethodHash); + return; + } + if (ts.isSwitchStatement(node)) { + this.root(node.expression, JsRootContext.CONDITION, ownerMethodHash); + for (const clause of node.caseBlock.clauses) { + if (ts.isCaseClause(clause)) { + this.root(clause.expression, JsRootContext.SWITCH_CASE_TEST, ownerMethodHash); + } + for (const statement of clause.statements) { + this.visitStatement(statement, ownerMethodHash); + } + } + return; + } + if (ts.isBlock(node)) { + for (const statement of node.statements) { + this.visitStatement(statement, ownerMethodHash); + } + return; + } + if (ts.isTryStatement(node)) { + this.visitStatement(node.tryBlock, ownerMethodHash); + if (node.catchClause !== undefined) { + this.visitStatement(node.catchClause.block, ownerMethodHash); + } + if (node.finallyBlock !== undefined) { + this.visitStatement(node.finallyBlock, ownerMethodHash); + } + return; + } + if (ts.isLabeledStatement(node)) { + this.visitStatement(node.statement, ownerMethodHash); + return; + } + if (ts.isWithStatement(node)) { + this.root(node.expression, JsRootContext.WITH_TARGET, ownerMethodHash); + this.visitStatement(node.statement, ownerMethodHash); + return; + } + if (ts.isFunctionDeclaration(node)) { + this.visitCallable(node); + return; + } + if (ts.isClassDeclaration(node)) { + this.visitClass(node, ownerMethodHash); + return; + } + if (ts.isExportAssignment(node)) { + this.root(node.expression, JsRootContext.EXPORT_VALUE, ownerMethodHash); + return; + } + if (ts.isExportDeclaration(node)) { + // `export { a as b }` binds no expression: the names are declarations + // elsewhere and the specifier is a module edge. Nothing to root. + return; + } + if (ts.isImportDeclaration(node)) { + return; + } + // A statement form with no expression of its own — `break`, `continue`, + // `debugger`, an empty statement. Descending generically here is what would + // turn this into a tree walk, so it deliberately does not. + } + + private visitVariableDeclarationList( + list: ts.VariableDeclarationList, + ownerMethodHash: string + ): void { + for (const declaration of list.declarations) { + this.root(declaration.initializer, JsRootContext.VARIABLE_INITIALIZER, + ownerMethodHash); + // A binding pattern's DEFAULTS are expressions that run: `const { a = f() } + // = o` calls `f`. The pattern's NAMES are declarations and deliberately + // never enter this relation. + this.rootPatternDefaults(declaration.name, ownerMethodHash); + } + } + + private rootPatternDefaults(name: ts.BindingName, ownerMethodHash: string): void { + if (ts.isIdentifier(name)) { + return; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) { + continue; + } + if (element.initializer !== undefined) { + this.root(element.initializer, JsRootContext.PARAMETER_DEFAULT, ownerMethodHash); + } + if (element.propertyName !== undefined + && ts.isComputedPropertyName(element.propertyName)) { + this.root(element.propertyName.expression, JsRootContext.COMPUTED_NAME, + ownerMethodHash); + } + this.rootPatternDefaults(element.name, ownerMethodHash); + } + } + + private visitClass(node: ts.ClassLikeDeclaration, ownerMethodHash: string): void { + // A heritage clause is an EXPRESSION that runs: `class X extends mixin(Y)` + // calls `mixin` at class-definition time. Emitting the class and dropping + // this loses a real call site. + for (const clause of node.heritageClauses ?? []) { + for (const type of clause.types) { + this.root(type.expression, JsRootContext.HERITAGE, ownerMethodHash); + } + } + for (const member of node.members) { + if (member.name !== undefined && ts.isComputedPropertyName(member.name)) { + this.root(member.name.expression, JsRootContext.COMPUTED_NAME, ownerMethodHash); + } + if (ts.isPropertyDeclaration(member)) { + this.root(member.initializer, JsRootContext.FIELD_INITIALIZER, ownerMethodHash); + continue; + } + if (ts.isMethodDeclaration(member) || ts.isConstructorDeclaration(member) + || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) { + this.visitCallable(member); + continue; + } + if (ts.isClassStaticBlockDeclaration(member)) { + const hash = this.methodHashFor(member, ownerMethodHash); + for (const statement of member.body.statements) { + this.visitStatement(statement, hash); + } + continue; + } + } + } + + /** + * A callable's own body, under its own method row. + * + * The explicit descent §6 demands. A parameter default is rooted here too and + * belongs to the callable's own method, because it is evaluated at call time + * in the callable's own scope — `function f(a, b = a)` resolves `a` to the + * parameter, not to anything outside. + */ + private visitCallable(node: ts.FunctionLikeDeclaration): void { + const hash = this.methodHashFor(node, this.options.moduleInitMethodHash); + for (const parameter of node.parameters) { + if (parameter.initializer !== undefined) { + this.root(parameter.initializer, JsRootContext.PARAMETER_DEFAULT, hash); + } + this.rootPatternDefaults(parameter.name, hash); + } + const body = node.body; + if (body === undefined) { + return; + } + if (ts.isBlock(body)) { + for (const statement of body.statements) { + this.visitStatement(statement, hash); + } + return; + } + // A concise arrow: `x => x * 2`. The body IS the returned expression, so it + // is rooted as a RETURN — an extractor looking for a ReturnStatement finds + // none and reports a function that returns nothing. + this.root(body, JsRootContext.RETURN, hash); + } + + /** + * Roots one expression, descending into any callable it contains. + * + * The descent is the subtle part. A function expression inside an expression — + * `arr.map(function (x) { return f(x); })` — gets a `js_expression` row for + * the function itself, and its BODY belongs to its own method row. So after + * emitting the tree, every callable within it is visited as a callable. + * Without that, `return function () { … }` emits the function and nothing + * inside it. + */ + private root( + node: ts.Expression | undefined, + rootContext: JsRootContext, + ownerMethodHash: string + ): void { + if (node === undefined) { + return; + } + this.options.extractor.emitRoot({ node, rootContext, ownerMethodHash }); + this.descendIntoCallables(node); + } + + /** + * Visits every callable inside an expression tree, as a callable. + * + * **Stops at each one, and the recursion is single-path.** `visitCallable` + * reaches whatever is nested deeper through the statements of its own body, + * under its own method hash, so descending past a callable here would visit an + * inner function's body twice — once under the outer method and once under its + * own — and a construct visited on two paths **doubles** its rows rather than + * colliding. + * + * An earlier shape did exactly that: it walked the children first, then + * checked whether the node itself was a callable, so a `return function () { + * … }` inside a function expression was reached along both paths. The + * extractor's own guard absorbed the duplicate rows, which is precisely why it + * would not have been noticed. + * + * The root itself may BE a callable: `const f = function () { … }` roots at + * the function directly, and `(function () { … })()` unwraps to a call whose + * callee is one. + */ + private descendIntoCallables(node: ts.Node): void { + if (ts.isFunctionExpression(node) || ts.isArrowFunction(node)) { + this.visitCallable(node); + return; + } + // An object literal's METHOD or ACCESSOR is a callable too, and this walker + // did not know it. `{ m(x) { f(); } }` reached `f()` through `forEachChild` + // — which looks only for callables and roots no expression — so the call + // was never emitted at all. 3,210 of 3,264 recall misses corpus-wide. + // + // A CLASS member does not arrive here: `visitClass` handles those directly, + // which is why `{ m() {} }` was broken while `class C { m() {} }` was fine. + if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) + || ts.isSetAccessorDeclaration(node)) { + if (ts.isComputedPropertyName(node.name)) { + this.root(node.name.expression, JsRootContext.COMPUTED_NAME, + this.options.moduleInitMethodHash); + } + this.visitCallable(node); + return; + } + if (ts.isClassExpression(node)) { + this.visitClass(node, this.options.moduleInitMethodHash); + return; + } + ts.forEachChild(node, (child) => { + this.descendIntoCallables(child); + }); + } + + private methodHashFor(node: ts.Node, fallback: string): string { + return this.options.methodHashByNode.get(nodeKey(node)) ?? fallback; + } +} diff --git a/parser/src/parsers/javascript/extractors/js-fact-extractor.ts b/parser/src/parsers/javascript/extractors/js-fact-extractor.ts new file mode 100644 index 000000000..0ce032843 --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-fact-extractor.ts @@ -0,0 +1,782 @@ +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { JsBlockRegistry } from '@/analysis-types/javascript/JsBlockRegistry'; +import { JsFieldRegistry } from '@/analysis-types/javascript/JsFieldRegistry'; +import { JsMethodParameterRegistry } from + '@/analysis-types/javascript/JsMethodParameterRegistry'; +import { JsMethodRegistry } from '@/analysis-types/javascript/JsMethodRegistry'; +import { JsModuleRegistry } from '@/analysis-types/javascript/JsModuleRegistry'; +import { JsScopeRegistry } from '@/analysis-types/javascript/JsScopeRegistry'; +import { JsTypeHeritageRegistry } from + '@/analysis-types/javascript/JsTypeHeritageRegistry'; +import { JsTypeRegistry } from '@/analysis-types/javascript/JsTypeRegistry'; +import { JsVariableRegistry } from '@/analysis-types/javascript/JsVariableRegistry'; +import { JsCallSiteRegistry } from '@/analysis-types/javascript/JsCallSiteRegistry'; +import { JsExpressionRegistry } from '@/analysis-types/javascript/JsExpressionRegistry'; +import { JsDeclarationExtractor } from + '@/parsers/javascript/extractors/js-declaration-extractor'; +import { JsSourceProvenance } from '@/enums/javascript/modules'; +import { JsExpressionExtractor } from + '@/parsers/javascript/extractors/js-expression-extractor'; +import { JsExportRegistry } from '@/analysis-types/javascript/JsExportRegistry'; +import { JsImportRegistry } from '@/analysis-types/javascript/JsImportRegistry'; +import { JsExportTargetKind } from '@/enums/javascript/exports'; +import { nodeKey } from '@/parsers/javascript/extractors/js-symbol-table'; +import { JsExpressionWalker } from + '@/parsers/javascript/extractors/js-expression-walker'; +import { extractModuleEdges } from + '@/parsers/javascript/extractors/js-module-edge-extractor'; +import { JsCommentRegistry } from '@/analysis-types/javascript/JsCommentRegistry'; +import { JsTypeReferenceRegistry } from + '@/analysis-types/javascript/JsTypeReferenceRegistry'; +import { JsCommentAttachmentKind } from '@/enums/javascript/comments'; +import { JsTypeReferenceContextKind, JsTypeReferenceOwnerKind } from + '@/enums/javascript/type-references'; +import { JsParseGapRegistry } from '@/analysis-types/javascript/JsParseGapRegistry'; +import { extractComments } from '@/parsers/javascript/extractors/js-comment-extractor'; +import { extractParseGaps } from + '@/parsers/javascript/extractors/js-parse-gap-extractor'; +import { JsDocExtractor } from '@/parsers/javascript/extractors/js-jsdoc-extractor'; +import { JsModuleSystem, JsModuleSystemSource } from '@/enums/javascript/modules'; +import { + extractModule, + ModuleShape, + scriptKindFor, +} from '@/parsers/javascript/extractors/js-module-extractor'; +import { + buildScopes, + ScopeBuildResult, +} from '@/parsers/javascript/extractors/js-scope-builder'; +import { extractScopes } from '@/parsers/javascript/extractors/js-scope-extractor'; + +/** + * Extracts the whole fact spine for ONE JavaScript file. + * + * ## The order is a dependency order, and JavaScript adds a second pass to it + * + * `BUILDING-A-PARSER.md` §1 gives the order as + * `module → type → method → … → expression → call_site`, expressions last + * because they reference everything else. That holds here, **and then inverts at + * the end**: + * + * ``` + * module → scope → type → type_heritage → method → method_parameter → field + * → variable → type_reference (JSDoc) + * → expression → call_site + * → import / export ← SECOND PASS, minted FROM expression rows + * ``` + * + * 83.6% of JavaScript module edges are expression-borne: `require('./x')` is a + * call and `module.exports = X` is an assignment. So `js_import` and `js_export` + * cannot be built before the expressions they are minted from, and the module + * graph therefore depends on the relation that §1 says comes last. That is the + * finding that made JavaScript its own front end rather than a mode of the + * TypeScript one. + * + * ## No `ts.Program`, at any step, for any row + * + * `ts.createSourceFile` is text to AST and `ts.resolveModuleName` is a pure + * function of a specifier and options. Neither needs `node_modules` resolved and + * neither typechecks. The reasons this is a rule and not a preference: + * hermeticity, no worst-case bound, and — the one that actually bites — a + * Program on an unresolvable checkout still builds and quietly types everything + * `any`, returning **confident wrong answers** rather than failing. Building one + * is nearly free; that was never the argument. + */ +export interface JsFileExtractionOptions { + readonly absoluteFilePath: string; + readonly filePath: string; + readonly baseMservPath: string; + readonly moduleQualifiedName: string; + readonly sourceText: string; + readonly serviceVersionLinkHash: string; + /** + * From the `package.json` that GOVERNS this file — never a run-wide constant. + * + * `moduleSystem` is in `js_module`'s primary key, so this is the one option + * whose wrong value does not produce a wrong column but an incomparable fact + * base. It is resolved per file, by `PackageJsonResolver`, and the governing + * file is frequently outside the repository entirely. + */ + readonly moduleSystem: JsModuleSystem; + readonly moduleSystemSource: JsModuleSystemSource; + readonly governingPackageJsonPath: string; + readonly packageName: string; + readonly compilerOptions: ts.CompilerOptions; + /** Absolute path -> `js_module` hash for every file in the analysis. */ + readonly projectModuleHashes: ReadonlyMap; + /** Absolute path -> project-relative path, extension stripped. */ + readonly toProjectRelative: (absolutePath: string) => string; +} + +export interface JsFileFacts { + readonly modules: readonly JsModuleRegistry[]; + readonly scopes: readonly JsScopeRegistry[]; + readonly types: readonly JsTypeRegistry[]; + readonly heritages: readonly JsTypeHeritageRegistry[]; + readonly methods: readonly JsMethodRegistry[]; + readonly methodParameters: readonly JsMethodParameterRegistry[]; + readonly fields: readonly JsFieldRegistry[]; + readonly variables: readonly JsVariableRegistry[]; + readonly blocks: readonly JsBlockRegistry[]; + readonly expressions: readonly JsExpressionRegistry[]; + readonly callSites: readonly JsCallSiteRegistry[]; + readonly imports: readonly JsImportRegistry[]; + readonly exports: readonly JsExportRegistry[]; + readonly comments: readonly JsCommentRegistry[]; + readonly typeReferences: readonly JsTypeReferenceRegistry[]; + readonly parseGaps: readonly JsParseGapRegistry[]; + // OPTIONAL, because a DECLINED file has none of them. + // + // A Flow file returns after its module row and is never bound, never walked + // and never linked — so there is no binder, no declaration extractor and no + // scope tree to hand back. Marking these optional is the type saying that, + // rather than a lie kept consistent by building state nobody will read. + // + // Nothing outside this file consumes them today; they exist so a later pass + // can link without re-deriving a key. + readonly declarations?: JsDeclarationExtractor; + readonly expressionExtractor?: JsExpressionExtractor; + readonly shape?: ModuleShape; + /** The binder's tree, kept so later passes resolve names rather than hashes. */ + readonly binder?: ScopeBuildResult; + readonly sourceFile?: ts.SourceFile; + readonly fileModuleHash?: string; + readonly filePath?: string; +} + +export function extractJavaScriptFile(options: JsFileExtractionOptions): JsFileFacts { + // `setParentNodes = true`. The parent pointers are not a convenience: owner + // derivation and scope lookup both walk ANCESTORS, and the alternative — + // comparing positions — picks the wrong scope whenever two of them begin at + // the same offset, which in JavaScript happens constantly because an IIFE's + // function, its call and its parenthesis all start together. + const sourceFile = ts.createSourceFile( + options.absoluteFilePath, + options.sourceText, + ts.ScriptTarget.Latest, + true, + scriptKindFor(options.absoluteFilePath) + ); + + const moduleResult = extractModule({ + sourceFile, + sourceText: options.sourceText, + filePath: options.filePath, + baseMservPath: options.baseMservPath, + moduleQualifiedName: options.moduleQualifiedName, + moduleSystem: options.moduleSystem, + moduleSystemSource: options.moduleSystemSource, + governingPackageJsonPath: options.governingPackageJsonPath, + packageName: options.packageName, + serviceVersionLinkHash: options.serviceVersionLinkHash, + }); + const fileModuleHash = moduleResult.module.getHash(); + + // FLOW IS OUT OF SCOPE, and this is where the scope ends. + // + // Ruled after js-corpus sized it: 3,793 parameters carrying Flow annotations + // indistinguishable from TypeScript ones, `declare function` minting + // type-only method rows into the call graph, and zero of 248 `@flow` files + // parsing cleanly. `ts.createSourceFile` under ScriptKind.JS accepts Flow's + // grammar where it overlaps TypeScript's and MIS-PARSES where it diverges, + // which is one cause wearing three faces. + // + // The module row is still emitted, and that is the entire design. A silent + // skip is §9's nested-config failure — 1,270 of 1,821 files excluded with nothing + // counting them, and a run that reported success. Here the file is in the + // fact base saying exactly what it is, so "how much of this tree was + // declined" is a query rather than a guess, and gate 7.3.5 — a non-PROJECT + // file contributes zero rows to any denominator — covers it with no new code. + // + // RETURNING EARLY, before the binder. Not after emitting and filtering: the + // point is that no row exists to be filtered, so nothing downstream can read + // a Flow annotation as a TypeScript one by forgetting to check a column. + if (moduleResult.module.sourceProvenance === JsSourceProvenance.FLOW_REJECTED) { + return { + modules: [moduleResult.module], + scopes: [], types: [], heritages: [], methods: [], methodParameters: [], + fields: [], variables: [], blocks: [], expressions: [], callSites: [], + imports: [], exports: [], comments: [], typeReferences: [], parseGaps: [], + }; + } + + // The binder, second. Nothing downstream is trustworthy until the scope tree + // is right: in a language where 0.165% of parameters are annotated, what a + // name refers to is the binder's answer and not the type system's. This is the + // step `ts-fact-extractor.ts` spends on declaration-merge scopes and this + // front end spends on hoisting, the temporal dead zone and `this`. + const binder = buildScopes({ + sourceFile, + moduleSystem: options.moduleSystem, + hasEsmSyntax: moduleResult.shape.hasEsmSyntax, + }); + const scopes = extractScopes({ + build: binder, + moduleHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + }); + moduleResult.module.setModuleScopeLinkHash( + scopes.hashOfScope(binder.moduleScope) + ); + + // Declarations third. Types before methods before parameters before fields + // before variables — each step needs the hashes the previous one minted, and + // all of them are one walk so no construct is visited twice. + const declarations = new JsDeclarationExtractor({ + sourceFile, + binder, + filePath: options.filePath, + fileName: path.basename(options.filePath), + baseMservPath: options.baseMservPath, + moduleHash: fileModuleHash, + moduleQualifiedName: options.moduleQualifiedName, + serviceVersionLinkHash: options.serviceVersionLinkHash, + hashOfScope: scopes.hashOfScope, + }); + declarations.run(); + moduleResult.module.setModuleInitMethodLinkHash(declarations.moduleInitMethodHash); + + // Expressions and call sites LAST among the tree-walking passes, per §1: they + // reference everything above by hash, and building them earlier would mean + // re-deriving keys that do not exist yet. + const expressions = new JsExpressionExtractor({ + sourceFile, + binder, + moduleHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + hashOfScope: scopes.hashOfScope, + declarationByAssignment: declarations.declarationByAssignment, + variableHashByBindingKey: declarations.variableHashByNode, + parameterHashByNode: declarations.parameterHashByNode, + declaresTypeNamed: (name) => declarations.typeNamed(name) !== undefined, + }); + new JsExpressionWalker({ + sourceFile, + extractor: expressions, + methodHashByNode: declarations.methodHashByNode, + moduleInitMethodHash: declarations.moduleInitMethodHash, + }).run(); + + // c32 `introducesDeclarationLinkHash`: the callable an expression IS. + // + // The gap it closes: `emitter.on('x', () => { … })` produces a call site, an + // argument expression, and a js_method row for the arrow — and nothing joined + // the third to the second, so every call inside that body was orphaned from + // the edge that reaches it. Measured here at 10,169 callbacks in argument + // position with no link, and independently by js-oracle at 7,923 of 25,573 + // callables (31.0%), 98.2% of them anonymous. + // + // Deliberately NOT c12/c13: `isDeclarationBearing` means "this assignment + // declares a member", and an arrow passed as an argument declares none. + // Widening that column would leave every existing consumer reading the old + // meaning against a column that now carries something else — silently, which + // is the §4 defect class. + // + // Same file, one hop, purely syntactic: both rows were minted by this pass. + for (const [identity, row] of expressions.rowByNode) { + const method = declarations.methodHashByNode.get(identity); + if (method !== undefined) { + row.setIntroducesDeclarationLinkHash(method); + continue; + } + // A CLASS_EXPRESSION introduces a js_type, and the column is declared + // FK→js_method. A class expression's row therefore points at its + // CONSTRUCTOR, which is the callable it introduces and the thing an engine + // following a call would want; a class with no constructor links nothing + // rather than pointing into the wrong relation. + const type = declarations.typeHashByNode.get(identity); + if (type !== undefined) { + const constructor = declarations.constructorMethodOf(type); + if (constructor !== undefined) { + row.setIntroducesDeclarationLinkHash(constructor); + } + } + } + + // Close the declaration-to-expression links, now that the hashes exist. A + // variable's initializer, a field's, a prototype assignment's source — each is + // a chain an engine can follow, and each was empty until this point. + for (const pending of declarations.pendingExpressionLinks) { + const hash = expressions.rootHashByNode.get(pending.nodeIdentity) + ?? expressions.rowByNode.get(pending.nodeIdentity)?.getHash(); + if (hash !== undefined && hash !== '') { + pending.link(hash); + } + } + + // THE SECOND PASS. `js_import` and `js_export` are minted FROM the expression + // rows above, because 83.6% of JavaScript module edges are expression-borne: + // `require('./x')` is a call and `module.exports = X` is an assignment. This + // is the step that inverts §1's build order, and it is the finding that made + // JavaScript its own front end rather than a mode of the TypeScript one. + // EVERY declaration under a name, in source order — not the first one. + // + // It was `Map`: types last-wins, methods and variables + // first-wins behind a `has` guard. A module declaring `author` twice gave + // every `exports.author =` the first one. 570 colliding names over 4,561 + // files, 3,999 shadowed declarations — the largest of the six name-keyed + // indexes, and the same failure as all of them: a name is not an identity. + // + // Resolved at the EXPORT's own position, nearest-preceding, so + // `module.exports.f = f` picks the `f` a reader would see. + const declarationTargetByName = new Map< + string, Array<{ kind: JsExportTargetKind; hash: string; start: number }> + >(); + const addTarget = ( + name: string, kind: JsExportTargetKind, hash: string, line: number, column: number + ): void => { + if (name === '') { + return; + } + const existing = declarationTargetByName.get(name) ?? []; + existing.push({ kind, hash, start: offsetOfRow(sourceFile, line, column) }); + existing.sort((a, b) => a.start - b.start); + declarationTargetByName.set(name, existing); + }; + for (const type of declarations.types) { + addTarget(type.name, JsExportTargetKind.TYPE, type.getHash(), + type.startLine, type.startColumn); + } + for (const method of declarations.methods) { + if (method.ownerTypeLinkHash === '') { + addTarget(method.name, JsExportTargetKind.METHOD, method.getHash(), + method.startLine, method.startColumn); + } + } + for (const variable of declarations.variables) { + addTarget(variable.name, JsExportTargetKind.VARIABLE, variable.getHash(), + variable.startLine, variable.startColumn); + } + const moduleEdges = extractModuleEdges({ + sourceFile, + binder, + absoluteFilePath: options.absoluteFilePath, + moduleHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + serviceVersion: '', + compilerOptions: options.compilerOptions, + hashOfScope: scopes.hashOfScope, + expressionRowByNode: expressions.rowByNode, + rootHashByNode: expressions.rootHashByNode, + methodHashByNode: declarations.methodHashByNode, + moduleInitMethodHash: declarations.moduleInitMethodHash, + toProjectRelative: options.toProjectRelative, + projectModuleHashes: options.projectModuleHashes, + declarationTargetByName, + }); + // A scope names the method it belongs to. Back-patched because a scope is + // minted during the binder pass, which runs BEFORE declarations — and it has + // to, since a method's bodyScopeLinkHash points back at the scope. + for (const scope of binder.scopes) { + if (scope.ownerNode === null) { + continue; + } + const hash = declarations.methodHashByNode.get(nodeKey(scope.ownerNode)); + if (hash !== undefined) { + scopes.rowByScopeKey.get(scope.key)?.setOwnerMethodLinkHash(hash); + } + } + + // THE THREE HOPS, for an inheritance edge. §0: a receiver whose type lives in + // another file needs the declared name as written, the importing module, and + // the import's resolvedFilePath — and `class Router extends EventEmitter` + // where `EventEmitter` came from `require('events')` is exactly that shape. + // The name was already there; without these two the row named a supertype an + // engine had no way to find, which is an incompleteness invisible to every + // count because the row exists and is correctly positioned. + for (const heritage of declarations.heritages) { + const importRow = moduleEdges.importBinding(heritage.superTypeName, + offsetOfRow(sourceFile, heritage.startLine, 1)); + if (importRow === undefined) { + continue; + } + heritage.setImportLinkHash(importRow.getHash()); + heritage.setResolvedFilePath(importRow.resolvedFilePath); + } + // Exported declarations say so, and the module names its default export. + // `module.exports = Router` IS the default export in CommonJS, so a module + // with one and an empty defaultExportLinkHash is a module whose principal + // export an engine cannot find. + const exportedNames = new Set(); + for (const row of moduleEdges.exports) { + if (row.localName !== '') { + exportedNames.add(row.localName); + } + if (row.exportedName !== '' && row.exportedName !== 'default') { + exportedNames.add(row.exportedName); + } + if (row.exportedName === 'default' || row.exportForm === 'EXPORT_DEFAULT') { + moduleResult.module.setDefaultExportLinkHash(row.getHash()); + } + } + for (const type of declarations.types) { + if (exportedNames.has(type.name)) { + type.setIsExported(); + } + } + for (const method of declarations.methods) { + if (method.name !== '' && exportedNames.has(method.name)) { + method.setIsExported(); + } + } + for (const variable of declarations.variables) { + if (exportedNames.has(variable.name)) { + variable.setIsExported(); + } + } + + // A variable bound by `require()` names the import it aliases. That plus + // `initializerKind = REQUIRE_CALL` is the hop the engine walks for the 34.4% + // of declines that are calls through a required binding. + // BY IDENTITY, not by name. A variable is bound by an import only when its + // declaration node IS the import's binding node; resolving by name let a + // shadowing local claim the import in both directions (72 wrong reverse + // targets became 152 when the name lookup became position-aware — more + // shadows found it). The binder keys variables on the name Identifier and + // the import extractor now records the same node, so the join is exact. + const variableByHash = new Map(declarations.variables.map((v) => [v.getHash(), v])); + for (const [declarationKey, variableHash] of declarations.variableHashByNode) { + const importRow = moduleEdges.importByBindingNode.get(declarationKey); + if (importRow === undefined) { + continue; + } + const variable = variableByHash.get(variableHash); + if (variable === undefined) { + continue; + } + variable.setImportLinkHash(importRow.getHash()); + importRow.setBoundVariableLinkHash(variable.getHash()); + } + // The same hop, on the call site: a receiver that came through an import + // carries the import row, which is one of the three things §0 says makes a + // row complete. + for (const callSite of expressions.callSites) { + if (callSite.receiverText === '') { + continue; + } + const importRow = moduleEdges.importBinding(callSite.receiverText, + offsetOfRow(sourceFile, callSite.startLine, callSite.startColumn)); + if (importRow !== undefined) { + callSite.setImportLinkHash(importRow.getHash()); + } + } + + // Comments, which are TRIVIA: not in the AST, so no walk reaches them. After + // the declarations, because attachment is by a declaration's start offset. + const ownerByStart = new Map< + number, { kind: JsCommentAttachmentKind; hash: string } + >(); + const recordOwner = ( + index: ReadonlyMap, + kind: JsCommentAttachmentKind + ): void => { + for (const [identity, hash] of index) { + // A node identity is `kind:start:end`; the comment scan knows only the + // start. First-wins, because several nodes begin at one offset — a + // declaration and its own name — and the OUTERMOST is the one a preceding + // comment documents. + const start = Number(identity.split(':')[1] ?? ''); + if (!Number.isNaN(start) && !ownerByStart.has(start)) { + ownerByStart.set(start, { kind, hash }); + } + } + }; + recordOwner(declarations.typeHashByNode, JsCommentAttachmentKind.TYPE); + recordOwner(declarations.methodHashByNode, JsCommentAttachmentKind.METHOD); + recordOwner(declarations.fieldHashByNode, JsCommentAttachmentKind.FIELD); + recordOwner(declarations.variableHashByNode, JsCommentAttachmentKind.VARIABLE); + // The statement offsets, which is what a leading comment actually precedes. + for (const [start, owner] of declarations.commentOwnerStarts) { + if (!ownerByStart.has(start)) { + ownerByStart.set(start, { + kind: owner.kind as JsCommentAttachmentKind, hash: owner.hash, + }); + } + } + const comments = extractComments({ + sourceFile, + moduleHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + ownerByStart, + }); + + // Every declaration that a JSDoc comment documents points at it. The comment + // relation already knows which declaration each comment is attached to, so + // this is that index inverted rather than a second attachment computation — + // two answers to one question is how the two relations come to disagree. + const commentByOwnerHash = new Map(); + for (const comment of comments.comments) { + if (!comment.isJsdoc) { + continue; + } + const owner = comment.attachedToLinkHashValue(); + if (owner !== '' && !commentByOwnerHash.has(owner)) { + commentByOwnerHash.set(owner, comment.getHash()); + } + } + for (const method of declarations.methods) { + const comment = commentByOwnerHash.get(method.getHash()); + if (comment !== undefined) { + method.setJsdocCommentLinkHash(comment); + } + } + for (const type of declarations.types) { + const comment = commentByOwnerHash.get(type.getHash()); + if (comment !== undefined) { + type.setJsdocCommentLinkHash(comment); + } + } + // A parameter's documentation is its FUNCTION's comment: `@param {T} x` lives + // in the block above the function, not above the parameter, so the parameter + // cites the same comment its owner does. + for (const parameter of declarations.methodParameters) { + const comment = commentByOwnerHash.get(parameter.ownerMethodLinkHash); + if (comment !== undefined) { + parameter.setJsdocCommentLinkHash(comment); + } + } + + // The JSDoc type trees, last: they link to an OWNER row and to the COMMENT + // they were read out of, so both must already exist. `Array>` is three rows here, not a string — a consumer that has to + // re-parse a string to find a generic argument gets it wrong on the first + // nested union. + const jsdoc = new JsDocExtractor({ + sourceFile, + moduleHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + commentByStart: comments.commentByStart, + }); + for (const { tag, row } of declarations.jsDocTypeTags) { + const owner = { + node: tag, + ownerKind: JsTypeReferenceOwnerKind.TYPE, + ownerHash: row.getHash(), + }; + jsdoc.typedefTypeOf(tag, owner); + // `@template T` on a `@typedef` — a GENERIC type alias, whose tag sits in + // the same comment block. The block attaches to whatever node follows it, + // and at end of file that is the EOF token, so asking the tag's own + // container is the only way to find it. + jsdoc.templatesOf({ ...owner, node: containerOf(tag) ?? tag }); + // The evidence for a COMMENT_ONLY row. The gate asserts it points at a + // comment whose `declaresType` is true, so the two relations have to agree + // about which comments are declarations rather than each asserting it alone. + let container: ts.Node | undefined = tag; + while (container !== undefined && container.kind !== ts.SyntaxKind.JSDoc) { + container = container.parent; + } + const evidence = container === undefined + ? undefined + : comments.commentByStart.get(container.pos); + if (evidence !== undefined) { + row.setJsdocCommentLinkHash(evidence.getHash()); + } + } + for (const method of declarations.methods) { + const node = declarations.nodeForMethod(method); + if (node === undefined) { + continue; + } + const owner = { + node, + ownerKind: JsTypeReferenceOwnerKind.METHOD, + ownerHash: method.getHash(), + }; + const returnType = jsdoc.returnTypeOf(owner); + if (returnType !== undefined) { + method.setReturnTypeReferenceLinkHash(returnType.root.getHash()); + } + // `@this {T}` — the receiver's declared type. There is no column on + // js_method to hold it, and that is the point: the TREE is the fact, and it + // is reachable from the method through js_type_reference.ownerLinkHash like + // every other JSDoc-borne type. Emitting it costs one call; not emitting it + // cost the only statement of a receiver type JavaScript has. + jsdoc.thisTypeOf(owner); + // `@throws {T}` — the closest JavaScript comes to a throws clause. Ten over + // the corpus, and each is a fact an engine cannot get from anywhere else. + jsdoc.throwsTypesOf(owner); + // `@template T` — 624 measured, and the reason there is no + // `js_type_parameter` relation: a separate table for that many comment-borne + // rows is not worth having, so a type parameter is a type reference with + // `contextKind = TEMPLATE`. The departure from `ts_type_parameter` is a + // decision, recorded here and in the enum. + jsdoc.templatesOf(owner); + } + // `@extends {T}` and `@implements {T}` — heritage asserted in a COMMENT, which + // for `implements` is the only route the language has at all. The method + // existed and was never called, so both context kinds were declared and never + // emitted: a gap invisible to every count, because no row was misplaced — + // there simply were none. + for (const type of declarations.types) { + const node = declarations.nodeForType(type); + if (node === undefined) { + continue; + } + const owner = { + node, + ownerKind: JsTypeReferenceOwnerKind.TYPE, + ownerHash: type.getHash(), + }; + jsdoc.heritageOf(owner); + // `@template T` on a CLASS. Collected only for methods until the oracle's + // JSDoc correction made the counts checkable against ground truth, which + // showed three class-level templates falling on the floor. A generic class + // whose type parameter is invisible is a class whose members cannot be + // substituted. + jsdoc.templatesOf(owner); + } + // Every other typed position: parameters, fields and variables, each linking + // the tree its JSDoc declared. Collected at MINT time by the declaration pass, + // because the owner kind and the node are known there and nowhere else. + for (const pending of declarations.pendingTypeReferences) { + // An EXPRESSION owner's row was minted by the pass that just ran; its hash + // could not be known when the declaration walk recorded the reference. If + // the expression pass emitted nothing for the node — it can, for a value the + // depth cap dropped — there is no owner, and a reference owned by nothing is + // a dangling FK, so it is not emitted. + let ownerHash = pending.ownerHash; + if (pending.ownerKind === JsTypeReferenceOwnerKind.EXPRESSION) { + const row = pending.ownerNode === undefined + ? undefined + : expressions.rowByNode.get(nodeKey(pending.ownerNode)); + if (row === undefined) { + continue; + } + ownerHash = row.getHash(); + } + const owner = { + node: pending.node, + ownerKind: pending.ownerKind, + ownerHash, + parameterName: pending.parameterName, + }; + const result = pending.contextKind === JsTypeReferenceContextKind.PARAM + ? jsdoc.parameterTypeOf(owner) + : jsdoc.declaredTypeOf(owner, pending.contextKind); + if (result !== undefined) { + pending.link(result.root.getHash()); + } + } + + // The same three hops for a JSDoc type reference: `@param {Router} r` where + // `Router` was required is followable for exactly the same reason an + // inheritance edge is. After the JSDoc pass, because that is what mints the + // rows this fills. + // An IMPORT_TYPE row's hop is its OWN js_import row (§3.8.1), minted here + // per occurrence — the module-edge walk never enters a comment. Before the + // by-name join below, which must not see these: the qualifier `Y` of + // `import("./x").Y` could share a name with a runtime import and be joined + // to the wrong edge. + const linkedByImportType = new Set(); + for (const { row, node } of jsdoc.importTypeNodes) { + const importRow = moduleEdges.emitJsDocImportType(node); + if (importRow === undefined) { + continue; + } + row.setImportLinkHash(importRow.getHash()); + row.setResolvedFilePath(importRow.resolvedFilePath); + linkedByImportType.add(row); + } + for (const reference of jsdoc.typeReferences) { + if (linkedByImportType.has(reference)) { + continue; + } + const importRow = moduleEdges.importBinding(reference.typeName, + offsetOfRow(sourceFile, reference.startLine, reference.startColumn)); + if (importRow === undefined) { + continue; + } + reference.setImportLinkHash(importRow.getHash()); + reference.setResolvedFilePath(importRow.resolvedFilePath); + } + + // What the parser could not do, derived from the rows it emitted. Last, + // because it reads every other relation — which is what makes a gap row exist + // exactly when the fact it describes is in the relation it names. + const parseGaps = extractParseGaps({ + sourceFile, + moduleHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + imports: moduleEdges.imports, + typeReferences: jsdoc.typeReferences, + expressions: expressions.expressions, + callSites: expressions.callSites, + scopes: scopes.scopes, + hasFlowPragma: moduleResult.shape.hasFlowPragma, + isStrictModeFile: binder.moduleScope.isStrictMode, + }); + + return { + modules: [moduleResult.module], + scopes: scopes.scopes, + types: declarations.types, + heritages: declarations.heritages, + methods: declarations.methods, + methodParameters: declarations.methodParameters, + fields: declarations.fields, + variables: declarations.variables, + blocks: declarations.blocks, + expressions: expressions.expressions, + callSites: expressions.callSites, + imports: moduleEdges.imports, + exports: moduleEdges.exports, + comments: comments.comments, + typeReferences: jsdoc.typeReferences, + parseGaps, + declarations, + expressionExtractor: expressions, + shape: moduleResult.shape, + binder, + sourceFile, + fileModuleHash, + filePath: options.filePath, + }; +} + +/** + * The `JSDoc` block a tag belongs to. + * + * A tag's own `.parent` is the block; the block's parent is the node the comment + * documents. Asking the tag rather than walking from a declaration is what + * reaches a block at end of file, which attaches to the EOF token and belongs to + * no declaration at all. + */ +function containerOf(tag: ts.Node): ts.Node | undefined { + let current: ts.Node | undefined = tag; + while (current !== undefined && current.kind !== ts.SyntaxKind.JSDoc) { + current = current.parent; + } + return current; +} + +/** Kept so a caller can name the file without re-deriving it from the path. */ +export function fileNameOf(filePath: string): string { + return path.basename(filePath); +} + +/** + * A row's own byte offset, from the position it already carries. + * + * Every row records `startLine`/`startColumn`, so resolving a name AT a row + * needs no extra threading — and a name must be resolved at a position, because + * a module that binds `paint` twice gives references before and after the second + * binding different answers. + * + * 1-based on both axes in the fact base, 0-based in the compiler API. + */ +function offsetOfRow(sourceFile: ts.SourceFile, line: number, column: number): number { + try { + return sourceFile.getPositionOfLineAndCharacter( + Math.max(0, line - 1), Math.max(0, column - 1) + ); + } catch { + // A position past the end of the file cannot be converted. Returning 0 means + // "before everything", which selects the first binding — the same answer the + // old last-wins map would never have given, and the safe one. + return 0; + } +} diff --git a/parser/src/parsers/javascript/extractors/js-ir-completeness.ts b/parser/src/parsers/javascript/extractors/js-ir-completeness.ts new file mode 100644 index 000000000..79ef2b6fd --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-ir-completeness.ts @@ -0,0 +1,264 @@ +import { JsCallKind, JsReceiverTypeSource } from '@/enums/javascript/call-sites'; +import { JsImportResolutionOutcome } from '@/enums/javascript/imports'; +import { JsFileFacts } from '@/parsers/javascript/extractors/js-fact-extractor'; + +/** + * IR completeness — the measure that replaces "resolution rate". + * + * ## Why "what fraction did the parser resolve" is the wrong question + * + * The parser emits IR; the engine resolves. Java is the proof: + * `referencedTypeRegistryLinkHash` is populated **0 times in 67,938 rows**, and + * that is the design rather than an oversight. Asking a parser built on that + * principle what fraction it resolved gets the answer zero, correctly, and + * learns nothing. + * + * The right question is whether **every hop an engine would need was emitted**. + * A receiver whose type lives in another file needs three things and only three: + * the declared type name as written, the importing module, and the import's + * `resolvedFilePath`. If those are present the row is complete *even though the + * parser resolved nothing*. + * + * ## The 52.6% ceiling sizes this; it does not cap it + * + * The oracle — tsc with `checkJs` — decides 50,740 of 96,544 call sites. That + * number is what the oracle's gates are sized against, and it is **not** a bound + * on completeness: a call the checker declines can still have every hop present, + * which is precisely the `const x = require('y'); x.foo()` case that accounts for + * 34.4% of the declines. + * + * ## Reported PER BUCKET, never as one number + * + * A single percentage hides which hop is missing, and the buckets are not + * interchangeable — `AMBIENT_BUILTIN_TARGET` is a statement about the platform + * and `RECEIVER_UNTYPED` is a statement about the code. Collapsing them would + * report a defect population that does not exist, which §7 is entirely about. + * + * Two of the buckets are **complete because they say so**: a computed callee and + * a dynamic-code call are honest terminals, not misses. A gate that counts them + * as incomplete is measuring the language rather than the parser. + */ +export interface CompletenessBucket { + readonly total: number; + readonly complete: number; +} + +export interface IrCompletenessReport { + /** Receiver or callee declared in this file, with a binding link. */ + readonly sameFile: CompletenessBucket; + /** Reached through an import that carries `resolvedFilePath`. */ + readonly importHop: CompletenessBucket; + /** Typed only by JSDoc, with the type reference emitted. */ + readonly jsdocTyped: CompletenessBucket; + /** In the `lib_*` population: a Node builtin or an ECMAScript intrinsic. */ + readonly ambientTarget: CompletenessBucket; + /** `obj[expr]()` and `eval` — complete BECAUSE they say the name is not fixed. */ + readonly honestlyUnresolvable: CompletenessBucket; + /** Everything else: a receiver with no channel at all. */ + readonly untyped: CompletenessBucket; + /** + * The failure that matters, counted on its own. + * + * A call site whose receiver came through an import and which carries **no** + * `importLinkHash` is unreconstructable by any engine — and it is invisible to + * every count-based check, because the row exists and is correctly positioned. + */ + readonly importHopMissing: number; + readonly callSites: number; +} + +/** + * The running totals, and the reason this measure FOLDS rather than collects. + * + * ## Retaining every file's facts is the ceiling, and it was being retained + * + * The analyzer streamed its rows to per-relation writers and then pushed the + * same objects onto an array so this function could run at the end. The comment + * above that push named it as "the ceiling §10 identifies as the single + * highest-value architectural change available" — and then did it, which meant + * peak memory was the ENTIRE fact base as live objects, strictly larger than the + * CSV it had just finished streaming. + * + * It survived an 816-file corpus. At 4,561 files the run died with + * `FATAL ERROR: Ineffective mark-compacts near heap limit` after 336 seconds, + * having already written most of its output correctly — the worst shape of + * failure, because the cost is paid before the crash and the crash names no + * cause. + * + * Nothing in the loop was ever cross-file: every bucket increment depends only + * on the file in hand. So the measure folds, the facts are released as soon as + * their rows are appended, and memory is now flat in the number of files. + */ +export interface CompletenessAccumulator { + readonly sameFile: { total: number; complete: number }; + readonly importHop: { total: number; complete: number }; + readonly jsdocTyped: { total: number; complete: number }; + readonly ambientTarget: { total: number; complete: number }; + readonly honestlyUnresolvable: { total: number; complete: number }; + readonly untyped: { total: number; complete: number }; + importHopMissing: number; + callSites: number; +} + +export function createCompletenessAccumulator(): CompletenessAccumulator { + return { + sameFile: { total: 0, complete: 0 }, + importHop: { total: 0, complete: 0 }, + jsdocTyped: { total: 0, complete: 0 }, + ambientTarget: { total: 0, complete: 0 }, + honestlyUnresolvable: { total: 0, complete: 0 }, + untyped: { total: 0, complete: 0 }, + importHopMissing: 0, + callSites: 0, + }; +} + +/** The report, once every file has been folded in. */ +export function finishCompleteness( + accumulator: CompletenessAccumulator +): IrCompletenessReport { + return { + sameFile: accumulator.sameFile, + importHop: accumulator.importHop, + jsdocTyped: accumulator.jsdocTyped, + ambientTarget: accumulator.ambientTarget, + honestlyUnresolvable: accumulator.honestlyUnresolvable, + untyped: accumulator.untyped, + importHopMissing: accumulator.importHopMissing, + callSites: accumulator.callSites, + }; +} + +/** + * Fold ONE file's facts into the running totals, then let them go. + * + * Called from the analyzer immediately after the file's rows are appended to + * their writers, which is the last moment the objects are needed at all. + */ +export function accumulateFileCompleteness( + buckets: CompletenessAccumulator, + facts: JsFileFacts +): void { + { + // A file classified as bundler output contributes to no denominator. Gate + // 7.3.5: bundled output is classified, never counted — a bundler's output can + // carry more call sites than the source tree it was built from, and every + // one of them teaches nothing. + if (facts.modules[0]?.sourceProvenance !== 'PROJECT') { + return; + } + const resolvedImports = new Set(); + for (const row of facts.imports) { + if (row.resolutionOutcome === JsImportResolutionOutcome.RESOLVED_PROJECT + || row.resolutionOutcome === JsImportResolutionOutcome.RESOLVED_EXTERNAL) { + resolvedImports.add(row.getHash()); + } + } + const typeReferenceOwners = new Set( + facts.typeReferences.map((row) => row.ownerLinkHash) + ); + + for (const call of facts.callSites) { + buckets.callSites += 1; + // An honest terminal, and it comes FIRST: a computed callee is complete + // because the row says the name is not fixed by syntax, and asking whether + // its receiver is typed is asking the wrong question of it. + if (call.callKind === JsCallKind.COMPUTED_CALL + || call.callKind === JsCallKind.DYNAMIC_CODE_CALL) { + buckets.honestlyUnresolvable.total += 1; + buckets.honestlyUnresolvable.complete += 1; + continue; + } + const named = call.calleeName !== ''; + switch (call.receiverTypeSource) { + case JsReceiverTypeSource.IMPORT_ALIAS: { + buckets.importHop.total += 1; + // The three hops §0 names, all present: the name as written, the + // import, and the resolved path on it. + const hop = call.importLinkHashValue(); + if (named && hop !== '' && resolvedImports.has(hop)) { + buckets.importHop.complete += 1; + } else if (hop === '') { + buckets.importHopMissing += 1; + } + break; + } + case JsReceiverTypeSource.LOCAL_CLASS: { + buckets.sameFile.total += 1; + if (named) { + buckets.sameFile.complete += 1; + } + break; + } + case JsReceiverTypeSource.JSDOC: { + buckets.jsdocTyped.total += 1; + if (named && typeReferenceOwners.size > 0) { + buckets.jsdocTyped.complete += 1; + } + break; + } + case JsReceiverTypeSource.NODE_BUILTIN: { + buckets.ambientTarget.total += 1; + // Complete when the name is there: the target is in the `lib_*` + // population and is NOT in this project, so there is no further hop + // for the parser to emit. Counting these as incomplete would report + // 15.3-24.4% of a real corpus as a parser gap when it is a property of the + // platform — the environmental class §7 says to name rather than hide. + if (named) { + buckets.ambientTarget.complete += 1; + } + break; + } + default: { + buckets.untyped.total += 1; + // A bare `fn()` with a name is as complete as syntax allows: the + // engine has a name to look up. What it lacks is a receiver type, + // which no amount of parsing produces. + if (named && call.receiverText === '') { + buckets.untyped.complete += 1; + } + break; + } + } + } + } +} + +/** + * The whole measure in one call, for a caller that already holds every file. + * + * Only the gate does — the analyzer folds file by file. Kept so the two paths + * are one implementation rather than two that must be checked against each + * other. + */ +export function measureIrCompleteness( + perFile: readonly JsFileFacts[] +): IrCompletenessReport { + const buckets = createCompletenessAccumulator(); + for (const facts of perFile) { + accumulateFileCompleteness(buckets, facts); + } + return finishCompleteness(buckets); +} + +/** One line per bucket, because a single percentage hides which hop is missing. */ +export function formatCompleteness(report: IrCompletenessReport): string[] { + const line = (label: string, bucket: CompletenessBucket): string => { + const share = bucket.total === 0 + ? ' —' + : `${((bucket.complete / bucket.total) * 100).toFixed(1)}%`; + return ` ${label.padEnd(24)}${String(bucket.complete).padStart(8)} / ` + + `${String(bucket.total).padEnd(8)} ${share}`; + }; + return [ + ` ${'call sites'.padEnd(24)}${String(report.callSites).padStart(8)}`, + line('same file', report.sameFile), + line('import hop available', report.importHop), + line('JSDoc typed', report.jsdocTyped), + line('ambient/builtin target', report.ambientTarget), + line('honestly unresolvable', report.honestlyUnresolvable), + line('receiver untyped', report.untyped), + ` ${'import hop MISSING'.padEnd(24)}${String(report.importHopMissing).padStart(8)}` + + ' <- the only one that is a defect', + ]; +} diff --git a/parser/src/parsers/javascript/extractors/js-jsdoc-extractor.ts b/parser/src/parsers/javascript/extractors/js-jsdoc-extractor.ts new file mode 100644 index 000000000..9616422d7 --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-jsdoc-extractor.ts @@ -0,0 +1,691 @@ +import * as ts from 'typescript'; + +import { JS_TYPE_REFERENCE_MAX_DEPTH } from '@/constants/javascript-constants'; +import { JsCommentRegistry } from '@/analysis-types/javascript/JsCommentRegistry'; +import { JsTypeReferenceRegistry } from + '@/analysis-types/javascript/JsTypeReferenceRegistry'; +import { + JsTypeReferenceContextKind, + JsTypeReferenceKind, + JsTypeReferenceOwnerKind, +} from '@/enums/javascript/type-references'; +import { + jsDocHostsOf, jsDocParameterTagFor, jsDocTagsOfAllBlocks, pointOf, +} from '@/utils/javascript'; + +/** + * `js_type_reference` — the JavaScript type system in its entirety, parsed out + * of comments. + * + * ## JSDoc is a type annotation, not a comment feature + * + * | channel | share of parameters | + * |---|---| + * | nothing | 62.1% | + * | **JSDoc** | **37.9%** | + * | syntactic annotation | 0% in the replication corpus; 64 reported and retracted | + * + * The compiler parses `@param`, `@returns`, `@type`, `@typedef`, `@template`, + * `@extends` and `@implements` into `node.jsDoc` and **uses them for inference + * under `checkJs`**. `ts-comment-extractor.ts` extracts tag *names*; that is a + * starting point and not the thing. What this file does is read the type + * EXPRESSIONS the tags carry, as a tree. + * + * ## A tree, not a string + * + * `Array>` is **three rows** with parent FKs, depth and + * child index — the same shape `ts_type_reference` uses. A consumer that has to + * re-parse a string to find a generic argument is one that will get it wrong on + * the first nested union. + * + * ## Every row is type-only, and the gate asserts it + * + * `isTypeOnly` is `true` in every row here. **No call-graph rule may traverse + * this relation**, and `@callback` — which names a callable shape — is exactly + * the row most likely to be mistaken for a call target. It is not one. + * + * ## `UNKNOWN_SYNTAX` is deliberate and expected to be non-empty + * + * JSDoc type syntax is not standardised; Closure, TypeScript and jsdoc.app all + * differ on `!T`, on `Object`, on `function(this:T, …)`. A type expression + * this cannot decompose gets **one row with its text preserved**, rather than a + * guess or a dropped tag. Guessing puts a plausible wrong type into the fact + * base; dropping loses the only declared-type channel the language has. + */ +export interface JsDocExtractionOptions { + readonly sourceFile: ts.SourceFile; + readonly moduleHash: string; + readonly serviceVersionLinkHash: string; + /** Comment row by start offset, so a row can cite the comment it came from. */ + readonly commentByStart: ReadonlyMap; +} + +/** One owner asking for the types its JSDoc declares. */ +/** An IMPORT_TYPE row and the node it was read from — the fact extractor mints its js_import row. */ +export interface JsDocImportType { + readonly row: JsTypeReferenceRegistry; + readonly node: ts.ImportTypeNode; +} + +export interface JsDocOwner { + readonly node: ts.Node; + readonly ownerKind: JsTypeReferenceOwnerKind; + readonly ownerHash: string; + /** For a parameter, the name it binds, so the right `@param` is chosen. */ + readonly parameterName?: string; +} + +export class JsDocExtractor { + readonly typeReferences: JsTypeReferenceRegistry[] = []; + /** Every IMPORT_TYPE row with its node, in emission order. */ + readonly importTypeNodes: JsDocImportType[] = []; + + private readonly options: JsDocExtractionOptions; + private readonly sourceFile: ts.SourceFile; + /** Template tags already emitted, so a shared block cannot emit one twice. */ + private readonly emittedTemplateTags = new Set(); + + constructor(options: JsDocExtractionOptions) { + this.options = options; + this.sourceFile = options.sourceFile; + } + + /** + * The declared type of a method's return, from `@returns`. + * + * Returns the root row and the name as written, so the caller can fill both + * `returnTypeName` and `returnTypeReferenceLinkHash` — the string for a + * consumer that wants one hop, the tree for a consumer that wants the shape. + */ + returnTypeOf(owner: JsDocOwner): { name: string; root: JsTypeReferenceRegistry } | undefined { + const tag = ts.getJSDocReturnTag(owner.node); + const type = tag?.typeExpression?.type; + if (tag === undefined || type === undefined) { + return undefined; + } + return this.emitTree(type, owner, JsTypeReferenceContextKind.RETURN, + tagNameOf(tag)); + } + + /** + * The declared type of `this`, from `@this {T}`. + * + * ## The only channel that can state a receiver's type, and it was dropped + * + * `@param` and `@returns` on the SAME function emitted their trees; `@this` + * emitted nothing — no row, no gap. `JsTypeReferenceContextKind.THIS` was 0. + * + * Its row count will be small and its value is not its row count. In the + * prototype-era CommonJS stratum an untyped `this` is **6.5% of all oracle + * declines** — 3,653 calls on shipped source that tsc cannot resolve — for + * exactly the reason the enum records: nothing declares the receiver. `@this` + * is the one construct in JavaScript that can, and the parser was throwing it + * away. + */ + thisTypeOf(owner: JsDocOwner): { name: string; root: JsTypeReferenceRegistry } | undefined { + for (const tag of jsDocTagsOfAllBlocks(owner.node)) { + if (tag.kind !== ts.SyntaxKind.JSDocThisTag) { + continue; + } + const type = (tag as ts.JSDocThisTag).typeExpression?.type; + if (type === undefined) { + continue; + } + return this.emitTree(type, owner, JsTypeReferenceContextKind.THIS, + tagNameOf(tag)); + } + return undefined; + } + + /** + * Every `@throws {T}` / `@exception {T}` on a callable, one tree each. + * + * A function may document several, and each is its own row under THROWS — + * the closest JavaScript comes to a throws clause. Read from all attached + * blocks and from the statement that carries the callable, as `@param` is. + */ + throwsTypesOf(owner: JsDocOwner): JsTypeReferenceRegistry[] { + const out: JsTypeReferenceRegistry[] = []; + for (const host of jsDocHostsOf(owner.node)) { + for (const tag of jsDocTagsOfAllBlocks(host)) { + if (tag.kind !== ts.SyntaxKind.JSDocThrowsTag) { + continue; + } + const type = (tag as ts.JSDocThrowsTag).typeExpression?.type; + if (type === undefined) { + continue; + } + const emitted = this.emitTree(type, owner, JsTypeReferenceContextKind.THROWS, + tagNameOf(tag)); + if (emitted !== undefined) { + out.push(emitted.root); + } + } + } + return out; + } + + /** The declared type of a parameter, from the matching `@param {T} name`. */ + parameterTypeOf( + owner: JsDocOwner + ): { name: string; root: JsTypeReferenceRegistry } | undefined { + // NO NAME GUARD. It used to return early for a parameter with no name of + // its own, because the old fallback matched tags BY name and had nothing to + // match on. The shared selection falls back to POSITION, which is exactly + // what a destructured parameter needs — and the guard, left in place, + // produced the last 246 of the disagreement: a `declaredTypeName` from the + // name path and no tree from this one, on every + // `apply(dep, source, { module, runtimeTemplate })` in one bundler's own source. + // + // ONE selection, shared with the path that fills `declaredTypeName`. The + // two had separate implementations and disagreed 300 times over the corpus — + // a name with no tree is an internally inconsistent pair, and it is the kind + // nothing reports because both halves look fine alone. + const tag = ts.isParameter(owner.node) + ? jsDocParameterTagFor(owner.node) + : undefined; + const type = tag?.typeExpression?.type; + if (tag === undefined || type === undefined) { + return undefined; + } + // PARAM either way (§3.14.3): the POSITION decides the context kind, the tag + // only decides the tagName column — `type` for a block on the parameter + // node itself, `param` for the function's tag. + return this.emitTree(type, owner, JsTypeReferenceContextKind.PARAM, tagNameOf(tag)); + } + + /** The declared type of a variable or field, from `@type {T}`. */ + declaredTypeOf( + owner: JsDocOwner, + context: JsTypeReferenceContextKind + ): { name: string; root: JsTypeReferenceRegistry } | undefined { + const tag = ts.getJSDocTypeTag(owner.node); + const type = tag?.typeExpression?.type; + if (type === undefined) { + return undefined; + } + return this.emitTree(type, owner, context, 'type'); + } + + /** The shape a `@typedef`/`@callback` declares. */ + typedefTypeOf( + tag: ts.JSDocTypedefTag | ts.JSDocCallbackTag, + owner: JsDocOwner + ): { name: string; root: JsTypeReferenceRegistry } | undefined { + const expression = tag.typeExpression; + if (expression === undefined) { + return undefined; + } + // A `@callback`'s type expression is a JSDocSignature — the compiler's + // function type spelled as `@param` and `@returns` tags — and the signature + // ITSELF is the root. Reading `.type` off it took the `@returns` TAG as the + // root, so every callback in the corpus was one UNKNOWN_SYNTAX row named + // with the raw tag text (`@returns {void}`) and its parameter types lost: + // 59 of 59, and no gate said so, because a wrong kind is still one row. + const type = ts.isJSDocTypeLiteral(expression) || ts.isJSDocSignature(expression) + ? expression + : expression.type; + if (type === undefined) { + return undefined; + } + return this.emitTree(type as ts.Node, owner, JsTypeReferenceContextKind.TYPEDEF, + ts.isJSDocTypedefTag(tag) ? 'typedef' : 'callback'); + } + + /** + * `@template T` — a type parameter, which has no relation of its own here. + * + * DEDUPED BY TAG. One comment block can declare two `@typedef`s and one + * `@template`, and the block is reached once per typedef — so the template + * came out twice, with different owners, which meant no primary key collided + * and nothing reported it. Measured against ground truth: 20 parameters in the + * corpus, 26 rows emitted. The count is the only thing that showed it. + */ + templatesOf(owner: JsDocOwner): void { + for (const tag of jsDocTagsOfAllBlocks(owner.node)) { + if (!ts.isJSDocTemplateTag(tag) || this.emittedTemplateTags.has(tag)) { + continue; + } + this.emittedTemplateTags.add(tag); + for (const parameter of tag.typeParameters) { + // 624 measured, and the schema's ruling is that a separate relation + // for that many comment-borne rows is not worth a table. Recorded here + // so the departure from `ts_type_parameter` reads as a decision. + this.emitNamedRow(parameter.name.text, owner, + JsTypeReferenceContextKind.TEMPLATE, 'template', parameter); + } + } + } + + /** `@extends {T}` / `@implements {T}` — heritage asserted in a comment. */ + heritageOf(owner: JsDocOwner): { name: string; kind: JsTypeReferenceContextKind }[] { + const out: { name: string; kind: JsTypeReferenceContextKind }[] = []; + for (const tag of jsDocTagsOfAllBlocks(owner.node)) { + const isExtends = ts.isJSDocAugmentsTag(tag); + const isImplements = ts.isJSDocImplementsTag(tag); + if (!isExtends && !isImplements) { + continue; + } + const context = isExtends + ? JsTypeReferenceContextKind.EXTENDS + // JavaScript has no `implements`, so a comment is the ONLY route by + // which a file can state one. + : JsTypeReferenceContextKind.IMPLEMENTS; + const expression = (tag as ts.JSDocAugmentsTag | ts.JSDocImplementsTag).class; + const result = this.emitTree(expression, owner, context, + isExtends ? 'extends' : 'implements'); + if (result !== undefined) { + out.push({ name: result.name, kind: context }); + } + } + return out; + } + + // ------------------------------------------------------------------------- + + private emitNamedRow( + typeName: string, + owner: JsDocOwner, + context: JsTypeReferenceContextKind, + tagName: string, + node: ts.Node + ): JsTypeReferenceRegistry { + return this.mint({ + typeName, + referenceKind: JsTypeReferenceKind.NAMED, + parentHash: '', + depth: 0, + childIndex: 0, + owner, + context, + tagName, + node, + }); + } + + /** + * Emits a whole type expression as a tree, returning its root. + * + * Depth-capped at 32, with `isTruncated` on the parent rather than a subtree + * silently vanishing — a cap that can never fire is a cap nobody maintains. + */ + private emitTree( + node: ts.Node, + owner: JsDocOwner, + context: JsTypeReferenceContextKind, + tagName: string + ): { name: string; root: JsTypeReferenceRegistry } | undefined { + const unwrapped = unwrapParenthesizedType(node); + const root = this.emitNode(node, owner, context, tagName, '', 0, 0); + if (root === undefined) { + return undefined; + } + return { name: nameOfType(unwrapped, this.sourceFile), root }; + } + + private emitNode( + node: ts.Node, + owner: JsDocOwner, + context: JsTypeReferenceContextKind, + tagName: string, + parentHash: string, + depth: number, + childIndex: number + ): JsTypeReferenceRegistry | undefined { + if (depth > JS_TYPE_REFERENCE_MAX_DEPTH) { + return undefined; + } + // Classified and named from the UNWRAPPED node; POSITIONED at the node as + // written, parentheses included. `@param {(A|B)} x` is a union whose row + // begins at the `(` — where the type expression starts, and where a + // position join from the tag lands. Positioned at the inner node, 17 such + // rows read as missing to a join keyed on the tag. + const written = node; + node = unwrapParenthesizedType(node); + const kind = referenceKindOf(node); + const row = this.mint({ + typeName: nameOfType(node, this.sourceFile), + referenceKind: kind, + parentHash, + depth, + childIndex, + owner, + context, + tagName, + node: written, + }); + const children = childTypesOf(node); + let emitted = 0; + for (let i = 0; i < children.length; i += 1) { + const child = this.emitNode(children[i]!, owner, context, tagName, + row.getHash(), depth + 1, i); + if (child === undefined) { + row.setIsTruncated(); + continue; + } + emitted += 1; + } + row.setChildCount(emitted); + return row; + } + + private mint(init: { + typeName: string; + referenceKind: JsTypeReferenceKind; + parentHash: string; + depth: number; + childIndex: number; + owner: JsDocOwner; + context: JsTypeReferenceContextKind; + tagName: string; + node: ts.Node; + }): JsTypeReferenceRegistry { + const at = pointOf(init.node, this.sourceFile); + const row = new JsTypeReferenceRegistry({ + typeName: init.typeName, + referenceKind: init.referenceKind, + parentReferenceLinkHash: init.parentHash, + depth: init.depth, + childIndex: init.childIndex, + contextKind: init.context, + tagName: init.tagName, + ownerKind: init.owner.ownerKind, + ownerLinkHash: init.owner.ownerHash, + // ALWAYS true. Every row here is type-only, and no call-graph rule may + // traverse this relation — the gate asserts it in every row. + isTypeOnly: true, + isBuiltinType: BUILTIN_TYPE_NAMES.has(init.typeName), + commentLinkHash: this.commentHashFor(init.node), + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.typeReferences.push(row); + if (ts.isImportTypeNode(init.node)) { + this.importTypeNodes.push({ row, node: init.node }); + } + return row; + } + + /** + * The comment a JSDoc type node was read out of. + * + * Walks up to the enclosing `JSDoc` node and looks it up by start offset, + * which is the same key the comment scan used. The two relations therefore + * agree about which comment a type came from, rather than each computing its + * own answer. + */ + private commentHashFor(node: ts.Node): string { + let current: ts.Node | undefined = node; + while (current !== undefined) { + if (current.kind === ts.SyntaxKind.JSDoc) { + return this.options.commentByStart.get(current.pos)?.getHash() ?? ''; + } + current = current.parent; + } + return ''; + } +} + +/** + * Which JSDoc type form this node is. + * + * `UNKNOWN_SYNTAX` is the honest terminal, not a failure: JSDoc type syntax is + * not standardised, and a node this does not recognise keeps its text rather + * than being guessed at or dropped. + */ +function referenceKindOf(node: ts.Node): JsTypeReferenceKind { + if (ts.isTypeReferenceNode(node)) { + return node.typeArguments !== undefined && node.typeArguments.length > 0 + ? JsTypeReferenceKind.GENERIC_APPLICATION + : JsTypeReferenceKind.NAMED; + } + if (ts.isUnionTypeNode(node)) { + return JsTypeReferenceKind.UNION; + } + if (ts.isIntersectionTypeNode(node)) { + return JsTypeReferenceKind.INTERSECTION; + } + if (ts.isArrayTypeNode(node)) { + return JsTypeReferenceKind.ARRAY; + } + if (ts.isFunctionTypeNode(node) || ts.isJSDocFunctionType(node)) { + // A callable SHAPE, and the row most likely to be mistaken for a call + // target. `isTypeOnly` is true and the gate asserts no call site reaches it. + return JsTypeReferenceKind.FUNCTION_TYPE; + } + if (ts.isJSDocSignature(node) || ts.isConstructorTypeNode(node)) { + // `@callback` spells its function type as tags; `new (…) => T` is the + // constructor form of the same shape. Parameters and return are children. + return JsTypeReferenceKind.FUNCTION_TYPE; + } + if (ts.isExpressionWithTypeArguments(node)) { + // `@extends {Base}` / `@implements {I}` — the compiler parses the operand as + // a heritage expression, not a type reference. It named itself correctly + // and was classified UNKNOWN_SYNTAX on every one of 173 tags, its type + // arguments never enqueued. + return node.typeArguments !== undefined && node.typeArguments.length > 0 + ? JsTypeReferenceKind.GENERIC_APPLICATION + : JsTypeReferenceKind.NAMED; + } + if (ts.isTypeLiteralNode(node) || ts.isJSDocTypeLiteral(node)) { + return JsTypeReferenceKind.OBJECT_TYPE; + } + // The five kinds ruled in §3.14.4, each a node the compiler already + // distinguishes. 3,718 import types and ~180 of the others had sat under + // UNKNOWN_SYNTAX with plausible text — decidable shapes we declined to decide. + if (ts.isImportTypeNode(node)) { + return JsTypeReferenceKind.IMPORT_TYPE; + } + if (ts.isTupleTypeNode(node)) { + return JsTypeReferenceKind.TUPLE; + } + if (ts.isIndexedAccessTypeNode(node)) { + return JsTypeReferenceKind.INDEXED_ACCESS; + } + if (ts.isTypeQueryNode(node)) { + return JsTypeReferenceKind.TYPE_QUERY; + } + if (ts.isTypePredicateNode(node)) { + return JsTypeReferenceKind.TYPE_PREDICATE; + } + if (ts.isOptionalTypeNode(node)) { + // `[a, b?]` — a tuple element's optionality, the written-TypeScript + // spelling of the same fact JSDoc's `T=` records. + return JsTypeReferenceKind.OPTIONAL; + } + if (ts.isRestTypeNode(node)) { + return JsTypeReferenceKind.REST; + } + if (ts.isLiteralTypeNode(node)) { + return JsTypeReferenceKind.TYPE_LITERAL; + } + if (ts.isJSDocNullableType(node)) { + return JsTypeReferenceKind.NULLABLE; + } + if (ts.isJSDocNonNullableType(node)) { + return JsTypeReferenceKind.NON_NULLABLE; + } + if (ts.isJSDocOptionalType(node)) { + return JsTypeReferenceKind.OPTIONAL; + } + if (ts.isJSDocVariadicType(node)) { + return JsTypeReferenceKind.REST; + } + if (ts.isJSDocAllType(node) || ts.isJSDocUnknownType(node) + || node.kind === ts.SyntaxKind.AnyKeyword) { + return JsTypeReferenceKind.ANY; + } + if (isPrimitiveKeyword(node)) { + return JsTypeReferenceKind.NAMED; + } + return JsTypeReferenceKind.UNKNOWN_SYNTAX; +} + +function childTypesOf(node: ts.Node): ts.Node[] { + if (ts.isTypeReferenceNode(node)) { + return [...(node.typeArguments ?? [])]; + } + if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { + return [...node.types]; + } + if (ts.isArrayTypeNode(node)) { + return [node.elementType]; + } + if (ts.isJSDocNullableType(node) || ts.isJSDocNonNullableType(node) + || ts.isJSDocOptionalType(node) || ts.isJSDocVariadicType(node)) { + return [node.type]; + } + if (ts.isFunctionTypeNode(node) || ts.isJSDocFunctionType(node) || ts.isConstructorTypeNode(node)) { + const parameters = node.parameters + .map((parameter) => parameter.type) + .filter((type): type is ts.TypeNode => type !== undefined); + return node.type === undefined ? parameters : [...parameters, node.type]; + } + if (ts.isJSDocSignature(node)) { + // Parameters in tag order, then the return — the same order a written + // function type's children take. + const parameters = node.parameters + .map((parameter) => parameter.typeExpression?.type) + .filter((type): type is ts.TypeNode => type !== undefined); + const returned = node.type?.typeExpression?.type; + return returned === undefined ? parameters : [...parameters, returned]; + } + if (ts.isExpressionWithTypeArguments(node)) { + return [...(node.typeArguments ?? [])]; + } + if (ts.isImportTypeNode(node) || ts.isTypeQueryNode(node)) { + // The specifier of an import type is NOT a child: it is a `js_import` row + // (§3.14.4), reached through importLinkHash. Type arguments are children. + return [...(node.typeArguments ?? [])]; + } + if (ts.isTupleTypeNode(node)) { + return node.elements.map((element) => (ts.isNamedTupleMember(element) ? element.type : element)); + } + if (ts.isIndexedAccessTypeNode(node)) { + return [node.objectType, node.indexType]; + } + if (ts.isTypePredicateNode(node)) { + return node.type === undefined ? [] : [node.type]; + } + if (ts.isOptionalTypeNode(node) || ts.isRestTypeNode(node)) { + return [node.type]; + } + if (ts.isTypeLiteralNode(node)) { + return node.members + .map((member) => (ts.isPropertySignature(member) ? member.type : undefined)) + .filter((type): type is ts.TypeNode => type !== undefined); + } + if (ts.isJSDocTypeLiteral(node)) { + // `@typedef {Object} T` followed by `@property {string} name` lines: the + // property types are the members, exactly as a written `{name: string}`'s + // are above. The names ride on the tags and this relation has no column + // for a member name in either spelling. + return (node.jsDocPropertyTags ?? []) + .map((tag) => tag.typeExpression?.type) + .filter((type): type is ts.TypeNode => type !== undefined); + } + return []; +} + +/** + * The name as written, WITHOUT type arguments. + * + * `Array` names `Array`, so a scope lookup needs no string surgery — the + * argument is a child row. TypeScript learned this one the same way. + */ +function nameOfType(node: ts.Node, sourceFile: ts.SourceFile): string { + if (ts.isTypeReferenceNode(node)) { + return node.typeName.getText(sourceFile); + } + if (ts.isLiteralTypeNode(node)) { + return node.literal.getText(sourceFile); + } + if (ts.isThisTypeNode(node)) { + // A ThisType node is not a keyword token, so tokenToString has no name for it. + return 'this'; + } + if (isPrimitiveKeyword(node)) { + return ts.tokenToString(node.kind) ?? ''; + } + if (ts.isJSDocAllType(node)) { + return '*'; + } + if (ts.isArrayTypeNode(node)) { + return 'Array'; + } + if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node) + || ts.isTypeLiteralNode(node) || ts.isJSDocTypeLiteral(node) + || ts.isFunctionTypeNode(node) || ts.isJSDocFunctionType(node) + || ts.isJSDocSignature(node) || ts.isConstructorTypeNode(node)) { + return ''; + } + if (ts.isJSDocNullableType(node) || ts.isJSDocNonNullableType(node) + || ts.isJSDocOptionalType(node) || ts.isJSDocVariadicType(node)) { + return ''; + } + if (ts.isExpressionWithTypeArguments(node)) { + return node.expression.getText(sourceFile); + } + if (ts.isImportTypeNode(node)) { + // The QUALIFIER — `Y` of `import("./x").Y`; `""` for a bare `import("./x")`, + // which names the module itself. The specifier belongs to the import row. + return node.qualifier?.getText(sourceFile) ?? ''; + } + if (ts.isTypeQueryNode(node)) { + return node.exprName.getText(sourceFile); + } + if (ts.isTypePredicateNode(node)) { + return node.parameterName.getText(sourceFile); + } + if (ts.isTupleTypeNode(node) || ts.isIndexedAccessTypeNode(node) + || ts.isOptionalTypeNode(node) || ts.isRestTypeNode(node)) { + return ''; + } + // UNKNOWN_SYNTAX keeps its text rather than being dropped or guessed at. + return node.getText(sourceFile); +} + +function isPrimitiveKeyword(node: ts.Node): boolean { + return node.kind === ts.SyntaxKind.StringKeyword + || node.kind === ts.SyntaxKind.NumberKeyword + || node.kind === ts.SyntaxKind.BooleanKeyword + || node.kind === ts.SyntaxKind.ObjectKeyword + || node.kind === ts.SyntaxKind.VoidKeyword + || node.kind === ts.SyntaxKind.UndefinedKeyword + || node.kind === ts.SyntaxKind.NullKeyword + || node.kind === ts.SyntaxKind.NeverKeyword + || node.kind === ts.SyntaxKind.SymbolKeyword + || node.kind === ts.SyntaxKind.BigIntKeyword + || node.kind === ts.SyntaxKind.UnknownKeyword + || ts.isThisTypeNode(node); +} + +/** + * `(A|null)` is `A|null`. Constraint 6: a tree rooted at a non-emitting node + * dies before its children are enqueued — and a parenthesised type kept its + * raw text as one UNKNOWN_SYNTAX row while the union inside it, the only + * thing the parentheses were written for, was never reached. Unwrapped at the + * root and at every child, in this one place. + */ +function unwrapParenthesizedType(node: ts.Node): ts.Node { + let current = node; + while (ts.isParenthesizedTypeNode(current)) { + current = current.type; + } + return current; +} + +function tagNameOf(tag: ts.JSDocTag): string { + return tag.tagName.text; +} + +/** Names that need no declaration to resolve. Feeds `isBuiltinType`. */ +const BUILTIN_TYPE_NAMES: ReadonlySet = new Set([ + 'string', 'number', 'boolean', 'object', 'symbol', 'bigint', 'undefined', + 'null', 'void', 'never', 'any', 'unknown', '*', + 'Array', 'Object', 'Function', 'Date', 'RegExp', 'Error', 'Promise', 'Map', + 'Set', 'WeakMap', 'WeakSet', 'Symbol', 'String', 'Number', 'Boolean', + 'ArrayBuffer', 'Buffer', 'Iterable', 'Iterator', 'AsyncIterable', +]); diff --git a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts new file mode 100644 index 000000000..26c5fd8bc --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts @@ -0,0 +1,1296 @@ +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { JS_DEFAULT_EXPORT_NAME } from '@/constants/javascript-constants'; +import { JsExportRegistry } from '@/analysis-types/javascript/JsExportRegistry'; +import { JsExpressionRegistry } from '@/analysis-types/javascript/JsExpressionRegistry'; +import { JsImportRegistry } from '@/analysis-types/javascript/JsImportRegistry'; +import { + JsExportForm, + JsExportTargetKind, + JsExportedValueKind, +} from '@/enums/javascript/exports'; +import { + JsEdgeBearer, + JsImportBindingForm, + JsImportForm, + JsImportResolutionOutcome, + JsSpecifierKind, +} from '@/enums/javascript/imports'; +import { ScopeBuildResult } from '@/parsers/javascript/extractors/js-scope-builder'; +import { JsScopeNode, nodeKey } from '@/parsers/javascript/extractors/js-symbol-table'; +import { + enclosingVariableDeclaration, + isDynamicImportCall, + isNodeBuiltinSpecifier, + isRequireCall, + pointOf, +} from '@/utils/javascript'; + +/** + * `js_import` and `js_export`, minted in a **second pass from expression rows**. + * + * ## CommonJS inverts the build order, and this file is where it happens + * + * §1 of `BUILDING-A-PARSER.md` puts expressions last because they reference + * everything else. In JavaScript **83.6% of module edges are expression-borne**: + * `require('./x')` is a call and `module.exports = X` is an assignment. So the + * module graph depends partly on the relation that is built last, and these two + * relations are built after it. + * + * That is the single finding that made JavaScript its own front end. Routing + * expression-minted rows into `ts_import` would have inverted the build order + * for the whole TypeScript front end, to serve a language whose every module + * edge is a top-level declaration. + * + * ## The 1:1 is structural, not asserted + * + * `isModuleEdge` on `js_expression` is **set here**, by the pass that mints the + * edge row, and `sourceExpressionLinkHash` points back at the expression. Gate + * 7.3.1 then reads: every expression with the flag is pointed at by exactly one + * edge row. A second pass that double-mints **doubles** the module-edge count + * rather than colliding, and nothing else would report it. + * + * ## Nothing reaches THROUGH an edge + * + * The specifier is recorded **as written**, with `specifierKind` and + * `resolvedFilePath` from `ts.resolveModuleName` — a pure function of a + * specifier, options and a host, needing no Program and no installed + * `node_modules` for its answer to be honest. Nothing here names what + * `./router` exports. `resolvedModuleLinkHash` is tier 3 and has no setter: + * turning a path string into a link is cross-file following, which is the + * engine's work and which TypeScript wrote once and then deleted. + */ +export interface ModuleEdgeExtractionOptions { + readonly sourceFile: ts.SourceFile; + readonly binder: ScopeBuildResult; + readonly absoluteFilePath: string; + readonly moduleHash: string; + readonly serviceVersionLinkHash: string; + readonly compilerOptions: ts.CompilerOptions; + readonly serviceVersion: string; + readonly hashOfScope: (scope: JsScopeNode) => string; + readonly expressionRowByNode: ReadonlyMap; + readonly rootHashByNode: ReadonlyMap; + /** `nodeKey` -> `js_method` hash, so a nested `require` names its owner. */ + readonly methodHashByNode: ReadonlyMap; + readonly moduleInitMethodHash: string; + /** Project-relative path for an absolute one, so `resolvedFilePath` joins. */ + readonly toProjectRelative: (absolutePath: string) => string; + /** Absolute paths the analysis covers, for `RESOLVED_PROJECT`. */ + readonly projectModuleHashes: ReadonlyMap; + /** Declarations by name, so an export can point at what it exports. */ + /** + * Every declaration under a name, in source order, with its offset. + * + * A LIST because a name is not an identity — see the sweep that found six + * indexes making that assumption. The export picks by position. + */ + readonly declarationTargetByName: ReadonlyMap< + string, ReadonlyArray<{ kind: JsExportTargetKind; hash: string; start: number }> + >; +} + +export interface ModuleEdgeResult { + readonly imports: readonly JsImportRegistry[]; + readonly exports: readonly JsExportRegistry[]; + /** Local name -> the import that binds it, for the call-site hop. */ + /** + * Local name -> the imports that bind it, in source order. + * + * **A list, not a single row.** It was `Map` and + * last-wins, so a module that binds a name twice — `const paint = + * require('paint')` in two branches, `pad` in two functions — gave every + * reference to the LAST binding. 57 colliding names over 4,561 files, 92 + * shadowed imports, a figure js-corpus reached independently by measuring + * order bias rather than shadow counting. + * + * The caller picks by POSITION through {@link importBinding}, because which + * import a name refers to is decided by where the reference is. + */ + readonly importsByLocalName: ReadonlyMap; + /** + * The import binding `name` refers to AT this offset. + * + * Nearest-preceding, falling back to the first: a reference above every + * binding is the hoisting case and still means the one that follows. + */ + readonly importBinding: (name: string, at: number) => JsImportRegistry | undefined; + /** The import that binds THIS Identifier node, by identity. See the field's doc. */ + readonly importByBindingNode: ReadonlyMap; + /** + * Mints the `js_import` row for a JSDoc `import("./x").Y` (§3.8.1). Called + * after the JSDoc pass, which is what finds the nodes; the row joins the + * `imports` list, so it is written with every other edge. + */ + readonly emitJsDocImportType: (node: ts.ImportTypeNode) => JsImportRegistry | undefined; +} + +export function extractModuleEdges( + options: ModuleEdgeExtractionOptions +): ModuleEdgeResult { + return new JsModuleEdgeExtractor(options).run(); +} + +class JsModuleEdgeExtractor { + private readonly options: ModuleEdgeExtractionOptions; + private readonly sourceFile: ts.SourceFile; + private readonly imports: JsImportRegistry[] = []; + private readonly exports: JsExportRegistry[] = []; + private readonly importsByLocalName = new Map>(); + /** + * Import row by the key of the Identifier it BINDS. + * + * ## The reverse link was keyed on a name, and got WORSE when the forward + * one was fixed + * + * `boundVariableLinkHash` was set by walking every variable, resolving its + * NAME to an import, and writing the variable back onto that import. A local + * `const paint = …` inside a function resolved to the module-level + * `require('paint')` — because that is the nearest preceding import of that + * name — and then overwrote the import's reverse link with itself. Both + * directions wrong. Making the forward lookup position-aware let MORE shadows + * find the import: 72 wrong targets became 152. Sixth instance of the + * name-keyed class, and the only one that went backwards. + * + * A variable is bound BY an import only if it IS the import's binding: the + * same Identifier node. The binder keys every import binding on that node + * (`clause.name`, `bindings.name`, `element.name`, `declaration.name`), the + * variable row is keyed on the same node, and this map joins them by identity. + */ + private readonly importByBindingNode = new Map(); + /** Every require/import call already turned into an import row, by node key. */ + private readonly handledCalls = new Set(); + /** + * Local names bound to a `createRequire(...)` result. + * + * A call through one of these is a real module edge spelled as an ordinary + * call on a local name, so matching the identifier `require` alone sees a call + * to an unknown function and emits nothing. + */ + private readonly createdRequireNames = new Set(); + + constructor(options: ModuleEdgeExtractionOptions) { + this.options = options; + this.sourceFile = options.sourceFile; + } + + run(): ModuleEdgeResult { + // Declaration-borne edges first — `import`/`export` statements, which only + // ever sit at the top level. 16.4% of edges, and the half TypeScript's + // extractors already model. + for (const statement of this.sourceFile.statements) { + this.visitDeclarationEdge(statement); + } + + // `createRequire` bindings first, in their own pass: a call through the name + // it binds is a module edge, and in a file that calls before it binds — legal + // where the binding is hoisted — a single ordered walk would miss it. + this.collectCreatedRequireNames(this.sourceFile); + + // Expression-borne edges second, by a RECURSIVE walk. A scan of + // `sourceFile.statements` — which is what every TypeScript module-edge + // extractor does, because every TypeScript module edge is a top-level + // declaration — misses one require in seven: 1,227 of 9,055 measured calls + // are not top level, 1,048 in a function body and 179 in a block. + this.walkExpressionEdges(this.sourceFile); + + this.markOverwrites(); + return { + imports: this.imports, + exports: this.exports, + importsByLocalName: this.importsByLocalName, + importByBindingNode: this.importByBindingNode, + emitJsDocImportType: (node) => this.emitJsDocImportType(node), + importBinding: (name, at) => { + const bound = this.importsByLocalName.get(name); + if (bound === undefined || bound.length === 0) { + return undefined; + } + let chosen = bound[0]!; + for (const candidate of bound) { + if (candidate.start <= at) { + chosen = candidate; + } + } + return chosen.row; + }, + }; + } + + // ------------------------------------------------------------------------- + // declaration-borne + // ------------------------------------------------------------------------- + + private visitDeclarationEdge(statement: ts.Statement): void { + if (ts.isImportDeclaration(statement)) { + this.emitImportDeclaration(statement); + return; + } + if (ts.isExportDeclaration(statement)) { + this.emitExportDeclaration(statement); + return; + } + if (ts.isExportAssignment(statement)) { + this.emitExportDefault(statement); + return; + } + if (ts.isImportEqualsDeclaration(statement)) { + // `import x = require('y')`. TypeScript syntax that `ts.createSourceFile` + // will parse out of a `.js` file if it meets it, so a file carrying one + // produces a module edge rather than a silent nothing. + const reference = statement.moduleReference; + this.emitImport({ + node: statement, + specifier: ts.isExternalModuleReference(reference) + && ts.isStringLiteralLike(reference.expression) + ? reference.expression.text + : reference.getText(this.sourceFile), + importForm: JsImportForm.IMPORT_EQUALS, + bindingForm: JsImportBindingForm.NAMESPACE, + importedName: '', + localName: statement.name.text, + edgeBearer: JsEdgeBearer.DECLARATION, + sourceExpression: undefined, + }); + return; + } + if (hasExportModifier(statement)) { + this.emitExportedDeclaration(statement); + return; + } + } + + private emitImportDeclaration(node: ts.ImportDeclaration): void { + const specifier = ts.isStringLiteralLike(node.moduleSpecifier) + ? node.moduleSpecifier.text + : ''; + const clause = node.importClause; + if (clause === undefined) { + // `import './polyfill'` binds nothing and is still a real edge: the + // module runs and its side effects happen. TypeScript had this defect — + // an empty import recorded no module edge, so a package imported only for + // its ambient declarations could not be staged. + this.emitImport({ + node, + specifier, + importForm: JsImportForm.IMPORT_DECLARATION, + bindingForm: JsImportBindingForm.SIDE_EFFECT_ONLY, + importedName: '', + localName: '', + edgeBearer: JsEdgeBearer.DECLARATION, + sourceExpression: undefined, + }); + return; + } + if (clause.name !== undefined) { + this.emitImport({ + node, + specifier, + importForm: JsImportForm.IMPORT_DECLARATION, + bindingForm: JsImportBindingForm.DEFAULT, + importedName: 'default', + localName: clause.name.text, + bindingName: clause.name, + edgeBearer: JsEdgeBearer.DECLARATION, + sourceExpression: undefined, + }); + } + const bindings = clause.namedBindings; + if (bindings === undefined) { + return; + } + if (ts.isNamespaceImport(bindings)) { + this.emitImport({ + node, + specifier, + importForm: JsImportForm.IMPORT_DECLARATION, + bindingForm: JsImportBindingForm.NAMESPACE, + importedName: '', + localName: bindings.name.text, + bindingName: bindings.name, + edgeBearer: JsEdgeBearer.DECLARATION, + sourceExpression: undefined, + }); + return; + } + for (const element of bindings.elements) { + this.emitImport({ + node: element, + specifier, + importForm: JsImportForm.IMPORT_DECLARATION, + bindingForm: JsImportBindingForm.NAMED, + importedName: (element.propertyName ?? element.name).text, + localName: element.name.text, + bindingName: element.name, + edgeBearer: JsEdgeBearer.DECLARATION, + sourceExpression: undefined, + }); + } + } + + private emitExportDeclaration(node: ts.ExportDeclaration): void { + const specifier = node.moduleSpecifier !== undefined + && ts.isStringLiteralLike(node.moduleSpecifier) + ? node.moduleSpecifier.text + : ''; + if (node.exportClause === undefined) { + // `export * from './y'` — an import and an export in one statement. + const importRow = specifier === '' ? undefined : this.emitImport({ + node, + specifier, + importForm: JsImportForm.IMPORT_DECLARATION, + bindingForm: JsImportBindingForm.NAMESPACE, + importedName: '', + localName: '', + edgeBearer: JsEdgeBearer.DECLARATION, + sourceExpression: undefined, + }); + this.emitExport({ + node, + exportedName: '*', + localName: '', + exportForm: JsExportForm.EXPORT_ALL, + exportedValueKind: JsExportedValueKind.OTHER, + edgeBearer: JsEdgeBearer.DECLARATION, + isReExport: true, + reExportSpecifier: specifier, + reExportImport: importRow, + sourceExpression: undefined, + }); + return; + } + if (ts.isNamespaceExport(node.exportClause)) { + // `export * as ns from './y'` — the whole module, re-exported under ONE + // name. Its clause is a NamespaceExport, which is neither `undefined` + // (bare `export *`) nor NamedExports (`export { a as b }`), so it fell + // between the two branches and emitted NOTHING: one framework's seven-line + // namespace re-export module recorded zero exports while both other forms + // beside it emitted. + // + // EXPORT_ALL is the right form — an import and an export in one + // statement — and `exportedName` carries what distinguishes it from the + // bare case. No local binding is minted: `ns` is not in scope inside this + // module, only in whoever imports it. + const importRow = specifier === '' ? undefined : this.emitImport({ + node, + specifier, + importForm: JsImportForm.IMPORT_DECLARATION, + bindingForm: JsImportBindingForm.NAMESPACE, + importedName: '', + localName: '', + edgeBearer: JsEdgeBearer.DECLARATION, + sourceExpression: undefined, + }); + this.emitExport({ + node, + exportedName: node.exportClause.name.text, + localName: '', + exportForm: JsExportForm.EXPORT_ALL, + exportedValueKind: JsExportedValueKind.OTHER, + edgeBearer: JsEdgeBearer.DECLARATION, + isReExport: true, + reExportSpecifier: specifier, + reExportImport: importRow, + sourceExpression: undefined, + }); + return; + } + if (ts.isNamedExports(node.exportClause)) { + for (const element of node.exportClause.elements) { + const local = (element.propertyName ?? element.name).text; + this.emitExport({ + node: element, + exportedName: element.name.text, + localName: local, + exportForm: JsExportForm.EXPORT_DECLARATION, + exportedValueKind: JsExportedValueKind.IDENTIFIER, + edgeBearer: JsEdgeBearer.DECLARATION, + isReExport: specifier !== '', + reExportSpecifier: specifier, + reExportImport: undefined, + sourceExpression: undefined, + }); + } + } + } + + private emitExportDefault(node: ts.ExportAssignment): void { + this.emitExport({ + node, + exportedName: JS_DEFAULT_EXPORT_NAME, + localName: ts.isIdentifier(node.expression) ? node.expression.text : '', + exportForm: JsExportForm.EXPORT_DEFAULT, + exportedValueKind: valueKindOf(node.expression), + edgeBearer: JsEdgeBearer.DECLARATION, + isReExport: false, + reExportSpecifier: '', + reExportImport: undefined, + sourceExpression: node.expression, + }); + } + + private emitExportedDeclaration(statement: ts.Statement): void { + // `export default function named() {}` / `export default class K {}`: + // the DEFAULT modifier decides the exported name, not whether the + // declaration has one. Only the name was consulted, so a named default + // export was emitted under its local name — indistinguishable from + // `export function named()`, and `import x from` found no `default` + // (#176: 16 of 16 on one ESM package). The anonymous form and the + // expression form were already `default`. + const isDefault = (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) + && (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Default) !== 0; + for (const name of exportedNamesOf(statement)) { + this.emitExport({ + node: statement, + exportedName: isDefault ? JS_DEFAULT_EXPORT_NAME : name, + localName: name, + exportForm: JsExportForm.EXPORT_DECLARATION, + exportedValueKind: declaredValueKindOf(statement), + edgeBearer: JsEdgeBearer.DECLARATION, + isReExport: false, + reExportSpecifier: '', + reExportImport: undefined, + sourceExpression: undefined, + }); + } + } + + // ------------------------------------------------------------------------- + // expression-borne — 83.6% of edges + // ------------------------------------------------------------------------- + + /** + * A RECURSIVE walk, because a module edge is not necessarily at the top of + * the file. + * + * `require` inside an `if` inside a function body is a real module edge and + * 13.6% of them are exactly that. A statement-list scan never sees one. + */ + private walkExpressionEdges(node: ts.Node): void { + if (ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + this.visitExportAssignment(node); + } + if (ts.isCallExpression(node)) { + this.visitEdgeCall(node); + } + ts.forEachChild(node, (child) => { + this.walkExpressionEdges(child); + }); + } + + /** + * `module.exports = X`, `module.exports.x = …`, `exports.x = …`. + * + * `module.exports = require('./y')` is handled here and produces **two** rows + * in two relations, joined by `reExportImportLinkHash` — 81 measured, and one + * line of source that is simultaneously an import and an export. Dropping + * either half loses a real edge. + */ + private visitExportAssignment(node: ts.BinaryExpression): void { + const target = node.left; + if (!ts.isPropertyAccessExpression(target)) { + return; + } + const form = exportFormOf(target); + if (form === undefined) { + return; + } + const exportedName = form === JsExportForm.MODULE_EXPORTS_ASSIGNMENT + ? JS_DEFAULT_EXPORT_NAME + : target.name.text; + + // The re-export case: the right-hand side is itself a module edge. + let reExportImport: JsImportRegistry | undefined; + const value = node.right; + if (ts.isCallExpression(value) && isRequireCall(value)) { + reExportImport = this.emitRequire(value, { + bindingForm: JsImportBindingForm.NAMESPACE, + importedName: '', + localName: '', + }); + } + + this.emitExport({ + node, + exportedName, + localName: ts.isIdentifier(value) ? value.text : '', + exportForm: form, + exportedValueKind: reExportImport !== undefined + ? JsExportedValueKind.REQUIRE_REEXPORT + : valueKindOf(value), + edgeBearer: JsEdgeBearer.EXPRESSION, + isReExport: reExportImport !== undefined, + reExportSpecifier: reExportImport?.specifier ?? '', + reExportImport, + sourceExpression: node, + }); + } + + private collectCreatedRequireNames(node: ts.Node): void { + if (ts.isCallExpression(node) && isCreateRequireCall(node)) { + const declaration = enclosingVariableDeclaration(node); + if (declaration !== undefined && ts.isIdentifier(declaration.name)) { + this.createdRequireNames.add(declaration.name.text); + } + } + ts.forEachChild(node, (child) => { + this.collectCreatedRequireNames(child); + }); + } + + private visitEdgeCall(node: ts.CallExpression): void { + if (this.handledCalls.has(nodeKey(node))) { + return; + } + if (isRequireCall(node) || this.isCreatedRequireCall(node)) { + this.emitRequireWithBinding(node); + return; + } + // `const require = createRequire(import.meta.url)` — how an ES module + // reaches CommonJS. The edge is the requires made THROUGH the resulting + // function, and the binding is what makes them findable, so the name it + // binds is remembered here and matched in `isCreatedRequireCall`. + if (isCreateRequireCall(node)) { + const declaration = enclosingVariableDeclaration(node); + if (declaration !== undefined && ts.isIdentifier(declaration.name)) { + this.createdRequireNames.add(declaration.name.text); + } + return; + } + if (isDynamicImportCall(node)) { + this.emitImport({ + node, + specifier: literalSpecifierOf(node) ?? '', + importForm: JsImportForm.DYNAMIC_IMPORT, + bindingForm: JsImportBindingForm.NAMESPACE, + importedName: '', + localName: '', + edgeBearer: JsEdgeBearer.EXPRESSION, + sourceExpression: node, + }); + return; + } + // `Object.defineProperty(exports, 'x', …)` — what transpilers emit, and the + // only lazy export form: the value is computed on first read. + if (isDefinePropertyOnExports(node)) { + const nameArgument = node.arguments[1]; + this.emitExport({ + node, + exportedName: nameArgument !== undefined && ts.isStringLiteralLike(nameArgument) + ? nameArgument.text + : '', + localName: '', + exportForm: JsExportForm.OBJECT_DEFINE_PROPERTY, + exportedValueKind: JsExportedValueKind.OTHER, + edgeBearer: JsEdgeBearer.EXPRESSION, + isReExport: false, + reExportSpecifier: '', + reExportImport: undefined, + sourceExpression: node, + }); + } + } + + /** + * A `require()` together with whatever it binds. + * + * Four binding shapes, and the destructured one is why `startColumn` is in + * `js_import`'s primary key: `const { a, b } = require('x')` produces two rows + * on one line with one specifier, and without the column they collide **by + * doubling, not by erroring**. + */ + private emitRequireWithBinding(call: ts.CallExpression): void { + const declaration = enclosingVariableDeclaration(call); + if (declaration === undefined) { + // A bare `require('./polyfill')` — no binding, and still a real edge. + this.emitRequire(call, { + bindingForm: JsImportBindingForm.SIDE_EFFECT_ONLY, + importedName: '', + localName: '', + }); + return; + } + // `const x = require('y').Thing` — a NAMED import through a member access, + // which is how CommonJS pulls one export out. + const initializer = declaration.initializer; + const throughMember = initializer !== undefined + && ts.isPropertyAccessExpression(initializer) + && initializer.expression === call; + + if (ts.isIdentifier(declaration.name)) { + this.emitRequire(call, { + bindingForm: throughMember + ? JsImportBindingForm.NAMED + : JsImportBindingForm.NAMESPACE, + importedName: throughMember + ? (initializer as ts.PropertyAccessExpression).name.text + : '', + localName: declaration.name.text, + bindingName: declaration.name, + }); + return; + } + if (ts.isObjectBindingPattern(declaration.name)) { + this.emitDestructuredRequire(call, declaration.name, ''); + return; + } + this.emitRequire(call, { + bindingForm: JsImportBindingForm.NAMESPACE, + importedName: '', + localName: '', + }); + } + + /** + * One import row per name an object pattern binds, however deeply nested. + * + * ## A nested pattern dropped the edge entirely, and said nothing + * + * `const { codes: { ERR_A, ERR_B } } = require('./errors');` produced ZERO + * js_import rows and ZERO js_parse_gap rows. Not a degraded edge — an ABSENT + * one, with nothing recording that anything had been dropped, which §9 says is + * the thing a parser may never do. + * + * The cause was a `continue` on `!ts.isIdentifier(element.name)`. A nested + * pattern's name IS a pattern, so the branch written to skip an unsupported + * element skipped an entire subtree of supported ones. Every flat form in the + * same file worked: `{a, b}`, `{join: j}`, `{m = 1}`, `[f]`, `{k, ...r}`. + * + * 72 of 13,190 require edges corpus-wide, 68 of them in one standard-library tree, which uses + * exactly this shape for its error tables. + * + * ## `importedName` carries the PATH, because the path is what is imported + * + * `ERR_A` alone would name something the module does not export. The module + * exports `codes`, and `ERR_A` is a property of it, so the imported name is + * `codes.ERR_A` — the same thing the single-level `require('x').Thing` form + * already records as `Thing`, one level deeper. + * + * An ARRAY pattern is deliberately not recursed into: its elements are + * positional, and a module does not export an index. That form already emits + * its row through the namespace branch. + */ + private emitDestructuredRequire( + call: ts.CallExpression, + pattern: ts.ObjectBindingPattern, + prefix: string + ): void { + for (const element of pattern.elements) { + const key = element.propertyName !== undefined + && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : ts.isIdentifier(element.name) ? element.name.text : ''; + if (ts.isObjectBindingPattern(element.name)) { + this.emitDestructuredRequire(call, element.name, + prefix === '' ? key : `${prefix}.${key}`); + continue; + } + if (!ts.isIdentifier(element.name)) { + continue; + } + this.emitRequire(call, { + bindingForm: JsImportBindingForm.DESTRUCTURED, + importedName: prefix === '' ? key : `${prefix}.${key}`, + localName: element.name.text, + bindingName: element.name, + // The NAME node, not the call: one row per bound name, and each needs + // its own column so the two on one line do not collide. + positionNode: element.name, + }); + } + } + + /** A call through a name bound by `createRequire`. */ + private isCreatedRequireCall(node: ts.CallExpression): boolean { + return ts.isIdentifier(node.expression) + && this.createdRequireNames.has(node.expression.text) + && node.arguments.length >= 1; + } + + private emitRequire( + call: ts.CallExpression, + binding: { + bindingForm: JsImportBindingForm; + importedName: string; + localName: string; + positionNode?: ts.Node; + bindingName?: ts.Identifier; + } + ): JsImportRegistry | undefined { + this.handledCalls.add(nodeKey(call)); + const specifier = literalSpecifierOf(call); + return this.emitImport({ + node: binding.positionNode ?? call, + bindingName: binding.bindingName, + specifier: specifier ?? textOfFirstArgument(call, this.sourceFile), + specifierKind: specifier !== undefined + ? JsSpecifierKind.STRING_LITERAL + : isTemplateSpecifier(call) + ? JsSpecifierKind.TEMPLATE + : JsSpecifierKind.NON_LITERAL, + importForm: this.isCreatedRequireCall(call) + ? JsImportForm.CREATE_REQUIRE + : JsImportForm.REQUIRE_CALL, + bindingForm: binding.bindingForm, + importedName: binding.importedName, + localName: binding.localName, + edgeBearer: JsEdgeBearer.EXPRESSION, + sourceExpression: call, + }); + } + + /** + * The `js_import` row a JSDoc `import("./x").Y` mints. Schema §3.8.1. + * + * One row PER OCCURRENCE — the key carries the position and each type + * reference links its own. `COMMENT`-borne, `JSDOC_IMPORT_TYPE`, + * `NO_LOCAL_BINDING`, `isTypeOnly`; the qualifier is `importedName` and + * nothing is bound locally. Resolved by the same `ts.resolveModuleName` as + * every runtime specifier, so `resolutionOutcome` is honest on it too. + * Called from the fact extractor after the JSDoc pass, which is what finds + * the nodes — the module-edge walk never enters a comment. + */ + emitJsDocImportType(node: ts.ImportTypeNode): JsImportRegistry | undefined { + const argument = node.argument; + const literal = ts.isLiteralTypeNode(argument) && ts.isStringLiteral(argument.literal) + ? argument.literal + : undefined; + return this.emitImport({ + node, + specifier: literal?.text ?? argument.getText(this.sourceFile), + specifierKind: literal === undefined ? JsSpecifierKind.NON_LITERAL : JsSpecifierKind.STRING_LITERAL, + importForm: JsImportForm.JSDOC_IMPORT_TYPE, + bindingForm: JsImportBindingForm.NO_LOCAL_BINDING, + importedName: node.qualifier?.getText(this.sourceFile) ?? '', + localName: '', + edgeBearer: JsEdgeBearer.COMMENT, + sourceExpression: undefined, + isTypeOnly: true, + }); + } + + // ------------------------------------------------------------------------- + // row construction + // ------------------------------------------------------------------------- + + private emitImport(init: { + node: ts.Node; + specifier: string; + specifierKind?: JsSpecifierKind; + importForm: JsImportForm; + bindingForm: JsImportBindingForm; + importedName: string; + localName: string; + /** The Identifier this import BINDS, when it binds one — the binder keys on it. */ + bindingName?: ts.Identifier; + edgeBearer: JsEdgeBearer; + sourceExpression: ts.Node | undefined; + /** `true` only for a JSDoc import type (§3.8.1); every runtime edge is false. */ + isTypeOnly?: boolean; + }): JsImportRegistry | undefined { + const at = this.positionOf(init.node); + const specifierKind = init.specifierKind ?? JsSpecifierKind.STRING_LITERAL; + const resolution = this.resolve(init.specifier, specifierKind); + const scope = this.scopeAt(init.node); + const row = new JsImportRegistry({ + specifier: init.specifier, + specifierKind, + edgeBearer: init.edgeBearer, + importForm: init.importForm, + isTopLevel: isTopLevel(init.sourceExpression ?? init.node), + isConditional: isConditional(init.sourceExpression ?? init.node), + bindingForm: init.bindingForm, + importedName: init.importedName, + localName: init.localName, + resolvedFilePath: resolution.filePath, + resolutionOutcome: resolution.outcome, + // NOT a parity slot: JavaScript has `import type`, spelled + // `import("./x").Y` in a comment (§3.14.4). True for that row only. + isTypeOnly: init.isTypeOnly ?? false, + ownerScopeLinkHash: this.options.hashOfScope(scope), + ownerMethodLinkHash: this.enclosingMethodHash(methodWalkStartOf(init.node)), + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.imports.push(row); + if (init.bindingName !== undefined) { + this.importByBindingNode.set(nodeKey(init.bindingName), row); + } + if (init.localName !== '') { + const bound = this.importsByLocalName.get(init.localName) ?? []; + // The BINDING's own position, which is the node the row was positioned + // from — one row per bound name, each at its own name node. + bound.push({ row, start: init.node.getStart(this.sourceFile) }); + bound.sort((a, b) => a.start - b.start); + this.importsByLocalName.set(init.localName, bound); + } + this.linkExpression(init.sourceExpression, row, (hash) => { + row.setSourceExpressionLinkHash(hash); + }); + return row; + } + + private emitExport(init: { + node: ts.Node; + exportedName: string; + localName: string; + exportForm: JsExportForm; + exportedValueKind: JsExportedValueKind; + edgeBearer: JsEdgeBearer; + isReExport: boolean; + reExportSpecifier: string; + reExportImport: JsImportRegistry | undefined; + sourceExpression: ts.Node | undefined; + }): void { + const at = this.positionOf(init.node); + const scope = this.scopeAt(init.node); + const row = new JsExportRegistry({ + exportedName: init.exportedName, + localName: init.localName, + edgeBearer: init.edgeBearer, + exportForm: init.exportForm, + exportedValueKind: init.exportedValueKind, + isReExport: init.isReExport, + reExportSpecifier: init.reExportSpecifier, + isTopLevel: isTopLevel(init.node), + isConditional: isConditional(init.node), + targetKind: JsExportTargetKind.EXPRESSION_VALUE, + ownerScopeLinkHash: this.options.hashOfScope(scope), + ownerMethodLinkHash: this.enclosingMethodHash(init.node), + ownerModuleLinkHash: this.options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + if (init.reExportImport !== undefined) { + row.setReExportImportLinkHash(init.reExportImport.getHash()); + } + // Same-file one hop: an exported NAME that is also a declaration in this + // file points at it. Nothing crosses a module boundary. + // BY LOCAL NAME ONLY. The fallback to `exportedName` when there was no + // local was a guess, and it guessed wrong: `exports.getAuthor4Token = + // async (token) => { … }` linked to a `const getAuthor4Token` declared 26 + // lines earlier — a different function that happens to share the name. + // The exported value is a fresh anonymous arrow; its own row is reachable + // through sourceExpressionLinkHash and the expression's c32, which is the + // honest path. 107 exports over the corpus, every one of them pointing at a + // declaration that was not the thing exported. + const candidates = init.localName === '' + ? undefined + : this.options.declarationTargetByName.get(init.localName); + // Nearest-preceding WITHIN A KIND, with the kind priority preserved. + // + // A name is resolved AT a position — a module declaring `author` twice gave + // every export the first one. But position alone is not enough, because ONE + // declaration can appear under several kinds: `function author() {}` mints a + // js_method AND the js_variable that binds its name, at different offsets. + // Taking the latest row before the export picked the binding variable over + // the method, silently changing `targetKind` on every such export. + // + // So position disambiguates between declarations of the SAME kind, and the + // kind priority — TYPE, then METHOD, then VARIABLE — decides between kinds, + // exactly as the first-wins map did before. + const exportOffset = init.node.getStart(this.sourceFile); + const nearest = ( + kind: JsExportTargetKind + ): { kind: JsExportTargetKind; hash: string; start: number } | undefined => { + let chosen: { kind: JsExportTargetKind; hash: string; start: number } | undefined; + for (const candidate of candidates ?? []) { + if (candidate.kind !== kind) { + continue; + } + if (chosen === undefined + || (candidate.start <= exportOffset && candidate.start >= chosen.start)) { + chosen = candidate; + } + } + return chosen; + }; + const target = nearest(JsExportTargetKind.TYPE) + ?? nearest(JsExportTargetKind.METHOD) + ?? nearest(JsExportTargetKind.VARIABLE); + if (target !== undefined) { + row.setTargetKind(target.kind); + row.setTargetLinkHash(target.hash); + } + this.exports.push(row); + this.linkExpression(init.sourceExpression, row, (hash) => { + row.setSourceExpressionLinkHash(hash); + }); + } + + /** + * Ties an edge row to the expression it was minted from, and flags that + * expression. + * + * Both directions in one place, so they cannot diverge: the expression's + * `isModuleEdge` and `moduleEdgeLinkHash`, and the edge's + * `sourceExpressionLinkHash`. Gate 7.3.1 reads exactly this pairing. + */ + private linkExpression( + node: ts.Node | undefined, + edge: { getHash(): string }, + setSource: (hash: string) => void + ): void { + if (node === undefined) { + return; + } + const row = this.options.expressionRowByNode.get(nodeKey(node)); + if (row === undefined) { + return; + } + setSource(row.getHash()); + row.setIsModuleEdge(true); + row.setModuleEdgeLinkHash(edge.getHash()); + } + + /** + * `overwritesPreviousExport`, set only when the overwrite is UNCONDITIONAL. + * + * ```js + * exports.a = 1; + * module.exports = { b }; // exports ONLY b. `a` is gone. + * ``` + * + * A fact base recording both edges with no ordering tells the engine this + * module exports `a`, which is false. Suppressing the earlier row loses the + * fact that the assignment executed. The flag keeps both and lets the engine + * decide. + * + * **The conditional case is deliberately not claimed.** `if (x) module.exports + * = {}` *may* overwrite, and a boolean that collapses those two cases is + * asserting a runtime conclusion from syntax — which is the thing this schema + * is most careful not to do. + * + * ## Unconditional is not enough: it must also be TOP LEVEL + * + * ```js + * function installTestDouble(double) { module.exports = double; } // nothing calls it + * for (const k of maybeEmpty) { module.exports = { k }; } // may run zero times + * ``` + * + * Neither is syntactically guarded, so both had `isConditional = false`, and + * both claimed `overwritesPreviousExport = true` — asserting that earlier + * export edges are **discarded** by an assignment that may never run. That is + * exactly the runtime conclusion the paragraph above refuses to draw, in a + * place the recogniser was not looking. Found by `js-fixtures`, on a pair of + * fixtures written so the two files would differ and which did not. + * + * The fix is on THIS column rather than on `isConditional`, deliberately. + * `isConditional` means *syntactically guarded* and is worth keeping that way; + * widening it would make every one of the 1,048 measured nested requires + * conditional and leave it nearly redundant with `!isTopLevel`. What the + * overwrite claim needs is *does this definitely execute*, which is + * unconditional **and** top level — two columns a consumer already has. + */ + private markOverwrites(): void { + let seenAnyExport = false; + for (const row of this.exports) { + if (row.exportForm === JsExportForm.MODULE_EXPORTS_ASSIGNMENT + && seenAnyExport && !row.isConditional && row.isTopLevel) { + row.setOverwritesPreviousExport(true); + } + seenAnyExport = true; + } + } + + // ------------------------------------------------------------------------- + + /** + * `ts.resolveModuleName`, and nothing more. + * + * A pure function of a specifier, options and a host. It builds no Program, + * typechecks nothing, and needs no installed `node_modules` for its answer to + * be honest — an unresolvable specifier returns `undefined`, which is a + * correct answer and not a missing one. + * + * Environmental unresolution is **named, not hidden and not counted as a + * parser gap**. Missing `node_modules` accounted for 10,068 of zod's 10,162 + * incomplete hand-offs in TypeScript, and §7 is explicit that reporting those + * as gaps is wrong and hiding them is also wrong. + */ + private resolve( + specifier: string, + kind: JsSpecifierKind + ): { filePath: string; outcome: JsImportResolutionOutcome } { + if (kind !== JsSpecifierKind.STRING_LITERAL) { + // Unresolvable BY CONSTRUCTION. 17 measured, and the row says so rather + // than guessing — a guessed module edge is worse than an absent one, + // because nothing downstream can tell it from a real one. + return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_NON_LITERAL }; + } + if (isNodeBuiltinSpecifier(specifier)) { + // Not a failure. Calls through a builtin are 15.3-24.4% of all oracle declines, + // and the target lives in the `lib_*` population rather than anywhere in + // the repository — which no amount of installing dependencies changes. + return { filePath: '', outcome: JsImportResolutionOutcome.RESOLVED_BUILTIN }; + } + const resolved = ts.resolveModuleName( + specifier, + this.options.absoluteFilePath, + this.options.compilerOptions, + ts.sys + ).resolvedModule?.resolvedFileName; + if (resolved === undefined) { + return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_MISSING }; + } + const absolute = path.normalize(resolved); + if (this.options.projectModuleHashes.has(absolute)) { + return { + filePath: this.options.toProjectRelative(absolute), + outcome: JsImportResolutionOutcome.RESOLVED_PROJECT, + }; + } + return { + filePath: absolute.split(path.sep).join('/'), + outcome: JsImportResolutionOutcome.RESOLVED_EXTERNAL, + }; + } + + private enclosingMethodHash(node: ts.Node): string { + let current: ts.Node | undefined = node; + while (current !== undefined) { + const hash = this.options.methodHashByNode.get(nodeKey(current)); + if (hash !== undefined) { + return hash; + } + current = current.parent; + } + return this.options.moduleInitMethodHash; + } + + private scopeAt(node: ts.Node): JsScopeNode { + let current: ts.Node | undefined = node; + while (current !== undefined) { + const scope = this.options.binder.enclosingScopeOf.get(nodeKey(current)); + if (scope !== undefined) { + return scope; + } + current = current.parent; + } + return this.options.binder.moduleScope; + } + + private positionOf(node: ts.Node): { startLine: number; startColumn: number } { + return pointOf(node, this.sourceFile); + } +} + +// --------------------------------------------------------------------------- + +/** `createRequire(import.meta.url)`, however it was imported. */ +function isCreateRequireCall(node: ts.CallExpression): boolean { + const callee = node.expression; + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) ? callee.name.text : ''; + return name === 'createRequire'; +} + +function literalSpecifierOf(call: ts.CallExpression): string | undefined { + const first = call.arguments[0]; + if (first === undefined) { + return undefined; + } + return ts.isStringLiteralLike(first) ? first.text : undefined; +} + +function isTemplateSpecifier(call: ts.CallExpression): boolean { + const first = call.arguments[0]; + // A template's SHAPE is known even though its value is not — a consumer can + // see the directory being indexed into, which is enough to stage a subtree. + // A plain NON_LITERAL offers nothing, so the two are separate values. + return first !== undefined && ts.isTemplateExpression(first); +} + +function textOfFirstArgument(call: ts.CallExpression, sourceFile: ts.SourceFile): string { + const first = call.arguments[0]; + return first === undefined ? '' : first.getText(sourceFile); +} + +/** `module.exports`, `module.exports.x`, `exports.x`. */ +function exportFormOf(target: ts.PropertyAccessExpression): JsExportForm | undefined { + if (ts.isIdentifier(target.expression)) { + if (target.expression.text === 'module' && target.name.text === 'exports') { + return JsExportForm.MODULE_EXPORTS_ASSIGNMENT; + } + if (target.expression.text === 'exports') { + // `exports.foo = …`. The same edge as `module.exports.foo` through a + // different alias — and the two stop being equivalent the moment a + // `module.exports = {}` runs, because `exports` still points at the old + // object and a later `exports.x = 1` then exports nothing at all. + return JsExportForm.EXPORTS_MEMBER; + } + return undefined; + } + if (ts.isPropertyAccessExpression(target.expression) + && ts.isIdentifier(target.expression.expression) + && target.expression.expression.text === 'module' + && target.expression.name.text === 'exports') { + return JsExportForm.MODULE_EXPORTS_MEMBER; + } + return undefined; +} + +function isDefinePropertyOnExports(call: ts.CallExpression): boolean { + if (!ts.isPropertyAccessExpression(call.expression) + || call.expression.name.text !== 'defineProperty' + || call.arguments.length < 2) { + return false; + } + const target = call.arguments[0]!; + if (ts.isIdentifier(target) && target.text === 'exports') { + return true; + } + return ts.isPropertyAccessExpression(target) + && ts.isIdentifier(target.expression) && target.expression.text === 'module' + && target.name.text === 'exports'; +} + +/** + * What is on the right-hand side, which is what an importer actually gets. + * + * `OBJECT_LITERAL` at 335 measured is the pre-ES6 namespace, and it reaches the + * fact base as an expression rather than as a `js_type` — treating every object + * literal as a type is how a fact base acquires 50,000 meaningless types. + */ +function valueKindOf(value: ts.Expression): JsExportedValueKind { + if (ts.isFunctionExpression(value) || ts.isArrowFunction(value)) { + return JsExportedValueKind.FUNCTION; + } + if (ts.isClassExpression(value)) { + return JsExportedValueKind.CLASS; + } + if (ts.isObjectLiteralExpression(value)) { + return JsExportedValueKind.OBJECT_LITERAL; + } + if (ts.isIdentifier(value)) { + return JsExportedValueKind.IDENTIFIER; + } + return JsExportedValueKind.OTHER; +} + +function declaredValueKindOf(statement: ts.Statement): JsExportedValueKind { + if (ts.isFunctionDeclaration(statement)) { + return JsExportedValueKind.FUNCTION; + } + if (ts.isClassDeclaration(statement)) { + return JsExportedValueKind.CLASS; + } + return JsExportedValueKind.IDENTIFIER; +} + +function hasExportModifier(statement: ts.Statement): boolean { + return ts.canHaveModifiers(statement) + && (ts.getModifiers(statement) ?? []).some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ); +} + +function exportedNamesOf(statement: ts.Statement): string[] { + if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) { + return statement.name === undefined ? [JS_DEFAULT_EXPORT_NAME] : [statement.name.text]; + } + if (ts.isVariableStatement(statement)) { + const names: string[] = []; + for (const declaration of statement.declarationList.declarations) { + collectBindingNames(declaration.name, names); + } + return names; + } + return []; +} + +function collectBindingNames(name: ts.BindingName, into: string[]): void { + if (ts.isIdentifier(name)) { + into.push(name.text); + return; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) { + continue; + } + collectBindingNames(element.name, into); + } +} + +/** + * Is this edge at the top of the file? + * + * **False for 13.6% of requires** — 1,048 in a function body and 179 in a block. + * The walk up stops at the first function or block, which is what makes the + * count mean what the schema says it means. + */ +/** + * Where the enclosing-method walk starts for a node that may sit in a comment. + * + * A JSDoc block is attached to the node it DOCUMENTS, so walking up from a + * type inside `/** @param {import('./x').Y} p *\/ function f(p) {}` reaches + * `f` — and the row is then "owned" by a method whose span starts on the next + * line. 1,071 rows outside their owner on the first corpus run. The method + * that contains a comment is the one containing its HOST, so the walk starts + * at the host's parent. A node that is not in a comment starts at itself. + */ +function methodWalkStartOf(node: ts.Node): ts.Node { + let current: ts.Node | undefined = node; + while (current !== undefined && current.kind !== ts.SyntaxKind.JSDoc) { + current = current.parent; + } + return current?.parent?.parent ?? node; +} + +function isTopLevel(node: ts.Node): boolean { + let current: ts.Node | undefined = node.parent; + while (current !== undefined) { + if (ts.isSourceFile(current)) { + return true; + } + if (ts.isBlock(current) || ts.isFunctionLike(current) || ts.isCaseClause(current) + || ts.isClassLike(current)) { + return false; + } + current = current.parent; + } + return false; +} + +/** + * Is this edge inside something that may not run? + * + * An `if`, a `try`, a ternary, or the right side of `&&`/`||`/`??`. A module + * edge that may never execute is a different claim from one that always does, + * and it is the column that keeps `overwritesPreviousExport` from asserting a + * runtime conclusion. + */ +function isConditional(node: ts.Node): boolean { + let current: ts.Node | undefined = node.parent; + let child: ts.Node = node; + while (current !== undefined) { + if (ts.isSourceFile(current) || ts.isFunctionLike(current)) { + return false; + } + if (ts.isIfStatement(current) || ts.isTryStatement(current) + || ts.isSwitchStatement(current) || ts.isConditionalExpression(current)) { + return true; + } + if (ts.isBinaryExpression(current) && current.right === child + && (current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + || current.operatorToken.kind === ts.SyntaxKind.BarBarToken + || current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)) { + return true; + } + child = current; + current = current.parent; + } + return false; +} + diff --git a/parser/src/parsers/javascript/extractors/js-module-extractor.ts b/parser/src/parsers/javascript/extractors/js-module-extractor.ts new file mode 100644 index 000000000..aedb30c66 --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-module-extractor.ts @@ -0,0 +1,507 @@ +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + JS_BUNDLED_LINE_LENGTH_THRESHOLD, + JS_EMISSION_REGIME, + JS_MINIFIED_NAME_PATTERN, + JS_TARGET_VERSION, +} from '@/constants/javascript-constants'; +import { + JsContradictionKind, + JsModuleKind, + JsModuleSystem, + JsModuleSystemSource, + JsScriptKind, + JsSourceProvenance, +} from '@/enums/javascript/modules'; +import { JsModuleRegistry } from '@/analysis-types/javascript/JsModuleRegistry'; +import { keyOf } from '@/analysis-types/javascript/js-row'; +import { EntityUtils } from '@/utils/entity-utils'; +import { + isFlowDeclarationFileName, isRequireCall, jsExtensionOf, lastLineOf, + opensFunctionBoundary, stripJsExtension, +} from '@/utils/javascript'; + +/** + * `js_module` — one row per file, minted so that its hash is computable before + * the file is parsed. + * + * ## The hash comes from paths alone, and that is a structural requirement + * + * §1 of `BUILDING-A-PARSER.md`: *mint every module hash up front from PATHS + * ALONE, before parsing any file*, so a declaration in file B can key itself + * under file A's scope without file A having been read. {@link moduleHashFor} is + * that function, and it is deliberately separate from {@link extractModule} — + * the analyzer calls the former for every file before the loop that calls the + * latter. + * + * The one thing that makes this non-obvious for JavaScript is that + * `moduleSystem` is **in the key**, and `moduleSystem` comes from a + * `package.json`. That is still paths alone: `PackageJsonResolver` is a pure + * function of the filesystem, reads no JavaScript, and creates no Program. + */ + +/** + * The `js_module` primary key, from a path and a governing module system. + * + * Kept as a free function because the analyzer needs every module's hash before + * any row object exists. + */ +export function moduleHashFor( + filePath: string, + baseMservPath: string, + moduleSystem: JsModuleSystem, + serviceVersionLinkHash: string +): string { + return EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.JS_MODULE, + keyOf(filePath, baseMservPath, moduleSystem, JS_EMISSION_REGIME, serviceVersionLinkHash) + ); +} + +/** + * `ts.ScriptKind` for a JavaScript file. + * + * `.jsx` gets `JSX` because the extension states the intent, and everything + * else gets `JS`. The schema's measurement is what makes this safe rather than + * a guess: **0 of 2,942 `.js` files parse differently** between the two, because + * `ScriptKind.JS` already carries `languageVariant = JSX` — JavaScript has no + * type-assertion syntax for `<` to be ambiguous with, so the `.ts`/`.tsx` + * problem does not exist here. + * + * `.mjs` and `.cjs` are `JS`: they differ in module system, not in grammar. + */ +export function scriptKindFor(filePath: string): ts.ScriptKind { + return isJsxFile(filePath) ? ts.ScriptKind.JSX : ts.ScriptKind.JS; +} + +/** + * Is this file JSX by NAME? + * + * One spelling, because there were two. `scriptKindFor` above decides what the + * compiler parses; the module row's `scriptKind` column decides what the fact + * base records, and it re-implemented the same `endsWith('.jsx')` inline. They + * agreed — checked, not assumed — and nothing would have said so if one had + * gained a case and the other had not, which is exactly how the three spellings + * of "is this a JavaScript file" drifted apart. + * + * Via `jsExtensionOf`, so `a.jsx.flow` answers the same as `a.jsx`. + */ +function isJsxFile(filePath: string): boolean { + return jsExtensionOf(path.basename(filePath)) === '.jsx'; +} + +export interface ModuleExtractionOptions { + readonly sourceFile: ts.SourceFile; + readonly sourceText: string; + readonly filePath: string; + readonly baseMservPath: string; + readonly moduleQualifiedName: string; + readonly moduleSystem: JsModuleSystem; + readonly moduleSystemSource: JsModuleSystemSource; + readonly governingPackageJsonPath: string; + readonly packageName: string; + readonly serviceVersionLinkHash: string; +} + +export interface ModuleExtractionResult { + readonly module: JsModuleRegistry; + /** The scan's findings, reused downstream rather than recomputed. */ + readonly shape: ModuleShape; +} + +/** + * What one pass over the file establishes about the module as a whole. + * + * Collected in a single traversal rather than four, and handed to the callers + * that need it — the binder needs `hasEsmSyntax` to decide strict mode, and the + * module row needs all of it. + */ +export interface ModuleShape { + /** A top-level `import` or `export` declaration: the file IS an ES module. */ + readonly hasEsmSyntax: boolean; + /** A `require(...)` call ANYWHERE, including inside a function body. */ + readonly hasRequireCall: boolean; + readonly hasTopLevelAwait: boolean; + readonly hasJsxContent: boolean; + readonly hasFlowPragma: boolean; +} + +export function extractModule(options: ModuleExtractionOptions): ModuleExtractionResult { + const shape = scanModuleShape(options.sourceFile, options.sourceText); + const contradiction = contradictionOf(options.moduleSystem, shape); + const sourceProvenance = isFlowFile(options.filePath, shape.hasFlowPragma) + ? JsSourceProvenance.FLOW_REJECTED + : provenanceOf(options.filePath, options.sourceText); + + const module = new JsModuleRegistry({ + name: stemOf(options.filePath), + qualifiedName: options.moduleQualifiedName, + fileName: path.basename(options.filePath), + filePath: options.filePath, + baseMservPath: options.baseMservPath, + // A JavaScript file's kind turns on the same question TypeScript's does — + // top-level import/export — and for the same consequence: module scope and + // implicit strict mode versus the global object and sloppy mode. + moduleKind: shape.hasEsmSyntax ? JsModuleKind.SOURCE_MODULE : JsModuleKind.SCRIPT_GLOBAL, + scriptKind: isJsxFile(options.filePath) ? JsScriptKind.JSX : JsScriptKind.JS, + moduleSystem: options.moduleSystem, + moduleSystemSource: options.moduleSystemSource, + governingPackageJsonPath: options.governingPackageJsonPath, + contradictsGoverningConfig: contradiction !== JsContradictionKind.NONE, + contradictionKind: contradiction, + packageName: options.packageName, + isExternalModule: shape.hasEsmSyntax, + hasTopLevelAwait: shape.hasTopLevelAwait, + // Recorded AFTER parsing, not guessed from the extension before it. The + // schema is explicit that this is an observation and the script kind is not + // a decision (§0.0). + hasJsxContent: shape.hasJsxContent, + hasFlowPragma: shape.hasFlowPragma, + emissionRegime: JS_EMISSION_REGIME, + targetTsVersion: JS_TARGET_VERSION, + startLine: 1, + endLine: lastLineOf(options.sourceFile), + sourceProvenance, + serviceVersionLinkHash: options.serviceVersionLinkHash, + }); + + return { module, shape }; +} + +/** + * Whether the file's own syntax contradicts what its config says it is. + * + * The Q3 ruling is *emit normally, flag it*, so this returns a value and never + * a skip. All 170 measured contradictions are `ESM_SYNTAX_UNDER_COMMONJS` and + * all of them are bundler input, where `package.json` governs nothing because + * the bundler reads the file before Node ever would. + * + * `REQUIRE_UNDER_ESM` measured **0**, and it is the direction that genuinely + * throws — `require` is not defined in an ES module. Its absence is a fact + * worth being able to see, which is why it is a separate value rather than + * folded into one boolean. + */ +function contradictionOf( + moduleSystem: JsModuleSystem, + shape: ModuleShape +): JsContradictionKind { + // MIXED is a file that uses BOTH module systems, whichever one governs it. + // + // It was UNREACHABLE BY CONSTRUCTION before: the two one-directional tests + // below check opposite values of `moduleSystem`, so they could never both be + // true, and a value the schema declares could never be emitted. js-fixtures + // found it with a file named `mixed-both-systems.js` that emits an + // IMPORT_DECLARATION and two REQUIRE_CALLs and came out + // ESM_SYNTAX_UNDER_COMMONJS. + // + // Reading it as "both systems present" is the only reading under which the + // value exists, and it is the stronger statement: whichever config governs, + // part of the file cannot run under it. The one-directional values keep their + // meaning for the far more common case of a file using one system wrongly. + if (shape.hasEsmSyntax && shape.hasRequireCall) { + return JsContradictionKind.MIXED; + } + const esmUnderCommonJs = moduleSystem === JsModuleSystem.COMMONJS && shape.hasEsmSyntax; + const requireUnderEsm = moduleSystem === JsModuleSystem.ESM && shape.hasRequireCall; + if (esmUnderCommonJs) { + return JsContradictionKind.ESM_SYNTAX_UNDER_COMMONJS; + } + if (requireUnderEsm) { + return JsContradictionKind.REQUIRE_UNDER_ESM; + } + return JsContradictionKind.NONE; +} + +/** + * Bundled, generated, or hand-written. + * + * - **The name.** `foo.min.js` is minified whatever its line lengths are, and a + * minifier configured to wrap lines would otherwise slip through. Build + * products only; module-format markers were removed (see the pattern). + * - **A line no human writes, AND a content signal.** Length alone labelled a + * 399-line hand-written file for one 7,286-character regex literal — the + * shape this comment once said could not happen. A single long LITERAL is + * excused; a long line beside a `sourceMappingURL` footer or a bundler + * preamble is a minified bundle. Ruled 2026-09-13; the measured worth of the + * length signal is recorded on the constant. + * - **A preamble with ordinary lines** is a readable concatenated build: + * not minified, not source, its own value. + */ +function provenanceOf(filePath: string, sourceText: string): JsSourceProvenance { + if (JS_MINIFIED_NAME_PATTERN.test(path.basename(filePath))) { + return JsSourceProvenance.BUNDLED; + } + const preamble = hasBundlerPreamble(sourceText); + if (longestLineOf(sourceText) > JS_BUNDLED_LINE_LENGTH_THRESHOLD + && (preamble || hasSourceMapFooter(sourceText))) { + return JsSourceProvenance.BUNDLED; + } + if (preamble) { + return JsSourceProvenance.GENERATED_MONOLITH; + } + return JsSourceProvenance.PROJECT; +} + +/** + * `//# sourceMappingURL=` in the FOOTER — the last line by convention, so the + * search is bounded to the tail, the mirror of the preamble's head bound. It + * is not a provenance on its own (a 45-line hand-written fixture carried one, + * see below); it is the content signal that lets a long line count. + */ +function hasSourceMapFooter(sourceText: string): boolean { + return /\/[/*][#@]\s*sourceMappingURL=/.test(sourceText.slice(-1_024)); +} + +/** Scans without allocating one string per line — bundles are one huge line. */ +function longestLineOf(sourceText: string): number { + let longest = 0; + let lineStart = 0; + for (let i = 0; i < sourceText.length; i += 1) { + if (sourceText.charCodeAt(i) === 10) { + const length = i - lineStart; + if (length > longest) { + longest = length; + } + lineStart = i + 1; + } + } + const tail = sourceText.length - lineStart; + return tail > longest ? tail : longest; +} + +/** + * The runtime preambles bundlers emit, matched only near the top of the file. + * + * Bounded to the first 4 KB on purpose: `webpackBootstrap` appearing in a + * comment halfway down a hand-written file is not evidence the file is + * generated, and an unbounded search would make it so. + * + * ## `sourceMappingURL` was here and is not a preamble + * + * It is a **footer** by convention — the last line of a generated file — and the + * positional bound that makes the other four safe does nothing for it: for any + * file under 4 KB the head window IS the whole file, so a trailing + * `//# sourceMappingURL=` read as a preamble and a 45-line hand-written fixture + * was classified `GENERATED_MONOLITH`. + * + * That is worse than a wrong column. Gate 7.3.5 says a non-`PROJECT` file + * contributes zero rows to any coverage denominator, so the file was **silently + * removed from coverage while every count read green** — found by `js-fixtures`, + * on a fixture written to test something else entirely. + * + * The other four really are headers: a bundler's runtime preamble is the first + * thing in the file, which is what makes bounding the search meaningful. + */ +const BUNDLER_PREAMBLES: readonly RegExp[] = [ + /webpackBootstrap/, + /__webpack_require__/, + /\bdefine\.amd\b[\s\S]{0,200}\bmodule\.exports\b/, + /\(function\s*\(\s*global\s*,\s*factory\s*\)/, +]; + +function hasBundlerPreamble(sourceText: string): boolean { + const head = sourceText.slice(0, 4_096); + return BUNDLER_PREAMBLES.some((pattern) => pattern.test(head)); +} + +/** + * One traversal, four findings. + * + * ## `hasRequireCall` must see a nested `require`, and that is the whole reason + * this is a recursive walk rather than a statement-list scan + * + * 13.6% of measured `require()` calls are **not top-level** — 1,048 inside a + * function body and 179 inside a block. A scan of `sourceFile.statements`, which + * is what every TypeScript module-edge extractor does because every TypeScript + * module edge is a top-level declaration, misses one require in seven. The + * module row needs the answer for `REQUIRE_UNDER_ESM`, and the import extractor + * needs it for the edges themselves. + * + * ## `hasTopLevelAwait` must NOT descend into functions + * + * `await` inside an `async function` is ordinary. Top-level `await` forces module + * semantics on the file, which is a different claim, so the walk stops at every + * function boundary for that flag specifically — while continuing for the other + * three. + */ +function scanModuleShape(sourceFile: ts.SourceFile, sourceText: string): ModuleShape { + let hasEsmSyntax = false; + let hasRequireCall = false; + // Names bound by `createRequire(import.meta.url)`. A call through one of these + // is the SANCTIONED ESM->CommonJS bridge, not the unqualified global + // `require` — the file runs, and `js_import` already classifies the edge as + // CREATE_REQUIRE rather than REQUIRE_CALL. Counting it here made the MODULE + // row say REQUIRE_UNDER_ESM while the IMPORT rows said CREATE_REQUIRE: two + // relations contradicting each other inside one fact base, losing no rows and + // therefore invisible to every count. Found by js-fixtures. + const createdRequireNames = collectCreatedRequireNames(sourceFile); + let hasTopLevelAwait = false; + let hasJsxContent = false; + + for (const statement of sourceFile.statements) { + if (isEsmModuleStatement(statement)) { + hasEsmSyntax = true; + break; + } + } + + const visit = (node: ts.Node, insideFunction: boolean): void => { + if (!hasRequireCall && isRequireCall(node) + && !isCreatedRequireBinding(node, createdRequireNames)) { + hasRequireCall = true; + } + if (!hasJsxContent && isJsxNode(node)) { + hasJsxContent = true; + } + if (!insideFunction && !hasTopLevelAwait && ts.isAwaitExpression(node)) { + hasTopLevelAwait = true; + } + // Brace every branch, and make the boundary explicit: a function's body is + // still walked (the other three findings live in there), but everything + // inside it is no longer "top level" for the await question. + // opensFunctionBoundary, NOT opensThisScope: an arrow does not rebind `this` + // and does end the top level, so `async () => { await x; }` has no + // top-level await. Using the `this` predicate here reported 33 of 816 real + // files as having one. + const entersFunction = insideFunction || opensFunctionBoundary(node); + ts.forEachChild(node, (child) => { + visit(child, entersFunction); + }); + }; + ts.forEachChild(sourceFile, (child) => { + visit(child, false); + }); + + return { + hasEsmSyntax, + hasRequireCall, + hasTopLevelAwait, + hasJsxContent, + hasFlowPragma: hasFlowPragma(sourceText), + }; +} + +/** + * A top-level statement that makes the file an ES module. + * + * `import ... from` and every `export` form, including `export {}` — which + * declares nothing and is the canonical way to force module semantics on a file + * that would otherwise be a script. An `import(...)` expression does NOT count: + * dynamic import is legal in a CommonJS file and is how CommonJS reaches ESM. + */ +function isEsmModuleStatement(statement: ts.Statement): boolean { + if (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) { + return true; + } + if (ts.isExportAssignment(statement)) { + return true; + } + return ts.canHaveModifiers(statement) + && (ts.getModifiers(statement) ?? []).some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ); +} + +/** + * Local names bound to a `createRequire(...)` result. + * + * Collected in their own pass, because a call through the name may precede the + * binding in source order and the binding still governs it. + */ +function collectCreatedRequireNames(sourceFile: ts.SourceFile): ReadonlySet { + const names = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) + && node.initializer !== undefined && ts.isCallExpression(node.initializer)) { + const callee = node.initializer.expression; + const calleeName = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) ? callee.name.text : ''; + if (calleeName === 'createRequire') { + names.add(node.name.text); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + return names; +} + +function isCreatedRequireBinding( + node: ts.Node, + createdRequireNames: ReadonlySet +): boolean { + return ts.isCallExpression(node) && ts.isIdentifier(node.expression) + && createdRequireNames.has(node.expression.text); +} + +function isJsxNode(node: ts.Node): boolean { + return ts.isJsxElement(node) + || ts.isJsxSelfClosingElement(node) + || ts.isJsxFragment(node); +} + +/** + * `@flow` in a leading comment. + * + * The reason this column exists is now a RETRACTED measurement, and the column + * outlives it deliberately. 64 syntactic type annotations were reported, all + * Flow; the replication corpus measures 0, because all 64 were in one package it + * does not contain. `js-oracle` kept `SYNTACTIC_FLOW` because the held-back + * corpus is Flow throughout and was chosen to test exactly this — so the pragma + * is the evidence that will let that test be read. `ts.createSourceFile` parses Flow + * into real `.type` nodes where the two grammars overlap and mis-parses silently + * where they do not, so recording the pragma is what keeps a Flow annotation + * from being read later as a TypeScript one. + */ +/** + * Is this file Flow, and therefore out of scope? + * + * Two signals, both cheap and both decided before any walking happens: + * the `@flow` pragma, and the `.js.flow` extension that Flow uses for its + * declaration files. + * + * The extension is checked as well as the pragma because a `.js.flow` file is + * ENTIRELY type declarations and frequently carries no pragma at all — it does + * not need one, its name is the declaration. + * + * ## `.flowconfig` was considered and REJECTED, with the ratio + * + * Recorded because a signal rejected in silence gets proposed again, and this + * one is the obvious third candidate: Flow's real rule is that an ancestor + * `.flowconfig` governs, which is how the 12 remaining detection misses — all + * in one UI library, all carrying NO pragma anywhere in the file — are Flow at all. + * + * Measured on a 4,561-file corpus before deciding: it contains exactly **one** + * `.flowconfig`, under one scaffolding tool's fixture template, and + * honouring it would decline **85 files to catch 12** — most of the 85 are not + * Flow. Worse, that library's own subtree carries no `.flowconfig` at all, so it would + * not catch even those 12. + * + * So the detector stays as ruled, and the residue is REPORTED by name instead: + * `SYNTACTIC_FLOW` is now a detection-miss measure, never a zero-row assertion, + * because what it counts is a property of the corpus rather than of the parser. + */ +export function isFlowFile(filePath: string, pragmaPresent: boolean): boolean { + return pragmaPresent || isFlowDeclarationFileName(path.basename(filePath)); +} + +function hasFlowPragma(sourceText: string): boolean { + // Bounded to the head: the pragma is a file-level directive by convention and + // an unbounded search would match the word in any prose comment. + return /@flow\b/.test(sourceText.slice(0, 2_048)); +} + +/** + * A module's `name`: the basename with its JavaScript extension removed. + * + * Cutting at the LAST dot gave `a.js` for `a.js.flow` — a name column carrying + * an extension, and one that would join against nothing. + */ +function stemOf(filePath: string): string { + return stripJsExtension(path.basename(filePath)); +} diff --git a/parser/src/parsers/javascript/extractors/js-parse-gap-extractor.ts b/parser/src/parsers/javascript/extractors/js-parse-gap-extractor.ts new file mode 100644 index 000000000..9e2be51f3 --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-parse-gap-extractor.ts @@ -0,0 +1,280 @@ +import * as ts from 'typescript'; + +import { JsCallSiteRegistry } from '@/analysis-types/javascript/JsCallSiteRegistry'; +import { JsExpressionRegistry } from '@/analysis-types/javascript/JsExpressionRegistry'; +import { JsImportRegistry } from '@/analysis-types/javascript/JsImportRegistry'; +import { JsParseGapRegistry } from '@/analysis-types/javascript/JsParseGapRegistry'; +import { JsScopeRegistry } from '@/analysis-types/javascript/JsScopeRegistry'; +import { JsTypeReferenceRegistry } from + '@/analysis-types/javascript/JsTypeReferenceRegistry'; +import { JsParseGapKind } from '@/enums/javascript/parse-gaps'; +import { JsScopeKind } from '@/enums/javascript/scopes'; +import { JsSpecifierKind } from '@/enums/javascript/imports'; +import { JsTypeReferenceKind } from '@/enums/javascript/type-references'; +import { parseDiagnosticsOf, pointAtOffset } from '@/utils/javascript'; + +/** + * `js_parse_gap` — what the parser could not do, recorded as **data rather than + * a log line**. + * + * ## Why a relation and not a warning + * + * §9 of `BUILDING-A-PARSER.md`: *if the analyzer drops something for a + * structural reason, it must say so.* On one large framework checkout a nested config silently + * excluded 1,270 of 1,821 files and **nothing counted them** — the run reported + * success with a fact base missing two thirds of the project. + * + * A log line is not a count, and a count that is not in the fact base cannot be + * joined against the rows that are. `relatedRelation` and `relatedLinkHash` name + * the row the gap is about, so a consumer can ask "what is missing from + * `js_call_site` here" and get an answer rather than a number. + * + * ## It reads the emitted rows rather than instrumenting the passes + * + * Every gap below is already visible in the fact base — an import with + * `specifierKind = NON_LITERAL`, a type reference with + * `referenceKind = UNKNOWN_SYNTAX`, an expression with `isTruncated`. Deriving + * the gaps from those rows rather than threading a reporter through five + * extractors means the two can never disagree: a gap row exists exactly when the + * fact it describes is in the relation it names. + * + * The one exception is a **parse diagnostic**, which is not in any relation + * because the construct it concerns never became a row at all. + */ +export interface ParseGapOptions { + readonly sourceFile: ts.SourceFile; + readonly moduleHash: string; + readonly serviceVersionLinkHash: string; + readonly imports: readonly JsImportRegistry[]; + readonly typeReferences: readonly JsTypeReferenceRegistry[]; + readonly expressions: readonly JsExpressionRegistry[]; + readonly callSites: readonly JsCallSiteRegistry[]; + readonly scopes: readonly JsScopeRegistry[]; + readonly hasFlowPragma: boolean; + /** + * Is the file's own top-level scope strict? + * + * Decides whether a strict-mode-only grammar diagnostic is a real gap. See + * {@link STRICT_MODE_ONLY_DIAGNOSTICS}. + */ + readonly isStrictModeFile: boolean; +} + +export function extractParseGaps(options: ParseGapOptions): JsParseGapRegistry[] { + const out: JsParseGapRegistry[] = []; + // The same gap, reported twice, is one gap. + // + // `ts.createSourceFile` recovers from an unterminated JSX element by emitting + // `1005: '(); + const mint = (init: { + gapKind: JsParseGapKind; + detail: string; + relatedRelation: string; + relatedLinkHash: string; + startLine: number; + startColumn: number; + isRecoverable: boolean; + }): void => { + const row = new JsParseGapRegistry({ + ...init, + ownerModuleLinkHash: options.moduleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + }); + if (minted.has(row.getHash())) { + return; + } + minted.add(row.getHash()); + out.push(row); + }; + + // Parse diagnostics. Should be rare — zero over 25.9 MB of TypeScript — and + // emitted anyway, because an always-empty relation that suddenly has rows is a + // signal and a missing relation is a silence. + for (const diagnostic of parseDiagnosticsOf(options.sourceFile)) { + if (!options.isStrictModeFile + && STRICT_MODE_ONLY_DIAGNOSTICS.has(diagnostic.code)) { + // NOT a gap. TypeScript applies these grammar rules unconditionally + // because it has no sloppy mode to be in; in a sloppy CommonJS file the + // construct is legal, `node --check` accepts it, and — verified on + // `cjs/hoisting/sloppy-implicit-global.js` — every row is still emitted: + // the literals, the bindings, all of it. Minting a PARSE_ERROR here would + // report a defect that does not exist, which §7 says is as wrong as + // hiding one. + // + // Narrow on purpose: exactly the codes whose rule is "…in strict mode", + // and only when the file is not in it. A genuine syntax error still + // produces a row. + continue; + } + const at = pointAtOffset(diagnostic.start ?? 0, options.sourceFile); + mint({ + gapKind: JsParseGapKind.PARSE_ERROR, + detail: `${diagnostic.code}: ` + + ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '), + relatedRelation: 'js_module', + relatedLinkHash: options.moduleHash, + startLine: at.startLine, + startColumn: at.startColumn, + // The compiler recovers and produces a tree, so the rest of the file is + // still extracted. The gap says which part was not. + isRecoverable: true, + }); + } + + // `require(variable)`. Unresolvable BY CONSTRUCTION, and the row already says + // so — this makes it countable alongside every other thing the parser + // declined to answer, rather than only discoverable by filtering js_import. + for (const row of options.imports) { + if (row.specifierKind === JsSpecifierKind.STRING_LITERAL) { + continue; + } + mint({ + gapKind: JsParseGapKind.NON_LITERAL_SPECIFIER, + detail: row.specifier, + relatedRelation: 'js_import', + relatedLinkHash: row.getHash(), + startLine: row.startLine, + startColumn: row.startColumn, + // Not recoverable by any amount of static analysis: the specifier's value + // is a runtime fact. + isRecoverable: false, + }); + } + + // A JSDoc type expression that could not be decomposed. EXPECTED to be + // non-empty: JSDoc type syntax is not standardised, and Closure, TypeScript + // and jsdoc.app all differ. The text is preserved on the type-reference row. + for (const row of options.typeReferences) { + if (row.referenceKind !== JsTypeReferenceKind.UNKNOWN_SYNTAX) { + continue; + } + mint({ + gapKind: JsParseGapKind.UNKNOWN_JSDOC_SYNTAX, + detail: row.typeName, + relatedRelation: 'js_type_reference', + relatedLinkHash: row.getHash(), + startLine: row.startLine, + startColumn: row.startColumn, + isRecoverable: true, + }); + } + + // The depth cap fired and a subtree was dropped. 32, not TypeScript's + // effective 20: the corpus reaches depth 67 with a p99 of 26. + for (const row of options.expressions) { + if (!row.wasTruncated()) { + continue; + } + mint({ + gapKind: JsParseGapKind.DEPTH_CAP_REACHED, + detail: row.expressionKind, + relatedRelation: 'js_expression', + relatedLinkHash: row.getHash(), + startLine: row.startLine, + startColumn: row.startColumn, + isRecoverable: true, + }); + } + + // `eval` and `new Function`. The call IS emitted — a fact base that omits it + // asserts the program has no dynamic code, which is a stronger claim than + // admitting one call cannot be followed — and the gap says the target is not. + for (const row of options.callSites) { + if (!row.isDynamicCode) { + continue; + } + mint({ + gapKind: JsParseGapKind.DYNAMIC_CODE, + detail: row.calleeText, + relatedRelation: 'js_call_site', + relatedLinkHash: row.getHash(), + startLine: row.startLine, + startColumn: row.startColumn, + isRecoverable: false, + }); + } + + // A `with` body, where NO name is statically resolvable. The honest answer is + // to mark the scope rather than emit confident bindings that may all be wrong. + for (const row of options.scopes) { + if (row.scopeKind !== JsScopeKind.WITH) { + continue; + } + mint({ + gapKind: JsParseGapKind.WITH_STATEMENT_SCOPE, + detail: 'every name in this body may be shadowed by the with object', + relatedRelation: 'js_scope', + relatedLinkHash: row.getHash(), + startLine: row.startLine, + startColumn: row.startColumn, + isRecoverable: false, + }); + } + + // A `@flow` pragma. `ts.createSourceFile` parses the grammar Flow shares with + // TypeScript and MIS-PARSES the rest silently, so this row is the only place + // the disagreement becomes visible at all. + if (options.hasFlowPragma) { + mint({ + gapKind: JsParseGapKind.FLOW_SYNTAX, + detail: 'file carries a @flow pragma; syntax outside the shared grammar is mis-parsed', + relatedRelation: 'js_module', + relatedLinkHash: options.moduleHash, + startLine: 1, + startColumn: 1, + isRecoverable: true, + }); + } + + return out; +} + +/** + * Grammar rules TypeScript enforces unconditionally that apply only in strict + * mode. + * + * `ts.createSourceFile` has no sloppy mode, so it reports these on code that is + * legal in a non-strict CommonJS file and that Node — the schema's named second + * oracle — accepts. The nodes are produced either way, so nothing is lost and + * nothing should be reported. + * + * ## The general rule, now ruled and written down + * + * `js-oracle` reproduced this rather than taking it on report — on + * `var mode = 0777` tsc emits 1121/1487 and the AST is complete, zero unbuilt + * nodes — and generalised it so the next case needs no ruling: + * + * > **`js_parse_gap` asserts a row is missing. Where no row is missing there is + * > no gap — whatever the compiler says.** + * + * This set is the operational form of that rule for the class of diagnostics + * where completeness has actually been verified. Widening it means doing the + * same check, not assuming: confirm the rows are emitted, then add the code. + * + * There is deliberately no `gapKind` for oracle disagreement. A tsc-versus-Node + * disagreement belongs in `../parser-oracle/javascript/` beside the V8 + * adjudicator — the same place `resolverAgreement` went, and for the same + * reason: recording it here would mean the parser running two resolvers. + */ +const STRICT_MODE_ONLY_DIAGNOSTICS: ReadonlySet = new Set([ + 1121, // Octal literals are not allowed. Use the syntax '0o755'. + 1487, // Octal escape sequences are not allowed. Use the syntax '\x41'. + 1210, // Code contained in a class is evaluated in strict mode… + 1212, // Identifier expected. '{0}' is a reserved word in strict mode. + 1213, // …in strict mode. Class definitions are automatically in strict mode. + 1214, // …in strict mode. Modules are automatically in strict mode. + 1215, // Invalid use of '{0}'. Modules are automatically in strict mode. + 1250, // Function declarations are not allowed inside blocks in strict mode… + 1251, // …when targeting 'ES5'. Class definitions are automatically in strict mode. + 1252, // …when targeting 'ES5'. Modules are automatically in strict mode. +]); diff --git a/parser/src/parsers/javascript/extractors/js-scope-builder.ts b/parser/src/parsers/javascript/extractors/js-scope-builder.ts new file mode 100644 index 000000000..2ed549a8b --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-scope-builder.ts @@ -0,0 +1,941 @@ +import * as ts from 'typescript'; + +import { JsModuleSystem } from '@/enums/javascript/modules'; +import { JsScopeKind, JsStrictModeSource } from '@/enums/javascript/scopes'; +import { JsBindingRegime } from '@/enums/javascript/variables'; +import { + addBinding, + JsBinding, + JsScopeNode, + nearestFunctionScope, + nodeKey, +} from '@/parsers/javascript/extractors/js-symbol-table'; +import { pointOf } from '@/utils/javascript'; + +/** + * Builds the scope tree and every `(scope, name)` binding — the JavaScript + * counterpart of CPython's two-pass symbol-table construction, over a + * TypeScript AST. + * + * `python-scope-builder.ts` is the model and `ts-binder.ts` is not, and the + * reason is worth stating once: `ts-binder.ts` computes declaration-MERGE + * scopes, which is a problem JavaScript does not have. What JavaScript has is + * the problem CPython's binder solves — *where is this name visible from, given + * that it is not where it is written* — plus two things Python does not have at + * all: a temporal dead zone, and a `this` that is rebound by call form. + * + * ## Pass 1 declares; pass 2 finds what was never declared + * + * Pass 1 ({@link build}) walks the tree, opens a scope at each of the nine forms + * that introduce one, and records every declaration. Pass 2 + * ({@link bindImplicitGlobals}) walks the assignments and creates a + * `GLOBAL_IMPLICIT` binding for every sloppy-mode write to a name pass 1 never + * bound. The second pass cannot be folded into the first: whether `x = 1` + * declares anything depends on whether `x` is declared *anywhere* in the + * enclosing chain, including on a line below it. + * + * ## The traps, each of which a naive walker falls into + * + * **1. Hoisting puts a declaration in a scope it is not written in.** A `var` + * inside a block belongs to the enclosing function's table. This is the reason + * {@link JsBinding} carries two scope pointers, and the reason a single "scope" + * column would be the §3 defect class. + * + * **2. A function declaration in a block hoists differently by strict mode.** In + * strict mode it is block-scoped. In sloppy mode Annex B also binds the name in + * the enclosing function scope, which is why real code gets away with calling + * one from outside its block. The strictness is therefore needed *while* + * binding, not afterwards — so directives are read on entry to each scope. + * + * **3. A parameter's default expression is evaluated in the function's OWN + * scope**, not the enclosing one, so `function f(a, b = a)` works. Python's + * binder has the opposite rule for defaults and that difference is exactly the + * kind of thing a port gets wrong by inheritance. + * + * **4. A named function expression binds its own name inside itself.** + * `const f = function g() { return g; }` — `g` is visible in the body and + * nowhere else. Missing it turns a self-recursive callback into an unresolved + * free name. + * + * **5. A class body is always strict, whatever contains it.** A class in a + * sloppy CommonJS file has a strict body and strict methods, so an undeclared + * assignment inside a method throws where the identical line at file level + * creates a global. + * + * **6. The walk must descend into function bodies.** §6: *the worklist stops at + * function boundaries — descend explicitly.* Here that is structural rather than + * optional, because a nested `require` (13.6% of them) and a prototype + * assignment inside an IIFE both live past that boundary. + */ +export interface ScopeBuildOptions { + readonly sourceFile: ts.SourceFile; + /** + * Decides the root scope's strictness, and therefore whether + * `GLOBAL_IMPLICIT` is possible in this file at all. + * + * An ES module is strict with no way to opt out. A CommonJS file is sloppy + * unless it says `'use strict'`. This is why `moduleSystem` is in + * `js_module`'s primary key: the same source text binds a global in one + * answer and throws in the other. + */ + readonly moduleSystem: JsModuleSystem; + /** A top-level `import`/`export`, which makes the file strict regardless. */ + readonly hasEsmSyntax: boolean; +} + +export interface ScopeBuildResult { + /** The ambient scope. `parentScopeLinkHash` is `""` for exactly this one. */ + readonly globalScope: JsScopeNode; + /** The file's own top-level scope, and `js_module.moduleScopeLinkHash`. */ + readonly moduleScope: JsScopeNode; + /** Every scope, in a deterministic pre-order. */ + readonly scopes: readonly JsScopeNode[]; + /** + * The scope a node OPENS, by the node's key alone. + * + * ## The other map answered a different question, and three readers asked it + * the wrong one + * + * `enclosingScopeOf` is the scope a node is IN — recorded at the moment the binder + * visits the node, before dispatching to whatever opens a child. For a + * function declaration that is the scope the function is declared in, not the + * one its body runs in. `visitFunctionLike` read it as the body scope, so + * **every js_method.bodyScopeLinkHash was the enclosing scope**, every + * FUNCTION_BODY block linked to the wrong scope, `visitClass` took the + * enclosing scope for the class scope, and `mintConstructorFunctionType` — + * expecting the function's own scope and taking `.parent` to reach the + * enclosing one — overshot to GLOBAL on every constructor function. + * + * Populated and wrong, in link columns the engine needs to know where a + * method's locals live. Nothing counted it because every value was a valid + * scope hash. Found by an @type-over-assignment resolving from the wrong + * scope inside a function body. + * + * The builder's internal index held the answer under `${kind}:${nodeKey}`; + * this is the same index keyed on the node alone, because a caller holding a + * node does not know which kind of scope it opened. The internal one is no + * longer exported: two public spellings of "the scope this node opens" is the + * trap this rename exists to close, and nothing outside the builder read it. + */ + readonly scopeOpenedBy: ReadonlyMap; + /** + * The innermost scope ENCLOSING each node, for every node the binder visited. + * + * ## Named as the opposite of `scopeOpenedBy`, because the old pair invited + * the biggest defect this front end has had + * + * It was `scopeOfNode`, beside `scopeByNode` — two accessors differing by one + * preposition, with opposite meanings, and the declaration walk read this one + * as "the scope the node opens" in four places. Every method's body scope was + * its enclosing scope on every row since the first commit. A fix that left + * the trap in place would have been the .gitignore-trailing-slash shape: the + * next reader, moving fast, would have made the same substitution. + * + * So the pair now reads as two sentences that cannot be swapped: + * `enclosingScopeOf(node)` — the scope the node sits IN; + * `scopeOpenedBy(node)` — the scope the node OPENS. A function declaration + * has both, and they are different scopes. + * + * Built during the walk rather than by comparing positions afterwards. + * Position comparison picks the wrong scope whenever two of them begin at the + * same offset, which in JavaScript is constant: an IIFE's parenthesis, its + * function and its call all start together. + */ + readonly enclosingScopeOf: ReadonlyMap; + /** Every binding, in declaration order, for `js_variable`. */ + readonly bindings: readonly JsBinding[]; +} + +export function buildScopes(options: ScopeBuildOptions): ScopeBuildResult { + const builder = new JsScopeBuilder(options); + return builder.build(); +} + +class JsScopeBuilder { + private readonly sourceFile: ts.SourceFile; + private readonly options: ScopeBuildOptions; + private readonly scopes: JsScopeNode[] = []; + private readonly scopeByNode = new Map(); + private readonly enclosingScopeOf = new Map(); + private readonly bindings: JsBinding[] = []; + private globalScope!: JsScopeNode; + private moduleScope!: JsScopeNode; + + constructor(options: ScopeBuildOptions) { + this.options = options; + this.sourceFile = options.sourceFile; + } + + build(): ScopeBuildResult { + // The root pair. GLOBAL is the only scope with no parent, and MODULE is its + // only child — see JsScopeKind.GLOBAL for why the ambient scope is a row + // rather than an absence. + this.globalScope = this.openScope({ + node: this.sourceFile, + kind: JsScopeKind.GLOBAL, + parent: null, + isFunctionScope: true, + bindsThis: true, + bindsArguments: false, + isStrictMode: false, + strictModeSource: JsStrictModeSource.SLOPPY, + ownerNode: null, + }); + + // An ES module is strict with no opt-out. A CommonJS file is sloppy unless a + // directive says otherwise, and `hasEsmSyntax` is consulted too because a + // file with top-level `import` is an ES module by syntax even where its + // `package.json` says CommonJS — that is the 6.2% contradiction class, and + // those files really are strict when a bundler runs them. + const esm = this.options.moduleSystem === JsModuleSystem.ESM + || this.options.hasEsmSyntax; + const directive = hasUseStrictDirective(this.sourceFile.statements); + this.moduleScope = this.openScope({ + node: this.sourceFile, + kind: JsScopeKind.MODULE, + parent: this.globalScope, + isFunctionScope: true, + bindsThis: true, + bindsArguments: false, + isStrictMode: esm || directive, + strictModeSource: esm + ? JsStrictModeSource.ESM_IMPLICIT + : directive + ? JsStrictModeSource.USE_STRICT_DIRECTIVE + : JsStrictModeSource.SLOPPY, + ownerNode: null, + }); + + for (const statement of this.sourceFile.statements) { + this.visit(statement, this.moduleScope); + } + + this.bindImplicitGlobals(); + + const scopeOpenedBy = new Map(); + for (const scope of this.scopes) { + if (scope.ownerNode !== null) { + scopeOpenedBy.set(nodeKey(scope.ownerNode), scope); + } + } + return { + globalScope: this.globalScope, + moduleScope: this.moduleScope, + scopes: this.scopes, + enclosingScopeOf: this.enclosingScopeOf, + scopeOpenedBy, + bindings: this.bindings, + }; + } + + // ------------------------------------------------------------------------- + // scope construction + // ------------------------------------------------------------------------- + + private openScope(init: { + node: ts.Node; + kind: JsScopeKind; + parent: JsScopeNode | null; + isFunctionScope: boolean; + bindsThis: boolean; + bindsArguments: boolean; + isStrictMode: boolean; + strictModeSource: JsStrictModeSource; + ownerNode: ts.Node | null; + }): JsScopeNode { + const at = pointOf(init.node, this.sourceFile); + const scope: JsScopeNode = { + key: `${init.kind}:${nodeKey(init.node)}`, + kind: init.kind, + parent: init.parent, + depth: init.parent === null ? 0 : init.parent.depth + 1, + isFunctionScope: init.isFunctionScope, + bindsThis: init.bindsThis, + bindsArguments: init.bindsArguments, + isStrictMode: init.isStrictMode, + strictModeSource: init.strictModeSource, + // Inherited, so a scope opened anywhere inside a `with` body carries it. + // See visitWithStatement for why this direction and not the other. + hasWithStatement: init.parent?.hasWithStatement ?? false, + startLine: at.startLine, + startColumn: at.startColumn, + children: [], + bindings: new Map(), + ownerNode: init.ownerNode, + }; + init.parent?.children.push(scope); + this.scopes.push(scope); + this.scopeByNode.set(scope.key, scope); + return scope; + } + + /** + * Strictness is inherited unless something in this scope overrides it. + * + * Three overrides, in order of authority: a class body is strict + * unconditionally; a `'use strict'` directive at the top of a function body + * makes that function and everything in it strict; and otherwise the parent's + * answer stands. Strictness only ever goes one way — there is no directive + * that turns it off — which is what makes inheritance the right default. + */ + private strictnessFor( + parent: JsScopeNode, + kind: JsScopeKind, + body: ts.Node | undefined + ): { isStrictMode: boolean; strictModeSource: JsStrictModeSource } { + if (kind === JsScopeKind.CLASS || kind === JsScopeKind.CLASS_STATIC_BLOCK) { + return { + isStrictMode: true, + strictModeSource: parent.isStrictMode + ? parent.strictModeSource + : JsStrictModeSource.CLASS_BODY_IMPLICIT, + }; + } + if (!parent.isStrictMode && body !== undefined && ts.isBlock(body) + && hasUseStrictDirective(body.statements)) { + return { + isStrictMode: true, + strictModeSource: JsStrictModeSource.USE_STRICT_DIRECTIVE, + }; + } + return { + isStrictMode: parent.isStrictMode, + strictModeSource: parent.strictModeSource, + }; + } + + // ------------------------------------------------------------------------- + // pass 1 — the walk + // ------------------------------------------------------------------------- + + /** + * Visits one node in `scope`, opening a child scope where the node calls for + * one. + * + * Every branch is braced. §6 of `BUILDING-A-PARSER.md`: a dangling `else` in + * dispatch-heavy extractor code is nearly invisible and silently doubles or + * drops output, and this function is the densest dispatch in the front end. + */ + private visit(node: ts.Node, scope: JsScopeNode): void { + this.enclosingScopeOf.set(nodeKey(node), scope); + + if (ts.isFunctionDeclaration(node)) { + this.visitFunctionDeclaration(node, scope); + return; + } + if (ts.isFunctionExpression(node) || ts.isArrowFunction(node)) { + this.visitFunctionLike(node, scope); + return; + } + if (ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) + || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) { + this.visitFunctionLike(node, scope); + return; + } + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + this.visitClass(node, scope); + return; + } + if (ts.isClassStaticBlockDeclaration(node)) { + this.visitStaticBlock(node, scope); + return; + } + if (ts.isVariableStatement(node)) { + this.visitVariableDeclarationList(node.declarationList, scope); + return; + } + if (ts.isBlock(node)) { + this.visitBlockScope(node, scope, node.statements); + return; + } + if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) { + this.visitForStatement(node, scope); + return; + } + if (ts.isCatchClause(node)) { + this.visitCatchClause(node, scope); + return; + } + if (ts.isCaseBlock(node)) { + // A `switch` body is ONE block scope shared by every clause, not one per + // clause: `case 1: let x = 1; case 2: x;` is legal and refers to the same + // binding. Emitting a scope per clause would make the second reference + // unresolved. + const child = this.openBlockScope(node, scope); + for (const clause of node.clauses) { + for (const statement of clause.statements) { + this.visit(statement, child); + } + } + return; + } + if (ts.isWithStatement(node)) { + this.visitWithStatement(node, scope); + return; + } + if (ts.isImportDeclaration(node)) { + this.visitImportDeclaration(node, scope); + return; + } + // Everything else: descend, staying in this scope. The descent is + // unconditional on purpose — a subtree rooted at a node that opens no scope + // still contains declarations, and §6's parenthesis and JSX-brace failures + // were both a subtree dying before its children were enqueued. + ts.forEachChild(node, (child) => { + this.visit(child, scope); + }); + } + + /** + * `function f() {}` — the name hoists, and where to depends on strict mode. + * + * In strict mode a function declaration in a block is block-scoped. In sloppy + * mode Annex B's web-compatibility semantics *also* bind the name as a `var` + * in the enclosing function scope, which is why real pre-ES6 code calls one + * from outside the `if` that declares it and works. + * + * Modelled as: the declaration scope is the current scope when strict, and the + * nearest function scope when sloppy. At function or module level the two are + * the same scope and the distinction does not arise — which is 5,271 of the + * measured declarations. + */ + private visitFunctionDeclaration(node: ts.FunctionDeclaration, scope: JsScopeNode): void { + if (node.name !== undefined) { + this.declare({ + name: node.name.text, + regime: JsBindingRegime.FUNCTION_DECLARATION_HOISTED, + declarationScope: scope.isStrictMode ? scope : nearestFunctionScope(scope), + syntacticScope: scope, + declarationNode: node.name, + hasTemporalDeadZone: false, + }); + } + this.visitFunctionLike(node, scope); + } + + /** + * Opens a function or arrow scope, binds its parameters, and descends. + * + * Three things happen in a deliberate order: + * + * 1. **A named function expression binds its own name inside itself.** + * `function g() { return g; }` assigned to `f` — `g` is visible in the body + * and nowhere else. Bound first so a parameter of the same name shadows it, + * which is what the runtime does. + * 2. **Parameters are bound in the function's own scope**, before the body, so + * a default expression can refer to an earlier parameter. + * 3. **The body is descended into.** Explicitly, because the worklist stopping + * at a function boundary is how `return function () { … }` emitted the + * function and nothing inside it. + */ + private visitFunctionLike( + node: ts.FunctionLikeDeclaration, + scope: JsScopeNode + ): void { + const isArrow = ts.isArrowFunction(node); + const kind = isArrow ? JsScopeKind.ARROW : JsScopeKind.FUNCTION; + const strictness = this.strictnessFor(scope, kind, node.body); + const child = this.openScope({ + node, + kind, + parent: scope, + isFunctionScope: true, + // An arrow binds neither `this` nor `arguments`; it inherits both from + // where it was written. That single fact is what makes `this` usable in a + // callback, and it is why ARROW is a scope kind rather than a flag. + bindsThis: !isArrow, + bindsArguments: !isArrow, + isStrictMode: strictness.isStrictMode, + strictModeSource: strictness.strictModeSource, + ownerNode: node, + }); + + if ((ts.isFunctionExpression(node)) && node.name !== undefined) { + this.declare({ + name: node.name.text, + regime: JsBindingRegime.FUNCTION_DECLARATION_HOISTED, + declarationScope: child, + syntacticScope: child, + declarationNode: node.name, + hasTemporalDeadZone: false, + }); + } + + for (const parameter of node.parameters) { + // The PARAMETER NODE sits in the function's own scope. Its name is + // bound there and its default is visited there, but the node itself was + // never keyed — so a walk up from anything inside it (a JSDoc + // `/** @type {import('./x').T} */ p`) fell through to the function + // node, whose enclosing scope is the OUTER one, and the import's owner + // scope named a scope its owner method does not own. 2 rows of 23,834, + // both `@type` on a parameter, found by js-corpus's link-meaning sweep. + this.enclosingScopeOf.set(nodeKey(parameter), child); + this.bindPattern(parameter.name, child, child, JsBindingRegime.PARAMETER, false); + if (parameter.initializer !== undefined) { + // Evaluated in the function's OWN scope, so `(a, b = a)` resolves. + this.visit(parameter.initializer, child); + } + } + // Decorators and computed member names sit OUTSIDE the function's scope — + // they are evaluated where the declaration is written. Visiting them in the + // child would make a name they reference resolve against the parameters, + // which is Python's trap 1 in its JavaScript form. + if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) + || ts.isSetAccessorDeclaration(node)) { + if (node.name !== undefined && ts.isComputedPropertyName(node.name)) { + this.visit(node.name.expression, scope); + } + } + if (node.body !== undefined) { + if (ts.isBlock(node.body)) { + // The body's statements go directly in the function scope: a function + // body is not a separate block scope, so `var` and `let` at the top of + // one are both function-scope-visible. + for (const statement of node.body.statements) { + this.visit(statement, child); + } + } else { + // A concise arrow body: `x => x * 2`. One expression, same scope. + this.visit(node.body, child); + } + } + } + + /** + * A class body: a scope, always strict, binding the class's own name. + * + * The heritage clause is evaluated in the ENCLOSING scope — `class X extends + * mixin(Y)` calls `mixin` before the class exists — so it is visited there and + * not in the child. + */ + private visitClass(node: ts.ClassLikeDeclaration, scope: JsScopeNode): void { + if (ts.isClassDeclaration(node) && node.name !== undefined) { + this.declare({ + name: node.name.text, + regime: JsBindingRegime.CLASS_TDZ, + declarationScope: scope, + syntacticScope: scope, + declarationNode: node.name, + hasTemporalDeadZone: true, + }); + } + for (const clause of node.heritageClauses ?? []) { + for (const type of clause.types) { + this.visit(type.expression, scope); + } + } + const strictness = this.strictnessFor(scope, JsScopeKind.CLASS, undefined); + const child = this.openScope({ + node, + kind: JsScopeKind.CLASS, + parent: scope, + isFunctionScope: false, + bindsThis: true, + bindsArguments: false, + isStrictMode: strictness.isStrictMode, + strictModeSource: strictness.strictModeSource, + ownerNode: node, + }); + // The class's own name is bound INSIDE the body too, so a method can reach + // the class even when the outer binding has been reassigned. This is a + // separate binding from the one above, in a separate scope, and both are + // real. + if (node.name !== undefined) { + this.declare({ + name: node.name.text, + regime: JsBindingRegime.CLASS_TDZ, + declarationScope: child, + syntacticScope: child, + declarationNode: node.name, + hasTemporalDeadZone: true, + }); + } + for (const member of node.members) { + this.visit(member, child); + } + } + + private visitStaticBlock(node: ts.ClassStaticBlockDeclaration, scope: JsScopeNode): void { + const strictness = this.strictnessFor(scope, JsScopeKind.CLASS_STATIC_BLOCK, node.body); + const child = this.openScope({ + node, + kind: JsScopeKind.CLASS_STATIC_BLOCK, + parent: scope, + isFunctionScope: true, + bindsThis: true, + bindsArguments: false, + isStrictMode: strictness.isStrictMode, + strictModeSource: strictness.strictModeSource, + ownerNode: node, + }); + for (const statement of node.body.statements) { + this.visit(statement, child); + } + } + + private openBlockScope(node: ts.Node, scope: JsScopeNode): JsScopeNode { + const strictness = this.strictnessFor(scope, JsScopeKind.BLOCK, undefined); + return this.openScope({ + node, + kind: JsScopeKind.BLOCK, + parent: scope, + isFunctionScope: false, + bindsThis: scope.bindsThis, + bindsArguments: scope.bindsArguments, + isStrictMode: strictness.isStrictMode, + strictModeSource: strictness.strictModeSource, + ownerNode: null, + }); + } + + private visitBlockScope( + node: ts.Node, + scope: JsScopeNode, + statements: ts.NodeArray + ): void { + const child = this.openBlockScope(node, scope); + for (const statement of statements) { + this.visit(statement, child); + } + } + + /** + * A loop head and its body are ONE scope. + * + * `for (let i = 0; i < n; i++) { … }` — `i` belongs to a scope the head and + * the body share, which is what makes each iteration's `i` a distinct binding + * that a closure can capture. Putting the head in the enclosing scope would + * make `i` visible after the loop; putting the body in a child of the head + * would work but adds a scope row for nothing. + * + * A `var` in the head still hoists out, because {@link declare} sends it to + * the nearest function scope — the head being a block scope does not change + * that, which is the point of having two scope pointers. + */ + private visitForStatement( + node: ts.ForStatement | ts.ForInStatement | ts.ForOfStatement, + scope: JsScopeNode + ): void { + const child = this.openBlockScope(node, scope); + if (ts.isForStatement(node)) { + if (node.initializer !== undefined) { + if (ts.isVariableDeclarationList(node.initializer)) { + this.visitVariableDeclarationList(node.initializer, child); + } else { + this.visit(node.initializer, child); + } + } + if (node.condition !== undefined) { + this.visit(node.condition, child); + } + if (node.incrementor !== undefined) { + this.visit(node.incrementor, child); + } + } else { + if (ts.isVariableDeclarationList(node.initializer)) { + this.visitVariableDeclarationList(node.initializer, child); + } else { + this.visit(node.initializer, child); + } + // The iterated expression is evaluated in the ENCLOSING scope — it runs + // once, before the loop variable exists. + this.visit(node.expression, scope); + } + this.visit(node.statement, child); + } + + /** + * `catch (e) { … }` — the parameter gets its own scope. + * + * Its own scope and not the block's, because `catch (e) { let e = 1; }` is a + * legal shadow: the parameter and the block binding are two names in two + * scopes. A single scope would make that a redeclaration. + */ + private visitCatchClause(node: ts.CatchClause, scope: JsScopeNode): void { + const strictness = this.strictnessFor(scope, JsScopeKind.CATCH, node.block); + const child = this.openScope({ + node, + kind: JsScopeKind.CATCH, + parent: scope, + isFunctionScope: false, + bindsThis: scope.bindsThis, + bindsArguments: scope.bindsArguments, + isStrictMode: strictness.isStrictMode, + strictModeSource: strictness.strictModeSource, + ownerNode: null, + }); + if (node.variableDeclaration !== undefined) { + this.bindPattern( + node.variableDeclaration.name, + child, + child, + JsBindingRegime.CATCH_PARAMETER, + false + ); + } + this.visitBlockScope(node.block, child, node.block.statements); + } + + /** + * `with (obj) { … }` — a scope in which no name is statically resolvable. + * + * ## The flag propagates DOWN, not up + * + * Every scope nested inside the body inherits it, because a name written three + * blocks deep inside a `with` is just as shadowable by the object as one + * written directly in it. Inheritance happens in {@link openScope}, so a scope + * created later in the walk gets it too — marking only the `with` scope itself + * would leave the body's own blocks reading as resolvable. + * + * It deliberately does **not** propagate up. An earlier version walked the + * ancestor chain, on the reasoning that a binding declared outside the `with` + * is unsafe *when referenced inside it*. That is true and it is not what the + * column says: the claim is about the scope, and a reference in the module + * scope *outside* the `with` body resolves perfectly well. Marking the module + * and global scopes turned a rare local defect into a file-wide one, which is + * over-claiming in the direction that discards good bindings. + */ + private visitWithStatement(node: ts.WithStatement, scope: JsScopeNode): void { + this.visit(node.expression, scope); + const strictness = this.strictnessFor(scope, JsScopeKind.WITH, undefined); + const child = this.openScope({ + node, + kind: JsScopeKind.WITH, + parent: scope, + isFunctionScope: false, + bindsThis: scope.bindsThis, + bindsArguments: scope.bindsArguments, + isStrictMode: strictness.isStrictMode, + strictModeSource: strictness.strictModeSource, + ownerNode: null, + }); + child.hasWithStatement = true; + this.visit(node.statement, child); + } + + /** + * `var` / `let` / `const`, including every name a pattern binds. + * + * The regime decides the declaration scope, and that is the only place in this + * file where the two scope columns diverge on purpose. + */ + private visitVariableDeclarationList( + list: ts.VariableDeclarationList, + scope: JsScopeNode + ): void { + const isLet = (list.flags & ts.NodeFlags.Let) !== 0; + const isConst = (list.flags & ts.NodeFlags.Const) !== 0; + const regime = isConst + ? JsBindingRegime.CONST_BLOCK_TDZ + : isLet + ? JsBindingRegime.LET_BLOCK_TDZ + : JsBindingRegime.VAR_FUNCTION_SCOPED_HOISTED; + // THE HOISTING LINE. A `var` is visible from the top of the nearest function + // scope whatever block it is written in; a `let` stops where it is written. + const declarationScope = regime === JsBindingRegime.VAR_FUNCTION_SCOPED_HOISTED + ? nearestFunctionScope(scope) + : scope; + for (const declaration of list.declarations) { + this.bindPattern( + declaration.name, + declarationScope, + scope, + regime, + regime !== JsBindingRegime.VAR_FUNCTION_SCOPED_HOISTED + ); + if (declaration.initializer !== undefined) { + this.visit(declaration.initializer, scope); + } + } + } + + /** + * `import { a as b } from 'x'` — every local name it binds. + * + * Module-scoped whatever block the declaration is in, because an `import` is + * only legal at the top level. The specifier itself is a module edge and is + * the import extractor's business; what happens here is the binding, so a + * later reference to `b` resolves to `IMPORTED` rather than reading as free. + */ + private visitImportDeclaration(node: ts.ImportDeclaration, scope: JsScopeNode): void { + const clause = node.importClause; + if (clause === undefined) { + // `import './side-effect.js'` binds nothing. The module edge is still + // real and is recorded by the import extractor. + return; + } + if (clause.name !== undefined) { + this.declareImport(clause.name, scope); + } + const bindings = clause.namedBindings; + if (bindings === undefined) { + return; + } + if (ts.isNamespaceImport(bindings)) { + this.declareImport(bindings.name, scope); + return; + } + for (const element of bindings.elements) { + this.declareImport(element.name, scope); + } + } + + private declareImport(name: ts.Identifier, scope: JsScopeNode): void { + this.declare({ + name: name.text, + regime: JsBindingRegime.IMPORT_BINDING, + declarationScope: this.moduleScope, + syntacticScope: scope, + declarationNode: name, + hasTemporalDeadZone: true, + }); + } + + /** + * Binds every name in a binding name, which may be a whole pattern. + * + * One call per *name*, so `const { a, b: [c] } = o` produces three bindings. + * The pattern itself is not a binding — it is the syntax that produces them — + * and `js_variable.bindingForm` plus `patternRootVariableLinkHash` are what + * keep the grouping visible. + */ + private bindPattern( + name: ts.BindingName, + declarationScope: JsScopeNode, + syntacticScope: JsScopeNode, + regime: JsBindingRegime, + hasTemporalDeadZone: boolean + ): void { + if (ts.isIdentifier(name)) { + this.declare({ + name: name.text, + regime, + declarationScope, + syntacticScope, + declarationNode: name, + hasTemporalDeadZone, + }); + return; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) { + // A hole in an array pattern: `const [, b] = xs`. Binds nothing, and + // still occupies a position, which the variable extractor records. + continue; + } + this.bindPattern( + element.name, + declarationScope, + syntacticScope, + regime, + hasTemporalDeadZone + ); + if (element.initializer !== undefined) { + this.visit(element.initializer, syntacticScope); + } + if (ts.isBindingElement(element) && element.propertyName !== undefined + && ts.isComputedPropertyName(element.propertyName)) { + this.visit(element.propertyName.expression, syntacticScope); + } + } + } + + private declare(init: { + name: string; + regime: JsBindingRegime; + declarationScope: JsScopeNode; + syntacticScope: JsScopeNode; + declarationNode: ts.Node | null; + hasTemporalDeadZone: boolean; + }): void { + const binding: JsBinding = { ...init, redeclarationCount: 0 }; + const kept = addBinding(binding); + if (kept === binding) { + this.bindings.push(binding); + } + } + + // ------------------------------------------------------------------------- + // pass 2 — the bindings nobody declared + // ------------------------------------------------------------------------- + + /** + * `GLOBAL_IMPLICIT`: a sloppy-mode assignment to a name with no declaration. + * + * ## Why this cannot be folded into pass 1 + * + * Whether `x = 1` declares anything depends on whether `x` is declared + * *anywhere* in the enclosing chain — including on a line below it, since a + * `var` hoists and a function declaration hoists with its body. So the + * question is unanswerable until pass 1 has finished, exactly as CPython's + * `analyze_block` is a separate top-down walk for the same structural reason. + * + * ## Why it is a binding and not a curiosity + * + * It is the one binding with **no declaration syntax anywhere**, and it is + * visible to other files: in sloppy mode `x = 1` creates a property on the + * global object. Parenting it at the module scope would assert it is + * module-local, which is the single thing it is not — so it lands in `GLOBAL`. + * + * In strict mode the same line throws, so nothing is bound and nothing is + * emitted. That asymmetry is why `js_scope.isStrictMode` is load-bearing + * rather than bookkeeping. + */ + private bindImplicitGlobals(): void { + const visit = (node: ts.Node): void => { + if (ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isIdentifier(node.left)) { + const scope = this.enclosingScopeOf.get(nodeKey(node)) + ?? this.enclosingScopeOf.get(nodeKey(node.left)); + if (scope !== undefined && !scope.isStrictMode) { + const name = node.left.text; + if (!this.isBoundAnywhere(scope, name)) { + this.declare({ + name, + regime: JsBindingRegime.GLOBAL_IMPLICIT, + declarationScope: this.globalScope, + syntacticScope: scope, + declarationNode: node.left, + hasTemporalDeadZone: false, + }); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(this.sourceFile, visit); + } + + private isBoundAnywhere(scope: JsScopeNode, name: string): boolean { + for (let current: JsScopeNode | null = scope; current !== null; + current = current.parent) { + if (current.bindings.has(name)) { + return true; + } + } + return false; + } +} + +/** + * A `'use strict'` directive at the top of a statement list. + * + * The directive prologue is the run of string-literal expression statements at + * the very start — so `'use asm'; 'use strict';` counts and + * `doThing(); 'use strict';` does not. Scanning the whole list would make a + * string used as a value turn a sloppy function strict, which changes whether + * every undeclared assignment in it is a binding or a throw. + */ +function hasUseStrictDirective(statements: ts.NodeArray): boolean { + for (const statement of statements) { + if (!ts.isExpressionStatement(statement) + || !ts.isStringLiteralLike(statement.expression)) { + return false; + } + if (statement.expression.text === 'use strict') { + return true; + } + } + return false; +} diff --git a/parser/src/parsers/javascript/extractors/js-scope-extractor.ts b/parser/src/parsers/javascript/extractors/js-scope-extractor.ts new file mode 100644 index 000000000..15e487158 --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-scope-extractor.ts @@ -0,0 +1,81 @@ +import { JsScopeRegistry } from '@/analysis-types/javascript/JsScopeRegistry'; +import { JsScopeNode } from '@/parsers/javascript/extractors/js-symbol-table'; +import { ScopeBuildResult } from '@/parsers/javascript/extractors/js-scope-builder'; + +/** + * Turns the binder's scope tree into `js_scope` rows. + * + * Separate from the builder on purpose: the builder answers *what are the + * scopes*, and every consumer inside the parser wants that answer as a tree it + * can walk. This file answers *what rows does the tree emit*, which nothing + * inside the parser cares about. Keeping them apart is what lets the declaration + * and expression passes resolve names against a scope without going through a + * hash. + * + * ## Emission order is creation order, and creation order is the walk + * + * `ScopeBuildResult.scopes` is in the order the builder opened them — a + * pre-order traversal of the source. That is deterministic for a given file and + * independent of hash values, which is what makes two runs byte-identical. + * Sorting by hash instead would also be deterministic and would scramble the + * file's structure in the output, making a diff unreadable for no gain. + * + * ## A parent's row always precedes its children's + * + * A consequence of pre-order, and worth relying on: a consumer streaming the + * relation can build the tree in one pass without buffering. + */ +export interface ScopeExtractionResult { + readonly scopes: readonly JsScopeRegistry[]; + /** The registry row for each scope, keyed by the builder's scope key. */ + readonly rowByScopeKey: ReadonlyMap; + /** Convenience: the hash of the scope a node sits in. */ + readonly hashOfScope: (scope: JsScopeNode) => string; +} + +export function extractScopes(options: { + readonly build: ScopeBuildResult; + readonly moduleHash: string; + readonly serviceVersionLinkHash: string; +}): ScopeExtractionResult { + const rowByScopeKey = new Map(); + const rows: JsScopeRegistry[] = []; + + for (const scope of options.build.scopes) { + const row = new JsScopeRegistry({ + scopeKind: scope.kind, + // The parent's row exists already: pre-order guarantees it, and a `""` + // here for anything but GLOBAL would be a broken tree rather than a + // missing optional value. + parentScopeLinkHash: scope.parent === null + ? '' + : rowByScopeKey.get(scope.parent.key)?.getHash() ?? '', + depth: scope.depth, + isFunctionScope: scope.isFunctionScope, + bindsThis: scope.bindsThis, + bindsArguments: scope.bindsArguments, + isStrictMode: scope.isStrictMode, + strictModeSource: scope.strictModeSource, + hasWithStatement: scope.hasWithStatement, + ownerModuleLinkHash: options.moduleHash, + startLine: scope.startLine, + startColumn: scope.startColumn, + serviceVersionLinkHash: options.serviceVersionLinkHash, + }); + // Counted from the scope's own table, which holds exactly the bindings whose + // DECLARATION scope this is. A `var` written in a block is counted in the + // enclosing function scope and not in the block — which is the whole point, + // and makes this column a check on the hoisting model rather than a tally of + // syntax. + row.setDeclaredBindingCount(scope.bindings.size); + rows.push(row); + rowByScopeKey.set(scope.key, row); + } + + return { + scopes: rows, + rowByScopeKey, + hashOfScope: (scope: JsScopeNode): string => + rowByScopeKey.get(scope.key)?.getHash() ?? '', + }; +} diff --git a/parser/src/parsers/javascript/extractors/js-symbol-table.ts b/parser/src/parsers/javascript/extractors/js-symbol-table.ts new file mode 100644 index 000000000..407894dc0 --- /dev/null +++ b/parser/src/parsers/javascript/extractors/js-symbol-table.ts @@ -0,0 +1,227 @@ +import * as ts from 'typescript'; + +import { JsScopeKind, JsStrictModeSource } from '@/enums/javascript/scopes'; +import { JsBindingRegime } from '@/enums/javascript/variables'; + +/** + * The binder's data model: a scope tree and a `(scope, name)` table. + * + * A deliberate port of `python-symbol-table.ts`, and the choice of model to port + * is the whole point. `ts-binder.ts` exists to compute **declaration-merge + * scopes** — TypeScript's problem, where one name legitimately denotes several + * declarations. JavaScript has no declaration merging, and has instead the + * problem CPython's symbol table solves: *which binding does this name refer + * to, given that where a name is visible is not where it is written.* + * + * ## Why two scope pointers per binding, and not one + * + * ```js + * function f() { + * if (cond) { + * var a = 1; // syntactic: the if-block. declaration: f's scope. + * let b = 2; // syntactic: the if-block. declaration: the if-block. + * } + * return a; // legal. `a` is visible here; `b` is not. + * } + * ``` + * + * The difference between those two columns **is** hoisting, and it is not + * recoverable from anything else in the fact base: an engine would have to + * re-implement JavaScript's scoping rules to derive one from the other. Emitting + * a single column called "scope" is §3 of `BUILDING-A-PARSER.md` — the parts are + * present and the structure is absent. + * + * ## State discipline + * + * **Nothing is stored on a `ts.Node`.** Every map here is keyed on + * {@link nodeKey}, which is `${kind}:${start}:${end}` — the byte RANGE, never the + * start offset, because a call and its callee share a start offset constantly + * and so do a function and its own body. Python learned the same rule from + * tree-sitter's evicting wrappers; the reason differs and the rule does not. + */ + +/** A lexical scope, during the binder pass. */ +export interface JsScopeNode { + /** `${kind}:${start}:${end}` of the node that opens this scope. */ + readonly key: string; + readonly kind: JsScopeKind; + readonly parent: JsScopeNode | null; + readonly depth: number; + /** `var` hoists to the nearest scope with this set; `let` stops at the nearest block. */ + readonly isFunctionScope: boolean; + /** False for `ARROW`. That is the whole of lexical `this`. */ + readonly bindsThis: boolean; + readonly bindsArguments: boolean; + isStrictMode: boolean; + strictModeSource: JsStrictModeSource; + /** `with` makes every name in the body statically unresolvable. */ + hasWithStatement: boolean; + readonly startLine: number; + readonly startColumn: number; + readonly children: JsScopeNode[]; + /** + * Bindings DECLARED here — that is, whose `declarationScope` is this scope. + * + * A `var` written in a block appears in its enclosing function scope's table + * and not in the block's, which is exactly what makes a lookup from inside the + * block find it and a lookup from a sibling block find it too. + */ + readonly bindings: Map; + /** The function-like node this scope belongs to, for the owner FK. */ + readonly ownerNode: ts.Node | null; +} + +/** One name bound in one scope. */ +export interface JsBinding { + readonly name: string; + readonly regime: JsBindingRegime; + /** Where the name is VISIBLE FROM. `js_variable.declarationScopeLinkHash`. */ + readonly declarationScope: JsScopeNode; + /** Where the declaration is WRITTEN. `js_variable.syntacticScopeLinkHash`. */ + readonly syntacticScope: JsScopeNode; + /** The identifier node that declares it; `null` for an implicit global. */ + readonly declarationNode: ts.Node | null; + readonly hasTemporalDeadZone: boolean; + /** + * First-wins among several declarations of one name. + * + * `var x` twice in one function is legal and is ONE binding. Recording the + * redeclaration count keeps that visible instead of silently discarding the + * later ones — and it is why this is not an array. + */ + redeclarationCount: number; +} + +/** + * Node identity: the byte RANGE, plus the kind. + * + * §2 of `BUILDING-A-PARSER.md`: *node identity is the byte range, not the start + * offset.* `${start}` alone collides constantly — a call and its callee, an IIFE's + * parenthesis and its function, a declaration and its own name. The kind is + * included because two nodes can share a full range too: a `ParenthesizedExpression` + * and nothing else, but also an `ExpressionStatement` and its expression when + * there is no semicolon. + */ +export function nodeKey(node: ts.Node): string { + return `${node.kind}:${node.getStart()}:${node.end}`; +} + +/** The nearest enclosing scope a `var` or hoisted function declaration lands in. */ +export function nearestFunctionScope(scope: JsScopeNode): JsScopeNode { + let current: JsScopeNode = scope; + while (!current.isFunctionScope && current.parent !== null) { + current = current.parent; + } + return current; +} + +/** + * Resolves a name from `scope` outward, returning the binding and where it came + * from. + * + * ## Why the answer has two parts + * + * `js_expression.bindingResolution` records *which scope* a name came from — + * `LOCAL`, `CLOSURE`, `MODULE`, `IMPORTED`, `GLOBAL_BUILTIN`, `UNRESOLVED_FREE` + * — because that is the binder's output and the engine's input. A name resolved + * in the current scope and the same name resolved three closures up are the same + * `js_variable` row and two very different facts about the code: the second one + * means the value outlives its frame. + * + * ## What this deliberately does NOT do + * + * It does not cross a module boundary. An imported name resolves to its + * `IMPORT_BINDING` in this module's scope and stops there; following the import + * to the declaring file is cross-file resolution, which is the engine's work. + */ +export interface NameResolution { + readonly binding: JsBinding; + /** How far up the scope chain it was found, in function-scope crossings. */ + readonly functionBoundariesCrossed: number; + readonly foundIn: JsScopeNode; +} + +export function resolveName( + scope: JsScopeNode, + name: string +): NameResolution | undefined { + let current: JsScopeNode | null = scope; + let crossed = 0; + while (current !== null) { + const binding = current.bindings.get(name); + if (binding !== undefined) { + return { binding, functionBoundariesCrossed: crossed, foundIn: current }; + } + if (current.isFunctionScope) { + crossed += 1; + } + current = current.parent; + } + return undefined; +} + +/** + * Adds a binding, first-wins. + * + * `var x = 1; var x = 2;` in one function is legal and declares ONE name. The + * first declaration wins the row and the second increments + * `redeclarationCount`, so the fact that it happened is kept rather than + * silently dropped — and so two `js_variable` rows are never minted for one + * binding, which would double the count rather than collide. + * + * A `var` is allowed to be redeclared over a function declaration and vice + * versa; a `let` over anything is a syntax error the runtime rejects, so if it + * appears the file does not run and recording the first is the honest answer. + */ +export function addBinding(binding: JsBinding): JsBinding { + const table = binding.declarationScope.bindings; + const existing = table.get(binding.name); + if (existing !== undefined) { + existing.redeclarationCount += 1; + return existing; + } + table.set(binding.name, binding); + return binding; +} + +/** + * The names JavaScript provides with no declaration anywhere. + * + * Used to separate `GLOBAL_BUILTIN` from `UNRESOLVED_FREE`, which are two + * different reports: the first says the target is in the `lib_*` population, and + * the second says the parser genuinely does not know. Conflating them would hide + * a real gap inside a category that is expected to be large. + * + * Deliberately **not** exhaustive, and deliberately not read from + * `lib.*.d.ts`. Reading the lib files would make this a function of an installed + * TypeScript rather than of the source, and the value it feeds is a coarse + * classification, not a resolution. The schema's own measurement is the reason + * it matters at all: 15.3-24.4% of oracle declines are calls whose receiver is a Node + * builtin with no ambient declarations, which is the `lib_*` population and not + * a parser gap. + */ +export const GLOBAL_BUILTIN_NAMES: ReadonlySet = new Set([ + // ECMAScript intrinsics. + 'Array', 'ArrayBuffer', 'BigInt', 'Boolean', 'DataView', 'Date', 'Error', + 'EvalError', 'FinalizationRegistry', 'Float32Array', 'Float64Array', 'Function', + 'Int8Array', 'Int16Array', 'Int32Array', 'Intl', 'JSON', 'Map', 'Math', + 'Number', 'Object', 'Promise', 'Proxy', 'RangeError', 'ReferenceError', + 'Reflect', 'RegExp', 'Set', 'SharedArrayBuffer', 'String', 'Symbol', + 'SyntaxError', 'TypeError', 'URIError', 'Uint8Array', 'Uint8ClampedArray', + 'Uint16Array', 'Uint32Array', 'WeakMap', 'WeakRef', 'WeakSet', + 'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent', 'escape', + 'eval', 'globalThis', 'isFinite', 'isNaN', 'parseFloat', 'parseInt', 'unescape', + 'Infinity', 'NaN', 'undefined', + // The Node and browser ambients that appear in almost every real file. These + // are the 15.3-24.4% class: present at runtime, described only by @types/node. + 'console', 'process', 'Buffer', 'setTimeout', 'clearTimeout', 'setInterval', + 'clearInterval', 'setImmediate', 'clearImmediate', 'queueMicrotask', + 'structuredClone', 'TextDecoder', 'TextEncoder', 'URL', 'URLSearchParams', + 'AbortController', 'AbortSignal', 'Event', 'EventTarget', 'fetch', 'Headers', + 'Request', 'Response', 'window', 'document', 'navigator', 'location', + 'localStorage', 'sessionStorage', 'performance', 'crypto', + // CommonJS's own ambients. `require` is here so a bare reference to it does + // not read as an unresolved free name; the CALL is a module edge, handled + // separately and never as an ordinary call site. + 'require', 'module', 'exports', '__dirname', '__filename', +]); diff --git a/parser/src/parsers/javascript/package-json-resolver.ts b/parser/src/parsers/javascript/package-json-resolver.ts new file mode 100644 index 000000000..69b7299ef --- /dev/null +++ b/parser/src/parsers/javascript/package-json-resolver.ts @@ -0,0 +1,241 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { JsModuleSystem, JsModuleSystemSource } from '@/enums/javascript/modules'; +import { jsExtensionOf } from '@/utils/javascript'; + +/** + * Which `package.json` GOVERNS a `.js` file, and therefore whether it is + * CommonJS or ESM. + * + * ## Why this is the first thing built, and built once + * + * `js_module.moduleSystem` is **in the primary key**, so this answer propagates + * into every child hash in the fact base. A wrong answer does not produce a + * wrong column — it partitions two identical programs into two incomparable + * fact sets, or merges two different ones. There is no later pass that can + * correct it. + * + * ## This is §9 of `BUILDING-A-PARSER.md` in its nastier form + * + * *Read the governing config per file, never one config per run.* TypeScript + * has the same rule for `tsconfig.json`, but JavaScript's version is worse in + * three specific ways: + * + * 1. **The governing file is frequently not in the repository.** A `.js` file + * inside a dependency is governed by that dependency's own `package.json`, + * and a loose script directory is governed by nothing at all — 15.4% of the + * measured corpus has no `package.json` anywhere up the tree. + * 2. **There is no `include`/`exclude`.** Nearest ancestor wins, full stop. A + * `package.json` cannot disown a file, so unlike `TsConfigResolver` the walk + * stops at the first one found rather than continuing past a config that + * does not claim the file. + * 3. **`.mjs` and `.cjs` override it outright.** They are not hints and they do + * not merely win a tie: the extension *is* the declaration, and no + * `package.json` can contradict it. So the extension check happens before + * the walk, not after it. + * + * ## What "nearest ancestor" means precisely + * + * Node's rule, which this implements: walk from the file's own directory + * upward, and the **first** directory containing a `package.json` decides. + * Absence of a `"type"` field in that file is a real answer — the spec's default + * is CommonJS — and it is recorded distinctly from an explicit + * `"type": "commonjs"` (`PKG_TYPE_ABSENT_DEFAULT` versus + * `PKG_TYPE_COMMONJS`), because 76.0% of files land on the former and they are + * not making the same claim. + * + * ## No Program, no network, no install + * + * Reading a `package.json` is `fs.readFileSync` and `JSON.parse`. Nothing here + * resolves a module, typechecks anything, or needs `node_modules` to exist. + * + * ## Caching discipline + * + * Two caches, both keyed on **absolute directory paths** — never on a node, a + * file object, or anything with an identity that can be recycled. The + * per-directory cache is what makes this O(depth) once rather than O(files × + * depth): a 1,045-file package shares one answer for its whole subtree. + */ +export interface GoverningPackageJson { + /** + * Absolute path of the deciding `package.json`; `""` when none was found. + * + * Recorded in `js_module.governingPackageJsonPath` as *evidence*, and + * deliberately kept out of the module's primary key: evidence moving without + * the conclusion moving must not cascade every child hash. + */ + readonly packageJsonPath: string; + readonly moduleSystem: JsModuleSystem; + readonly moduleSystemSource: JsModuleSystemSource; + /** `name` from the deciding `package.json`; `""` when absent or unnamed. */ + readonly packageName: string; +} + +/** What one `package.json` says, once parsed. */ +interface PackageJsonFacts { + readonly path: string; + /** `undefined` when the file has no `"type"` field — which is 76.0% of them. */ + readonly type: 'module' | 'commonjs' | undefined; + readonly name: string; +} + +export class PackageJsonResolver { + /** Parsed facts by absolute `package.json` path. `undefined` = unreadable. */ + private readonly parsed = new Map(); + + /** + * Nearest-ancestor answer by absolute DIRECTORY path. + * + * Keyed on the directory rather than the file because every file in a + * directory shares the answer, and the walk is the expensive part. + */ + private readonly byDirectory = new Map(); + + /** + * The module system in force FOR THIS FILE. + * + * @param absoluteFilePath absolute, normalised path of the `.js`/`.mjs`/`.cjs` file + */ + resolve(absoluteFilePath: string): GoverningPackageJson { + // The extension check is FIRST and it is total. `.mjs` is an ES module and + // `.cjs` is CommonJS whatever any config says, so consulting the config + // first and then overriding would be doing work whose answer is discarded — + // and would invite a future edit that lets the config win. + // `jsExtensionOf`, not `path.extname`. The override `.cjs` makes is TOTAL, + // and `path.extname('a.cjs.flow')` is `.flow` — so the one extension whose + // whole point is that no config can overrule it was being overruled by a + // suffix. Same trap that made the walker blind to Flow declaration files. + const extension = jsExtensionOf(path.basename(absoluteFilePath)); + if (extension === '.mjs' || extension === '.cjs') { + // The nearest package.json is still read, for `packageName` and for the + // evidence column. It just does not decide the module system. + const nearest = this.nearest(path.dirname(absoluteFilePath)); + return { + packageJsonPath: nearest?.path ?? '', + moduleSystem: extension === '.mjs' ? JsModuleSystem.ESM : JsModuleSystem.COMMONJS, + moduleSystemSource: extension === '.mjs' + ? JsModuleSystemSource.EXT_MJS + : JsModuleSystemSource.EXT_CJS, + packageName: nearest?.name ?? '', + }; + } + + const nearest = this.nearest(path.dirname(absoluteFilePath)); + if (nearest === undefined) { + return { + packageJsonPath: '', + moduleSystem: JsModuleSystem.COMMONJS, + moduleSystemSource: JsModuleSystemSource.NO_PACKAGE_JSON_DEFAULT, + packageName: '', + }; + } + if (nearest.type === 'module') { + return { + packageJsonPath: nearest.path, + moduleSystem: JsModuleSystem.ESM, + moduleSystemSource: JsModuleSystemSource.PKG_TYPE_MODULE, + packageName: nearest.name, + }; + } + if (nearest.type === 'commonjs') { + return { + packageJsonPath: nearest.path, + moduleSystem: JsModuleSystem.COMMONJS, + moduleSystemSource: JsModuleSystemSource.PKG_TYPE_COMMONJS, + packageName: nearest.name, + }; + } + // A package.json with no `"type"`. CommonJS by the spec's default — a real + // answer, recorded as a DEFAULT so it is never mistaken for a declaration. + return { + packageJsonPath: nearest.path, + moduleSystem: JsModuleSystem.COMMONJS, + moduleSystemSource: JsModuleSystemSource.PKG_TYPE_ABSENT_DEFAULT, + packageName: nearest.name, + }; + } + + /** + * Walks up from `directory` to the filesystem root, first `package.json` wins. + * + * Unlike `TsConfigResolver.resolve`, a found config never "disowns" the file, + * so there is no reason to continue the walk past it. That is Node's rule and + * it is the rule the runtime actually applies. + * + * Every directory visited is memoised with the answer, not just the starting + * one, so a deep tree costs one walk in total rather than one per level. + */ + private nearest(directory: string): PackageJsonFacts | undefined { + const cached = this.byDirectory.get(directory); + if (cached !== undefined || this.byDirectory.has(directory)) { + return cached; + } + + const visited: string[] = []; + let current = directory; + let answer: PackageJsonFacts | undefined; + for (;;) { + const seen = this.byDirectory.has(current); + if (seen) { + answer = this.byDirectory.get(current); + break; + } + visited.push(current); + const candidate = path.join(current, 'package.json'); + const facts = this.read(candidate); + if (facts !== undefined) { + answer = facts; + break; + } + const parent = path.dirname(current); + if (parent === current) { + // Filesystem root reached with nothing found. `undefined` is the + // answer, and it is a legitimate one: 15.4% of real files have no + // governing package.json at all. + answer = undefined; + break; + } + current = parent; + } + for (const seenDirectory of visited) { + this.byDirectory.set(seenDirectory, answer); + } + return answer; + } + + /** + * Reads and parses one `package.json`, or returns `undefined`. + * + * A malformed `package.json` is treated as absent rather than as an error. + * The alternative — failing the file — would make one unparseable config in a + * vendored directory abort analysis of a tree that Node itself would happily + * run, since Node only reads the field it needs. + * + * `"type"` is only honoured when it is exactly `"module"` or `"commonjs"`. + * Anything else is not a value Node recognises, so it is treated as absent, + * which is what Node does. + */ + private read(packageJsonPath: string): PackageJsonFacts | undefined { + if (this.parsed.has(packageJsonPath)) { + return this.parsed.get(packageJsonPath); + } + let facts: PackageJsonFacts | undefined; + try { + const raw = fs.readFileSync(packageJsonPath, 'utf-8'); + const json = JSON.parse(raw) as { type?: unknown; name?: unknown }; + const declared = json.type === 'module' || json.type === 'commonjs' + ? json.type + : undefined; + facts = { + path: packageJsonPath, + type: declared, + name: typeof json.name === 'string' ? json.name : '', + }; + } catch { + facts = undefined; + } + this.parsed.set(packageJsonPath, facts); + return facts; + } +} diff --git a/parser/src/parsers/language-parser.ts b/parser/src/parsers/language-parser.ts new file mode 100644 index 000000000..eeb26becc --- /dev/null +++ b/parser/src/parsers/language-parser.ts @@ -0,0 +1,40 @@ +import Parser from 'tree-sitter'; + +import { ProjectLanguage } from '@/types/ProjectInfo'; + +/** + * Generic interface for language-specific tree-sitter parsers + */ +export interface LanguageParser { + /** + * The programming language this parser handles + */ + readonly language: ProjectLanguage; + + /** + * File extension this parser handles (e.g., '.java', '.py') + */ + readonly fileExtension: string; + + /** + * Parses source code into a syntax tree + * @param sourceCode Source code as string + * @returns Parsed syntax tree + */ + parse(sourceCode: string): Parser.Tree; + + /** + * Gets the root node of a parsed tree + * @param tree Parsed syntax tree + * @returns Root syntax node + */ + getRootNode(tree: Parser.Tree): Parser.SyntaxNode; + + /** + * Queries the syntax tree using tree-sitter query syntax + * @param node Starting node for the query + * @param queryString Tree-sitter query string + * @returns Query matches + */ + query(node: Parser.SyntaxNode, queryString: string): Parser.QueryMatch[]; +} diff --git a/parser/src/parsers/parser-factory.ts b/parser/src/parsers/parser-factory.ts new file mode 100644 index 000000000..27b2908df --- /dev/null +++ b/parser/src/parsers/parser-factory.ts @@ -0,0 +1,73 @@ +import { JavaParser } from '@/parsers/java/java-parser'; +import { LanguageParser } from '@/parsers/language-parser'; +import { PythonParser } from '@/parsers/python/python-parser'; +import { ProjectLanguage } from '@/types/ProjectInfo'; + +/** + * Factory for creating language-specific parsers + * Manages parser instances and delegates to the appropriate parser + */ +export class ParserFactory { + private parsers: Map; + + constructor() { + this.parsers = new Map(); + this.registerDefaultParsers(); + } + + /** + * Registers default parsers for supported languages + */ + private registerDefaultParsers(): void { + this.registerParser(new JavaParser()); + this.registerParser(new PythonParser()); + } + + /** + * Registers a language parser + * @param parser Language parser to register + */ + registerParser(parser: LanguageParser): void { + this.parsers.set(parser.language, parser); + } + + /** + * Gets a parser for the specified language + * @param language Programming language + * @returns Language parser or undefined if not supported + */ + getParser(language: ProjectLanguage): LanguageParser | undefined { + return this.parsers.get(language); + } + + /** + * Gets a parser by file extension + * @param fileExtension File extension (e.g., '.java') + * @returns Language parser or undefined if not supported + */ + getParserByExtension(fileExtension: string): LanguageParser | undefined { + for (const parser of this.parsers.values()) { + if (parser.fileExtension === fileExtension) { + return parser; + } + } + return undefined; + } + + /** + * Checks if a language is supported + * @param language Programming language + * @returns True if language is supported + */ + isLanguageSupported(language: ProjectLanguage): boolean { + return this.parsers.has(language); + } + + /** + * Gets all supported languages + * @returns Array of supported languages + */ + getSupportedLanguages(): ProjectLanguage[] { + return Array.from(this.parsers.keys()); + } +} diff --git a/parser/src/parsers/properties/properties-parser.ts b/parser/src/parsers/properties/properties-parser.ts new file mode 100644 index 000000000..7a58475ef --- /dev/null +++ b/parser/src/parsers/properties/properties-parser.ts @@ -0,0 +1,638 @@ +import { PropertyKey } from '@/analysis-types/properties/PropertyKey'; +import { PropertyValueSegment } from '@/analysis-types/properties/PropertyValueSegment'; +import { PropertyDelimiter } from '@/enums/properties/PropertyDelimiter'; +import { PropertyValueSegmentType } from '@/enums/properties/PropertyValueSegmentType'; + +/** + * Parsed result from a single property line (may span multiple lines with \ continuation). + */ +interface ParsedProperty { + key: string; + rawKey: string; + value: string; + delimiter: PropertyDelimiter; + hasValue: boolean; + isMultiLineKey: boolean; + isMultiLineValue: boolean; + startLine: number; + endLine: number; + keyStartCol: number; + keyEndCol: number; + valueStartCol: number; + valueEndCol: number; +} + +/** + * Parser for Java .properties files. + * + * Handles all standard .properties syntax: + * - `=`, `:`, and whitespace delimiters + * - `#` and `!` comments + * - `\` line continuations for both keys and values + * - Unicode escapes (`\uXXXX`) + * - Escaped special characters (`\=`, `\:`, `\!`, `\#`, `\\`, `\n`, `\t`, `\r`) + * - No-value keys (value defaults to empty string) + * - Leading whitespace stripping on keys + * - Leading whitespace stripping on continuation lines + * + * Two-pass approach: + * 1. Parse all keys to build a set of known property names + * 2. Parse all values, classifying ${...} references as PROPERTY_REFERENCE + * when the name matches a known key, or ENV_VARIABLE otherwise + */ +export class PropertiesParser { + private knownKeys: Set = new Set(); + + /** + * Parses a .properties file and returns PropertyKey and PropertyValueSegment entities. + * + * @param content File content + * @param filePath Absolute path to the file + * @param baseMservPath Project root path + * @param serviceVersionLinkHash Service version hash + * @returns Tuple of [PropertyKey[], PropertyValueSegment[]] + */ + parse( + content: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): [PropertyKey[], PropertyValueSegment[]] { + const lines = content.split('\n'); + + // Pass 1: Parse all properties to collect known keys + const parsedProperties = this.parseAllProperties(lines); + this.knownKeys = new Set(parsedProperties.map(p => p.key)); + + // Pass 2: Build PropertyKey and PropertyValueSegment entities + const propertyKeys: PropertyKey[] = []; + const valueSegments: PropertyValueSegment[] = []; + + for (const parsed of parsedProperties) { + const propertyKey = PropertyKey.builder( + parsed.key, + filePath, + baseMservPath, + parsed.startLine, + parsed.endLine, + parsed.keyStartCol, + parsed.keyEndCol, + parsed.delimiter, + serviceVersionLinkHash + ) + .withRawKey(parsed.rawKey) + .withHasValue(parsed.hasValue) + .withIsMultiLineKey(parsed.isMultiLineKey) + .withIsMultiLineValue(parsed.isMultiLineValue) + .build(); + + propertyKeys.push(propertyKey); + + // Parse value segments + if (!parsed.hasValue || parsed.value.length === 0) { + // EMPTY segment + const segment = PropertyValueSegment.builder( + '', + PropertyValueSegmentType.EMPTY, + 0, + 0, + propertyKey.getHash(), + parsed.startLine, + parsed.endLine, + parsed.valueStartCol, + parsed.valueEndCol + ).build(); + valueSegments.push(segment); + } else { + const segments = this.parseValueSegments( + parsed.value, + propertyKey.getHash(), + parsed.startLine, + parsed.valueStartCol, + 0, // depth + '' // no parent + ); + valueSegments.push(...segments); + } + } + + return [propertyKeys, valueSegments]; + } + + /** + * Parse all property lines from the file content, handling comments, + * blank lines, and line continuations. + */ + private parseAllProperties(lines: string[]): ParsedProperty[] { + const properties: ParsedProperty[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]!; + const trimmed = line.trimStart(); + + // Skip blank lines and comments + if (trimmed.length === 0 || trimmed.startsWith('#') || trimmed.startsWith('!')) { + i++; + continue; + } + + // Collect the full logical line (handling \ continuations) + const startLine = i + 1; // 1-indexed + let fullLine = line; + let endLine = startLine; + + while (this.endsWithContinuation(fullLine) && i + 1 < lines.length) { + // Remove trailing backslash + fullLine = fullLine.substring(0, fullLine.length - 1); + i++; + endLine = i + 1; + // Continuation lines: leading whitespace is stripped + fullLine += lines[i]!.trimStart(); + } + + // Parse the logical line into key, delimiter, value + const parsed = this.parseLogicalLine(fullLine, line, startLine, endLine); + if (parsed) { + properties.push(parsed); + } + + i++; + } + + return properties; + } + + /** + * Check if a line ends with an odd number of backslashes (indicating continuation). + */ + private endsWithContinuation(line: string): boolean { + let count = 0; + for (let i = line.length - 1; i >= 0; i--) { + if (line[i] === '\\') { + count++; + } else { + break; + } + } + // Odd number of trailing backslashes means continuation + return count > 0 && count % 2 === 1; + } + + /** + * Parse a single logical line (after continuation joining) into its key, delimiter, and value. + */ + private parseLogicalLine( + logicalLine: string, + _originalFirstLine: string, + startLine: number, + endLine: number + ): ParsedProperty | null { + // Skip leading whitespace to find the key start + let pos = 0; + while (pos < logicalLine.length && this.isWhitespace(logicalLine[pos]!)) { + pos++; + } + + if (pos >= logicalLine.length) { + return null; // Empty line after trimming + } + + const keyStartCol = pos; + + // Parse the key (may contain escaped characters) + const keyResult = this.parseKey(logicalLine, pos); + const rawKey = logicalLine.substring(keyStartCol, keyResult.endPos); + const resolvedKey = keyResult.key; + const keyEndCol = keyResult.endPos; + const isMultiLineKey = startLine !== endLine && keyResult.endPos < logicalLine.length; + + pos = keyResult.endPos; + + // Skip whitespace between key and delimiter + while (pos < logicalLine.length && this.isWhitespace(logicalLine[pos]!)) { + pos++; + } + + // Determine delimiter + let delimiter: PropertyDelimiter; + let hasValue = false; + let valueStartCol = pos; + + if (pos >= logicalLine.length) { + // No delimiter, no value — standalone key + delimiter = PropertyDelimiter.NONE; + hasValue = false; + return { + key: resolvedKey, + rawKey: rawKey !== resolvedKey ? rawKey : resolvedKey, + value: '', + delimiter, + hasValue, + isMultiLineKey: false, + isMultiLineValue: false, + startLine, + endLine, + keyStartCol, + keyEndCol, + valueStartCol: keyEndCol, + valueEndCol: keyEndCol, + }; + } + + const ch = logicalLine[pos]!; + if (ch === '=') { + delimiter = PropertyDelimiter.EQUALS; + pos++; + } else if (ch === ':') { + delimiter = PropertyDelimiter.COLON; + pos++; + } else { + // The delimiter is whitespace (already consumed above) + delimiter = PropertyDelimiter.SPACE; + } + + // Skip whitespace after delimiter + while (pos < logicalLine.length && this.isWhitespace(logicalLine[pos]!)) { + pos++; + } + + valueStartCol = pos; + const value = logicalLine.substring(pos); + hasValue = true; + + const isMultiLineValue = startLine !== endLine; + const valueEndCol = logicalLine.length; + + return { + key: resolvedKey, + rawKey: rawKey !== resolvedKey ? rawKey : resolvedKey, + value, + delimiter, + hasValue, + isMultiLineKey: isMultiLineKey && startLine !== endLine, + isMultiLineValue, + startLine, + endLine, + keyStartCol, + keyEndCol, + valueStartCol, + valueEndCol, + }; + } + + /** + * Parse the key portion of a property line, handling escape sequences. + * Key ends at unescaped `=`, `:`, or whitespace. + */ + private parseKey(line: string, startPos: number): { key: string; endPos: number } { + let key = ''; + let pos = startPos; + + while (pos < line.length) { + const ch = line[pos]!; + + if (ch === '\\' && pos + 1 < line.length) { + const next = line[pos + 1]!; + if (next === 'u' && pos + 5 < line.length) { + // Unicode escape + const hex = line.substring(pos + 2, pos + 6); + const codePoint = parseInt(hex, 16); + if (!isNaN(codePoint)) { + key += String.fromCharCode(codePoint); + pos += 6; + continue; + } + } + // Escaped delimiter or special char in key + if (next === '=' || next === ':' || next === ' ' || next === '\\' || + next === 'n' || next === 't' || next === 'r') { + key += this.resolveEscape(next); + pos += 2; + continue; + } + // Unknown escape — keep as-is + key += ch; + pos++; + continue; + } + + // Unescaped delimiter or whitespace ends the key + if (ch === '=' || ch === ':' || this.isWhitespace(ch)) { + break; + } + + key += ch; + pos++; + } + + return { key, endPos: pos }; + } + + /** + * Resolve a single escape character to its actual value. + */ + private resolveEscape(ch: string): string { + switch (ch) { + case 'n': return '\n'; + case 't': return '\t'; + case 'r': return '\r'; + default: return ch; + } + } + + private isWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\f'; + } + + /** + * Parse a value string into segments, recursively handling nested ${...} references. + * + * @param value The value string to parse + * @param propertyKeyLinkHash Hash of the owning PropertyKey + * @param baseLine Line number where the value starts + * @param baseCol Column offset where the value starts + * @param depth Current nesting depth (0 = top-level) + * @param parentSegmentHash Hash of the parent segment (empty at depth 0) + * @returns Array of PropertyValueSegment entities + */ + parseValueSegments( + value: string, + propertyKeyLinkHash: string, + baseLine: number, + baseCol: number, + depth: number, + parentSegmentHash: string + ): PropertyValueSegment[] { + const segments: PropertyValueSegment[] = []; + let pos = 0; + let position = 0; // segment order within this level + let literalStart = 0; + + while (pos < value.length) { + // Check for SpEL expression: #{...} + if (value[pos] === '#' && pos + 1 < value.length && value[pos + 1] === '{') { + // Flush preceding literal + if (pos > literalStart) { + const litText = value.substring(literalStart, pos); + const seg = this.createLiteralSegment( + litText, position, depth, propertyKeyLinkHash, parentSegmentHash, + baseLine, baseCol + literalStart + ); + segments.push(seg); + position++; + } + + // Find matching closing brace + const spelStart = pos; + const spelEnd = this.findMatchingBrace(value, pos + 1); + const spelContent = value.substring(pos + 2, spelEnd); + + const spelSegment = PropertyValueSegment.builder( + spelContent, + PropertyValueSegmentType.SPEL_EXPRESSION, + position, + depth, + propertyKeyLinkHash, + baseLine, + baseLine, + baseCol + spelStart, + baseCol + spelEnd + 1 + ) + .withParentSegmentLinkHash(parentSegmentHash) + .build(); + segments.push(spelSegment); + + // Parse inner ${...} references within SpEL as children + const innerRefs = this.parseValueSegments( + spelContent, + propertyKeyLinkHash, + baseLine, + baseCol + spelStart + 2, + depth + 1, + spelSegment.getHash() + ); + // Only add child segments that are actual references (not the full SpEL literal) + const refChildren = innerRefs.filter( + s => s.getSegmentType() !== PropertyValueSegmentType.LITERAL + ); + segments.push(...refChildren); + + position++; + pos = spelEnd + 1; + literalStart = pos; + continue; + } + + // Check for ${...} placeholder + if (value[pos] === '$' && pos + 1 < value.length && value[pos + 1] === '{') { + // Flush preceding literal + if (pos > literalStart) { + const litText = value.substring(literalStart, pos); + const seg = this.createLiteralSegment( + litText, position, depth, propertyKeyLinkHash, parentSegmentHash, + baseLine, baseCol + literalStart + ); + segments.push(seg); + position++; + } + + // Find matching closing brace (handles nested ${...}) + const refStart = pos; + const refEnd = this.findMatchingBrace(value, pos + 1); + const refContent = value.substring(pos + 2, refEnd); + + // Parse the reference content: name[:default] + const refSegments = this.parseReference( + refContent, + position, + depth, + propertyKeyLinkHash, + parentSegmentHash, + baseLine, + baseCol + refStart, + baseCol + refEnd + 1 + ); + segments.push(...refSegments); + + position++; + pos = refEnd + 1; + literalStart = pos; + continue; + } + + pos++; + } + + // Flush trailing literal + if (literalStart < value.length) { + const litText = value.substring(literalStart); + const seg = this.createLiteralSegment( + litText, position, depth, propertyKeyLinkHash, parentSegmentHash, + baseLine, baseCol + literalStart + ); + segments.push(seg); + } + + return segments; + } + + /** + * Parse a reference content (inside ${...}) into segments. + * Handles: name, name:default, random.*, nested defaults like name:${other:val} + */ + private parseReference( + content: string, + position: number, + depth: number, + propertyKeyLinkHash: string, + parentSegmentHash: string, + baseLine: number, + startCol: number, + endCol: number + ): PropertyValueSegment[] { + const segments: PropertyValueSegment[] = []; + + // Find the first colon that's not inside a nested ${...} + const colonPos = this.findTopLevelColon(content); + + let name: string; + let defaultText: string | undefined; + + if (colonPos === -1) { + // No default: ${NAME} + name = content; + } else { + // Has default: ${NAME:default} + name = content.substring(0, colonPos); + defaultText = content.substring(colonPos + 1); + } + + // Determine segment type + let segmentType: PropertyValueSegmentType; + if (name.startsWith('random.')) { + segmentType = PropertyValueSegmentType.RANDOM; + } else if (defaultText !== undefined) { + if (this.knownKeys.has(name)) { + segmentType = PropertyValueSegmentType.PROPERTY_REF_WITH_DEFAULT; + } else { + segmentType = PropertyValueSegmentType.ENV_WITH_DEFAULT; + } + } else { + if (this.knownKeys.has(name)) { + segmentType = PropertyValueSegmentType.PROPERTY_REFERENCE; + } else { + segmentType = PropertyValueSegmentType.ENV_VARIABLE; + } + } + + // Determine the plain default value (without nested ${} resolved) + const plainDefault = defaultText !== undefined ? defaultText : ''; + + const refSegment = PropertyValueSegment.builder( + name, + segmentType, + position, + depth, + propertyKeyLinkHash, + baseLine, + baseLine, + startCol, + endCol + ) + .withDefaultValue(plainDefault) + .withParentSegmentLinkHash(parentSegmentHash) + .build(); + segments.push(refSegment); + + // If the default contains nested ${...} or #{...}, recursively parse children + if (defaultText !== undefined && (defaultText.includes('${') || defaultText.includes('#{'))) { + const childSegments = this.parseValueSegments( + defaultText, + propertyKeyLinkHash, + baseLine, + startCol + 2 + name.length + 1, // ${NAME: offset + depth + 1, + refSegment.getHash() + ); + segments.push(...childSegments); + } + + return segments; + } + + /** + * Find the position of the first colon at the top level (not inside nested ${...}). + */ + private findTopLevelColon(content: string): number { + let braceDepth = 0; + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + if (ch === '$' && i + 1 < content.length && content[i + 1] === '{') { + braceDepth++; + i++; // skip { + } else if (ch === '#' && i + 1 < content.length && content[i + 1] === '{') { + braceDepth++; + i++; // skip { + } else if (ch === '}') { + if (braceDepth > 0) braceDepth--; + } else if (ch === ':' && braceDepth === 0) { + return i; + } + } + return -1; + } + + /** + * Find the matching closing brace for an opening { at position pos. + * Handles nested ${...} and #{...} correctly. + */ + private findMatchingBrace(value: string, openBracePos: number): number { + let depth = 1; + let pos = openBracePos + 1; // skip past the opening { + + while (pos < value.length && depth > 0) { + const ch = value[pos]!; + if ((ch === '$' || ch === '#') && pos + 1 < value.length && value[pos + 1] === '{') { + depth++; + pos++; // skip past $ + } else if (ch === '}') { + depth--; + if (depth === 0) { + return pos; + } + } + pos++; + } + + // If no matching brace found, return end of string + return value.length - 1; + } + + /** + * Create a LITERAL segment. + */ + private createLiteralSegment( + text: string, + position: number, + depth: number, + propertyKeyLinkHash: string, + parentSegmentHash: string, + baseLine: number, + startCol: number + ): PropertyValueSegment { + return PropertyValueSegment.builder( + text, + PropertyValueSegmentType.LITERAL, + position, + depth, + propertyKeyLinkHash, + baseLine, + baseLine, + startCol, + startCol + text.length + ) + .withParentSegmentLinkHash(parentSegmentHash) + .build(); + } +} diff --git a/parser/src/parsers/python/extractors/index.ts b/parser/src/parsers/python/extractors/index.ts new file mode 100644 index 000000000..3cebbfb04 --- /dev/null +++ b/parser/src/parsers/python/extractors/index.ts @@ -0,0 +1,22 @@ +export { PythonDeclarationExtractor } from '@/parsers/python/extractors/python-declaration-extractor'; +export type { + PythonDeclarationExtraction, + PythonDeclarationInput, +} from '@/parsers/python/extractors/python-declaration-extractor'; +export { PythonExpressionExtractor } from '@/parsers/python/extractors/python-expression-extractor'; +export { PythonFactExtractor } from '@/parsers/python/extractors/python-fact-extractor'; +export type { PythonFactSet } from '@/parsers/python/extractors/python-fact-extractor'; +export { PythonScopeBuilder } from '@/parsers/python/extractors/python-scope-builder'; +export { PythonScopeExtractor } from '@/parsers/python/extractors/python-scope-extractor'; +export type { + PythonExtractionInput, + PythonModuleExtraction, +} from '@/parsers/python/extractors/python-scope-extractor'; +export { + analyzeSymbolTable, + createSymbolBlock, + DEF_BOUND, + isOptimized, + SymbolFlags, + SymbolScope, +} from '@/parsers/python/extractors/python-symbol-table'; diff --git a/parser/src/parsers/python/extractors/python-block-extractor.ts b/parser/src/parsers/python/extractors/python-block-extractor.ts new file mode 100644 index 000000000..bbbd3d552 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-block-extractor.ts @@ -0,0 +1,591 @@ +import Parser from 'tree-sitter'; + +import { PyBlockRegistry, PyMethodRegistry, PyModuleRegistry, PyTypeRegistry } from '@/analysis-types/python'; +import { PythonBlockKind } from '@/enums/python/blocks'; +import { PythonSourcePositions } from '@/utils/python/python-position-utils'; + +/** Everything the block stage produces for one module. */ +export interface PythonBlockExtraction { + blocks: PyBlockRegistry[]; + /** `py_block` PK -> the condition expression's `startIndex:endIndex`. */ + conditionRangeByBlock: Map; +} + +export interface PythonBlockInput { + module: PyModuleRegistry; + rootNode: Parser.SyntaxNode; + filePath: string; + serviceVersionLinkHash: string; + types: PyTypeRegistry[]; + methods: PyMethodRegistry[]; + typeHashByNodeId: Map; + methodHashByNodeId: Map; + classInitHashByNodeId: Map; + scopeHashByNodeId: Map; + moduleMethodHash: string; + positions: PythonSourcePositions; +} + +/** Lexical state threaded through the walk. */ +interface BlockContext { + methodOwnerHash: string; + pyTypeLinkHash: string; + ownerTypeName: string; + ownerQualifiedName: string; + ownerMethodName: string; + scopeHash: string; + parentHash: string; + depth: number; + isModuleLevel: boolean; +} + +/** + * Extracts `py_block` — the control-flow structure the engine needs for + * reachability and narrowing. + * + * Containment is by SPAN, not by a foreign key on every expression. That is how + * `java_block` works and the reason is the same: an expression is inside a block + * when its span is, and putting a block FK on the largest relation in the schema + * would cost a column per row to encode what the positions already state. + * + * The link that DOES exist runs the other way — `conditionExpressionLinkHash` + * points from the block at its test — because the test is one expression per + * block rather than one per row, and because narrowing is the thing this relation + * exists to enable: + * + * ```python + * if isinstance(x, Foo): + * x.method() # x is a Foo here and nowhere else + * ``` + * + * Python-specific care, each verified against the grammar rather than assumed: + * + * - `elif` is its OWN kind, not a nested `if`. tree-sitter models it as an + * `elif_clause` sibling, matching CPython's grammar, and flattening it into a + * nested `if` would inflate `nestingDepth` for every chain. + * - an `except`/`else`/`finally` carries `tryStatementHash` back to its `try`, + * because a handler is meaningless without knowing what it guards. + * - `if TYPE_CHECKING:` is flagged: its imports exist for a type checker and + * never execute, so a rule treating them as runtime imports is wrong about + * every one of them. + */ +export class PythonBlockExtractor { + private input!: PythonBlockInput; + private blocks: PyBlockRegistry[] = []; + private conditionRangeByBlock = new Map(); + private order = 0; + + extract(input: PythonBlockInput): PythonBlockExtraction { + this.input = input; + this.blocks = []; + this.conditionRangeByBlock = new Map(); + this.order = 0; + + const moduleBlock = this.emit( + input.rootNode, + PythonBlockKind.MODULE_BODY, + { + methodOwnerHash: input.moduleMethodHash, + pyTypeLinkHash: '', + ownerTypeName: '', + ownerQualifiedName: input.module.getQualifiedName(), + ownerMethodName: '', + scopeHash: input.scopeHashByNodeId.get(input.rootNode.id) ?? '', + parentHash: input.module.getHash(), + depth: 0, + isModuleLevel: true, + }, + null + ); + + this.walk(input.rootNode, { + methodOwnerHash: input.moduleMethodHash, + pyTypeLinkHash: '', + ownerTypeName: '', + ownerQualifiedName: input.module.getQualifiedName(), + ownerMethodName: '', + scopeHash: input.scopeHashByNodeId.get(input.rootNode.id) ?? '', + parentHash: moduleBlock.getHash(), + depth: 1, + isModuleLevel: true, + }); + + return { blocks: this.blocks, conditionRangeByBlock: this.conditionRangeByBlock }; + } + + private walk(node: Parser.SyntaxNode, context: BlockContext): void { + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (!child || child.isExtra) { + continue; + } + this.visit(child, context); + } + } + + private visit(node: Parser.SyntaxNode, context: BlockContext): void { + switch (node.type) { + case 'class_definition': { + this.visitClass(node, context); + return; + } + case 'function_definition': { + this.visitFunction(node, context); + return; + } + case 'decorated_definition': { + const definition = node.childForFieldName('definition'); + if (definition) { + this.visit(definition, context); + } + return; + } + case 'if_statement': { + this.visitIf(node, context); + return; + } + case 'while_statement': { + this.visitLoop(node, context, PythonBlockKind.WHILE, true); + return; + } + case 'for_statement': { + this.visitLoop( + node, + context, + this.isAsync(node) ? PythonBlockKind.ASYNC_FOR : PythonBlockKind.FOR, + false + ); + return; + } + case 'with_statement': { + this.visitWith(node, context); + return; + } + case 'try_statement': { + this.visitTry(node, context); + return; + } + case 'match_statement': { + this.visitMatch(node, context); + return; + } + default: { + // Any other statement can still CONTAIN a block, so the walk continues + // rather than stopping at nodes this switch does not name. + this.walk(node, context); + } + } + } + + private visitClass(node: Parser.SyntaxNode, context: BlockContext): void { + const body = node.childForFieldName('body'); + if (!body) { + return; + } + const typeHash = this.input.typeHashByNodeId.get(node.id) ?? ''; + const type = this.input.types.find(candidate => candidate.getHash() === typeHash); + const inner: BlockContext = { + ...context, + pyTypeLinkHash: typeHash, + ownerTypeName: type?.getName() ?? '', + ownerQualifiedName: type?.getQualifiedName() ?? context.ownerQualifiedName, + ownerMethodName: '', + methodOwnerHash: this.input.classInitHashByNodeId.get(node.id) ?? context.methodOwnerHash, + scopeHash: this.input.scopeHashByNodeId.get(node.id) ?? context.scopeHash, + isModuleLevel: false, + }; + const block = this.emit(body, PythonBlockKind.CLASS_BODY, inner, null); + this.walk(body, { ...inner, parentHash: block.getHash(), depth: inner.depth + 1 }); + } + + private visitFunction(node: Parser.SyntaxNode, context: BlockContext): void { + const body = node.childForFieldName('body'); + if (!body) { + return; + } + const methodHash = this.input.methodHashByNodeId.get(node.id) ?? context.methodOwnerHash; + const method = this.input.methods.find(candidate => candidate.getHash() === methodHash); + const inner: BlockContext = { + ...context, + methodOwnerHash: methodHash, + ownerMethodName: method?.getName() ?? node.childForFieldName('name')?.text ?? '', + scopeHash: this.input.scopeHashByNodeId.get(node.id) ?? context.scopeHash, + isModuleLevel: false, + }; + const block = this.emit(body, PythonBlockKind.FUNCTION_BODY, inner, null); + this.walk(body, { ...inner, parentHash: block.getHash(), depth: inner.depth + 1 }); + } + + /** + * `if` / `elif` / `else`. + * + * `elif` is emitted as its own block at the SAME depth as the `if`, because + * that is what CPython's grammar and tree-sitter both model — an + * `elif_clause` sibling, not a nested statement. Treating it as a nested `if` + * would make a five-branch chain report depth five. + */ + private visitIf(node: Parser.SyntaxNode, context: BlockContext): void { + const condition = node.childForFieldName('condition'); + const consequence = node.childForFieldName('consequence'); + let hasElse = false; + for (let index = 0; index < node.namedChildCount; index += 1) { + if (node.namedChild(index)?.type === 'else_clause') { + hasElse = true; + } + } + if (consequence) { + const block = this.emit(consequence, PythonBlockKind.IF, context, condition, hasElse); + this.walk(consequence, { + ...context, + parentHash: block.getHash(), + depth: context.depth + 1, + }); + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const clause = node.namedChild(index); + if (!clause) { + continue; + } + if (clause.type === 'elif_clause') { + const elifCondition = clause.childForFieldName('condition'); + const elifBody = clause.childForFieldName('consequence'); + if (elifBody) { + const block = this.emit(elifBody, PythonBlockKind.ELIF, context, elifCondition); + this.walk(elifBody, { + ...context, + parentHash: block.getHash(), + depth: context.depth + 1, + }); + } + continue; + } + if (clause.type === 'else_clause') { + const elseBody = clause.childForFieldName('body') ?? clause.namedChild(0); + if (elseBody) { + const block = this.emit(elseBody, PythonBlockKind.ELSE, context, null); + this.walk(elseBody, { + ...context, + parentHash: block.getHash(), + depth: context.depth + 1, + }); + } + } + } + } + + private visitLoop( + node: Parser.SyntaxNode, + context: BlockContext, + kind: PythonBlockKind, + conditionIsTest: boolean + ): void { + const body = node.childForFieldName('body'); + const condition = conditionIsTest ? node.childForFieldName('condition') : null; + let hasElse = false; + for (let index = 0; index < node.namedChildCount; index += 1) { + if (node.namedChild(index)?.type === 'else_clause') { + hasElse = true; + } + } + if (body) { + const block = this.emit(body, kind, context, condition, hasElse); + this.walk(body, { ...context, parentHash: block.getHash(), depth: context.depth + 1 }); + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const clause = node.namedChild(index); + if (clause?.type === 'else_clause') { + const elseBody = clause.childForFieldName('body') ?? clause.namedChild(0); + if (elseBody) { + const block = this.emit(elseBody, PythonBlockKind.ELSE, context, null); + this.walk(elseBody, { + ...context, + parentHash: block.getHash(), + depth: context.depth + 1, + }); + } + } + } + } + + private visitWith(node: Parser.SyntaxNode, context: BlockContext): void { + const body = node.childForFieldName('body'); + if (!body) { + return; + } + const kind = this.isAsync(node) ? PythonBlockKind.ASYNC_WITH : PythonBlockKind.WITH; + let resources = 0; + for (let index = 0; index < node.namedChildCount; index += 1) { + const clause = node.namedChild(index); + if (clause?.type === 'with_clause') { + for (let inner = 0; inner < clause.namedChildCount; inner += 1) { + if (clause.namedChild(inner)?.type === 'with_item') { + resources += 1; + } + } + } + } + const block = this.emit(body, kind, context, null, false, resources); + this.walk(body, { ...context, parentHash: block.getHash(), depth: context.depth + 1 }); + } + + /** + * `try` with its handlers. + * + * Every handler, `else` and `finally` carries `tryStatementHash` back to the + * `try` it belongs to. A handler read in isolation says nothing — what matters + * is which body it guards. + */ + private visitTry(node: Parser.SyntaxNode, context: BlockContext): void { + const body = node.childForFieldName('body'); + if (!body) { + return; + } + let hasElse = false; + for (let index = 0; index < node.namedChildCount; index += 1) { + if (node.namedChild(index)?.type === 'else_clause') { + hasElse = true; + } + } + const tryBlock = this.emit(body, PythonBlockKind.TRY, context, null, hasElse); + this.walk(body, { ...context, parentHash: tryBlock.getHash(), depth: context.depth + 1 }); + + for (let index = 0; index < node.namedChildCount; index += 1) { + const clause = node.namedChild(index); + if (!clause || clause.id === body.id) { + continue; + } + const isExcept = clause.type === 'except_clause'; + const isExceptStar = clause.type === 'except_group_clause'; + const isFinally = clause.type === 'finally_clause'; + const isElse = clause.type === 'else_clause'; + if (!isExcept && !isExceptStar && !isFinally && !isElse) { + continue; + } + const clauseBody = clause.childForFieldName('body') ?? this.lastBlockChild(clause); + if (!clauseBody) { + continue; + } + const kind = isExcept + ? PythonBlockKind.EXCEPT + : isExceptStar + ? PythonBlockKind.EXCEPT_STAR + : isFinally + ? PythonBlockKind.FINALLY + : PythonBlockKind.ELSE; + const handler = this.handlerDetail(clause, isExcept || isExceptStar); + const block = this.emit( + clauseBody, + kind, + context, + null, + false, + undefined, + tryBlock.getHash(), + handler + ); + this.walk(clauseBody, { + ...context, + parentHash: block.getHash(), + depth: context.depth + 1, + }); + } + } + + private visitMatch(node: Parser.SyntaxNode, context: BlockContext): void { + const subject = node.childForFieldName('subject'); + const body = this.lastBlockChild(node); + if (!body) { + return; + } + const matchBlock = this.emit(body, PythonBlockKind.MATCH, context, subject); + const inner = { ...context, parentHash: matchBlock.getHash(), depth: context.depth + 1 }; + for (let index = 0; index < body.namedChildCount; index += 1) { + const clause = body.namedChild(index); + if (clause?.type !== 'case_clause') { + if (clause) { + this.visit(clause, inner); + } + continue; + } + const caseBody = this.lastBlockChild(clause); + if (!caseBody) { + continue; + } + const block = this.emit(caseBody, PythonBlockKind.CASE, inner, null); + this.walk(caseBody, { ...inner, parentHash: block.getHash(), depth: inner.depth + 1 }); + } + } + + /** `except ValueError as e` / `except (A, B)` / `except* X`. */ + private handlerDetail( + clause: Parser.SyntaxNode, + isExcept: boolean + ): { types: string; target: string } | undefined { + if (!isExcept) { + return undefined; + } + const types: string[] = []; + let target = ''; + for (let index = 0; index < clause.namedChildCount; index += 1) { + const child = clause.namedChild(index); + if (!child || child.type === 'block' || child.isExtra) { + continue; + } + if (child.type === 'as_pattern') { + const caught = child.namedChild(0); + if (caught) { + types.push(...this.exceptionNames(caught)); + } + // The alias is a bare identifier, not an `as_pattern_target`. + const alias = child.namedChild(child.namedChildCount - 1); + if (alias && alias.id !== caught?.id) { + target = alias.text.replace(/^as\s+/, '').trim(); + } + continue; + } + types.push(...this.exceptionNames(child)); + } + return { types: types.join(','), target }; + } + + /** A single caught type, or every member of a tuple form. */ + private exceptionNames(node: Parser.SyntaxNode): string[] { + if (node.type === 'tuple' || node.type === 'expression_list') { + const names: string[] = []; + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + names.push(child.text.trim()); + } + } + return names; + } + return [node.text.trim()]; + } + + private emit( + body: Parser.SyntaxNode, + kind: PythonBlockKind, + context: BlockContext, + condition: Parser.SyntaxNode | null, + hasElseClause = false, + resourceCount?: number, + tryStatementHash?: string, + handler?: { types: string; target: string } + ): PyBlockRegistry { + // The condition UNWRAPPED. `if (a and b):` has a parenthesized_expression as + // its condition node, and keeping the parens made conditionText differ from + // every other spelling of the same test — `(x)` and `x` are the same + // condition. This also matches the node the FK already points at, which is + // unwrapped for the same reason. + let conditionNode = condition; + while (conditionNode && conditionNode.type === 'parenthesized_expression') { + const inner = conditionNode.namedChild(0); + if (!inner) { + break; + } + conditionNode = inner; + } + const conditionText = conditionNode + ? conditionNode.text.replace(/\s+/g, ' ').trim() + : ''; + // The block ends at its last STATEMENT, not at the grammar node's end. A + // trailing comment — `return -1 # incomplete` — sits inside the tree-sitter + // `block` node and pushed endColumn past the code, so the span disagreed with + // ast on 32 blocks in 25 files. Same defect, same fix, as py_type.endLine. + const lastStatement = this.lastStatementOf(body); + const endNode = lastStatement ?? body; + const builder = PyBlockRegistry.builder( + kind, + this.input.filePath, + body.startPosition.row + 1, + this.input.positions.byteColumn(body.startPosition.row, body.startPosition.column), + endNode.endPosition.row + 1, + this.input.positions.byteColumn(endNode.endPosition.row, endNode.endPosition.column), + context.methodOwnerHash, + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withOrder(this.order++, context.depth) + .withOwner( + context.pyTypeLinkHash, + context.ownerTypeName, + context.ownerQualifiedName, + context.ownerMethodName + ) + .withContainer(context.parentHash, context.scopeHash) + .withCondition(conditionText, this.isTypeCheckingGuard(conditionText)) + .withFlags(hasElseClause, context.isModuleLevel); + + if (resourceCount !== undefined) { + builder.withResourceCount(resourceCount); + } + if (tryStatementHash) { + builder.withTryStatement(tryStatementHash); + } + if (handler) { + builder.withHandler(handler.types, handler.target); + } + + const block = builder.build(); + this.blocks.push(block); + if (condition) { + // A PARENTHESISED condition — `if (a and b):` — has no expression row of + // its own, because parentheses are pure grouping and the expression stage + // treats them as transparent, exactly as ast does. Joining on the outer + // span therefore found nothing and left the FK empty on 73 blocks. Unwrap + // to the node that actually carries a row. + let joinable = condition; + while (joinable.type === 'parenthesized_expression') { + const inner = joinable.namedChild(0); + if (!inner) { + break; + } + joinable = inner; + } + this.conditionRangeByBlock.set( + block.getHash(), + `${joinable.startIndex}:${joinable.endIndex}` + ); + } + return block; + } + + /** + * `if TYPE_CHECKING:` and its `typing.TYPE_CHECKING` spelling. + * + * These blocks hold imports that a type checker sees and the runtime never + * executes, so a rule treating them as runtime imports is wrong about all 338 + * of them in the measured corpus. + */ + private isTypeCheckingGuard(conditionText: string): boolean { + const normalized = conditionText.replace(/\s+/g, ''); + return normalized === 'TYPE_CHECKING' || normalized.endsWith('.TYPE_CHECKING'); + } + + private isAsync(node: Parser.SyntaxNode): boolean { + const first = node.child(0); + return first?.text === 'async'; + } + + /** The last non-comment statement in a body — where ast says the block ends. */ + private lastStatementOf(body: Parser.SyntaxNode): Parser.SyntaxNode | null { + for (let index = body.namedChildCount - 1; index >= 0; index -= 1) { + const child = body.namedChild(index); + if (child && !child.isExtra && child.type !== 'comment') { + return child; + } + } + return null; + } + + private lastBlockChild(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + for (let index = node.namedChildCount - 1; index >= 0; index -= 1) { + const child = node.namedChild(index); + if (child?.type === 'block') { + return child; + } + } + return null; + } +} diff --git a/parser/src/parsers/python/extractors/python-comment-extractor.ts b/parser/src/parsers/python/extractors/python-comment-extractor.ts new file mode 100644 index 000000000..ec4494f3e --- /dev/null +++ b/parser/src/parsers/python/extractors/python-comment-extractor.ts @@ -0,0 +1,290 @@ +import Parser from 'tree-sitter'; + +import { + PyCommentRegistry, + PyMethodRegistry, + PyModuleRegistry, + PyTypeRegistry, +} from '@/analysis-types/python'; +import { PythonCommentKind } from '@/enums/python/comments'; +import { PythonExpressionOwnerKind } from '@/enums/python/expressions'; +import { PythonSourcePositions } from '@/utils/python/python-position-utils'; + +export interface PythonCommentInput { + module: PyModuleRegistry; + rootNode: Parser.SyntaxNode; + filePath: string; + serviceVersionLinkHash: string; + types: PyTypeRegistry[]; + methods: PyMethodRegistry[]; + typeHashByNodeId: Map; + methodHashByNodeId: Map; + moduleMethodHash: string; + positions: PythonSourcePositions; +} + +/** `# noqa`, `# type: ignore`, `# pylint: disable=…` and friends. */ +const SUPPRESSION = /^#\s*(noqa|type:\s*ignore|pylint\s*:|flake8\s*:|mypy\s*:)/i; +/** `# pragma: no cover` and similar. */ +const PRAGMA = /^#\s*pragma\s*:/i; +/** PEP 484 `# type: List[int]`, but NOT `# type: ignore`. */ +const TYPE_COMMENT = /^#\s*type\s*:\s*(?!ignore)(.+)$/; +/** PEP 263, valid on the first two lines only. */ +const ENCODING = /coding[:=]\s*([-\w.]+)/; + +/** + * Extracts `py_comment`, including docstrings. + * + * Most of what this collects is not commentary. An encoding cookie decides how + * the file decodes, a `# type:` comment carries an annotation `ast` will parse, + * and a `# noqa` is an explicit decision that a rule reporting the suppressed + * finding is arguing with. Emitting them all as `LINE_COMMENT` would keep the + * text and lose every instruction in it. + * + * Docstrings are emitted here AND remain `py_expression` LITERAL rows. §2.17 + * requires that: a docstring genuinely is a string expression and `ast` agrees, + * so the duplication is intentional and a recall check must whitelist it rather + * than report it forever. + * + * Consecutive line comments become one `BLOCK_COMMENT_RUN`, because a six-line + * explanation is one comment to a reader and six rows would make it look like + * six unrelated remarks. + */ +export class PythonCommentExtractor { + private input!: PythonCommentInput; + private comments: PyCommentRegistry[] = []; + private index = 0; + + extract(input: PythonCommentInput): PyCommentRegistry[] { + this.input = input; + this.comments = []; + this.index = 0; + + this.collectDocstrings(input.rootNode); + this.collectComments(input.rootNode); + + // Source order, so `commentIndex` means something to a reader comparing + // against the file. + this.comments.sort((left, right) => left.getStartLine() - right.getStartLine()); + return this.comments; + } + + // ---- docstrings ------------------------------------------------------ + + /** + * The first statement of a module, class or function, when it is a string. + * + * That positional rule is the whole definition — Python has no docstring + * syntax, only a convention the runtime honours by storing `__doc__`. A string + * anywhere else is an ordinary expression statement and is deliberately not + * collected. + */ + private collectDocstrings(root: Parser.SyntaxNode): void { + const moduleDoc = this.firstStringStatement(root); + if (moduleDoc) { + this.push( + moduleDoc, + PythonCommentKind.DOCSTRING_MODULE, + this.input.moduleMethodHash, + PythonExpressionOwnerKind.MODULE, + true + ); + } + + const worklist: Parser.SyntaxNode[] = [root]; + while (worklist.length > 0) { + const node = worklist.shift()!; + if (node.type === 'class_definition' || node.type === 'function_definition') { + const body = node.childForFieldName('body'); + const doc = body ? this.firstStringStatement(body) : null; + if (doc) { + const isClass = node.type === 'class_definition'; + this.push( + doc, + isClass ? PythonCommentKind.DOCSTRING_CLASS : PythonCommentKind.DOCSTRING_FUNCTION, + isClass + ? this.input.typeHashByNodeId.get(node.id) ?? '' + : this.input.methodHashByNodeId.get(node.id) ?? '', + isClass ? PythonExpressionOwnerKind.TYPE : PythonExpressionOwnerKind.METHOD, + true + ); + } + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + } + + private firstStringStatement(container: Parser.SyntaxNode): Parser.SyntaxNode | null { + for (let index = 0; index < container.namedChildCount; index += 1) { + const child = container.namedChild(index); + if (!child || child.isExtra) { + continue; + } + if (child.type !== 'expression_statement') { + return null; + } + const inner = child.namedChild(0); + return inner && (inner.type === 'string' || inner.type === 'concatenated_string') + ? inner + : null; + } + return null; + } + + // ---- comments -------------------------------------------------------- + + private collectComments(root: Parser.SyntaxNode): void { + const found: Parser.SyntaxNode[] = []; + const worklist: Parser.SyntaxNode[] = [root]; + while (worklist.length > 0) { + const node = worklist.shift()!; + if (node.type === 'comment') { + found.push(node); + } + for (let index = 0; index < node.childCount; index += 1) { + const child = node.child(index); + if (child) { + worklist.push(child); + } + } + } + found.sort((left, right) => left.startPosition.row - right.startPosition.row); + + // Merge consecutive line comments at the same indent into ONE run. A + // multi-line explanation is one comment to a reader, and emitting six rows + // would present it as six unrelated remarks. + let runStart: Parser.SyntaxNode | null = null; + let runEnd: Parser.SyntaxNode | null = null; + const runText: string[] = []; + + const flush = (): void => { + if (!runStart || !runEnd) { + return; + } + if (runText.length > 1) { + this.pushRun(runStart, runEnd, runText.join('\n')); + } else { + this.pushSingle(runStart); + } + runStart = null; + runEnd = null; + runText.length = 0; + }; + + for (const comment of found) { + const kind = this.classify(comment); + // Only PLAIN comments merge. A directive is a fact in its own right and + // folding it into a run would bury the instruction. + if (kind !== PythonCommentKind.LINE_COMMENT) { + flush(); + this.pushSingle(comment); + continue; + } + const contiguous = + runEnd !== null && + comment.startPosition.row === runEnd.startPosition.row + 1 && + comment.startPosition.column === runEnd.startPosition.column; + if (!contiguous) { + flush(); + runStart = comment; + } + runEnd = comment; + runText.push(comment.text); + } + flush(); + } + + private classify(comment: Parser.SyntaxNode): PythonCommentKind { + const text = comment.text; + const row = comment.startPosition.row; + if (row === 0 && text.startsWith('#!')) { + return PythonCommentKind.SHEBANG; + } + // PEP 263 restricts the cookie to the first two lines; a `coding:` further + // down is an ordinary comment and honouring it would be wrong. + if (row <= 1 && ENCODING.test(text)) { + return PythonCommentKind.ENCODING_COOKIE; + } + if (SUPPRESSION.test(text)) { + return PythonCommentKind.NOQA; + } + if (PRAGMA.test(text)) { + return PythonCommentKind.PRAGMA; + } + if (TYPE_COMMENT.test(text)) { + return PythonCommentKind.TYPE_COMMENT; + } + return PythonCommentKind.LINE_COMMENT; + } + + private pushSingle(comment: Parser.SyntaxNode): void { + const kind = this.classify(comment); + const payload = TYPE_COMMENT.exec(comment.text); + this.push( + comment, + kind, + this.input.moduleMethodHash, + PythonExpressionOwnerKind.MODULE, + false, + kind === PythonCommentKind.TYPE_COMMENT ? (payload?.[1] ?? '').trim() : '' + ); + } + + private pushRun(start: Parser.SyntaxNode, end: Parser.SyntaxNode, text: string): void { + this.comments.push( + new PyCommentRegistry( + PythonCommentKind.BLOCK_COMMENT_RUN, + this.normalize(text), + this.input.filePath, + start.startPosition.row + 1, + this.input.positions.byteColumn(start.startPosition.row, start.startPosition.column), + end.endPosition.row + 1, + this.input.positions.byteColumn(end.endPosition.row, end.endPosition.column), + this.input.moduleMethodHash, + this.index++, + PythonExpressionOwnerKind.MODULE, + this.input.module.getHash(), + '', + false, + this.input.serviceVersionLinkHash + ) + ); + } + + private push( + node: Parser.SyntaxNode, + kind: PythonCommentKind, + ownerHash: string, + ownerKind: PythonExpressionOwnerKind, + isDocstring: boolean, + typeCommentPayload = '' + ): void { + this.comments.push( + new PyCommentRegistry( + kind, + this.normalize(node.text), + this.input.filePath, + node.startPosition.row + 1, + this.input.positions.byteColumn(node.startPosition.row, node.startPosition.column), + node.endPosition.row + 1, + this.input.positions.byteColumn(node.endPosition.row, node.endPosition.column), + ownerHash, + this.index++, + ownerKind, + this.input.module.getHash(), + typeCommentPayload, + isDocstring, + this.input.serviceVersionLinkHash + ) + ); + } + + private normalize(text: string): string { + return text.replace(/\s+/g, ' ').trim(); + } +} diff --git a/parser/src/parsers/python/extractors/python-declaration-extractor.ts b/parser/src/parsers/python/extractors/python-declaration-extractor.ts new file mode 100644 index 000000000..e751e864e --- /dev/null +++ b/parser/src/parsers/python/extractors/python-declaration-extractor.ts @@ -0,0 +1,2253 @@ +import Parser from 'tree-sitter'; + +import { + PyImportRegistry, + PyMethodParameterRegistry, + PyMethodRegistry, + PyModuleRegistry, + PyTypeBaseRegistry, + PyTypeRegistry, +} from '@/analysis-types/python'; +import { + PYTHON_CLASS_INITIALIZER_NAME, + PYTHON_LAMBDA_METHOD_NAME, + PYTHON_MODULE_INITIALIZER_NAME, +} from '@/constants/python-constants'; +import { PythonImportKind } from '@/enums/python/imports'; +import { + PythonDefaultValueKind, + PythonMethodAccess, + PythonMethodKind, + PythonMethodModifier, + PythonParameterKind, +} from '@/enums/python/methods'; +import { + PythonBaseKind, + PythonMroKind, + PythonTypeAccess, + PythonTypeCategory, + PythonTypeModifier, + PythonTypePlacement, +} from '@/enums/python/types'; +import { + PythonTypeRefContext, + PythonTypeRefOwnerKind, +} from '@/enums/python/type-references'; +import { TypePositionInput } from '@/parsers/python/extractors/python-type-reference-extractor'; +import { EntityUtils } from '@/utils/entity-utils'; +import { PythonSourcePositions } from '@/utils/python'; + +/** What the declaration stage produces for one module. */ +export interface PythonDeclarationExtraction { + types: PyTypeRegistry[]; + typeBases: PyTypeBaseRegistry[]; + methods: PyMethodRegistry[]; + methodParameters: PyMethodParameterRegistry[]; + imports: PyImportRegistry[]; + + /** `function_definition` node id -> `py_method` PK, for the expression stage. */ + methodHashByNodeId: Map; + /** `class_definition` node id -> `py_type` PK. */ + typeHashByNodeId: Map; + /** `class_definition` node id -> its `` method PK. */ + classInitHashByNodeId: Map; + /** The synthetic `` method PK — the fallback expression owner. */ + moduleMethodHash: string; + /** + * Type references found in declarations — bases, annotations, return types — + * handed to the type-reference stage rather than resolved here. Set on the way + * out and consumed by the fact extractor; the interface never declared it. + */ + typePositions: TypePositionInput[]; + /** + * `lambda` node id -> its `py_method` PK. + * + * A lambda is a real `py_method` (schema §2.7 lists `lambda` alongside `def`), + * so its scope has an owner and its body's expressions and calls are + * attributed to IT rather than to the function it happens to sit in. + */ + lambdaMethodByNodeId: Map; + /** + * `py_method_parameter` PK -> the `startIndex:endIndex` of its default-value + * expression. Joined against the expression stage's byte-range index, since + * the two stages mint their rows independently. + */ + parameterDefaultByteRange: Map; + /** + * Annotation node `startIndex:endIndex` -> the `py_method_parameter` PK it + * annotates. + * + * Lets the expression stage make a parameter's annotation subtree OWNED by + * that parameter, which is what `expressionOwnerKind=METHOD_PARAMETER` exists + * for. Without it there is no way to get from a parameter row to the N type + * references in its annotation — and a parameter row has only ONE + * `potentialQualifiedName` slot, so `Dict[TypeA, TypeB]` cannot be expressed + * there at all. + */ + parameterHashByAnnotationRange: Map; + /** + * Scope-introducing `node.id` -> the PK of the entity that OWNS that scope: a + * `py_type` for a class body, a `py_method` for a function or lambda. Used to + * back-patch `py_scope.ownerHash`, which cannot be known when the scope row is + * minted because the declaration does not exist yet. + */ + scopeOwnerByNodeId: Map; + /** + * Scope-introducing `node.id` -> the enclosing `py_method` PK, for + * `py_binding.pyMethodLinkHash`. + */ + enclosingMethodByScopeNodeId: Map; +} + +export interface PythonDeclarationInput { + module: PyModuleRegistry; + rootNode: Parser.SyntaxNode; + filePath: string; + baseMservPath: string; + fileName: string; + serviceVersionLinkHash: string; + /** Scope-introducing `node.id` -> `py_scope` PK, from the scope stage. */ + scopeHashByNodeId: Map; + /** `(scopeHash, name)` -> `py_binding` PK, from the scope stage. */ + bindingHashByScopeAndName: Map; + /** Scope-introducing `node.id` -> the scope's qualified name. */ + qualifiedNameByNodeId: Map; + /** Converts tree-sitter character columns to CPython UTF-8 byte columns. */ + positions: PythonSourcePositions; +} + +/** Lexical context threaded through the walk. */ +interface DeclarationContext { + /** Enclosing `py_type` PK, or `''` at module level. */ + enclosingTypeHash: string; + enclosingTypeName: string; + enclosingTypeQualifiedName: string; + /** Enclosing `py_method` PK — the synthetic `` at worst, never `''`. */ + enclosingMethodHash: string; + /** The scope PK a declaration's *name* is bound in. */ + bindingScopeHash: string; + /** True inside a class body, where a `def` becomes a method. */ + inClassBody: boolean; + /** True inside a function body, where a `class` is LOCAL_PLACEMENT. */ + inFunctionBody: boolean; + /** True under `if`/`try` at module level. */ + isConditional: boolean; + /** True under `if TYPE_CHECKING:` — absent at runtime. */ + isTypeCheckingOnly: boolean; +} + +/** + * Emits the declaration relations: `py_type`, `py_type_base`, `py_method`, + * `py_method_parameter` and `py_import`. + * + * Stage 2 of the build order. Where stage 1 is adjudicated *exactly* by + * `symtable`, this stage is cross-checked against `ast` — a second + * implementation, so a disagreement means "adjudicate", not automatically "we + * are wrong". + * + * ## The two synthetic methods + * + * Python allows executable code where Java cannot: at module level (3,006 + * statements across 826 measured files) and in class bodies. But + * `expr_ultimate_method` in the resolution layer requires every expression to + * reach a method or call attribution silently fails. So this extractor mints: + * + * - one `` method per module (`MODULE_INITIALIZER`), owning module-level + * code, and + * - one `` method per class (`CLASS_INITIALIZER`). + * + * Both carry the `SYNTHETIC` modifier. This is the same move the Java parser + * already makes for `` / `` initializer blocks, which is why the + * whole call-chain layer works on Python with no new rules. + * + * ## State discipline + * + * Context is threaded as an explicit parameter, never written onto nodes. + * node-tree-sitter's wrapper cache evicts entries, so a tag set while + * descending is gone on the way back up — an error that passes every small test + * and corrupts large files silently. + */ +export class PythonDeclarationExtractor { + private input!: PythonDeclarationInput; + private types: PyTypeRegistry[] = []; + private typeBases: PyTypeBaseRegistry[] = []; + private methods: PyMethodRegistry[] = []; + private methodParameters: PyMethodParameterRegistry[] = []; + private imports: PyImportRegistry[] = []; + private methodHashByNodeId = new Map(); + private typeHashByNodeId = new Map(); + private classInitHashByNodeId = new Map(); + private parameterDefaultByteRange = new Map(); + private parameterHashByAnnotationRange = new Map(); + private typePositions: TypePositionInput[] = []; + private lambdaMethodByNodeId = new Map(); + private scopeOwnerByNodeId = new Map(); + private enclosingMethodByScopeNodeId = new Map(); + + extract(input: PythonDeclarationInput): PythonDeclarationExtraction { + this.input = input; + this.types = []; + this.typeBases = []; + this.methods = []; + this.methodParameters = []; + this.imports = []; + this.methodHashByNodeId = new Map(); + this.typeHashByNodeId = new Map(); + this.classInitHashByNodeId = new Map(); + this.parameterDefaultByteRange = new Map(); + this.parameterHashByAnnotationRange = new Map(); + this.typePositions = []; + this.lambdaMethodByNodeId = new Map(); + this.scopeOwnerByNodeId = new Map(); + this.enclosingMethodByScopeNodeId = new Map(); + + const moduleScopeHash = input.scopeHashByNodeId.get(input.rootNode.id) ?? ''; + const moduleInit = this.emitModuleInitializer(moduleScopeHash); + input.module.setModuleInitMethodLinkHash(moduleInit.getHash()); + // The module scope is owned by the module itself, and module-level bindings + // belong to the synthetic method. + this.scopeOwnerByNodeId.set(input.rootNode.id, input.module.getHash()); + this.enclosingMethodByScopeNodeId.set(input.rootNode.id, moduleInit.getHash()); + + this.visitBody(input.rootNode, { + enclosingTypeHash: '', + enclosingTypeName: '', + enclosingTypeQualifiedName: '', + enclosingMethodHash: moduleInit.getHash(), + bindingScopeHash: moduleScopeHash, + inClassBody: false, + inFunctionBody: false, + isConditional: false, + isTypeCheckingOnly: false, + }); + + // Lambdas are expressions, so the statement walk above does not reach them. + // They get their own pass, which threads the same owner context. + this.emitLambdaMethods(input.rootNode, '', moduleInit.getHash()); + this.propagateCategoriesThroughLocalBases(); + + return { + types: this.types, + typeBases: this.typeBases, + methods: this.methods, + methodParameters: this.methodParameters, + imports: this.imports, + methodHashByNodeId: this.methodHashByNodeId, + typeHashByNodeId: this.typeHashByNodeId, + classInitHashByNodeId: this.classInitHashByNodeId, + moduleMethodHash: moduleInit.getHash(), + parameterDefaultByteRange: this.parameterDefaultByteRange, + parameterHashByAnnotationRange: this.parameterHashByAnnotationRange, + typePositions: this.typePositions, + lambdaMethodByNodeId: this.lambdaMethodByNodeId, + scopeOwnerByNodeId: this.scopeOwnerByNodeId, + enclosingMethodByScopeNodeId: this.enclosingMethodByScopeNodeId, + }; + } + + // ------------------------------------------------------------- synthetics + + /** + * Mints the `` initializer. + * + * Its span is the whole file, so every module-level expression falls inside an + * owner. `pyTypeLinkHash` is empty, which is correct: a module is not a type. + */ + private emitModuleInitializer(moduleScopeHash: string): PyMethodRegistry { + const root = this.input.rootNode; + const method = PyMethodRegistry.builder( + PYTHON_MODULE_INITIALIZER_NAME, + `${PYTHON_MODULE_INITIALIZER_NAME}()`, + `${this.input.module.getQualifiedName()}.${PYTHON_MODULE_INITIALIZER_NAME}`, + this.input.filePath, + root.startPosition.row + 1, + root.endPosition.row + 1, + this.input.positions.byteColumn(root.startPosition.row, root.startPosition.column), + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withKindAndAccess(PythonMethodKind.MODULE_INITIALIZER, PythonMethodAccess.PUBLIC_ACCESS) + .withModifiers([PythonMethodModifier.SYNTHETIC]) + .withScopeLinkHash(moduleScopeHash) + .withEndColumn(this.input.positions.byteColumn(root.endPosition.row, root.endPosition.column)) + .build(); + this.methods.push(method); + return method; + } + + /** Mints the `` initializer for one class. */ + private emitClassInitializer( + classNode: Parser.SyntaxNode, + type: PyTypeRegistry, + classScopeHash: string + ): PyMethodRegistry { + const method = PyMethodRegistry.builder( + PYTHON_CLASS_INITIALIZER_NAME, + `${PYTHON_CLASS_INITIALIZER_NAME}()`, + `${type.getQualifiedName()}.${PYTHON_CLASS_INITIALIZER_NAME}`, + this.input.filePath, + classNode.startPosition.row + 1, + classNode.endPosition.row + 1, + this.input.positions.byteColumn(classNode.startPosition.row, classNode.startPosition.column), + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withKindAndAccess(PythonMethodKind.CLASS_INITIALIZER, PythonMethodAccess.PUBLIC_ACCESS) + .withModifiers([PythonMethodModifier.SYNTHETIC]) + .withOwner(type.getHash(), type.getName(), type.getQualifiedName()) + .withScopeLinkHash(classScopeHash) + .withEndColumn( + this.input.positions.byteColumn(classNode.endPosition.row, classNode.endPosition.column) + ) + .build(); + this.methods.push(method); + return method; + } + + /** + * Emits a `py_method` per `lambda`, in a dedicated pass. + * + * Lambdas need their own pass because they are **expressions**: they occur in + * defaults, decorators, class bases, comprehensions and arbitrary nested + * expressions, none of which the statement walk descends into. Schema §2.7 + * lists `lambda` alongside `def` and `async def`, and the entire reason + * `startColumn` is in the `py_method` primary key is that + * `g = (lambda: 1, lambda: 2)` produces two rows whose name, qualifiedName, + * signature and startLine are all identical. + * + * Without these rows three things break, all silently: a LAMBDA scope's + * `ownerHash` has nothing to point at, a call inside a lambda is attributed to + * the enclosing function instead of the lambda, and `methodKind=LAMBDA` is + * never emitted at all. + * + * The walk threads the enclosing type and method itself rather than reusing + * the statement walk's context, because a lambda's owner is whatever + * definition lexically encloses it — which for a lambda inside a nested def is + * that def, and for a lambda in a decorator is the definition OUTSIDE the one + * being decorated. + */ + private emitLambdaMethods( + node: Parser.SyntaxNode, + enclosingTypeHash: string, + enclosingMethodHash: string + ): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + + if (child.type === 'class_definition') { + const body = child.childForFieldName('body'); + const typeHash = this.typeHashByNodeId.get(child.id) ?? enclosingTypeHash; + const classInit = this.classInitHashByNodeId.get(child.id) ?? enclosingMethodHash; + // Bases and decorators are evaluated OUTSIDE the class body, so a lambda + // in them belongs to the enclosing owner, not to the class. + for (let j = 0; j < child.namedChildCount; j++) { + const part = child.namedChild(j); + if (part && part.id !== body?.id) { + this.emitLambdaMethods(part, enclosingTypeHash, enclosingMethodHash); + } + } + if (body) { + this.emitLambdaMethods(body, typeHash, classInit); + } + continue; + } + + if (child.type === 'function_definition') { + const body = child.childForFieldName('body'); + const methodHash = this.methodHashByNodeId.get(child.id) ?? enclosingMethodHash; + // Same rule: defaults, annotations and decorators evaluate outside. + for (let j = 0; j < child.namedChildCount; j++) { + const part = child.namedChild(j); + if (part && part.id !== body?.id) { + this.emitLambdaMethods(part, enclosingTypeHash, enclosingMethodHash); + } + } + if (body) { + this.emitLambdaMethods(body, enclosingTypeHash, methodHash); + } + continue; + } + + if (child.type === 'lambda') { + const method = this.emitLambdaMethod(child, enclosingTypeHash, enclosingMethodHash); + const parametersNode = child.childForFieldName('parameters'); + const bodyNode = child.childForFieldName('body'); + // A lambda's own defaults evaluate in the ENCLOSING scope, so a lambda + // nested in a default belongs to the enclosing owner; only the body is + // owned by this lambda. + if (parametersNode) { + this.emitLambdaMethods(parametersNode, enclosingTypeHash, enclosingMethodHash); + } + if (bodyNode) { + this.emitLambdaMethods(bodyNode, enclosingTypeHash, method.getHash()); + } + continue; + } + + this.emitLambdaMethods(child, enclosingTypeHash, enclosingMethodHash); + } + } + + private emitLambdaMethod( + node: Parser.SyntaxNode, + enclosingTypeHash: string, + enclosingMethodHash: string + ): PyMethodRegistry { + const parametersNode = node.childForFieldName('parameters'); + const parameters = this.collectParameters(parametersNode); + const scopeHash = this.input.scopeHashByNodeId.get(node.id) ?? ''; + // CPython's __qualname__ for a lambda is ``; the scope keeps + // symtable's own name, `lambda`. Each column follows its own source of truth. + const qualifiedName = + (this.input.qualifiedNameByNodeId.get(node.id) ?? '').replace(/\.lambda$/, `.${PYTHON_LAMBDA_METHOD_NAME}`) || + PYTHON_LAMBDA_METHOD_NAME; + const end = this.declarationEndPosition(node); + + const method = PyMethodRegistry.builder( + PYTHON_LAMBDA_METHOD_NAME, + this.buildSignature(PYTHON_LAMBDA_METHOD_NAME, parameters), + qualifiedName, + this.input.filePath, + node.startPosition.row + 1, + end.row + 1, + this.input.positions.byteColumn(node.startPosition.row, node.startPosition.column), + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withDetailedSignature(this.buildDetailedSignature(PYTHON_LAMBDA_METHOD_NAME, parameters, null)) + .withKindAndAccess(PythonMethodKind.LAMBDA, PythonMethodAccess.PUBLIC_ACCESS) + .withParameterShape({ + parameterCount: parameters.filter(p => p.name !== '').length, + posOnlyCount: parameters.filter(p => p.kind === PythonParameterKind.POSITIONAL_ONLY).length, + kwOnlyCount: parameters.filter(p => p.kind === PythonParameterKind.KEYWORD_ONLY).length, + isVarArgs: parameters.some(p => p.kind === PythonParameterKind.VAR_POSITIONAL), + hasKwArgs: parameters.some(p => p.kind === PythonParameterKind.VAR_KEYWORD), + // A lambda never has a receiver: it is not bound as a method even when + // it is assigned to a class attribute. + hasReceiverParameter: false, + }) + .withScopeLinkHash(scopeHash) + .withEnclosingMemberLinkHash(enclosingMethodHash) + .withEndColumn(this.input.positions.byteColumn(end.row, end.column)) + .build(); + + if (enclosingTypeHash !== '') { + // The owning class, when the lambda sits inside a class body. + method.setDeclaringBindingLinkHash(''); + } + this.methods.push(method); + this.lambdaMethodByNodeId.set(node.id, method.getHash()); + this.scopeOwnerByNodeId.set(node.id, method.getHash()); + this.enclosingMethodByScopeNodeId.set(node.id, method.getHash()); + this.emitLambdaParameters(parameters, method, scopeHash, enclosingTypeHash); + return method; + } + + private emitLambdaParameters( + parameters: ParameterEntry[], + method: PyMethodRegistry, + scopeHash: string, + /** + * Threaded through like every other emitter here. The annotation branch below + * referenced a `context` that does not exist in this scope — the method has no + * such parameter — so the file did not compile. Python forbids annotations on + * lambda parameters, which is why the branch never ran and the error went + * unnoticed: it is unreachable, not merely untested. + */ + enclosingTypeHash: string + ): void { + for (const parameter of parameters) { + const builder = PyMethodParameterRegistry.builder( + parameter.name, + parameter.position, + method.getHash(), + parameter.kind, + parameter.node.startPosition.row + 1, + parameter.node.endPosition.row + 1, + this.input.serviceVersionLinkHash + ).withBindingLinkHash( + this.input.bindingHashByScopeAndName.get(`${scopeHash}::${parameter.name}`) ?? '' + ); + if (parameter.defaultNode) { + builder.withDefault( + EntityUtils.normalizeWhitespace(parameter.defaultNode.text), + this.defaultValueKindOf(parameter.defaultNode) + ); + } + const row = builder.build(); + if (parameter.defaultNode) { + const defaultNode = this.unwrapParentheses(parameter.defaultNode); + this.parameterDefaultByteRange.set( + row.getHash(), + `${defaultNode.startIndex}:${defaultNode.endIndex}` + ); + } + // The annotation node's range, so the expression stage can make this + // parameter the OWNER of its annotation subtree. That ownership is the + // only route from a parameter row to the N types its annotation + // references: the row has one potentialQualifiedName slot, which cannot + // express `Dict[TypeA, TypeB]`. + const annotationNode = parameter.node.childForFieldName('type'); + if (annotationNode) { + this.parameterHashByAnnotationRange.set( + `${annotationNode.startIndex}:${annotationNode.endIndex}`, + row.getHash() + ); + this.typePositions.push({ + node: annotationNode, + context: PythonTypeRefContext.METHOD_PARAM, + ownerHash: row.getHash(), + ownerKind: PythonTypeRefOwnerKind.METHOD_PARAM, + enclosingTypeHash, + scopeHash, + }); + } + this.methodParameters.push(row); + } + } + + // ------------------------------------------------------------------ walk + + private visitBody(node: Parser.SyntaxNode, context: DeclarationContext): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) { + this.visitStatement(child, context); + } + } + } + + private visitStatement(node: Parser.SyntaxNode, context: DeclarationContext): void { + switch (node.type) { + case 'decorated_definition': { + const decorators: Parser.SyntaxNode[] = []; + let definition: Parser.SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'decorator') { + decorators.push(child); + continue; + } + definition = child; + } + if (definition?.type === 'class_definition') { + this.visitClassDefinition(definition, context, decorators); + return; + } + if (definition?.type === 'function_definition') { + this.visitFunctionDefinition(definition, context, decorators); + return; + } + if (definition) { + this.visitStatement(definition, context); + } + return; + } + + case 'class_definition': { + this.visitClassDefinition(node, context, []); + return; + } + + case 'function_definition': { + this.visitFunctionDefinition(node, context, []); + return; + } + + case 'import_statement': + case 'import_from_statement': + case 'future_import_statement': { + this.visitImport(node, context); + return; + } + + case 'expression_statement': { + // An annotated assignment is a type position too: `total: TypeC = None`. + // Its owner is the BINDING the name creates, so the engine can go from a + // variable to the types its declared type references. + // + // NOT in a class body, though. There the same annotation belongs to a + // FIELD, and the field extractor emits it with owner kind FIELD and + // context FIELD_TYPE. Emitting both put two rows on one annotation with + // different owners, which double-counts every `class Foo: x: Bar` in any + // tally of how many type references resolve. The field is the canonical + // owner of a class attribute's declared type; the binding remains + // reachable through py_field. + for (let i = 0; i < node.namedChildCount; i++) { + const inner = node.namedChild(i); + if (inner?.type !== 'assignment') { + continue; + } + const target = inner.childForFieldName('left'); + const annotation = inner.childForFieldName('type'); + if (!annotation || target?.type !== 'identifier') { + continue; + } + const bindingHash = this.input.bindingHashByScopeAndName.get( + `${context.bindingScopeHash}::${target.text}` + ); + if (!bindingHash) { + continue; + } + if (context.inClassBody) { + continue; + } + this.typePositions.push({ + node: annotation, + context: PythonTypeRefContext.VARIABLE_ANNOTATION, + ownerHash: bindingHash, + ownerKind: PythonTypeRefOwnerKind.BINDING, + enclosingTypeHash: context.enclosingTypeHash, + scopeHash: context.bindingScopeHash, + }); + } + this.visitBody(node, context); + return; + } + + case 'if_statement': { + // An `if TYPE_CHECKING:` body does not exist at runtime, and everything + // under a module-level `if` is conditional. Both facts are inherited by + // every declaration nested inside. + const condition = node.childForFieldName('condition'); + const isTypeChecking = + condition !== null && /\bTYPE_CHECKING\b/.test(condition.text); + const nested: DeclarationContext = { + ...context, + isConditional: true, + isTypeCheckingOnly: context.isTypeCheckingOnly || isTypeChecking, + }; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.id === condition?.id) { + continue; + } + this.visitStatement(child, nested); + } + return; + } + + case 'try_statement': + case 'while_statement': + case 'for_statement': + case 'with_statement': + case 'match_statement': { + const nested: DeclarationContext = { ...context, isConditional: true }; + this.visitBody(node, nested); + return; + } + + default: { + this.visitBody(node, context); + return; + } + } + } + + // ----------------------------------------------------------------- types + + private visitClassDefinition( + node: Parser.SyntaxNode, + context: DeclarationContext, + decorators: Parser.SyntaxNode[] + ): void { + const nameNode = node.childForFieldName('name'); + const bodyNode = node.childForFieldName('body'); + const argumentsNode = node.childForFieldName('superclasses'); + const className = nameNode?.text ?? ''; + const classScopeHash = this.input.scopeHashByNodeId.get(node.id) ?? ''; + const qualifiedName = + this.input.qualifiedNameByNodeId.get(node.id) ?? + `${this.input.module.getQualifiedName()}.${className}`; + + const bases = this.collectBases(argumentsNode); + const decoratorNames = decorators.map(d => this.decoratorDottedPath(d)); + const classEnd = this.declarationEndPosition(node); + const declaresAbstractMember = bodyNode !== null && this.hasAbstractMember(bodyNode); + + const type = PyTypeRegistry.builder( + className, + qualifiedName, + this.input.fileName, + this.input.filePath, + this.input.baseMservPath, + node.startPosition.row + 1, + classEnd.row + 1, + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withCategoryAndAccess( + this.classifyType(bases, decoratorNames, declaresAbstractMember), + this.accessOf(className) as unknown as PythonTypeAccess + ) + .withModifiers(this.typeModifiersOf(bases, decoratorNames, bodyNode)) + .withPlacement(this.placementOf(context)) + .withEnclosing(context.enclosingTypeHash, context.inFunctionBody ? context.enclosingMethodHash : '') + .withScopeLinkHash(classScopeHash) + .withBases( + bases.filter(b => b.keywordName === '').length, + bases.some(b => b.isDynamic), + this.mroKindOf(bases), + bases.find(b => b.keywordName === 'metaclass')?.baseText ?? '' + ) + .withDocstring(this.docstringOf(bodyNode)) + .build(); + + this.types.push(type); + this.typeHashByNodeId.set(node.id, type.getHash()); + this.scopeOwnerByNodeId.set(node.id, type.getHash()); + type.setDeclaringBindingLinkHash( + this.input.bindingHashByScopeAndName.get( + `${context.bindingScopeHash}::${className}` + ) ?? '' + ); + + this.emitBases(bases, type); + + const classInit = this.emitClassInitializer(node, type, classScopeHash); + type.setClassInitMethodLinkHash(classInit.getHash()); + this.classInitHashByNodeId.set(node.id, classInit.getHash()); + // Class-body bindings belong to the synthetic method. + this.enclosingMethodByScopeNodeId.set(node.id, classInit.getHash()); + + if (!bodyNode) { + return; + } + this.visitBody(bodyNode, { + ...context, + enclosingTypeHash: type.getHash(), + enclosingTypeName: className, + enclosingTypeQualifiedName: qualifiedName, + enclosingMethodHash: classInit.getHash(), + bindingScopeHash: classScopeHash, + inClassBody: true, + inFunctionBody: false, + }); + } + + /** + * Refines `typeCategory` by following base classes **within this module**. + * + * A category is often stated one hop away rather than on the class itself: + * + * ```python + * class MessageDefect(ValueError): ... # EXCEPTION_CLASS_TYPE, by name + * class NoBoundaryDefect(MessageDefect): ... # also an exception, but the + * # base name matches no pattern + * ``` + * + * Resolving that is squarely parser work: §0.5 puts intra-module resolution in + * layer 3, on the grounds that it is decidable within one module. What is NOT + * attempted is the cross-module case — `class BrokenProcessPool(_base.BrokenExecutor)` + * stays `CLASS_TYPE`, because the base is not in this module and guessing would + * be exactly the confident-wrong-answer this schema is organised against. The + * engine refines those. + * + * Also not attempted: a base bound by an assignment to a call result, as in + * `_Instruction = collections.namedtuple(...)` followed by + * `class Instruction(_Instruction)`. That is constructor-call inference, which + * the schema assigns to the deferred `py_type_inference` relation. + * + * Iterated to a fixed point so a chain of any depth resolves, bounded by the + * number of types as a guard against a cyclic base list in malformed source. + */ + private propagateCategoriesThroughLocalBases(): void { + // Categories a subclass genuinely inherits. Three are deliberately absent, + // and each exclusion is a real distinction rather than caution: + // + // - ABC_TYPE: abstractness is NOT inherited. It depends on whether abstract + // methods remain unimplemented, which is per-class — + // `class closing(AbstractContextManager)` descends from an ABC and is + // perfectly concrete, and CPython's inspect.isabstract agrees. An earlier + // version of this pass inherited it and mislabelled nine concrete + // contextlib classes. + // - PROTOCOL_TYPE: subclassing a Protocol produces an implementation of it, + // not another Protocol. + // - DATACLASS_TYPE: @dataclass decorates one class; a subclass is not a + // dataclass unless it is itself decorated. + const inheritable = new Set([ + PythonTypeCategory.EXCEPTION_CLASS_TYPE, + PythonTypeCategory.ENUM_CLASS_TYPE, + PythonTypeCategory.NAMEDTUPLE_TYPE, + PythonTypeCategory.TYPEDDICT_TYPE, + PythonTypeCategory.METACLASS_TYPE, + ]); + + const typeByName = new Map(); + for (const type of this.types) { + // Last definition wins, matching runtime rebinding semantics. + typeByName.set(type.getName(), type); + } + const baseNamesByTypeHash = new Map(); + for (const base of this.typeBases) { + if (base.getKeywordName() !== '' || base.getBaseSimpleName() === '') { + continue; + } + const existing = baseNamesByTypeHash.get(base.getPyTypeLinkHash()) ?? []; + existing.push(base.getBaseSimpleName()); + baseNamesByTypeHash.set(base.getPyTypeLinkHash(), existing); + } + + for (let pass = 0; pass < this.types.length; pass++) { + let changed = false; + for (const type of this.types) { + if (type.getTypeCategory() !== PythonTypeCategory.CLASS_TYPE) { + continue; + } + for (const baseName of baseNamesByTypeHash.get(type.getHash()) ?? []) { + const base = typeByName.get(baseName); + if (!base || base.getHash() === type.getHash()) { + continue; + } + if (inheritable.has(base.getTypeCategory())) { + type.setTypeCategory(base.getTypeCategory()); + changed = true; + break; + } + } + } + if (!changed) { + break; + } + } + } + + /** + * Collects the base list, keeping positional and keyword entries distinct. + * + * `metaclass=` and `total=` sit in the same syntactic list as real bases but + * are not bases, so they get rows with an empty `position` and never advance + * the MRO index. + */ + private collectBases(argumentsNode: Parser.SyntaxNode | null): BaseEntry[] { + if (!argumentsNode) { + return []; + } + const entries: BaseEntry[] = []; + let position = 0; + + for (let i = 0; i < argumentsNode.namedChildCount; i++) { + const child = argumentsNode.namedChild(i); + // Grammar extras are NAMED nodes, so a comment inside a base list was + // becoming a base — and worse, taking an MRO POSITION. `class C(A, # + // comment\n B)` put the comment at position 1 and pushed B to 2, which + // silently corrupts C3 linearisation for any class written that way. + // Third site of this root cause, after argument lists and parameter lists. + if (!child || child.isExtra) { + continue; + } + + if (child.type === 'keyword_argument') { + const keyword = child.childForFieldName('name')?.text ?? ''; + const value = child.childForFieldName('value'); + entries.push({ + node: value ?? child, + baseKind: + keyword === 'metaclass' + ? PythonBaseKind.KEYWORD_METACLASS + : PythonBaseKind.KEYWORD_OTHER, + position: null, + keywordName: keyword, + baseText: EntityUtils.normalizeWhitespace(value?.text ?? ''), + isDynamic: false, + }); + continue; + } + + const kind = this.baseKindOf(child); + entries.push({ + node: child, + baseKind: kind, + position: position++, + keywordName: '', + baseText: EntityUtils.normalizeWhitespace(child.text), + isDynamic: kind === PythonBaseKind.CALL || kind === PythonBaseKind.STARRED, + }); + } + return entries; + } + + private emitBases(bases: BaseEntry[], type: PyTypeRegistry): void { + for (const base of bases) { + const builder = PyTypeBaseRegistry.builder( + base.baseKind, + base.baseText, + type.getHash(), + this.input.module.getHash(), + base.node.startPosition.row + 1, + this.input.serviceVersionLinkHash + ) + .withKeywordName(base.keywordName) + .withIsDynamic(base.isDynamic) + .withNameParts(this.baseSimpleNameOf(base), this.dottedPathOf(base.node)); + + if (base.position !== null) { + builder.withPosition(base.position); + } + const row = builder.build(); + this.typeBases.push(row); + // A positional base and `metaclass=` name TYPES. Any other keyword — + // `total=False` on a TypedDict — is a value, so it gets no type reference + // and correspondingly no twin link. + if (base.keywordName === '' || base.keywordName === 'metaclass') { + this.typePositions.push({ + node: base.node, + context: + base.keywordName === 'metaclass' + ? PythonTypeRefContext.METACLASS + : PythonTypeRefContext.BASE_CLASS, + ownerHash: row.getHash(), + ownerKind: PythonTypeRefOwnerKind.TYPE_BASE, + enclosingTypeHash: type.getHash(), + scopeHash: '', + }); + } + } + } + + /** + * `baseSimpleName` for a base, or `''` when the base is not name-shaped. + * + * §2.5 c3 says "rightmost identifier — `Mapping`; `\"\"` if not name-shaped", + * and a computed base is not name-shaped: + * + * ```python + * class _Method(_namedtuple('_Method', 'name ident')): ... # CALL + * class _swapped_meta(type(Structure)): ... # CALL + * ``` + * + * Returning the CALLEE's name for those would claim the class inherits from + * `_namedtuple` or `type`, when it inherits from whatever the call returned. + * The text is not lost — `baseText` keeps `_namedtuple('_Method', …)` verbatim + * and `isDynamic` marks it — but the NAME slot has to stay empty, or a + * name→type resolver will resolve it to the wrong thing. + */ + private baseSimpleNameOf(base: BaseEntry): string { + switch (base.baseKind) { + case PythonBaseKind.CALL: + case PythonBaseKind.STARRED: { + return ''; + } + default: { + return this.rightmostName(base.node); + } + } + } + + private baseKindOf(node: Parser.SyntaxNode): PythonBaseKind { + switch (node.type) { + case 'identifier': { + return PythonBaseKind.NAME; + } + case 'attribute': { + return PythonBaseKind.DOTTED_NAME; + } + case 'subscript': + case 'generic_type': { + return PythonBaseKind.SUBSCRIPT; + } + case 'call': { + return PythonBaseKind.CALL; + } + case 'list_splat': { + return PythonBaseKind.STARRED; + } + default: { + return PythonBaseKind.NAME; + } + } + } + + /** + * Distinguishes a trivial MRO from a real C3 linearisation from one that + * cannot be linearised at all. + * + * 19.5% of classes have no explicit base and 12.1% have more than one, so all + * four answers occur often enough to matter. + */ + private mroKindOf(bases: BaseEntry[]): PythonMroKind { + const positional = bases.filter(b => b.keywordName === ''); + if (positional.some(b => b.isDynamic)) { + return PythonMroKind.DYNAMIC_UNKNOWN; + } + if (positional.length === 0) { + return PythonMroKind.IMPLICIT_OBJECT; + } + if (positional.length === 1) { + return PythonMroKind.SINGLE_INHERITANCE; + } + return PythonMroKind.C3_LINEARIZABLE; + } + + /** + * The rightmost name of the `metaclass=` keyword argument, or `''`. + * + * Extracted separately because a metaclass is a **classification signal** even + * though it is not a base. `class C(metaclass=ABCMeta)` and `class C(ABC)` are + * the same thing to a reader and to `isinstance`, but only the second is a + * positional base — so filtering keyword entries out before classifying makes + * the first invisible. + */ + private metaclassSimpleName(bases: BaseEntry[]): string { + const metaclass = bases.find(b => b.keywordName === 'metaclass'); + if (!metaclass) { + return ''; + } + // Handles both `metaclass=ABCMeta` and `metaclass=abc.ABCMeta`. + return this.rightmostName(metaclass.node) || metaclass.baseText.split('.').pop() || ''; + } + + /** + * Classifies a class. + * + * **Keyword bases are inspected, not discarded.** The `metaclass=` idiom is + * ordinary Python, not a curiosity: `metaclass=ABCMeta` predates the `ABC` + * convenience base and is still required when a class needs an unrelated + * metaclass mixed in, and `metaclass=EnumMeta` is how you write an Enum-like + * class that needs behaviour `Enum.__new__` does not provide. Dropping keyword + * entries before the name checks made every such class a plain `CLASS_TYPE`, + * and in the ABCMeta case produced an internally inconsistent row — + * `typeModifier=ABSTRACT` alongside `typeCategory=CLASS_TYPE` — because the + * modifier pass did not filter them and this one did. + */ + private classifyType( + bases: BaseEntry[], + decoratorNames: string[], + declaresAbstractMember: boolean + ): PythonTypeCategory { + const baseNames = bases + .filter(b => b.keywordName === '') + .map(b => this.rightmostName(b.node)); + const metaclass = this.metaclassSimpleName(bases); + + if (decoratorNames.some(d => d.endsWith('dataclass'))) { + return PythonTypeCategory.DATACLASS_TYPE; + } + // A metaclass names the kind of class this IS, so it is checked with the + // same equality tests applied to positional bases. + if (metaclass === 'ABCMeta') { + return PythonTypeCategory.ABC_TYPE; + } + if (metaclass === 'EnumMeta' || metaclass === 'EnumType') { + return PythonTypeCategory.ENUM_CLASS_TYPE; + } + if (baseNames.some(n => n === 'Protocol')) { + return PythonTypeCategory.PROTOCOL_TYPE; + } + if (baseNames.some(n => n === 'TypedDict')) { + return PythonTypeCategory.TYPEDDICT_TYPE; + } + if (baseNames.some(n => n === 'NamedTuple' || n === 'namedtuple')) { + return PythonTypeCategory.NAMEDTUPLE_TYPE; + } + if (baseNames.some(n => /^(Enum|IntEnum|StrEnum|Flag|IntFlag)$/.test(n))) { + return PythonTypeCategory.ENUM_CLASS_TYPE; + } + if (baseNames.some(n => n === 'type')) { + return PythonTypeCategory.METACLASS_TYPE; + } + if (baseNames.some(n => n === 'ABC') || baseNames.some(n => n === 'ABCMeta')) { + return PythonTypeCategory.ABC_TYPE; + } + // A class that declares @abstractmethod members IS an abstract base, even + // when it inherits ABCMeta through a base rather than naming it. Without + // this the row is self-contradictory — typeCategory=CLASS_TYPE alongside an + // ABSTRACT modifier — which is what `numbers.Complex`, `Real`, `Rational` + // and `Integral` all produced. Purely syntactic: the decorators are right + // there in the class body, so no cross-module knowledge is needed. + if (declaresAbstractMember) { + return PythonTypeCategory.ABC_TYPE; + } + if (baseNames.some(n => /(Error|Exception|Warning)$/.test(n))) { + return PythonTypeCategory.EXCEPTION_CLASS_TYPE; + } + if (baseNames.some(n => n === 'Generic')) { + return PythonTypeCategory.GENERIC_TYPE; + } + return PythonTypeCategory.CLASS_TYPE; + } + + private typeModifiersOf( + bases: BaseEntry[], + decoratorNames: string[], + bodyNode: Parser.SyntaxNode | null + ): PythonTypeModifier[] { + const modifiers: PythonTypeModifier[] = []; + // Positional bases and the metaclass are considered separately, so the two + // classification passes cannot disagree about whether a keyword entry + // counts — which is exactly the inconsistency this replaced. + const baseNames = bases + .filter(b => b.keywordName === '') + .map(b => this.rightmostName(b.node)); + const metaclass = this.metaclassSimpleName(bases); + + if (decoratorNames.some(d => d.endsWith('final'))) { + modifiers.push(PythonTypeModifier.FINAL); + } + if (decoratorNames.some(d => d.includes('dataclass')) && /frozen\s*=\s*True/.test( + bases.map(b => b.baseText).join(' ') + )) { + modifiers.push(PythonTypeModifier.FROZEN); + } + if (decoratorNames.some(d => d.endsWith('runtime_checkable'))) { + modifiers.push(PythonTypeModifier.RUNTIME_CHECKABLE); + } + if (baseNames.some(n => n === 'ABC' || n === 'ABCMeta') || metaclass === 'ABCMeta') { + modifiers.push(PythonTypeModifier.ABSTRACT); + } + if (baseNames.some(n => n === 'Generic' || n === 'Protocol')) { + modifiers.push(PythonTypeModifier.GENERIC); + } + + if (bodyNode) { + const members = this.classBodyMemberNames(bodyNode); + if (members.has('__slots__')) { + modifiers.push(PythonTypeModifier.SLOTS); + } + // The dispatch escape hatches: a class answering for names that appear + // nowhere in the source means "attribute not found" is not a safe + // conclusion about it. + if (members.has('__getattr__') || members.has('__getattribute__')) { + modifiers.push(PythonTypeModifier.HAS_GETATTR); + } + if (members.has('__setattr__')) { + modifiers.push(PythonTypeModifier.HAS_SETATTR); + } + if (members.has('__call__')) { + // HAS_CALL only. `CALLABLE_INSTANCE` is not emitted: a class defines + // `__call__` if and only if its instances are callable, so as the schema + // defines them today the two are COEXTENSIVE and emitting both puts the + // same fact in the row twice under two names. A consumer counting + // modifiers would double-count, and a rule keying on one would silently + // depend on which name happened to be written. + // + // The oracle emits HAS_CALL alone, so emitting both also showed as a + // disagreement — the correct outcome for a field with no agreed meaning. + // A0 has the schema call open (remove the value, or redefine the pair as + // declared-here versus callable-including-inherited, both of which are + // detectable). Until that lands, emitting the one value that is defined + // is the honest option, and the alternative — inventing a distinction to + // justify keeping both — is the thing to avoid. + modifiers.push(PythonTypeModifier.HAS_CALL); + } + if (this.hasAbstractMember(bodyNode)) { + modifiers.push(PythonTypeModifier.ABSTRACT); + } + } + return Array.from(new Set(modifiers)); + } + + private classBodyMemberNames(bodyNode: Parser.SyntaxNode): Set { + const names = new Set(); + for (let i = 0; i < bodyNode.namedChildCount; i++) { + const statement = bodyNode.namedChild(i); + if (!statement) { + continue; + } + const definition = + statement.type === 'decorated_definition' + ? statement.namedChild(statement.namedChildCount - 1) + : statement; + if (definition?.type === 'function_definition') { + const name = definition.childForFieldName('name')?.text; + if (name) { + names.add(name); + } + continue; + } + if (statement.type === 'expression_statement') { + const assignment = statement.namedChild(0); + if (assignment?.type === 'assignment') { + const left = assignment.childForFieldName('left'); + if (left?.type === 'identifier') { + names.add(left.text); + } + } + } + } + return names; + } + + private hasAbstractMember(bodyNode: Parser.SyntaxNode): boolean { + for (let i = 0; i < bodyNode.namedChildCount; i++) { + const statement = bodyNode.namedChild(i); + if (statement?.type !== 'decorated_definition') { + continue; + } + for (let j = 0; j < statement.namedChildCount; j++) { + const decorator = statement.namedChild(j); + if (decorator?.type === 'decorator' && /abstract/.test(decorator.text)) { + return true; + } + } + } + return false; + } + + private placementOf(context: DeclarationContext): PythonTypePlacement { + if (context.isTypeCheckingOnly) { + return PythonTypePlacement.TYPE_CHECKING_PLACEMENT; + } + if (context.inFunctionBody) { + return PythonTypePlacement.LOCAL_PLACEMENT; + } + if (context.inClassBody) { + return PythonTypePlacement.NESTED_PLACEMENT; + } + if (context.isConditional) { + return PythonTypePlacement.CONDITIONAL_PLACEMENT; + } + return PythonTypePlacement.TOP_LEVEL_PLACEMENT; + } + + // --------------------------------------------------------------- methods + + private visitFunctionDefinition( + node: Parser.SyntaxNode, + context: DeclarationContext, + decorators: Parser.SyntaxNode[] + ): void { + const nameNode = node.childForFieldName('name'); + const parametersNode = node.childForFieldName('parameters'); + const returnTypeNode = node.childForFieldName('return_type'); + const bodyNode = node.childForFieldName('body'); + const functionName = nameNode?.text ?? ''; + const scopeHash = this.input.scopeHashByNodeId.get(node.id) ?? ''; + const qualifiedName = + this.input.qualifiedNameByNodeId.get(node.id) ?? + `${this.input.module.getQualifiedName()}.${functionName}`; + + const parameters = this.collectParameters(parametersNode); + const decoratorNames = decorators.map(d => this.decoratorDottedPath(d)); + const isAsync = this.hasAsyncPrefix(node); + const isGenerator = bodyNode !== null && this.containsYield(bodyNode); + const methodEnd = this.declarationEndPosition(node); + + const method = PyMethodRegistry.builder( + functionName, + this.buildSignature(functionName, parameters), + qualifiedName, + this.input.filePath, + node.startPosition.row + 1, + methodEnd.row + 1, + this.input.positions.byteColumn(node.startPosition.row, node.startPosition.column), + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withDetailedSignature(this.buildDetailedSignature(functionName, parameters, returnTypeNode)) + .withOwner( + context.enclosingTypeHash, + context.enclosingTypeName, + context.enclosingTypeQualifiedName + ) + .withKindAndAccess( + this.methodKindOf(functionName, decoratorNames, context, isAsync, isGenerator), + this.methodAccessOf(functionName) + ) + .withModifiers(this.methodModifiersOf(decoratorNames, isAsync, isGenerator, functionName)) + .withReturnTypeName(returnTypeNode ? this.normalizeTypeText(returnTypeNode.text) : '') + .withParameterShape({ + parameterCount: parameters.filter(p => p.name !== '').length, + posOnlyCount: parameters.filter(p => p.kind === PythonParameterKind.POSITIONAL_ONLY).length, + kwOnlyCount: parameters.filter(p => p.kind === PythonParameterKind.KEYWORD_ONLY).length, + isVarArgs: parameters.some(p => p.kind === PythonParameterKind.VAR_POSITIONAL), + hasKwArgs: parameters.some(p => p.kind === PythonParameterKind.VAR_KEYWORD), + hasReceiverParameter: this.hasReceiver(parameters, context, decoratorNames, functionName), + }) + .withBodyFlags({ + isAsync, + isGenerator, + bodyIsStub: bodyNode !== null && this.bodyIsStub(bodyNode), + decoratorCount: decorators.length, + }) + .withThrowsExceptions(bodyNode ? this.collectRaisedTypeNames(bodyNode) : []) + .withScopeLinkHash(scopeHash) + .withEnclosingMemberLinkHash(context.inFunctionBody ? context.enclosingMethodHash : '') + .withEndColumn(this.input.positions.byteColumn(methodEnd.row, methodEnd.column)) + .build(); + + this.methods.push(method); + this.methodHashByNodeId.set(node.id, method.getHash()); + if (returnTypeNode) { + // The one type position that has NO home on the spine: py_method carries + // returnTypeName as text with no resolved counterpart, so without this the + // return type is reachable only through the expression tree. + this.typePositions.push({ + node: returnTypeNode, + context: PythonTypeRefContext.METHOD_RETURN, + ownerHash: method.getHash(), + ownerKind: PythonTypeRefOwnerKind.METHOD, + enclosingTypeHash: context.enclosingTypeHash, + scopeHash, + }); + } + this.scopeOwnerByNodeId.set(node.id, method.getHash()); + this.enclosingMethodByScopeNodeId.set(node.id, method.getHash()); + method.setDeclaringBindingLinkHash( + this.input.bindingHashByScopeAndName.get( + `${context.bindingScopeHash}::${functionName}` + ) ?? '' + ); + + this.emitParameters(parameters, method, scopeHash, context, decoratorNames); + + if (!bodyNode) { + return; + } + this.visitBody(bodyNode, { + ...context, + enclosingMethodHash: method.getHash(), + bindingScopeHash: scopeHash, + inClassBody: false, + inFunctionBody: true, + }); + } + + /** + * Collects parameters in order, including the bare `/` and `*` markers. + * + * The markers get rows with an empty name because they occupy a position and + * change the meaning of every parameter after them — dropping them would make + * a positional-only signature indistinguishable from a normal one. + */ + private collectParameters(parametersNode: Parser.SyntaxNode | null): ParameterEntry[] { + if (!parametersNode) { + return []; + } + const entries: ParameterEntry[] = []; + let seenKeywordSeparator = false; + let position = 0; + + for (let i = 0; i < parametersNode.namedChildCount; i++) { + const param = parametersNode.namedChild(i); + // Grammar extras — a comment or line continuation inside the parameter + // list — are NAMED nodes. Counting them as parameters inflates + // kwOnlyCount, which aiohttp's `debug: Any = ..., # comment` exposes. + if (!param || param.isExtra) { + continue; + } + + if (param.type === 'positional_separator') { + entries.push(this.markerEntry(param, PythonParameterKind.POSITIONAL_ONLY_MARKER, position++)); + // Everything BEFORE `/` is retroactively positional-only. + for (const earlier of entries) { + if (earlier.kind === PythonParameterKind.POSITIONAL_OR_KEYWORD) { + earlier.kind = PythonParameterKind.POSITIONAL_ONLY; + } + } + continue; + } + + if (param.type === 'keyword_separator') { + seenKeywordSeparator = true; + entries.push(this.markerEntry(param, PythonParameterKind.KEYWORD_ONLY_MARKER, position++)); + continue; + } + + const splat = this.splatKindOf(param); + if (splat === 'list') { + seenKeywordSeparator = true; + } + + const kind = + splat === 'list' + ? PythonParameterKind.VAR_POSITIONAL + : splat === 'dictionary' + ? PythonParameterKind.VAR_KEYWORD + : seenKeywordSeparator + ? PythonParameterKind.KEYWORD_ONLY + : PythonParameterKind.POSITIONAL_OR_KEYWORD; + + const nameNode = this.parameterNameNode(param); + const typeNode = param.childForFieldName('type'); + const valueNode = param.childForFieldName('value'); + + entries.push({ + node: param, + name: nameNode?.text ?? '', + kind, + position: position++, + annotation: typeNode ? this.normalizeTypeText(typeNode.text) : '', + annotationIsString: typeNode?.namedChild(0)?.type === 'string', + defaultNode: valueNode ?? null, + }); + } + + return entries; + } + + private markerEntry( + node: Parser.SyntaxNode, + kind: PythonParameterKind, + position: number + ): ParameterEntry { + return { + node, + name: '', + kind, + position, + annotation: '', + annotationIsString: false, + defaultNode: null, + }; + } + + private parameterNameNode(param: Parser.SyntaxNode): Parser.SyntaxNode | null { + if (param.type === 'identifier') { + return param; + } + const direct = param.childForFieldName('name') ?? param.namedChild(0); + if (direct?.type === 'identifier') { + return direct; + } + // An annotated splat nests one level deeper than a bare one. + if (direct?.type === 'list_splat_pattern' || direct?.type === 'dictionary_splat_pattern') { + const inner = direct.namedChild(0); + return inner?.type === 'identifier' ? inner : null; + } + return null; + } + + private splatKindOf(param: Parser.SyntaxNode): 'list' | 'dictionary' | null { + if (param.type === 'list_splat_pattern') { + return 'list'; + } + if (param.type === 'dictionary_splat_pattern') { + return 'dictionary'; + } + for (let i = 0; i < param.namedChildCount; i++) { + const child = param.namedChild(i); + if (child?.type === 'list_splat_pattern') { + return 'list'; + } + if (child?.type === 'dictionary_splat_pattern') { + return 'dictionary'; + } + } + return null; + } + + private emitParameters( + parameters: ParameterEntry[], + method: PyMethodRegistry, + scopeHash: string, + context: DeclarationContext, + decoratorNames: string[] + ): void { + const receiverIndex = this.receiverIndexOf(parameters, context, decoratorNames, method.getName()); + + for (const parameter of parameters) { + const builder = PyMethodParameterRegistry.builder( + parameter.name, + parameter.position, + method.getHash(), + parameter.kind, + parameter.node.startPosition.row + 1, + parameter.node.endPosition.row + 1, + this.input.serviceVersionLinkHash + ) + .withAnnotation( + parameter.annotation, + this.annotationBaseType(parameter.annotation), + parameter.annotationIsString + ) + .withIsReceiverParameter(parameter.position === receiverIndex) + .withBindingLinkHash( + this.input.bindingHashByScopeAndName.get(`${scopeHash}::${parameter.name}`) ?? '' + ); + + if (parameter.defaultNode) { + builder.withDefault( + EntityUtils.normalizeWhitespace(parameter.defaultNode.text), + this.defaultValueKindOf(parameter.defaultNode) + ); + } + const row = builder.build(); + if (parameter.defaultNode) { + // Unwrap parentheses before recording the range. The expression stage + // treats a parenthesized_expression as transparent and emits a row for + // the expression INSIDE it, so recording the outer node's range here + // would make the two stages describe different nodes and the join would + // silently find nothing — as it did for `def f(seed=(computed := 42))`. + const defaultNode = this.unwrapParentheses(parameter.defaultNode); + this.parameterDefaultByteRange.set( + row.getHash(), + `${defaultNode.startIndex}:${defaultNode.endIndex}` + ); + } + // The annotation node's range, so the expression stage can make this + // parameter the OWNER of its annotation subtree. That ownership is the + // only route from a parameter row to the N types its annotation + // references: the row has one potentialQualifiedName slot, which cannot + // express `Dict[TypeA, TypeB]`. + const annotationNode = parameter.node.childForFieldName('type'); + if (annotationNode) { + this.parameterHashByAnnotationRange.set( + `${annotationNode.startIndex}:${annotationNode.endIndex}`, + row.getHash() + ); + this.typePositions.push({ + node: annotationNode, + context: PythonTypeRefContext.METHOD_PARAM, + ownerHash: row.getHash(), + ownerKind: PythonTypeRefOwnerKind.METHOD_PARAM, + enclosingTypeHash: context.enclosingTypeHash, + scopeHash, + }); + } + this.methodParameters.push(row); + } + } + + /** + * Which parameter is the receiver, or -1 for none. + * + * A `@staticmethod` has no receiver, so **every** positional argument shifts + * by one relative to an instance method. Getting this wrong misaligns + * argument→parameter flow for the whole signature. + */ + private receiverIndexOf( + parameters: ParameterEntry[], + context: DeclarationContext, + decoratorNames: string[], + methodName: string + ): number { + if (!context.inClassBody) { + return -1; + } + if (decoratorNames.some(d => d.endsWith('staticmethod'))) { + return -1; + } + // `__new__` is an implicit staticmethod, but its first parameter IS the + // class, so it still has a receiver — unlike a written @staticmethod. + void methodName; + const first = parameters[0]; + if (!first || first.name === '') { + return -1; + } + if ( + first.kind !== PythonParameterKind.POSITIONAL_OR_KEYWORD && + first.kind !== PythonParameterKind.POSITIONAL_ONLY + ) { + return -1; + } + return first.position; + } + + private hasReceiver( + parameters: ParameterEntry[], + context: DeclarationContext, + decoratorNames: string[], + methodName: string + ): boolean { + return this.receiverIndexOf(parameters, context, decoratorNames, methodName) >= 0; + } + + /** Strips redundant parentheses, which carry no expression of their own. */ + private unwrapParentheses(node: Parser.SyntaxNode): Parser.SyntaxNode { + let current = node; + while (current.type === 'parenthesized_expression') { + const inner = current.namedChild(0); + if (!inner) { + return current; + } + current = inner; + } + return current; + } + + private defaultValueKindOf(node: Parser.SyntaxNode): PythonDefaultValueKind { + switch (node.type) { + case 'none': { + return PythonDefaultValueKind.NONE_LITERAL; + } + case 'true': + case 'false': { + return PythonDefaultValueKind.BOOL; + } + case 'string': + case 'concatenated_string': { + return PythonDefaultValueKind.STRING; + } + case 'integer': + case 'float': { + return PythonDefaultValueKind.NUMBER; + } + case 'list': { + return PythonDefaultValueKind.LIST; + } + case 'dictionary': { + return PythonDefaultValueKind.DICT; + } + case 'set': { + return PythonDefaultValueKind.SET; + } + case 'tuple': { + return PythonDefaultValueKind.TUPLE; + } + case 'call': { + return PythonDefaultValueKind.CALL; + } + case 'identifier': + case 'attribute': { + return PythonDefaultValueKind.NAME; + } + case 'lambda': { + return PythonDefaultValueKind.LAMBDA; + } + case 'ellipsis': { + return PythonDefaultValueKind.ELLIPSIS; + } + case 'unary_operator': { + // A negative number literal is still a number. + const operand = node.namedChild(0); + if (operand?.type === 'integer' || operand?.type === 'float') { + return PythonDefaultValueKind.NUMBER; + } + return PythonDefaultValueKind.UNKNOWN; + } + default: { + return PythonDefaultValueKind.UNKNOWN; + } + } + } + + private methodKindOf( + name: string, + decoratorNames: string[], + context: DeclarationContext, + isAsync: boolean, + isGenerator: boolean + ): PythonMethodKind { + if (decoratorNames.some(d => d.endsWith('overload'))) { + return PythonMethodKind.OVERLOAD_STUB; + } + // Python converts three dunders IMPLICITLY, with no decorator written: + // `__new__` becomes a staticmethod, and `__init_subclass__` and + // `__class_getitem__` become classmethods. That changes what argument 0 is, + // so it shifts every positional argument→parameter link — the same hazard as + // a missed @classmethod. `__new__` keeps the more specific ALLOCATOR, which + // the schema designates for it. + if (name === '__init_subclass__' || name === '__class_getitem__') { + return PythonMethodKind.CLASS_METHOD; + } + if (decoratorNames.some(d => d.endsWith('staticmethod'))) { + return PythonMethodKind.STATIC_METHOD; + } + if (decoratorNames.some(d => d.endsWith('classmethod'))) { + return PythonMethodKind.CLASS_METHOD; + } + // `functools.cached_property` is `@property` with a memo: a descriptor whose `__get__` + // runs the decorated body once and caches the result, so `obj.x` is a READ THAT RUNS A + // METHOD BODY, exactly as `@property` is. It matched none of the tests here and fell + // through to INSTANCE_METHOD, so every consumer saw a plain method that is never called: + // the read was attributed as a field access rather than the call it is, the body looked + // unreached, and anything invoked on the result had no type to dispatch on, because the + // type comes from the getter's return and nothing consults it. + // + // Same two spellings the `property` test above accepts, for the same reason: bare when + // imported directly, dotted otherwise — and the suffix form also covers the third-party + // re-exports, `django.utils.functional.cached_property` among them. + // + // A cached property is simultaneously a getter and a FIELD, since the first read writes + // an instance attribute of the same name. That needs nothing extra here: the field + // extractor keys its property handling on PROPERTY_GETTER, so a cached property now + // takes exactly the path `@property` already takes, and that path only ever ADDS a + // modifier — it never suppresses the field. Checked both ways on a class that declares + // the getter and also writes the attribute: the field keeps its row and its origin, and + // the row is identical to the one the `@property` spelling produces. + if (decoratorNames.some(d => d === 'property' || d.endsWith('.property') + || d === 'cached_property' || d.endsWith('.cached_property'))) { + return PythonMethodKind.PROPERTY_GETTER; + } + if (decoratorNames.some(d => d.endsWith('.setter'))) { + return PythonMethodKind.PROPERTY_SETTER; + } + if (decoratorNames.some(d => d.endsWith('.deleter'))) { + return PythonMethodKind.PROPERTY_DELETER; + } + if (decoratorNames.some(d => d.endsWith('abstractmethod'))) { + return PythonMethodKind.ABSTRACT_METHOD; + } + if (name === '__init__') { + return PythonMethodKind.CONSTRUCTOR; + } + if (name === '__new__') { + return PythonMethodKind.ALLOCATOR; + } + if (isAsync && isGenerator) { + return PythonMethodKind.ASYNC_GENERATOR; + } + if (isAsync) { + return PythonMethodKind.ASYNC_FUNCTION; + } + if (isGenerator) { + return PythonMethodKind.GENERATOR; + } + if (name.startsWith('__') && name.endsWith('__')) { + return PythonMethodKind.DUNDER_METHOD; + } + if (context.inClassBody) { + return PythonMethodKind.INSTANCE_METHOD; + } + if (context.inFunctionBody) { + return PythonMethodKind.NESTED_FUNCTION; + } + return PythonMethodKind.FUNCTION; + } + + private methodModifiersOf( + decoratorNames: string[], + isAsync: boolean, + isGenerator: boolean, + methodName: string + ): PythonMethodModifier[] { + const modifiers: PythonMethodModifier[] = []; + // The implicit conversions Python applies without a decorator. + if (methodName === '__init_subclass__' || methodName === '__class_getitem__') { + modifiers.push(PythonMethodModifier.CLASS); + } + if (methodName === '__new__') { + modifiers.push(PythonMethodModifier.STATIC); + } + if (isAsync) { + modifiers.push(PythonMethodModifier.ASYNC); + } + if (isGenerator) { + modifiers.push(PythonMethodModifier.GENERATOR); + } + for (const decorator of decoratorNames) { + if (decorator.endsWith('staticmethod')) { + modifiers.push(PythonMethodModifier.STATIC); + } + if (decorator.endsWith('classmethod')) { + modifiers.push(PythonMethodModifier.CLASS); + } + if (decorator === 'property' || decorator.endsWith('.property')) { + modifiers.push(PythonMethodModifier.PROPERTY); + } + if (decorator.endsWith('.setter')) { + modifiers.push(PythonMethodModifier.SETTER); + } + if (decorator.endsWith('.deleter')) { + modifiers.push(PythonMethodModifier.DELETER); + } + if (decorator.endsWith('abstractmethod')) { + modifiers.push(PythonMethodModifier.ABSTRACT); + } + if (decorator.endsWith('overload')) { + modifiers.push(PythonMethodModifier.OVERLOAD); + } + if (decorator.endsWith('final')) { + modifiers.push(PythonMethodModifier.FINAL); + } + if (/(lru_cache|^cache$|cached_property)/.test(decorator)) { + modifiers.push(PythonMethodModifier.CACHED); + } + } + return Array.from(new Set(modifiers)); + } + + private methodAccessOf(name: string): PythonMethodAccess { + if (name.startsWith('__') && name.endsWith('__')) { + return PythonMethodAccess.DUNDER_ACCESS; + } + if (name.startsWith('__')) { + return PythonMethodAccess.PRIVATE_ACCESS; + } + if (name.startsWith('_')) { + return PythonMethodAccess.PROTECTED_ACCESS; + } + return PythonMethodAccess.PUBLIC_ACCESS; + } + + private accessOf(name: string): PythonTypeAccess { + if (name.startsWith('__')) { + return PythonTypeAccess.PRIVATE_ACCESS; + } + if (name.startsWith('_')) { + return PythonTypeAccess.PROTECTED_ACCESS; + } + return PythonTypeAccess.PUBLIC_ACCESS; + } + + /** + * Whether a body is only `...`, `pass`, or a docstring. + * + * These must never be call targets: `@overload` signatures (567 measured), + * `Protocol` members and `.pyi` bodies all look like functions and implement + * nothing. + */ + private bodyIsStub(bodyNode: Parser.SyntaxNode): boolean { + let meaningful = 0; + for (let i = 0; i < bodyNode.namedChildCount; i++) { + const statement = bodyNode.namedChild(i); + if (!statement) { + continue; + } + if (statement.type === 'pass_statement') { + continue; + } + if (statement.type === 'expression_statement') { + const inner = statement.namedChild(0); + if (inner?.type === 'string' || inner?.type === 'ellipsis') { + continue; + } + } + meaningful += 1; + } + return meaningful === 0; + } + + /** + * Type names appearing in `raise X` in this body. + * + * **Inferred, not declared** — Python has no `throws` clause. This is a lower + * bound: it cannot see what a callee raises, and it stops at nested scope + * boundaries so a nested function's raises are not attributed here. + */ + private collectRaisedTypeNames(bodyNode: Parser.SyntaxNode): string[] { + const names: string[] = []; + const worklist: Parser.SyntaxNode[] = [bodyNode]; + while (worklist.length > 0) { + const node = worklist.pop(); + if (!node) { + continue; + } + if (node.type === 'raise_statement') { + const target = node.namedChild(0); + if (target) { + const name = this.rightmostName( + target.type === 'call' ? (target.childForFieldName('function') ?? target) : target + ); + if (name) { + names.push(name); + } + } + continue; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if ( + child && + child.type !== 'function_definition' && + child.type !== 'class_definition' && + child.type !== 'decorated_definition' + ) { + worklist.push(child); + } + } + } + return Array.from(new Set(names)).sort(); + } + + private buildSignature(name: string, parameters: ParameterEntry[]): string { + const parts = parameters.map(p => { + if (p.kind === PythonParameterKind.POSITIONAL_ONLY_MARKER) { + return '/'; + } + if (p.kind === PythonParameterKind.KEYWORD_ONLY_MARKER) { + return '*'; + } + if (p.kind === PythonParameterKind.VAR_POSITIONAL) { + return `*${p.name}`; + } + if (p.kind === PythonParameterKind.VAR_KEYWORD) { + return `**${p.name}`; + } + return p.name; + }); + return `${name}(${parts.join(', ')})`; + } + + private buildDetailedSignature( + name: string, + parameters: ParameterEntry[], + returnTypeNode: Parser.SyntaxNode | null + ): string { + const parts = parameters.map(p => { + if (p.kind === PythonParameterKind.POSITIONAL_ONLY_MARKER) { + return '/'; + } + if (p.kind === PythonParameterKind.KEYWORD_ONLY_MARKER) { + return '*'; + } + const prefix = + p.kind === PythonParameterKind.VAR_POSITIONAL + ? '*' + : p.kind === PythonParameterKind.VAR_KEYWORD + ? '**' + : ''; + const annotation = p.annotation ? `: ${p.annotation}` : ''; + const dflt = p.defaultNode + ? ` = ${EntityUtils.normalizeWhitespace(p.defaultNode.text)}` + : ''; + return `${prefix}${p.name}${annotation}${dflt}`; + }); + const returns = returnTypeNode ? ` -> ${this.normalizeTypeText(returnTypeNode.text)}` : ''; + return `${name}(${parts.join(', ')})${returns}`; + } + + // --------------------------------------------------------------- imports + + /** + * Emits **one row per bound name**, which is the rule that decides row counts. + * + * ```python + * import a.b.c # ONE row: binds `a`, importedPath `a.b.c` + * from m import x, y # TWO rows + * from . import sib # ONE row, relativeLevel 1 + * from .m import * # ONE row, binds nothing knowable + * ``` + */ + private visitImport(node: Parser.SyntaxNode, context: DeclarationContext): void { + const isFrom = + node.type === 'import_from_statement' || node.type === 'future_import_statement'; + const line = node.startPosition.row + 1; + const moduleNameNode = node.childForFieldName('module_name'); + const relativeLevel = this.relativeLevelOf(moduleNameNode); + const modulePath = this.moduleTextOf(moduleNameNode, node); + + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || (isFrom && child.id === moduleNameNode?.id)) { + continue; + } + + if (child.type === 'wildcard_import') { + this.pushImport( + relativeLevel > 0 ? PythonImportKind.RELATIVE_WILDCARD : PythonImportKind.FROM_WILDCARD, + modulePath, + '*', + line, + context, + { isWildcard: true, relativeLevel, packageOrTypeName: modulePath } + ); + continue; + } + + if (child.type === 'aliased_import') { + const original = child.namedChild(0)?.text ?? ''; + const alias = child.childForFieldName('alias')?.text ?? ''; + const kind = isFrom + ? node.type === 'future_import_statement' + ? PythonImportKind.FUTURE + : relativeLevel > 0 + ? PythonImportKind.RELATIVE_MEMBER + : PythonImportKind.FROM_MEMBER_ALIAS + : PythonImportKind.MODULE_IMPORT_ALIAS; + this.pushImport( + kind, + isFrom ? this.joinModulePath(modulePath, original) : original, + alias, + line, + context, + { + relativeLevel, + originalName: original, + aliasName: alias, + isModuleImport: !isFrom, + packageOrTypeName: isFrom ? modulePath : '', + } + ); + continue; + } + + if (child.type !== 'dotted_name') { + continue; + } + + if (!isFrom) { + // `import a.b.c` binds ONLY `a`; `b` and `c` are reached by attribute + // access afterwards and are not bindings. + const bound = child.namedChild(0)?.text ?? child.text; + this.pushImport( + PythonImportKind.MODULE_IMPORT, + child.text, + bound, + line, + context, + { isModuleImport: true, originalName: child.text } + ); + continue; + } + + const member = child.text; + const kind = + node.type === 'future_import_statement' + ? PythonImportKind.FUTURE + : relativeLevel > 0 + ? PythonImportKind.RELATIVE_MEMBER + : PythonImportKind.FROM_MEMBER; + this.pushImport(kind, this.joinModulePath(modulePath, member), member, line, context, { + relativeLevel, + originalName: member, + packageOrTypeName: modulePath, + }); + } + } + + private pushImport( + kind: PythonImportKind, + importedPath: string, + simpleName: string, + line: number, + context: DeclarationContext, + options: { + relativeLevel?: number; + originalName?: string; + aliasName?: string; + isWildcard?: boolean; + isModuleImport?: boolean; + packageOrTypeName?: string; + } + ): void { + const record = PyImportRegistry.builder( + kind, + importedPath, + simpleName, + this.input.filePath, + line, + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withRelativeLevel(options.relativeLevel ?? 0) + .withNames(options.originalName ?? simpleName, options.aliasName ?? '') + .withPackageOrTypeName(options.packageOrTypeName ?? '') + .withFlags({ + isWildcard: options.isWildcard ?? false, + isModuleImport: options.isModuleImport ?? false, + isTypeCheckingOnly: context.isTypeCheckingOnly, + isConditional: context.isConditional, + }) + .withPyScopeLinkHash(context.bindingScopeHash) + .withBindingLinkHash( + this.input.bindingHashByScopeAndName.get( + `${context.bindingScopeHash}::${simpleName}` + ) ?? '' + ) + .build(); + this.imports.push(record); + } + + /** Leading dots on a relative import: `from ..pkg import x` is level 2. */ + private relativeLevelOf(moduleNameNode: Parser.SyntaxNode | null): number { + if (!moduleNameNode || moduleNameNode.type !== 'relative_import') { + return 0; + } + for (let i = 0; i < moduleNameNode.namedChildCount; i++) { + const child = moduleNameNode.namedChild(i); + if (child?.type === 'import_prefix') { + return child.text.length; + } + } + return 0; + } + + private moduleTextOf( + moduleNameNode: Parser.SyntaxNode | null, + node: Parser.SyntaxNode + ): string { + if (!moduleNameNode) { + return node.type === 'future_import_statement' ? '__future__' : ''; + } + if (moduleNameNode.type !== 'relative_import') { + return moduleNameNode.text; + } + for (let i = 0; i < moduleNameNode.namedChildCount; i++) { + const child = moduleNameNode.namedChild(i); + if (child?.type === 'dotted_name') { + return child.text; + } + } + return ''; + } + + private joinModulePath(modulePath: string, member: string): string { + return modulePath.length === 0 ? member : `${modulePath}.${member}`; + } + + // --------------------------------------------------------------- helpers + + private decoratorDottedPath(decorator: Parser.SyntaxNode): string { + const inner = decorator.namedChild(0); + if (!inner) { + return ''; + } + // `@deco(arg)` — the decorator's identity is the callee, not the call. + const target = inner.type === 'call' ? (inner.childForFieldName('function') ?? inner) : inner; + return EntityUtils.normalizeWhitespace(target.text); + } + + private rightmostName(node: Parser.SyntaxNode): string { + switch (node.type) { + case 'identifier': { + return node.text; + } + case 'attribute': { + return node.childForFieldName('attribute')?.text ?? ''; + } + case 'dotted_name': { + return node.namedChild(node.namedChildCount - 1)?.text ?? ''; + } + case 'subscript': + case 'generic_type': { + const value = node.childForFieldName('value') ?? node.namedChild(0); + return value ? this.rightmostName(value) : ''; + } + case 'call': { + const fn = node.childForFieldName('function'); + return fn ? this.rightmostName(fn) : ''; + } + default: { + return ''; + } + } + } + + private dottedPathOf(node: Parser.SyntaxNode): string { + if (node.type === 'identifier' || node.type === 'dotted_name') { + return node.text; + } + if (node.type === 'attribute') { + return EntityUtils.normalizeWhitespace(node.text); + } + return ''; + } + + /** An annotation minus its subscripts: `Optional[User]` -> `Optional`. */ + private annotationBaseType(annotation: string): string { + if (annotation.length === 0) { + return ''; + } + const bracket = annotation.indexOf('['); + return bracket < 0 ? annotation : annotation.slice(0, bracket); + } + + private docstringOf(bodyNode: Parser.SyntaxNode | null): string { + if (!bodyNode) { + return ''; + } + const first = bodyNode.namedChild(0); + if (first?.type !== 'expression_statement') { + return ''; + } + const literal = first.namedChild(0); + if (literal?.type !== 'string') { + return ''; + } + for (let i = 0; i < literal.namedChildCount; i++) { + const part = literal.namedChild(i); + if (part?.type === 'string_content') { + return EntityUtils.normalizeWhitespace(part.text); + } + } + return ''; + } + + /** + * The end position of a declaration, **excluding trailing comments**. + * + * tree-sitter's `function_definition` extends to the last token inside the + * indented block, which includes a trailing comment; CPython's `ast` reports + * `end_lineno` as the last line of the last *statement*. They differ: + * + * ```python + * def flush(self): + * self._checkClosed() + * # XXX Should this return the number of bytes written??? + * # ^ tree-sitter ends here, ast ends on the line above + * ``` + * + * `ast` is the reference definition of a declaration's extent, and it is also + * load-bearing: `endLine` is part of the `py_type` primary key, so following + * tree-sitter would make a class's identity change when someone appends a + * comment to its last method. Comments carry their own spans in their own + * relation and do not need to fall inside a method's. + */ + private declarationEndPosition(node: Parser.SyntaxNode): { row: number; column: number } { + for (let i = node.childCount - 1; i >= 0; i--) { + const child = node.child(i); + if (!child || child.type === 'comment') { + continue; + } + return this.declarationEndPosition(child); + } + return { row: node.endPosition.row, column: node.endPosition.column }; + } + + /** + * Canonicalises annotation text so a multi-line annotation reads the same as + * the single-line spelling of the same type. + * + * Without this, a `-> typing.Tuple[\n int,\n str,\n]` becomes + * `typing.Tuple[ int, str, ]` while the same annotation written inline yields + * `typing.Tuple[int, str]`, so two identical types compare unequal. + */ + private normalizeTypeText(text: string): string { + return EntityUtils.normalizeWhitespace(text) + .replace(/\(\s+/g, '(') + .replace(/\s+\)/g, ')') + .replace(/\[\s+/g, '[') + .replace(/\s+\]/g, ']') + .replace(/\s+,/g, ',') + .replace(/,(?=\S)/g, ', '); + } + + private hasAsyncPrefix(node: Parser.SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.type === 'async') { + return true; + } + } + return false; + } + + private containsYield(bodyNode: Parser.SyntaxNode): boolean { + const worklist: Parser.SyntaxNode[] = [bodyNode]; + while (worklist.length > 0) { + const node = worklist.pop(); + if (!node) { + continue; + } + if (node.type === 'yield') { + return true; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type !== 'function_definition' && child.type !== 'class_definition') { + worklist.push(child); + } + } + } + return false; + } +} + +/** One entry in a class's base list, before it becomes a row. */ +interface BaseEntry { + node: Parser.SyntaxNode; + baseKind: PythonBaseKind; + /** `null` for keyword entries, which take no MRO position. */ + position: number | null; + keywordName: string; + baseText: string; + isDynamic: boolean; +} + +/** One parameter, before it becomes a row. */ +interface ParameterEntry { + node: Parser.SyntaxNode; + name: string; + kind: PythonParameterKind; + position: number; + annotation: string; + annotationIsString: boolean; + defaultNode: Parser.SyntaxNode | null; +} diff --git a/parser/src/parsers/python/extractors/python-decorator-extractor.ts b/parser/src/parsers/python/extractors/python-decorator-extractor.ts new file mode 100644 index 000000000..792aa8748 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-decorator-extractor.ts @@ -0,0 +1,521 @@ +import Parser from 'tree-sitter'; + +import { + PyDecoratorArgumentRegistry, + PyDecoratorRegistry, + PyModuleRegistry, +} from '@/analysis-types/python'; +import { + PythonBuiltinDecoratorKind, + PythonDecoratorArgumentValueType, + PythonDecoratorContext, + PythonDecoratorKind, +} from '@/enums/python/decorators'; + +/** Everything the decorator stage produces for one module. */ +export interface PythonDecoratorExtraction { + decorators: PyDecoratorRegistry[]; + decoratorArguments: PyDecoratorArgumentRegistry[]; + /** `py_decorator` PK -> the decorator expression's `startIndex:endIndex`. */ + expressionRangeByDecorator: Map; + /** `py_decorator_argument` PK -> the argument's `startIndex:endIndex`. */ + expressionRangeByArgument: Map; +} + +export interface PythonDecoratorInput { + module: PyModuleRegistry; + rootNode: Parser.SyntaxNode; + serviceVersionLinkHash: string; + typeHashByNodeId: Map; + methodHashByNodeId: Map; +} + +/** + * Builtin decorators, and whether each REPLACES what it decorates. + * + * The replacement flag is the load-bearing half. `@staticmethod` and + * `@classmethod` are descriptors that change how the function is BOUND but leave + * a call reaching the same body, so a call-graph edge to the `def` survives. + * `@lru_cache` and `@contextmanager` return a different object entirely, so + * after decoration the name does not refer to the `def` at all and an edge to it + * is a runtime lie. + * + * `@property` sits with the replacers: `obj.x` on a property is a CALL, not an + * attribute read, and treating the name as the function it decorates loses that. + */ +const BUILTIN_DECORATORS: ReadonlyMap = + new Map([ + ['staticmethod', { kind: PythonBuiltinDecoratorKind.STATICMETHOD, replaces: false }], + ['classmethod', { kind: PythonBuiltinDecoratorKind.CLASSMETHOD, replaces: false }], + ['abstractmethod', { kind: PythonBuiltinDecoratorKind.ABSTRACTMETHOD, replaces: false }], + ['overload', { kind: PythonBuiltinDecoratorKind.OVERLOAD, replaces: false }], + ['final', { kind: PythonBuiltinDecoratorKind.FINAL, replaces: false }], + ['wraps', { kind: PythonBuiltinDecoratorKind.WRAPS, replaces: false }], + ['property', { kind: PythonBuiltinDecoratorKind.PROPERTY, replaces: true }], + ['setter', { kind: PythonBuiltinDecoratorKind.SETTER, replaces: true }], + ['deleter', { kind: PythonBuiltinDecoratorKind.DELETER, replaces: true }], + ['cached_property', { kind: PythonBuiltinDecoratorKind.CACHED_PROPERTY, replaces: true }], + ['lru_cache', { kind: PythonBuiltinDecoratorKind.LRU_CACHE, replaces: true }], + ['cache', { kind: PythonBuiltinDecoratorKind.LRU_CACHE, replaces: true }], + ['dataclass', { kind: PythonBuiltinDecoratorKind.DATACLASS, replaces: false }], + ['contextmanager', { kind: PythonBuiltinDecoratorKind.CONTEXTMANAGER, replaces: true }], + ['asynccontextmanager', { kind: PythonBuiltinDecoratorKind.CONTEXTMANAGER, replaces: true }], + ]); + +/** + * Extracts `py_decorator` and `py_decorator_argument`. + * + * A decorator is not metadata. It is a call that runs at definition time and + * whose result is rebound to the decorated name, and two consequences drive the + * whole design here: + * + * 1. **They execute bottom-up.** The decorator nearest the `def` runs first, so + * the source order a reader sees is the reverse of the order that runs. Both + * are emitted — `position` for what is written, `applicationOrder` for what + * happens — because a question like "does auth run before routing" needs the + * second and only the second. + * 2. **They can replace the target.** `@lru_cache` hands back a wrapper, so the + * decorated name no longer refers to the `def`. `replacesTarget` records that, + * and without it a call graph asserts an edge the runtime does not have. + */ +export class PythonDecoratorExtractor { + private input!: PythonDecoratorInput; + private decorators: PyDecoratorRegistry[] = []; + private decoratorArguments: PyDecoratorArgumentRegistry[] = []; + private expressionRangeByDecorator = new Map(); + private expressionRangeByArgument = new Map(); + + extract(input: PythonDecoratorInput): PythonDecoratorExtraction { + this.input = input; + this.decorators = []; + this.decoratorArguments = []; + this.expressionRangeByDecorator = new Map(); + this.expressionRangeByArgument = new Map(); + + this.walk(input.rootNode, false); + + return { + decorators: this.decorators, + decoratorArguments: this.decoratorArguments, + expressionRangeByDecorator: this.expressionRangeByDecorator, + expressionRangeByArgument: this.expressionRangeByArgument, + }; + } + + /** + * Finds every `decorated_definition`. + * + * `insideFunction` is threaded because it decides `context`: a decorated `def` + * inside another function is a NESTED_FUNCTION_DECLARATION, which is the shape + * every decorator factory has internally and is worth telling apart from a + * module-level or class-level method. + */ + private walk(node: Parser.SyntaxNode, insideFunction: boolean): void { + if (node.type === 'decorated_definition') { + this.visitDecorated(node, insideFunction); + } + const entersFunction = node.type === 'function_definition' || node.type === 'lambda'; + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + this.walk(child, insideFunction || entersFunction); + } + } + } + + private visitDecorated(node: Parser.SyntaxNode, insideFunction: boolean): void { + const definition = node.childForFieldName('definition'); + if (!definition) { + return; + } + + const decoratorNodes: Parser.SyntaxNode[] = []; + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && child.type === 'decorator' && !child.isExtra) { + decoratorNodes.push(child); + } + } + if (decoratorNodes.length === 0) { + return; + } + + const isClass = definition.type === 'class_definition'; + const ownerHash = isClass + ? this.input.typeHashByNodeId.get(definition.id) ?? '' + : this.input.methodHashByNodeId.get(definition.id) ?? ''; + if (ownerHash === '') { + return; + } + const context = isClass + ? PythonDecoratorContext.TYPE_DECLARATION + : insideFunction + ? PythonDecoratorContext.NESTED_FUNCTION_DECLARATION + : PythonDecoratorContext.METHOD_DECLARATION; + + decoratorNodes.forEach((decoratorNode, position) => { + // Bottom-up: the decorator written LAST is applied FIRST. + const applicationOrder = decoratorNodes.length - 1 - position; + this.emitDecorator( + decoratorNode, + ownerHash, + context, + isClass, + position, + applicationOrder + ); + }); + } + + private emitDecorator( + decoratorNode: Parser.SyntaxNode, + ownerHash: string, + context: PythonDecoratorContext, + isClass: boolean, + position: number, + applicationOrder: number + ): void { + // The decorator node includes the `@`; the EXPRESSION is its named child. + const expression = decoratorNode.namedChild(0); + if (!expression) { + return; + } + const shape = this.shapeOf(expression); + const builtin = BUILTIN_DECORATORS.get(shape.name); + + const builder = PyDecoratorRegistry.builder( + shape.name, + shape.kind, + context, + ownerHash, + this.normalize(decoratorNode.text), + position, + decoratorNode.startPosition.row + 1, + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withApplicationOrder(applicationOrder) + .withEndLine(decoratorNode.endPosition.row + 1) + .withDottedPath(shape.dottedPath) + .withOwnerLinks(isClass ? ownerHash : '', isClass ? '' : ownerHash); + + // EMPTY for a decorator that is not a call, per §2.12. I had set it to '0' + // on the reasoning that "zero arguments" is a fact and an empty column is + // invisible to a query. A0 corrected it and is right: 0 asserts CALLED WITH + // NOTHING, and `@property` was never called at all. There is no argument + // list to have a length. `@f()` and `@f` differ in exactly this, and + // collapsing them would make the column unable to express the difference -- + // the reverse of the problem I thought I was fixing. + if (shape.arguments) { + builder.withArgumentCount(String(this.positionalAndKeyword(shape.arguments).length)); + } + if (builtin) { + builder.withBuiltin(builtin.kind, builtin.replaces); + } else { + // An unknown decorator is ASSUMED to replace its target. That is the + // conservative direction: the overwhelming majority of hand-written + // decorators return a wrapper, and claiming otherwise would assert a + // call-graph edge to a `def` the name no longer refers to. + builder.withBuiltin(PythonBuiltinDecoratorKind.NONE, true); + } + + const decorator = builder.build(); + this.decorators.push(decorator); + this.expressionRangeByDecorator.set( + decorator.getHash(), + `${expression.startIndex}:${expression.endIndex}` + ); + + if (shape.arguments) { + this.emitArguments(shape.arguments, decorator.getHash()); + } + } + + private emitArguments(args: Parser.SyntaxNode, parentHash: string): void { + this.positionalAndKeyword(args).forEach((argument, position) => { + const isKeyword = argument.type === 'keyword_argument'; + const isStarred = + argument.type === 'list_splat' || argument.type === 'dictionary_splat'; + const nameNode = isKeyword ? argument.childForFieldName('name') : null; + const valueNode = isKeyword ? argument.childForFieldName('value') : argument; + if (!valueNode) { + return; + } + + // A LIST or TUPLE argument is emitted one row per ELEMENT, as Java does for + // an array-valued annotation argument. `methods=["GET", "POST"]` is two + // verbs, and collapsing them into one row would force every consumer to + // re-parse the text we already have parsed. + const elements = this.splittableElements(valueNode); + if (elements.length > 0) { + elements.forEach((element, arrayIndex) => { + this.pushArgument( + nameNode?.text ?? '', + element, + position, + parentHash, + String(arrayIndex), + isKeyword, + isStarred + ); + }); + return; + } + this.pushArgument( + nameNode?.text ?? '', + valueNode, + position, + parentHash, + '', + isKeyword, + isStarred + ); + }); + } + + private pushArgument( + argumentName: string, + valueNode: Parser.SyntaxNode, + position: number, + parentHash: string, + arrayIndex: string, + isKeyword: boolean, + isStarred: boolean + ): void { + const argument = new PyDecoratorArgumentRegistry( + argumentName, + this.literalText(valueNode), + this.valueTypeOf(valueNode), + position, + parentHash, + arrayIndex, + valueNode.startPosition.row + 1, + valueNode.endPosition.row + 1, + isKeyword, + isStarred, + this.input.serviceVersionLinkHash + ); + this.decoratorArguments.push(argument); + this.expressionRangeByArgument.set( + argument.getHash(), + `${valueNode.startIndex}:${valueNode.endIndex}` + ); + } + + /** The named children of an argument list, minus comments. */ + private positionalAndKeyword(args: Parser.SyntaxNode): Parser.SyntaxNode[] { + const found: Parser.SyntaxNode[] = []; + for (let index = 0; index < args.namedChildCount; index += 1) { + const child = args.namedChild(index); + if (child && !child.isExtra) { + found.push(child); + } + } + return found; + } + + /** Elements of a list/tuple/set literal, or `[]` for anything else. */ + private splittableElements(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + if (node.type !== 'list' && node.type !== 'tuple' && node.type !== 'set') { + return []; + } + return this.positionalAndKeyword(node); + } + + /** + * Classifies the decorator expression. + * + * `decoratorName` is the RIGHTMOST identifier, so `@app.route("/x")` is named + * `route` and its `dottedPath` is `app.route` — matching how a consumer thinks + * about it, and keeping the name comparable with a bare `@route`. + */ + private shapeOf(expression: Parser.SyntaxNode): { + name: string; + kind: PythonDecoratorKind; + dottedPath: string; + arguments: Parser.SyntaxNode | null; + } { + if (expression.type === 'call') { + const callee = expression.childForFieldName('function'); + const args = expression.childForFieldName('arguments'); + const dotted = this.pureDottedName(callee); + if (dotted === '') { + // PEP 614: the callee can be any expression, as in `@null(null)(null)` + // whose callee is itself a call. It is a call, but not a call OF A NAME, + // and there is nothing for dottedPath to point at. + return { + name: this.rightmostName(callee), + kind: PythonDecoratorKind.EXPRESSION, + dottedPath: '', + arguments: args ?? null, + }; + } + return { + name: this.rightmostName(callee), + kind: dotted.includes('.') + ? PythonDecoratorKind.ATTRIBUTE_CALL + : PythonDecoratorKind.CALL, + dottedPath: dotted, + arguments: args ?? null, + }; + } + if (expression.type === 'attribute') { + // KIND stays syntactic and dottedPath carries resolvability: they are + // two different facts and collapsing them loses one. + // `@[null][0].__call__.__call__` IS an attribute access, and saying so + // costs nothing now that dottedPath no longer claims it can be resolved. + return { + name: this.rightmostName(expression), + kind: PythonDecoratorKind.ATTRIBUTE, + dottedPath: this.pureDottedName(expression), + arguments: null, + }; + } + if (expression.type === 'identifier') { + return { + name: expression.text, + kind: PythonDecoratorKind.BARE, + // A single segment IS a dotted path of length one. Leaving it empty + // meant `@contextlib.contextmanager` was joinable by dottedPath and + // `@classmethod` was not, so every consumer had to special-case the + // bare form and fall back to decoratorName. + dottedPath: expression.text, + arguments: null, + }; + } + if (expression.type === 'subscript') { + return { + name: this.rightmostName(expression.childForFieldName('value')), + kind: PythonDecoratorKind.SUBSCRIPT, + // `@Registry[int]` points at `Registry`; the subscript is not part of + // any name. The full text with brackets in it was never resolvable. + dottedPath: this.pureDottedName(expression.childForFieldName('value')), + arguments: null, + }; + } + // PEP 614 removed the grammar restriction, so any expression is legal here. + return { + name: this.rightmostName(expression), + kind: PythonDecoratorKind.EXPRESSION, + dottedPath: '', + arguments: null, + }; + } + + /** + * The dotted path, but ONLY when the whole expression is a name chain. + * + * PEP 614 allows any expression as a decorator, and CPython's own + * test_grammar.py exercises `@[null][0].__call__.__call__` and + * `@[..., null, ...][1]`. Normalising the raw text put strings like + * `[null][0].__call__.__call__` and `null(null)` into dottedPath -- a column + * whose only purpose is to be joined against a resolvable entity. Nothing can + * ever match those, so they were not a link but the appearance of one, and a + * consumer counting resolvable decorators would have counted them. + * + * Empty means "this decorator has no name to resolve", which is a true and + * checkable statement. `decoratorName` still carries the rightmost identifier, + * because that is informative without claiming to be resolvable. + */ + private pureDottedName(node: Parser.SyntaxNode | null | undefined): string { + if (!node) { + return ''; + } + if (node.type === 'identifier') { + return node.text; + } + if (node.type === 'attribute') { + const base = this.pureDottedName(node.childForFieldName('object')); + if (base === '') { + return ''; + } + const attribute = node.childForFieldName('attribute'); + return attribute ? `${base}.${attribute.text}` : ''; + } + return ''; + } + + private rightmostName(node: Parser.SyntaxNode | null | undefined): string { + if (!node) { + return ''; + } + if (node.type === 'identifier') { + return node.text; + } + if (node.type === 'attribute') { + return node.childForFieldName('attribute')?.text ?? ''; + } + const last = node.namedChild(node.namedChildCount - 1); + return last && last !== node ? this.rightmostName(last) : ''; + } + + private valueTypeOf(node: Parser.SyntaxNode): PythonDecoratorArgumentValueType { + switch (node.type) { + case 'string': + case 'concatenated_string': { + const start = node.child(0); + return (start?.text ?? '').toLowerCase().includes('f') + ? PythonDecoratorArgumentValueType.FSTRING + : PythonDecoratorArgumentValueType.STRING_LITERAL; + } + case 'integer': + case 'float': { + return PythonDecoratorArgumentValueType.NUMBER_LITERAL; + } + case 'true': + case 'false': { + return PythonDecoratorArgumentValueType.BOOLEAN_LITERAL; + } + case 'none': { + return PythonDecoratorArgumentValueType.NONE_LITERAL; + } + case 'list': { + return PythonDecoratorArgumentValueType.LIST; + } + case 'dictionary': { + return PythonDecoratorArgumentValueType.DICT; + } + case 'tuple': { + return PythonDecoratorArgumentValueType.TUPLE; + } + case 'set': { + return PythonDecoratorArgumentValueType.SET; + } + case 'identifier': { + return PythonDecoratorArgumentValueType.NAME_REFERENCE; + } + case 'attribute': { + return PythonDecoratorArgumentValueType.ATTRIBUTE_REFERENCE; + } + case 'call': { + return PythonDecoratorArgumentValueType.CALL; + } + case 'lambda': { + return PythonDecoratorArgumentValueType.LAMBDA; + } + default: { + return PythonDecoratorArgumentValueType.UNKNOWN; + } + } + } + + /** A string argument's VALUE, quotes stripped; other nodes keep their text. */ + private literalText(node: Parser.SyntaxNode): string { + const text = this.normalize(node.text); + if (node.type !== 'string') { + return text; + } + const quote = text.search(/['"]/); + if (quote < 0) { + return text; + } + return text + .slice(quote) + .replace(/^('''|"""|'|")/, '') + .replace(/('''|"""|'|")$/, ''); + } + + private normalize(text: string): string { + return text.replace(/\s+/g, ' ').trim(); + } +} diff --git a/parser/src/parsers/python/extractors/python-expression-extractor.ts b/parser/src/parsers/python/extractors/python-expression-extractor.ts new file mode 100644 index 000000000..8405aad5c --- /dev/null +++ b/parser/src/parsers/python/extractors/python-expression-extractor.ts @@ -0,0 +1,3272 @@ +import Parser from 'tree-sitter'; + +import { PyBindingRegistry } from '@/analysis-types/python'; +import { + PyCallSiteRegistry, + PyExpressionRegistry, + PyModuleRegistry, +} from '@/analysis-types/python'; +import { + PythonCallKind, + PythonReceiverKind, +} from '@/enums/python/call-sites'; +import { PythonBindingKind, PythonBindingOrigin } from '@/enums/python/bindings'; +import { + PythonComprehensionKind, + PythonEdgeRole, + PythonExpressionKind, + PythonExpressionOwnerKind, + PythonLiteralType, + PythonNameContext, + PythonReferencedEntityKind, + PythonRootContext, + PythonUnaryFixity, +} from '@/enums/python/expressions'; +import { + PythonInferenceConfidence, + PythonInferenceEvidence, + PythonInferredTypeKind, +} from '@/enums/python/inference'; +import { + PYTHON_BUILTIN_COLLECTION_TYPES, + PYTHON_BUILTIN_SCALAR_TYPES, +} from '@/constants/python-constants'; +import { + decomposeMisparsedTypeAlias, + isMisparsedTypeAlias, +} from '@/parsers/python/python-soft-keywords'; +import { EntityUtils } from '@/utils/entity-utils'; +import { PythonSourcePositions } from '@/utils/python'; + +/** What the expression stage produces for one module. */ +export interface PythonExpressionExtraction { + expressions: PyExpressionRegistry[]; + callSites: PyCallSiteRegistry[]; + /** + * `startIndex:endIndex` -> the PK of the expression emitted for that byte + * range, for EVERY expression rather than only tree roots. + * + * Keyed on the byte range because that is what identifies a node: a start + * offset alone collides for nested calls. Lets a later stage join to an + * expression it did not create — a parameter's default-value root, say — + * without re-walking the tree or guessing from spans. + * + * Not restricted to roots: a lambda's parameter default is emitted as a CHILD + * of the lambda expression, so a roots-only index silently fails to link it. + */ + expressionByByteRange: Map; + /** + * Assignment TARGET byte range -> its VALUE's byte range. + * + * The exact pairing of `x = f()`, recorded while both nodes are in hand. The + * emitted tree cannot express it: target and value are two unrelated depth-0 + * roots with no parent, so a consumer joining them has to guess from + * (scope, line) — which a multi-line or semicolon-separated statement breaks. + * A schema gap is filed with A0; this keeps resolution exact meanwhile. + */ + assignedValueByTargetRange: Map; +} + +export interface PythonExpressionInput { + module: PyModuleRegistry; + rootNode: Parser.SyntaxNode; + serviceVersionLinkHash: string; + /** Scope-introducing `node.id` -> `py_scope` PK. */ + scopeHashByNodeId: Map; + /** `(scopeHash, name)` -> `py_binding` PK. */ + bindingHashByScopeAndName: Map; + /** Same key, the whole record. The classification lives in its predicates. */ + bindingByScopeAndName: Map; + /** Scope-introducing `node.id` -> owning `py_method` PK. */ + methodHashByNodeId: Map; + /** Class `node.id` -> `py_type` PK. */ + typeHashByNodeId: Map; + /** The synthetic `` method PK — the fallback owner. */ + moduleMethodHash: string; + /** Class `node.id` -> its `` method PK. */ + classInitHashByNodeId: Map; + /** Converts tree-sitter character columns to CPython UTF-8 byte columns. */ + positions: PythonSourcePositions; + /** `lambda` node id -> its `py_method` PK, so a lambda body owns its own facts. */ + lambdaMethodByNodeId: Map; + /** Annotation node byte range -> the `py_method_parameter` PK it annotates. */ + parameterHashByAnnotationRange: Map; +} + +/** + * One queued expression node, carrying **all** of its context explicitly. + * + * This mirrors `expression-reference-extractor.ts`'s `PendingChild`, and the + * reason it carries context rather than looking it up is the same: nothing may + * be stored on a tree-sitter node. node-tree-sitter's wrapper cache evicts + * entries, so a property written while descending is gone by the time the parent + * is revisited, and `.parent` walks then return untagged objects. It works on + * small files and fails silently at scale. + */ +interface PendingExpression { + node: Parser.SyntaxNode; + /** + * 0-based index of the enclosing `return` within its method, or `null`. + * + * Carried on the pending record rather than read from a field at emit time + * because the walk is DEFERRED — the worklist drains after the statement visit + * has moved on, so a field would hold whatever return was visited last. + */ + returnStatementIndex: number | null; + /** + * A SYNTHETIC node: a row CPython's ast has that tree-sitter does not. + * + * `d[1, 2]` is a Subscript whose index is a `Tuple` in ast, while tree-sitter + * makes the indices direct children of the subscript with no tuple node + * between. The tuple is a real expression — `d[1, 2]` and `d[(1, 2)]` are the + * same program — so it needs a row, and the row needs a span the grammar does + * not give us. + */ + /** Children to enqueue under a synthetic row, since the grammar has none. */ + syntheticChildren?: Parser.SyntaxNode[]; + /** Edge role for those children. `ELEMENT` unless the synthetic row is a call. */ + syntheticChildEdgeRole?: PythonEdgeRole; + /** + * Callee name for a synthetic CALL, where no `function` child exists to read it from. + * + * Used by the `type(obj).attr = value` recovery: the grammar swallowed the call node + * outright, so the name comes from the soft keyword rather than from an identifier. + */ + syntheticCalleeName?: string; + synthetic?: { + kind: PythonExpressionKind; + startIndex: number; + endIndex: number; + startRow: number; + startColumn: number; + endRow: number; + endColumn: number; + }; + parentHash: string; + edgeRole: PythonEdgeRole; + position: number; + depth: number; + scopeHash: string; + ownerHash: string; + ownerKind: PythonExpressionOwnerKind; + rootContext: PythonRootContext; + nameContext: PythonNameContext; + argumentKeywordName: string; + isAwaited: boolean; + isStarred: boolean; + /** The enclosing method, never empty — `` at worst. */ + methodHash: string; + /** The enclosing class, or `''`. */ + typeHash: string; + isModuleLevelCall: boolean; + isConditional: boolean; + /** The enclosing method's receiver parameter name, for classifying `self`. */ + receiverName: string; + /** True when the enclosing method is a classmethod, so the receiver is `cls`. */ + receiverIsClass: boolean; +} + +/** Statement-level context threaded through the statement walk. */ +interface StatementContext { + scopeHash: string; + ownerHash: string; + ownerKind: PythonExpressionOwnerKind; + methodHash: string; + typeHash: string; + isModuleLevel: boolean; + isConditional: boolean; + receiverName: string; + receiverIsClass: boolean; + /** + * True only directly inside a CLASS BODY. + * + * Cannot be inferred from `ownerKind` or `typeHash`: the class body's owner is + * the synthetic `` METHOD, and `typeHash` is inherited by + * everything lexically inside the class including nested functions. Without an + * explicit flag, a `def` nested in a method looked like a method and took its + * own first parameter as a receiver. + */ + directClassMember: boolean; + /** Where a bare expression statement sits, for `rootContext`. */ + statementRootContext: PythonRootContext; +} + +/** + * Emits `py_expression` and `py_call_site`. + * + * Stage 3 of the build order. It unlocks attribute chains (18.8% of receivers + * are depth-2) and argument flow, which is the primary typing mechanism given + * that 68.2% of parameters carry no annotation. + * + * ## Traversal + * + * A FIFO worklist, exactly as the Java expression extractor uses. Each queued + * item carries its own parent hash, edge role, position, depth and scope, so the + * traversal needs no ambient state and no node tagging. Breadth-first ordering + * also makes the output stable: siblings are emitted together in position order, + * which is what makes byte-identical output achievable. + * + * ## Node identity is the byte RANGE + * + * The expression key includes `startLine`, `startColumn`, `endLine` and + * `endColumn`. A start offset alone collides: in `super().f()` the outer call + * and the inner `super()` share a start position, and two writes to the same + * target in one statement share one too. + */ +export class PythonExpressionExtractor { + private input!: PythonExpressionInput; + private expressions: PyExpressionRegistry[] = []; + private callSites: PyCallSiteRegistry[] = []; + private worklist: PendingExpression[] = []; + /** Call-site PK -> the byte range of its receiver, resolved after the walk. */ + private pendingReceiverLinks: { callSite: PyCallSiteRegistry; range: string }[] = []; + /** Byte range -> PK, for every expression emitted. */ + /** Per-method return counter, for `py_expression.returnStatementIndex`. */ + /** + * Assignment TARGET byte range -> its VALUE's byte range. + * + * The exact pairing, recorded where both nodes are known. Needed because the + * target and the value are emitted as two unrelated depth-0 roots. + */ + private assignedValueByTargetRange = new Map(); + private returnIndexByMethod = new Map(); + /** The index of the return being walked, or `null` outside one. */ + private currentReturnStatementIndex: number | null = null; + private expressionByByteRange = new Map(); + + extract(input: PythonExpressionInput): PythonExpressionExtraction { + this.input = input; + this.expressions = []; + this.callSites = []; + this.worklist = []; + this.expressionByByteRange = new Map(); + this.pendingReceiverLinks = []; + + const moduleScopeHash = input.scopeHashByNodeId.get(input.rootNode.id) ?? ''; + this.visitStatements(input.rootNode, { + scopeHash: moduleScopeHash, + ownerHash: input.moduleMethodHash, + ownerKind: PythonExpressionOwnerKind.METHOD, + methodHash: input.moduleMethodHash, + typeHash: '', + isModuleLevel: true, + isConditional: false, + receiverName: '', + receiverIsClass: false, + statementRootContext: PythonRootContext.MODULE_LEVEL_STATEMENT, + // Module level is by definition not inside a class body. + directClassMember: false, + }); + + // The receiver's PK does not exist when its call site is minted, so the FK + // is resolved here, once every expression has been emitted. + for (const link of this.pendingReceiverLinks) { + const hash = this.expressionByByteRange.get(link.range); + if (hash) { + link.callSite.setReceiverExpressionLinkHash(hash); + } + } + + return { + expressions: this.expressions, + callSites: this.callSites, + expressionByByteRange: this.expressionByByteRange, + assignedValueByTargetRange: this.assignedValueByTargetRange, + }; + } + + // ---------------------------------------------------------- statement walk + + private visitStatements(node: Parser.SyntaxNode, context: StatementContext): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) { + this.visitStatement(child, context); + } + } + } + + private visitStatement(node: Parser.SyntaxNode, context: StatementContext): void { + switch (node.type) { + case 'decorated_definition': { + this.visitDecoratedDefinition(node, context); + return; + } + + case 'class_definition': { + this.visitClassDefinition(node, context, []); + return; + } + + case 'function_definition': { + this.visitFunctionDefinition(node, context, []); + return; + } + + case 'expression_statement': { + // A BARE TUPLE as a statement — `1, 2, 3` or `x, y` — is FLATTENED by + // tree-sitter into several direct children of the statement, with no + // tuple node at all. CPython sees one `Tuple`, so emitting each child as + // its own root lost the tuple entirely: 628 of them on the torture + // corpus. The statement's own span is the tuple's span, so it can carry + // the row. + const parts: Parser.SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const inner = node.namedChild(i); + if (inner && !inner.isExtra) { + parts.push(inner); + } + } + if (parts.length > 1) { + this.enqueueRoot( + node, + context, + context.statementRootContext, + PythonEdgeRole.ROOT + ); + return; + } + for (const inner of parts) { + this.visitStatementExpression(inner, context); + } + return; + } + + case 'return_statement': { + // Java's column 19: the 0-based index of THIS return within its method, + // carried by every expression in the returned value. It is what lets a + // rule say "the second return leaks the token" rather than only "some + // return does", and multi-return functions are the norm — 508 returns in + // asyncio alone. + const seen = this.returnIndexByMethod.get(context.methodHash) ?? 0; + this.returnIndexByMethod.set(context.methodHash, seen + 1); + this.currentReturnStatementIndex = seen; + this.enqueueRoots(node, context, PythonRootContext.RETURN_VALUE, PythonEdgeRole.RETURN_VALUE); + this.currentReturnStatementIndex = null; + return; + } + + case 'if_statement': + case 'elif_clause': { + const condition = node.childForFieldName('condition'); + if (condition) { + this.enqueueRoot(condition, context, PythonRootContext.IF_CONDITION, PythonEdgeRole.CONDITION); + } + this.visitChildBlocks(node, { ...context, isConditional: true }, condition); + return; + } + + case 'while_statement': { + const condition = node.childForFieldName('condition'); + if (condition) { + this.enqueueRoot(condition, context, PythonRootContext.WHILE_CONDITION, PythonEdgeRole.CONDITION); + } + this.visitChildBlocks(node, { ...context, isConditional: true }, condition); + return; + } + + case 'for_statement': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + // `async for` drives __aiter__/__anext__ where `for` drives __iter__/__next__, so the + // two are different constructs rather than one construct with a flag. Nothing else on + // the expression records it: the enclosing function cannot decide it (an `async def` + // holds plain `for` loops too), and the ASYNC_FOR block cannot either, because the + // iterable is owned by the METHOD rather than by that block. + const asyncLoop = PythonExpressionExtractor.isAsyncStatement(node); + if (right) { + this.enqueueRoot( + right, + context, + asyncLoop ? PythonRootContext.ASYNC_FOR_ITERABLE : PythonRootContext.FOR_ITERABLE, + PythonEdgeRole.ROOT + ); + } + if (left) { + this.enqueueRoot( + left, + context, + asyncLoop ? PythonRootContext.ASYNC_FOR_TARGET : PythonRootContext.FOR_TARGET, + PythonEdgeRole.ROOT, + PythonNameContext.STORE + ); + } + this.visitChildBlocks(node, { ...context, isConditional: true }, left, right); + return; + } + + case 'with_statement': { + this.visitWithStatement(node, context); + return; + } + + case 'raise_statement': { + this.enqueueRoots(node, context, PythonRootContext.RAISE_VALUE, PythonEdgeRole.RAISE_EXC); + return; + } + + case 'assert_statement': { + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (!part) { + continue; + } + this.enqueueRoot( + part, + context, + i === 0 ? PythonRootContext.ASSERT_CONDITION : PythonRootContext.ASSERT_MESSAGE, + i === 0 ? PythonEdgeRole.CONDITION : PythonEdgeRole.ARGUMENT + ); + } + return; + } + + case 'delete_statement': { + this.enqueueRoots( + node, + context, + PythonRootContext.DELETE_TARGET, + PythonEdgeRole.ROOT, + PythonNameContext.DEL + ); + return; + } + + case 'try_statement': { + this.visitTryStatement(node, context); + return; + } + + case 'match_statement': { + this.visitMatchStatement(node, context); + return; + } + + // Imports bind names but contain no expressions to model; global and + // nonlocal are declarations, not reads. + case 'type_alias_statement': { + if (!isMisparsedTypeAlias(node)) { + this.visitStatements(node, context); + return; + } + // `type(obj).attr = value` is an assignment. The grammar dropped the + // `type(...)` call node, so the outer call cannot be recovered here and + // is reported as a py_parse_gap instead. Everything else can be: + // without this the value was never walked at all, so a nested + // `compute(val)` produced no expression and no call site either. + const parts = decomposeMisparsedTypeAlias(node); + const value = parts.value; + if (value !== null) { + this.enqueueRoot( + value, + context, + PythonRootContext.ASSIGNMENT_VALUE, + PythonEdgeRole.ASSIGNMENT_VALUE + ); + } + if (parts.annotation !== null) { + this.enqueueRoot( + parts.annotation, + context, + PythonRootContext.ANNOTATED_ASSIGNMENT, + PythonEdgeRole.ROOT + ); + } + const target = parts.target; + if (target !== null) { + // The target node starts at the `(` the grammar left behind, so its span reads + // `(obj).attr` where the source says `type(obj).attr`. Anything joining the IR to + // source by position — the tier-1 conservation comparison among them — would miss + // it by four characters, so the span is widened to the keyword the call was + // rebuilt from. Only for the call form: `type[o].x` keeps the span it has, because + // no call is being recovered there. + const object = target.childForFieldName('object') ?? target.namedChild(0); + const keyword = object + ? PythonExpressionExtractor.swallowedTypeCallKeyword(object) + : null; + this.enqueueRoot( + target, + context, + PythonRootContext.ASSIGNMENT_VALUE, + PythonEdgeRole.ASSIGNMENT_TARGET, + PythonNameContext.STORE, + 0, + keyword + ? { + kind: PythonExpressionKind.ATTRIBUTE_ACCESS, + startIndex: keyword.startIndex, + endIndex: target.endIndex, + startRow: keyword.startPosition.row, + startColumn: keyword.startPosition.column, + endRow: target.endPosition.row, + endColumn: target.endPosition.column, + } + : undefined + ); + } + return; + } + + case 'import_statement': + case 'import_from_statement': + case 'future_import_statement': + case 'global_statement': + case 'nonlocal_statement': + case 'pass_statement': + case 'break_statement': + case 'continue_statement': { + return; + } + + default: { + this.visitStatements(node, context); + return; + } + } + } + + /** A bare expression statement, or an assignment in one. */ + private visitStatementExpression(node: Parser.SyntaxNode, context: StatementContext): void { + if (node.type === 'assignment') { + // Emit the ASSIGNMENT itself as the depth-0 root, with the target(s) and + // the value as its depth-1 CHILDREN — exactly as a CALL parents its + // arguments. + // + // Previously target and value were two unrelated depth-0 roots with no + // parent, and nothing in the IR related them. That made §2.10's + // justification for deleting `py_field_write` — "the value is the sibling + // ASSIGNMENT_VALUE under the same parent" — false in the emitted output, + // since there was no same parent. A consumer asking what flows into a + // binding had to guess from (scope, line), which breaks on `a = f(); b = + // g()`. I filed that as a schema gap; A0 adjudicated it as parser + // non-compliance and was right: `ASSIGNMENT` has been in the expression + // kind enum all along and this simply never emitted it. + this.enqueueRoot( + node, + context, + node.childForFieldName('type') + ? PythonRootContext.ANNOTATED_ASSIGNMENT + : PythonRootContext.ASSIGNMENT_VALUE, + PythonEdgeRole.ROOT + ); + return; + } + + if (node.type === 'augmented_assignment') { + // The NODE itself is the depth-0 root, exactly as for a plain assignment, + // and the target and value become its depth-1 children. + // + // They used to be two unrelated depth-0 roots with no parent, which loses + // the pairing entirely. Recovering it by joining on (scope, line, + // rootContext) is not safe: `a += 1; b += 2` puts four such rows on one + // line in one scope, and the join yields four pairs of which two are + // wrong — `a` paired with `2`, `b` with `1`. That invents value flow + // rather than losing it, which is the worse direction. The wrapper makes + // the pairing explicit and the join unnecessary. + // + // Mirrors Java's COMPOUND_ASSIGNMENT: one wrapper carrying + // ASSIGNMENT_TARGET / ASSIGNMENT_VALUE, not one kind per operator. + this.enqueueRoot( + node, + context, + PythonRootContext.AUGMENTED_ASSIGNMENT, + PythonEdgeRole.ROOT + ); + return; + } + + this.enqueueRoot(node, context, context.statementRootContext, PythonEdgeRole.ROOT); + } + + /** + * True for `async for` / `async with`, which the grammar marks with a leading `async` + * token on the same statement node. Matches `PythonBlockExtractor.isAsync`, deliberately: + * the block row and the expression rows must agree about the same statement. + */ + private static isAsyncStatement(node: Parser.SyntaxNode): boolean { + return node.child(0)?.text === 'async'; + } + + private visitWithStatement(node: Parser.SyntaxNode, context: StatementContext): void { + const asyncWith = PythonExpressionExtractor.isAsyncStatement(node); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'with_clause') { + // `async with` drives __aenter__/__aexit__ where `with` drives __enter__/__exit__ — + // the same distinction as `async for`, and absent for the same reason. + const contextRoot = asyncWith + ? PythonRootContext.ASYNC_WITH_CONTEXT + : PythonRootContext.WITH_CONTEXT; + const targetRoot = asyncWith + ? PythonRootContext.ASYNC_WITH_TARGET + : PythonRootContext.WITH_TARGET; + for (let j = 0; j < child.namedChildCount; j++) { + const item = child.namedChild(j); + if (item?.type !== 'with_item') { + continue; + } + const value = item.childForFieldName('value') ?? item.namedChild(0); + if (!value) { + continue; + } + if (value.type === 'as_pattern') { + const source = value.namedChild(0); + if (source) { + this.enqueueRoot(source, context, contextRoot, PythonEdgeRole.WITH_CONTEXT); + } + const target = value.namedChild(1); + if (target) { + this.enqueueRoot( + target, + context, + targetRoot, + PythonEdgeRole.WITH_TARGET, + PythonNameContext.STORE + ); + } + continue; + } + this.enqueueRoot(value, context, contextRoot, PythonEdgeRole.WITH_CONTEXT); + } + continue; + } + this.visitStatement(child, context); + } + } + + private visitTryStatement(node: Parser.SyntaxNode, context: StatementContext): void { + const nested: StatementContext = { ...context, isConditional: true }; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'except_clause' || child.type === 'except_group_clause') { + for (let j = 0; j < child.namedChildCount; j++) { + const part = child.namedChild(j); + if (!part) { + continue; + } + if (part.type === 'block') { + this.visitStatement(part, nested); + continue; + } + if (part.type === 'as_pattern') { + const source = part.namedChild(0); + if (source) { + this.enqueueRoot( + source, + nested, + PythonRootContext.EXCEPT_TYPE, + PythonEdgeRole.EXCEPT_TYPE + ); + } + const target = part.namedChild(1); + if (target) { + this.enqueueRoot( + target, + nested, + PythonRootContext.EXCEPT_TYPE, + PythonEdgeRole.EXCEPT_TARGET, + PythonNameContext.STORE + ); + } + continue; + } + this.enqueueRoot(part, nested, PythonRootContext.EXCEPT_TYPE, PythonEdgeRole.EXCEPT_TYPE); + } + continue; + } + this.visitStatement(child, nested); + } + } + + private visitMatchStatement(node: Parser.SyntaxNode, context: StatementContext): void { + const subject = node.namedChild(0); + if (subject && subject.type !== 'block') { + this.enqueueRoot( + subject, + context, + PythonRootContext.MATCH_SUBJECT, + PythonEdgeRole.MATCH_SUBJECT + ); + } + const nested: StatementContext = { ...context, isConditional: true }; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.id === subject?.id) { + continue; + } + if (child.type === 'block') { + for (let j = 0; j < child.namedChildCount; j++) { + const clause = child.namedChild(j); + if (clause?.type === 'case_clause') { + this.visitCaseClause(clause, nested); + continue; + } + if (clause) { + this.visitStatement(clause, nested); + } + } + continue; + } + this.visitStatements(child, nested); + } + } + + /** + * One `case` clause: pattern, optional guard, body. + * + * The guard needs handling in its own right rather than falling through to the + * generic statement walk, because it is an **expression** and can contain + * calls and walrus bindings that the generic walk would silently drop: + * + * ```python + * case [head, *tail] if (n := len(tail)) > 0: + * # ^ this call is lost without an explicit guard root + * ``` + */ + private visitCaseClause(node: Parser.SyntaxNode, context: StatementContext): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'case_pattern') { + this.enqueueRoot( + child, + context, + PythonRootContext.CASE_PATTERN, + PythonEdgeRole.MATCH_PATTERN + ); + continue; + } + if (child.type === 'if_clause') { + const guard = child.namedChild(0); + if (guard) { + this.enqueueRoot( + guard, + context, + PythonRootContext.CASE_GUARD, + PythonEdgeRole.CONDITION + ); + } + continue; + } + this.visitStatement(child, context); + } + } + + /** Visits a compound statement's blocks, skipping nodes already handled. */ + private visitChildBlocks( + node: Parser.SyntaxNode, + context: StatementContext, + ...handled: (Parser.SyntaxNode | null)[] + ): void { + const handledIds = new Set(handled.filter(n => n !== null).map(n => n!.id)); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || handledIds.has(child.id)) { + continue; + } + this.visitStatement(child, context); + } + } + + // ------------------------------------------------------------ definitions + + private visitDecoratedDefinition( + node: Parser.SyntaxNode, + context: StatementContext + ): void { + const decorators: Parser.SyntaxNode[] = []; + let definition: Parser.SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'decorator') { + decorators.push(child); + continue; + } + definition = child; + } + if (definition?.type === 'class_definition') { + this.visitClassDefinition(definition, context, decorators); + return; + } + if (definition?.type === 'function_definition') { + this.visitFunctionDefinition(definition, context, decorators); + return; + } + if (definition) { + this.visitStatement(definition, context); + } + } + + private visitClassDefinition( + node: Parser.SyntaxNode, + context: StatementContext, + decorators: Parser.SyntaxNode[] + ): void { + // Decorators and bases are evaluated in the ENCLOSING scope. + for (const decorator of decorators) { + const inner = decorator.namedChild(0); + if (inner) { + this.enqueueRoot(inner, context, PythonRootContext.DECORATOR, PythonEdgeRole.DECORATOR_EXPR); + } + } + const argumentsNode = node.childForFieldName('superclasses'); + if (argumentsNode) { + for (let i = 0; i < argumentsNode.namedChildCount; i++) { + const base = argumentsNode.namedChild(i); + if (!base) { + continue; + } + // `class C(Base, metaclass=M)` — enqueue the VALUE of a keyword base, + // not the `keyword_argument` wrapper. Enqueuing the wrapper emitted a + // BASE_CLASS row for it AND another for its value, both at the same + // position, so a consumer reading (edgeRole, position) as a key saw two + // rows claiming to be base 1. The keyword NAME is not lost: py_type_base + // carries it as KEYWORD_METACLASS with keywordName="metaclass". + const keywordValue = + base.type === 'keyword_argument' + ? base.childForFieldName('value') ?? base.namedChild(1) + : null; + this.enqueueRoot( + keywordValue ?? base, + context, + PythonRootContext.BASE_CLASS_LIST, + PythonEdgeRole.BASE_CLASS, + PythonNameContext.LOAD, + i + ); + } + } + + const bodyNode = node.childForFieldName('body'); + if (!bodyNode) { + return; + } + const typeHash = this.input.typeHashByNodeId.get(node.id) ?? ''; + const classInitHash = + this.input.classInitHashByNodeId.get(node.id) ?? context.methodHash; + + this.visitStatements(bodyNode, { + ...context, + scopeHash: this.input.scopeHashByNodeId.get(node.id) ?? context.scopeHash, + ownerHash: classInitHash, + ownerKind: PythonExpressionOwnerKind.METHOD, + directClassMember: true, + methodHash: classInitHash, + typeHash, + isModuleLevel: false, + receiverName: '', + receiverIsClass: false, + statementRootContext: PythonRootContext.CLASS_BODY_STATEMENT, + }); + } + + private visitFunctionDefinition( + node: Parser.SyntaxNode, + context: StatementContext, + decorators: Parser.SyntaxNode[] + ): void { + // Decorators, defaults and annotations are evaluated in the ENCLOSING scope. + for (const decorator of decorators) { + const inner = decorator.namedChild(0); + if (inner) { + this.enqueueRoot(inner, context, PythonRootContext.DECORATOR, PythonEdgeRole.DECORATOR_EXPR); + } + } + const parametersNode = node.childForFieldName('parameters'); + if (parametersNode) { + for (let i = 0; i < parametersNode.namedChildCount; i++) { + const param = parametersNode.namedChild(i); + if (!param || param.isExtra) { + continue; + } + const value = param.childForFieldName('value'); + if (value) { + this.enqueueRoot( + value, + context, + PythonRootContext.DEFAULT_VALUE, + PythonEdgeRole.DEFAULT_VALUE, + PythonNameContext.LOAD, + i + ); + } + const type = param.childForFieldName('type'); + if (type) { + // A parameter's annotation is OWNED BY THE PARAMETER, which is what + // expressionOwnerKind=METHOD_PARAMETER exists for. It is the only way + // to get from a parameter row to the N type references in its + // annotation: the row has a single potentialQualifiedName slot, so + // `Dict[TypeA, TypeB]` cannot be expressed there — it needs one + // expression entry per referenced type, joined back through this owner. + const parameterHash = this.input.parameterHashByAnnotationRange.get( + `${type.startIndex}:${type.endIndex}` + ); + this.enqueueRoot( + type, + parameterHash + ? { + ...context, + ownerHash: parameterHash, + ownerKind: PythonExpressionOwnerKind.METHOD_PARAMETER, + } + : context, + PythonRootContext.ANNOTATION, + PythonEdgeRole.ANNOTATION, + PythonNameContext.LOAD, + i + ); + } + } + } + const returnType = node.childForFieldName('return_type'); + if (returnType) { + this.enqueueRoot(returnType, context, PythonRootContext.ANNOTATION, PythonEdgeRole.ANNOTATION); + } + + const bodyNode = node.childForFieldName('body'); + if (!bodyNode) { + return; + } + const methodHash = this.input.methodHashByNodeId.get(node.id) ?? context.methodHash; + const decoratorText = decorators.map(d => d.text).join(' '); + const isClassMethod = /@\s*classmethod/.test(decoratorText); + const isStaticMethod = /@\s*staticmethod/.test(decoratorText); + // A receiver belongs to a DIRECT class member. `context.typeHash` is + // inherited by everything lexically inside the class, so a nested `def` + // inside a method satisfied it too — and took its OWN first parameter as a + // receiver. `async def wrap(n)` inside a method made `n` a SELF_REFERENCE, + // which is simply false, and 578 nodes on the torture corpus were + // misclassified in one direction or the other. + // + // A nested function INHERITS the enclosing method's receiver instead, which + // is what the language does: `self` inside a closure is the enclosing + // method's `self`, reached as a free variable, so it stays a SELF_REFERENCE. + const isDirectClassMember = context.directClassMember && context.typeHash !== ''; + const receiverName = isDirectClassMember + ? isStaticMethod + ? '' + : this.firstParameterName(parametersNode) + : context.receiverName; + const receiverIsClass = isDirectClassMember ? isClassMethod : context.receiverIsClass; + + this.visitStatements(bodyNode, { + ...context, + scopeHash: this.input.scopeHashByNodeId.get(node.id) ?? context.scopeHash, + ownerHash: methodHash, + ownerKind: PythonExpressionOwnerKind.METHOD, + methodHash, + isModuleLevel: false, + receiverName, + receiverIsClass, + // Anything inside a function body is no longer a direct class member. + directClassMember: false, + statementRootContext: PythonRootContext.EXPRESSION_STATEMENT, + }); + } + + /** + * The first parameter's name — the actual receiver, which is `self` only by + * convention. Code that names it `s` or `this` still has a receiver. + */ + private firstParameterName(parametersNode: Parser.SyntaxNode | null): string { + if (!parametersNode) { + return ''; + } + // Skip any leading grammar extra, so a comment before the first parameter + // does not make the method look like it has no receiver. + let first: Parser.SyntaxNode | null = null; + for (let i = 0; i < parametersNode.namedChildCount; i++) { + const candidate = parametersNode.namedChild(i); + if (candidate && !candidate.isExtra) { + first = candidate; + break; + } + } + if (!first) { + return ''; + } + if (first.type === 'identifier') { + return first.text; + } + const named = first.childForFieldName('name') ?? first.namedChild(0); + return named?.type === 'identifier' ? named.text : ''; + } + + // ----------------------------------------------------------- enqueue roots + + private enqueueRoots( + node: Parser.SyntaxNode, + context: StatementContext, + rootContext: PythonRootContext, + edgeRole: PythonEdgeRole, + nameContext: PythonNameContext = PythonNameContext.LOAD + ): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type !== 'block') { + this.enqueueRoot(child, context, rootContext, edgeRole, nameContext, i); + } + } + } + + private enqueueRoot( + node: Parser.SyntaxNode, + context: StatementContext, + rootContext: PythonRootContext, + edgeRole: PythonEdgeRole, + nameContext: PythonNameContext = PythonNameContext.LOAD, + position = 0, + synthetic?: PendingExpression['synthetic'] + ): void { + this.worklist.push({ + node, + synthetic, + parentHash: '', + edgeRole, + position, + depth: 0, + scopeHash: context.scopeHash, + ownerHash: context.ownerHash, + ownerKind: context.ownerKind, + rootContext, + nameContext, + returnStatementIndex: this.currentReturnStatementIndex, + argumentKeywordName: '', + isAwaited: false, + isStarred: false, + methodHash: context.methodHash, + typeHash: context.typeHash, + isModuleLevelCall: context.isModuleLevel, + isConditional: context.isConditional, + receiverName: context.receiverName, + receiverIsClass: context.receiverIsClass, + }); + this.drain(); + } + + /** + * Processes the worklist FIFO, so siblings are emitted together in position + * order and output is stable run to run. + */ + private drain(): void { + while (this.worklist.length > 0) { + const pending = this.worklist.shift(); + if (pending) { + this.emitExpression(pending); + } + } + } + + // ------------------------------------------------------------- expressions + + private emitExpression(pending: PendingExpression): void { + const node = pending.node; + + // Transparent wrappers contribute no row of their own; the inner expression + // takes the parent's role directly. Emitting them would add a node the + // source does not contain and break `depth` for every descendant. + // + // `pair` matters here: a dict literal's children are `pair` nodes, so + // dropping them loses every key AND value in the dict — including calls, as + // in `{'A': self.__seqToRE(...)}` from _strptime.py. + if ( + node.type === 'parenthesized_expression' || + // tree-sitter wraps EVERY element of a match pattern in its own + // `case_pattern`, so `case (0, 0)` nests case_pattern > tuple_pattern > + // case_pattern > integer. ast has no such node — the pattern IS the + // expression — so emitting a row for the wrapper duplicated its own child + // at an identical span. Ten duplicate rows on one fixture, each + // double-counting whatever aggregates over them. + node.type === 'case_pattern' || + // `case Point(x=px)` nests keyword_pattern(identifier x, dotted_name px). + // The first child is the ATTRIBUTE LABEL, not a reference to anything -- + // emitting it invented a read of a name `x` that does not exist, the same + // trap as a keyword argument's name. Only the value is a sub-pattern. + node.type === 'keyword_pattern' || + node.type === 'pair' + ) { + // A PARENTHESISED node has one child and is pure grouping, so the child + // inherits the parent's position. Renumbering it from the child index + // silently reset it to 0 — so in + // `re.compile(pattern, (A | B | C))` the second argument reported + // position 0, and argument-to-parameter linking would have bound it to the + // first parameter. `expression_list` and `pair` are genuine sequences, + // where the child index IS the position. + const grouping = node.type === 'parenthesized_expression'; + for (let i = 0; i < node.namedChildCount; i++) { + const inner = node.namedChild(i); + if (inner) { + // PEP 634: a BARE name in a pattern is a CAPTURE, which binds, while a + // DOTTED name is a value pattern, which reads. Every pattern name was + // reported LOAD, contradicting py_binding, which correctly records the + // same names with origin MATCH_CAPTURE. `_` is the wildcard and binds + // nothing. + // tree-sitter wraps even a BARE capture in a `dotted_name`, so + // `case other:` is case_pattern > dotted_name > identifier. A + // dotted_name with one child is a bare name and therefore a capture; + // with two or more it is a value pattern and reads. + const bareName = + inner.type === 'identifier' || + (inner.type === 'dotted_name' && inner.namedChildCount === 1); + const inPattern = node.type === 'case_pattern' || node.type === 'keyword_pattern'; + const isCapture = inPattern && bareName && inner.text !== '_'; + // Skip the keyword LABEL of `x=px`; it names an attribute of the + // matched class, not a binding or a read. + if (node.type === 'keyword_pattern' && i === 0) { + continue; + } + this.worklist.push({ + ...pending, + node: inner, + position: grouping ? pending.position : i, + ...(isCapture ? { nameContext: PythonNameContext.STORE } : {}), + }); + } + } + return; + } + + // A SYNTHETIC row short-circuits classification and span: the grammar has no + // node here, so both come from the pending record. See `synthetic`. + const kind = pending.synthetic ? pending.synthetic.kind : this.expressionKindOf(node, pending); + if (kind === null) { + // An unrecognised node is treated as TRANSPARENT, never dropped. Dropping + // it would silently discard its whole subtree — which is how a dict's + // `pair` nodes took every call in the dict with them. Being transparent + // means the worst case is a flatter tree than ideal, not a missing fact. + if (!this.isLeafToken(node)) { + for (let i = 0; i < node.namedChildCount; i++) { + const inner = node.namedChild(i); + if (inner && !inner.isExtra) { + this.worklist.push({ ...pending, node: inner }); + } + } + } + return; + } + + const builder = PyExpressionRegistry.builder( + kind, + pending.edgeRole, + pending.rootContext, + pending.ownerKind, + pending.ownerHash, + pending.scopeHash, + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withParent(pending.parentHash, pending.position, pending.depth) + .withSpan( + (pending.synthetic?.startRow ?? node.startPosition.row) + 1, + this.input.positions.byteColumn( + pending.synthetic?.startRow ?? node.startPosition.row, + pending.synthetic?.startColumn ?? node.startPosition.column + ), + (pending.synthetic?.endRow ?? node.endPosition.row) + 1, + this.input.positions.byteColumn( + pending.synthetic?.endRow ?? node.endPosition.row, + pending.synthetic?.endColumn ?? node.endPosition.column + ) + ) + .withNameContext(pending.nameContext) + .withArgumentKeywordName(pending.argumentKeywordName) + .withFlags({ isAwaited: pending.isAwaited, isStarred: pending.isStarred }) + .withPyTypeLinkHash(pending.typeHash); + + this.applyKindSpecificFields(builder, node, kind, pending); + this.applyInference(builder, node, kind, pending); + if (pending.returnStatementIndex !== null && pending.returnStatementIndex !== undefined) { + builder.withReturnStatementIndex(pending.returnStatementIndex); + } + + const expression = builder.build(); + this.expressions.push(expression); + + this.expressionByByteRange.set( + `${node.startIndex}:${node.endIndex}`, + expression.getHash() + ); + + if (kind === PythonExpressionKind.CALL) { + this.emitCallSite(node, expression, pending); + } + + // Leaves have no sub-expressions. A string's string_start/string_content + // parts are not expressions, and descending into them would emit noise. + if (!this.isLeafKind(kind, node)) { + this.enqueueChildren(node, kind, expression, pending); + } + } + + + /** + * Fills the four inference columns (schema v7 §2.15 c33-c36). + * + * These replaced the deleted `py_type_inference` relation, and the reason they + * became columns rather than a table is the constraint honoured here: every + * inference a PARSER may make is 1:1 with a single node. `3` is an `int`, + * `f"{x}"` is a `str` whatever `x` is, `[e for e in xs]` is a `list`. None of + * those needs a second fact. + * + * What this deliberately does NOT do is the interesting half. It never types a + * name, a call, or an attribute, because each of those needs resolution — that + * a name reaches a class, that a callee is a constructor — and resolution is + * the engine's tier. Emitting `Foo()` as type `Foo` here would look like a free + * win and would be wrong whenever `Foo` is a factory function rather than a + * class. The one exception is `cast(Foo, v)`, where the programmer has asserted + * the type in the source and the parser is only reading it back. + * + * Confidence separates "the syntax admits nothing else" from "the syntax names + * a type the runtime need not honour": an annotation is not enforced, so + * `x: int` holding a `str` is legal Python. + */ + private applyInference( + builder: ReturnType, + node: Parser.SyntaxNode, + kind: PythonExpressionKind, + pending: PendingExpression + ): void { + const inference = this.inferenceFor(node, kind); + if (!inference) { + return; + } + // A parameter default types the parameter by construction, which is a + // stronger statement about WHY the type is known than the literal alone. + const evidence = + pending.edgeRole === PythonEdgeRole.DEFAULT_VALUE + ? PythonInferenceEvidence.DEFAULT_VALUE + : inference.evidence; + builder.withInference( + inference.typeName, + inference.typeKind, + evidence, + inference.confidence + ); + } + + /** The syntactic type of one node, or `null` when nothing is derivable. */ + private inferenceFor( + node: Parser.SyntaxNode, + kind: PythonExpressionKind + ): { + typeName: string; + typeKind: PythonInferredTypeKind; + evidence: PythonInferenceEvidence; + confidence: PythonInferenceConfidence; + } | null { + const scalar = (typeName: string) => ({ + typeName, + typeKind: PythonInferredTypeKind.BUILTIN_SCALAR, + evidence: PythonInferenceEvidence.LITERAL, + confidence: PythonInferenceConfidence.CERTAIN, + }); + const collection = (typeName: string) => ({ + typeName, + typeKind: PythonInferredTypeKind.BUILTIN_COLLECTION, + evidence: PythonInferenceEvidence.COLLECTION_LITERAL, + confidence: PythonInferenceConfidence.CERTAIN, + }); + const comprehension = (typeName: string) => ({ + typeName, + typeKind: PythonInferredTypeKind.BUILTIN_COLLECTION, + evidence: PythonInferenceEvidence.COMPREHENSION, + confidence: PythonInferenceConfidence.CERTAIN, + }); + + switch (node.type) { + case 'integer': { + return scalar('int'); + } + case 'float': { + return scalar('float'); + } + case 'true': + case 'false': { + return scalar('bool'); + } + case 'none': { + return { + typeName: 'None', + typeKind: PythonInferredTypeKind.NONE_TYPE, + evidence: PythonInferenceEvidence.LITERAL, + confidence: PythonInferenceConfidence.CERTAIN, + }; + } + case 'string': + case 'concatenated_string': { + // An f-string is a `str` whatever it interpolates, but a BYTES literal is + // not a `str` at all, and conflating them is the mistake that makes an + // encode/decode rule wrong. + if (this.isBytesLiteral(node)) { + return scalar('bytes'); + } + if (this.isFormattedString(node)) { + return { + typeName: 'str', + typeKind: PythonInferredTypeKind.BUILTIN_SCALAR, + evidence: PythonInferenceEvidence.FSTRING, + confidence: PythonInferenceConfidence.CERTAIN, + }; + } + return scalar('str'); + } + case 'list': { + return collection('list'); + } + case 'dictionary': { + return collection('dict'); + } + case 'set': { + return collection('set'); + } + case 'tuple': { + return collection('tuple'); + } + case 'list_comprehension': { + return comprehension('list'); + } + case 'dictionary_comprehension': { + return comprehension('dict'); + } + case 'set_comprehension': { + return comprehension('set'); + } + case 'generator_expression': { + // Not a collection: a genexp is lazy, and treating it as a `list` would + // license an indexing rule that raises at runtime. + return { + typeName: 'Generator', + typeKind: PythonInferredTypeKind.CALLABLE, + evidence: PythonInferenceEvidence.COMPREHENSION, + confidence: PythonInferenceConfidence.CERTAIN, + }; + } + case 'lambda': { + return { + typeName: 'Callable', + typeKind: PythonInferredTypeKind.CALLABLE, + evidence: PythonInferenceEvidence.LITERAL, + confidence: PythonInferenceConfidence.CERTAIN, + }; + } + case 'call': { + return this.castInference(node); + } + default: { + void kind; + return null; + } + } + } + + /** + * `typing.cast(Foo, v)` — the only call the parser types. + * + * It is admissible where `Foo()` is not because the programmer has written the + * type down; the parser is reading an assertion, not deducing one. Confidence + * is `PROBABLE` rather than `CERTAIN` for the reason `cast` exists at all: it + * is a promise to the type checker that the runtime does not verify. + */ + private castInference(node: Parser.SyntaxNode): { + typeName: string; + typeKind: PythonInferredTypeKind; + evidence: PythonInferenceEvidence; + confidence: PythonInferenceConfidence; + } | null { + const callee = node.childForFieldName('function'); + if (!callee) { + return null; + } + const calleeName = callee.text.split('.').pop() ?? ''; + if (calleeName !== 'cast') { + return null; + } + const args = node.childForFieldName('arguments'); + if (!args) { + return null; + } + let first: Parser.SyntaxNode | null = null; + for (let index = 0; index < args.namedChildCount; index += 1) { + const child = args.namedChild(index); + if (child && !child.isExtra) { + first = child; + break; + } + } + if (!first) { + return null; + } + const typeName = + first.type === 'string' + ? first.text.replace(/^[a-zA-Z]*['"]|['"]$/g, '') + : first.text.replace(/\s+/g, ''); + if (typeName === '') { + return null; + } + return { + typeName, + typeKind: this.builtinKindOf(typeName), + evidence: PythonInferenceEvidence.CAST, + confidence: PythonInferenceConfidence.PROBABLE, + }; + } + + /** Classifies a type NAME, without resolving it. */ + private builtinKindOf(typeName: string): PythonInferredTypeKind { + const head = typeName.split('[')[0] ?? ''; + if (PYTHON_BUILTIN_SCALAR_TYPES.has(head)) { + return PythonInferredTypeKind.BUILTIN_SCALAR; + } + if (PYTHON_BUILTIN_COLLECTION_TYPES.has(head)) { + return PythonInferredTypeKind.BUILTIN_COLLECTION; + } + if (head === 'None' || head === 'NoneType') { + return PythonInferredTypeKind.NONE_TYPE; + } + if (head === 'Callable') { + return PythonInferredTypeKind.CALLABLE; + } + // A name that is not a builtin MIGHT be a user class, but saying so would + // claim a resolution this stage has not done. + return PythonInferredTypeKind.UNKNOWN; + } + + private isFormattedString(node: Parser.SyntaxNode): boolean { + if (node.type === 'concatenated_string') { + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && this.isFormattedString(child)) { + return true; + } + } + return false; + } + const start = node.child(0); + const prefix = start ? start.text.toLowerCase() : ''; + return prefix.includes('f'); + } + + private isBytesLiteral(node: Parser.SyntaxNode): boolean { + if (node.type === 'concatenated_string') { + const first = node.namedChild(0); + return first ? this.isBytesLiteral(first) : false; + } + const start = node.child(0); + const prefix = start ? start.text.toLowerCase() : ''; + return prefix.includes('b'); + } + + /** + * Fills the fields whose meaning depends on the node kind, most importantly + * `literalValue` — the shared **name slot** that carries a callee name, an + * attribute name, an identifier, or a literal's text, exactly as Java's + * column 10 does. + */ + private applyKindSpecificFields( + builder: ReturnType, + node: Parser.SyntaxNode, + kind: PythonExpressionKind, + pending: PendingExpression + ): void { + switch (kind) { + case PythonExpressionKind.NAME_REFERENCE: + case PythonExpressionKind.SELF_REFERENCE: + case PythonExpressionKind.CLS_REFERENCE: { + builder.withName(node.text); + builder.withReferencedEntity( + this.referencedEntityKindOf(node.text, kind, pending.scopeHash, pending.nameContext), + '' + ); + const bindingHash = this.input.bindingHashByScopeAndName.get( + `${pending.scopeHash}::${node.text}` + ); + if (bindingHash) { + builder.withBindingLinkHash(bindingHash); + } + return; + } + + case PythonExpressionKind.ATTRIBUTE_ACCESS: { + const attribute = + node.childForFieldName('attribute') ?? + node.namedChild(node.namedChildCount - 1); + builder.withName(attribute?.text ?? ''); + builder.withDottedPath(this.dottedPathOf(node)); + return; + } + + case PythonExpressionKind.CALL: { + if (pending.syntheticCalleeName !== undefined) { + builder.withName(pending.syntheticCalleeName); + builder.withDottedPath(pending.syntheticCalleeName); + return; + } + const fn = node.childForFieldName('function'); + builder.withName(fn ? this.calleeNameOf(fn) : ''); + if (fn) { + builder.withDottedPath(this.dottedPathOf(fn)); + } + return; + } + + case PythonExpressionKind.LITERAL: { + builder.withLiteral(this.literalTypeOf(node), this.literalTextOf(node)); + return; + } + + case PythonExpressionKind.BINARY_OPERATION: + case PythonExpressionKind.COMPARISON: + case PythonExpressionKind.BOOLEAN_OPERATION: { + builder.withOperator(this.binaryOperatorOf(node), PythonUnaryFixity.NONE); + return; + } + + case PythonExpressionKind.AUGMENTED_ASSIGNMENT: { + // PD-12. The wrapper shipped with operatorString empty on every row, + // while the column is populated on every other operator-bearing kind, + // so `a += b` and `d |= other` were indistinguishable. They are not the + // same operation: `|=` on a dict is a MERGE, which a value-flow pass + // resolves differently from an arithmetic accumulate. tree-sitter puts + // the operator in its own field, so nothing had to be recovered from + // text. + builder.withOperator( + node.childForFieldName('operator')?.text ?? '', + PythonUnaryFixity.NONE + ); + return; + } + + case PythonExpressionKind.UNARY_OPERATION: { + const operator = node.child(0); + builder.withOperator(operator?.text ?? '', PythonUnaryFixity.PREFIX); + return; + } + + case PythonExpressionKind.ASSIGNMENT_EXPRESSION: { + builder.withOperator(':=', PythonUnaryFixity.NONE); + const target = node.childForFieldName('name') ?? node.namedChild(0); + builder.withName(target?.text ?? ''); + return; + } + + case PythonExpressionKind.LAMBDA: { + builder.withLambdaScopeHash(this.input.scopeHashByNodeId.get(node.id) ?? ''); + return; + } + + case PythonExpressionKind.LIST_COMPREHENSION: + case PythonExpressionKind.SET_COMPREHENSION: + case PythonExpressionKind.DICT_COMPREHENSION: + case PythonExpressionKind.GENERATOR_EXPRESSION: { + builder.withLambdaScopeHash(this.input.scopeHashByNodeId.get(node.id) ?? ''); + builder.withComprehensionKind(this.comprehensionKindOf(node)); + return; + } + + case PythonExpressionKind.FSTRING: + case PythonExpressionKind.FSTRING_INTERPOLATION: { + builder.withLiteral(PythonLiteralType.FSTRING, ''); + return; + } + + default: { + return; + } + } + } + + /** + * Queues an expression's children with their edge roles. + * + * The roles are what make the tree queryable: a rule asking for a call's + * receiver looks for `RECEIVER`, and one asking for its keyword arguments + * looks for `KEYWORD_ARGUMENT` plus `argumentKeywordName`. + */ + /** + * The `nameContext` a child inherits. + * + * A write target propagates through the SHAPE of an unpacking — tuple, list, + * and the starred element inside one — because each leaf of that shape is + * itself assigned. It does not propagate through an attribute or a subscript, + * whose sub-expressions are evaluated to FIND the thing being written. + */ + private childNameContext( + kind: PythonExpressionKind, + parentContext: PythonNameContext + ): PythonNameContext { + if (kind === PythonExpressionKind.NAME_REFERENCE) { + return parentContext; + } + if (parentContext === PythonNameContext.LOAD) { + return PythonNameContext.LOAD; + } + const unpacking = + kind === PythonExpressionKind.TUPLE || + kind === PythonExpressionKind.LIST || + kind === PythonExpressionKind.STARRED; + return unpacking ? parentContext : PythonNameContext.LOAD; + } + + private enqueueChildren( + node: Parser.SyntaxNode, + kind: PythonExpressionKind, + expression: PyExpressionRegistry, + pending: PendingExpression + ): void { + const base: PendingExpression = { + ...pending, + parentHash: expression.getHash(), + depth: pending.depth + 1, + argumentKeywordName: '', + isAwaited: false, + isStarred: false, + // Only the node itself is a write target; its sub-expressions are reads. + // `self.x = 1` writes the attribute but READS `self`, and `d[k] = 1` + // reads both `d` and `k`. + // + // UNPACKING is the exception, and getting it wrong was silent: in + // `a, b = 1, 2` the TUPLE is the target and BOTH elements are writes. + // CPython says so directly — `ast.Tuple(ctx=Store)` has elements with + // `ctx=Store` — and treating them as reads made 1,472 names across 100 + // stdlib files look like reads of variables that are in fact assigned + // there. Nothing caught it, because every self-consistency invariant still + // held: the tree shape was right and only the LOAD/STORE label was wrong. + nameContext: this.childNameContext(kind, pending.nameContext), + }; + + // A synthetic row's children are carried on the pending record, because the + // grammar node it borrows its identity from has different children. + if (pending.syntheticChildren) { + pending.syntheticChildren.forEach((child, index) => { + this.worklist.push({ + ...base, + node: child, + edgeRole: pending.syntheticChildEdgeRole ?? PythonEdgeRole.ELEMENT, + position: index, + synthetic: undefined, + syntheticChildren: undefined, + syntheticChildEdgeRole: undefined, + syntheticCalleeName: undefined, + }); + }); + return; + } + + switch (kind) { + case PythonExpressionKind.AUGMENTED_ASSIGNMENT: { + // `a += f()` READS a and WRITES a. The target keeps STORE so the + // binding side is right; the read is recoverable because the same node + // is the target of an augmented assignment, which is what distinguishes + // it from a plain one. + const augTarget = node.childForFieldName('left'); + const augValue = node.childForFieldName('right'); + if (augValue) { + this.worklist.push({ + ...base, + node: augValue, + edgeRole: PythonEdgeRole.ASSIGNMENT_VALUE, + position: 1, + }); + } + if (augTarget) { + this.worklist.push({ + ...base, + node: augTarget, + edgeRole: PythonEdgeRole.ASSIGNMENT_TARGET, + position: 0, + nameContext: PythonNameContext.STORE, + }); + } + return; + } + + case PythonExpressionKind.ANNOTATED_ASSIGNMENT: + case PythonExpressionKind.ASSIGNMENT: { + const left = node.childForFieldName('left'); + const type = node.childForFieldName('type'); + let value = node.childForFieldName('right'); + + // A CHAINED assignment. tree-sitter nests `a = b = None` as + // assignment(left=a, right=assignment(left=b, ...)), so an inner `left` + // would arrive as a VALUE and be recorded as a read. CPython sees one + // Assign with targets=[a, b], both Store, and is plainly right: `b` is + // assigned, not evaluated. Peel the chain so every target is a target + // and only the final value is a value. + const chained: Parser.SyntaxNode[] = []; + while (value && value.type === 'assignment') { + const innerLeft = value.childForFieldName('left'); + if (innerLeft) { + chained.push(innerLeft); + } + value = value.childForFieldName('right'); + } + + if (value) { + this.worklist.push({ + ...base, + node: value, + edgeRole: PythonEdgeRole.ASSIGNMENT_VALUE, + position: 0, + nameContext: PythonNameContext.LOAD, + }); + } + if (type) { + this.worklist.push({ + ...base, + node: type, + edgeRole: PythonEdgeRole.ANNOTATION, + rootContext: PythonRootContext.ANNOTATION, + position: 0, + nameContext: PythonNameContext.LOAD, + }); + } + const targets = left ? [left, ...chained] : chained; + targets.forEach((target, index) => { + if (value) { + this.assignedValueByTargetRange.set( + `${target.startIndex}:${target.endIndex}`, + `${value.startIndex}:${value.endIndex}` + ); + } + this.worklist.push({ + ...base, + node: target, + edgeRole: PythonEdgeRole.ASSIGNMENT_TARGET, + rootContext: PythonRootContext.ASSIGNMENT_TARGET, + position: index, + nameContext: PythonNameContext.STORE, + }); + }); + return; + } + + case PythonExpressionKind.CALL: { + const fn = node.childForFieldName('function'); + const args = node.childForFieldName('arguments'); + if (fn) { + // The callee of a method call is an attribute whose object is the + // receiver; RECEIVER is used on the callee itself so that the Java + // `call-site.dl` pattern ports unchanged. + this.worklist.push({ + ...base, + node: fn, + edgeRole: + fn.type === 'attribute' ? PythonEdgeRole.RECEIVER : PythonEdgeRole.CALLEE, + position: 0, + }); + } + if (args) { + this.enqueueArguments(args, base); + } + return; + } + + case PythonExpressionKind.ATTRIBUTE_ACCESS: { + // `member_type` has no `object` field; its left side is the first child. + const object = node.childForFieldName('object') ?? node.namedChild(0); + if (object) { + // `type(obj).attr = value` — the grammar read the leading `type` as PEP 695's soft + // keyword and left the call's parentheses behind as a `parenthesized_expression`, + // so the object here IS the swallowed call's argument list. Rebuild the call: it + // spans the keyword through the closing paren, its callee is `type`, and what the + // parentheses hold are its arguments rather than a parenthesised value. + const swallowed = PythonExpressionExtractor.swallowedTypeCallKeyword(object); + if (swallowed) { + this.worklist.push({ + ...base, + node: object, + edgeRole: PythonEdgeRole.ATTRIBUTE_OBJECT, + position: 0, + synthetic: { + kind: PythonExpressionKind.CALL, + startIndex: swallowed.startIndex, + endIndex: object.endIndex, + startRow: swallowed.startPosition.row, + startColumn: swallowed.startPosition.column, + endRow: object.endPosition.row, + endColumn: object.endPosition.column, + }, + syntheticChildren: object.namedChildren.filter(c => !c.isExtra), + syntheticChildEdgeRole: PythonEdgeRole.ARGUMENT, + syntheticCalleeName: swallowed.text, + }); + return; + } + this.worklist.push({ + ...base, + node: object, + edgeRole: PythonEdgeRole.ATTRIBUTE_OBJECT, + position: 0, + }); + } + return; + } + + case PythonExpressionKind.SUBSCRIPT: { + if (node.type === 'generic_type') { + // `generic_type` = base name + a `type_parameter` holding the args. + const genericBase = node.namedChild(0); + if (genericBase) { + this.worklist.push({ + ...base, + node: genericBase, + edgeRole: PythonEdgeRole.SUBSCRIPT_OBJECT, + position: 0, + }); + } + let argIndex = 1; + for (let i = 1; i < node.namedChildCount; i++) { + const parameterList = node.namedChild(i); + if (parameterList?.type !== 'type_parameter') { + continue; + } + for (let j = 0; j < parameterList.namedChildCount; j++) { + const arg = parameterList.namedChild(j); + if (!arg || arg.isExtra) { + continue; + } + this.worklist.push({ + ...base, + node: arg, + edgeRole: PythonEdgeRole.SUBSCRIPT_INDEX, + position: argIndex++, + }); + } + } + return; + } + const value = node.childForFieldName('value'); + if (value) { + this.worklist.push({ + ...base, + node: value, + edgeRole: PythonEdgeRole.SUBSCRIPT_OBJECT, + position: 0, + }); + } + // `d[a, b, c]` has THREE `subscript` field children, and + // childForFieldName returns only the first — so indexing by field alone + // silently drops every index after the first, including any calls in + // them. dataclasses.py depends on this: `_hash_action[bool(a), bool(b), + // bool(c), d]` loses three of its four calls. + const indices: Parser.SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.id !== value?.id && !child.isExtra) { + indices.push(child); + } + } + + // MORE THAN ONE index, or a trailing comma, means the subscript's index + // is a TUPLE — `d[1, 2]` and `d[(1, 2)]` are the same program, and + // `d[1,]` is a one-element tuple. tree-sitter has no node for it, so the + // row is synthetic and spans the text BETWEEN the brackets, which is what + // ast reports. + const brackets = this.subscriptBracketSpan(node); + // A trailing comma makes a ONE-element tuple: `d[1,]` is `d[(1,)]`. + const trailingComma = node.text.trimEnd().endsWith(',]'); + if (brackets && (indices.length > 1 || trailingComma)) { + this.worklist.push({ + ...base, + node, + edgeRole: PythonEdgeRole.SUBSCRIPT_INDEX, + position: 1, + synthetic: { kind: PythonExpressionKind.TUPLE, ...brackets }, + syntheticChildren: indices, + }); + return; + } + + let index = 1; + for (const child of indices) { + this.worklist.push({ + ...base, + node: child, + edgeRole: PythonEdgeRole.SUBSCRIPT_INDEX, + position: index++, + }); + } + return; + } + + case PythonExpressionKind.SLICE: { + const roles = [ + PythonEdgeRole.SLICE_LOWER, + PythonEdgeRole.SLICE_UPPER, + PythonEdgeRole.SLICE_STEP, + ]; + let index = 0; + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (!part) { + continue; + } + this.worklist.push({ + ...base, + node: part, + edgeRole: roles[Math.min(index, roles.length - 1)]!, + position: index, + }); + index += 1; + } + return; + } + + case PythonExpressionKind.BINARY_OPERATION: + case PythonExpressionKind.COMPARISON: + case PythonExpressionKind.BOOLEAN_OPERATION: { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left) { + this.worklist.push({ + ...base, + node: left, + edgeRole: PythonEdgeRole.OPERAND_LEFT, + position: 0, + }); + } + if (right) { + this.worklist.push({ + ...base, + node: right, + edgeRole: PythonEdgeRole.OPERAND_RIGHT, + position: 1, + }); + } + if (!left && !right) { + // Chained comparisons such as `a < b < c` have no left/right fields. + let position = 0; + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (part) { + this.worklist.push({ + ...base, + node: part, + edgeRole: + position === 0 ? PythonEdgeRole.OPERAND_LEFT : PythonEdgeRole.OPERAND_RIGHT, + position: position++, + }); + } + } + } + return; + } + + case PythonExpressionKind.UNARY_OPERATION: { + const argument = node.childForFieldName('argument') ?? node.namedChild(0); + if (argument) { + this.worklist.push({ + ...base, + node: argument, + edgeRole: PythonEdgeRole.UNARY_OPERAND, + position: 0, + }); + } + return; + } + + case PythonExpressionKind.CONDITIONAL_EXPRESSION: { + // `a if cond else b` — body, condition, orelse, in source order. + const roles = [PythonEdgeRole.BODY, PythonEdgeRole.CONDITION, PythonEdgeRole.ORELSE]; + let index = 0; + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (part) { + this.worklist.push({ + ...base, + node: part, + edgeRole: roles[Math.min(index, roles.length - 1)]!, + position: index, + }); + index += 1; + } + } + return; + } + + case PythonExpressionKind.AWAIT: { + const inner = node.namedChild(0); + if (inner) { + this.worklist.push({ + ...base, + node: inner, + edgeRole: PythonEdgeRole.AWAIT_OPERAND, + position: 0, + isAwaited: true, + }); + } + return; + } + + case PythonExpressionKind.YIELD: + case PythonExpressionKind.YIELD_FROM: { + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (part) { + this.worklist.push({ + ...base, + node: part, + edgeRole: PythonEdgeRole.YIELD_VALUE, + position: i, + }); + } + } + return; + } + + case PythonExpressionKind.STARRED: + case PythonExpressionKind.DOUBLE_STARRED: { + const inner = node.namedChild(0); + if (inner) { + this.worklist.push({ + ...base, + node: inner, + edgeRole: pending.edgeRole, + position: 0, + isStarred: true, + }); + } + return; + } + + case PythonExpressionKind.ASSIGNMENT_EXPRESSION: { + const target = node.childForFieldName('name') ?? node.namedChild(0); + const value = node.childForFieldName('value') ?? node.namedChild(1); + if (value) { + this.worklist.push({ + ...base, + node: value, + edgeRole: PythonEdgeRole.ASSIGNMENT_VALUE, + position: 1, + }); + } + if (target) { + this.worklist.push({ + ...base, + node: target, + edgeRole: PythonEdgeRole.ASSIGNMENT_TARGET, + position: 0, + nameContext: PythonNameContext.STORE, + }); + } + return; + } + + case PythonExpressionKind.LAMBDA: { + // Parameter defaults are evaluated in the ENCLOSING scope, at the moment + // the lambda is created — the same rule as for a `def`. Walking only the + // body loses them: `CFUNCTYPE(None)(lambda x=Nasty(): None)` from the + // stdlib test suite hides a real call in a lambda default. + const parameters = node.childForFieldName('parameters'); + if (parameters) { + for (let i = 0; i < parameters.namedChildCount; i++) { + const param = parameters.namedChild(i); + if (param?.isExtra) { + continue; + } + const value = param?.childForFieldName('value'); + if (value) { + this.worklist.push({ + ...base, + node: value, + edgeRole: PythonEdgeRole.DEFAULT_VALUE, + position: i, + }); + } + } + } + // The body evaluates in the LAMBDA's own scope AND is owned by the + // lambda's own py_method — not by the function the lambda sits in. + // Attributing a call inside a lambda to the enclosing function is a real + // call-graph error: the lambda is a separate callable that may be invoked + // from anywhere it is passed to. + const body = node.childForFieldName('body'); + const lambdaScope = this.input.scopeHashByNodeId.get(node.id); + const lambdaMethod = this.input.lambdaMethodByNodeId.get(node.id); + if (body && lambdaScope) { + this.worklist.push({ + ...base, + node: body, + edgeRole: PythonEdgeRole.LAMBDA_BODY, + position: 0, + scopeHash: lambdaScope, + rootContext: PythonRootContext.LAMBDA_BODY, + ...(lambdaMethod + ? { + ownerHash: lambdaMethod, + ownerKind: PythonExpressionOwnerKind.LAMBDA, + methodHash: lambdaMethod, + // A lambda body is not module-level code even when the lambda + // itself is written at module level. + isModuleLevelCall: false, + } + : {}), + }); + } + return; + } + + case PythonExpressionKind.LIST_COMPREHENSION: + case PythonExpressionKind.SET_COMPREHENSION: + case PythonExpressionKind.DICT_COMPREHENSION: + case PythonExpressionKind.GENERATOR_EXPRESSION: { + this.enqueueComprehensionChildren(node, base); + return; + } + + case PythonExpressionKind.TUPLE: + case PythonExpressionKind.LIST: + case PythonExpressionKind.SET: + case PythonExpressionKind.DICT: + case PythonExpressionKind.FSTRING: + case PythonExpressionKind.FSTRING_INTERPOLATION: + default: { + const isFstring = + kind === PythonExpressionKind.FSTRING || + kind === PythonExpressionKind.FSTRING_INTERPOLATION; + const isDisplay = + kind === PythonExpressionKind.LIST || + kind === PythonExpressionKind.SET || + kind === PythonExpressionKind.TUPLE || + kind === PythonExpressionKind.DICT; + // A collection element is NOT an argument. These used to carry ARGUMENT + // because no element role existed, so a rule joining edgeRole=ARGUMENT to + // a call site picked up every element of every literal passed to one: + // `f([a, b])` has ONE argument, not three. + const role = isFstring + ? PythonEdgeRole.FSTRING_EXPRESSION + : isDisplay + ? PythonEdgeRole.ELEMENT + : PythonEdgeRole.ARGUMENT; + let position = 0; + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (!part || part.type === 'block' || part.isExtra) { + continue; + } + // A dict ENTRY is a `pair`, and flattening it lost both the pairing and + // the ordinal: `{k: v, k2: v2}` gave k and k2 position 0 and v and v2 + // position 1, so `position` — the ordinal among siblings in the same + // role — identified nothing. Emitting KEY and VALUE with the ENTRY + // index as position restores both. + if (part.type === 'pair') { + const key = part.childForFieldName('key'); + const value = part.childForFieldName('value'); + if (key) { + this.worklist.push({ + ...base, + node: key, + edgeRole: PythonEdgeRole.KEY, + position, + }); + } + if (value) { + this.worklist.push({ + ...base, + node: value, + edgeRole: PythonEdgeRole.VALUE, + position, + }); + } + position += 1; + continue; + } + this.worklist.push({ ...base, node: part, edgeRole: role, position: position++ }); + } + return; + } + } + } + + /** + * Queues call arguments, distinguishing the four kinds that argument flow + * needs to tell apart. + * + * Keyword arguments carry their name in `argumentKeywordName`. Without it, + * argument→parameter linking can only work positionally and silently loses + * 12,000 measured keyword arguments. + */ + private enqueueArguments(args: Parser.SyntaxNode, base: PendingExpression): void { + // A sole generator expression is passed WITHOUT an argument_list wrapper: + // `tuple(x for x in y)` puts the generator_expression directly in the + // `arguments` field. Iterating its children as if it were an argument list + // counts the element and each for-clause as separate arguments and loses any + // call inside the clause entirely. + if (args.type !== 'argument_list') { + this.worklist.push({ + ...base, + node: args, + edgeRole: PythonEdgeRole.ARGUMENT, + position: 0, + }); + return; + } + + let positional = 0; + for (let i = 0; i < args.namedChildCount; i++) { + const arg = args.namedChild(i); + if (!arg || arg.isExtra) { + continue; + } + + if (arg.type === 'keyword_argument') { + const name = arg.childForFieldName('name')?.text ?? ''; + const value = arg.childForFieldName('value'); + if (value) { + this.worklist.push({ + ...base, + node: value, + edgeRole: PythonEdgeRole.KEYWORD_ARGUMENT, + position: positional, + argumentKeywordName: name, + }); + } + continue; + } + + if (arg.type === 'list_splat') { + const inner = arg.namedChild(0); + if (inner) { + this.worklist.push({ + ...base, + node: inner, + edgeRole: PythonEdgeRole.STAR_ARGUMENT, + position: positional++, + isStarred: true, + }); + } + continue; + } + + if (arg.type === 'dictionary_splat') { + const inner = arg.namedChild(0); + if (inner) { + this.worklist.push({ + ...base, + node: inner, + edgeRole: PythonEdgeRole.DOUBLE_STAR_ARGUMENT, + position: positional, + isStarred: true, + }); + } + continue; + } + + this.worklist.push({ + ...base, + node: arg, + edgeRole: PythonEdgeRole.ARGUMENT, + position: positional++, + }); + } + } + + /** + * Queues a comprehension's parts. + * + * The **outermost iterable is evaluated in the enclosing scope** while + * everything else evaluates inside the comprehension's own scope. Assigning + * them all the inner scope would make the outer iterable's names resolve + * against the wrong binding set. + */ + private enqueueComprehensionChildren( + node: Parser.SyntaxNode, + base: PendingExpression + ): void { + const innerScope = this.input.scopeHashByNodeId.get(node.id) ?? base.scopeHash; + const clauses: Parser.SyntaxNode[] = []; + const elements: Parser.SyntaxNode[] = []; + + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'for_in_clause') { + clauses.push(child); + continue; + } + if (child.type === 'if_clause') { + elements.push(child); + continue; + } + elements.push(child); + } + + clauses.forEach((clause, index) => { + const target = clause.childForFieldName('left'); + const iterable = clause.childForFieldName('right'); + if (iterable) { + this.worklist.push({ + ...base, + node: iterable, + edgeRole: PythonEdgeRole.COMPREHENSION_ITERABLE, + position: index, + // Only the FIRST iterable is evaluated outside. + scopeHash: index === 0 ? base.scopeHash : innerScope, + rootContext: index === 0 ? base.rootContext : PythonRootContext.COMPREHENSION, + }); + } + if (target) { + this.worklist.push({ + ...base, + node: target, + edgeRole: PythonEdgeRole.COMPREHENSION_TARGET, + position: index, + scopeHash: innerScope, + rootContext: PythonRootContext.COMPREHENSION, + nameContext: PythonNameContext.STORE, + }); + } + }); + + elements.forEach((element, index) => { + const isCondition = element.type === 'if_clause'; + const target = isCondition ? (element.namedChild(0) ?? element) : element; + this.worklist.push({ + ...base, + node: target, + edgeRole: isCondition + ? PythonEdgeRole.COMPREHENSION_CONDITION + : PythonEdgeRole.COMPREHENSION_ELEMENT, + position: index, + scopeHash: innerScope, + rootContext: PythonRootContext.COMPREHENSION, + }); + }); + } + + // ------------------------------------------------------------- call sites + + private emitCallSite( + node: Parser.SyntaxNode, + expression: PyExpressionRegistry, + pending: PendingExpression + ): void { + // A synthetic call has no `function` child to read: the `type(obj).attr` recovery + // rebuilt it from a keyword token, so the name travels on the pending record. It is an + // ordinary named call to a builtin — nothing about it is dynamic — and its arguments are + // the parenthesised group the grammar left behind rather than an `argument_list`. + if (pending.syntheticCalleeName !== undefined) { + const synthesised = PyCallSiteRegistry.builder( + PythonCallKind.SIMPLE_CALL, + pending.syntheticCalleeName, + expression.getHash(), + pending.scopeHash, + pending.methodHash, + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withCallee(pending.syntheticCalleeName) + .withReceiver(PythonReceiverKind.NONE, '', '') + .withPyTypeLinkHash(pending.typeHash) + .withArguments(this.summarizeArguments(node)) + .withFlags({ + isModuleLevelCall: pending.isModuleLevelCall, + isConditional: pending.isConditional, + }) + .withSpan( + (pending.synthetic?.startRow ?? node.startPosition.row) + 1, + this.input.positions.byteColumn( + pending.synthetic?.startRow ?? node.startPosition.row, + pending.synthetic?.startColumn ?? node.startPosition.column + ), + (pending.synthetic?.endRow ?? node.endPosition.row) + 1 + ) + .build(); + this.callSites.push(synthesised); + return; + } + + const fn = PythonExpressionExtractor.unwrapSplatInCalleePosition( + node.childForFieldName('function') + ); + const args = node.childForFieldName('arguments'); + const argumentSummary = this.summarizeArguments(args); + const receiver = this.classifyReceiver(fn, pending); + + const callSite = PyCallSiteRegistry.builder( + this.callKindOf(fn, receiver.kind, pending), + fn ? this.calleeNameOf(fn) : '', + expression.getHash(), + pending.scopeHash, + pending.methodHash, + this.input.module.getHash(), + this.input.serviceVersionLinkHash + ) + .withCallee(fn ? this.dottedPathOf(fn) : '') + .withReceiver(receiver.kind, receiver.text, '') + .withPyTypeLinkHash(pending.typeHash) + .withArguments(argumentSummary) + .withFlags({ + isModuleLevelCall: pending.isModuleLevelCall, + isConditional: pending.isConditional, + }) + .withSpan( + node.startPosition.row + 1, + this.input.positions.byteColumn(node.startPosition.row, node.startPosition.column), + node.endPosition.row + 1 + ) + .build(); + + this.callSites.push(callSite); + if (receiver.node) { + this.pendingReceiverLinks.push({ + callSite, + range: `${receiver.node.startIndex}:${receiver.node.endIndex}`, + }); + } + } + + private summarizeArguments(args: Parser.SyntaxNode | null): { + positionalArgCount: number; + keywordArgCount: number; + hasStarArgs: boolean; + hasDoubleStarArgs: boolean; + keywordNames: string[]; + } { + const summary = { + positionalArgCount: 0, + keywordArgCount: 0, + hasStarArgs: false, + hasDoubleStarArgs: false, + keywordNames: [] as string[], + }; + if (!args) { + return summary; + } + // See enqueueArguments: a bare generator expression is one argument, not a + // list of its parts. + if (args.type !== 'argument_list') { + summary.positionalArgCount = 1; + return summary; + } + for (let i = 0; i < args.namedChildCount; i++) { + const arg = args.namedChild(i); + if (!arg) { + continue; + } + // Grammar EXTRAS — comments and line-continuation backslashes — are + // NAMED nodes that can sit between arguments, so counting named children + // blindly inflates positionalArgCount. Both occur in real multi-line + // calls (ftplib.py:975 uses a continuation, argparse uses comments). + // `isExtra` is the grammar's own answer to "is this syntactically + // incidental", which beats maintaining a blacklist of node types. + if (arg.isExtra) { + continue; + } + if (arg.type === 'keyword_argument') { + summary.keywordArgCount += 1; + summary.keywordNames.push(arg.childForFieldName('name')?.text ?? ''); + continue; + } + if (arg.type === 'list_splat') { + summary.hasStarArgs = true; + continue; + } + if (arg.type === 'dictionary_splat') { + summary.hasDoubleStarArgs = true; + continue; + } + summary.positionalArgCount += 1; + } + return summary; + } + + /** + /** + * The `type` keyword token of a `type(obj).attr = value` whose call the grammar swallowed, + * or `null` for anything else. + * + * tree-sitter reads the leading `type` as PEP 695's soft keyword and then accepts + * `(obj).attr` as an alias name, so the statement parses cleanly as a + * `type_alias_statement` and the CALL NODE NEVER EXISTS — `(obj)` is left as a + * `parenthesized_expression` hanging off an `attribute`. `python-soft-keywords.ts` already + * detects the misparse; this finds the token the call has to be rebuilt from, because + * there is no identifier node for `type` anywhere in the tree. + * + * Two things must NOT match, and both reach the same branch: + * + * - `type[o].x = 1`, where the object is a `list` rather than a parenthesised group. + * That is a subscript, not a call, and synthesising one would invent a call site. + * - the VALUE side of the statement. Only the leading `type` is taken as the keyword, + * so the paren group must be the first thing after it — checked by position rather + * than by shape, since a nested `type(...)` elsewhere in the statement is a real call + * the grammar parsed correctly on its own. + */ + private static swallowedTypeCallKeyword( + object: Parser.SyntaxNode + ): Parser.SyntaxNode | null { + if (object.type !== 'parenthesized_expression' || object.parent?.type !== 'attribute') { + return null; + } + let statement: Parser.SyntaxNode | null = object.parent; + while (statement && statement.type !== 'type_alias_statement') { + if (statement.type === 'block' || statement.type === 'module') { + return null; + } + statement = statement.parent; + } + if (!statement || !isMisparsedTypeAlias(statement)) { + return null; + } + const keyword = statement.child(0); + if (!keyword || keyword.text !== 'type' || keyword.endIndex > object.startIndex) { + return null; + } + // Only whitespace may sit between the keyword and the parenthesis it opened. + const between = statement.text.slice( + keyword.endIndex - statement.startIndex, + object.startIndex - statement.startIndex + ); + return between.trim() === '' ? keyword : null; + } + + /** + * Unwraps a splat that tree-sitter-python nested INTO a callee position. + * + * `[*items()]` and `{*items()}` — a single splatted element in a list or set display — + * parse as `call(function: list_splat(*, items), arguments: ())`, i.e. as though the + * source had said `(*items)()`. It does not: the `*` applies to the call's RESULT, and + * the callee is `items`. The sibling forms are nested correctly, which is what localises + * this — `(*items(),)` is `list_splat(call(items))`, `{**mapping()}` is + * `dictionary_splat(call(mapping))`, and `f(*items())` puts the splat in the argument + * list where it belongs. Two splats in one display (`[*a(), *b()]`) also parse correctly, + * so it is specifically the single-element case. + * + * The same mis-nesting reaches an attribute callee one level deeper: `[*obj.method()]` + * parses as `attribute(object: list_splat(*, obj), attribute: method)`, so the RECEIVER + * is the splat and its text comes out `*obj` — a receiver no engine can resolve. Both + * shapes are the same rule, so both call sites use this. + * + * Left as a compensation here rather than waited on: the grammar is shared across + * languages and cannot be upgraded for this alone. + */ + private static unwrapSplatInCalleePosition( + node: Parser.SyntaxNode | null + ): Parser.SyntaxNode | null { + if (!node || (node.type !== 'list_splat' && node.type !== 'dictionary_splat')) { + return node; + } + const named = node.namedChildren; + return named[named.length - 1] ?? node; + } + + /** + * Classifies the receiver by its **syntactic shape**, which is all that is + * honestly knowable about a duck-typed receiver. + * + * `self` is recognised by comparing against the enclosing method's actual + * first parameter name rather than the literal string `self`, because the name + * is a convention: a method whose receiver is named `s` still has a receiver. + */ + private classifyReceiver( + fn: Parser.SyntaxNode | null, + pending: PendingExpression + ): { kind: PythonReceiverKind; text: string; node: Parser.SyntaxNode | null } { + if (!fn) { + return { kind: PythonReceiverKind.UNKNOWN, text: '', node: null }; + } + if (fn.type === 'subscript') { + // `HANDLERS[k](a)` — the CALLEE is a subscript, not a receiver-dot-method. + // This used to fall through to NONE and then to DYNAMIC_CALL, which says + // "unresolvable by construction" and drops the container name. It is not + // unresolvable: the container is written down, so an engine that can type + // HANDLERS can type its elements. Naming the container is the difference + // between a lead and a dead end. + const container = fn.childForFieldName('value'); + return { + kind: PythonReceiverKind.SUBSCRIPT, + text: container ? EntityUtils.normalizeWhitespace(container.text) : '', + node: container ?? null, + }; + } + if (fn.type !== 'attribute') { + return { kind: PythonReceiverKind.NONE, text: '', node: null }; + } + let object = PythonExpressionExtractor.unwrapSplatInCalleePosition( + fn.childForFieldName('object') + ); + if (!object) { + return { kind: PythonReceiverKind.UNKNOWN, text: '', node: null }; + } + // Unwrapped before the text is taken, or the receiver reads `*obj` rather than `obj`. + const text = EntityUtils.normalizeWhitespace(object.text); + // A parenthesised receiver is the same receiver. Multi-line string + // construction makes this common: `("a" "b").format(x)`. + while (object.type === 'parenthesized_expression') { + const inner = object.namedChild(0); + if (!inner) { + break; + } + object = inner; + } + + switch (object.type) { + case 'identifier': { + if (pending.receiverName !== '' && object.text === pending.receiverName) { + return { + kind: pending.receiverIsClass ? PythonReceiverKind.CLS : PythonReceiverKind.SELF, + text, + node: object, + }; + } + return { kind: PythonReceiverKind.NAME, text, node: object }; + } + case 'attribute': { + return { kind: PythonReceiverKind.ATTRIBUTE, text, node: object }; + } + case 'call': { + // `super()` is a call result, but a special one: it is an MRO-ordered + // lookup sliced after the enclosing class, not virtual dispatch. + const inner = object.childForFieldName('function'); + if (inner?.text === 'super') { + return { kind: PythonReceiverKind.SUPER, text, node: object }; + } + return { kind: PythonReceiverKind.CALL_RESULT, text, node: object }; + } + case 'subscript': { + return { kind: PythonReceiverKind.SUBSCRIPT, text, node: object }; + } + case 'string': + case 'concatenated_string': + case 'integer': + case 'float': + case 'true': + case 'false': + case 'none': + case 'list': + case 'dictionary': + case 'set': + case 'tuple': { + return { kind: PythonReceiverKind.LITERAL, text, node: object }; + } + default: { + return { kind: PythonReceiverKind.UNKNOWN, text, node: object }; + } + } + } + + private callKindOf( + fn: Parser.SyntaxNode | null, + receiverKind: PythonReceiverKind, + pending: PendingExpression + ): PythonCallKind { + if (pending.rootContext === PythonRootContext.DECORATOR) { + return PythonCallKind.DECORATOR_CALL; + } + switch (receiverKind) { + case PythonReceiverKind.SUPER: { + return PythonCallKind.SUPER_CALL; + } + case PythonReceiverKind.SELF: { + return PythonCallKind.SELF_CALL; + } + case PythonReceiverKind.CLS: { + return PythonCallKind.CLS_CALL; + } + case PythonReceiverKind.CALL_RESULT: { + return PythonCallKind.CHAINED_CALL; + } + case PythonReceiverKind.SUBSCRIPT: { + return PythonCallKind.SUBSCRIPT_CALL; + } + case PythonReceiverKind.ATTRIBUTE: + case PythonReceiverKind.NAME: + case PythonReceiverKind.LITERAL: { + return PythonCallKind.METHOD_CALL; + } + case PythonReceiverKind.NONE: { + if (fn?.type === 'identifier') { + return PythonCallKind.SIMPLE_CALL; + } + return PythonCallKind.DYNAMIC_CALL; + } + default: { + return PythonCallKind.UNKNOWN_CALLEE_CALL; + } + } + } + + // ---------------------------------------------------------------- helpers + + /** + * Maps a tree-sitter node type to an expression kind, or `null` for a node + * that produces no row of its own. + * + * **This function is pure.** It must never enqueue work, and the rule is worth + * stating because breaking it fails silently: an earlier version enqueued the + * inner node for an annotation wrapper *and* returned `null`, so the + * transparent fallback enqueued it a second time and every annotation + * sub-expression was emitted twice with an identical primary key. Classify + * here; enqueue in `enqueueChildren` and the fallback, nowhere else. + */ + private expressionKindOf( + node: Parser.SyntaxNode, + pending: PendingExpression + ): PythonExpressionKind | null { + switch (node.type) { + case 'call': { + return PythonExpressionKind.CALL; + } + case 'attribute': + // `member_type` is a dotted name in TYPE position (`A[int].Inner`). Same + // construct as an attribute access, so it must produce the same shape — + // the generic fallback treated its trailing identifier as a name. + case 'member_type': { + return PythonExpressionKind.ATTRIBUTE_ACCESS; + } + case 'splat_type': { + return PythonExpressionKind.STARRED; + } + case 'subscript': + // A subscripted annotation is spelled `generic_type` by this grammar, but + // it is the same construct as `d[k]` and must produce the same shape. + // Falling through to the transparent fallback flattened + // `Optional[Dict[str, int]]` into four siblings all at depth 0 — and + // `depth` is a frozen spine column that `type-hierarchy.dl` filters on to + // find the OUTERMOST type reference. + case 'generic_type': { + return PythonExpressionKind.SUBSCRIPT; + } + case 'slice': { + return PythonExpressionKind.SLICE; + } + case 'identifier': { + if (pending.receiverName !== '' && node.text === pending.receiverName) { + return pending.receiverIsClass + ? PythonExpressionKind.CLS_REFERENCE + : PythonExpressionKind.SELF_REFERENCE; + } + return PythonExpressionKind.NAME_REFERENCE; + } + case 'string': + case 'concatenated_string': { + return this.isFormatString(node) + ? PythonExpressionKind.FSTRING + : PythonExpressionKind.LITERAL; + } + case 'interpolation': { + return PythonExpressionKind.FSTRING_INTERPOLATION; + } + case 'integer': + case 'float': + case 'true': + case 'false': + case 'none': { + return PythonExpressionKind.LITERAL; + } + case 'ellipsis': { + return PythonExpressionKind.ELLIPSIS; + } + case 'tuple': + // `(a, b) = f()` — a PARENTHESISED tuple target. tree-sitter spells this + // `tuple_pattern` where the bare form is `expression_list` and the + // match-pattern form is `pattern_list`; all three are `Tuple` to ast, and + // only two of them were mapped. It was the commonest completeness gap in + // the stdlib. + // See the expression_statement case: a multi-part statement IS a bare + // tuple, and the statement node carries its span. + case 'expression_statement': + case 'tuple_pattern': + case 'pattern_list': + // A BARE tuple — `return a, b` or `match a, b:` or `del a, b`. tree-sitter + // spells it `expression_list` and this used to treat it as a transparent + // wrapper, passing the elements through and emitting no row for the tuple + // itself. CPython disagrees: ast produces a `Tuple` node there, exactly as + // it does for the parenthesised form, so the tuple was a lost fact — a + // consumer asking what a function returns saw the last element rather than + // a tuple of them. + case 'expression_list': { + return PythonExpressionKind.TUPLE; + } + case 'list': { + return PythonExpressionKind.LIST; + } + case 'set': { + return PythonExpressionKind.SET; + } + case 'dictionary': { + return PythonExpressionKind.DICT; + } + case 'list_comprehension': { + return PythonExpressionKind.LIST_COMPREHENSION; + } + case 'set_comprehension': { + return PythonExpressionKind.SET_COMPREHENSION; + } + case 'dictionary_comprehension': { + return PythonExpressionKind.DICT_COMPREHENSION; + } + case 'generator_expression': { + return PythonExpressionKind.GENERATOR_EXPRESSION; + } + case 'lambda': { + return PythonExpressionKind.LAMBDA; + } + case 'conditional_expression': { + return PythonExpressionKind.CONDITIONAL_EXPRESSION; + } + case 'binary_operator': { + return PythonExpressionKind.BINARY_OPERATION; + } + case 'unary_operator': + case 'not_operator': { + return PythonExpressionKind.UNARY_OPERATION; + } + case 'boolean_operator': { + return PythonExpressionKind.BOOLEAN_OPERATION; + } + case 'comparison_operator': { + return PythonExpressionKind.COMPARISON; + } + case 'named_expression': { + return PythonExpressionKind.ASSIGNMENT_EXPRESSION; + } + case 'list_splat': + case 'list_splat_pattern': { + return PythonExpressionKind.STARRED; + } + case 'dictionary_splat': + case 'dictionary_splat_pattern': { + return PythonExpressionKind.DOUBLE_STARRED; + } + case 'await': { + return PythonExpressionKind.AWAIT; + } + case 'yield': { + return this.isYieldFrom(node) + ? PythonExpressionKind.YIELD_FROM + : PythonExpressionKind.YIELD; + } + case 'assignment': { + // `x: int = 0` is an ANNOTATED assignment and says something a plain + // one does not: the name has a declared type. Both reported kind + // ASSIGNMENT, so the two were indistinguishable in the fact base and + // ANNOTATED_ASSIGNMENT was declared but never emitted. The annotation + // is the `type` field, which is exactly the test the root context + // already uses. + return node.childForFieldName('type') + ? PythonExpressionKind.ANNOTATED_ASSIGNMENT + : PythonExpressionKind.ASSIGNMENT; + } + case 'augmented_assignment': { + return PythonExpressionKind.AUGMENTED_ASSIGNMENT; + } + case 'case_pattern': { + return PythonExpressionKind.MATCH_PATTERN; + } + case 'dotted_name': { + // In a match pattern tree-sitter gives `cls.SHORT` as a `dotted_name` + // with two identifier children, NOT an `attribute`. With no kind it + // fell to the generic walk and emitted `cls` and `SHORT` as two + // unrelated depth-0 rows, so a value pattern was indistinguishable from + // two capture names. A single identifier under it is just a name. + return node.namedChildCount > 1 + ? PythonExpressionKind.ATTRIBUTE_ACCESS + : PythonExpressionKind.NAME_REFERENCE; + } + default: { + // Including `type`, `generic_type` and `type_parameter`: annotation + // wrappers with no expression of their own, handled by the transparent + // fallback in emitExpression. + return null; + } + } + } + + /** + * Kinds with no sub-expressions worth emitting. + * + * An **implicitly concatenated** string is the exception, and it is a trap: + * + * ```python + * raise TypeError(f'{type(self).__name__}() is deprecated ' + * 'and will be removed') + * ``` + * + * That is one `concatenated_string` whose parts include an f-string, so + * treating it as a plain literal leaf silently discards the `type(self)` call + * inside it. Implicit concatenation across lines is extremely common in + * error-message construction, so this is not a corner case. + */ + private isLeafKind(kind: PythonExpressionKind, node: Parser.SyntaxNode): boolean { + if (node.type === 'concatenated_string') { + return false; + } + return ( + kind === PythonExpressionKind.NAME_REFERENCE || + kind === PythonExpressionKind.SELF_REFERENCE || + kind === PythonExpressionKind.CLS_REFERENCE || + kind === PythonExpressionKind.LITERAL || + kind === PythonExpressionKind.ELLIPSIS + ); + } + + /** + * Token-ish nodes that cannot contain an expression. + * + * Grammar extras (comments, line continuations) are handled by `isExtra` at + * the enqueue sites; this covers string internals, which are named children + * of a literal but are not expressions. + */ + /** + * The span BETWEEN a subscript's brackets, which is what ast reports as the + * index tuple's own span. `d[1, 2]` gives the span of `1, 2`. + */ + private subscriptBracketSpan(node: Parser.SyntaxNode): { + startIndex: number; + endIndex: number; + startRow: number; + startColumn: number; + endRow: number; + endColumn: number; + } | null { + let open: Parser.SyntaxNode | null = null; + let close: Parser.SyntaxNode | null = null; + for (let index = 0; index < node.childCount; index += 1) { + const child = node.child(index); + if (child?.type === '[') { + open = child; + } + if (child?.type === ']') { + close = child; + } + } + if (!open || !close) { + return null; + } + return { + startIndex: open.endIndex, + endIndex: close.startIndex, + startRow: open.endPosition.row, + startColumn: open.endPosition.column, + endRow: close.startPosition.row, + endColumn: close.startPosition.column, + }; + } + + private isLeafToken(node: Parser.SyntaxNode): boolean { + if (node.isExtra) { + return true; + } + switch (node.type) { + case 'string_start': + case 'string_content': + case 'string_end': + case 'escape_sequence': + case 'type_conversion': + case 'positional_separator': + case 'keyword_separator': { + return true; + } + default: { + return false; + } + } + } + + /** + * Whether a string node is an f-string, looking through implicit + * concatenation: `'a' f'{b}'` is one concatenated_string and IS formatted. + */ + private isFormatString(node: Parser.SyntaxNode): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'interpolation') { + return true; + } + if (child?.type === 'string' && this.isFormatString(child)) { + return true; + } + } + const start = node.child(0); + return start?.type === 'string_start' && /^[a-zA-Z]*f/i.test(start.text); + } + + private isYieldFrom(node: Parser.SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.type === 'from') { + return true; + } + } + return false; + } + + private literalTypeOf(node: Parser.SyntaxNode): PythonLiteralType { + switch (node.type) { + case 'integer': { + return PythonLiteralType.INTEGER; + } + case 'float': { + return PythonLiteralType.FLOAT; + } + case 'true': + case 'false': { + return PythonLiteralType.BOOLEAN; + } + case 'none': { + return PythonLiteralType.NONE; + } + case 'ellipsis': { + return PythonLiteralType.ELLIPSIS; + } + default: { + const start = node.child(0); + const prefix = start?.type === 'string_start' ? start.text.toLowerCase() : ''; + if (prefix.includes('b')) { + return PythonLiteralType.BYTES; + } + if (prefix.includes('r')) { + return PythonLiteralType.RAW_STRING; + } + return PythonLiteralType.STRING; + } + } + } + + private literalTextOf(node: Parser.SyntaxNode): string { + // `...` carries no text of its own in tree-sitter but IS a value; CPython + // names it `Ellipsis`, and an empty literalValue made it indistinguishable + // from a literal we failed to read. + if (node.type === 'ellipsis') { + return 'Ellipsis'; + } + if (node.type !== 'string' && node.type !== 'concatenated_string') { + return node.text; + } + // An IMPLICITLY CONCATENATED string is ONE value: `"part-one" "part-two"` is + // `part-onepart-two` to CPython. Returning the first part alone — or, when + // the parts sit in nested `string` nodes, returning nothing at all — lost the + // value entirely, which matters because this is how long SQL and URL + // literals are written. + const parts: string[] = []; + const collect = (current: Parser.SyntaxNode): void => { + for (let index = 0; index < current.namedChildCount; index += 1) { + const part = current.namedChild(index); + if (!part) { + continue; + } + if (part.type === 'string_content') { + parts.push(part.text); + continue; + } + if (part.type === 'string' || part.type === 'concatenated_string') { + collect(part); + } + } + }; + collect(node); + return EntityUtils.normalizeWhitespace(parts.join('')); + } + + private binaryOperatorOf(node: Parser.SyntaxNode): string { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + const parts: string[] = []; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child || child.isNamed) { + continue; + } + if (child.id === left?.id || child.id === right?.id) { + continue; + } + parts.push(child.text); + } + if (parts.length > 0) { + return parts.join(' '); + } + // `is not` and `not in` are two tokens; collect any operator keywords. + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && !child.isNamed) { + parts.push(child.text); + } + } + return parts.join(' '); + } + + private comprehensionKindOf(node: Parser.SyntaxNode): PythonComprehensionKind { + const isAsync = this.hasAsyncClause(node); + switch (node.type) { + case 'set_comprehension': { + return isAsync ? PythonComprehensionKind.ASYNC_SET : PythonComprehensionKind.SET; + } + case 'dictionary_comprehension': { + return isAsync ? PythonComprehensionKind.ASYNC_DICT : PythonComprehensionKind.DICT; + } + case 'generator_expression': { + return isAsync + ? PythonComprehensionKind.ASYNC_GENERATOR + : PythonComprehensionKind.GENERATOR; + } + default: { + return isAsync ? PythonComprehensionKind.ASYNC_LIST : PythonComprehensionKind.LIST; + } + } + } + + private hasAsyncClause(node: Parser.SyntaxNode): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const clause = node.namedChild(i); + if (clause?.type !== 'for_in_clause') { + continue; + } + for (let j = 0; j < clause.childCount; j++) { + if (clause.child(j)?.type === 'async') { + return true; + } + } + } + return false; + } + + /** + * What a name reference points at. + * + * This used to stop at UNKNOWN for everything that was not `self`, `cls` or + * `super`, on the reasoning that anything finer needed the binding table and + * would otherwise be a guess. The premise was right and the conclusion was + * not: the binding table is available HERE, and the classification is a + * lookup rather than an inference. + * + * py_binding mirrors CPython's symtable, so the referencing scope has an + * entry for every name used in it -- a global read from inside a function has + * a row in the FUNCTION's scope with is_global set. So the lookup by + * `scope::name` hits for reads of enclosing names too, not only for names + * bound locally, and its predicates match symtable exactly. Leaving the + * column UNKNOWN forced every consumer to re-derive by joining expression to + * scope to binding, reconstructing something already computed. + * + * Order matters, and follows how specific each answer is: + * + * - a builtin, an import and a parameter each say more than "local"; + * - `nonlocal x` is checked before free, because a declared nonlocal is also + * free and the declaration is the stronger statement; + * - global is checked before local because at MODULE scope symtable reports + * both, and a module-level name is a global. Inside a function the two are + * mutually exclusive, so the order only decides the module case. + * + * A walrus target is reported only at the STORE. `(n := f())` binds `n`, but + * a later read of `n` is an ordinary local read, and calling every occurrence + * a target would describe the binding where the reference was asked about. + * + * The linker still overrides this with TYPE or METHOD when the name resolves + * to an entity, because it guards on the HASH being empty rather than on the + * kind. So a class used by name ends up TYPE, not GLOBAL_VARIABLE. + */ + private referencedEntityKindOf( + name: string, + kind: PythonExpressionKind, + scopeHash: string, + nameContext: PythonNameContext + ): PythonReferencedEntityKind { + if (kind === PythonExpressionKind.SELF_REFERENCE) { + return PythonReferencedEntityKind.SELF; + } + if (kind === PythonExpressionKind.CLS_REFERENCE) { + return PythonReferencedEntityKind.CLS; + } + if (name === 'super') { + return PythonReferencedEntityKind.SUPER; + } + + const binding = this.input.bindingByScopeAndName.get(`${scopeHash}::${name}`); + if (!binding) { + return PythonReferencedEntityKind.UNKNOWN; + } + if (binding.getBindingKind() === PythonBindingKind.BUILTIN) { + return PythonReferencedEntityKind.BUILTIN; + } + if (binding.getIsImported()) { + return PythonReferencedEntityKind.IMPORT; + } + if (binding.getIsParameter()) { + return PythonReferencedEntityKind.PARAMETER; + } + + const origin = binding.getBindingOrigin(); + if (origin === PythonBindingOrigin.WALRUS && nameContext !== PythonNameContext.LOAD) { + return PythonReferencedEntityKind.WALRUS_TARGET; + } + if (origin === PythonBindingOrigin.EXCEPT_TARGET) { + return PythonReferencedEntityKind.EXCEPT_VARIABLE; + } + if (origin === PythonBindingOrigin.COMPREHENSION_TARGET) { + return PythonReferencedEntityKind.COMPREHENSION_VARIABLE; + } + + if (binding.getIsNonlocal()) { + return PythonReferencedEntityKind.NONLOCAL_VARIABLE; + } + if (binding.getIsFree()) { + return PythonReferencedEntityKind.FREE_VARIABLE; + } + if (binding.getIsGlobal()) { + return PythonReferencedEntityKind.GLOBAL_VARIABLE; + } + if (binding.getIsLocal()) { + return PythonReferencedEntityKind.LOCAL_VARIABLE; + } + return PythonReferencedEntityKind.UNKNOWN; + } + + /** + * The callee's simple name, or `''` when the callee has no name. + * + * A call whose callee is itself a call or a subscript has **no callee name**, + * and saying otherwise is actively misleading: + * + * ```python + * functools.wraps(f)(g) # calls the RESULT of wraps(f), not wraps + * handlers[key](event) # calls whatever the dict holds + * ``` + * + * Returning `wraps` for the first would let a rule conclude that `wraps` is + * the target, when the target is its return value. `''` plus + * `callKind=CHAINED_CALL`/`SUBSCRIPT_CALL` is the honest description, and it + * is what CPython's own `ast` view reports. + */ + private calleeNameOf(fn: Parser.SyntaxNode): string { + switch (fn.type) { + case 'identifier': { + return fn.text; + } + case 'attribute': { + return fn.childForFieldName('attribute')?.text ?? ''; + } + default: { + return ''; + } + } + } + + /** The full written path of an attribute chain, or `''` if not name-shaped. */ + private dottedPathOf(node: Parser.SyntaxNode): string { + if (node.type === 'identifier') { + return node.text; + } + if (node.type === 'attribute') { + const object = node.childForFieldName('object'); + const attribute = node.childForFieldName('attribute')?.text ?? ''; + if (!object) { + return attribute; + } + const prefix = this.dottedPathOf(object); + return prefix === '' ? attribute : `${prefix}.${attribute}`; + } + return ''; + } +} diff --git a/parser/src/parsers/python/extractors/python-fact-extractor.ts b/parser/src/parsers/python/extractors/python-fact-extractor.ts new file mode 100644 index 000000000..87beb652b --- /dev/null +++ b/parser/src/parsers/python/extractors/python-fact-extractor.ts @@ -0,0 +1,795 @@ +import * as path from 'path'; + +import { + PyBindingRegistry, + PyBlockRegistry, + PyCallSiteRegistry, + PyCommentRegistry, + PyDecoratorArgumentRegistry, + PyDecoratorRegistry, + PyFieldPositionRegistry, + PyFieldRegistry, + PyExpressionRegistry, + PyImportRegistry, + PyMethodParameterRegistry, + PyMethodRegistry, + PyModuleRegistry, + PyParseGapRegistry, + PyScopeRegistry, + PyTypeBaseRegistry, + PyTypeParameterRegistry, + PyTypeReferenceRegistry, + PyTypeRegistry, +} from '@/analysis-types/python'; +import { PythonBindingTargetKind } from '@/enums/python/bindings'; +import { PythonDialect } from '@/enums/python/modules'; +import { PythonScopeOwnerKind } from '@/enums/python/scopes'; +import { PythonTypeRefOwnerKind } from '@/enums/python/type-references'; +import { SkippedFileReason } from '@/enums/SkippedFileReason'; +import { PythonDeclarationExtractor } from '@/parsers/python/extractors/python-declaration-extractor'; +import { PythonExpressionExtractor } from '@/parsers/python/extractors/python-expression-extractor'; +import { PythonBlockExtractor } from '@/parsers/python/extractors/python-block-extractor'; +import { PythonCommentExtractor } from '@/parsers/python/extractors/python-comment-extractor'; +import { PythonParseGapExtractor } from '@/parsers/python/extractors/python-parse-gap-extractor'; +import { PythonTypeParameterExtractor } from '@/parsers/python/extractors/python-type-parameter-extractor'; +import { PythonDecoratorExtractor } from '@/parsers/python/extractors/python-decorator-extractor'; +import { PythonFieldExtractor } from '@/parsers/python/extractors/python-field-extractor'; +import { PythonResolutionLinker } from '@/parsers/python/extractors/python-resolution-linker'; +import { PythonTypeReferenceExtractor } from '@/parsers/python/extractors/python-type-reference-extractor'; +import { + PythonExtractionInput, + PythonModuleExtraction, + PythonScopeExtractor, +} from '@/parsers/python/extractors/python-scope-extractor'; +import { Python2Finding } from '@/parsers/python/types'; + +/** Every spine relation the parser emits for one file. */ +export interface PythonFactSet { + /** `undefined` when the file was rejected. */ + module?: PyModuleRegistry; + scopes: PyScopeRegistry[]; + bindings: PyBindingRegistry[]; + types: PyTypeRegistry[]; + typeBases: PyTypeBaseRegistry[]; + methods: PyMethodRegistry[]; + methodParameters: PyMethodParameterRegistry[]; + imports: PyImportRegistry[]; + expressions: PyExpressionRegistry[]; + callSites: PyCallSiteRegistry[]; + /** + * Class and instance attributes. + * + * Un-deferred because it is not optional for a call graph: `self.x.m()` is + * unresolvable without the type of `x`, and this is the only relation that + * carries it. It was 0/2,537 resolved before this existed. + */ + fields: PyFieldRegistry[]; + /** Class-body declaration order — a generated `__init__` honours it. */ + fieldPositions: PyFieldPositionRegistry[]; + /** + * Decorator applications, and their arguments. + * + * A decorator is a call that runs at definition time and rebinds the decorated + * name, so `applicationOrder` (bottom-up, the order that runs) and + * `replacesTarget` are what a consumer actually needs — not just the name. + */ + decorators: PyDecoratorRegistry[]; + decoratorArguments: PyDecoratorArgumentRegistry[]; + /** + * Control-flow blocks. Containment is by SPAN, as in Java; the one FK runs the + * other way, from a block to its condition expression, because that is what + * `isinstance` narrowing needs. + */ + blocks: PyBlockRegistry[]; + /** + * Regions the grammar could not represent. + * + * The one relation whose ABSENCE is invisible: a region the parser gave up on + * produces silence, and silence looks identical to "there was nothing there". + * Zero rows is the expected state for ~99.6% of modules. + */ + parseGaps: PyParseGapRegistry[]; + /** + * Comments and docstrings. Most are DIRECTIVES rather than prose — an encoding + * cookie, a `# type:` annotation, a `# noqa` — and a docstring appears here as + * well as in `py_expression`, which §2.17 makes intentional. + */ + comments: PyCommentRegistry[]; + /** + * PEP 695 type parameters (3.12 syntax only). + * + * A pre-3.12 `TypeVar` is a runtime ASSIGNMENT, not a declaration, and lands + * in `py_binding` with `targetEntityKind=TYPE_VAR` instead — a different fact, + * modelled differently. + */ + typeParameters: PyTypeParameterRegistry[]; + /** + * `(pyTypeLinkHash, attributeName)` -> `py_field` PK, and `py_method` PK -> + * receiver name. Both are indexes the cross-module pass needs to redo the + * attribute join it cannot recompute from CSV rows alone. + */ + fieldHashByTypeAndName: Map; + receiverNameByMethodHash: Map; + /** Assignment target byte range -> value byte range; the exact pairing. */ + assignedValueByTargetRange: Map; + expressionByByteRange: Map; + /** + * Type references — the nested tree that links `Dict[TypeA, TypeB]` to all + * three types with parent/position/depth. + */ + typeReferences: PyTypeReferenceRegistry[]; + + dialect: PythonDialect; + /** Present only for a rejected file. */ + skippedReason?: SkippedFileReason; + /** Python 2 constructs found, for `py_parse_gap` and the skipped-files CSV. */ + python2Findings: Python2Finding[]; +} + +/** + * Runs both spine stages over one Python file and returns every relation. + * + * This is the entry point a caller should use. It exists so the two stages + * share **one parse and one symbol table**: the declaration stage needs the + * scope PKs and binding PKs that the scope stage mints, and re-parsing to get + * them would both cost double on files over the 32,767-character limit and risk + * the two stages disagreeing about the tree they are describing. + * + * ## Rejection is total + * + * If the file is Python 2, **nothing** is emitted — no module row, no scopes, no + * declarations — and `skippedReason` is set instead. A Python 2 file parses + * cleanly, so there is no error for a downstream stage to notice; emitting a + * partial fact set would be a confident wrong answer. + */ +export class PythonFactExtractor { + private scopeExtractor: PythonScopeExtractor; + private declarationExtractor: PythonDeclarationExtractor; + private expressionExtractor: PythonExpressionExtractor; + private resolutionLinker: PythonResolutionLinker; + private typeReferenceExtractor: PythonTypeReferenceExtractor; + private fieldExtractor: PythonFieldExtractor; + private decoratorExtractor: PythonDecoratorExtractor; + private blockExtractor: PythonBlockExtractor; + private parseGapExtractor: PythonParseGapExtractor; + private commentExtractor: PythonCommentExtractor; + private typeParameterExtractor: PythonTypeParameterExtractor; + /** The parameter rows of the file being processed, for default-value linking. */ + private lastParameters: PyMethodParameterRegistry[] = []; + + constructor( + scopeExtractor?: PythonScopeExtractor, + declarationExtractor?: PythonDeclarationExtractor, + expressionExtractor?: PythonExpressionExtractor, + resolutionLinker?: PythonResolutionLinker, + typeReferenceExtractor?: PythonTypeReferenceExtractor + ) { + this.scopeExtractor = scopeExtractor ?? new PythonScopeExtractor(); + this.declarationExtractor = declarationExtractor ?? new PythonDeclarationExtractor(); + this.expressionExtractor = expressionExtractor ?? new PythonExpressionExtractor(); + this.resolutionLinker = resolutionLinker ?? new PythonResolutionLinker(); + this.typeReferenceExtractor = + typeReferenceExtractor ?? new PythonTypeReferenceExtractor(); + this.fieldExtractor = new PythonFieldExtractor(); + this.decoratorExtractor = new PythonDecoratorExtractor(); + this.blockExtractor = new PythonBlockExtractor(); + this.parseGapExtractor = new PythonParseGapExtractor(); + this.commentExtractor = new PythonCommentExtractor(); + this.typeParameterExtractor = new PythonTypeParameterExtractor(); + } + + extract(input: PythonExtractionInput): PythonFactSet { + const scopeStage = this.scopeExtractor.extract(input); + + if (scopeStage.dialect !== PythonDialect.PY3 || !scopeStage.module || !scopeStage.rootNode) { + return { + scopes: [], + bindings: [], + types: [], + typeBases: [], + methods: [], + methodParameters: [], + imports: [], + expressions: [], + callSites: [], + fields: [], + fieldPositions: [], + decorators: [], + decoratorArguments: [], + blocks: [], + comments: [], + typeParameters: [], + // A REJECTED module still gets its gaps. This is the case the relation + // exists for: nothing else is emitted, so without these rows the file is + // indistinguishable from one that simply had no facts in it. + parseGaps: + scopeStage.module && scopeStage.rootNode + ? this.parseGapExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + serviceVersionLinkHash: input.serviceVersionLinkHash, + positions: scopeStage.positions, + python2Findings: scopeStage.python2Findings, + }) + : [], + fieldHashByTypeAndName: new Map(), + receiverNameByMethodHash: new Map(), + assignedValueByTargetRange: new Map(), + expressionByByteRange: new Map(), + typeReferences: [], + dialect: scopeStage.dialect, + skippedReason: SkippedFileReason.PY2_CONSTRUCT_DETECTED, + python2Findings: scopeStage.python2Findings, + }; + } + + const declarations = this.declarationExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + filePath: input.filePath, + baseMservPath: input.baseMservPath, + fileName: path.basename(input.filePath), + serviceVersionLinkHash: input.serviceVersionLinkHash, + scopeHashByNodeId: scopeStage.scopeHashByNodeId, + bindingHashByScopeAndName: scopeStage.bindingHashByScopeAndName, + qualifiedNameByNodeId: scopeStage.qualifiedNameByNodeId, + positions: scopeStage.positions, + }); + + this.lastParameters = declarations.methodParameters; + + const expressionStage = this.expressionExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + serviceVersionLinkHash: input.serviceVersionLinkHash, + scopeHashByNodeId: scopeStage.scopeHashByNodeId, + bindingHashByScopeAndName: scopeStage.bindingHashByScopeAndName, + bindingByScopeAndName: scopeStage.bindingByScopeAndName, + methodHashByNodeId: declarations.methodHashByNodeId, + typeHashByNodeId: declarations.typeHashByNodeId, + moduleMethodHash: declarations.moduleMethodHash, + classInitHashByNodeId: declarations.classInitHashByNodeId, + positions: scopeStage.positions, + lambdaMethodByNodeId: declarations.lambdaMethodByNodeId, + parameterHashByAnnotationRange: declarations.parameterHashByAnnotationRange, + }); + + const fieldStage = this.fieldExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + filePath: input.filePath, + serviceVersionLinkHash: input.serviceVersionLinkHash, + types: declarations.types, + methods: declarations.methods, + typeHashByNodeId: declarations.typeHashByNodeId, + methodHashByNodeId: declarations.methodHashByNodeId, + scopeHashByNodeId: scopeStage.scopeHashByNodeId, + bindingHashByScopeAndName: scopeStage.bindingHashByScopeAndName, + positions: scopeStage.positions, + }); + + const decoratorStage = this.decoratorExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + serviceVersionLinkHash: input.serviceVersionLinkHash, + typeHashByNodeId: declarations.typeHashByNodeId, + methodHashByNodeId: declarations.methodHashByNodeId, + }); + + const blockStage = this.blockExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + filePath: input.filePath, + serviceVersionLinkHash: input.serviceVersionLinkHash, + types: declarations.types, + methods: declarations.methods, + typeHashByNodeId: declarations.typeHashByNodeId, + methodHashByNodeId: declarations.methodHashByNodeId, + classInitHashByNodeId: declarations.classInitHashByNodeId, + scopeHashByNodeId: scopeStage.scopeHashByNodeId, + moduleMethodHash: declarations.moduleMethodHash, + positions: scopeStage.positions, + }); + + // ---- back-patching -------------------------------------------------- + // Three FKs cannot be set when their row is minted, because the entity they + // point at does not exist yet. Accumulate-then-export makes patching free: + // nothing has been written, and none of these columns is part of a primary + // key, so no hash changes. + // Intra-module resolution runs last, once every entity it can point at + // exists. Cross-module resolution is the project pass's job: it needs the + // module graph, which a single-file extraction does not have. + // Positions that live inside EXPRESSIONS -- `isinstance(x, Foo)`, + // `raise ValueError(...)` -- are collected here rather than in the + // declaration walk, because they are owned by py_expression rows and those + // do not exist until the expression stage has run. + const scopeByExpressionHash = new Map(); + const typeByExpressionHash = new Map(); + const bindingByExpressionHash = new Map(); + for (const expression of expressionStage.expressions) { + scopeByExpressionHash.set(expression.getHash(), expression.getPyScopeLinkHash()); + typeByExpressionHash.set(expression.getHash(), expression.getPyTypeLinkHash()); + bindingByExpressionHash.set(expression.getHash(), expression.getBindingLinkHash()); + } + const narrowingPositions = this.typeReferenceExtractor.collectNarrowingPositions({ + rootNode: scopeStage.rootNode, + expressionByByteRange: expressionStage.expressionByByteRange, + scopeByExpressionHash, + typeByExpressionHash, + bindingByExpressionHash, + }); + + const typeReferences = this.typeReferenceExtractor.extract({ + rootNode: scopeStage.rootNode, + positions: [ + ...declarations.typePositions, + ...fieldStage.fieldTypePositions, + ...narrowingPositions, + ], + bindingHashByScopeAndName: scopeStage.bindingHashByScopeAndName, + moduleScopeHash: scopeStage.module.getModuleScopeLinkHash(), + pyModuleLinkHash: scopeStage.module.getHash(), + serviceVersionLinkHash: input.serviceVersionLinkHash, + }); + + this.linkTypeBasesToTheirReferences(declarations.typeBases, typeReferences); + // py_type_reference.pyExpressionLinkHash was declared and never filled, so + // every row carried "". The type stage and the expression stage mint rows + // independently, so the join is on byte range, as it is for blocks and + // decorators. + // + // py_type_base is deliberately NOT given the same link. It already carries + // pyTypeReferenceLinkHash on every row, so a base reaches its expression + // through its reference. A second, direct edge to the same node would be a + // redundant path that can disagree with the first one. + for (const reference of typeReferences) { + const range = this.typeReferenceExtractor.byteRangeByReference.get(reference.getHash()); + const expressionHash = range + ? expressionStage.expressionByByteRange.get(range) + : undefined; + if (expressionHash) { + reference.setPyExpressionLinkHash(expressionHash); + } + } + // Before resolution, not after: resolving a decorator needs its expression's + // SCOPE, and the scope is only reachable through this FK. Linking afterwards + // left every decorator unresolved while looking correct in isolation. + this.linkDecoratorsToTheirExpressions(decoratorStage, expressionStage); + // Joined on BYTE RANGE, like every other cross-stage link here: the block + // stage and the expression stage mint rows independently. + for (const block of blockStage.blocks) { + const range = blockStage.conditionRangeByBlock.get(block.getHash()); + const expressionHash = range + ? expressionStage.expressionByByteRange.get(range) + : undefined; + if (expressionHash) { + block.setConditionExpressionLinkHash(expressionHash); + } + } + + this.resolutionLinker.link({ + scopes: scopeStage.scopes, + bindings: scopeStage.bindings, + types: declarations.types, + typeBases: declarations.typeBases, + methods: declarations.methods, + methodParameters: declarations.methodParameters, + imports: declarations.imports, + callSites: expressionStage.callSites, + expressions: expressionStage.expressions, + typeReferences, + fields: fieldStage.fields, + decorators: decoratorStage.decorators, + decoratorArguments: decoratorStage.decoratorArguments, + fieldHashByTypeAndName: fieldStage.fieldHashByTypeAndName, + receiverNameByMethodHash: fieldStage.receiverNameByMethodHash, + assignedValueByTargetRange: expressionStage.assignedValueByTargetRange, + expressionByByteRange: expressionStage.expressionByByteRange, + byteRangeByExpression: new Map( + [...expressionStage.expressionByByteRange].map(([range, hash]) => [hash, range]) + ), + }); + + this.linkBindingTargets(scopeStage, declarations, fieldStage); + this.linkFieldsToTheirWriteExpressions(fieldStage, expressionStage); + this.linkScopeOwners(scopeStage, declarations); + this.linkBindingMethods(scopeStage, declarations); + this.linkParameterDefaults(declarations, expressionStage); + + return { + module: scopeStage.module, + scopes: scopeStage.scopes, + bindings: scopeStage.bindings, + types: declarations.types, + typeBases: declarations.typeBases, + methods: declarations.methods, + methodParameters: declarations.methodParameters, + imports: declarations.imports, + expressions: expressionStage.expressions, + callSites: expressionStage.callSites, + fields: fieldStage.fields, + fieldPositions: fieldStage.fieldPositions, + decorators: decoratorStage.decorators, + decoratorArguments: decoratorStage.decoratorArguments, + typeParameters: this.typeParameterExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + filePath: input.filePath, + serviceVersionLinkHash: input.serviceVersionLinkHash, + types: declarations.types, + methods: declarations.methods, + typeHashByNodeId: declarations.typeHashByNodeId, + methodHashByNodeId: declarations.methodHashByNodeId, + scopeHashByNodeId: scopeStage.scopeHashByNodeId, + }), + blocks: blockStage.blocks, + comments: this.commentExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + filePath: input.filePath, + serviceVersionLinkHash: input.serviceVersionLinkHash, + types: declarations.types, + methods: declarations.methods, + typeHashByNodeId: declarations.typeHashByNodeId, + methodHashByNodeId: declarations.methodHashByNodeId, + moduleMethodHash: declarations.moduleMethodHash, + positions: scopeStage.positions, + }), + parseGaps: this.parseGapExtractor.extract({ + module: scopeStage.module, + rootNode: scopeStage.rootNode, + serviceVersionLinkHash: input.serviceVersionLinkHash, + positions: scopeStage.positions, + python2Findings: scopeStage.python2Findings, + }), + fieldHashByTypeAndName: fieldStage.fieldHashByTypeAndName, + receiverNameByMethodHash: fieldStage.receiverNameByMethodHash, + assignedValueByTargetRange: expressionStage.assignedValueByTargetRange, + expressionByByteRange: expressionStage.expressionByByteRange, + typeReferences, + dialect: scopeStage.dialect, + python2Findings: [], + }; + } + + /** + * Links each `py_type_base` row to its twin `py_type_reference`. + * + * The two rows are created by different stages, so the FK can only be set + * afterwards. Both directions matter: the type-reference row already carries + * `typeReferenceOwnerHash` pointing AT the base, but §2.5 c9 is the reverse + * link, and `type-hierarchy.dl` traverses base → type_reference — so a rule + * ported from Java finds nothing without it. + * + * Only the DEPTH-0 reference is the twin. A nested argument such as the `T` in + * `class Box(Generic[T])` is owned by the same base row but is not the base's + * own reference. + */ + private linkTypeBasesToTheirReferences( + typeBases: PyTypeBaseRegistry[], + typeReferences: PyTypeReferenceRegistry[] + ): void { + const rootByOwner = new Map(); + for (const reference of typeReferences) { + if (reference.getDepth() !== 0) { + continue; + } + if (reference.getReferenceOwnerKind() !== PythonTypeRefOwnerKind.TYPE_BASE) { + continue; + } + rootByOwner.set(reference.getTypeReferenceOwnerHash(), reference); + } + for (const base of typeBases) { + const twin = rootByOwner.get(base.getHash()); + if (twin) { + base.setPyTypeReferenceLinkHash(twin.getHash()); + } + } + } + + /** + * Sets `py_field.pyExpressionLinkHash` — the first write's target node. + * + * Joined on the target's BYTE RANGE, since the field stage and the expression + * stage mint their rows independently. A byte range identifies a node + * uniquely where a start offset does not. + */ + private linkFieldsToTheirWriteExpressions( + fieldStage: { fields: PyFieldRegistry[]; targetByteRangeByField: Map }, + expressionStage: { expressionByByteRange: Map } + ): void { + for (const field of fieldStage.fields) { + const range = fieldStage.targetByteRangeByField.get(field.getHash()); + if (!range) { + continue; + } + const expressionHash = expressionStage.expressionByByteRange.get(range); + if (expressionHash) { + field.setPyExpressionLinkHash(expressionHash); + } + } + } + + /** + * Sets `py_decorator.pyExpressionLinkHash` and the same on its arguments. + * + * Joined on BYTE RANGE, since the decorator stage and the expression stage + * mint their rows independently. Without it a consumer can read a decorator's + * text but cannot reach the expression tree underneath it — so + * `@app.route(PREFIX + "/admin")` would be an opaque string rather than a + * concatenation whose operands are already linked to their bindings. + */ + private linkDecoratorsToTheirExpressions( + decoratorStage: { + decorators: PyDecoratorRegistry[]; + decoratorArguments: PyDecoratorArgumentRegistry[]; + expressionRangeByDecorator: Map; + expressionRangeByArgument: Map; + }, + expressionStage: { expressionByByteRange: Map } + ): void { + for (const decorator of decoratorStage.decorators) { + const range = decoratorStage.expressionRangeByDecorator.get(decorator.getHash()); + const expressionHash = range ? expressionStage.expressionByByteRange.get(range) : undefined; + if (expressionHash) { + decorator.setPyExpressionLinkHash(expressionHash); + } + } + for (const argument of decoratorStage.decoratorArguments) { + const range = decoratorStage.expressionRangeByArgument.get(argument.getHash()); + const expressionHash = range ? expressionStage.expressionByByteRange.get(range) : undefined; + if (expressionHash) { + argument.setPyExpressionLinkHash(expressionHash); + } + } + } + + /** + * Sets `py_binding.targetEntityHash` — the entity a binding actually declares. + * + * `targetEntityKind` was being written on every row while `targetEntityHash` + * stayed empty, which is the same failure as the type_base twin FK and just as + * invisible: a discriminator saying `METHOD` with nothing to dereference reads + * as healthy to an orphan check, because an empty FK is skipped. A consumer + * asking "which def does this name bind?" had to fall back to matching names, + * which is exactly what the hash exists to avoid — two `def handler` in one + * module are different entities with the same name. + * + * The link is built from the REVERSE direction, which already existed: + * declarations record `declaringBindingLinkHash`, so this inverts that rather + * than re-deriving the association and risking a different answer. + */ + private linkBindingTargets( + scopeStage: PythonModuleExtraction, + declarations: ReturnType, + fieldStage: { fields: PyFieldRegistry[] } + ): void { + const targetByBinding = new Map(); + for (const type of declarations.types) { + const binding = type.getDeclaringBindingLinkHash(); + if (binding !== '') { + targetByBinding.set(binding, { kind: PythonBindingTargetKind.TYPE, hash: type.getHash() }); + } + } + for (const method of declarations.methods) { + const binding = method.getDeclaringBindingLinkHash(); + if (binding !== '') { + targetByBinding.set(binding, { + kind: PythonBindingTargetKind.METHOD, + hash: method.getHash(), + }); + } + } + for (const record of declarations.imports) { + const binding = record.getBindingLinkHash(); + if (binding !== '') { + targetByBinding.set(binding, { + kind: PythonBindingTargetKind.IMPORT, + hash: record.getHash(), + }); + } + } + // A parameter binds too, and its entity is the parameter row rather than the + // method — `local-flow.dl` needs the parameter to attribute an argument. + for (const parameter of declarations.methodParameters) { + const binding = parameter.getBindingLinkHash(); + if (binding !== '' && !targetByBinding.has(binding)) { + targetByBinding.set(binding, { + kind: PythonBindingTargetKind.PARAMETER, + hash: parameter.getHash(), + }); + } + } + void fieldStage; + + for (const binding of scopeStage.bindings) { + const target = targetByBinding.get(binding.getHash()); + if (target) { + binding.setTargetEntity(target.kind, target.hash); + } + } + + // A FREE variable closes over a binding in an ENCLOSING scope, and until now + // it resolved to nothing — `targetEntityKind=NONE`, empty hash. That broke + // the one chain a decorator exists to make traceable: + // + // @audit on Impl.run -> audit -> returns wrapper -> wrapper calls fn + // + // `fn` inside `wrapper` IS `audit`'s parameter, and without this link the + // trace dead-ends exactly where it becomes interesting. CPython's symtable + // states the relationship outright — a free variable resolves to a cell in + // an enclosing scope — so this is reading a fact rather than inferring one. + const parentScopeOf = new Map(); + for (const scope of scopeStage.scopes) { + parentScopeOf.set(scope.getHash(), scope.getParentScopeLinkHash()); + } + const bindingByScopeAndName = new Map(); + for (const binding of scopeStage.bindings) { + bindingByScopeAndName.set( + `${binding.getPyScopeLinkHash()}::${binding.getName()}`, + binding + ); + } + for (const binding of scopeStage.bindings) { + if (!binding.isFreeVariable() || binding.getTargetEntityHash() !== '') { + continue; + } + let scope: string | undefined = parentScopeOf.get(binding.getPyScopeLinkHash()); + let guard = 0; + while (scope !== undefined && scope !== '' && guard < 200) { + guard += 1; + const enclosing = bindingByScopeAndName.get(`${scope}::${binding.getName()}`); + // Only a binding that actually BINDS terminates the walk; a scope that + // merely mentions the name is not where the cell lives. + if (enclosing?.isBound() && enclosing.getTargetEntityHash() !== '') { + binding.setTargetEntity( + enclosing.getTargetEntityKind(), + enclosing.getTargetEntityHash() + ); + break; + } + scope = parentScopeOf.get(scope); + } + } + } + + /** + * Sets `py_scope.ownerHash`, the polymorphic owner FK. + * + * Without this the discriminator lies: `ownerKind` would say `TYPE` while + * `ownerHash` was empty, so invariant #1 could not resolve the FK in the + * relation the discriminator names. + */ + private linkScopeOwners( + scopeStage: PythonModuleExtraction, + declarations: { + scopeOwnerByNodeId: Map; + enclosingMethodByScopeNodeId: Map; + } + ): void { + const ownerByScopeHash = new Map(); + for (const [nodeId, ownerHash] of declarations.scopeOwnerByNodeId) { + const scopeHash = scopeStage.scopeHashByNodeId.get(nodeId); + if (scopeHash) { + ownerByScopeHash.set(scopeHash, ownerHash); + } + } + for (const scope of scopeStage.scopes) { + const ownerHash = ownerByScopeHash.get(scope.getHash()); + if (ownerHash) { + scope.setOwner(scope.getOwnerKind(), ownerHash); + continue; + } + // A comprehension scope has no declaration of its own, so it inherits the + // nearest enclosing METHOD. Resolving against the owner map instead would + // hand a module-level comprehension the py_module hash while its + // discriminator says COMPREHENSION — present, non-dangling, and pointing + // into the wrong relation. A LAMBDA scope does not come here at all: it has + // its own py_method, registered above. + if (scope.getOwnerKind() === PythonScopeOwnerKind.COMPREHENSION) { + const enclosing = this.enclosingMethodFor(scope, scopeStage, declarations); + if (enclosing) { + scope.setOwner(scope.getOwnerKind(), enclosing); + } + } + } + } + + /** + * Sets `py_binding.pyMethodLinkHash` — the enclosing function. + * + * This is the `java_local_variable` column-13 analogue, which is what lets + * `local-flow.dl` port to Python by relation rename alone. Leaving it empty + * would silently break that port. + */ + private linkBindingMethods( + scopeStage: PythonModuleExtraction, + declarations: { enclosingMethodByScopeNodeId: Map } + ): void { + const methodByScopeHash = new Map(); + for (const [nodeId, methodHash] of declarations.enclosingMethodByScopeNodeId) { + const scopeHash = scopeStage.scopeHashByNodeId.get(nodeId); + if (scopeHash) { + methodByScopeHash.set(scopeHash, methodHash); + } + } + // A comprehension or lambda scope has no method row of its own, so its + // bindings belong to the nearest enclosing scope that does. + const parentByScopeHash = new Map(); + for (const scope of scopeStage.scopes) { + parentByScopeHash.set(scope.getHash(), scope.getParentScopeLinkHash()); + } + for (const binding of scopeStage.bindings) { + let scopeHash: string | undefined = binding.getPyScopeLinkHash(); + let methodHash = methodByScopeHash.get(scopeHash); + while (!methodHash && scopeHash) { + scopeHash = parentByScopeHash.get(scopeHash); + methodHash = scopeHash ? methodByScopeHash.get(scopeHash) : undefined; + } + if (methodHash) { + binding.setPyMethodLinkHash(methodHash); + } + } + } + + /** + * Sets `py_method_parameter.pyExpressionLinkHash` — the default-value root. + * + * The two stages mint their rows independently, so they are joined on the + * default expression's BYTE RANGE. A byte range identifies a node uniquely, + * where a start offset alone does not. + */ + private linkParameterDefaults( + declarations: { parameterDefaultByteRange: Map }, + expressionStage: { expressionByByteRange: Map } + ): void { + if (declarations.parameterDefaultByteRange.size === 0) { + return; + } + for (const parameter of this.lastParameters) { + const range = declarations.parameterDefaultByteRange.get(parameter.getHash()); + if (!range) { + continue; + } + const expressionHash = expressionStage.expressionByByteRange.get(range); + if (expressionHash) { + parameter.setPyExpressionLinkHash(expressionHash); + } + } + } + + /** + * Walks up the scope chain to the nearest enclosing `py_method`. + * + * Keyed on the enclosing-METHOD map rather than the owner map, because the + * owner of a module scope is the module itself. A comprehension needs a + * method, and the synthetic `` / `` initializers exist so + * that one always exists. + */ + private enclosingMethodFor( + scope: { getParentScopeLinkHash(): string }, + scopeStage: PythonModuleExtraction, + declarations: { enclosingMethodByScopeNodeId: Map } + ): string | undefined { + const ownerByScopeHash = new Map(); + for (const [nodeId, ownerHash] of declarations.enclosingMethodByScopeNodeId) { + const scopeHash = scopeStage.scopeHashByNodeId.get(nodeId); + if (scopeHash) { + ownerByScopeHash.set(scopeHash, ownerHash); + } + } + const parentByScopeHash = new Map(); + for (const s of scopeStage.scopes) { + parentByScopeHash.set(s.getHash(), s.getParentScopeLinkHash()); + } + let current: string | undefined = scope.getParentScopeLinkHash(); + while (current) { + const owner = ownerByScopeHash.get(current); + if (owner) { + return owner; + } + current = parentByScopeHash.get(current); + } + return undefined; + } +} diff --git a/parser/src/parsers/python/extractors/python-field-extractor.ts b/parser/src/parsers/python/extractors/python-field-extractor.ts new file mode 100644 index 000000000..ef4374779 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-field-extractor.ts @@ -0,0 +1,1512 @@ +import Parser from 'tree-sitter'; + +import { PYTHON_BUILTIN_TYPE_METHODS } from '@/constants/python-constants'; + +import { + PyFieldPositionRegistry, + PyFieldRegistry, + PyMethodRegistry, + PyModuleRegistry, + PyTypeRegistry, +} from '@/analysis-types/python'; +import { + PythonFieldModifier, + PythonFieldOrigin, + PythonInitializerKind, +} from '@/enums/python/fields'; +import { PythonMethodAccess } from '@/enums/python/methods'; +import { PythonMethodKind } from '@/enums/python/methods'; +import { + PythonTypeRefContext, + PythonTypeRefOwnerKind, +} from '@/enums/python/type-references'; +import type { TypePositionInput } from '@/parsers/python/extractors/python-type-reference-extractor'; +import { PythonSourcePositions } from '@/utils/python/python-position-utils'; + +/** Everything the field stage produces for one module. */ +export interface PythonFieldExtraction { + fields: PyFieldRegistry[]; + fieldPositions: PyFieldPositionRegistry[]; + /** + * Type references found on field annotations, handed to the type-reference + * stage. Built and returned by the stage; the interface never declared it. + */ + fieldTypePositions: TypePositionInput[]; + /** + * `(pyTypeLinkHash, attributeName)` -> `py_field` PK. + * + * The join key schema §2.10 names when it deleted `py_field_write`: linking a + * write expression to its merged field row is a resolution rule, not a stored + * fact. This is that rule's index. + */ + fieldHashByTypeAndName: Map; + /** `py_field` PK -> its first write target's `startIndex:endIndex`. */ + targetByteRangeByField: Map; + /** + * `py_field` PK -> the row, so a resolver can read the attribute's type + * without re-scanning the list. + */ + fieldByHash: Map; + /** + * `py_method` PK -> that method's receiver parameter name. + * + * Needed to tell `self.conn.send()` from `other.conn.send()`: only the first + * is an attribute of the enclosing class, and the receiver is not always + * called `self`. + */ + receiverNameByMethodHash: Map; +} + +export interface PythonFieldInput { + module: PyModuleRegistry; + rootNode: Parser.SyntaxNode; + filePath: string; + serviceVersionLinkHash: string; + types: PyTypeRegistry[]; + methods: PyMethodRegistry[]; + typeHashByNodeId: Map; + methodHashByNodeId: Map; + scopeHashByNodeId: Map; + bindingHashByScopeAndName: Map; + positions: PythonSourcePositions; +} + +/** One observed write, before writes are merged into a field row. */ +interface WriteObservation { + name: string; + /** + * The builtin type this write produces, if any — `list` for `[]`. + * + * Kept per observation so the merge can notice two writes DISAGREEING, which + * is the difference between an attribute that is a `list` and one that is + * sometimes a `list` and sometimes `None`. + */ + writtenBuiltinType: string; + origin: PythonFieldOrigin; + line: number; + endLine: number; + methodHash: string; + methodName: string; + receiverName: string; + annotationText: string; + annotationIsString: boolean; + /** The annotation NODE, needed to build the py_type_reference tree. */ + annotationNode: Parser.SyntaxNode | null; + initializerText: string; + initializerKind: PythonInitializerKind; + bindingHash: string; + targetByteRange: string; + /** Class-body declaration order, or `-1` for a `self.*` write. */ + declarationPosition: number; +} + +/** What a class contributes, gathered before any row is minted. */ +interface ClassCollection { + typeHash: string; + /** The class body's `py_scope` PK — where a class-body attribute IS a binding. */ + classScopeHash: string; + typeName: string; + qualifiedName: string; + observations: WriteObservation[]; + propertyNames: Set; + slotNames: Set; + isDataclass: boolean; + isNamedTuple: boolean; + isTypedDict: boolean; + isEnum: boolean; +} + +const INIT_METHOD_NAMES = new Set(['__init__', '__new__', '__post_init__']); + +/** + * Recovers class attributes and instance attributes. + * + * Python has no field declarations, so this stage does what a Java parser gets + * for free: it works out which attributes a class has by looking at every place + * one is WRITTEN, and merges those writes into one row per attribute. + * + * ## Why this is a separate stage + * + * The declaration stage walks declarations, and an attribute is not one. There + * is no `field_definition` node to visit — `self.buf = b""` is an assignment + * statement whose target happens to be an attribute of the first parameter, and + * recognising it requires knowing which parameter that is. That test needs the + * `py_method` rows, so this runs after them. + * + * ## The merge, and what it must not destroy + * + * ```python + * class Buffer: + * limit = 4096 # class body + * def __init__(self): + * self.data = bytearray() # first write — establishes it + * def reset(self): + * self.data = bytearray() # second write, second method + * ``` + * + * `data` is ONE attribute with `writeCount=2` and `writtenInMethodCount=2`. + * The second number is the one worth having: `> 1` means the attribute is + * mutable state shared across methods, which is exactly the shape a data-flow + * rule needs to notice. The initialiser columns stay pinned to the first write, + * because that is the write that establishes the attribute; `endLine` grows to + * cover them all. + * + * ## Names are mangled, matching CPython + * + * `self.__x` inside `class C` stores `_C__x` — mangling applies to attribute + * names, not just to locals. This stage stores the mangled name, so `name` + * equals the runtime `__dict__` key and matches what `py_binding` already does. + * The alternative, storing `__x`, would let a reference from a DIFFERENT class + * match this field, which at runtime raises `AttributeError`. + */ +export class PythonFieldExtractor { + private input!: PythonFieldInput; + private methodByNodeId = new Map(); + private receiverNameByMethodHash = new Map(); + private dictAliases = new Set(); + private annotationByField = new Map< + string, + { node: Parser.SyntaxNode; typeHash: string; scopeHash: string } + >(); + private targetByteRangeByField = new Map(); + + extract(input: PythonFieldInput): PythonFieldExtraction { + this.input = input; + this.methodByNodeId = new Map(); + + const methodByHash = new Map(); + for (const method of input.methods) { + methodByHash.set(method.getHash(), method); + } + for (const [nodeId, hash] of input.methodHashByNodeId) { + const method = methodByHash.get(hash); + if (method) { + this.methodByNodeId.set(nodeId, method); + } + } + + const typeByHash = new Map(); + for (const type of input.types) { + typeByHash.set(type.getHash(), type); + } + + this.targetByteRangeByField = new Map(); + const fields: PyFieldRegistry[] = []; + const fieldPositions: PyFieldPositionRegistry[] = []; + const fieldHashByTypeAndName = new Map(); + const fieldByHash = new Map(); + this.receiverNameByMethodHash = new Map(); + + for (const classNode of this.findClassNodes(input.rootNode)) { + const typeHash = input.typeHashByNodeId.get(classNode.id); + if (!typeHash) { + continue; + } + const type = typeByHash.get(typeHash); + if (!type) { + continue; + } + const collection = this.collectClass(classNode, typeHash, type); + this.mintFields(collection, fields, fieldPositions, fieldHashByTypeAndName); + } + + for (const field of fields) { + fieldByHash.set(field.getHash(), field); + } + + const fieldTypePositions: TypePositionInput[] = []; + for (const field of fields) { + const annotation = this.annotationByField.get(field.getHash()); + if (annotation === undefined) { + continue; + } + fieldTypePositions.push({ + node: annotation.node, + context: PythonTypeRefContext.FIELD_TYPE, + ownerHash: field.getHash(), + ownerKind: PythonTypeRefOwnerKind.FIELD, + enclosingTypeHash: annotation.typeHash, + scopeHash: annotation.scopeHash, + }); + } + + return { + fields, + fieldTypePositions, + fieldPositions, + fieldHashByTypeAndName, + fieldByHash, + receiverNameByMethodHash: this.receiverNameByMethodHash, + targetByteRangeByField: this.targetByteRangeByField, + }; + } + + /** Every `class_definition` in the file, including nested ones. */ + private findClassNodes(root: Parser.SyntaxNode): Parser.SyntaxNode[] { + const found: Parser.SyntaxNode[] = []; + const worklist: Parser.SyntaxNode[] = [root]; + while (worklist.length > 0) { + const node = worklist.shift()!; + if (node.type === 'class_definition') { + found.push(node); + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + return found; + } + + // ---- collection ------------------------------------------------------ + + private collectClass( + classNode: Parser.SyntaxNode, + typeHash: string, + type: PyTypeRegistry + ): ClassCollection { + const collection: ClassCollection = { + typeHash, + classScopeHash: this.input.scopeHashByNodeId.get(classNode.id) ?? '', + typeName: type.getName(), + qualifiedName: type.getQualifiedName(), + observations: [], + propertyNames: new Set(), + slotNames: new Set(), + isDataclass: this.hasDataclassDecorator(classNode), + isNamedTuple: this.hasBaseNamed(classNode, ['NamedTuple']), + isTypedDict: this.hasBaseNamed(classNode, ['TypedDict']), + isEnum: this.hasBaseNamed(classNode, ['Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag']), + }; + + const body = classNode.childForFieldName('body'); + if (!body) { + return collection; + } + + this.collectClassBody(body, collection); + this.collectMethodBodies(body, collection); + return collection; + } + + /** + * Walks the class body's own statements. + * + * Only DIRECT statements count as declarations. A statement nested inside a + * method belongs to that method, and one inside a nested class belongs to the + * nested class — both are visited separately, so descending here would attach + * the attribute to the wrong owner. + */ + private collectClassBody(body: Parser.SyntaxNode, collection: ClassCollection): void { + let position = 0; + const statements: Parser.SyntaxNode[] = []; + for (let index = 0; index < body.namedChildCount; index += 1) { + const child = body.namedChild(index); + if (child && !child.isExtra) { + statements.push(child); + } + } + + for (const statement of statements) { + // A class-body assignment can be guarded — `if sys.platform == "win32": + // FLAG = 1` inside a class body is a real class attribute. Descend through + // compound statements, but never into a def or a nested class. + for (const inner of this.classLevelStatements(statement)) { + if (inner.type !== 'expression_statement') { + continue; + } + for (let index = 0; index < inner.namedChildCount; index += 1) { + const assignment = inner.namedChild(index); + if (!assignment || assignment.isExtra) { + continue; + } + const consumed = this.collectClassBodyAssignment(assignment, collection, position); + position += consumed; + } + } + } + } + + /** + * Flattens a class-body statement to the statements that can declare an + * attribute, stopping at any nested scope. + */ + private classLevelStatements(statement: Parser.SyntaxNode): Parser.SyntaxNode[] { + if (statement.type === 'expression_statement') { + return [statement]; + } + const nested = new Set([ + 'function_definition', + 'decorated_definition', + 'class_definition', + ]); + if (nested.has(statement.type)) { + return []; + } + const found: Parser.SyntaxNode[] = []; + const worklist: Parser.SyntaxNode[] = [statement]; + while (worklist.length > 0) { + const node = worklist.shift()!; + if (node.type === 'expression_statement') { + found.push(node); + continue; + } + if (nested.has(node.type)) { + continue; + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + return found; + } + + /** + * Records one class-body assignment. Returns how many declaration positions + * it consumed, since `x = y = 1` declares two attributes. + */ + private collectClassBodyAssignment( + assignment: Parser.SyntaxNode, + collection: ClassCollection, + startPosition: number + ): number { + if (assignment.type !== 'assignment') { + return 0; + } + + const left = assignment.childForFieldName('left'); + const right = assignment.childForFieldName('right'); + const annotation = assignment.childForFieldName('type'); + if (!left) { + return 0; + } + + if (this.isSlotsTarget(left) && right) { + this.collectSlots(right, collection); + return 0; + } + + // `a = b = c = 1` nests rightward: left is `a`, right is the assignment + // `b = c = 1`. Walk the spine so every target in the chain is declared, and + // take the value from the innermost right-hand side, which all of them share. + const targets = this.assignmentTargets(left); + let value = right; + while (value && value.type === 'assignment') { + const chainedLeft = value.childForFieldName('left'); + if (chainedLeft) { + targets.push(...this.assignmentTargets(chainedLeft)); + } + value = value.childForFieldName('right'); + } + + let consumed = 0; + for (const target of targets) { + const rawName = target.text; + if (rawName === '' || rawName === '__slots__') { + continue; + } + const name = this.mangle(collection.typeName, rawName); + const hasValue = value !== null && value !== undefined; + const origin = this.classBodyOrigin(collection, hasValue, annotation !== null); + collection.observations.push({ + name, + origin, + line: target.startPosition.row, + endLine: assignment.endPosition.row, + methodHash: '', + methodName: '', + receiverName: '', + annotationText: annotation ? this.normalizeText(annotation.text) : '', + annotationIsString: annotation ? this.isStringAnnotation(annotation) : false, + annotationNode: annotation, + initializerText: value ? this.normalizeText(value.text) : '', + initializerKind: value ? this.initializerKindOf(value) : PythonInitializerKind.NONE, + writtenBuiltinType: value ? this.builtinTypeOfValue(value) : '', + bindingHash: this.classBodyBinding(collection, name), + targetByteRange: `${target.startIndex}:${target.endIndex}`, + declarationPosition: startPosition + consumed, + }); + consumed += 1; + } + return consumed; + } + + /** `__slots__ = ("a", "b")` declares two attributes, one per string. */ + private collectSlots(value: Parser.SyntaxNode, collection: ClassCollection): void { + const worklist: Parser.SyntaxNode[] = [value]; + while (worklist.length > 0) { + const node = worklist.shift()!; + if (node.type === 'string') { + const literal = this.stringLiteralValue(node); + if (literal !== '') { + collection.slotNames.add(this.mangle(collection.typeName, literal)); + collection.observations.push({ + name: this.mangle(collection.typeName, literal), + origin: PythonFieldOrigin.SLOTS_ENTRY, + line: node.startPosition.row, + endLine: node.endPosition.row, + methodHash: '', + methodName: '', + receiverName: '', + annotationText: '', + annotationIsString: false, + annotationNode: null, + initializerText: '', + initializerKind: PythonInitializerKind.NONE, + writtenBuiltinType: '', + bindingHash: '', + targetByteRange: `${node.startIndex}:${node.endIndex}`, + declarationPosition: -1, + }); + } + continue; + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + } + + /** + * Walks the methods of this class looking for writes through the receiver. + * + * The receiver name is taken from the method's OWN first parameter rather than + * assumed to be `self`: `def f(this, x): this.y = x` is legal and binds an + * attribute, and 2.4% of stdlib methods use a name other than `self`. + */ + private collectMethodBodies(body: Parser.SyntaxNode, collection: ClassCollection): void { + for (let index = 0; index < body.namedChildCount; index += 1) { + const statement = body.namedChild(index); + if (!statement || statement.isExtra) { + continue; + } + const definition = this.unwrapDecorated(statement); + if (!definition || definition.type !== 'function_definition') { + continue; + } + + const method = this.methodByNodeId.get(definition.id); + const methodName = definition.childForFieldName('name')?.text ?? ''; + if (method && this.isProperty(method)) { + collection.propertyNames.add(this.mangle(collection.typeName, methodName)); + } + + const receiverName = this.receiverNameOf(definition, method); + if (receiverName === '') { + continue; + } + if (method) { + this.receiverNameByMethodHash.set(method.getHash(), receiverName); + } + const methodBody = definition.childForFieldName('body'); + if (!methodBody) { + continue; + } + this.collectReceiverWrites( + methodBody, + collection, + method ? method.getHash() : '', + methodName, + receiverName + ); + } + } + + /** + * Finds every write to `.` in one method body. + * + * Descends into nested functions on purpose: a closure inside `__init__` that + * sets `self.done = True` writes the SAME attribute, and attributing it to the + * closure rather than dropping it is the point. It stops at a nested class, + * whose methods have their own receiver. + */ + private collectReceiverWrites( + methodBody: Parser.SyntaxNode, + collection: ClassCollection, + methodHash: string, + methodName: string, + receiverName: string + ): void { + this.dictAliases = this.collectDictAliases(methodBody, receiverName); + + const worklist: Parser.SyntaxNode[] = [methodBody]; + while (worklist.length > 0) { + const node = worklist.shift()!; + + if (node.type === 'class_definition') { + continue; + } + + this.recordWritesIn(node, collection, methodHash, methodName, receiverName); + + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + } + + /** Dispatches one statement to the write shapes that can bind an attribute. */ + private recordWritesIn( + node: Parser.SyntaxNode, + collection: ClassCollection, + methodHash: string, + methodName: string, + receiverName: string + ): void { + switch (node.type) { + case 'assignment': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + const annotation = node.childForFieldName('type'); + if (!left) { + return; + } + const dictName = this.instanceDictKey(left, receiverName); + if (dictName !== '') { + this.pushAttributeWrite( + left, + node, + collection, + methodHash, + methodName, + receiverName, + annotation ?? null, + right ?? null, + PythonFieldOrigin.SELF_ASSIGN, + dictName + ); + return; + } + const targets = this.attributeTargets(left, receiverName); + // `self.a, self.b = pair` — the value is one ELEMENT of the RHS, not the + // whole thing, so claiming the tuple as each attribute's initialiser + // would be wrong. Only a sole target owns the RHS. + const soleTarget = targets.length === 1 && this.attributeTargets(left, receiverName).length === 1; + for (const target of targets) { + const valueNode = soleTarget ? right : null; + this.pushAttributeWrite( + target, + node, + collection, + methodHash, + methodName, + receiverName, + annotation ?? null, + valueNode ?? null, + annotation ? PythonFieldOrigin.SELF_ASSIGN : PythonFieldOrigin.SELF_ASSIGN + ); + } + return; + } + case 'augmented_assignment': { + const left = node.childForFieldName('left'); + if (!left) { + return; + } + for (const target of this.attributeTargets(left, receiverName)) { + this.pushAttributeWrite( + target, + node, + collection, + methodHash, + methodName, + receiverName, + null, + null, + PythonFieldOrigin.SELF_AUGASSIGN + ); + } + return; + } + case 'for_statement': { + const left = node.childForFieldName('left'); + if (!left) { + return; + } + for (const target of this.attributeTargets(left, receiverName)) { + this.pushAttributeWrite( + target, + node, + collection, + methodHash, + methodName, + receiverName, + null, + null, + PythonFieldOrigin.SELF_ASSIGN + ); + } + return; + } + case 'as_pattern': { + // `with open(p) as self.fh:` — the alias sits in an as_pattern whose + // target is an attribute. + const alias = node.namedChild(node.namedChildCount - 1); + if (!alias) { + return; + } + for (const target of this.attributeTargets(alias, receiverName)) { + this.pushAttributeWrite( + target, + node, + collection, + methodHash, + methodName, + receiverName, + null, + null, + PythonFieldOrigin.SELF_ASSIGN + ); + } + return; + } + case 'call': { + this.recordSetattr(node, collection, methodHash, methodName, receiverName); + return; + } + default: { + return; + } + } + } + + /** + * Records `setattr(self, "flag", value)`. + * + * Only the constant-name form is recorded. `setattr(self, name, value)` with a + * computed name creates an attribute whose identity is not statically known, + * and inventing a row named `name` would assert an attribute that does not + * exist. + */ + private recordSetattr( + call: Parser.SyntaxNode, + collection: ClassCollection, + methodHash: string, + methodName: string, + receiverName: string + ): void { + const callee = call.childForFieldName('function'); + if (!callee || callee.text !== 'setattr') { + return; + } + const args = call.childForFieldName('arguments'); + if (!args) { + return; + } + const positional: Parser.SyntaxNode[] = []; + for (let index = 0; index < args.namedChildCount; index += 1) { + const child = args.namedChild(index); + if (child && !child.isExtra) { + positional.push(child); + } + } + if (positional.length < 2) { + return; + } + if (positional[0]!.text !== receiverName) { + return; + } + if (positional[1]!.type !== 'string') { + return; + } + const literal = this.stringLiteralValue(positional[1]!); + if (literal === '') { + return; + } + const value = positional.length > 2 ? positional[2]! : null; + collection.observations.push({ + name: this.mangle(collection.typeName, literal), + origin: PythonFieldOrigin.SETATTR_DYNAMIC, + line: call.startPosition.row, + endLine: call.endPosition.row, + methodHash, + methodName, + receiverName, + annotationText: '', + annotationIsString: false, + annotationNode: null, + initializerText: value ? this.normalizeText(value.text) : '', + initializerKind: value ? this.initializerKindOf(value) : PythonInitializerKind.NONE, + writtenBuiltinType: value ? this.builtinTypeOfValue(value) : '', + bindingHash: '', + targetByteRange: `${positional[1]!.startIndex}:${positional[1]!.endIndex}`, + declarationPosition: -1, + }); + } + + private pushAttributeWrite( + target: Parser.SyntaxNode, + statement: Parser.SyntaxNode, + collection: ClassCollection, + methodHash: string, + methodName: string, + receiverName: string, + annotation: Parser.SyntaxNode | null, + value: Parser.SyntaxNode | null, + origin: PythonFieldOrigin, + nameOverride = '' + ): void { + const attributeName = target.childForFieldName('attribute'); + if (!attributeName && nameOverride === '') { + return; + } + collection.observations.push({ + name: this.mangle(collection.typeName, nameOverride || attributeName!.text), + origin, + line: target.startPosition.row, + endLine: statement.endPosition.row, + methodHash, + methodName, + receiverName, + annotationText: annotation ? this.normalizeText(annotation.text) : '', + annotationIsString: annotation ? this.isStringAnnotation(annotation) : false, + annotationNode: annotation, + initializerText: value ? this.normalizeText(value.text) : '', + initializerKind: value ? this.initializerKindOf(value) : PythonInitializerKind.NONE, + writtenBuiltinType: value ? this.builtinTypeOfValue(value) : '', + // Schema §2.9 c25: no binding exists for `self.x`. CPython's symtable + // records `self` as a local and the attribute name NOWHERE — it lives in + // the instance `__dict__`, resolved at runtime. Empty here is the + // modelling problem, not an omission. + bindingHash: '', + targetByteRange: `${target.startIndex}:${target.endIndex}`, + declarationPosition: -1, + }); + } + + // ---- minting --------------------------------------------------------- + + /** + * Merges the observations for one class into `py_field` rows. + * + * Merge key is `(name, origin)`, matching the PK. Origin is part of it because + * a class attribute and an instance attribute of the same name are genuinely + * two facts: `limit = 4096` in the body and `self.limit = n` in `__init__` + * produce two rows, and the engine needs both to reason about which one a read + * sees. + */ + private mintFields( + collection: ClassCollection, + fields: PyFieldRegistry[], + fieldPositions: PyFieldPositionRegistry[], + fieldHashByTypeAndName: Map + ): void { + const byKey = new Map(); + const writtenTypesByKey = new Map>(); + const writingMethodsByKey = new Map>(); + const positionByKey = new Map(); + const ordered: PyFieldRegistry[] = []; + + for (const observation of collection.observations) { + const key = observation.name + '||' + observation.origin; + const existing = byKey.get(key); + if (existing) { + // Two writes of different builtin types make the attribute's type + // ambiguous, and a resolver must refuse rather than take the first. + if (observation.writtenBuiltinType !== '') { + const seen = writtenTypesByKey.get(key)!; + seen.add(observation.writtenBuiltinType); + if (seen.size > 1) { + existing.markAmbiguous(); + } + } + const writers = writingMethodsByKey.get(key)!; + existing.recordAdditionalWrite(observation.endLine, observation.methodHash, writers); + if (observation.annotationText !== '') { + existing.adoptAnnotation( + observation.annotationText, + this.baseTypeOf(observation.annotationText), + observation.annotationIsString + ); + this.recordFieldAnnotation(existing.getHash(), observation, collection); + } + continue; + } + + const field = this.buildField(collection, observation); + this.recordFieldAnnotation(field.getHash(), observation, collection); + this.targetByteRangeByField.set(field.getHash(), observation.targetByteRange); + byKey.set(key, field); + writtenTypesByKey.set( + key, + observation.writtenBuiltinType === '' + ? new Set() + : new Set([observation.writtenBuiltinType]) + ); + const writers = new Set(); + if (observation.methodHash !== '') { + writers.add(observation.methodHash); + } + writingMethodsByKey.set(key, writers); + if (observation.declarationPosition >= 0) { + positionByKey.set(key, observation.declarationPosition); + } + ordered.push(field); + } + + // Modifiers that depend on the whole class are applied after the merge: + // whether a `@property` of the same name exists, and whether the name is a + // slot, are facts about the class rather than about one write. + for (const [, field] of byKey) { + if (collection.propertyNames.has(field.getName())) { + field.addModifier(PythonFieldModifier.PROPERTY_BACKED); + } + if ( + collection.slotNames.has(field.getName()) && + field.getFieldOrigin() !== PythonFieldOrigin.SLOTS_ENTRY + ) { + field.addModifier(PythonFieldModifier.SLOT); + } + if (field.getWriteCount() === 1 && field.getFieldOrigin() !== PythonFieldOrigin.SELF_AUGASSIGN) { + field.addModifier(PythonFieldModifier.READ_ONLY); + } + + } + + // Positions are assigned over ALL fields, class-body declarations first in + // declaration order and then method-recovered attributes in first-write + // order, so the relation is 1:1 with `py_field` as it is in Java. Restricting + // it to class-body fields dropped exactly the rows a constructor-argument + // rule needs, since Python's constructor-assigned fields are SELF_ASSIGN. + const positioned = [...ordered].sort((left, right) => { + const leftDeclared = positionByKey.get(left.getName() + '||' + left.getFieldOrigin()); + const rightDeclared = positionByKey.get(right.getName() + '||' + right.getFieldOrigin()); + if (leftDeclared !== undefined && rightDeclared !== undefined) { + return leftDeclared - rightDeclared; + } + if (leftDeclared !== undefined) { + return -1; + } + if (rightDeclared !== undefined) { + return 1; + } + return left.getFirstWriteLine() - right.getFirstWriteLine(); + }); + positioned.forEach((field, index) => { + fieldPositions.push( + new PyFieldPositionRegistry(collection.typeHash, field.getHash(), index) + ); + }); + + for (const field of ordered) { + fields.push(field); + const joinKey = collection.typeHash + '||' + field.getName(); + // A class attribute and an instance attribute share a name. The join key + // keeps the INSTANCE one, which is what an attribute read through a + // receiver actually reaches at runtime. + const incumbent = fieldHashByTypeAndName.get(joinKey); + if (!incumbent || this.isInstanceOrigin(field.getFieldOrigin())) { + fieldHashByTypeAndName.set(joinKey, field.getHash()); + } + } + } + + private buildField( + collection: ClassCollection, + observation: WriteObservation + ): PyFieldRegistry { + const modifiers = this.modifiersFor(collection, observation); + const builder = PyFieldRegistry.builder( + observation.name, + observation.origin, + collection.typeHash, + this.input.module.getHash(), + this.input.filePath, + observation.line, + this.input.serviceVersionLinkHash + ) + .withEndLine(observation.endLine) + .withOwner(collection.typeName, collection.qualifiedName) + .withAccess(this.accessOf(observation.name)) + .withModifier(modifiers.join(',')) + .withAnnotation( + observation.annotationText, + this.baseTypeOf(observation.annotationText), + observation.annotationIsString + ) + .withReceiverName(observation.receiverName) + .withInitializer(observation.initializerText, observation.initializerKind) + .withBindingLinkHash(observation.bindingHash) + .withDeclaringMethod(observation.methodHash, INIT_METHOD_NAMES.has(observation.methodName)) + .withWriteCounts(1, observation.methodHash === '' ? 0 : 1); + + if (observation.annotationText !== '') { + builder.withPotentialQualifiedName( + collection.qualifiedName + '.' + observation.name, + false + ); + } + + return builder.build(); + } + + /** + * Works out `fieldModifier` for one attribute. + * + * `CLASS_VAR` versus `INSTANCE_VAR` follows the ORIGIN rather than the + * annotation, with one exception the language forces: `ClassVar[int]` on a + * class-body entry says explicitly that it is not an instance attribute, and + * `dataclass` uses precisely that to decide what to leave out of the generated + * `__init__`. + */ + private modifiersFor(collection: ClassCollection, observation: WriteObservation): string[] { + const modifiers: string[] = []; + const annotation = observation.annotationText; + const isClassBody = + observation.origin === PythonFieldOrigin.CLASS_BODY_ASSIGN || + observation.origin === PythonFieldOrigin.CLASS_BODY_ANNOTATION_ONLY || + observation.origin === PythonFieldOrigin.DATACLASS_FIELD || + observation.origin === PythonFieldOrigin.NAMEDTUPLE_FIELD || + observation.origin === PythonFieldOrigin.TYPEDDICT_KEY || + observation.origin === PythonFieldOrigin.ENUM_MEMBER; + + if (observation.origin === PythonFieldOrigin.SLOTS_ENTRY) { + modifiers.push(PythonFieldModifier.SLOT); + modifiers.push(PythonFieldModifier.INSTANCE_VAR); + } else if (isClassBody) { + modifiers.push(PythonFieldModifier.CLASS_VAR); + } else { + modifiers.push(PythonFieldModifier.INSTANCE_VAR); + } + + if (annotation.startsWith('ClassVar')) { + modifiers.push(PythonFieldModifier.CLASSVAR_ANNOTATED); + } + if (annotation.startsWith('Final')) { + modifiers.push(PythonFieldModifier.FINAL); + } + if ( + observation.initializerKind === PythonInitializerKind.CALL && + observation.initializerText.startsWith('field(') + ) { + modifiers.push(PythonFieldModifier.DATACLASS_FIELD); + } + if (observation.origin === PythonFieldOrigin.DATACLASS_FIELD) { + modifiers.push(PythonFieldModifier.DATACLASS_FIELD); + } + if (observation.origin === PythonFieldOrigin.ENUM_MEMBER) { + modifiers.push(PythonFieldModifier.ENUM_MEMBER); + } + if (collection.isDataclass && isClassBody && !modifiers.includes(PythonFieldModifier.DATACLASS_FIELD)) { + modifiers.push(PythonFieldModifier.DATACLASS_FIELD); + } + return modifiers; + } + + // ---- shape helpers --------------------------------------------------- + + /** + * Which origin a class-body entry gets. + * + * The class's own shape decides: a `@dataclass` body declares dataclass + * fields, a `TypedDict` body declares keys, an `Enum` body declares members. + * These are different facts with different runtime behaviour — an enum member + * becomes an instance of the enum class, and a TypedDict key never exists as + * an attribute at all. + */ + private classBodyOrigin( + collection: ClassCollection, + hasValue: boolean, + hasAnnotation: boolean + ): PythonFieldOrigin { + if (collection.isTypedDict && hasAnnotation) { + return PythonFieldOrigin.TYPEDDICT_KEY; + } + if (collection.isNamedTuple && hasAnnotation) { + return PythonFieldOrigin.NAMEDTUPLE_FIELD; + } + if (collection.isDataclass && hasAnnotation) { + return PythonFieldOrigin.DATACLASS_FIELD; + } + if (collection.isEnum && hasValue && !hasAnnotation) { + return PythonFieldOrigin.ENUM_MEMBER; + } + if (!hasValue) { + return PythonFieldOrigin.CLASS_BODY_ANNOTATION_ONLY; + } + return PythonFieldOrigin.CLASS_BODY_ASSIGN; + } + + private isInstanceOrigin(origin: PythonFieldOrigin): boolean { + return ( + origin === PythonFieldOrigin.SELF_ASSIGN || + origin === PythonFieldOrigin.SELF_AUGASSIGN || + origin === PythonFieldOrigin.SLOTS_ENTRY || + origin === PythonFieldOrigin.SETATTR_DYNAMIC + ); + } + + /** Bare names on the left of a class-body assignment. */ + private assignmentTargets(left: Parser.SyntaxNode): Parser.SyntaxNode[] { + if (left.type === 'identifier') { + return [left]; + } + const found: Parser.SyntaxNode[] = []; + const worklist: Parser.SyntaxNode[] = [left]; + while (worklist.length > 0) { + const node = worklist.shift()!; + if (node.type === 'identifier') { + found.push(node); + continue; + } + // An attribute or subscript on the left of a class-body assignment is not + // a declaration of this class: `Config.registry["k"] = v` mutates + // something else. + if ( + node.type === 'attribute' || + node.type === 'subscript' + ) { + continue; + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + return found; + } + + /** + * `attribute` nodes on the left of a write whose object is the receiver. + * + * The object test is exact: `self.x` counts, and `self.inner.x` does not — + * that writes an attribute of whatever `self.inner` is, which is a different + * class. Claiming it here would attach a stranger's attribute to this one. + */ + private attributeTargets(left: Parser.SyntaxNode, receiverName: string): Parser.SyntaxNode[] { + const found: Parser.SyntaxNode[] = []; + const worklist: Parser.SyntaxNode[] = [left]; + while (worklist.length > 0) { + const node = worklist.shift()!; + if (node.type === 'attribute') { + const object = node.childForFieldName('object'); + if ( + object && + object.type === 'identifier' && + object.text === receiverName + ) { + found.push(node); + } + continue; + } + if (node.type === 'subscript') { + continue; + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + return found; + } + + private isSlotsTarget(left: Parser.SyntaxNode): boolean { + return left.type === 'identifier' && left.text === '__slots__'; + } + + /** + * The receiver name for a method — its first positional parameter. + * + * A `@staticmethod` has no receiver, so it gets `''` and contributes nothing: + * its first parameter is an ordinary argument, and treating it as `self` would + * invent attributes on the class from writes to a caller's object. + */ + private receiverNameOf( + definition: Parser.SyntaxNode, + method: PyMethodRegistry | undefined + ): string { + if (method && method.getMethodKind() === PythonMethodKind.STATIC_METHOD) { + return ''; + } + const parameters = definition.childForFieldName('parameters'); + if (!parameters) { + return ''; + } + for (let index = 0; index < parameters.namedChildCount; index += 1) { + const parameter = parameters.namedChild(index); + if (!parameter || parameter.isExtra) { + continue; + } + if (parameter.type === 'identifier') { + return parameter.text; + } + if (parameter.type === 'default_parameter') { + return parameter.childForFieldName('name')?.text ?? ''; + } + if (parameter.type === 'typed_parameter') { + const inner = parameter.namedChild(0); + return inner && inner.type === 'identifier' ? inner.text : ''; + } + return ''; + } + return ''; + } + + private isProperty(method: PyMethodRegistry): boolean { + return method.getMethodKind() === PythonMethodKind.PROPERTY_GETTER; + } + + private unwrapDecorated(statement: Parser.SyntaxNode): Parser.SyntaxNode | null { + if (statement.type !== 'decorated_definition') { + return statement; + } + return statement.childForFieldName('definition') ?? null; + } + + private hasDataclassDecorator(classNode: Parser.SyntaxNode): boolean { + const parent = classNode.parent; + if (!parent || parent.type !== 'decorated_definition') { + return false; + } + for (let index = 0; index < parent.namedChildCount; index += 1) { + const child = parent.namedChild(index); + if (!child || child.type !== 'decorator') { + continue; + } + if (child.text.includes('dataclass')) { + return true; + } + } + return false; + } + + private hasBaseNamed(classNode: Parser.SyntaxNode, names: string[]): boolean { + const superclasses = classNode.childForFieldName('superclasses'); + if (!superclasses) { + return false; + } + for (let index = 0; index < superclasses.namedChildCount; index += 1) { + const base = superclasses.namedChild(index); + if (!base || base.isExtra) { + continue; + } + const rightmost = base.text.split('.').pop() ?? ''; + const head = rightmost.split('[')[0] ?? ''; + if (names.includes(head)) { + return true; + } + } + return false; + } + + /** + * CPython's private-name mangling, applied to attribute names. + * + * `self.__x` inside `class C` stores `_C__x`. A trailing double underscore + * opts out, which is why `__init__` is not mangled. + */ + private mangle(className: string, name: string): string { + if (!name.startsWith('__') || name.endsWith('__')) { + return name; + } + const stripped = className.replace(/^_+/, ''); + if (stripped === '') { + return name; + } + return '_' + stripped + name; + } + + private accessOf(name: string): PythonMethodAccess { + if (name.startsWith('__') && name.endsWith('__')) { + return PythonMethodAccess.DUNDER_ACCESS; + } + if (name.startsWith('_') && name.includes('__')) { + return PythonMethodAccess.PRIVATE_ACCESS; + } + if (name.startsWith('_')) { + return PythonMethodAccess.PROTECTED_ACCESS; + } + return PythonMethodAccess.PUBLIC_ACCESS; + } + + /** + * The `py_binding` PK for a class-body attribute. + * + * This is the asymmetry schema §2.9 c25 is about. `limit = 4096` in a class + * body IS a binding — CPython's symtable records it in the class block, so + * there is a row to point at. `self.limit = n` is not: the symtable records + * `self` and nothing else, because the attribute lives in the instance + * `__dict__` and is resolved at runtime. Empty is therefore the correct answer + * for `self.*`, not a missing link. + */ + private classBodyBinding(collection: ClassCollection, name: string): string { + if (collection.classScopeHash === '') { + return ''; + } + return ( + this.input.bindingHashByScopeAndName.get(`${collection.classScopeHash}::${name}`) ?? '' + ); + } + + private initializerKindOf(value: Parser.SyntaxNode): PythonInitializerKind { + switch (value.type) { + case 'string': + case 'integer': + case 'float': + case 'true': + case 'false': + case 'none': + case 'list': + case 'dictionary': + case 'set': + case 'tuple': + case 'concatenated_string': { + return PythonInitializerKind.LITERAL; + } + case 'call': { + return PythonInitializerKind.CALL; + } + case 'identifier': { + return PythonInitializerKind.NAME; + } + case 'attribute': { + return PythonInitializerKind.ATTRIBUTE; + } + case 'lambda': { + return PythonInitializerKind.LAMBDA; + } + case 'list_comprehension': + case 'set_comprehension': + case 'dictionary_comprehension': + case 'generator_expression': { + return PythonInitializerKind.COMPREHENSION; + } + default: { + return PythonInitializerKind.UNKNOWN; + } + } + } + + private isStringAnnotation(annotation: Parser.SyntaxNode): boolean { + return annotation.namedChild(0)?.type === 'string'; + } + + /** + * The head of an annotation: `Dict` for `Dict[str, int]`. + * + * A PEP 484 forward reference is quoted, and the quotes are part of the + * annotation TEXT: `value: "Union[str, bytes]"` arrives here still carrying + * them. Splitting at the first `[` without removing them first yields + * `"Union` -- a name no type in the project can ever match, so the reference + * is silently unresolvable rather than wrong in a visible way. + * + * Only a WHOLE quoted annotation is unwrapped. `Optional["Holder"]` keeps its + * inner quotes, because the head there is `Optional` and the quoted part is + * an argument that this function never looks at. + */ + private baseTypeOf(annotationText: string): string { + const unquoted = this.stripForwardRefQuotes(annotationText); + if (unquoted === '') { + return ''; + } + const head = unquoted.split('[')[0] ?? ''; + return (head.split('.').pop() ?? '').trim(); + } + + /** Removes the surrounding quotes of a whole-string forward reference. */ + private stripForwardRefQuotes(annotationText: string): string { + const text = annotationText.trim(); + if (text.length < 2) { + return text; + } + const first = text[0]; + if ((first !== '"' && first !== "'") || text[text.length - 1] !== first) { + return text; + } + return text.slice(1, -1).trim(); + } + + /** + * The VALUE of a string literal, prefix and quotes removed. + * + * `__slots__ = ("a",)` declares an attribute named `a`, not `"a"`. An f-string + * is rejected: `f"{n}"` in a `__slots__` list has no statically known name, and + * a row named `{n}` would assert an attribute that never exists. + */ + private stringLiteralValue(node: Parser.SyntaxNode): string { + const text = node.text; + const quote = text.search(/['"]/); + if (quote < 0) { + return ''; + } + const prefix = text.slice(0, quote).toLowerCase(); + if (prefix.includes('f')) { + return ''; + } + const body = text.slice(quote).replace(/^('''|\"\"\"|'|")/, '').replace(/('''|\"\"\"|'|")$/, ''); + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(body) ? body : ''; + } + + /** + * The builtin type a value expression produces, or `''`. + * + * Only the forms where the syntax settles it. A call to anything other than a + * builtin constructor is deliberately `''`: its return type is unknown here, so + * claiming one would manufacture a disagreement or hide a real one. + */ + private builtinTypeOfValue(value: Parser.SyntaxNode): string { + switch (value.type) { + case 'list': + case 'list_comprehension': { + return 'list'; + } + case 'dictionary': + case 'dictionary_comprehension': { + return 'dict'; + } + case 'set': + case 'set_comprehension': { + return 'set'; + } + case 'tuple': { + return 'tuple'; + } + case 'integer': { + return 'int'; + } + case 'float': { + return 'float'; + } + case 'true': + case 'false': { + return 'bool'; + } + case 'none': { + return 'None'; + } + case 'string': + case 'concatenated_string': { + const start = value.child(0); + const prefix = start ? start.text.toLowerCase() : ''; + return prefix.includes('b') ? 'bytes' : 'str'; + } + case 'call': { + const callee = value.childForFieldName('function'); + const name = callee ? (callee.text.split('.').pop() ?? '') : ''; + return PYTHON_BUILTIN_TYPE_METHODS.has(name) ? name : ''; + } + default: { + return ''; + } + } + } + + private normalizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); + } + + /** + * Remembers the annotation that gave a field its declared type, so a + * `py_type_reference` can be OWNED BY THE FIELD. + * + * `class Foo: x: Bar` previously produced a reference owned by the class-body + * BINDING with context VARIABLE_ANNOTATION, and nothing owned by the field -- + * FIELD_TYPE and owner kind FIELD were both dead. So "what type does field x + * of Foo declare?" could not be answered by joining from py_field at all: a + * consumer had to know that a class attribute is ALSO a binding, find the + * class body scope and match on name. That is a join nobody should have to + * discover, and getting it wrong returns nothing rather than failing. + * + * Only the FIRST annotation is kept. A field is observed many times -- the + * class-body declaration plus every `self.x = ...` -- and the declaration is + * the one that declares the type. + */ + private recordFieldAnnotation( + fieldHash: string, + observation: WriteObservation, + collection: ClassCollection + ): void { + if (observation.annotationNode === null || this.annotationByField.has(fieldHash)) { + return; + } + this.annotationByField.set(fieldHash, { + node: observation.annotationNode, + typeHash: collection.typeHash, + scopeHash: collection.classScopeHash, + }); + } + + /** + * `self.__dict__['x'] = v` names attribute `x` exactly as `self.x = v` does. + * + * Classes that must bypass a custom `__setattr__` write through `__dict__` + * instead, and they usually alias it first: + * + * __dict__ = self.__dict__ + * __dict__['_mock_children'] = {} + * + * Every attribute defined that way had NO py_field row, so a call on it could + * not be linked to anything -- on unittest that was every remaining + * parser-owned gap but two. The key is a string LITERAL, so this is decided + * statically and is not inference: a computed key is skipped rather than + * guessed. + * + * Returns the attribute name, or `''` when the target is not such a write. + */ + private instanceDictKey(left: Parser.SyntaxNode, receiverName: string): string { + if (left.type !== 'subscript') { + return ''; + } + const value = left.childForFieldName('value'); + if (!value) { + return ''; + } + const isDirect = + value.type === 'attribute' && + value.childForFieldName('object')?.text === receiverName && + value.childForFieldName('attribute')?.text === '__dict__'; + const isAlias = value.type === 'identifier' && this.dictAliases.has(value.text); + if (!isDirect && !isAlias) { + return ''; + } + const key = left.childForFieldName('subscript') ?? left.namedChild(1); + if (!key || key.type !== 'string') { + return ''; + } + const content = key.namedChildren.find(c => c.type === 'string_content'); + return content ? content.text : ''; + } + + /** Locals bound to `self.__dict__` in this method body. */ + private collectDictAliases(methodBody: Parser.SyntaxNode, receiverName: string): Set { + const aliases = new Set(); + const worklist: Parser.SyntaxNode[] = [methodBody]; + while (worklist.length > 0) { + const node = worklist.shift()!; + if (node.type === 'assignment') { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if ( + left?.type === 'identifier' && + right?.type === 'attribute' && + right.childForFieldName('object')?.text === receiverName && + right.childForFieldName('attribute')?.text === '__dict__' + ) { + aliases.add(left.text); + } + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + return aliases; + } +} diff --git a/parser/src/parsers/python/extractors/python-parse-gap-extractor.ts b/parser/src/parsers/python/extractors/python-parse-gap-extractor.ts new file mode 100644 index 000000000..551fac8f7 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-parse-gap-extractor.ts @@ -0,0 +1,147 @@ +import Parser from 'tree-sitter'; + +import { PyModuleRegistry, PyParseGapRegistry } from '@/analysis-types/python'; +import { + PythonParseGapDisposition, + PythonParseGapKind, +} from '@/enums/python/parse-gaps'; +import { PythonSourcePositions } from '@/utils/python/python-position-utils'; +import { isMisparsedTypeAlias } from '@/parsers/python/python-soft-keywords'; +import { Python2Finding } from '@/parsers/python/types'; + +export interface PythonParseGapInput { + module: PyModuleRegistry; + rootNode: Parser.SyntaxNode; + serviceVersionLinkHash: string; + positions: PythonSourcePositions; + /** Python 2 constructs found by the dialect detector, if any. */ + python2Findings: Python2Finding[]; +} + +/** + * Records every region the grammar could not represent. + * + * This is the relation whose ABSENCE is invisible, and that is the argument for + * building it. Every other omission in this schema surfaces as a missing row + * somewhere a consumer is already looking. A region the parser could not read + * produces SILENCE, and silence is indistinguishable from "there was nothing + * there" — a rule that finds no `eval` cannot tell a clean file from one where + * the parser gave up on the block containing it. + * + * Two dispositions, and the distinction is the point: + * + * - `ERROR_NODE` — the grammar knew it was lost and said so. + * - `MISPARSED_SILENTLY` — the grammar produced a plausible but WRONG node and + * raised nothing. A Python 2 backtick repr parses cleanly into something that + * means something else, so the facts derived from it are confident and wrong. + * Only this relation can tell a consumer the difference. + * + * Nested errors are NOT reported twice: tree-sitter marks a whole region as one + * ERROR and may nest more inside it, and emitting each would turn one + * unreadable construct into a pile of rows suggesting many separate problems. + */ +export class PythonParseGapExtractor { + extract(input: PythonParseGapInput): PyParseGapRegistry[] { + const gaps: PyParseGapRegistry[] = []; + + // A Python 2 module is rejected wholesale (§6.2), which means NO `py_module` + // row exists for it — and every `py_parse_gap` row is keyed on + // `pyModuleLinkHash` with no `filePath` column to fall back on. So a Py2 gap + // emitted here would be unattributable: a row pointing at a module that was + // never written. + // + // It is not lost, though. `skipped-python-files.csv` already records the + // rejection WITH the construct, line, column and a count, which is strictly + // more than this relation could carry. Emitting a second, unjoinable copy + // would add noise and no information. Raised with A0 as a modelling question + // rather than settled here, since the schema does list PY2_CONSTRUCT_DETECTED + // as a constructKind and only one of the two places can be right. + for (const finding of input.python2Findings) { + gaps.push( + new PyParseGapRegistry( + input.module.getHash(), + PythonParseGapKind.PY2_CONSTRUCT_DETECTED, + // A Py2 construct parses CLEANLY — that is precisely why it needs + // detecting rather than catching as an error. + PythonParseGapDisposition.MISPARSED_SILENTLY, + finding.startLine, + finding.startColumn, + finding.startLine, + finding.startColumn, + finding.construct, + input.serviceVersionLinkHash + ) + ); + } + + this.collectErrors(input, input.rootNode, gaps, false); + this.collectSoftKeywordMisparses(input, input.rootNode, gaps); + return gaps; + } + + /** + * A soft keyword applied where it should not have been leaves no ERROR node, + * so nothing above would ever find it. + */ + private collectSoftKeywordMisparses( + input: PythonParseGapInput, + node: Parser.SyntaxNode, + gaps: PyParseGapRegistry[] + ): void { + if (isMisparsedTypeAlias(node)) { + gaps.push( + new PyParseGapRegistry( + input.module.getHash(), + PythonParseGapKind.SOFT_KEYWORD_MISPARSE, + PythonParseGapDisposition.MISPARSED_SILENTLY, + node.startPosition.row + 1, + input.positions.byteColumn(node.startPosition.row, node.startPosition.column), + node.endPosition.row + 1, + input.positions.byteColumn(node.endPosition.row, node.endPosition.column), + node.text.replace(/\s+/g, ' ').trim().slice(0, 200), + input.serviceVersionLinkHash + ) + ); + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child) { + this.collectSoftKeywordMisparses(input, child, gaps); + } + } + } + + private collectErrors( + input: PythonParseGapInput, + node: Parser.SyntaxNode, + gaps: PyParseGapRegistry[], + insideError: boolean + ): void { + let nowInsideError = insideError; + + if (!insideError && (node.type === 'ERROR' || node.isMissing)) { + const isMissing = node.isMissing; + gaps.push( + new PyParseGapRegistry( + input.module.getHash(), + isMissing ? PythonParseGapKind.MISSING_NODE : PythonParseGapKind.ERROR_NODE, + PythonParseGapDisposition.ERROR_NODE, + node.startPosition.row + 1, + input.positions.byteColumn(node.startPosition.row, node.startPosition.column), + node.endPosition.row + 1, + input.positions.byteColumn(node.endPosition.row, node.endPosition.column), + node.text.replace(/\s+/g, ' ').trim().slice(0, 200), + input.serviceVersionLinkHash + ) + ); + nowInsideError = true; + } + + for (let index = 0; index < node.childCount; index += 1) { + const child = node.child(index); + if (child) { + this.collectErrors(input, child, gaps, nowInsideError); + } + } + } +} diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts new file mode 100644 index 000000000..23cb9bce4 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -0,0 +1,3386 @@ +import { + PyBindingRegistry, + PyCallSiteRegistry, + PyDecoratorArgumentRegistry, + PyDecoratorRegistry, + PyExpressionRegistry, + PyFieldRegistry, + PyImportRegistry, + PyMethodParameterRegistry, + PyMethodRegistry, + PyScopeRegistry, + PyTypeBaseRegistry, + PyTypeReferenceRegistry, + PyTypeRegistry, +} from '@/analysis-types/python'; +import { PYTHON_BUILTIN_TYPE_METHODS } from '@/constants/python-constants'; +import { PYTHON_BUILTIN_NAMES } from '@/constants/python-constants'; +import { PythonReceiverKind, PythonResolvedCalleeKind } from '@/enums/python/call-sites'; +import { PythonDecoratorArgumentValueType } from '@/enums/python/decorators'; +import { PythonInitializerKind } from '@/enums/python/fields'; +import { + PythonEdgeRole, + PythonExpressionKind, + PythonReferencedEntityKind, + PythonRootContext, +} from '@/enums/python/expressions'; +import { PythonImportTargetKind } from '@/enums/python/imports'; +import { PythonMethodKind } from '@/enums/python/methods'; + +/** One module's facts, for the project-level pass. */ +export interface ProjectModuleFacts extends ResolutionInput { + qualifiedName: string; + moduleHash: string; + /** + * Whether this module is a package's `__init__.py`. + * + * Needed for relative imports, and the distinction is not cosmetic. For a + * submodule `unittest.case`, `from .x import y` means `unittest.x` — drop the + * last segment to get the package. For the package's own `__init__.py`, + * qualified name `unittest`, the current package IS `unittest`, so dropping a + * segment walks one level too far and every `from .case import TestCase` + * re-export fails to resolve. + */ + isPackage?: boolean; +} + +export interface ProjectResolutionStats { + importsResolved: number; + callSitesResolved: number; +} + +/** Shared lookup context for MRO-based resolution, with the MRO memoised. */ +interface MroContext { + typesByHash: Map; + basesByType: Map; + methodsByTypeAndName: Map; + /** typeHash -> its C3 linearisation, or null when it cannot be computed. */ + mroCache: Map; +} + +/** Everything the linker needs from one module's fact set. */ +export interface ResolutionInput { + scopes: PyScopeRegistry[]; + bindings: PyBindingRegistry[]; + types: PyTypeRegistry[]; + typeBases: PyTypeBaseRegistry[]; + methods: PyMethodRegistry[]; + methodParameters: PyMethodParameterRegistry[]; + imports: PyImportRegistry[]; + callSites: PyCallSiteRegistry[]; + expressions: PyExpressionRegistry[]; + typeReferences: PyTypeReferenceRegistry[]; + fields: PyFieldRegistry[]; + decorators?: PyDecoratorRegistry[]; + decoratorArguments?: PyDecoratorArgumentRegistry[]; + /** Assignment target byte range -> value byte range, from the expression stage. */ + assignedValueByTargetRange?: Map; + /** Byte range -> expression PK. */ + expressionByByteRange?: Map; + /** Expression PK -> byte range, the inverse. */ + byteRangeByExpression?: Map; + /** `(pyTypeLinkHash, attributeName)` -> `py_field` PK. */ + fieldHashByTypeAndName: Map; + /** `py_method` PK -> its receiver parameter name. */ + receiverNameByMethodHash: Map; +} + +/** + * Python builtins that are callable and shadow nothing by default. + * + * Recorded as `BUILTIN` with an EMPTY hash: there is no `py_method` row for a + * builtin, so a hash would be a lie, but the kind is a real fact and strictly + * better than `UNRESOLVED`. A name is only treated as a builtin when no local + * binding shadows it, which is checked before this set is consulted. + */ +// Every builtin name, generated from the pinned interpreter. This was a +// hand-written list of ~60 callables and it omitted EVERY exception type, so +// `raise ValueError(...)` was reported as an unresolved name rather than a call +// to a builtin. On the stdlib that was the single largest category of +// "unresolved": 1011 ValueError, 516 TypeError, 165 RuntimeError. A hand-listed +// set of a language's builtins drifts the moment the language adds one. +const CALLABLE_BUILTINS: ReadonlySet = PYTHON_BUILTIN_NAMES; + +/** + * NOTE: the HAS_GETATTR / HAS_SETATTR escape-hatch check was removed along with + * `hasEscapeHatch`, which nothing called. The reasoning is worth keeping: a + * `__getattr__` on a type does NOT undermine a positive attribute finding, + * because it is consulted only after normal lookup fails, so an explicitly + * declared member always wins. `__getattribute__` does intercept + * unconditionally, and the schema marks that on the type via HAS_GETATTR rather + * than redesigning around it, leaving the engine to act on the marker. + */ + +/** + * Resolves the parser-local half of call-site and base-class linkage. + * + * ## The rule this implements + * + * `UNRESOLVED` is the honest default only where resolution is genuinely not + * derivable. Where a **single** target follows from facts the parser already + * emits, `UNRESOLVED` is not honesty — it is a dropped fact, and the engine + * cannot recover it because the parser was the only place that information + * existed. + * + * ## And the harder half: do not over-resolve + * + * Naive name-only dispatch measures 24.63 candidate classes per attribute call + * and 1.22M candidate edges. At that fan-out every sink looks reachable and + * downstream data flow is worthless. So a hash is emitted **only when exactly + * one target is derivable**. Where several candidates exist the column stays + * `UNRESOLVED` — a candidate is never emitted as though it were a resolution, + * because widening this column's meaning would destroy the precision the whole + * schema exists to protect. + * + * ## Order, cheapest and most certain first + * + * 1. A name bound in the enclosing scope chain to a `def` or `class`, via the + * `py_binding` rows and `declaringBindingLinkHash` already emitted. + * 2. A name declared at module level in this module. + * 3. `self.X` — a method of the enclosing class, then its local MRO. + * 4. `super().X` and a class-qualified `Type.X` — walked through + * `py_type_base.resolvedTypeLinkHash`, which is why bases are resolved first. + * 5. Builtins, when nothing local shadows the name. + * + * Cross-module resolution is deliberately absent here: it needs the module + * graph, which only the project-level pass has. + */ +export class PythonResolutionLinker { + /** + * Dotted-suffix and per-module type indexes for the pass in flight. + * + * Held on the instance because the dotted resolver is reached from several + * places — bases, type references, annotations — and threading two more + * parameters through each of them would obscure the rule rather than clarify + * it. Both are rebuilt at the start of every pass, so no state survives a call. + */ + private qualifiedSuffixIndex = new Map(); + /** Dotted suffix -> module, for import discovery. See {@link findModule}. */ + private moduleSuffixIndex = new Map(); + private typesByNameByModuleName = new Map>(); + + /** + * Cross-module resolution, run once after every module has been extracted. + * + * Separate from {@link link} because it needs the **module graph**, which a + * single-file extraction does not have. Two steps, in order: + * + * 1. Resolve `py_import` rows to a module in this analysis, and then to the + * specific class or function they bind. `isExternalTarget` becomes an + * honest negative rather than a blanket true. + * 2. Re-run call-site resolution with imported names now visible, so + * `build_pipeline(items)` reaches `helpers.build_pipeline` and + * `Child.of("x")` reaches `Base.of` through the imported class's own bases. + * + * Anything that does not resolve to a module inside this analysis stays + * external and UNRESOLVED, which is what that column is for. + */ + linkProject(modules: ProjectModuleFacts[]): ProjectResolutionStats { + const stats: ProjectResolutionStats = { importsResolved: 0, callSitesResolved: 0 }; + + const moduleByQualifiedName = new Map(); + for (const module of modules) { + moduleByQualifiedName.set(module.qualifiedName, module); + } + + // Module-level entities, per module, for import target lookup. A name maps to + // an entity only when exactly one entity carries it. + const exportsByModule = new Map>(); + for (const module of modules) { + const exported = new Map(); + const add = (name: string, entity: PyMethodRegistry | PyTypeRegistry) => { + exported.set(name, exported.has(name) ? null : entity); + }; + for (const type of module.types) { + // Module-level classes only: a nested or function-local class is not part + // of the module namespace and cannot be the target of a from-import. + if (type.getEnclosingTypeLinkHash() === '' && type.getEnclosingMethodLinkHash() === '') { + add(type.getName(), type); + } + } + for (const method of module.methods) { + // Module-level functions only: a method belongs to its class, not to the + // module namespace. + if (method.getPyTypeLinkHash() === '' && method.getMethodKind() === PythonMethodKind.FUNCTION) { + add(method.getName(), method); + } + } + exportsByModule.set(module.qualifiedName, exported); + } + + this.buildModuleSuffixIndex(modules); + + // ---- step 1: imports + /** binding PK -> the entity that import binds, when it resolves in-project. */ + const entityByImportBinding = new Map(); + for (const module of modules) { + for (const record of module.imports) { + const targetName = this.importTargetModule(record, module.qualifiedName, module.isPackage === true); + let targetModule = + targetName === null ? undefined : this.findModule(targetName, moduleByQualifiedName); + + // `from . import protocols` and `from pkg import submodule` bind a + // MODULE, not a member of one. Without this the target is looked for as a + // class or function inside the package and never found — and because + // sibling references are then unresolvable, every base spelled + // `protocols.Protocol` stays unresolved too, which in turn blocks super() + // and self.X resolution on those classes. It cascades from one missing + // case, which is why it accounted for the largest single bucket. + if (!record.getIsModuleImport() && !record.getIsWildcard()) { + const member = record.getOriginalName().split('.').pop() ?? ''; + // MEMBER FIRST, submodule second. That is the interpreter's order: + // `from pkg.mod import name` looks for an attribute `name` on + // pkg.mod and only falls back to a submodule pkg.mod.name if there + // is none. + // + // Doing it the other way round broke exactly when the member shares + // its name with the module's own last segment. `from shared.retry + // import audited, retry` resolved `audited` to the function and + // `retry` to a MODULE with an empty hash, because findModule matches + // by SUFFIX and so `shared.retry.retry` matched the module + // `shared.retry`. The submodule shared.retry.retry does not exist. + // One import statement, two names, and only the colliding one broke. + const declared = + targetModule === undefined + ? undefined + : exportsByModule.get(targetModule.qualifiedName)?.get(member) ?? + this.followReExport(member, targetModule, exportsByModule, moduleByQualifiedName); + if (declared === undefined || declared === null) { + const asModule = targetName === null || targetName === '' + ? member + : `${targetName}.${member}`; + const memberModule = this.findModule(asModule, moduleByQualifiedName); + if (memberModule) { + record.setResolution(memberModule.moduleHash, PythonImportTargetKind.MODULE, ''); + stats.importsResolved += 1; + continue; + } + } + } + + if (!targetModule) { + continue; + } + if (record.getIsWildcard()) { + // A star import binds names we cannot enumerate. Recorded as a + // soundness hole rather than expanded — expanding would invent + // bindings symtable does not have. + record.setResolution(targetModule.moduleHash, PythonImportTargetKind.MODULE, ''); + stats.importsResolved += 1; + continue; + } + if (record.getIsModuleImport()) { + record.setResolution(targetModule.moduleHash, PythonImportTargetKind.MODULE, ''); + stats.importsResolved += 1; + continue; + } + const member = record.getOriginalName().split('.').pop() ?? ''; + const entity = + exportsByModule.get(targetModule.qualifiedName)?.get(member) ?? + this.followReExport(member, targetModule, exportsByModule, moduleByQualifiedName); + if (!entity) { + // The module resolved but the member did not — it may be a variable, a + // re-export, or genuinely absent. Module link only. + record.setResolution(targetModule.moduleHash, PythonImportTargetKind.MODULE, ''); + stats.importsResolved += 1; + continue; + } + const isType = entity instanceof PyTypeRegistry; + record.setResolution( + targetModule.moduleHash, + isType ? PythonImportTargetKind.TYPE : PythonImportTargetKind.FUNCTION, + entity.getHash() + ); + stats.importsResolved += 1; + const bindingHash = record.getBindingLinkHash(); + if (bindingHash !== '') { + entityByImportBinding.set(bindingHash, entity); + } + } + } + + // ---- step 2: bases, now that imports are resolved + // Per-module views of what each module's imports brought into scope. + const importedTypeByName = new Map>(); + const importedModuleByName = new Map>(); + for (const module of modules) { + const types = new Map(); + const mods = new Map(); + for (const record of module.imports) { + const bindingHash = record.getBindingLinkHash(); + const entity = bindingHash === '' ? undefined : entityByImportBinding.get(bindingHash); + if (entity instanceof PyTypeRegistry) { + const bound = record.getSimpleName(); + types.set(bound, types.has(bound) ? null : entity); + } + if (record.getResolvedTargetKind() === PythonImportTargetKind.MODULE) { + // The bound name refers to a module. Find which one by matching the + // resolved module hash, so `from . import protocols` and + // `import pkg.protocols` are handled by the same lookup. + const target = modules.find(m => m.moduleHash === record.getResolvedModuleLinkHash()); + if (target) { + mods.set(record.getSimpleName(), target); + } + } + } + importedTypeByName.set(module.qualifiedName, types); + importedModuleByName.set(module.qualifiedName, mods); + } + + // Module-level class ALIASES, built before bases because a base can be one: + // `_Aliased = Base` then `class ViaAlias(_Aliased)`. This needs no evaluation + // of module-level code — it is a name bound to a class and never rebound, so + // the binding resolves it. An independent resolver agrees, answering + // models.Base for exactly this shape. + const aliasByModule = new Map>(); + for (const module of modules) { + const scoped = new Map(); + for (const binding of module.bindings) { + scoped.set(`${binding.getPyScopeLinkHash()}::${binding.getName()}`, binding); + } + const parents = new Map(); + for (const scope of module.scopes) { + parents.set(scope.getHash(), scope.getParentScopeLinkHash()); + } + const entities = new Map(); + for (const type of module.types) { + if (type.getDeclaringBindingLinkHash() !== '') { + entities.set(type.getDeclaringBindingLinkHash(), type); + } + } + for (const method of module.methods) { + if (method.getDeclaringBindingLinkHash() !== '') { + entities.set(method.getDeclaringBindingLinkHash(), method); + } + } + for (const [bindingHash, entity] of entityByImportBinding) { + entities.set(bindingHash, entity); + } + const aliases = this.buildLocalAliasIndex(module, { + entityByBinding: entities, + bindingByScopeAndName: scoped, + parentScopeOf: parents, + }); + const byName = new Map(); + for (const [bindingHash, entity] of aliases) { + const binding = module.bindings.find(b => b.getHash() === bindingHash); + if (binding) { + byName.set(binding.getName(), entity); + } + } + aliasByModule.set(module.qualifiedName, byName); + } + + // Project-wide dotted resolution, built BEFORE bases are resolved because + // bases are the first thing that needs it. The suffix index answers nested + // and module-qualified names directly; the per-module map answers + // RE-EXPORTS, where `unittest.TestCase` is not a suffix of + // `unittest.case.TestCase` because `unittest/__init__.py` imports the name + // rather than declaring it. + this.qualifiedSuffixIndex = this.buildQualifiedSuffixIndex(modules.flatMap(m => m.types)); + this.typesByNameByModuleName = new Map(); + for (const module of modules) { + const names = this.uniqueByName(module.types, t => t.getName()); + for (const record of module.imports) { + const bindingHash = record.getBindingLinkHash(); + const entity = bindingHash === '' ? undefined : entityByImportBinding.get(bindingHash); + if (entity instanceof PyTypeRegistry) { + const bound = record.getSimpleName(); + names.set(bound, names.has(bound) ? null : entity); + } + } + this.typesByNameByModuleName.set(module.qualifiedName, names); + const last = module.qualifiedName.split('.').pop() ?? ''; + if (last !== '' && !this.typesByNameByModuleName.has(last)) { + this.typesByNameByModuleName.set(last, names); + } + } + + for (const module of modules) { + const localTypes = this.uniqueByName(module.types, t => t.getName()); + const imported = importedTypeByName.get(module.qualifiedName)!; + const importedModules = importedModuleByName.get(module.qualifiedName)!; + for (const base of module.typeBases) { + if ( + base.getKeywordName() !== '' || + base.getIsDynamic() || + base.getIsResolvedLocally() + ) { + continue; + } + const simpleName = base.getBaseSimpleName(); + if (simpleName === '') { + continue; + } + const dotted = base.getBaseDottedPath(); + if (dotted.includes('.')) { + // A dotted base such as `protocols.Protocol`: the leading segment names + // a module. In a package this is the ordinary way to reference a + // sibling, so skipping dotted bases — correct for a single-module pass, + // since the prefix is meaningless there — loses most of them. 44 of 60 + // unresolved bases in asyncio are exactly this shape. + const prefix = dotted.slice(0, dotted.lastIndexOf('.')); + const targetModule = importedModules.get(prefix.split('.')[0]!); + // A `continue` used to sit here when the prefix was not an imported + // module, which made the fallback below unreachable in exactly the + // case its own comment describes. The two paths answer different + // questions and both must run. + const candidates = (targetModule?.types ?? []).filter( + type => + type.getName() === simpleName && + type.getEnclosingTypeLinkHash() === '' && + type.getEnclosingMethodLinkHash() === '' + ); + if (candidates.length === 1) { + base.setResolution(candidates[0]!.getHash(), true); + continue; + } + // The member may be a module-level ALIAS rather than a declaration: + // `_PyFuture = Future` at the foot of asyncio/futures.py, then + // `class Task(futures._PyFuture)`. A declaration-only scan misses it. + if (targetModule) { + const aliased = aliasByModule.get(targetModule.qualifiedName)?.get(simpleName); + if (aliased instanceof PyTypeRegistry) { + base.setResolution(aliased.getHash(), true); + continue; + } + } + // The prefix did not name an imported module in THIS file — the usual + // reason being a re-export (`import unittest` then + // `class T(unittest.TestCase)`, where TestCase is declared in + // unittest/case.py). Walk the segments instead of giving up. + const walked = this.resolveDottedTypeName(dotted, { + typesByName: localTypes, + qualifiedSuffixIndex: this.qualifiedSuffixIndex, + typesByNameByModuleName: this.typesByNameByModuleName, + }); + if (walked && walked.getHash() !== base.getPyTypeLinkHash()) { + base.setResolution(walked.getHash(), true); + } + continue; + } + // A bare name: local first, then whatever an import bound, then a + // module-level ALIAS — `_Aliased = Base` is a class by another name. + const aliased = aliasByModule.get(module.qualifiedName)?.get(simpleName); + const target = + localTypes.get(simpleName) ?? + imported.get(simpleName) ?? + (aliased instanceof PyTypeRegistry ? aliased : undefined); + if (target && target.getHash() !== base.getPyTypeLinkHash()) { + base.setResolution(target.getHash(), true); + } + } + } + + // ---- step 3: call sites, with imported names now visible + const allTypes = modules.flatMap(m => m.types); + const allMethods = modules.flatMap(m => m.methods); + const allTypeBases = modules.flatMap(m => m.typeBases); + + const typesByHash = new Map(allTypes.map(t => [t.getHash(), t])); + const basesByType = new Map(); + for (const base of allTypeBases) { + const list = basesByType.get(base.getPyTypeLinkHash()) ?? []; + list.push(base); + basesByType.set(base.getPyTypeLinkHash(), list); + } + const methodsByTypeAndName = new Map(); + for (const method of allMethods) { + const key = `${method.getPyTypeLinkHash()}::${method.getName()}`; + const list = methodsByTypeAndName.get(key) ?? []; + list.push(method); + methodsByTypeAndName.set(key, list); + } + + // One cache for the whole project: an MRO does not change per module. + const mroCache = new Map(); + const projectReturnTypes = new Map(); + + // Attributes and receiver names, project-wide. Both keys are global — a + // `py_type` PK and a `py_method` PK are unique across the analysis — so one + // map serves every module, which is what makes a CROSS-MODULE attribute + // resolve: `self.transport.close()` where `transport` is annotated with a + // class imported from elsewhere. + const fieldByTypeAndName = new Map(); + // EVERY row for a name, not just the preferred one. An attribute routinely + // exists twice — `dialect: Dialect` in the class body and + // `self.dialect = ...` in `__init__` — and those are two py_field rows by + // design, since fieldOrigin is part of identity. Preferring the instance row + // is right for deciding WHICH ROW A READ REACHES and wrong for deciding + // WHERE THE TYPE COMES FROM, because the annotation is on the other row. + // Keeping only the preferred row is why ATTRIBUTE resolution read 0 of 227 + // on SQLAlchemy's engine package while an independent resolver got 97. + const fieldsByTypeAndName = new Map(); + const receiverNameByMethodHash = new Map(); + for (const module of modules) { + for (const field of module.fields) { + const key = `${field.getPyTypeLinkHash()}||${field.getName()}`; + fieldsByTypeAndName.set(key, [...(fieldsByTypeAndName.get(key) ?? []), field]); + const incumbent = fieldByTypeAndName.get(key); + if (!incumbent || incumbent.getFieldModifier().includes('CLASS_VAR')) { + fieldByTypeAndName.set(key, field); + } + } + for (const [methodHash, receiverName] of module.receiverNameByMethodHash) { + receiverNameByMethodHash.set(methodHash, receiverName); + } + } + + // A field's annotation must resolve in the namespace of the module that + // DECLARES the field, not the one calling through it — `self.q: Queue` means + // whatever `Queue` meant where the class was written. So the name->type map + // is built per module first, and the attribute type is settled before any + // cross-module call site consults it. + const typesByNameByModule = new Map>(); + for (const module of modules) { + const names = this.uniqueByName(module.types, t => t.getName()); + for (const record of module.imports) { + const bindingHash = record.getBindingLinkHash(); + const entity = bindingHash === '' ? undefined : entityByImportBinding.get(bindingHash); + if (entity instanceof PyTypeRegistry) { + const bound = record.getSimpleName(); + names.set(bound, names.has(bound) ? null : entity); + } + } + typesByNameByModule.set(module.moduleHash, names); + } + // Module-level functions by name, for factory typing. Ambiguous names map to + // null: two functions called `make` in one module cannot settle a type. + const moduleMethodsByNameByModule = new Map>(); + for (const module of modules) { + moduleMethodsByNameByModule.set( + module.moduleHash, + this.uniqueByName( + module.methods.filter(m => m.getPyTypeLinkHash() === ''), + m => m.getName() + ) + ); + } + const parametersByMethod = new Map(); + for (const module of modules) { + for (const parameter of module.methodParameters) { + const list = parametersByMethod.get(parameter.getPyMethodLinkHash()) ?? []; + list.push(parameter); + parametersByMethod.set(parameter.getPyMethodLinkHash(), list); + } + } + const fieldTypeByHash = new Map(); + for (const module of modules) { + const names = typesByNameByModule.get(module.moduleHash)!; + for (const field of module.fields) { + const resolved = this.typeOfField(field, { + typesByName: names, + parametersByMethod, + moduleMethodsByName: moduleMethodsByNameByModule.get(module.moduleHash), + }); + if (resolved) { + fieldTypeByHash.set(field.getHash(), resolved); + } + } + } + + // Two passes over the modules for return typing: the first types what each + // module can see on its own, the second lets a factory returning another + // factory's result resolve once the first is known. + for (let pass = 0; pass < 2; pass += 1) { + for (const module of modules) { + const scopedBindings = new Map(); + for (const binding of module.bindings) { + scopedBindings.set(`${binding.getPyScopeLinkHash()}::${binding.getName()}`, binding); + } + const scopedParents = new Map(); + for (const scope of module.scopes) { + scopedParents.set(scope.getHash(), scope.getParentScopeLinkHash()); + } + const scopedEntities = new Map(); + for (const method of module.methods) { + if (method.getDeclaringBindingLinkHash() !== '') { + scopedEntities.set(method.getDeclaringBindingLinkHash(), method); + } + } + for (const type of module.types) { + if (type.getDeclaringBindingLinkHash() !== '') { + scopedEntities.set(type.getDeclaringBindingLinkHash(), type); + } + } + for (const [bindingHash, entity] of entityByImportBinding) { + scopedEntities.set(bindingHash, entity); + } + const found = this.buildReturnTypeIndex(module, { + entityByBinding: scopedEntities, + bindingByScopeAndName: scopedBindings, + parentScopeOf: scopedParents, + typesByName: typesByNameByModule.get(module.moduleHash) ?? new Map(), + moduleMethodsByName: moduleMethodsByNameByModule.get(module.qualifiedName), + methodsByTypeAndName, + typesByHash, + basesByType, + mroCache, + returnedTypeByMethod: projectReturnTypes, + }); + for (const [methodHash, resolved] of found) { + if (resolved) { + projectReturnTypes.set(methodHash, resolved); + } + } + } + } + + for (const module of modules) { + const entityByBinding = new Map(); + for (const method of module.methods) { + if (method.getDeclaringBindingLinkHash() !== '') { + entityByBinding.set(method.getDeclaringBindingLinkHash(), method); + } + } + for (const type of module.types) { + if (type.getDeclaringBindingLinkHash() !== '') { + entityByBinding.set(type.getDeclaringBindingLinkHash(), type); + } + } + // Imported names participate in the same scope-chain lookup as local defs. + for (const [bindingHash, entity] of entityByImportBinding) { + entityByBinding.set(bindingHash, entity); + } + + const bindingByScopeAndName = new Map(); + for (const binding of module.bindings) { + bindingByScopeAndName.set(`${binding.getPyScopeLinkHash()}::${binding.getName()}`, binding); + } + const parentScopeOf = new Map(); + for (const scope of module.scopes) { + parentScopeOf.set(scope.getHash(), scope.getParentScopeLinkHash()); + } + const boundNames = new Set( + module.bindings.filter(b => b.isBound()).map(b => b.getName()) + ); + const importedModuleNames = new Set( + module.imports.filter(i => i.getIsModuleImport()).map(i => i.getSimpleName()) + ); + + // A NAME receiver may name an imported class, so the name->type map spans + // local classes plus whatever this module imported. + const typesByName = this.uniqueByName(module.types, t => t.getName()); + for (const record of module.imports) { + const bindingHash = record.getBindingLinkHash(); + const entity = bindingHash === '' ? undefined : entityByImportBinding.get(bindingHash); + if (entity instanceof PyTypeRegistry) { + const bound = record.getSimpleName(); + typesByName.set(bound, typesByName.has(bound) ? null : entity); + } + } + + // Annotations get a second pass too: `a: CustomTypeA` where CustomTypeA is + // imported can only resolve once the import graph exists. + this.resolveAnnotations(module, typesByName); + this.resolveTypeReferences(module, typesByName); + this.linkNameReferences(module, { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + }); + this.resolveDecorators(module, { + typesByName, + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + }); + + // The return index is PROJECT-WIDE, merged below, because a factory is + // usually imported: `reg = make_registry()` in one module needs the return + // type of a function declared in another. A per-module index answers + // nothing for exactly the calls that cross a file boundary, which is most + // of them. + // Callable aliases join the same binding->entity map the scope-chain + // lookup already consults, so `_Row(...)` resolves through the ordinary + // bare-name path rather than needing a branch of its own. + for (const [bindingHash, target] of this.buildLocalAliasIndex(module, { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + })) { + if (!entityByBinding.has(bindingHash)) { + entityByBinding.set(bindingHash, target); + } + } + + const localTypeByBinding = this.buildLocalTypeIndex(module, { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + returnedTypeByMethod: projectReturnTypes, + typesByName, + moduleMethodsByName: moduleMethodsByNameByModule.get(module.qualifiedName), + methodsByTypeAndName, + typesByHash, + basesByType, + mroCache, + }); + + for (const callSite of module.callSites) { + // Retry anything WITHOUT A HASH, not merely anything UNRESOLVED. The + // single-file pass has no module graph, so it can only say IMPORTED for + // a cross-module call — and skipping those here locked in the weaker + // answer from the less-informed pass. `core.make_node()` stayed IMPORTED + // with no hash even though the project pass can reach the declaration. + if (callSite.getResolvedCalleeHash() !== '') { + continue; + } + const target = this.resolveCallSite(callSite, { + typesByHash, + typesByName, + basesByType, + methodsByTypeAndName, + mroCache, + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + boundNames, + importedModuleNames, + fieldByTypeAndName, + fieldsByTypeAndName, + receiverNameByMethodHash, + fieldTypeByHash, + parametersByMethod, + moduleMethodsByName: moduleMethodsByNameByModule.get(module.moduleHash), + importedModules: importedModuleByName.get(module.qualifiedName), + exportsByModule, + moduleByQualifiedName, + localTypeByBinding, + }); + if (target) { + callSite.setResolvedCallee(target.kind, target.hash); + stats.callSitesResolved += 1; + } + } + } + + // Second pass for CALL_RESULT chains. The inner call has to be resolved + // before its return type can be read, so this cannot happen in one sweep. + // Bounded to a single retry: one hop is the parser's share, and a longer + // chain is the engine's to walk. + const methodByHash = new Map(allMethods.map(m => [m.getHash(), m])); + for (const module of modules) { + const innerCallReturnType = this.buildInnerCallReturnIndex( + module, + methodByHash, + projectReturnTypes, + typesByHash, + typesByNameByModule.get(module.moduleHash) ?? new Map(), + typesByNameByModule + ); + if (innerCallReturnType.size === 0) { + continue; + } + const typesByName = typesByNameByModule.get(module.moduleHash) ?? new Map(); + const bindingByScopeAndName = new Map(); + for (const binding of module.bindings) { + bindingByScopeAndName.set(`${binding.getPyScopeLinkHash()}::${binding.getName()}`, binding); + } + const parentScopeOf = new Map(); + for (const scope of module.scopes) { + parentScopeOf.set(scope.getHash(), scope.getParentScopeLinkHash()); + } + for (const callSite of module.callSites) { + if (callSite.getResolvedCalleeKind() !== PythonResolvedCalleeKind.UNRESOLVED) { + continue; + } + if (callSite.getReceiverKind() !== PythonReceiverKind.CALL_RESULT) { + continue; + } + const target = this.resolveCallSite(callSite, { + typesByHash, + typesByName, + basesByType, + methodsByTypeAndName, + mroCache, + entityByBinding: new Map(), + bindingByScopeAndName, + parentScopeOf, + boundNames: new Set(), + importedModuleNames: new Set(), + fieldByTypeAndName: new Map(), + receiverNameByMethodHash: new Map(), + innerCallReturnType, + }); + if (target) { + callSite.setResolvedCallee(target.kind, target.hash); + stats.callSitesResolved += 1; + } + } + } + + return stats; + } + + /** + * Resolves `py_type_reference.referencedTypeLinkHash`. + * + * Every node in the tree resolves independently, which is the point: for + * `Dict[TypeA, TypeB]` the `Dict` row stays unresolved (external) while the two + * argument rows each reach their own `py_type`. A single slot on the parameter + * could only ever have recorded one of the three. + */ + + /** + * Builds a lookup from every dotted SUFFIX of a type's qualified name to that + * type, with collisions mapped to `null`. + * + * A nested class `TopOne.Inner` in module `pkg.nest` has qualified name + * `pkg.nest.TopOne.Inner`, so it is registered under `Inner`, + * `TopOne.Inner`, `nest.TopOne.Inner` and the full name. That single structure + * answers all three shapes a dotted reference takes — a nested class named from + * its outer class, a class named through its module, and a fully qualified + * name — without special-casing any of them. + * + * Collisions map to `null` rather than to a first hit. Two classes named + * `Inner` in different outer classes make the bare name ambiguous, and + * answering it would be a guess; the longer, unambiguous suffix still resolves. + */ + private buildQualifiedSuffixIndex( + types: PyTypeRegistry[] + ): Map { + const index = new Map(); + for (const type of types) { + const qualified = type.getQualifiedName(); + if (qualified === '') { + continue; + } + const segments = qualified.split('.'); + for (let start = segments.length - 1; start >= 0; start -= 1) { + const suffix = segments.slice(start).join('.'); + if (index.has(suffix)) { + const incumbent = index.get(suffix); + if (incumbent !== type) { + index.set(suffix, null); + } + continue; + } + index.set(suffix, type); + } + } + return index; + } + + /** + * Resolves a possibly-dotted type name to a single class. + * + * Dotted names were previously skipped outright, on the correct reasoning that + * `pkg.mod.Cls` names something outside this module even when its last segment + * collides with a local class. That reasoning is sound and the conclusion was + * still wrong: the fix is to walk the segments, not to refuse the name. The + * measured cost of refusing was large — `unittest.TestCase` alone went + * unresolved 244 times, taking 3,201 `self.assertEqual`-style calls with it, + * because a method reachable through the MRO is only reachable once the base + * resolves. + * + * Two grounds, tried in order of certainty: + * + * 1. A unique qualified-name suffix. This covers `TopOne.Inner` in the same + * file and `models.Base` across modules, and it is unambiguous by + * construction because a colliding suffix was mapped to `null`. + * 2. A module binding. `unittest.TestCase` is not `unittest.case.TestCase` by + * suffix — `unittest/__init__.py` RE-EXPORTS it — so the module is found + * first and the name looked up in what that module binds, imports included. + */ + private resolveDottedTypeName( + raw: string, + ctx: { + typesByName: Map; + qualifiedSuffixIndex?: Map; + typesByNameByModuleName?: Map>; + } + ): PyTypeRegistry | null { + // A PEP 484 forward reference carries its quotes into the complete name: + // `-> "TopOne.Inner.Deepest"`. The single-segment path already strips them, + // so a dotted forward reference was the ONLY shape that failed — the quotes + // made every segment walk miss. + const dotted = raw.replace(/^['"]|['"]$/g, '').trim(); + if (dotted === '') { + return null; + } + if (!dotted.includes('.')) { + return ctx.typesByName.get(dotted) ?? null; + } + + const bySuffix = ctx.qualifiedSuffixIndex?.get(dotted); + if (bySuffix) { + return bySuffix; + } + + const segments = dotted.split('.'); + const memberName = segments[segments.length - 1]!; + const modulePath = segments.slice(0, -1).join('.'); + const moduleTypes = + ctx.typesByNameByModuleName?.get(modulePath) ?? + ctx.typesByNameByModuleName?.get(segments[segments.length - 2]!); + if (moduleTypes) { + return moduleTypes.get(memberName) ?? null; + } + return null; + } + + + /** + * Resolves decorators and their arguments to the entities they name. + * + * Two FKs that the schema declares and that would otherwise ship empty — + * `py_decorator.resolvedTargetHash` and + * `py_decorator_argument.referencedTypeHash`. Java declares the second and + * never populates it, so a rule ported across finds nothing on either side; + * that is the failure mode this project has now hit four times, and an empty + * FK is invisible to orphan checking because there is nothing to dereference. + * + * The decorator name is resolved through the SAME scope chain a call would + * use, so `@app.route` and a call to `app.route` reach the same entity. An + * argument that resolves to a class is retyped CLASS_REFERENCE, and a dotted + * argument whose base is a class becomes ENUM_CONSTANT — which is what an enum + * member is in a language with no enum syntax. + */ + private resolveDecorators( + input: ResolutionInput, + ctx: { + typesByName: Map; + entityByBinding: Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + } + ): void { + const decorators = input.decorators ?? []; + const decoratorArguments = input.decoratorArguments ?? []; + if (decorators.length === 0) { + return; + } + const scopeByExpression = new Map(); + for (const expression of input.expressions) { + scopeByExpression.set(expression.getHash(), expression.getPyScopeLinkHash()); + } + + const scopeOfDecorator = new Map(); + for (const decorator of decorators) { + const scope = scopeByExpression.get(decorator.getPyExpressionLinkHash()) ?? ''; + scopeOfDecorator.set(decorator.getHash(), scope); + const named = decorator.getDottedPath() === '' + ? decorator.getDecoratorName() + : decorator.getDottedPath(); + const target = named.includes('.') + ? this.resolveDottedTypeName(named, { + typesByName: ctx.typesByName, + qualifiedSuffixIndex: this.qualifiedSuffixIndex, + typesByNameByModuleName: this.typesByNameByModuleName, + }) + : this.lookupInScopeChain(decorator.getDecoratorName(), scope, ctx); + if (target) { + decorator.setResolvedTargetHash(target.getHash()); + } + } + + for (const argument of decoratorArguments) { + const valueType = argument.getValueType(); + const isName = valueType === PythonDecoratorArgumentValueType.NAME_REFERENCE; + const isDotted = valueType === PythonDecoratorArgumentValueType.ATTRIBUTE_REFERENCE; + if (!isName && !isDotted) { + continue; + } + const scope = scopeOfDecorator.get(argument.getParentDecoratorLinkHash()) ?? ''; + const value = argument.getArgumentValue(); + if (isName) { + const entity = this.lookupInScopeChain(value, scope, ctx); + if (entity instanceof PyTypeRegistry) { + argument.setReferencedType( + entity.getHash(), + PythonDecoratorArgumentValueType.CLASS_REFERENCE + ); + } + continue; + } + // `Color.RED` — resolve the BASE. If it is a class, this is an enum member + // or a class attribute, and either way the FK points at the class. + const base = value.slice(0, value.lastIndexOf('.')); + const owner = base.includes('.') + ? this.resolveDottedTypeName(base, { + typesByName: ctx.typesByName, + qualifiedSuffixIndex: this.qualifiedSuffixIndex, + typesByNameByModuleName: this.typesByNameByModuleName, + }) + : this.lookupInScopeChain(base, scope, ctx); + if (owner instanceof PyTypeRegistry) { + argument.setReferencedType( + owner.getHash(), + PythonDecoratorArgumentValueType.ENUM_CONSTANT + ); + } + } + } + + private resolveTypeReferences( + input: ResolutionInput, + typeByName: Map + ): void { + for (const reference of input.typeReferences) { + if (reference.getReferencedTypeLinkHash() !== '') { + continue; + } + // The COMPLETE name first: `TopOne.Inner` must reach the nested class, not + // the outer one that its first segment happens to name. + const complete = reference.getCompleteTypeName(); + const target = + (complete.includes('.') + ? this.resolveDottedTypeName(complete, { + typesByName: typeByName, + qualifiedSuffixIndex: this.qualifiedSuffixIndex, + typesByNameByModuleName: this.typesByNameByModuleName, + }) + : null) ?? typeByName.get(reference.getTypeName()) ?? null; + if (target) { + reference.setReferencedTypeLinkHash(target.getHash()); + } + } + } + + /** + * Links every NAME REFERENCE to the entity it names. + * + * This is what makes a *use* of a type reach the type's hash — the job Java's + * `java_type_reference` does. `py_type_reference` is in the deferred eleven, so + * until it lands `py_expression.referencedEntityKind` / `referencedEntityHash` + * (frozen columns c14/c15) are where that link lives, and leaving them empty + * forced the engine to re-derive names from text. + * + * It matters most for NESTED annotations. `y: Optional[CustomTypeB]` resolves + * its BASE to `Optional`, which is external, so `potentialQualifiedName` is + * legitimately empty — but the inner `CustomTypeB` is a NAME_REFERENCE in the + * annotation subtree, and this gives it a direct FK to `models.CustomTypeB`. + * Going through `bindingLinkHash` instead does not work from inside a class or + * method: the binding there is the LOCAL reference row, not the module-level + * import that defined the name, so the join dead-ends exactly where it is most + * needed. + */ + private linkNameReferences( + input: ResolutionInput, + ctx: { + entityByBinding: Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + } + ): void { + for (const expression of input.expressions) { + if (expression.getKind() !== PythonExpressionKind.NAME_REFERENCE) { + continue; + } + if (expression.getReferencedEntityHash() !== '') { + continue; + } + const name = expression.getLiteralValue(); + if (name === '') { + continue; + } + const entity = this.lookupInScopeChain(name, expression.getPyScopeLinkHash(), ctx); + if (!entity) { + continue; + } + const described = this.describeEntity(entity); + expression.setReferencedEntity( + described.kind === PythonResolvedCalleeKind.TYPE + ? PythonReferencedEntityKind.TYPE + : PythonReferencedEntityKind.METHOD, + described.hash + ); + } + } + + /** + * Resolves annotation text to an in-project type, filling + * `potentialQualifiedName` on parameters and bindings. + * + * These are spine columns that exist for exactly this, and leaving them empty + * is the same defect as an UNRESOLVED call site: the answer is derivable and + * the engine cannot recover it, because re-deriving a type from annotation + * TEXT is precisely what the schema forbids it to do. + * + * Resolution is on the annotation's BASE name — `Optional[CustomTypeB]` + * resolves `Optional` — which matches `declaredBaseType`'s stated meaning and + * Java's behaviour for `List`. The inner type is not lost: the + * annotation is also emitted as a py_expression subtree whose NAME_REFERENCE + * nodes carry `bindingLinkHash`, so `CustomTypeB` is reachable by joining that + * binding to the `py_import` row that bound it. + * + * `isAmbiguous` is set when a wildcard import is in scope, because a + * same-named class could then come from somewhere unenumerable and the + * resolution is a best guess rather than a fact. + */ + private resolveAnnotations( + input: ResolutionInput, + typeByName: Map + ): void { + const wildcardScopes = new Set( + input.imports.filter(i => i.getIsWildcard()).map(i => i.getPyScopeLinkHash()) + ); + const anyWildcard = wildcardScopes.size > 0; + + const baseNameOf = (annotation: string): string => { + // Strip subscripts, then take the rightmost dotted segment: `a.b.C[int]` + // resolves on `C`. + const withoutSubscript = annotation.split('[')[0]!.trim(); + const parts = withoutSubscript.split('.'); + return parts[parts.length - 1]!.trim(); + }; + + const methodsByHash = new Map(input.methods.map(m => [m.getHash(), m])); + + for (const parameter of input.methodParameters ?? []) { + const annotation = parameter.getParameterTypeName(); + if (annotation === '') { + continue; + } + const target = typeByName.get(baseNameOf(annotation)); + if (target) { + parameter.setResolvedAnnotation(target.getQualifiedName(), anyWildcard); + } else if (anyWildcard) { + // Unresolved AND a wildcard import is present: the name may well be a + // class we cannot see, so mark the imprecision rather than implying none. + parameter.setResolvedAnnotation('', true); + } + void methodsByHash; + } + + for (const binding of input.bindings) { + const annotation = binding.getDeclaredTypeName(); + if (annotation === '') { + continue; + } + const baseName = baseNameOf(annotation); + const target = typeByName.get(baseName); + binding.setResolvedAnnotation( + baseName, + target ? target.getQualifiedName() : '', + anyWildcard + ); + } + } + + /** + * The absolute module name an import refers to, or `null` when it cannot be + * determined. + * + * Relative imports are 38% of from-imports, so this is the common path rather + * than an edge case. `from .helpers import x` inside `pkg.service` resolves + * against `pkg`; each extra leading dot strips one more package level. + */ + /** + * Finds a module by name, tolerating an analysis root placed INSIDE the + * package. + * + * Module names are relative to the analysis root, so analysing `.../email` + * directly gives modules `parser`, `message` — while the source says + * `from email.parser import Parser`. Progressively dropping leading segments + * recovers that, and a candidate is accepted only when exactly ONE module + * matches, so an ambiguous suffix resolves to nothing rather than to a guess. + */ + /** + * The class a local variable holds, when the source settles it. + * + * Looks up the binding the receiver name resolves to, then the assignment that + * gave it its value. Three grounds, in order of certainty and each refusing + * rather than guessing: + * + * `x = Foo()` a constructor naming a class in scope + * `x = make_foo()` a function whose `-> Foo` says what it returns + * `x = self.make_foo()` a method on this class, same reasoning + * + * Deliberately refuses when the name is assigned MORE THAN ONCE with different + * types, and when the assignment is a bare name or a call it cannot type. A + * local rebound in a loop or reassigned on a branch is not reliably one type, + * and claiming otherwise would produce exactly the confident wrong edge this + * column exists to avoid. + */ + private typeOfLocalReceiver( + callSite: PyCallSiteRegistry, + ctx: { + receiverOverride?: string; + typesByHash: Map; + typesByName: Map; + basesByType: Map; + methodsByTypeAndName: Map; + mroCache: Map; + localTypeByBinding?: Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + }, + nameOverride?: string + ): PyTypeRegistry | null { + if (!ctx.localTypeByBinding) { + return null; + } + const receiver = nameOverride ?? callSite.getReceiverText(); + if (receiver === '' || receiver.includes('.')) { + return null; + } + // Walk the scope chain so a local declared in an enclosing function is found + // where the language would find it. + let scope: string | undefined = callSite.getPyScopeLinkHash(); + let guard = 0; + while (scope !== undefined && scope !== '' && guard < 200) { + guard += 1; + const binding = ctx.bindingByScopeAndName.get(`${scope}::${receiver}`); + if (binding?.isBound()) { + return ctx.localTypeByBinding.get(binding.getHash()) ?? null; + } + scope = ctx.parentScopeOf.get(scope); + } + return null; + } + + /** + * The class a PARAMETER receiver holds, from its annotation. + * + * `def run(self, case: TestCase)` then `case.setup()`. The annotation is the + * programmer stating the type, and it is the one place a caller's value is + * described without any inference at all — which is why it resolves here while + * the same receiver in unannotated code correctly does not. + */ + private typeOfParameterReceiver( + callSite: PyCallSiteRegistry, + ctx: { + typesByName: Map; + parametersByMethod?: Map; + }, + nameOverride?: string + ): PyTypeRegistry | null { + const receiver = nameOverride ?? callSite.getReceiverText(); + if (receiver === '' || receiver.includes('.') || !ctx.parametersByMethod) { + return null; + } + const parameters = ctx.parametersByMethod.get(callSite.getPyMethodLinkHash()) ?? []; + for (const parameter of parameters) { + if (parameter.getParamName() !== receiver) { + continue; + } + const annotation = parameter.getParameterTypeName(); + if (annotation === '') { + return null; + } + for (const candidate of this.namedTypesIn(annotation)) { + const resolved = ctx.typesByName.get(candidate); + if (resolved) { + return resolved; + } + } + return null; + } + return null; + } + + /** + * Types every local that is assigned exactly one derivable type. + * + * Built once per module from the expression tree: an `ASSIGNMENT_VALUE` whose + * parent statement targets a single name. A name assigned two different types + * maps to `null` and stays unresolved — that refusal is the point, since a + * variable reused for two purposes has no single callee. + */ + private buildLocalTypeIndex( + module: ResolutionInput, + ctx: { + entityByBinding?: Map; + bindingByScopeAndName?: Map; + parentScopeOf?: Map; + returnedTypeByMethod?: Map; + typesByName: Map; + moduleMethodsByName?: Map; + methodsByTypeAndName: Map; + typesByHash: Map; + basesByType: Map; + mroCache: Map; + } + ): Map { + const byBinding = new Map(); + const expressionByHash = new Map(); + for (const expression of module.expressions) { + expressionByHash.set(expression.getHash(), expression); + } + + for (const expression of module.expressions) { + if (expression.getEdgeRole() !== PythonEdgeRole.ASSIGNMENT_TARGET) { + continue; + } + if (expression.getKind() !== PythonExpressionKind.NAME_REFERENCE) { + continue; + } + // Depth 1, not 0: an assignment target is now a CHILD of the ASSIGNMENT + // node rather than a root of its own. A depth-0 filter silently matched + // nothing after that change and every local went untyped — the kind of + // regression a resolution count catches and a structural invariant does + // not, since the tree was still perfectly well-formed. + if (expression.getDepth() !== 1) { + continue; + } + const binding = expression.getBindingLinkHash(); + if (binding === '') { + continue; + } + const value = this.assignedValueFor(expression, module, expressionByHash); + const resolved = value ? this.typeOfAssignedValue(value, ctx) : null; + if (byBinding.has(binding)) { + // A second assignment. Agreeing is fine; disagreeing makes the name + // untyped rather than whichever came first. + if (byBinding.get(binding) !== resolved) { + byBinding.set(binding, null); + } + continue; + } + byBinding.set(binding, resolved); + } + return byBinding; + } + + /** The `ASSIGNMENT_VALUE` sibling of an assignment target. */ + /** + * Locals that are ALIASES for a callable, mapped to the entity they name. + * + * `_Row = Row` then `_Row(metadata, ...)`. This is a different question from + * local TYPE inference and I had built only the latter: type inference answers + * "what does this local HOLD", and an alias needs "what entity does this NAME + * REFER TO". CPython settles which case applies — `_Row(1)` compiles to + * LOAD_FAST, so the callee is the local binding rather than the global it was + * copied from. + * + * Only a bare NAME on the right-hand side counts. `x = foo()` binds the RESULT + * of a call, not an alias for `foo`, and conflating the two would send every + * call through `x` to the wrong entity. Any other assignment to the same name + * disqualifies it, since a local reassigned elsewhere is no longer reliably + * that entity. + */ + private buildLocalAliasIndex( + module: ResolutionInput, + ctx: { + entityByBinding: Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + } + ): Map { + const aliases = new Map(); + const expressionByHash = new Map(); + for (const expression of module.expressions) { + expressionByHash.set(expression.getHash(), expression); + } + const rejected = new Set(); + + for (const expression of module.expressions) { + if (expression.getEdgeRole() !== PythonEdgeRole.ASSIGNMENT_TARGET) { + continue; + } + if (expression.getKind() !== PythonExpressionKind.NAME_REFERENCE) { + continue; + } + const binding = expression.getBindingLinkHash(); + if (binding === '' || rejected.has(binding)) { + continue; + } + const value = this.assignedValueFor(expression, module, expressionByHash); + const target = + value && value.getKind() === PythonExpressionKind.NAME_REFERENCE + ? this.lookupInScopeChain(value.getLiteralValue(), value.getPyScopeLinkHash(), ctx) + : null; + if (!target) { + rejected.add(binding); + aliases.delete(binding); + continue; + } + const incumbent = aliases.get(binding); + if (incumbent && incumbent !== target) { + rejected.add(binding); + aliases.delete(binding); + continue; + } + aliases.set(binding, target); + } + return aliases; + } + /** + * The `ASSIGNMENT_VALUE` expression whose value flows into this target. + * + * Uses the EXACT pairing the expression stage recorded while both nodes were + * in hand. The previous version matched on (scope, line), which is a guess: + * `a = f(); b = g()` on one line pairs both targets with the first value, and + * a value continued onto the next line pairs with nothing. + */ + private assignedValueFor( + target: PyExpressionRegistry, + module: ResolutionInput, + expressionByHash: Map + ): PyExpressionRegistry | null { + const pairing = module.assignedValueByTargetRange; + const byRange = module.expressionByByteRange; + if (!pairing || !byRange) { + return null; + } + const targetRange = module.byteRangeByExpression?.get(target.getHash()); + if (!targetRange) { + return null; + } + const valueRange = pairing.get(targetRange); + if (!valueRange) { + return null; + } + const valueHash = byRange.get(valueRange); + return valueHash ? expressionByHash.get(valueHash) ?? null : null; + } + + /** + * Infers each method's return type from its `return` statements. + * + * Only when EVERY return whose type is derivable agrees on one class. A + * function with `return Registry()` on one branch and `return None` on another + * is not a `Registry`, and a caller that treats it as one gets a wrong edge on + * exactly the path where the value is absent — so disagreement refuses. + * + * Two passes, because a factory frequently returns the result of another + * factory. Two is enough for the common chain and stops well short of the + * whole-program fixpoint that belongs to the engine. + */ + private buildReturnTypeIndex( + module: ResolutionInput, + ctx: { + returnedTypeByMethod?: Map; + entityByBinding?: Map; + bindingByScopeAndName?: Map; + parentScopeOf?: Map; + typesByName: Map; + moduleMethodsByName?: Map; + methodsByTypeAndName: Map; + typesByHash: Map; + basesByType: Map; + mroCache: Map; + } + ): Map { + const byMethod = new Map(); + const returnValues = module.expressions.filter( + expression => + expression.getRootContext() === PythonRootContext.RETURN_VALUE && + expression.getDepth() === 0 + ); + for (let pass = 0; pass < 2; pass += 1) { + const working = new Map(byMethod); + for (const value of returnValues) { + const owner = value.getExpressionOwnerHash(); + if (owner === '') { + continue; + } + const resolved = this.typeOfAssignedValue(value, { + ...ctx, + returnedTypeByMethod: working, + }); + if (resolved === null) { + // A return this pass cannot type says nothing either way; only a + // CONFLICT between two typed returns makes the method untyped. + continue; + } + if (byMethod.has(owner) && byMethod.get(owner) !== resolved) { + byMethod.set(owner, null); + continue; + } + byMethod.set(owner, resolved); + } + } + return byMethod; + } + + /** The class an assigned expression produces, or `null`. */ + private typeOfAssignedValue( + value: PyExpressionRegistry, + ctx: { + entityByBinding?: Map; + bindingByScopeAndName?: Map; + parentScopeOf?: Map; + typesByName: Map; + moduleMethodsByName?: Map; + methodsByTypeAndName: Map; + typesByHash: Map; + basesByType: Map; + mroCache: Map; + returnedTypeByMethod?: Map; + } + ): PyTypeRegistry | null { + // `return self` types the method as its own class. This is the fluent-API + // shape — `def add(self, x): ...; return self` — and without it a chained + // call like `node.add(x).count()` has no receiver type even though the + // answer is written in the method. + if (value.getKind() === PythonExpressionKind.SELF_REFERENCE) { + const enclosing = value.getPyTypeLinkHash(); + return enclosing === '' ? null : ctx.typesByHash.get(enclosing) ?? null; + } + if (value.getKind() !== PythonExpressionKind.CALL) { + return null; + } + const callee = value.getLiteralValue(); + if (callee === '') { + return null; + } + // `cls(...)` inside a classmethod constructs the class it was called on, + // which for a single-class analysis is the enclosing class. This is how a + // classmethod factory is written — `return cls(name)` — so without it every + // `Builder.of(...)` result stays untyped and the call on it unresolved. + if (callee === 'cls' && value.getPyTypeLinkHash() !== '') { + return ctx.typesByHash.get(value.getPyTypeLinkHash()) ?? null; + } + + // `x = Foo()` — calling a class yields an instance of it. + const asClass = this.resolveDottedTypeName(callee, { + typesByName: ctx.typesByName, + qualifiedSuffixIndex: this.qualifiedSuffixIndex, + typesByNameByModuleName: this.typesByNameByModuleName, + }); + if (asClass) { + return asClass; + } + // `x = make_foo()` — a factory. Resolved through the SCOPE CHAIN rather than + // the current module's functions, because the factory is usually imported: + // `from core.base import make_registry` binds it here, and a module-local + // lookup finds nothing. The scope chain already knows about import bindings, + // so this reuses the same path a bare call would take. + const viaScope = + ctx.entityByBinding && ctx.bindingByScopeAndName && ctx.parentScopeOf + ? this.lookupInScopeChain(callee, value.getPyScopeLinkHash(), { + entityByBinding: ctx.entityByBinding, + bindingByScopeAndName: ctx.bindingByScopeAndName, + parentScopeOf: ctx.parentScopeOf, + }) + : null; + if (viaScope instanceof PyTypeRegistry) { + return viaScope; + } + if (viaScope) { + return this.returnedTypeOf(viaScope, ctx); + } + const factory = ctx.moduleMethodsByName?.get(callee.split('.').pop() ?? ''); + if (factory) { + return this.returnedTypeOf(factory, ctx); + } + const segments = value.getDottedPath().split('.'); + const bare = callee.split('.').pop() ?? ''; + + // `x = self.make_foo()` / `x = cls.make_foo()` — a method on the enclosing + // class. A factory method is the usual way a class hands out helpers. + const ownerType = value.getPyTypeLinkHash(); + if (ownerType !== '' && segments.length === 2) { + const receiver = segments[0] ?? ''; + // `cls(...)` constructs the enclosing class itself. + if (receiver === 'cls' && bare === 'cls') { + return ctx.typesByHash.get(ownerType) ?? null; + } + const method = this.lookupMethodOnTypeAndBases(ownerType, bare, ctx); + if (method) { + return this.returnedTypeOf(method, ctx); + } + } + + // `x = Builder.of(...)` — a classmethod on a NAMED class. Its return is + // typed the same way any other method's is. + if (segments.length === 2) { + const onClass = this.resolveDottedTypeName(segments[0] ?? '', { + typesByName: ctx.typesByName, + qualifiedSuffixIndex: this.qualifiedSuffixIndex, + typesByNameByModuleName: this.typesByNameByModuleName, + }); + if (onClass) { + const method = this.lookupMethodOnTypeAndBases(onClass.getHash(), bare, ctx); + if (method) { + // A classmethod returning `cls(...)` returns the class it was called + // ON, which is what makes `Builder.of(...)` a Builder. + return this.returnedTypeOf(method, ctx) ?? null; + } + } + } + return null; + } + + /** + * Follows a RE-EXPORT to the module that actually declares the name. + * + * A package `__init__.py` that says `from .runner import Runner` does not + * declare `Runner`; it re-binds it. So `from framework import Runner` + * elsewhere resolves to a module and stops, and every call through that name + * stays unresolved even though the class is right there in the analysis. This + * is the same shape as `unittest.TestCase`, and it is how most packages present + * their public API. + * + * The walk is bounded and refuses on a cycle rather than looping, since + * `a` importing from `b` importing from `a` is legal enough to parse. + */ + private followReExport( + member: string, + fromModule: ProjectModuleFacts, + exportsByModule: Map>, + moduleByQualifiedName: Map + ): PyMethodRegistry | PyTypeRegistry | undefined { + let current: ProjectModuleFacts | undefined = fromModule; + const visited = new Set(); + for (let hop = 0; hop < 8 && current; hop += 1) { + if (visited.has(current.qualifiedName)) { + return undefined; + } + visited.add(current.qualifiedName); + + const record = current.imports.find( + candidate => + !candidate.getIsModuleImport() && + !candidate.getIsWildcard() && + candidate.getSimpleName() === member + ); + if (!record) { + return undefined; + } + const nextName = this.importTargetModule( + record, + current.qualifiedName, + current.isPackage === true + ); + const next = nextName === null ? undefined : this.findModule(nextName, moduleByQualifiedName); + if (!next) { + return undefined; + } + const original = record.getOriginalName().split('.').pop() ?? member; + const found = exportsByModule.get(next.qualifiedName)?.get(original); + if (found) { + return found; + } + current = next; + } + return undefined; + } + + /** + * Finds the module an import names — the package-discovery step. + * + * Python resolves `import framework` against `sys.path`, so the name is + * relative to wherever the package ROOT sits. The analyser has no sys.path, and + * a module's qualified name depends on where analysis started: rooting at a + * directory that is itself a package makes every module carry that package's + * name, so `framework` is registered as `myproject.framework` and an exact + * match fails. That single mismatch left the base of every cross-package + * subclass unresolved, and with it every `self.method()` inherited from it. + * + * The fix mirrors what already works for types: match on any dotted SUFFIX of + * a module's qualified name, with collisions refusing rather than guessing. + * `framework` finds `myproject.framework`; `case` finds + * `myproject.framework.case`; and if two packages both contain `utils`, the + * bare name refuses while `framework.utils` still resolves. + * + * Dropping leading segments of the SEARCHED name is kept as a second step, for + * the mirror-image case where the import is more qualified than the module — + * `import myproject.framework` when analysis was rooted inside `myproject`. + */ + private findModule( + name: string, + moduleByQualifiedName: Map + ): ProjectModuleFacts | undefined { + const exact = moduleByQualifiedName.get(name); + if (exact) { + return exact; + } + + const bySuffix = this.moduleSuffixIndex.get(name); + if (bySuffix) { + return bySuffix; + } + + const parts = name.split('.'); + for (let drop = 1; drop < parts.length; drop++) { + const candidate = parts.slice(drop).join('.'); + const direct = moduleByQualifiedName.get(candidate); + if (direct) { + return direct; + } + const suffixed = this.moduleSuffixIndex.get(candidate); + if (suffixed) { + return suffixed; + } + } + return undefined; + } + + /** + * Registers every module under every dotted suffix of its qualified name. + * + * Collisions map to `null` so an ambiguous short name refuses while the longer, + * unambiguous one still resolves — the same rule the type index uses, for the + * same reason: a guess here silently attaches a subclass to the wrong base. + */ + private buildModuleSuffixIndex(modules: ProjectModuleFacts[]): void { + this.moduleSuffixIndex = new Map(); + const seen = new Map(); + for (const module of modules) { + const segments = module.qualifiedName.split('.'); + for (let start = segments.length - 1; start >= 0; start -= 1) { + const suffix = segments.slice(start).join('.'); + if (seen.has(suffix)) { + if (seen.get(suffix) !== module) { + seen.set(suffix, null); + } + continue; + } + seen.set(suffix, module); + } + } + for (const [suffix, module] of seen) { + if (module) { + this.moduleSuffixIndex.set(suffix, module); + } + } + } + + private importTargetModule( + record: PyImportRegistry, + importingModule: string, + importingIsPackage = false + ): string | null { + const level = record.getRelativeLevel(); + const stated = record.getIsModuleImport() + ? record.getImportedPath() + : record.getPackageOrTypeName(); + + if (level === 0) { + return stated === '' ? null : stated; + } + // The importing module's own package, then up (level - 1) more. A package's + // `__init__.py` IS its package, so nothing is dropped for it. + const parts = importingModule.split('.'); + if (!importingIsPackage) { + parts.pop(); + } + for (let i = 1; i < level; i++) { + parts.pop(); + } + const base = parts.join('.'); + if (stated === '') { + return base === '' ? null : base; + } + return base === '' ? stated : `${base}.${stated}`; + } + + /** Resolves within one module. Returns the number of call sites resolved. */ + link(input: ResolutionInput): number { + const typesByHash = new Map(input.types.map(t => [t.getHash(), t])); + const typesByName = this.uniqueByName(input.types, t => t.getName()); + // Single-file: only this module's types are visible, so the suffix index + // answers nested names (`TopOne.Inner`) and nothing cross-module. + this.qualifiedSuffixIndex = this.buildQualifiedSuffixIndex(input.types); + this.typesByNameByModuleName = new Map(); + + // Bases first: MRO resolution depends on them. + this.resolveTypeBases(input, typesByName); + this.resolveAnnotations(input, typesByName); + this.resolveTypeReferences(input, typesByName); + + const basesByType = new Map(); + for (const base of input.typeBases) { + const list = basesByType.get(base.getPyTypeLinkHash()) ?? []; + list.push(base); + basesByType.set(base.getPyTypeLinkHash(), list); + } + + // name -> the single method of that name on a given type + const methodsByTypeAndName = new Map(); + for (const method of input.methods) { + const key = `${method.getPyTypeLinkHash()}::${method.getName()}`; + const list = methodsByTypeAndName.get(key) ?? []; + list.push(method); + methodsByTypeAndName.set(key, list); + } + + // A binding's declared entity, so a lexically-bound name resolves to the + // exact def or class that bound it rather than to a same-named lookalike. + const entityByBinding = new Map(); + for (const method of input.methods) { + const binding = method.getDeclaringBindingLinkHash(); + if (binding !== '') { + entityByBinding.set(binding, method); + } + } + for (const type of input.types) { + const binding = type.getDeclaringBindingLinkHash(); + if (binding !== '') { + entityByBinding.set(binding, type); + } + } + + const bindingByScopeAndName = new Map(); + for (const binding of input.bindings) { + bindingByScopeAndName.set(`${binding.getPyScopeLinkHash()}::${binding.getName()}`, binding); + } + const parentScopeOf = new Map(); + for (const scope of input.scopes) { + parentScopeOf.set(scope.getHash(), scope.getParentScopeLinkHash()); + } + // Names this module genuinely BINDS. A merely-referenced name is not a + // shadow, so `sorted` stays a builtin unless the module really defines one. + const boundNames = new Set( + input.bindings.filter(b => b.isBound()).map(b => b.getName()) + ); + const importedModuleNames = new Set( + input.imports.filter(i => i.getIsModuleImport()).map(i => i.getSimpleName()) + ); + + this.linkNameReferences(input, { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + }); + this.resolveDecorators(input, { + typesByName, + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + }); + + const mroCache = new Map(); + + // Attributes, indexed the way the schema says to join them: §2.10 deleted + // `py_field_write` on the grounds that linking a write to its merged field + // row is a resolution rule over `(pyTypeLinkHash, name)`, not a stored fact. + const fieldByTypeAndName = new Map(); + // See the project pass: an attribute usually has more than one row, and the + // annotation may be on a different row from the assignment. + const fieldsByTypeAndName = new Map(); + for (const field of input.fields) { + const key = `${field.getPyTypeLinkHash()}||${field.getName()}`; + fieldsByTypeAndName.set(key, [...(fieldsByTypeAndName.get(key) ?? []), field]); + const incumbent = fieldByTypeAndName.get(key); + // A class attribute and an instance attribute can share a name. The + // instance one wins, because that is what a read through a receiver + // actually reaches once `__init__` has run. + if (!incumbent || incumbent.getFieldModifier().includes('CLASS_VAR')) { + fieldByTypeAndName.set(key, field); + } + } + + // Parameter flow and factory returns must work in single-file extraction too, + // not only in the project pass. They were project-only, which meant the + // commonest attribute shape of all — `self.pool = pool` with `pool: Pool` one + // line above — resolved for a directory and not for a file. + const parametersByMethod = new Map(); + for (const parameter of input.methodParameters) { + const list = parametersByMethod.get(parameter.getPyMethodLinkHash()) ?? []; + list.push(parameter); + parametersByMethod.set(parameter.getPyMethodLinkHash(), list); + } + const moduleMethodsByName = this.uniqueByName( + input.methods.filter(m => m.getPyTypeLinkHash() === ''), + m => m.getName() + ); + + this.linkAttributeExpressionsToFields(input, { + typesByHash, + basesByType, + methodsByTypeAndName, + mroCache, + fieldByTypeAndName, + }); + + const returnedTypeByMethod = this.buildReturnTypeIndex(input, { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + typesByName, + moduleMethodsByName, + methodsByTypeAndName, + typesByHash, + basesByType, + mroCache, + }); + const localTypeByBinding = this.buildLocalTypeIndex(input, { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + returnedTypeByMethod, + typesByName, + moduleMethodsByName, + methodsByTypeAndName, + typesByHash, + basesByType, + mroCache, + }); + + let resolved = 0; + for (const callSite of input.callSites) { + const target = this.resolveCallSite(callSite, { + typesByHash, + typesByName, + basesByType, + methodsByTypeAndName, + mroCache, + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + boundNames, + importedModuleNames, + fieldByTypeAndName, + fieldsByTypeAndName, + receiverNameByMethodHash: input.receiverNameByMethodHash, + parametersByMethod, + moduleMethodsByName, + localTypeByBinding, + returnedTypeByMethod, + }); + if (target) { + callSite.setResolvedCallee(target.kind, target.hash); + resolved += 1; + } + } + return resolved; + } + + /** + * `py_type_base.resolvedTypeLinkHash` for bases naming a class in this module. + * + * Only a name-shaped base with exactly one same-named class in the module + * resolves. A computed base (`class D(factory())`) or an ambiguous name does + * not, which keeps `isResolvedLocally` meaning what it says. + */ + private resolveTypeBases( + input: ResolutionInput, + typesByName: Map + ): void { + for (const base of input.typeBases) { + if (base.getKeywordName() !== '' || base.getIsDynamic()) { + continue; + } + const simpleName = base.getBaseSimpleName(); + if (simpleName === '') { + continue; + } + // A dotted base (`class T(unittest.TestCase)`) is resolved by WALKING the + // segments, not by matching its rightmost one against local classes — + // which would claim a local `TestCase` that has nothing to do with it. This + // used to be skipped outright, and the cost was measured: `unittest.TestCase` + // unresolved 244 times, taking 3,201 self.assertEqual-style calls with it, + // since a method is only reachable through the MRO once the base resolves. + const dotted = base.getBaseDottedPath(); + const target = dotted.includes('.') + ? this.resolveDottedTypeName(dotted, { + typesByName, + qualifiedSuffixIndex: this.qualifiedSuffixIndex, + typesByNameByModuleName: this.typesByNameByModuleName, + }) + : typesByName.get(simpleName); + if (target && target.getHash() !== base.getPyTypeLinkHash()) { + base.setResolution(target.getHash(), true); + } + } + } + + private resolveCallSite( + callSite: PyCallSiteRegistry, + ctx: { + typesByHash: Map; + typesByName: Map; + basesByType: Map; + methodsByTypeAndName: Map; + mroCache: Map; + entityByBinding: Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + boundNames: Set; + importedModuleNames: Set; + fieldByTypeAndName: Map; + /** + * All fields of a name; `fieldByTypeAndName` keeps only the winner. + * Both are read — the singular for MRO attribute lookup, the plural where + * every declaration matters — and only the singular was declared. + */ + fieldsByTypeAndName?: Map; + receiverNameByMethodHash: Map; + fieldTypeByHash?: Map; + parametersByMethod?: Map; + moduleMethodsByName?: Map; + /** Bound module name -> that module's facts, for in-project imports. */ + importedModules?: Map; + exportsByModule?: Map>; + moduleByQualifiedName?: Map; + localTypeByBinding?: Map; + returnedTypeByMethod?: Map; + innerCallReturnType?: Map; + } + ): { kind: PythonResolvedCalleeKind; hash: string } | null { + const name = callSite.getCalleeName(); + if (name === '') { + return null; + } + + switch (callSite.getReceiverKind()) { + case PythonReceiverKind.NONE: { + // `cls(...)` inside a classmethod constructs the enclosing class. + if (name === 'cls' && callSite.getPyTypeLinkHash() !== '') { + const enclosing = ctx.typesByHash.get(callSite.getPyTypeLinkHash()); + if (enclosing) { + return { kind: PythonResolvedCalleeKind.TYPE, hash: enclosing.getHash() }; + } + } + + // A bare name: the scope chain decides, and it decides exactly. + const entity = this.lookupInScopeChain(name, callSite.getPyScopeLinkHash(), ctx); + if (entity) { + return this.describeEntity(entity); + } + // Only a builtin if nothing in this module binds the name at all — + // otherwise a local `def list(...)` would be mistaken for the builtin. + if (!ctx.boundNames.has(name) && CALLABLE_BUILTINS.has(name)) { + return { kind: PythonResolvedCalleeKind.BUILTIN, hash: '' }; + } + return null; + } + + case PythonReceiverKind.SELF: + case PythonReceiverKind.CLS: { + const owner = callSite.getPyTypeLinkHash(); + if (owner === '') { + return null; + } + const method = this.lookupMethodOnTypeAndBases(owner, name, ctx); + return method ? { kind: PythonResolvedCalleeKind.METHOD, hash: method.getHash() } : null; + } + + case PythonReceiverKind.SUPER: { + // `super()` starts AFTER the enclosing class in the MRO, so the + // enclosing class's own method of that name is deliberately skipped — + // that is the whole point of super() and why `Child.describe` calling + // `super().describe()` must land on `Base.describe`, not itself. + const owner = callSite.getPyTypeLinkHash(); + if (owner === '') { + return null; + } + const method = this.lookupMethodOnBasesOnly(owner, name, ctx); + return method ? { kind: PythonResolvedCalleeKind.METHOD, hash: method.getHash() } : null; + } + + case PythonReceiverKind.NAME: { + // A receiver that names a LOCAL VARIABLE: `runner = Runner()` then + // `runner.run()`. The local's type comes from what was assigned to it, + // which is the same three grounds an attribute uses — a constructor, a + // factory's return annotation, or an annotation. This is the single + // largest unresolved bucket in real code, because most receivers are + // ordinary locals rather than `self` or a module. + const localType = + this.typeOfLocalReceiver(callSite, ctx) ?? + this.typeOfParameterReceiver(callSite, ctx); + if (localType) { + const onLocal = this.lookupMethodOnTypeAndBases(localType.getHash(), name, ctx); + if (onLocal) { + return { kind: PythonResolvedCalleeKind.METHOD, hash: onLocal.getHash() }; + } + } + + // A receiver that names a class in this module: `Base.make_default()`. + // The method may be inherited, so the local MRO is walked. + const receiver = callSite.getReceiverText(); + const type = receiver === '' ? null : ctx.typesByName.get(receiver); + if (!type) { + // A receiver naming a module. If that module is IN THIS ANALYSIS the + // target is a real entity, so the call gets a concrete hash rather than + // a bare `IMPORTED` — `events.get_event_loop()` inside asyncio reaches + // the actual function. Only when the module is genuinely outside does + // `IMPORTED` with an empty hash remain the honest answer: the fact that + // it is reached through an import is real, and a hash would be invented. + const inProject = receiver === '' ? undefined : ctx.importedModules?.get(receiver); + if (inProject) { + const member = this.lookupModuleMember( + inProject, + name, + ctx.exportsByModule, + ctx.moduleByQualifiedName + ); + if (member) { + return this.describeEntity(member); + } + } + if (receiver !== '' && ctx.importedModuleNames.has(receiver)) { + return { kind: PythonResolvedCalleeKind.IMPORTED, hash: '' }; + } + return null; + } + // A NESTED CLASS constructor: `TopOne.Inner()` names a type, not a + // method, so looking only for methods missed it entirely even though the + // same dotted name resolves fine in an annotation. + const nested = this.lookupNestedType(type, name, ctx); + if (nested) { + return { kind: PythonResolvedCalleeKind.TYPE, hash: nested.getHash() }; + } + const method = this.lookupMethodOnTypeAndBases(type.getHash(), name, ctx); + return method ? { kind: PythonResolvedCalleeKind.METHOD, hash: method.getHash() } : null; + } + + case PythonReceiverKind.ATTRIBUTE: { + return this.resolveAttributeReceiver(callSite, name, ctx); + } + + case PythonReceiverKind.CALL_RESULT: { + return this.resolveCallResultReceiver(callSite, name, ctx); + } + + // A SUBSCRIPT or UNKNOWN receiver needs an element type, which nothing in + // the emitted set carries: `handlers[key]()` reaches whatever was put into + // the container, and the container's writes are not tracked per element. + default: { + return null; + } + } + } + + + /** + * Resolves `self.conn.send()` — an ATTRIBUTE receiver. + * + * This was 0/2,537 before `py_field` existed, and the reason is worth stating + * precisely: the call is not hard to resolve, it was *missing a fact*. Reaching + * `send` needs the type of `conn`, and no relation carried it. + * + * The chain is three joins, and it refuses at every one it cannot make: + * + * 1. The receiver must be rooted at THIS method's receiver parameter, so + * `self.conn` counts and `other.conn` does not — the latter is an attribute + * of a class this call site knows nothing about. + * 2. The attribute must resolve to one `py_field` on the enclosing class or its + * MRO, which is where an inherited attribute is found. + * 3. That field must name exactly one type IN THIS MODULE, from its annotation + * or from a constructor initialiser. + * + * A receiver whose head is an imported module (`os.path.join`) is reported as + * `IMPORTED` with no hash, matching how a NAME receiver on a module is handled: + * the target is outside the analysis, so a hash would be invented, but "reached + * through an import" is a fact and beats silence. + */ + private resolveAttributeReceiver( + callSite: PyCallSiteRegistry, + calleeName: string, + ctx: { + typesByHash: Map; + typesByName: Map; + basesByType: Map; + methodsByTypeAndName: Map; + // Required by typeOfLocalReceiver, which this calls: typing a local + // receiver means finding its binding and walking out through scopes. + bindingByScopeAndName: Map; + parentScopeOf: Map; + mroCache: Map; + importedModuleNames: Set; + fieldByTypeAndName: Map; + fieldsByTypeAndName?: Map; + receiverNameByMethodHash: Map; + fieldTypeByHash?: Map; + parametersByMethod?: Map; + moduleMethodsByName?: Map; + importedModules?: Map; + exportsByModule?: Map>; + moduleByQualifiedName?: Map; + } + ): { kind: PythonResolvedCalleeKind; hash: string } | null { + const receiverText = callSite.getReceiverText(); + if (receiverText === '') { + return null; + } + const segments = receiverText.split('.'); + + // A dotted receiver naming a TYPE: `TopOne.Inner.Deepest()` constructs a + // doubly-nested class. The annotation path already walked these names; the + // CALL path did not, so the same name resolved in one position and not the + // other. + const receiverType = this.resolveDottedTypeName(receiverText, { + typesByName: ctx.typesByName, + qualifiedSuffixIndex: this.qualifiedSuffixIndex, + typesByNameByModuleName: this.typesByNameByModuleName, + }); + if (receiverType) { + const nested = this.lookupNestedType(receiverType, calleeName, ctx); + if (nested) { + return { kind: PythonResolvedCalleeKind.TYPE, hash: nested.getHash() }; + } + const onType = this.lookupMethodOnTypeAndBases(receiverType.getHash(), calleeName, ctx); + if (onType) { + return { kind: PythonResolvedCalleeKind.METHOD, hash: onType.getHash() }; + } + } + + // A dotted receiver whose head names an IN-PROJECT module: + // `pkg.mod.function()`. Resolvable to a real entity, unlike a stdlib path. + const headModule = ctx.importedModules?.get(segments[0] ?? ''); + if (headModule && segments.length === 2) { + const member = this.lookupModuleMember( + headModule, + segments[1] ?? '', + ctx.exportsByModule, + ctx.moduleByQualifiedName + ); + if (member instanceof PyTypeRegistry) { + const onMember = this.lookupMethodOnTypeAndBases(member.getHash(), calleeName, ctx); + if (onMember) { + return { kind: PythonResolvedCalleeKind.METHOD, hash: onMember.getHash() }; + } + } + } + + // The WHOLE receiver may name a module: `import pkg.mod` then + // `pkg.mod.Klass()` or `pkg.mod.func()`. + // + // The block above only tries `importedModules.get(segments[0])`, which for + // `import pkg.mod` binds the PACKAGE `pkg` — and `mod` is a submodule, not a + // member of the package's exports, so the lookup fails and the next line + // declares the whole thing external. It is not external at all: the module + // is right there in the corpus. `import pkg.mod` is one of the two ordinary + // ways to import, and every call through it was being written off. + // + // Tried longest-prefix-first so `a.b.c.D()` prefers module `a.b.c` over + // module `a.b` with an attribute walk, which is what Python itself does. + if (ctx.moduleByQualifiedName !== undefined) { + for (let take = segments.length; take >= 1; take -= 1) { + const candidate = segments.slice(0, take).join('.'); + const asModule = this.findModule(candidate, ctx.moduleByQualifiedName); + if (asModule === undefined) { + continue; + } + const remainder = segments.slice(take); + if (remainder.length === 0) { + const member = this.lookupModuleMember( + asModule, + calleeName, + ctx.exportsByModule, + ctx.moduleByQualifiedName + ); + if (member instanceof PyTypeRegistry) { + return { kind: PythonResolvedCalleeKind.TYPE, hash: member.getHash() }; + } + if (member instanceof PyMethodRegistry) { + return { kind: PythonResolvedCalleeKind.MODULE_FUNCTION, hash: member.getHash() }; + } + continue; + } + // `pkg.mod.Klass.method()` — the remainder names a type in that module + // and the callee is a method on it. + if (remainder.length === 1) { + const owner = this.lookupModuleMember( + asModule, + remainder[0] ?? '', + ctx.exportsByModule, + ctx.moduleByQualifiedName + ); + if (owner instanceof PyTypeRegistry) { + const onOwner = this.lookupMethodOnTypeAndBases(owner.getHash(), calleeName, ctx); + if (onOwner) { + return { kind: PythonResolvedCalleeKind.METHOD, hash: onOwner.getHash() }; + } + } + } + } + } + + // `os.path.join(...)` — the head names a module outside the analysis. + if (ctx.importedModuleNames.has(segments[0] ?? '')) { + return { kind: PythonResolvedCalleeKind.IMPORTED, hash: '' }; + } + + if (segments.length !== 2) { + // `self.a.b.c()` needs the type of `self.a.b`, which needs `self.a` first. + // Each hop multiplies the chance of a wrong answer, and the schema asks for + // a single DERIVABLE target — so the parser stops and the engine chains, + // which it can do because every individual hop is linked. + return null; + } + + // The type the receiver PREFIX holds. `self.x` uses the enclosing class; + // `builder.x` uses the type of the local `builder`, which is the same + // question one step removed. Handling only `self` meant an attribute of any + // other typed receiver was unreachable even when both hops were known. + const receiverName = ctx.receiverNameByMethodHash.get(callSite.getPyMethodLinkHash()); + const prefix = segments[0] ?? ''; + let ownerType = ''; + if (receiverName !== undefined && prefix === receiverName) { + ownerType = callSite.getPyTypeLinkHash(); + } else { + const prefixType = + this.typeOfLocalReceiver(callSite, ctx, prefix) ?? + this.typeOfParameterReceiver(callSite, ctx, prefix); + ownerType = prefixType ? prefixType.getHash() : ''; + } + if (ownerType === '') { + return null; + } + + const rows = this.lookupFieldRowsOnTypeAndBases(ownerType, segments[1] ?? '', ctx); + const field = rows[0] ?? this.lookupFieldOnTypeAndBases(ownerType, segments[1] ?? '', ctx); + if (!field) { + return null; + } + // Prefer the type settled in the DECLARING module's namespace; fall back to + // this module's only when the project pass has not run. Every row for the + // name is tried, because the annotation and the assignment are different + // rows and either may carry the answer. + let fieldType: PyTypeRegistry | null = null; + for (const row of rows.length > 0 ? rows : [field]) { + fieldType = ctx.fieldTypeByHash?.get(row.getHash()) ?? this.typeOfField(row, ctx); + if (fieldType) { + break; + } + } + if (fieldType) { + const method = this.lookupMethodOnTypeAndBases(fieldType.getHash(), calleeName, ctx); + return method ? { kind: PythonResolvedCalleeKind.METHOD, hash: method.getHash() } : null; + } + + // No project class, but the attribute may still hold a BUILTIN whose method + // set is known exactly. + // An attribute written with two different builtin types is not either of + // them. `self.result = []` here and `self.result = None` there means + // `self.result.append(x)` may well be an AttributeError at runtime, and + // reporting it as `list.append` would launder a bug into a fact. + const builtinType = field.getIsAmbiguous() + ? '' + : this.builtinTypeOfField(field, this.annotationFlowingInto(field, ctx)); + if (builtinType !== '') { + const members = PYTHON_BUILTIN_TYPE_METHODS.get(builtinType); + if (members?.has(calleeName)) { + return { kind: PythonResolvedCalleeKind.BUILTIN, hash: '' }; + } + } + return null; + } + + /** + * Maps each CALL_RESULT call site to the return type of its INNER call. + * + * Built after a first resolution pass, because it depends on the inner call + * already being resolved. The join is structural: the inner call is the + * RECEIVER child of the outer call in the expression tree, so this never + * matches on receiver text, which would conflate two identical calls on one + * line. + * + * This is the parser doing exactly ONE hop — inner callee to its declared or + * inferred return type — and no more. A longer chain stays for the engine, + * which can walk it precisely because each hop is linked. + */ + private buildInnerCallReturnIndex( + module: ResolutionInput, + methodByHash: Map, + returnedTypeByMethod: Map, + typesByHash: Map, + typesByName: Map, + typesByNameByModule: Map> + ): Map { + const index = new Map(); + const callSiteByExpression = new Map(); + for (const site of module.callSites) { + callSiteByExpression.set(site.getPyExpressionLinkHash(), site); + } + // `reg.first().label()` nests as CALL -> ATTRIBUTE_ACCESS(RECEIVER) -> + // CALL(ATTRIBUTE_OBJECT), so the inner call is a GRANDCHILD, not a child. + // Looking only one level down found nothing and the whole index came back + // empty — the rule was right and the tree walk was wrong. + const childrenByParent = new Map(); + for (const expression of module.expressions) { + const parent = expression.getParentExpressionHash(); + if (parent === '') { + continue; + } + const list = childrenByParent.get(parent) ?? []; + list.push(expression); + childrenByParent.set(parent, list); + } + const innerCallOf = (outerHash: string): PyExpressionRegistry | undefined => { + for (const child of childrenByParent.get(outerHash) ?? []) { + if (child.getEdgeRole() !== PythonEdgeRole.RECEIVER) { + continue; + } + if (child.getKind() === PythonExpressionKind.CALL) { + return child; + } + if (child.getKind() === PythonExpressionKind.ATTRIBUTE_ACCESS) { + for (const inner of childrenByParent.get(child.getHash()) ?? []) { + if (inner.getKind() === PythonExpressionKind.CALL) { + return inner; + } + } + } + } + return undefined; + }; + + for (const site of module.callSites) { + if (site.getReceiverKind() !== PythonReceiverKind.CALL_RESULT) { + continue; + } + const inner = innerCallOf(site.getPyExpressionLinkHash()); + if (!inner) { + continue; + } + const innerSite = callSiteByExpression.get(inner.getHash()); + const innerTarget = innerSite?.getResolvedCalleeHash() ?? ''; + if (innerTarget === '') { + continue; + } + // The inner call may construct a class, in which case the receiver IS that + // class; otherwise it is a method and the receiver is its return type. + const constructed = typesByHash.get(innerTarget); + if (constructed) { + index.set(site.getHash(), constructed); + continue; + } + const method = methodByHash.get(innerTarget); + if (!method) { + continue; + } + // The module's real name->type map, NOT an empty one. Passing an empty + // map meant an ANNOTATED return could never resolve — `-> "Node"` looked + // up `Node` in nothing and fell through to the inferred index, so the + // whole fluent-chain case failed for want of a parameter I had stubbed. + // + // And it must be the DECLARING module's map, not the caller's. An + // annotation is written in the scope of the method that carries it, so + // `-> "B"` on a method of p/b.py means p.b.B. A caller doing + // `from p.b import B as Alias` has no name `B` at all, so looking the + // annotation up in the CALLER's namespace failed for every aliased + // import — `Alias.of(1).describe()` broke while `B.of(1).describe()` + // worked, which is the same call reached by a different name. + const declaringModule = typesByNameByModule.get(method.getPyModuleLinkHash()); + const returned = + (declaringModule !== undefined + ? this.returnedTypeOf(method, { + typesByName: declaringModule, + returnedTypeByMethod, + }) + : null) ?? + this.returnedTypeOf(method, { + typesByName, + returnedTypeByMethod, + }); + if (returned) { + index.set(site.getHash(), returned); + } + } + return index; + } + + /** + * Resolves `make_conn().send()` — a CALL_RESULT receiver. + * + * Needs no relation that does not already exist: the inner call's callee has a + * `-> T` annotation, and `T` names a class. So the rule is to resolve the inner + * call FIRST, read its return annotation, and look the method up on that. + * + * The inner callee is resolved through the same machinery as any other name + * rather than matched textually, so `make_conn` means the `make_conn` this + * scope actually sees. If the callee has no return annotation the answer is + * refused: a function returning an unannotated value could return anything, and + * inferring it would require the whole-body return analysis that belongs to the + * engine. + */ + private resolveCallResultReceiver( + callSite: PyCallSiteRegistry, + calleeName: string, + ctx: { + typesByHash: Map; + typesByName: Map; + basesByType: Map; + methodsByTypeAndName: Map; + mroCache: Map; + entityByBinding: Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + innerCallReturnType?: Map; + } + ): { kind: PythonResolvedCalleeKind; hash: string } | null { + const receiverText = callSite.getReceiverText(); + + // `self._get_loop().create_future()` — the receiver is a call on THIS class, + // which is the commonest shape of all: 6 of asyncio's 12 CALL_RESULT sites. + // The inner method is found on the MRO, so an inherited one works too. + const selfMethod = /^(?:self|cls)\.([A-Za-z_][A-Za-z0-9_]*)\(/.exec(receiverText); + if (selfMethod) { + const owner = callSite.getPyTypeLinkHash(); + if (owner === '') { + return null; + } + const innerMethod = this.lookupMethodOnTypeAndBases(owner, selfMethod[1]!, ctx); + if (!innerMethod) { + return null; + } + const returnedFromSelf = this.returnedTypeOf(innerMethod, ctx); + if (!returnedFromSelf) { + return null; + } + const found = this.lookupMethodOnTypeAndBases(returnedFromSelf.getHash(), calleeName, ctx); + return found ? { kind: PythonResolvedCalleeKind.METHOD, hash: found.getHash() } : null; + } + + // The receiver is a call that THIS analysis already resolved: `reg.first()` + // in `reg.first().label()`. Rather than re-deriving the receiver's type, + // take the inner call's own resolved callee and read its return type — one + // hop off a link that already exists. The inner site is found through the + // expression tree, where it is the RECEIVER child of the outer call, so the + // join is structural rather than a text match on the receiver. + if (ctx.innerCallReturnType) { + const chained = ctx.innerCallReturnType.get(callSite.getHash()); + if (chained) { + const found = this.lookupMethodOnTypeAndBases(chained.getHash(), calleeName, ctx); + if (found) { + return { kind: PythonResolvedCalleeKind.METHOD, hash: found.getHash() }; + } + return null; + } + } + + // Otherwise only a direct `name()` receiver. `a.b()` and `f()()` need a + // receiver type this rule has not established, and each extra hop compounds + // the chance of a wrong answer. + const match = /^([A-Za-z_][A-Za-z0-9_]*)\(/.exec(receiverText); + if (!match) { + return null; + } + const innerName = match[1]!; + const inner = this.lookupInScopeChain(innerName, callSite.getPyScopeLinkHash(), ctx); + if (!inner) { + return null; + } + // `Conn().send()` — the inner name is a CLASS, so the receiver is an instance + // of it. This is the one case needing no return annotation at all, because + // calling a class always yields an instance of that class. + if (inner instanceof PyTypeRegistry) { + const method = this.lookupMethodOnTypeAndBases(inner.getHash(), calleeName, ctx); + return method ? { kind: PythonResolvedCalleeKind.METHOD, hash: method.getHash() } : null; + } + const returned = this.returnedTypeOf(inner, ctx); + if (!returned) { + return null; + } + const method = this.lookupMethodOnTypeAndBases(returned.getHash(), calleeName, ctx); + return method ? { kind: PythonResolvedCalleeKind.METHOD, hash: method.getHash() } : null; + } + + /** + * The class a method's `-> T` annotation names, when it names one. + * + * `Optional[Conn]` and `Conn | None` both resolve to `Conn`: a call on the + * result may raise at runtime if it is `None`, but the only class involved is + * `Conn`, and refusing here would lose the common annotated case for a reason + * that belongs to null analysis rather than to type resolution. + */ + private returnedTypeOf( + method: PyMethodRegistry, + ctx: { + typesByName: Map; + returnedTypeByMethod?: Map; + } + ): PyTypeRegistry | null { + const annotation = method.getReturnTypeName(); + if (annotation === '') { + // No `-> T`. The RETURN STATEMENT can still settle it: `def make_registry(): + // return Registry()` says what it hands back as plainly as an annotation + // would. This matters far more than the annotated case on real code — the + // CPython stdlib annotates 0.0% of returns, so annotation-only return + // typing reads nothing there. + return ctx.returnedTypeByMethod?.get(method.getHash()) ?? null; + } + for (const candidate of this.namedTypesIn(annotation)) { + const resolved = ctx.typesByName.get(candidate); + if (resolved) { + return resolved; + } + } + return null; + } + + /** + * The class names in an annotation, outermost first. + * + * `Optional[Conn]` yields `Optional`, `Conn`; the first that resolves to a real + * class wins, which lands on `Conn` because `Optional` is not a class in the + * analysed code. A container annotation such as `List[Conn]` correctly yields + * nothing: the call is on the LIST, not on a `Conn`. + */ + private namedTypesIn(annotation: string): string[] { + // A PEP 484 forward reference keeps its quotes in the annotation text: + // `def add(self, child) -> "Node"`. Splitting without stripping them left + // every segment quoted, so `"Node"` never matched the class `Node` and the + // whole fluent-API shape stayed unresolved — `node.add(node).name()` and + // every classmethod factory declared `-> "Builder"`. The single-segment + // dotted resolver already stripped quotes; this path did not. + const heads = annotation + .replace(/['"]/g, '') + .split(/[\[\],|]/) + .map(part => (part.split('.').pop() ?? '').trim()) + .filter(part => part !== '' && part !== 'None'); + const containers = new Set([ + 'List', 'Dict', 'Set', 'Tuple', 'FrozenSet', 'Sequence', 'Iterable', + 'Iterator', 'Generator', 'Mapping', 'MutableMapping', 'Awaitable', + 'Coroutine', 'AsyncIterator', 'AsyncGenerator', + 'list', 'dict', 'set', 'tuple', 'frozenset', + ]); + if (heads.length > 0 && containers.has(heads[0]!)) { + return []; + } + return heads; + } + + /** + * A module-level function or class of the given name, when exactly one exists. + * + * Only module-level entities count: a method of some class in that module is + * not reachable as `module.name`, and a nested function is not either. + */ + private lookupModuleMember( + module: ProjectModuleFacts, + name: string, + exportsByModule?: Map>, + moduleByQualifiedName?: Map + ): PyMethodRegistry | PyTypeRegistry | null { + const methods = module.methods.filter( + m => + m.getName() === name && + m.getPyTypeLinkHash() === '' && + m.getEnclosingMemberLinkHash() === '' + ); + const types = module.types.filter( + t => + t.getName() === name && + t.getEnclosingTypeLinkHash() === '' && + t.getEnclosingMethodLinkHash() === '' + ); + if (methods.length + types.length === 1) { + return methods[0] ?? types[0] ?? null; + } + if (methods.length + types.length > 1) { + return null; + } + // Nothing DECLARED under that name — but a package almost never declares its + // public API, it re-exports it. `util.warn(...)` reaches + // sqlalchemy/util/__init__.py, which imports `warn` from langhelpers, so a + // declaration-only lookup finds nothing for the commonest shape in a large + // codebase. This is the same re-export walk imports already use; it simply + // was not reached from here. + if (exportsByModule && moduleByQualifiedName) { + return ( + this.followReExport(name, module, exportsByModule, moduleByQualifiedName) ?? null + ); + } + return null; + } + + /** + * A class nested directly inside another, by name. + * + * `TopOne.Inner()` is a constructor call on a nested class. The qualified-name + * suffix index answers it directly, but the enclosing-type check is what keeps + * it honest: it must be nested in THIS class, not merely share a suffix with + * something else. + */ + private lookupNestedType( + outer: PyTypeRegistry, + name: string, + ctx: { typesByHash: Map } + ): PyTypeRegistry | null { + for (const candidate of ctx.typesByHash.values()) { + if ( + candidate.getName() === name && + candidate.getEnclosingTypeLinkHash() === outer.getHash() + ) { + return candidate; + } + } + return null; + } + + /** + * Finds an attribute on a class or anything it inherits from. + * + * Walks the MRO rather than the class alone, because an attribute set in a + * base's `__init__` is every subclass's attribute too — that is the ordinary + * case in a class hierarchy, not an edge one. + */ + private lookupFieldOnTypeAndBases( + typeHash: string, + attributeName: string, + ctx: { + typesByHash: Map; + basesByType: Map; + // Required by MroContext, which linearize() takes. Declared here rather + // than made optional there: one MRO consumer reads it, and the cluster + // that passes MroContext around needs it to stay required. + methodsByTypeAndName: Map; + mroCache: Map; + fieldByTypeAndName: Map; + } + ): PyFieldRegistry | null { + if (attributeName === '') { + return null; + } + // A class is always first in its own MRO — see lookupFieldRowsOnTypeAndBases. + const own = ctx.fieldByTypeAndName.get(`${typeHash}||${attributeName}`); + if (own) { + return own; + } + const mro = this.linearize(typeHash, ctx, new Set()); + if (!mro) { + return null; + } + for (const candidate of mro) { + const field = ctx.fieldByTypeAndName.get(`${candidate}||${attributeName}`); + if (field) { + return field; + } + } + return null; + } + + /** + * Every `py_field` row for an attribute name, nearest declaring class first. + * + * An attribute commonly has more than one row, because `fieldOrigin` is part + * of its identity: `dialect: Dialect` in the class body and + * `self.dialect = ...` in `__init__` are two facts about one attribute. Typing + * has to see BOTH — the annotation is on one and the assignment on the other — + * so a lookup that returns only the preferred row can find an attribute and + * still fail to type it. + */ + private lookupFieldRowsOnTypeAndBases( + typeHash: string, + attributeName: string, + ctx: { + typesByHash: Map; + basesByType: Map; + // Required by MroContext, which linearize() takes. Declared here rather + // than made optional there: one MRO consumer reads it, and the cluster + // that passes MroContext around needs it to stay required. + methodsByTypeAndName: Map; + mroCache: Map; + fieldsByTypeAndName?: Map; + } + ): PyFieldRegistry[] { + if (attributeName === '' || !ctx.fieldsByTypeAndName) { + return []; + } + // A class is always FIRST in its own MRO, so its own attributes need no + // linearisation and cannot be shadowed by an opaque base. Requiring the MRO + // here refused every `self..m()` in a class with ANY unresolvable base + // — and `class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"])` + // is the ordinary shape in real code, not an edge case. The identical fix + // was already made for METHOD lookup; fields never got it, which is why + // ATTRIBUTE resolution read 0 of 227 on SQLAlchemy's engine package. + const own = ctx.fieldsByTypeAndName.get(`${typeHash}||${attributeName}`); + if (own && own.length > 0) { + return this.annotatedFirst(own); + } + + const mro = this.linearize(typeHash, ctx, new Set()); + if (!mro) { + return []; + } + for (const candidate of mro) { + const rows = ctx.fieldsByTypeAndName.get(`${candidate}||${attributeName}`); + if (rows && rows.length > 0) { + return this.annotatedFirst(rows); + } + } + return []; + } + + /** + * Orders field rows so an ANNOTATED one is tried first. + * + * Which row a READ reaches at runtime is a different question from which row + * states the type. `dialect: Dialect` in the class body says what the + * attribute holds; `self.dialect = ...` in `__init__` says when it is set. + */ + private annotatedFirst(rows: PyFieldRegistry[]): PyFieldRegistry[] { + return [...rows].sort((left, right) => { + const l = left.getFieldTypeName() === '' ? 1 : 0; + const r = right.getFieldTypeName() === '' ? 1 : 0; + return l - r; + }); + } + + /** + * The type an attribute holds, when the source says so unambiguously. + * + * Two grounds are admitted, in order of strength. An ANNOTATION is the + * programmer stating the type. A CONSTRUCTOR INITIALISER is stronger still in + * one respect — `self.buf = Buffer()` cannot hold anything else at that + * point — but only if the name resolves to a class in this module; if it + * resolves to nothing, or to a function, it is a factory whose return type is + * unknown and this refuses. + * + * A literal initialiser (`self.items = []`) types the attribute as a BUILTIN, + * which is real information and is recorded on the expression row, but it + * yields no `py_type` to look a method up on, so it cannot resolve a call here. + */ + private typeOfField( + field: PyFieldRegistry, + ctx: { + typesByName: Map; + parametersByMethod?: Map; + moduleMethodsByName?: Map; + } + ): PyTypeRegistry | null { + // `Optional[Conn]` and `Conn | None` must reach `Conn`, so the whole + // annotation is scanned rather than just its head. A container annotation + // such as `List[Conn]` correctly yields nothing here: a call on the attribute + // is a call on the LIST, not on a `Conn`. + const annotation = field.getFieldTypeName(); + if (annotation !== '') { + for (const candidate of this.namedTypesIn(annotation)) { + const byAnnotation = ctx.typesByName.get(candidate); + if (byAnnotation) { + return byAnnotation; + } + } + } + if (field.getInitializerKind() === PythonInitializerKind.CALL) { + const calleeText = field.getInitializerText().split('(')[0] ?? ''; + const calleeName = (calleeText.split('.').pop() ?? '').trim(); + // A dotted callee names something outside this module even when its last + // segment collides with a local class. + if (calleeName !== '' && !calleeText.includes('.')) { + const byConstructor = ctx.typesByName.get(calleeName); + if (byConstructor) { + return byConstructor; + } + // Not a class — but a FACTORY function with a `-> T` annotation says what + // it hands back, so `self.pool = make_pool()` types the attribute too. + const factory = ctx.moduleMethodsByName?.get(calleeName); + if (factory) { + const returned = this.returnedTypeOf(factory, ctx); + if (returned) { + return returned; + } + } + } + } + // `self._loop = loop` in `def __init__(self, loop: AbstractEventLoop)`. + // The attribute holds whatever was PASSED IN, so the parameter's annotation + // is the attribute's type — the single largest bucket in the measurement, + // and the one place where argument flow already carries the answer. Only the + // declaring method's own parameters are consulted: a same-named parameter on + // a different method says nothing about this attribute. + if ( + field.getInitializerKind() === PythonInitializerKind.NAME && + ctx.parametersByMethod !== undefined + ) { + const parameters = ctx.parametersByMethod.get(field.getDeclaringMethodLinkHash()) ?? []; + const source = field.getInitializerText(); + for (const parameter of parameters) { + if (parameter.getParamName() !== source) { + continue; + } + const annotationText = parameter.getParameterTypeName(); + if (annotationText === '') { + return null; + } + for (const candidate of this.namedTypesIn(annotationText)) { + const resolved = ctx.typesByName.get(candidate); + if (resolved) { + return resolved; + } + } + return null; + } + } + return null; + } + + /** + * The BUILTIN type an attribute holds, when a literal or a builtin constructor + * settles it. + * + * `self._buffer = bytearray()` then `self._buffer.extend(d)` reaches + * `bytearray.extend`. There is no `py_method` row for a builtin, so the hash + * stays empty and only the KIND is claimed — the same treatment a bare + * `len(x)` already gets, and strictly better than `UNRESOLVED`. + * + * The method name is checked against that type's real attribute set rather than + * assumed. Without the check, `self._items = []` followed by + * `self._items.frobnicate()` would be reported as a builtin call, which is both + * wrong and hides a genuine bug in the analysed code. + */ + private builtinTypeOfField( + field: PyFieldRegistry, + annotationOverride?: string + ): string { + // An annotation naming a builtin is the strongest evidence available: + // `self.label: str` or a parameter annotated `str` flowing into it. + const annotation = annotationOverride ?? field.getFieldTypeName(); + const head = (annotation.split('[')[0] ?? '').split('.').pop()?.trim() ?? ''; + if (PYTHON_BUILTIN_TYPE_METHODS.has(head)) { + return head; + } + const inferred = this.builtinLiteralType(field); + if (inferred !== '') { + return inferred; + } + if (field.getInitializerKind() !== PythonInitializerKind.CALL) { + return ''; + } + const calleeText = field.getInitializerText().split('(')[0] ?? ''; + const calleeName = (calleeText.split('.').pop() ?? '').trim(); + return PYTHON_BUILTIN_TYPE_METHODS.has(calleeName) ? calleeName : ''; + } + + /** + * The annotation of the parameter whose value was assigned to this attribute. + * + * `def __init__(self, name: str): self.label = name` makes `self.label` a + * `str`, so `self.label.upper()` reaches `str.upper`. Without this the + * attribute has no annotation of its own and the information is simply lost, + * even though it is written down one line away. + */ + private annotationFlowingInto( + field: PyFieldRegistry, + ctx: { parametersByMethod?: Map } + ): string | undefined { + if (field.getInitializerKind() !== PythonInitializerKind.NAME) { + return undefined; + } + const parameters = ctx.parametersByMethod?.get(field.getDeclaringMethodLinkHash()) ?? []; + const source = field.getInitializerText(); + for (const parameter of parameters) { + if (parameter.getParamName() === source) { + return parameter.getParameterTypeName(); + } + } + return undefined; + } + + /** The builtin type a literal initialiser produces, from its first character. */ + private builtinLiteralType(field: PyFieldRegistry): string { + if (field.getInitializerKind() !== PythonInitializerKind.LITERAL) { + return ''; + } + const text = field.getInitializerText(); + if (text.startsWith('[')) { + return 'list'; + } + if (text.startsWith('(')) { + return 'tuple'; + } + // `{}` is a dict and `{1}` is a set — the same opening brace, so the + // distinction is the presence of a colon at the top level. A `set` and a + // `dict` share almost no methods, so guessing either way would be wrong half + // the time. + if (text.startsWith('{')) { + if (text === '{}') { + return 'dict'; + } + return text.includes(':') ? 'dict' : 'set'; + } + if (/^[a-zA-Z]*['"]/.test(text)) { + return text.toLowerCase().startsWith('b') ? 'bytes' : 'str'; + } + return ''; + } + + /** + * Links every attribute expression to the `py_field` it reaches. + * + * Schema §2.10 deleted `py_field_write` because the write facts already live on + * `py_expression` and the only thing it added was this join key. So the join is + * performed here, as a resolution rule, and written to the polymorphic + * `referencedEntityKind`/`referencedEntityHash` pair — which is exactly what + * that pair is for. + * + * Both directions of use are covered by one rule: a STORE is the write that + * created the attribute, a LOAD is a read of it, and both point at the same + * merged field row. + */ + private linkAttributeExpressionsToFields( + input: ResolutionInput, + ctx: { + typesByHash: Map; + basesByType: Map; + // Passed through to lookupFieldOnTypeAndBases, which linearises the MRO. + methodsByTypeAndName: Map; + mroCache: Map; + fieldByTypeAndName: Map; + } + ): number { + let linked = 0; + for (const expression of input.expressions) { + if (expression.getKind() !== PythonExpressionKind.ATTRIBUTE_ACCESS) { + continue; + } + if (expression.getReferencedEntityKind() !== PythonReferencedEntityKind.UNKNOWN) { + continue; + } + const ownerType = expression.getPyTypeLinkHash(); + if (ownerType === '') { + continue; + } + const dotted = expression.getDottedPath(); + // Only an attribute of the receiver. `self.a.b` names an attribute of + // whatever `self.a` is, and pointing it at this class's `b` would be a + // confident wrong answer. + const segments = dotted === '' ? [] : dotted.split('.'); + if (segments.length !== 2) { + continue; + } + const field = this.lookupFieldOnTypeAndBases(ownerType, segments[1] ?? '', ctx); + if (!field) { + continue; + } + expression.setReferencedEntity(PythonReferencedEntityKind.FIELD, field.getHash()); + linked += 1; + } + return linked; + } + + /** + * Walks the scope chain for a name bound to a `def` or `class`. + * + * Uses the emitted `py_binding` rows and `declaringBindingLinkHash` rather + * than a name match, so a nested `def inner` resolves to THAT `inner` and not + * to another of the same name elsewhere in the module. + */ + private lookupInScopeChain( + name: string, + scopeHash: string, + ctx: { + entityByBinding: Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + } + ): PyMethodRegistry | PyTypeRegistry | null { + let current: string | undefined = scopeHash; + let guard = 0; + while (current !== undefined && current !== '') { + if (++guard > 200) { + return null; + } + const binding = ctx.bindingByScopeAndName.get(`${current}::${name}`); + // A binding row exists for any name a scope MENTIONS, including one it only + // reads — symtable emits a GLOBAL_IMPLICIT Symbol for that. Only a row that + // genuinely binds may stop the walk; otherwise the first mention of a + // module-level function inside a nested scope looks like a shadow and halts + // the lookup one scope too early. That single mistake limited resolution to + // zero-hop lookups. + if (binding?.isBound()) { + // Bound here. A def or class gives an exact target; anything else + // (parameter, import, plain variable) shadows any outer definition, so + // the answer is "not derivable" rather than "keep looking". + return ctx.entityByBinding.get(binding.getHash()) ?? null; + } + current = ctx.parentScopeOf.get(current); + } + return null; + } + + /** + * The method this name resolves to on a type, following the **C3 MRO**. + * + * Not depth-first. The two differ, and the difference is not academic: + * + * ```python + * class A: def m(self): ... + * class B(A): pass + * class C(A): def m(self): ... + * class D(B, C): + * def m(self): return super().m() # CPython: C.m + * ``` + * + * Depth-first through B reaches `A.m` and stops. CPython's MRO is + * `D, B, C, A`, so the answer is `C.m` — C comes before A because A is in C's + * tail. An earlier version of this resolver used depth-first and got exactly + * that case wrong while looking correct on simpler ones, which is the worst + * shape for a defect. + */ + private lookupMethodOnTypeAndBases( + typeHash: string, + name: string, + ctx: MroContext + ): PyMethodRegistry | null { + return this.lookupAlongMro(typeHash, name, ctx, 0); + } + + /** + * The same lookup, starting **after** the class itself. + * + * `super()` is not virtual dispatch: it is an MRO-ordered lookup beginning at + * the position after the enclosing class, which is why `Child.describe` + * calling `super().describe()` must reach `Base.describe` and never itself. + */ + private lookupMethodOnBasesOnly( + typeHash: string, + name: string, + ctx: MroContext + ): PyMethodRegistry | null { + return this.lookupAlongMro(typeHash, name, ctx, 1); + } + + private lookupAlongMro( + typeHash: string, + name: string, + ctx: MroContext, + startIndex: number + ): PyMethodRegistry | null { + // A class is always FIRST in its own MRO, so a declaration on the class + // itself needs no linearisation and cannot be shadowed by an opaque base. + // Requiring the MRO here refused every `self.m()` in a class with an + // external base even when the class declared `m` directly — 1,549 of them + // across 400 stdlib files. + if (startIndex === 0) { + const own = this.singleMethodOn(typeHash, name, ctx); + if (own) { + return own; + } + } + + const mro = this.linearize(typeHash, ctx, new Set()); + if (mro !== null) { + for (let i = Math.max(startIndex, 1); i < mro.length; i++) { + const found = this.singleMethodOn(mro[i]!, name, ctx); + if (found) { + return found; + } + } + return null; + } + + // Full linearisation failed, but that does not always matter. What a claim + // actually needs is the MRO PREFIX up to the declaring class — every class + // before it must be known and must not declare the name. Anything after is + // irrelevant, because the first declaration wins. + // + // Under SINGLE inheritance the prefix is just the chain, so it is walkable + // without knowing the rest: `_SelectorSocketTransport(_SelectorTransport)` + // resolves `_fatal_error` to `_SelectorTransport` even when THAT class's own + // bases lie outside the analysis, because `_SelectorTransport` precedes them. + // With multiple bases the prefix order genuinely depends on C3, so nothing + // is claimed. + return this.lookupAlongSingleInheritanceChain(typeHash, name, ctx, startIndex); + } + + private lookupAlongSingleInheritanceChain( + typeHash: string, + name: string, + ctx: MroContext, + startIndex: number + ): PyMethodRegistry | null { + let current = typeHash; + let depth = 0; + const seen = new Set(); + + while (depth++ < 100 && !seen.has(current)) { + seen.add(current); + if (depth > startIndex) { + const found = this.singleMethodOn(current, name, ctx); + if (found) { + return found; + } + } + const bases = [...(ctx.basesByType.get(current) ?? [])] + .filter(b => b.getKeywordName() === '') + .filter(b => !(b.getBaseSimpleName() === 'object' && !b.getIsResolvedLocally())); + if (bases.length === 0) { + // Reached the implicit root without finding it. + return null; + } + if (bases.length > 1) { + // The prefix beyond this point depends on a linearisation that failed — + // but the FIRST base's head is still provably MRO index 1. C3 always + // takes it first: it could only be deferred if it appeared in a later + // base's tail, and a later base inheriting from an earlier one is + // precisely the inconsistent hierarchy CPython refuses to create. So a + // declaration on base 0 itself is certain; anything deeper is not. + const first = bases[0]!; + if (!first.getIsResolvedLocally()) { + return null; + } + return this.singleMethodOn(first.getResolvedTypeLinkHash(), name, ctx); + } + const base = bases[0]!; + if (!base.getIsResolvedLocally()) { + // Opaque position, and it comes BEFORE anything further up. + return null; + } + current = base.getResolvedTypeLinkHash(); + } + return null; + } + + /** + * C3 linearisation of a type, or `null` when it cannot be computed. + * + * `null` for two distinct reasons, both of which must block a resolution + * claim: a base outside the analysis (its own MRO is unknown, and it could + * declare the name), or a genuinely inconsistent hierarchy — the same + * condition under which CPython itself raises `TypeError` at class creation. + * + * Memoised per type: without it the MRO is recomputed for every call site on + * the class. + */ + private linearize( + typeHash: string, + ctx: MroContext, + visiting: Set + ): string[] | null { + const cached = ctx.mroCache.get(typeHash); + if (cached !== undefined) { + return cached; + } + if (visiting.has(typeHash)) { + // A cycle cannot occur in valid Python, but malformed or partially + // resolved input must not hang. + return null; + } + visiting.add(typeHash); + + const bases = [...(ctx.basesByType.get(typeHash) ?? [])] + .filter(b => b.getKeywordName() === '') + // An explicit `object` base is the implicit root written out longhand, and + // `class X(object)` is very common in older code. It is NOT opaque: its + // member set is fixed and entirely dunder, so it cannot be the target of + // any ordinary name and cannot shadow one. Treating it as an unknown base + // refused every inherited lookup under `class X(object)` — which is what + // blocked argparse.ArgumentParser, whose two bases both spell it out. + .filter(b => !(b.getBaseSimpleName() === 'object' && !b.getIsResolvedLocally())) + .sort((a, b) => Number(a.getPosition()) - Number(b.getPosition())); + + let result: string[] | null = [typeHash]; + if (bases.length > 0) { + if (bases.some(b => !b.getIsResolvedLocally())) { + result = null; + } else { + const baseHashes = bases.map(b => b.getResolvedTypeLinkHash()); + const sequences: string[][] = []; + for (const baseHash of baseHashes) { + const linear = this.linearize(baseHash, ctx, visiting); + if (linear === null) { + result = null; + break; + } + sequences.push([...linear]); + } + if (result !== null) { + // The direct base list is itself a constraint sequence, which is what + // makes C3 preserve the order bases were written in. + sequences.push([...baseHashes]); + const merged = this.c3Merge(sequences); + result = merged === null ? null : [typeHash, ...merged]; + } + } + } + + visiting.delete(typeHash); + ctx.mroCache.set(typeHash, result); + return result; + } + + /** + * The C3 merge: repeatedly take the head of the first sequence that appears in + * no other sequence's TAIL. + * + * "Appears in a tail" is the whole rule — it is what makes `C` precede `A` in + * `D(B, C)`, since `A` sits in `C`'s tail and so cannot be taken first. + * Returning `null` when no candidate qualifies mirrors CPython refusing to + * create the class. + */ + private c3Merge(sequences: string[][]): string[] | null { + const pending = sequences.map(s => [...s]).filter(s => s.length > 0); + const result: string[] = []; + + while (pending.length > 0) { + let taken: string | null = null; + for (const sequence of pending) { + const head = sequence[0]!; + const inSomeTail = pending.some(other => other.indexOf(head) > 0); + if (!inSomeTail) { + taken = head; + break; + } + } + if (taken === null) { + return null; + } + result.push(taken); + for (const sequence of pending) { + if (sequence[0] === taken) { + sequence.shift(); + } + } + for (let i = pending.length - 1; i >= 0; i--) { + if (pending[i]!.length === 0) { + pending.splice(i, 1); + } + } + } + return result; + } + + + private describeEntity( + entity: PyMethodRegistry | PyTypeRegistry + ): { kind: PythonResolvedCalleeKind; hash: string } { + if (entity instanceof PyTypeRegistry) { + // Calling a class constructs an instance of it. + return { kind: PythonResolvedCalleeKind.TYPE, hash: entity.getHash() }; + } + // A nested def is a plain function, not a bound method, even though it + // records the class it is lexically inside. + return { + kind: entity.isClassBodyMember() + ? PythonResolvedCalleeKind.METHOD + : PythonResolvedCalleeKind.MODULE_FUNCTION, + hash: entity.getHash(), + }; + } + + /** A name maps to an entity only when exactly one entity carries that name. */ + private uniqueByName(items: T[], nameOf: (item: T) => string): Map { + const byName = new Map(); + for (const item of items) { + const name = nameOf(item); + byName.set(name, byName.has(name) ? null : item); + } + return byName; + } + + /** + * The one method of this name declared directly on a type. + * + * `@overload` stubs are excluded: the schema says they are declarations and + * must never be call targets, so a name with two overload stubs and one real + * implementation resolves to the implementation rather than being treated as + * ambiguous. + * + * ABSTRACT methods are excluded for the same reason: `@abstractmethod def + * send(...): ...` never runs, the override does, so naming it as the target + * is a dead end one hop in. + * + * A method whose BODY is a stub but which is CONCRETE is NOT excluded, and + * used to be. That was the original bug and then I over-corrected it. Three + * different properties share the word "stub": + * + * OVERLOAD_STUB a declaration with no implementation -> not a target + * ABSTRACT_METHOD an implementation the subclass supplies -> not a target + * bodyIsStub the body is `pass` or `...` -> SAYS NOTHING + * + * The third is the normal shape of an overridable hook, and Python is full of + * them: ParserBase.unknown_decl, Bdb.user_line, Cmd.preloop. Those really do + * run when nothing overrides them. Filtering on bodyIsStub made + * `self.user_line(frame)` resolve to nothing though the method is on the very + * class making the call -- 159 of the stdlib's unresolved self-dispatches. + * Dropping the filter entirely then made `self.send(...)` resolve to the + * abstract declaration, which the engine correctly reports as the concrete + * implementation. Abstractness is the axis that matters, and the parser + * already records it as ABSTRACT_METHOD / ABSTRACT. + */ + private singleMethodOn( + typeHash: string, + name: string, + ctx: { methodsByTypeAndName: Map } + ): PyMethodRegistry | null { + const candidates = (ctx.methodsByTypeAndName.get(`${typeHash}::${name}`) ?? []).filter( + m => + m.getMethodKind() !== PythonMethodKind.OVERLOAD_STUB && + m.getMethodKind() !== PythonMethodKind.ABSTRACT_METHOD && + // A function nested inside a method is not reachable as `self.name`, + // even though it carries the enclosing class in pyTypeLinkHash. + m.isClassBodyMember() + ); + return candidates.length === 1 ? candidates[0]! : null; + } +} diff --git a/parser/src/parsers/python/extractors/python-scope-builder.ts b/parser/src/parsers/python/extractors/python-scope-builder.ts new file mode 100644 index 000000000..eeb77520c --- /dev/null +++ b/parser/src/parsers/python/extractors/python-scope-builder.ts @@ -0,0 +1,2121 @@ +import Parser from 'tree-sitter'; + +import { + PYTHON_LAMBDA_SCOPE_NAME, + PYTHON_LOCALS_MARKER, + PYTHON_MODULE_SCOPE_NAME, + PYTHON_SYNTHETIC_ITERATOR, +} from '@/constants/python-constants'; +import { + decomposeMisparsedTypeAlias, + isMisparsedTypeAlias, +} from '@/parsers/python/python-soft-keywords'; +import { normalizePythonIdentifier } from '@/utils/python/python-identifier-utils'; +import { PythonBindingOrigin } from '@/enums/python/bindings'; +import { PythonNameContext } from '@/enums/python/expressions'; +import { PythonScopeKind, SymbolBlockType } from '@/enums/python/scopes'; +import { + addSymbolFlags, + createSymbolBlock, + SymbolFlags, +} from '@/parsers/python/extractors/python-symbol-table'; +import { SymbolBlock } from '@/parsers/python/types'; +import { PythonSourcePositions } from '@/utils/python'; + +/** tree-sitter node types that introduce a new scope. Exactly eight forms. */ +const SCOPE_NODE_TYPES: ReadonlySet = new Set([ + 'function_definition', + 'class_definition', + 'lambda', + 'list_comprehension', + 'set_comprehension', + 'dictionary_comprehension', + 'generator_expression', +]); + +/** Comprehension forms, which all take the synthetic `.0` iterator parameter. */ +const COMPREHENSION_NODE_TYPES: ReadonlySet = new Set([ + 'list_comprehension', + 'set_comprehension', + 'dictionary_comprehension', + 'generator_expression', +]); + +/** + * Builds the scope tree and every `(scope, name)` flag set — pass 1 of CPython's + * two-pass symbol-table construction, over a tree-sitter tree. + * + * ## What makes this hard, and where the traps are + * + * **1. Some parts of a scope-introducing node are evaluated in the ENCLOSING + * scope.** This is the single most common way a naive walker goes wrong, because + * the resulting scope is a *sibling* rather than a child: + * + * ```python + * def f(x=lambda: 1): ... # the lambda is a child of f's PARENT + * @deco(lambda: 1) # likewise + * def g(): ... + * def h(x: C[lambda: 1]): ... # likewise — annotations too + * class K(Base(lambda: 1)): ... # likewise — base expressions too + * [y for y in ] # the OUTERMOST iterable is evaluated outside + * ``` + * + * Defaults, decorators, annotations, return annotations, class bases and class + * keywords are therefore visited in the current block, and only the body and + * parameter *names* go into the child block. + * + * **2. A walrus inside a comprehension binds outside it.** + * + * ```python + * filtered = [y for y in values if (last_seen := y) > 0] + * ``` + * + * `last_seen` is a local of the enclosing function, not of the listcomp — while + * the listcomp records it as `DEF_LOCAL | DEF_NONLOCAL`, which pass 2 resolves + * to `FREE`. Both halves are required; either alone gives the wrong answer. + * + * **3. Augmented assignment binds without referencing.** `x += 1` sets + * `DEF_LOCAL` and **not** `USE`, so `is_referenced` stays false. Verified + * against CPython rather than assumed. + * + * **4. A bare annotation still binds.** On the 3.10 target `x: int` with no + * value sets `DEF_ANNOT | DEF_LOCAL`, so `is_assigned` is true. This differs + * from what the syntax suggests and is exactly the kind of thing the oracle + * settles. + * + * ## State discipline + * + * Nothing is ever stored on a tree-sitter node. node-tree-sitter hands out + * transient wrappers whose cache evicts entries, so a property set in one + * traversal is gone by the next and `.parent` walks return untagged objects — + * silent at scale. All state lives in {@link SymbolBlock}, keyed by `node.id`. + */ +export class PythonScopeBuilder { + private blocksByNodeId = new Map(); + + /** + * The module block, kept so a `global` statement anywhere can reach it. + */ + private moduleBlock: SymbolBlock | null = null; + + /** + * Whether `from __future__ import annotations` (PEP 563) is in force. + * + * When it is, annotations are never evaluated, and CPython's symbol table does + * **not** visit them — so a name used only in an annotation is not + * `is_referenced`. Verified against CPython both ways: with the future import + * a type used only in a signature loses its `referenced` predicate, without it + * it keeps it. Getting this wrong misreports every `TYPE_CHECKING` import. + */ + private futureAnnotations = false; + + /** + * Converts tree-sitter's character columns to CPython's UTF-8 byte columns. + * `startColumn` is in the py_scope primary key and the schema states it is + * ast-derived, so the two must agree. + */ + private positions: PythonSourcePositions = new PythonSourcePositions(''); + + /** + * Builds the complete block tree for a module. + * + * @param rootNode the `module` node + * @param moduleQualifiedName dotted module name, used as the root qualname + * @param sourceLineCount total lines, for the module block's end position + * @returns the module block, with children sorted into source order + */ + build( + rootNode: Parser.SyntaxNode, + moduleQualifiedName: string, + positions: PythonSourcePositions + ): SymbolBlock { + this.blocksByNodeId = new Map(); + this.positions = positions; + this.futureAnnotations = this.hasFutureAnnotations(rootNode); + + const moduleBlock = createSymbolBlock({ + nodeId: rootNode.id, + blockType: SymbolBlockType.MODULE, + scopeKind: PythonScopeKind.MODULE, + // symtable calls the module scope `top`, and the harness compares the name. + name: PYTHON_MODULE_SCOPE_NAME, + qualifiedName: moduleQualifiedName, + parent: null, + startLine: 0, + startColumn: 0, + endLine: positions.lineCount(), + endColumn: positions.lastLineByteLength(), + nestingDepth: 0, + privateNamePrefix: '', + }); + this.blocksByNodeId.set(rootNode.id, moduleBlock); + this.moduleBlock = moduleBlock; + + this.visitBody(moduleBlock, rootNode); + this.finalizeOrdinals(moduleBlock); + return moduleBlock; + } + + /** Every block that was created, keyed by the id of its introducing node. */ + getBlocksByNodeId(): Map { + return this.blocksByNodeId; + } + + // ------------------------------------------------------------------ blocks + + /** + * Assigns `scopeOrdinal` in **evaluation order**, which is creation order here. + * + * This is emphatically *not* source order, and assuming it was is a mistake + * that survives every small test and then disagrees with CPython on real code. + * symtable orders a block's children by the order it *constructed* them, and + * parts of a signature are evaluated before the function body exists: + * + * ```python + * class SpecLoaderAdapter: + * def __init__(self, spec=lambda: 1): ... + * # ^ column 4 ^ column 37 + * ``` + * + * The lambda is a *sibling* of `__init__` and is built **first** — ordinal 0 + * for the lambda, 1 for the method — even though the `def` starts earlier on + * the line. Sorting by (line, column) inverts them. + * + * The traversal therefore visits each construct in exactly CPython's order + * (defaults, then kw-defaults, then annotations, then decorators, then the + * body) and this pass simply numbers what that produced. + */ + private finalizeOrdinals(block: SymbolBlock): void { + block.children.forEach((child, index) => { + child.scopeOrdinal = index; + this.finalizeOrdinals(child); + }); + } + + private createChildBlock( + parent: SymbolBlock, + node: Parser.SyntaxNode, + blockType: SymbolBlockType, + scopeKind: PythonScopeKind, + name: string + ): SymbolBlock { + const child = createSymbolBlock({ + nodeId: node.id, + blockType, + scopeKind, + name, + qualifiedName: this.buildQualifiedName(parent, name), + parent, + startLine: node.startPosition.row + 1, + startColumn: this.positions.byteColumn(node.startPosition.row, node.startPosition.column), + endLine: node.endPosition.row + 1, + endColumn: this.positions.byteColumn(node.endPosition.row, node.endPosition.column), + nestingDepth: parent.nestingDepth + 1, + // A class body sets the mangling prefix for itself and everything nested + // inside it; any other block simply inherits whatever was in force. + privateNamePrefix: + blockType === SymbolBlockType.CLASS + ? this.manglePrefixFor(name) + : parent.privateNamePrefix, + }); + parent.children.push(child); + this.blocksByNodeId.set(node.id, child); + return child; + } + + /** + * CPython `__qualname__` semantics, including the `` marker. + * + * A function's nested entities live in its ``; a class's do not. So + * `Outer.method..inner` but `Outer.method`. + */ + private buildQualifiedName(parent: SymbolBlock, name: string): string { + const parentIsFunctionLike = + parent.scopeKind === PythonScopeKind.FUNCTION || + parent.scopeKind === PythonScopeKind.LAMBDA || + parent.scopeKind === PythonScopeKind.COMPREHENSION_LIST || + parent.scopeKind === PythonScopeKind.COMPREHENSION_SET || + parent.scopeKind === PythonScopeKind.COMPREHENSION_DICT || + parent.scopeKind === PythonScopeKind.GENERATOR_EXPRESSION; + + return parentIsFunctionLike + ? `${parent.qualifiedName}.${PYTHON_LOCALS_MARKER}.${name}` + : `${parent.qualifiedName}.${name}`; + } + + // ------------------------------------------------------------------ defs + + /** + * CPython's `_Py_Mangle`, applied at the two choke points where a name enters + * a symbol table. + * + * Inside a class, `__x` becomes `_ClassName__x`. The exact conditions matter, + * and all three exclusions are real: + * + * ```python + * class Outer: + * __secret -> _Outer__secret # mangled + * __init__ -> __init__ # NOT: ends with two underscores + * __one_trailing_ -> _Outer__one_trailing_ # IS: only one trailing + * _single -> _single # NOT: only one leading underscore + * + * class ___: # a class name of only underscores mangles nothing + * ``` + * + * This is applied to bindings *and* references, and it reaches into every + * nested scope, including comprehensions inside methods. + */ + private mangleName(block: SymbolBlock, rawName: string): string { + // NFKC first, then mangling -- CPython normalises in the TOKENISER, so by + // the time private-name mangling runs the name is already folded. Doing it + // the other way round would leave a fullwidth `__x` unmangled. + const name = normalizePythonIdentifier(rawName); + if (!block.privateNamePrefix) { + return name; + } + if (!name.startsWith('__')) { + return name; + } + if (name.endsWith('__')) { + return name; + } + if (name.includes('.')) { + return name; + } + return block.privateNamePrefix + name; + } + + /** + * The prefix a class contributes: `_` plus the class name with leading + * underscores stripped. A name consisting only of underscores contributes + * nothing, which disables mangling inside it entirely. + */ + private manglePrefixFor(className: string): string { + const stripped = className.replace(/^_+/, ''); + if (stripped.length === 0) { + return ''; + } + return `_${stripped}`; + } + + private addDef( + block: SymbolBlock, + rawName: string, + flags: number, + origin: PythonBindingOrigin | null, + node: Parser.SyntaxNode + ): void { + if (!rawName) { + return; + } + const name = this.mangleName(block, rawName); + addSymbolFlags(block, name, flags); + + // A `global` declaration in ANY block also marks the name global in the + // module's own symbol table. CPython does this by writing through to + // `st->st_global`, which is why a module-level `counter = 0` reports + // is_declared_global=true when a nested function declares `global counter`. + // Without this the module row silently disagrees with CPython. + if ((flags & SymbolFlags.DEF_GLOBAL) !== 0 && this.moduleBlock !== null && block !== this.moduleBlock) { + addSymbolFlags(this.moduleBlock, name, SymbolFlags.DEF_GLOBAL); + } + + if (origin === null) { + return; + } + const line = node.startPosition.row + 1; + const existing = block.origins.get(name); + if (existing) { + existing.origins.add(origin); + existing.firstLine = Math.min(existing.firstLine, line); + existing.lastLine = Math.max(existing.lastLine, line); + existing.bindingCount += 1; + return; + } + block.origins.set(name, { + origins: new Set([origin]), + firstLine: line, + lastLine: line, + bindingCount: 1, + declaredTypeName: '', + }); + } + + private addUse(block: SymbolBlock, rawName: string): void { + if (!rawName) { + return; + } + const name = this.mangleName(block, rawName); + addSymbolFlags(block, name, SymbolFlags.USE); + + // Reading the name `super` inside a function counts as a use of + // `__class__`, because zero-argument `super()` needs the implicit class + // cell. CPython special-cases exactly this, and without it every method + // that calls `super()` is missing a `__class__` binding. + if ( + rawName === 'super' && + block.blockType === SymbolBlockType.FUNCTION + ) { + addSymbolFlags(block, '__class__', SymbolFlags.USE); + } + } + + /** Detects `from __future__ import annotations` (PEP 563). */ + private hasFutureAnnotations(rootNode: Parser.SyntaxNode): boolean { + for (let i = 0; i < rootNode.namedChildCount; i++) { + const child = rootNode.namedChild(i); + if (child?.type !== 'future_import_statement') { + continue; + } + for (let j = 0; j < child.namedChildCount; j++) { + if (child.namedChild(j)?.text === 'annotations') { + return true; + } + } + } + return false; + } + + /** + * Visits an annotation expression, unless PEP 563 is in force. + * + * Under `from __future__ import annotations` the annotation is never + * evaluated, so CPython does not visit it and names appearing only there are + * not referenced. + */ + private visitAnnotation(block: SymbolBlock, node: Parser.SyntaxNode): void { + if (this.futureAnnotations) { + return; + } + this.visitExpression(block, node, PythonNameContext.LOAD); + } + + // ------------------------------------------------------------------ walk + + /** Visits every statement in a block's body. */ + private visitBody(block: SymbolBlock, node: Parser.SyntaxNode): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) { + this.visitStatement(block, child); + } + } + } + + private visitStatement(block: SymbolBlock, node: Parser.SyntaxNode): void { + switch (node.type) { + case 'decorated_definition': { + // Decorators are evaluated in the CURRENT scope, so a lambda inside one + // is a sibling of the definition it wraps. They are NOT visited here, + // though: CPython visits defaults and annotations BEFORE decorators, so + // the decorator list is handed down and visited at the right point. + // Emitting them here instead misorders scopeOrdinal on any decorated + // function with a lambda in its signature. + const decorators: Parser.SyntaxNode[] = []; + let definition: Parser.SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'decorator') { + decorators.push(child); + continue; + } + definition = child; + } + if (definition?.type === 'function_definition') { + this.visitFunctionDefinition(block, definition, decorators); + return; + } + if (definition?.type === 'class_definition') { + this.visitClassDefinition(block, definition, decorators); + return; + } + if (definition) { + this.visitStatement(block, definition); + } + return; + } + + case 'function_definition': { + this.visitFunctionDefinition(block, node); + return; + } + + case 'class_definition': { + this.visitClassDefinition(block, node); + return; + } + + case 'global_statement': { + block.declaresGlobal = true; + for (let i = 0; i < node.namedChildCount; i++) { + const name = node.namedChild(i); + if (name?.type === 'identifier') { + this.addDef( + block, + name.text, + SymbolFlags.DEF_GLOBAL, + PythonBindingOrigin.GLOBAL_STMT, + name + ); + } + } + return; + } + + case 'nonlocal_statement': { + block.declaresNonlocal = true; + for (let i = 0; i < node.namedChildCount; i++) { + const name = node.namedChild(i); + if (name?.type === 'identifier') { + this.addDef( + block, + name.text, + SymbolFlags.DEF_NONLOCAL, + PythonBindingOrigin.NONLOCAL_STMT, + name + ); + } + } + return; + } + + case 'import_statement': + case 'import_from_statement': + case 'future_import_statement': { + // `from __future__ import annotations` is its own node type in this + // grammar, NOT an import_from_statement. Missing it means `annotations` + // is recorded as a read of an undefined name instead of an import + // binding. + this.visitImport(block, node); + return; + } + + case 'delete_statement': { + for (let i = 0; i < node.namedChildCount; i++) { + const target = node.namedChild(i); + if (target) { + this.visitTarget(block, target, PythonBindingOrigin.DEL, PythonNameContext.DEL); + } + } + return; + } + + case 'for_statement': { + this.visitForStatement(block, node); + return; + } + + case 'with_statement': { + this.visitWithStatement(block, node); + return; + } + + case 'try_statement': { + this.visitTryStatement(block, node); + return; + } + + case 'type_alias_statement': { + if (!isMisparsedTypeAlias(node)) { + this.visitTypeAliasStatement(block, node); + return; + } + // `type(obj).attr = value` is an assignment, not a type alias. The + // grammar's greedy soft keyword swallowed the call node outright, so + // `type` has no identifier node anywhere in the tree and the reference + // has to be synthesised from the keyword token; without it symtable + // reports a global that we do not. + this.addUse(block, 'type'); + const parts = decomposeMisparsedTypeAlias(node); + if (parts.target !== null) { + this.visitTarget( + block, + parts.target, + PythonBindingOrigin.ASSIGNMENT, + PythonNameContext.STORE + ); + } + if (parts.annotation !== null) { + this.visitExpression(block, parts.annotation, PythonNameContext.LOAD); + } + if (parts.value !== null) { + this.visitExpression(block, parts.value, PythonNameContext.LOAD); + } + return; + } + + case 'if_statement': + case 'while_statement': + case 'match_statement': + case 'block': + case 'print_statement': { + this.visitPrintStatement(block, node); + return; + } + + case 'expression_statement': + case 'return_statement': + case 'raise_statement': + case 'assert_statement': + case 'elif_clause': + case 'else_clause': + case 'finally_clause': + case 'case_clause': + case 'try_clause': + case 'with_clause': + case 'exec_statement': + default: { + this.visitGenericStatement(block, node); + return; + } + } + } + + /** + * `print >> stream, value` under Python 3 rules. + * + * The dialect detector lets this shape through because it is valid Python 3 -- + * the tuple `(print.__rshift__(stream), value)` -- and only a `print_statement` + * WITHOUT a chevron is rejected. Having accepted it, we owe it correct facts; + * accepting a file and then under-reporting it is the same silent wrong answer + * that rejection exists to prevent. + * + * The generic walk already covers the operands, since they are named children. + * What it cannot see is `print` itself: the grammar emits it as an ANONYMOUS + * keyword token, not an `identifier`, so no name visitor ever fires on it. Yet + * CPython reads it as an ordinary global load -- `f|print: global,referenced`. + * So the use is synthesized here from the token, and only when a chevron is + * present, which is the sole shape that reaches this code as valid Python 3. + */ + private visitPrintStatement(block: SymbolBlock, node: Parser.SyntaxNode): void { + let hasChevron = false; + for (let i = 0; i < node.childCount; i += 1) { + const child = node.child(i); + if (child && child.type === 'chevron') { + hasChevron = true; + break; + } + } + if (hasChevron) { + this.addUse(block, 'print'); + } + this.visitGenericStatement(block, node); + } + + /** + * Statements with no binding rules of their own: recurse into children, + * dispatching each to the statement or expression visitor as appropriate. + * + * Every branch is braced. In dispatch-heavy code a dangling `else` is nearly + * invisible when read and silently doubles or drops output. + */ + private visitGenericStatement(block: SymbolBlock, node: Parser.SyntaxNode): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'except_clause' || child.type === 'except_group_clause') { + this.visitExceptClause(block, child); + continue; + } + if (child.type === 'case_pattern') { + this.visitCasePattern(block, child); + continue; + } + if (this.isStatementLike(child)) { + this.visitStatement(block, child); + continue; + } + this.visitExpression(block, child, PythonNameContext.LOAD); + } + } + + private isStatementLike(node: Parser.SyntaxNode): boolean { + return ( + node.type.endsWith('_statement') || + node.type.endsWith('_clause') || + node.type === 'block' || + node.type === 'decorated_definition' + ); + } + + // -------------------------------------------------------------- functions + + /** + * Opens the PEP 695 annotation scope for a `class C[T]` / `def f[U]` / `type A[W]` + * type-parameter list, or returns the enclosing block unchanged when there is none. + * + * The shape is CPython 3.12's, read off `symtable` rather than guessed: + * + * ``` + * class C[T](Base): module + * type parameter 'C' <- T binds here, Base evaluated here + * class 'C' + * def f[U](a=D): module + * type parameter 'f' <- U binds here; D stays outside + * function 'f' + * class B[V: int]: module + * type parameter 'B' + * TypeVar bound 'V' <- int resolved here + * class 'B' + * ``` + * + * The two counter-intuitive parts are both load-bearing. The type-parameter scope WRAPS + * the class or function rather than nesting inside it, so a name bound here is visible to + * the body; and once a type-parameter list is present, a class's BASES move inside this + * scope — CPython records that as `.generic_base` — because a base may itself mention a + * type parameter. A function's DEFAULTS do not move: `.defaults` is a marker in this + * scope, but the default expressions are still evaluated outside it. + * + * Keyed on the `type_parameter` node rather than on the class or function node, because + * `blocksByNodeId` maps one node to one block and the class node already owns its own. + */ + private openTypeParamScope( + block: SymbolBlock, + node: Parser.SyntaxNode, + ownerName: string + ): SymbolBlock { + const list = node.children.find(c => c.type === 'type_parameter'); + if (!list) { + return block; + } + + const scope = this.createChildBlock( + block, + list, + SymbolBlockType.TYPE_PARAM, + PythonScopeKind.TYPE_PARAM, + ownerName + ); + + for (const entry of list.namedChildren) { + // Each parameter arrives wrapped in a `type` node; `T`, `*Ts` and `**P` all bind the + // bare identifier, and `T: bound` arrives as a `constrained_type`. + const inner = entry.type === 'type' ? entry.namedChild(0) : entry; + if (!inner) { + continue; + } + const constrained = inner.type === 'constrained_type' ? inner : null; + const nameNode = constrained + ? this.unwrapTypeNode(constrained.namedChild(0)) + : this.unwrapTypeNode(inner); + if (!nameNode) { + continue; + } + + this.addDef( + scope, + nameNode.text, + SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.TYPE_PARAM, + nameNode + ); + + // A bound gets its own scope, named for the parameter it constrains. + const boundNode = constrained ? this.unwrapTypeNode(constrained.namedChild(1)) : null; + if (boundNode) { + const boundScope = this.createChildBlock( + scope, + boundNode, + SymbolBlockType.TYPE_PARAM_BOUND, + PythonScopeKind.TYPE_PARAM_BOUND, + nameNode.text + ); + this.visitExpression(boundScope, boundNode, PythonNameContext.LOAD); + } + } + + return scope; + } + + /** + * A real PEP 695 `type A = …` statement (3.12). + * + * The alias NAME binds in the enclosing scope, and the VALUE is resolved in an annotation + * scope of its own — so a forward reference in an alias body is legal, which is the point + * of the construct. A generic alias wraps that value scope in a type-parameter scope, so + * `type A[W] = list[W]` is `type parameter 'A'` containing `type alias 'A'`, matching + * CPython exactly. + * + * The misparse guarded at the call site is the other reading of the same node — see + * `python-soft-keywords.ts`, where `type(obj).attr = value` lands here too. + */ + private visitTypeAliasStatement(block: SymbolBlock, node: Parser.SyntaxNode): void { + const left = this.unwrapTypeNode(node.namedChild(0)); + const value = this.unwrapTypeNode(node.namedChild(1)); + if (!left) { + this.visitGenericStatement(block, node); + return; + } + + // `type A = …` names itself with a bare identifier; `type A[W] = …` wraps that in a + // `generic_type` that also carries the parameter list. + const isGeneric = left.type === 'generic_type'; + const nameNode = isGeneric ? left.namedChild(0) : left; + const aliasName = nameNode?.text ?? ''; + if (nameNode) { + this.addDef( + block, + aliasName, + SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.TYPE_ALIAS, + nameNode + ); + } + + const typeParams = isGeneric ? this.openTypeParamScope(block, left, aliasName) : block; + + const aliasScope = this.createChildBlock( + typeParams, + node, + SymbolBlockType.TYPE_ALIAS, + PythonScopeKind.TYPE_ALIAS, + aliasName + ); + if (value) { + this.visitExpression(aliasScope, value, PythonNameContext.LOAD); + } + } + + /** + * Strips the wrappers tree-sitter puts around a type-parameter operand. + * + * `type` wraps every operand; `splat_type` additionally wraps `*Ts` and `**P`, and the + * star is NOT part of the bound name — CPython's symtable lists `Ts` and `P`, so binding + * `*Ts` would put a name in the table that no reference can ever match. + */ + private unwrapTypeNode(node: Parser.SyntaxNode | null): Parser.SyntaxNode | null { + let current = node; + while (current && (current.type === 'type' || current.type === 'splat_type')) { + current = current.namedChild(0); + } + return current; + } + + /** + * A `def` / `async def`, in CPython's exact visit order. + * + * The order is the specification, because it determines `scopeOrdinal` for + * every scope hidden in the signature. `symtable.c` does: + * + * ``` + * add the function's own name + * defaults (positional and positional-or-keyword) + * kw_defaults (keyword-only) + * annotations (posonly, args, vararg, kwarg, kwonly, THEN returns) + * decorators + * enter block -> parameters, then body + * ``` + * + * Two details are counter-intuitive and taken straight from CPython: + * decorators come **after** annotations, and the `*args` / `**kwargs` + * annotations are visited **before** the keyword-only ones. + */ + private visitFunctionDefinition( + block: SymbolBlock, + node: Parser.SyntaxNode, + decorators: Parser.SyntaxNode[] = [] + ): void { + const nameNode = node.childForFieldName('name'); + const parametersNode = node.childForFieldName('parameters'); + const returnTypeNode = node.childForFieldName('return_type'); + const bodyNode = node.childForFieldName('body'); + const functionName = nameNode?.text ?? ''; + + // The def binds its own name in the enclosing scope. + this.addDef( + block, + functionName, + SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.FUNCTION_DEF, + nameNode ?? node + ); + + // Defaults and decorators are evaluated in the ENCLOSING scope, so any scope they + // contain is a sibling of this function. That stays true under PEP 695: CPython puts a + // `.defaults` marker in the type-parameter scope but still evaluates the default + // expressions outside it. + if (parametersNode) { + this.visitParameterDefaults(block, parametersNode); + } + for (const decorator of decorators) { + this.visitExpressionChildren(block, decorator, PythonNameContext.LOAD); + } + + // A `def f[U](…)` opens an annotation scope that WRAPS the function, and the parameter + // and return ANNOTATIONS are resolved inside it — that is the whole point of the scope, + // since an annotation may mention `U`. + const typeParams = this.openTypeParamScope(block, node, functionName); + if (parametersNode) { + this.visitParameterAnnotations(typeParams, parametersNode); + } + if (returnTypeNode) { + this.visitAnnotation(typeParams, returnTypeNode); + } + + const child = this.createChildBlock( + typeParams, + node, + SymbolBlockType.FUNCTION, + PythonScopeKind.FUNCTION, + functionName + ); + child.isCoroutine = this.hasAsyncPrefix(node); + + if (parametersNode) { + this.visitParameterNames(child, parametersNode, PythonBindingOrigin.PARAMETER); + } + if (bodyNode) { + this.visitBody(child, bodyNode); + child.isGenerator = this.containsYield(bodyNode); + } + } + + /** + * A `class` statement, in CPython's exact visit order: bases, then keyword + * arguments such as `metaclass=`, then decorators, then the body. + */ + private visitClassDefinition( + block: SymbolBlock, + node: Parser.SyntaxNode, + decorators: Parser.SyntaxNode[] = [] + ): void { + const nameNode = node.childForFieldName('name'); + const argumentsNode = node.childForFieldName('superclasses'); + const bodyNode = node.childForFieldName('body'); + const className = nameNode?.text ?? ''; + + this.addDef( + block, + className, + SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.CLASS_DEF, + nameNode ?? node + ); + + // Decorators run in the enclosing scope, before anything the class opens. + for (const decorator of decorators) { + this.visitExpressionChildren(block, decorator, PythonNameContext.LOAD); + } + + // A `class C[T](Base)` opens an annotation scope that WRAPS the class, and once it + // exists the BASES are evaluated inside it rather than in the enclosing scope — a base + // may mention a type parameter, which is what CPython's `.generic_base` records. With no + // type parameters `openTypeParamScope` hands back `block`, so the bases stay where they + // were and nothing about an ordinary class changes. + const typeParams = this.openTypeParamScope(block, node, className); + + // Bases and keyword arguments still precede the body — the class body does not exist + // yet when they run. + if (argumentsNode) { + this.visitExpressionChildren(typeParams, argumentsNode, PythonNameContext.LOAD); + } + + const child = this.createChildBlock( + typeParams, + node, + SymbolBlockType.CLASS, + PythonScopeKind.CLASS, + className + ); + if (bodyNode) { + this.visitBody(child, bodyNode); + } + } + + private hasAsyncPrefix(node: Parser.SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.type === 'async') { + return true; + } + } + return false; + } + + /** + * Whether a body contains `yield` without crossing into a nested scope. + * + * A `yield` inside a nested `def` belongs to that def, not to this one, so the + * walk stops at every scope boundary. + */ + private containsYield(bodyNode: Parser.SyntaxNode): boolean { + const worklist: Parser.SyntaxNode[] = [bodyNode]; + while (worklist.length > 0) { + const node = worklist.pop(); + if (!node) { + continue; + } + if (node.type === 'yield') { + return true; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + // Lambdas and comprehensions can legally contain a yield that belongs + // to the enclosing function, but a nested def owns its own. + if (child && child.type !== 'function_definition' && child.type !== 'class_definition') { + worklist.push(child); + } + } + } + return false; + } + + // ------------------------------------------------------------- parameters + + /** + * Pass one over a parameter list: **default values only**, in source order. + * + * Defaults are evaluated once, at definition time, in the enclosing scope — + * which is what makes a mutable default shared across calls, and what makes a + * lambda in a default a sibling scope. + */ + private visitParameterDefaults( + block: SymbolBlock, + parametersNode: Parser.SyntaxNode + ): void { + for (let i = 0; i < parametersNode.namedChildCount; i++) { + const param = parametersNode.namedChild(i); + if (!param || param.isExtra) { + continue; + } + if (param.type !== 'default_parameter' && param.type !== 'typed_default_parameter') { + continue; + } + const valueNode = param.childForFieldName('value'); + if (valueNode) { + this.visitExpression(block, valueNode, PythonNameContext.LOAD); + } + } + } + + /** + * Pass two: **annotations only**, in CPython's order. + * + * CPython visits positional-only, then positional-or-keyword, then the + * `*args` annotation, then the `**kwargs` annotation, and only then the + * keyword-only annotations. That ordering is observable through + * `scopeOrdinal` whenever an annotation contains a lambda, so it is + * reproduced rather than approximated. + */ + private visitParameterAnnotations( + block: SymbolBlock, + parametersNode: Parser.SyntaxNode + ): void { + const positional: Parser.SyntaxNode[] = []; + const splats: Parser.SyntaxNode[] = []; + const keywordOnly: Parser.SyntaxNode[] = []; + let seenKeywordSeparator = false; + + for (let i = 0; i < parametersNode.namedChildCount; i++) { + const param = parametersNode.namedChild(i); + if (!param || param.isExtra) { + continue; + } + if (param.type === 'keyword_separator') { + seenKeywordSeparator = true; + continue; + } + if (param.type === 'list_splat_pattern') { + // A bare `*args` also opens the keyword-only section. + seenKeywordSeparator = true; + continue; + } + const typeNode = param.childForFieldName('type'); + if (!typeNode) { + continue; + } + if (this.isSplatParameter(param)) { + splats.push(typeNode); + // An annotated `*args` opens the keyword-only section too. + if (this.splatKind(param) === 'list') { + seenKeywordSeparator = true; + } + continue; + } + if (seenKeywordSeparator) { + keywordOnly.push(typeNode); + continue; + } + positional.push(typeNode); + } + + for (const annotation of [...positional, ...splats, ...keywordOnly]) { + this.visitAnnotation(block, annotation); + } + } + + /** Whether a `typed_parameter` wraps a `*args` / `**kwargs` splat. */ + private isSplatParameter(param: Parser.SyntaxNode): boolean { + return this.splatKind(param) !== null; + } + + private splatKind(param: Parser.SyntaxNode): 'list' | 'dictionary' | null { + for (let i = 0; i < param.namedChildCount; i++) { + const child = param.namedChild(i); + if (child?.type === 'list_splat_pattern') { + return 'list'; + } + if (child?.type === 'dictionary_splat_pattern') { + return 'dictionary'; + } + } + return null; + } + + /** Binds the parameter *names* in the function's own scope. */ + private visitParameterNames( + block: SymbolBlock, + parametersNode: Parser.SyntaxNode, + origin: PythonBindingOrigin + ): void { + for (let i = 0; i < parametersNode.namedChildCount; i++) { + const param = parametersNode.namedChild(i); + if (!param || param.isExtra) { + continue; + } + this.bindParameter(block, param, origin); + } + } + + private bindParameter( + block: SymbolBlock, + param: Parser.SyntaxNode, + origin: PythonBindingOrigin + ): void { + switch (param.type) { + case 'identifier': { + this.addDef(block, param.text, SymbolFlags.DEF_PARAM, origin, param); + return; + } + case 'default_parameter': + case 'typed_default_parameter': + case 'typed_parameter': { + const nameNode = param.childForFieldName('name') ?? param.namedChild(0); + if (nameNode?.type === 'identifier') { + this.addDef(block, nameNode.text, SymbolFlags.DEF_PARAM, origin, nameNode); + return; + } + // An ANNOTATED splat wraps its name one level deeper: + // `**kwargs: Any` is typed_parameter > dictionary_splat_pattern > + // identifier, where bare `**kwargs` is the splat pattern directly. + // Reading only the first named child silently loses the parameter, so + // `is_parameter` comes back false for every annotated *args/**kwargs. + if ( + nameNode?.type === 'list_splat_pattern' || + nameNode?.type === 'dictionary_splat_pattern' + ) { + const inner = nameNode.namedChild(0); + if (inner?.type === 'identifier') { + this.addDef(block, inner.text, SymbolFlags.DEF_PARAM, origin, inner); + } + } + return; + } + case 'list_splat_pattern': + case 'dictionary_splat_pattern': { + const nameNode = param.namedChild(0); + if (nameNode?.type === 'identifier') { + this.addDef(block, nameNode.text, SymbolFlags.DEF_PARAM, origin, nameNode); + } + return; + } + // `/` and `*` markers bind nothing. + case 'positional_separator': + case 'keyword_separator': { + return; + } + default: { + return; + } + } + } + + // ---------------------------------------------------------------- imports + + /** + * Import binding rules, which are about the **bound name** and nothing else. + * + * ```python + * import a.b.c # binds `a` only + * import a.b as ab # binds `ab` + * from m import x, y as z # binds `x` and `z` + * from m import * # binds nothing statically; marks the scope + * ``` + */ + private visitImport(block: SymbolBlock, node: Parser.SyntaxNode): void { + const isFromImport = + node.type === 'import_from_statement' || node.type === 'future_import_statement'; + const moduleNameNode = node.childForFieldName('module_name'); + + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + + // In a from-import the module part is not a binding. + if (isFromImport && child.id === moduleNameNode?.id) { + continue; + } + + if (child.type === 'wildcard_import') { + // `import *` binds names we cannot enumerate. Recorded as a soundness + // hole on the scope rather than guessed at. + block.usesWildcardImport = true; + continue; + } + + if (child.type === 'aliased_import') { + const aliasNode = child.childForFieldName('alias'); + if (aliasNode?.type === 'identifier') { + this.addDef( + block, + aliasNode.text, + SymbolFlags.DEF_IMPORT, + PythonBindingOrigin.IMPORT, + aliasNode + ); + } + continue; + } + + if (child.type === 'dotted_name') { + const firstSegment = child.namedChild(0); + if (!firstSegment) { + continue; + } + // `import a.b.c` binds `a`; `from m import name` binds `name`. + const boundNode = isFromImport + ? (child.namedChild(child.namedChildCount - 1) ?? firstSegment) + : firstSegment; + this.addDef( + block, + boundNode.text, + SymbolFlags.DEF_IMPORT, + PythonBindingOrigin.IMPORT, + boundNode + ); + continue; + } + + if (child.type === 'relative_import') { + // `from . import x` — the dots bind nothing; the members are separate + // named children handled by the dotted_name branch above. + continue; + } + } + } + + // ------------------------------------------------------- loops and blocks + + private visitForStatement(block: SymbolBlock, node: Parser.SyntaxNode): void { + const leftNode = node.childForFieldName('left'); + const rightNode = node.childForFieldName('right'); + const bodyNode = node.childForFieldName('body'); + const elseNode = node.childForFieldName('alternative'); + + if (rightNode) { + this.visitExpression(block, rightNode, PythonNameContext.LOAD); + } + if (leftNode) { + this.visitTarget(block, leftNode, PythonBindingOrigin.FOR_TARGET, PythonNameContext.STORE); + } + if (bodyNode) { + this.visitStatement(block, bodyNode); + } + if (elseNode) { + this.visitStatement(block, elseNode); + } + } + + private visitWithStatement(block: SymbolBlock, node: Parser.SyntaxNode): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'with_clause') { + for (let j = 0; j < child.namedChildCount; j++) { + const item = child.namedChild(j); + if (item?.type === 'with_item') { + this.visitWithItem(block, item); + } + } + continue; + } + this.visitStatement(block, child); + } + } + + /** + * One `with` item, unwrapping the parentheses PEP 617 allows around it. + * + * `with (m() as x):` wraps the `as_pattern` in a `parenthesized_expression`, + * so a direct type test misses it and the target never binds. Only the SINGLE + * item form wraps: `with (a as x, b as y):` is split by the grammar into two + * plain `with_item`s, and `with (a, b):` into two items whose values are the + * expressions, so neither is affected by this. Nested parentheses are why the + * unwrap is a loop rather than one step. + */ + private visitWithItem(block: SymbolBlock, item: Parser.SyntaxNode): void { + let valueNode = item.childForFieldName('value') ?? item.namedChild(0); + while (valueNode && valueNode.type === 'parenthesized_expression') { + const inner = valueNode.namedChild(0); + if (!inner) { + break; + } + valueNode = inner; + } + if (!valueNode) { + return; + } + if (valueNode.type === 'as_pattern') { + this.visitAsPattern(block, valueNode, PythonBindingOrigin.WITH_TARGET); + return; + } + this.visitExpression(block, valueNode, PythonNameContext.LOAD); + } + + /** + * A `try` statement, in symtable's order — which is **not** source order. + * + * CPython's symbol table visits `body`, then the `else` clause, then the + * `except` handlers, then `finally`. The compiler does not use that order, and + * source order does not either, but it is observable through `scopeOrdinal`: + * + * ```python + * try: + * CODESET + * except NameError: + * def getpreferredencoding(...): ... # source line 652, ordinal 30 + * else: + * def getpreferredencoding(...): ... # source line 657, ordinal 29 + * ``` + * + * The `else` definition is constructed first despite appearing later. This is + * from `locale.py` in the standard library, so it is load-bearing on real code + * rather than a curiosity. + */ + private visitTryStatement(block: SymbolBlock, node: Parser.SyntaxNode): void { + const handlers: Parser.SyntaxNode[] = []; + const elseClauses: Parser.SyntaxNode[] = []; + const finallyClauses: Parser.SyntaxNode[] = []; + const body: Parser.SyntaxNode[] = []; + + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'except_clause' || child.type === 'except_group_clause') { + handlers.push(child); + continue; + } + if (child.type === 'else_clause') { + elseClauses.push(child); + continue; + } + if (child.type === 'finally_clause') { + finallyClauses.push(child); + continue; + } + body.push(child); + } + + for (const part of body) { + this.visitStatement(block, part); + } + for (const part of elseClauses) { + this.visitStatement(block, part); + } + for (const handler of handlers) { + this.visitExceptClause(block, handler); + } + for (const part of finallyClauses) { + this.visitStatement(block, part); + } + } + + /** + * `except E as exc:` binds `exc`. `except E, exc:` is Python 2 and never + * reaches this code — the file is rejected before extraction. + */ + private visitExceptClause(block: SymbolBlock, node: Parser.SyntaxNode): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (child.type === 'as_pattern') { + this.visitAsPattern(block, child, PythonBindingOrigin.EXCEPT_TARGET); + continue; + } + if (child.type === 'block') { + this.visitStatement(block, child); + continue; + } + this.visitExpression(block, child, PythonNameContext.LOAD); + } + } + + private visitAsPattern( + block: SymbolBlock, + node: Parser.SyntaxNode, + origin: PythonBindingOrigin + ): void { + const valueNode = node.namedChild(0); + if (valueNode) { + this.visitExpression(block, valueNode, PythonNameContext.LOAD); + } + for (let i = 1; i < node.namedChildCount; i++) { + const target = node.namedChild(i); + if (target?.type === 'as_pattern_target') { + const inner = target.namedChild(0); + if (inner) { + this.visitTarget(block, inner, origin, PythonNameContext.STORE); + } + continue; + } + if (target) { + this.visitTarget(block, target, origin, PythonNameContext.STORE); + } + } + } + + /** + * `match` / `case` patterns. + * + * The trap here is that a capture and a class name look identical in the + * grammar — both are `dotted_name` under `case_pattern`: + * + * ```python + * case Point(x=px): # `Point` is a LOAD, `x` is an attribute name that binds + * # nothing, and only `px` is a capture + * case [a, *rest]: # `a` and `rest` are captures + * ``` + */ + private visitCasePattern(block: SymbolBlock, node: Parser.SyntaxNode): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) { + this.visitPatternNode(block, child); + } + } + } + + private visitPatternNode(block: SymbolBlock, node: Parser.SyntaxNode): void { + switch (node.type) { + case 'dotted_name': { + // A single bare identifier is a capture; a dotted path is a value load. + if (node.namedChildCount === 1) { + const nameNode = node.namedChild(0); + if (nameNode && nameNode.text !== '_') { + this.addDef( + block, + nameNode.text, + SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.MATCH_CAPTURE, + nameNode + ); + } + return; + } + const firstSegment = node.namedChild(0); + if (firstSegment) { + this.addUse(block, firstSegment.text); + } + return; + } + + case 'class_pattern': { + // The first child names the class and is a load; the rest are patterns. + const classNameNode = node.namedChild(0); + if (classNameNode?.type === 'dotted_name') { + const firstSegment = classNameNode.namedChild(0); + if (firstSegment) { + this.addUse(block, firstSegment.text); + } + } + for (let i = 1; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) { + this.visitPatternNode(block, child); + } + } + return; + } + + case 'keyword_pattern': { + // `x=px` — `x` is the attribute being matched and binds nothing. + for (let i = 1; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) { + this.visitPatternNode(block, child); + } + } + return; + } + + case 'splat_pattern': { + const nameNode = node.namedChild(0); + if (nameNode && nameNode.text !== '_') { + this.addDef( + block, + nameNode.text, + SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.MATCH_CAPTURE, + nameNode + ); + } + return; + } + + case 'as_pattern': { + // `case Event(kind=k) as e:` — the wrapped pattern binds its own + // captures, and the trailing bare identifier is a capture too. Note the + // alias here is a plain `identifier`, NOT the `as_pattern_target` that + // `with` and `except` produce. + const wrapped = node.namedChild(0); + if (wrapped) { + this.visitPatternNode(block, wrapped); + } + for (let i = 1; i < node.namedChildCount; i++) { + const alias = node.namedChild(i); + if (!alias) { + continue; + } + const aliasName = + alias.type === 'as_pattern_target' ? alias.namedChild(0) : alias; + if (aliasName?.type === 'identifier' && aliasName.text !== '_') { + this.addDef( + block, + aliasName.text, + SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.MATCH_CAPTURE, + aliasName + ); + } + } + return; + } + + case 'case_pattern': + case 'list_pattern': + case 'tuple_pattern': + case 'dict_pattern': + case 'union_pattern': { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) { + this.visitPatternNode(block, child); + } + } + return; + } + + default: { + // Literals and value patterns: any identifier in them is a load. + this.visitExpression(block, node, PythonNameContext.LOAD); + return; + } + } + } + + // ------------------------------------------------------------- assignment + + private visitAssignment(block: SymbolBlock, node: Parser.SyntaxNode): void { + const leftNode = node.childForFieldName('left'); + const typeNode = node.childForFieldName('type'); + const rightNode = node.childForFieldName('right'); + + // The value is evaluated before the target is bound. + if (rightNode) { + this.visitExpression(block, rightNode, PythonNameContext.LOAD); + } + if (typeNode) { + this.visitAnnotation(block, typeNode); + } + if (!leftNode) { + return; + } + + if (typeNode) { + // An annotated target. A *simple* name gets DEF_ANNOT | DEF_LOCAL even + // with no value, which is why `x: int` alone reports is_assigned=true. + if (leftNode.type === 'identifier') { + this.addDef( + block, + leftNode.text, + SymbolFlags.DEF_ANNOT | SymbolFlags.DEF_LOCAL, + rightNode + ? PythonBindingOrigin.ANNOTATED_ASSIGNMENT + : PythonBindingOrigin.ANNOTATION_ONLY, + leftNode + ); + const record = block.origins.get(leftNode.text); + if (record && !record.declaredTypeName) { + record.declaredTypeName = typeNode.text; + } + return; + } + // A PARENTHESISED name is not a simple target, and PEP 526 binds only + // simple ones: `(x): int` has `AnnAssign.simple == 0`, and CPython's + // symtable creates NO symbol for it — not a local, not even annotated. + // Verified directly: for `(y): int` inside a function, symtable reports + // y.is_local() False and y.is_annotated() False. Without this the name + // became a local and shadowed the global it was meant to annotate, which + // is the exact bug `test_var_annot_basic_semantics` exists to pin down. + // With a VALUE it is an ordinary assignment and does bind, so only the + // annotation-only form is skipped here. + // tree-sitter spells the parenthesised form `tuple_pattern`, not + // `parenthesized_expression` — `(y): int` parses as a tuple_pattern with a + // single identifier child. Checking for the latter matched nothing, so the + // first version of this fix changed no behaviour at all. + // A genuine tuple target is a SyntaxError here ("only single target (not + // tuple) can be annotated"), so a one-child tuple_pattern is always the + // parenthesised name. + if (leftNode.type === 'tuple_pattern' && !rightNode && leftNode.namedChildCount === 1) { + const inner = leftNode.namedChild(0); + if (inner && inner.type === 'identifier') { + this.visitExpression(block, typeNode, PythonNameContext.LOAD); + return; + } + } + // `obj.attr: int = 1` binds nothing; the target is still evaluated. + this.visitTarget(block, leftNode, PythonBindingOrigin.ANNOTATED_ASSIGNMENT, PythonNameContext.STORE); + return; + } + + this.visitTarget(block, leftNode, PythonBindingOrigin.ASSIGNMENT, PythonNameContext.STORE); + } + + /** + * Augmented assignment: `x += 1`. + * + * CPython sets `DEF_LOCAL` and **not** `USE` for the target, so + * `is_referenced` is false even though the operation obviously reads the name. + * Verified against CPython, not inferred from the semantics. + */ + private visitAugmentedAssignment(block: SymbolBlock, node: Parser.SyntaxNode): void { + const leftNode = node.childForFieldName('left'); + const rightNode = node.childForFieldName('right'); + + if (rightNode) { + this.visitExpression(block, rightNode, PythonNameContext.LOAD); + } + if (leftNode) { + this.visitTarget(block, leftNode, PythonBindingOrigin.AUGMENTED_ASSIGNMENT, PythonNameContext.STORE); + } + } + + /** + * Assignment targets, recursively. + * + * Only bare names bind. `self.x = 1` and `d[k] = 1` evaluate their object and + * bind nothing — which is precisely why instance attributes have no binding + * and need their own relation. + */ + private visitTarget( + block: SymbolBlock, + node: Parser.SyntaxNode, + origin: PythonBindingOrigin, + context: PythonNameContext + ): void { + switch (node.type) { + case 'identifier': { + this.addDef(block, node.text, SymbolFlags.DEF_LOCAL, origin, node); + return; + } + + case 'pattern_list': + case 'expression_list': + case 'tuple_pattern': + case 'list_pattern': + case 'tuple': + case 'list': { + const nestedOrigin = + origin === PythonBindingOrigin.ASSIGNMENT + ? PythonBindingOrigin.TUPLE_UNPACK_TARGET + : origin; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) { + this.visitTarget(block, child, nestedOrigin, context); + } + } + return; + } + + case 'list_splat_pattern': + case 'list_splat': { + const inner = node.namedChild(0); + if (inner) { + this.visitTarget(block, inner, PythonBindingOrigin.STAR_TARGET, context); + } + return; + } + + case 'parenthesized_expression': { + const inner = node.namedChild(0); + if (inner) { + this.visitTarget(block, inner, origin, context); + } + return; + } + + case 'attribute': + case 'subscript': { + // Binds nothing. Routed through visitExpression rather than straight to + // the children, because an `attribute`'s second child is the attribute + // LABEL and reading it as a name invents a binding for every + // `self.x = ...` in the codebase. + this.visitExpression(block, node, PythonNameContext.LOAD); + return; + } + + default: { + this.visitExpression(block, node, PythonNameContext.LOAD); + return; + } + } + } + + // ------------------------------------------------------------ expressions + + /** + * Visits an expression, recording reads and descending into nested scopes. + */ + private visitExpression( + block: SymbolBlock, + node: Parser.SyntaxNode, + context: PythonNameContext + ): void { + switch (node.type) { + case 'identifier': { + if (context === PythonNameContext.LOAD) { + this.addUse(block, node.text); + return; + } + this.addDef(block, node.text, SymbolFlags.DEF_LOCAL, PythonBindingOrigin.ASSIGNMENT, node); + return; + } + + case 'assignment': { + this.visitAssignment(block, node); + return; + } + + case 'augmented_assignment': { + this.visitAugmentedAssignment(block, node); + return; + } + + case 'named_expression': { + this.visitNamedExpression(block, node); + return; + } + + case 'attribute': { + // Only the object is a name; the attribute label resolves at runtime. + const objectNode = node.childForFieldName('object') ?? node.namedChild(0); + if (objectNode) { + this.visitExpression(block, objectNode, PythonNameContext.LOAD); + } + return; + } + + case 'member_type': { + // A dotted name in TYPE position whose left side is itself a type, as in + // `v: A[int].Inner`. The grammar gives it its own node rather than + // reusing `attribute`, so falling through to the generic walk visited the + // trailing identifier and invented a binding for `Inner` that CPython + // does not have. Only the left side is a name. + const leftNode = node.namedChild(0); + if (leftNode) { + this.visitExpression(block, leftNode, PythonNameContext.LOAD); + } + return; + } + + case 'splat_type': { + // `*Ts` in a type position — the name is the operand. + const operand = node.namedChild(0); + if (operand) { + this.visitExpression(block, operand, PythonNameContext.LOAD); + } + return; + } + + case 'keyword_argument': { + // `f(k=v)` — `k` is a parameter name, not a reference. + const valueNode = node.childForFieldName('value') ?? node.namedChild(1); + if (valueNode) { + this.visitExpression(block, valueNode, PythonNameContext.LOAD); + } + return; + } + + case 'function_definition': { + this.visitFunctionDefinition(block, node); + return; + } + + case 'class_definition': { + this.visitClassDefinition(block, node); + return; + } + + case 'lambda': { + this.visitLambda(block, node); + return; + } + + case 'list_comprehension': + case 'set_comprehension': + case 'dictionary_comprehension': + case 'generator_expression': { + this.visitComprehension(block, node); + return; + } + + default: { + this.visitExpressionChildren(block, node, context); + return; + } + } + } + + private visitExpressionChildren( + block: SymbolBlock, + node: Parser.SyntaxNode, + context: PythonNameContext + ): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) { + continue; + } + if (this.isStatementLike(child)) { + this.visitStatement(block, child); + continue; + } + this.visitExpression(block, child, context); + } + } + + private visitLambda(block: SymbolBlock, node: Parser.SyntaxNode): void { + const parametersNode = node.childForFieldName('parameters'); + const bodyNode = node.childForFieldName('body'); + + // Lambda defaults are evaluated in the enclosing scope, as with a def. + // A lambda cannot carry annotations, so only defaults apply here. + if (parametersNode) { + this.visitParameterDefaults(block, parametersNode); + } + + const child = this.createChildBlock( + block, + node, + SymbolBlockType.FUNCTION, + PythonScopeKind.LAMBDA, + PYTHON_LAMBDA_SCOPE_NAME + ); + + if (parametersNode) { + this.visitParameterNames(child, parametersNode, PythonBindingOrigin.LAMBDA_PARAM); + } + if (bodyNode) { + this.visitExpression(child, bodyNode, PythonNameContext.LOAD); + } + } + + /** + * Comprehensions and generator expressions. + * + * Two rules that are easy to miss and both change the answer: + * + * 1. **The outermost iterable is evaluated in the enclosing scope.** In + * `[i for i in range(3) for j in range(i)]`, `range(3)` runs outside and + * `range(i)` runs inside. + * 2. **A synthetic `.0` parameter holds that iterable.** It is a genuine + * `symtable.Symbol`, so it is emitted as a real binding — on the + * `PY3_0_11` target that is every comprehension in the corpus. + */ + private visitComprehension(block: SymbolBlock, node: Parser.SyntaxNode): void { + const scopeKind = this.comprehensionScopeKind(node.type); + const scopeName = this.comprehensionScopeName(node.type); + + const clauses: Parser.SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'for_in_clause') { + clauses.push(child); + } + } + + // Rule 1: the first clause's iterable belongs to the enclosing scope. + const firstClause = clauses[0]; + if (firstClause) { + const iterable = firstClause.childForFieldName('right'); + if (iterable) { + this.visitExpression(block, iterable, PythonNameContext.LOAD); + } + } + + const child = this.createChildBlock( + block, + node, + SymbolBlockType.FUNCTION, + scopeKind, + scopeName + ); + + // Rule 2: the implicit iterator parameter. + this.addDef( + child, + PYTHON_SYNTHETIC_ITERATOR, + SymbolFlags.DEF_PARAM, + PythonBindingOrigin.PARAMETER, + node + ); + + // Rule 3: the ELEMENT is visited LAST, after every clause -- not in source + // order, where it comes first. CPython's order inside the block is: + // generators[0].target, generators[0].ifs, then (iter, target, ifs) for each + // remaining generator, and only then the element. Verified on 3.10.4 by + // giving each position its own lambda and reading back the block order: + // + // [ (lambda: ELT)() for x in y if (lambda: IFF)() ] -> IFF, ELT + // [ (lambda: ELT)() for x in y for z in (lambda: IT2)() ] -> IT2, ELT + // + // Source order is right often enough to hide this: it only diverges when a + // clause and the element BOTH open a scope, and then it swaps their + // ordinals -- which is a dict comprehension with a listcomp in its value and + // a genexpr in its `if`, exactly what fontTools' subset_glyphs does. + const elementParts: Parser.SyntaxNode[] = []; + const clauseParts: Parser.SyntaxNode[] = []; + let seenClause = false; + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (!part) { + continue; + } + if (part.type === 'for_in_clause') { + seenClause = true; + } + if (seenClause) { + clauseParts.push(part); + } else { + elementParts.push(part); + } + } + + let clauseIndex = -1; + for (const part of clauseParts) { + if (part.type === 'for_in_clause') { + clauseIndex += 1; + const iterable = part.childForFieldName('right'); + // Every iterable except the first is evaluated inside the comprehension. + if (iterable && clauseIndex > 0) { + this.visitExpression(child, iterable, PythonNameContext.LOAD); + } + const target = part.childForFieldName('left'); + if (target) { + this.visitTarget( + child, + target, + PythonBindingOrigin.COMPREHENSION_TARGET, + PythonNameContext.STORE + ); + } + continue; + } + this.visitExpression(child, part, PythonNameContext.LOAD); + } + + for (const part of elementParts) { + // A dict comprehension visits its VALUE BEFORE ITS KEY -- + // symtable_handle_comprehension takes (elt=key, value=value) and emits + // `if (value) VISIT(value); VISIT(elt);`. Confirmed on 3.10.4: + // `{ (lambda: KEY)() : (lambda: VAL)() for x in y }` gives VAL, KEY. + if (part.type === 'pair') { + const value = part.childForFieldName('value'); + const key = part.childForFieldName('key'); + if (value) { + this.visitExpression(child, value, PythonNameContext.LOAD); + } + if (key) { + this.visitExpression(child, key, PythonNameContext.LOAD); + } + continue; + } + this.visitExpression(child, part, PythonNameContext.LOAD); + } + } + + private comprehensionScopeKind(nodeType: string): PythonScopeKind { + switch (nodeType) { + case 'set_comprehension': { + return PythonScopeKind.COMPREHENSION_SET; + } + case 'dictionary_comprehension': { + return PythonScopeKind.COMPREHENSION_DICT; + } + case 'generator_expression': { + return PythonScopeKind.GENERATOR_EXPRESSION; + } + case 'list_comprehension': + default: { + return PythonScopeKind.COMPREHENSION_LIST; + } + } + } + + private comprehensionScopeName(nodeType: string): string { + switch (nodeType) { + case 'set_comprehension': { + return 'setcomp'; + } + case 'dictionary_comprehension': { + return 'dictcomp'; + } + case 'generator_expression': { + return 'genexpr'; + } + case 'list_comprehension': + default: { + return 'listcomp'; + } + } + } + + /** + * The walrus operator, `:=`. + * + * Inside a comprehension the target binds in the nearest enclosing function or + * module scope, **not** in the comprehension — while the comprehension itself + * records the name as `DEF_LOCAL | DEF_NONLOCAL`, which pass 2 resolves to + * `FREE`. Both halves are needed to match CPython: + * + * ```python + * filtered = [y for y in values if (last_seen := y) > 0] + * # listcomp scope: last_seen -> assigned, free, nonlocal + * # function scope: last_seen -> assigned, local, referenced + * ``` + */ + private visitNamedExpression(block: SymbolBlock, node: Parser.SyntaxNode): void { + const targetNode = node.childForFieldName('name') ?? node.namedChild(0); + const valueNode = node.childForFieldName('value') ?? node.namedChild(1); + + if (valueNode) { + this.visitExpression(block, valueNode, PythonNameContext.LOAD); + } + if (!targetNode || targetNode.type !== 'identifier') { + return; + } + + // `f"{value:=10}"` is NOT a walrus. Inside an f-string replacement field the + // first `:` opens the format specifier, so CPython reads `=10` as the spec + // -- sign-aware padding to width 10 -- and `value` as a plain load. The + // tokenizer here is greedier and takes `:=` as one operator, inventing a + // binding that assigns something the program never assigns. A real walrus in + // an f-string requires parentheses, `f"{(value := 10)}"`, which arrives + // wrapped in a parenthesized_expression and so does not match this test. + // Even `f"{value := 10}"` with spaces is a format specifier to CPython. + // + // The value is still visited as an expression, which is what the nested + // replacement field case needs: `f"{x:={w}}"` parses its spec as a set + // literal, and walking it reports `w` as referenced, exactly as CPython + // reports the nested field. + if (node.parent?.type === 'interpolation') { + this.visitExpression(block, targetNode, PythonNameContext.LOAD); + return; + } + + const isComprehensionBlock = this.isComprehensionScope(block.scopeKind); + if (isComprehensionBlock) { + const owner = this.findNamedExpressionOwner(block); + // If the owning scope declared the name `global`, the comprehension + // INHERITS that and the walrus target is global there too -- not free. + // + // def f(): + // global G + // [G := 1 for _ in range(1)] # listcomp: G is global, declared + // + // Without this the comprehension reported G as free and nonlocal while + // symtable reports global and declared-global, and the owner picked up a + // spurious local. The name resolves to a module global at runtime, so the + // free reading points a consumer at the wrong binding entirely. + const mangled = this.mangleName(block, targetNode.text); + // Two ways the target lands in the global namespace. The owning function + // may have declared it `global`, or the owner may simply BE the module, + // which needs no declaration at all. symtable.c treats both as global but + // reaches them by different paths, and only the first was handled here. + const ownerIsModule = owner !== null && owner.scopeKind === PythonScopeKind.MODULE; + const ownerDeclaresGlobal = + owner !== null && ((owner.symbols.get(mangled) ?? 0) & SymbolFlags.DEF_GLOBAL) !== 0; + const targetIsGlobal = ownerIsModule || ownerDeclaresGlobal; + if (owner) { + // symtable_extend_namedexpr_scope returns straight after recording the + // directive for a ModuleBlock, never reaching the add_def_helper that + // sets DEF_LOCAL. So the module records the name as global and declared + // global but NOT as assigned -- the assignment is charged to the + // comprehension. A function owner does fall through and take DEF_LOCAL. + this.addDef( + owner, + targetNode.text, + ownerIsModule ? SymbolFlags.DEF_GLOBAL : SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.WALRUS, + targetNode + ); + } + // DEF_LOCAL stays set in the global case: the walrus still ASSIGNS, and + // symtable reports is_assigned true while resolving the name to the + // module global, so is_local is false. Dropping DEF_LOCAL here would have + // fixed the scope columns and broken the assignment column instead. + this.addDef( + block, + targetNode.text, + targetIsGlobal + ? SymbolFlags.DEF_LOCAL | SymbolFlags.DEF_GLOBAL + : SymbolFlags.DEF_LOCAL | SymbolFlags.DEF_NONLOCAL, + PythonBindingOrigin.WALRUS, + targetNode + ); + return; + } + + this.addDef( + block, + targetNode.text, + SymbolFlags.DEF_LOCAL, + PythonBindingOrigin.WALRUS, + targetNode + ); + } + + private isComprehensionScope(scopeKind: PythonScopeKind): boolean { + return ( + scopeKind === PythonScopeKind.COMPREHENSION_LIST || + scopeKind === PythonScopeKind.COMPREHENSION_SET || + scopeKind === PythonScopeKind.COMPREHENSION_DICT || + scopeKind === PythonScopeKind.GENERATOR_EXPRESSION + ); + } + + /** Walks out through nested comprehensions to the scope that owns the walrus. */ + private findNamedExpressionOwner(block: SymbolBlock): SymbolBlock | null { + let candidate = block.parent; + while (candidate !== null && this.isComprehensionScope(candidate.scopeKind)) { + candidate = candidate.parent; + } + return candidate; + } +} + +/** Exposed for the extractor, which needs the same classification. */ +export { COMPREHENSION_NODE_TYPES, SCOPE_NODE_TYPES }; diff --git a/parser/src/parsers/python/extractors/python-scope-extractor.ts b/parser/src/parsers/python/extractors/python-scope-extractor.ts new file mode 100644 index 000000000..cd379ac58 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-scope-extractor.ts @@ -0,0 +1,778 @@ +import * as path from 'path'; + +import { PYTHON_BUILTIN_NAMES } from '@/constants/python-constants'; + +import Parser from 'tree-sitter'; + +import { PyBindingRegistry, PyModuleRegistry, PyScopeRegistry } from '@/analysis-types/python'; +import { + PYTHON_MODULE_SCOPE_NAME, + PYTHON_TARGET_VERSION, +} from '@/constants/python-constants'; +import { + PythonBindingKind, + PythonBindingOrigin, +} from '@/enums/python/bindings'; +import { + PythonDialect, + PythonEmissionRegime, + PythonGrammarUsed, + PythonModuleKind, +} from '@/enums/python/modules'; +import { PythonScopeKind, PythonScopeOwnerKind, SymbolBlockType } from '@/enums/python/scopes'; +import { PythonDialectDetector } from '@/parsers/python/python-dialect-detector'; +import { PythonParser } from '@/parsers/python/python-parser'; +import { PythonScopeBuilder } from '@/parsers/python/extractors/python-scope-builder'; +import { + analyzeSymbolTable, + DEF_BOUND, + isOptimized, + SymbolFlags, + SymbolScope, +} from '@/parsers/python/extractors/python-symbol-table'; +import { Python2Finding, SymbolBlock } from '@/parsers/python/types'; +import { PythonSourcePositions } from '@/utils/python'; + +/** Everything the scope/binding stage produces for one file. */ +export interface PythonModuleExtraction { + /** `undefined` when the file was rejected — no facts are emitted at all. */ + module?: PyModuleRegistry; + scopes: PyScopeRegistry[]; + bindings: PyBindingRegistry[]; + /** The detected dialect. `PY2_DETECTED_REJECTED` means everything above is empty. */ + dialect: PythonDialect; + /** Python 2 constructs found, for `py_parse_gap` and `skipped-python-files.csv`. */ + python2Findings: Python2Finding[]; + /** + * Byte-offset to line/column index over this file's source, built once here and + * reused by every later stage. Declared because the stages already set and read + * it — it was assigned in two places and consumed in seven, and the interface + * simply never gained the field. + */ + positions: PythonSourcePositions; + + /** + * The parsed root, carried so the declaration stage does not re-parse. Files + * over 32,767 characters are expensive to parse and re-parsing would also + * risk the two stages disagreeing about the tree. + */ + rootNode?: Parser.SyntaxNode; + /** + * Scope-introducing `node.id` -> the `py_scope` PK for that node. + * + * This is how declarations link to scopes without re-deriving a qualified + * name. Keyed on `node.id` rather than tagged onto the node itself, because + * node-tree-sitter's wrapper cache evicts entries and a tag would silently + * vanish between stages. + */ + scopeHashByNodeId: Map; + /** Scope-introducing `node.id` -> the analysed symbol-table block. */ + blocksByNodeId: Map; + /** `(scopeHash, name)` -> binding PK, so declarations can link their bindings. */ + bindingHashByScopeAndName: Map; + /** Same key, the whole record: the classification lives in its predicates. */ + bindingByScopeAndName: Map; + /** + * Scope-introducing `node.id` -> the scope's qualified name. + * + * Declarations reuse this rather than re-deriving `__qualname__`, so a class + * and its scope can never disagree about their own name. + */ + qualifiedNameByNodeId: Map; +} + +export interface PythonExtractionInput { + sourceCode: string; + /** Repo-relative path, used verbatim in `filePath`. */ + filePath: string; + /** Service root, the Java convention. */ + baseMservPath: string; + /** Dotted module name. Defaults to the file's basename. */ + moduleQualifiedName?: string; + serviceVersionLinkHash: string; + emissionRegime?: PythonEmissionRegime; +} + +/** + * Emits the scope/binding spine — `py_module`, `py_scope`, `py_binding` — for one + * Python file. + * + * This is stage 1 of the build order and the only stage CPython adjudicates + * **exactly**: `symtable` is ground truth for the scope tree, for every + * `(scope, name)` pair, and for all eleven `Symbol` predicates. So this is where + * we find out whether the implementation is right, rather than merely plausible. + * + * ## Order of operations, and why rejection comes first + * + * 1. Parse (the parser owns the 32,767-character workaround). + * 2. **Detect dialect.** If any Python 2 construct is found, emit **nothing** — + * no module row, no scopes, no bindings — and return the findings so the + * caller can write `skipped-python-files.csv` and `py_parse_gap` rows. + * 3. Build the symbol table (pass 1), then analyse it (pass 2). + * 4. Emit rows in a deterministic pre-order. + * + * Rejection precedes emission because a Python 2 file parses *cleanly*: there is + * no error to catch downstream, so anything emitted before the check would be a + * confident wrong answer. + * + * ## Determinism + * + * Rows are accumulated and exported in a total order — scopes in scope-tree + * pre-order with siblings in source order, bindings sorted by name within each + * scope. Byte-identical output across runs is a gate, not a nicety, so nothing + * here depends on `Map` iteration order that a caller could perturb. + */ +export class PythonScopeExtractor { + private parser: PythonParser; + private detector: PythonDialectDetector; + + constructor(parser?: PythonParser, detector?: PythonDialectDetector) { + this.parser = parser ?? new PythonParser(); + this.detector = detector ?? new PythonDialectDetector(); + } + + extract(input: PythonExtractionInput): PythonModuleExtraction { + const regime = input.emissionRegime ?? PythonEmissionRegime.PY3_0_11; + if (regime !== PythonEmissionRegime.PY3_0_11) { + // The 3.12 regime differs structurally at every list/set/dict + // comprehension (PEP 709). There is no pinned 3.12 oracle, so emitting + // under it would produce facts nothing can adjudicate. Refusing is the + // honest failure; guessing is not. + throw new Error( + `emissionRegime ${regime} is not implemented: only PY3_0_11 is verified against the pinned oracle` + ); + } + + const tree = this.parser.parse(input.sourceCode); + const rootNode = this.parser.getRootNode(tree); + + const detection = this.detector.detect(rootNode, input.sourceCode); + if (detection.dialect !== PythonDialect.PY3) { + return { + scopes: [], + bindings: [], + dialect: detection.dialect, + python2Findings: detection.findings, + scopeHashByNodeId: new Map(), + blocksByNodeId: new Map(), + bindingHashByScopeAndName: new Map(), + bindingByScopeAndName: new Map(), + qualifiedNameByNodeId: new Map(), + positions: new PythonSourcePositions(input.sourceCode), + }; + } + + const moduleQualifiedName = + input.moduleQualifiedName ?? this.deriveModuleQualifiedName(input.filePath); + + const positions = new PythonSourcePositions(input.sourceCode); + + const builder = new PythonScopeBuilder(); + const rootBlock = builder.build(rootNode, moduleQualifiedName, positions); + analyzeSymbolTable(rootBlock); + + const module = this.buildModuleRow(input, rootNode, moduleQualifiedName, regime); + + const scopes: PyScopeRegistry[] = []; + const bindings: PyBindingRegistry[] = []; + const scopeHashByNodeId = new Map(); + const bindingHashByScopeAndName = new Map(); + this.emitBlock(rootBlock, module, '', input, scopes, bindings, { nextSymtableId: 0 }, { + scopeHashByNodeId, + bindingHashByScopeAndName, + }); + + // The module row is minted before its scope exists, so the FK is patched in. + const moduleScope = scopes[0]; + if (moduleScope) { + module.setModuleScopeLinkHash(moduleScope.getHash()); + } + + // Keyed exactly as the hash map is, so a consumer that has one has the + // other. Built here rather than in the expression stage because this is + // where the scope a binding belongs to is still known. + const bindingByScopeAndName = new Map(); + for (const binding of bindings) { + bindingByScopeAndName.set( + `${binding.getPyScopeLinkHash()}::${binding.getName()}`, + binding + ); + // A class-private name is BOUND mangled and WRITTEN raw. `__log_traceback` + // inside class Future binds `_Future__log_traceback`, while the expression + // that references it carries the spelling from the source, so a lookup by + // the written name missed and the reference stayed UNKNOWN. + // + // The mangled binding is therefore also reachable under the raw spelling. + // An existing entry is never overwritten: if a scope really does bind a + // name that looks like the unmangled form, that binding is the right + // answer and this alias must not displace it. + const raw = unmangledSpelling(binding.getName()); + if (raw !== null) { + const rawKey = `${binding.getPyScopeLinkHash()}::${raw}`; + if (!bindingByScopeAndName.has(rawKey)) { + bindingByScopeAndName.set(rawKey, binding); + } + } + } + + return { + module, + scopes, + bindings, + dialect: detection.dialect, + python2Findings: [], + rootNode, + scopeHashByNodeId, + blocksByNodeId: builder.getBlocksByNodeId(), + bindingHashByScopeAndName, + bindingByScopeAndName, + qualifiedNameByNodeId: this.collectQualifiedNames(builder.getBlocksByNodeId()), + positions, + }; + } + + /** Snapshots each block's qualified name, keyed by its introducing node. */ + private collectQualifiedNames( + blocksByNodeId: Map + ): Map { + const names = new Map(); + for (const [nodeId, block] of blocksByNodeId) { + names.set(nodeId, block.qualifiedName); + } + return names; + } + + // -------------------------------------------------------------- emission + + /** + * Emits one block and recurses, in pre-order with siblings in source order. + * + * The `symtableId` counter is a canonical pre-order ordinal rather than + * CPython's `get_id()`, which is a heap address and varies run to run. + */ + private emitBlock( + block: SymbolBlock, + module: PyModuleRegistry, + parentScopeHash: string, + input: PythonExtractionInput, + scopes: PyScopeRegistry[], + bindings: PyBindingRegistry[], + counter: { nextSymtableId: number }, + links: { + scopeHashByNodeId: Map; + bindingHashByScopeAndName: Map; + } + ): void { + const scope = PyScopeRegistry.builder( + block.scopeKind, + block.name, + block.qualifiedName, + module.getHash(), + input.filePath, + block.startLine, + block.startColumn, + input.serviceVersionLinkHash + ) + .withParent(parentScopeHash, block.nestingDepth) + .withSymtablePredicates(block.isNested, isOptimized(block), block.children.length > 0) + .withSymtableId(counter.nextSymtableId++) + .withFlags({ + usesWildcardImport: block.usesWildcardImport, + isGenerator: block.isGenerator, + isCoroutine: block.isCoroutine, + declaresGlobal: block.declaresGlobal, + declaresNonlocal: block.declaresNonlocal, + }) + .withEndPosition(block.endLine, block.endColumn) + .withScopeOrdinal(block.scopeOrdinal) + .withOwner(this.ownerKindFor(block), '') + .build(); + + scopes.push(scope); + links.scopeHashByNodeId.set(block.nodeId, scope.getHash()); + this.emitBindings(block, scope, module, input, bindings, links.bindingHashByScopeAndName); + + for (const child of block.children) { + this.emitBlock(child, module, scope.getHash(), input, scopes, bindings, counter, links); + } + } + + /** + * Emits one `py_binding` row per `(scope, name)`. + * + * Names are sorted so that output is byte-identical across runs regardless of + * the order pass 1 happened to encounter them in. + */ + private emitBindings( + block: SymbolBlock, + scope: PyScopeRegistry, + module: PyModuleRegistry, + input: PythonExtractionInput, + bindings: PyBindingRegistry[], + bindingHashByScopeAndName: Map + ): void { + const names = Array.from(block.symbols.keys()).sort(); + const childNames = new Set(block.children.map(child => child.name)); + + for (const name of names) { + const flags = block.symbols.get(name) ?? 0; + const symbolScope = block.scopes.get(name) ?? SymbolScope.NONE; + const origin = block.origins.get(name); + + // CPython's `symtable.py` decides "is this module scope?" by comparing the + // table's NAME to "top", not by checking its type: + // + // module_scope = (self._table.name == "top") + // + // So a function or class literally named `top` gets module-scope + // semantics for is_local/is_global. That is surprising, and arguably a + // CPython wart, but symtable is the oracle: `poplib.POP3.top` reports its + // parameters as is_global=true, and matching CPython means reproducing it. + const isModuleScope = block.name === PYTHON_MODULE_SCOPE_NAME; + + // The eleven predicates, computed exactly as `symtable.Symbol` does. + // The module-scope special case in is_local/is_global is CPython's own: a + // bound name at module level is simultaneously local and global. + const boundAtModuleScope = isModuleScope && (flags & DEF_BOUND) !== 0; + const predicates = { + isParameter: (flags & SymbolFlags.DEF_PARAM) !== 0, + isLocal: + symbolScope === SymbolScope.LOCAL || + symbolScope === SymbolScope.CELL || + boundAtModuleScope, + isGlobal: + symbolScope === SymbolScope.GLOBAL_IMPLICIT || + symbolScope === SymbolScope.GLOBAL_EXPLICIT || + boundAtModuleScope, + isNonlocal: (flags & SymbolFlags.DEF_NONLOCAL) !== 0, + isFree: symbolScope === SymbolScope.FREE, + isImported: (flags & SymbolFlags.DEF_IMPORT) !== 0, + isAssigned: (flags & SymbolFlags.DEF_LOCAL) !== 0, + isReferenced: (flags & SymbolFlags.USE) !== 0, + isDeclaredGlobal: symbolScope === SymbolScope.GLOBAL_EXPLICIT, + isAnnotated: (flags & SymbolFlags.DEF_ANNOT) !== 0, + // `is_namespace` is true when the name binds a def or class *here* — + // CPython tests whether any child symbol table carries this name. A + // lambda bound as `f = lambda: 1` does NOT qualify: its child table is + // named `lambda`, not `f`. + isNamespace: childNames.has(name), + }; + + const binding = PyBindingRegistry.builder( + name, + scope.getHash(), + module.getHash(), + input.filePath, + input.serviceVersionLinkHash + ) + .withKindAndOrigin( + this.bindingKindFor(block, name, flags, symbolScope, predicates.isParameter), + this.bindingOriginFor(origin) + ) + .withSymbolPredicates(predicates) + .withBindingSites( + origin?.bindingCount ?? 0, + origin?.firstLine ?? 0, + origin?.lastLine ?? 0 + ) + .withDeclaredType(origin?.declaredTypeName ?? '', '', '', false) + .build(); + + bindings.push(binding); + bindingHashByScopeAndName.set(`${scope.getHash()}::${name}`, binding.getHash()); + } + } + + /** + * Maps a resolved symbol scope to `py_binding.bindingKind`. + * + * The order of these tests matters: a parameter is also `LOCAL` by scope, and + * an imported name is also bound, so the more specific answer has to win. + */ + /** True when the module's own top-level block binds this name, shadowing a builtin. */ + private moduleBinds(block: SymbolBlock, name: string): boolean { + let current: SymbolBlock | null = block; + while (current !== null && current.parent !== null) { + current = current.parent; + } + if (current === null) { + return false; + } + const flags = current.symbols.get(name) ?? 0; + return (flags & DEF_BOUND) !== 0; + } + + private bindingKindFor( + block: SymbolBlock, + name: string, + flags: number, + symbolScope: number, + isParameter: boolean + ): PythonBindingKind { + if (symbolScope === SymbolScope.GLOBAL_EXPLICIT) { + return PythonBindingKind.GLOBAL_EXPLICIT; + } + if (symbolScope === SymbolScope.GLOBAL_IMPLICIT) { + // A builtin, unless the module shadows it. symtable reports both as + // "global" because LOAD_GLOBAL checks module globals first and builtins + // second, and which one wins is a runtime fact. The module block is the + // one place that settles it statically: if nothing in this module binds + // the name, the reference reaches the builtin. Without this the BUILTIN + // kind was declared and never emitted, and `len` was indistinguishable + // from a global someone defined. + if (PYTHON_BUILTIN_NAMES.has(name) && !this.moduleBinds(block, name)) { + return PythonBindingKind.BUILTIN; + } + return PythonBindingKind.GLOBAL_IMPLICIT; + } + if ((flags & SymbolFlags.DEF_NONLOCAL) !== 0) { + return PythonBindingKind.NONLOCAL; + } + if (symbolScope === SymbolScope.FREE) { + return PythonBindingKind.FREE; + } + if (isParameter) { + return PythonBindingKind.PARAMETER; + } + if ((flags & SymbolFlags.DEF_IMPORT) !== 0) { + return PythonBindingKind.IMPORTED; + } + if (symbolScope === SymbolScope.CELL) { + return PythonBindingKind.CELL; + } + if (block.blockType === SymbolBlockType.MODULE) { + return PythonBindingKind.MODULE_LEVEL; + } + if (block.blockType === SymbolBlockType.CLASS) { + return PythonBindingKind.CLASS_ATTRIBUTE; + } + if (symbolScope === SymbolScope.LOCAL) { + // An annotation with no value and no assignment anywhere. + if ((flags & SymbolFlags.DEF_ANNOT) !== 0 && (flags & SymbolFlags.DEF_LOCAL) === 0) { + return PythonBindingKind.ANNOTATED_ONLY; + } + return PythonBindingKind.LOCAL; + } + return PythonBindingKind.UNKNOWN; + } + + /** + * Collapses the recorded syntactic origins into one value. + * + * A name bound by two different forms in one scope reports `MULTIPLE` rather + * than arbitrarily picking the first, because "which form" is then genuinely + * not a single fact. + */ + private bindingOriginFor(origin?: { + origins: Set; + }): PythonBindingOrigin { + if (!origin || origin.origins.size === 0) { + // No binding site in this scope: the name is a read, or a free variable + // spliced in from a child. + return PythonBindingOrigin.ASSIGNMENT; + } + if (origin.origins.size > 1) { + return PythonBindingOrigin.MULTIPLE; + } + const [only] = origin.origins; + return only ?? PythonBindingOrigin.ASSIGNMENT; + } + + private ownerKindFor(block: SymbolBlock): PythonScopeOwnerKind { + switch (block.scopeKind) { + case PythonScopeKind.MODULE: { + return PythonScopeOwnerKind.MODULE; + } + case PythonScopeKind.CLASS: { + return PythonScopeOwnerKind.TYPE; + } + case PythonScopeKind.LAMBDA: { + return PythonScopeOwnerKind.LAMBDA; + } + case PythonScopeKind.COMPREHENSION_LIST: + case PythonScopeKind.COMPREHENSION_SET: + case PythonScopeKind.COMPREHENSION_DICT: + case PythonScopeKind.GENERATOR_EXPRESSION: { + return PythonScopeOwnerKind.COMPREHENSION; + } + default: { + return PythonScopeOwnerKind.METHOD; + } + } + } + + // ---------------------------------------------------------------- module + + private buildModuleRow( + input: PythonExtractionInput, + rootNode: Parser.SyntaxNode, + moduleQualifiedName: string, + regime: PythonEmissionRegime + ): PyModuleRegistry { + const fileName = path.basename(input.filePath); + const isStub = fileName.endsWith('.pyi'); + const isPackage = fileName === '__init__.py' || fileName === '__init__.pyi'; + const segments = moduleQualifiedName.split('.'); + const name = segments[segments.length - 1] ?? moduleQualifiedName; + const packageQualifiedName = segments.slice(0, -1).join('.'); + + const moduleKind = this.moduleKindFor(rootNode, isPackage, isStub); + const dunderAll = this.readDunderAll(rootNode); + + return PyModuleRegistry.builder( + name, + moduleQualifiedName, + fileName, + input.filePath, + input.baseMservPath, + moduleKind, + regime, + PYTHON_TARGET_VERSION, + input.serviceVersionLinkHash + ) + .withPackage(packageQualifiedName, isPackage) + .withIsStub(isStub) + .withDialect(PythonDialect.PY3) + .withGrammarUsed( + rootNode.hasError ? PythonGrammarUsed.TS_PYTHON3_PARTIAL : PythonGrammarUsed.TS_PYTHON3 + ) + .withFutureImports(this.readFutureImports(rootNode)) + .withEncodingDeclared(this.readEncodingCookie(input.sourceCode)) + .withHasModuleDocstring(this.hasModuleDocstring(rootNode)) + .withDunderAll(dunderAll.present, dunderAll.isStatic, dunderAll.names) + .build(); + } + + /** Falls back to the file's basename, matching the oracle's own default. */ + private deriveModuleQualifiedName(filePath: string): string { + const base = path.basename(filePath); + return base.replace(/\.pyi?$/, ''); + } + + private moduleKindFor( + rootNode: Parser.SyntaxNode, + isPackage: boolean, + isStub: boolean + ): PythonModuleKind { + if (isStub) { + return PythonModuleKind.STUB; + } + if (isPackage) { + return PythonModuleKind.PACKAGE_INIT; + } + if (this.hasMainGuard(rootNode)) { + return PythonModuleKind.MAIN_GUARD_SCRIPT; + } + return PythonModuleKind.MODULE; + } + + /** `if __name__ == "__main__":` at module level. */ + private hasMainGuard(rootNode: Parser.SyntaxNode): boolean { + for (let i = 0; i < rootNode.namedChildCount; i++) { + const child = rootNode.namedChild(i); + if (child?.type !== 'if_statement') { + continue; + } + const condition = child.childForFieldName('condition'); + if (condition && condition.text.includes('__name__')) { + return true; + } + } + return false; + } + + private hasModuleDocstring(rootNode: Parser.SyntaxNode): boolean { + const first = rootNode.namedChild(0); + if (first?.type !== 'expression_statement') { + return false; + } + return first.namedChild(0)?.type === 'string'; + } + + /** + * `from __future__ import annotations` and friends. + * + * Still load-bearing on Python 3: PEP 563 decides whether annotations are + * strings at runtime, which changes what a type reference means. + */ + private readFutureImports(rootNode: Parser.SyntaxNode): string[] { + const futures: string[] = []; + for (let i = 0; i < rootNode.namedChildCount; i++) { + const child = rootNode.namedChild(i); + // tree-sitter-python gives `from __future__ import x` its OWN node type, + // `future_import_statement`, rather than the ordinary + // `import_from_statement`. Matching only the ordinary one meant this + // returned empty for every file in existence — the grammar never produces + // the shape it was looking for. + if (child?.type !== 'future_import_statement' && child?.type !== 'import_from_statement') { + continue; + } + const moduleName = child.childForFieldName('module_name'); + if (child.type === 'import_from_statement' && moduleName?.text !== '__future__') { + continue; + } + for (let j = 0; j < child.namedChildCount; j++) { + const member = child.namedChild(j); + if (member && member.id !== moduleName?.id && member.type === 'dotted_name') { + futures.push(member.text); + } + } + } + return futures; + } + + /** PEP 263 encoding cookie, which may appear on either of the first two lines. */ + private readEncodingCookie(sourceCode: string): string { + const lines = sourceCode.split('\n', 2); + for (const line of lines) { + const match = /coding[:=]\s*([-\w.]+)/.exec(line); + if (match && line.trimStart().startsWith('#')) { + return match[1] ?? ''; + } + } + return ''; + } + + /** + * Reads `__all__`. + * + * `isStatic` is false when `__all__` is anything other than a list or tuple of + * string literals — `__all__ = __all__ + _d` and `__all__.extend(...)` both + * occur in real code. At roughly 3% of modules this is small, but treating a + * dynamically built `__all__` as authoritative is wrong in exactly the + * direction that hides public API, so it is flagged rather than guessed. + */ + /** + * `__all__`, and whether it can be read literally. + * + * `dunderAllIsStatic` is the load-bearing column: a consumer restricting a + * wildcard re-export trusts `dunderAllNames` when it is true, and falls back + * to the underscore rule when it is false. So a WRONG `true` is far worse + * than a `false` -- it makes an exported name look unexported, and a name the + * language really does export then resolves to nothing. + * + * Two shapes produced exactly that, and both are common: + * + * ```python + * __all__ = ["base"] # this was read, and returned immediately + * __all__ += ["extra"] # this was never seen: `extra` was dropped + * + * if sys.platform == "win32": # not a direct child of the module, so the + * __all__ = ["win_only"] # module reported no __all__ at all + * ``` + * + * The whole module is therefore scanned for every site that BINDS or MUTATES + * the name, not just the first top-level assignment. `static` now means what + * a consumer needs it to mean: there is exactly one such site, it is a plain + * top-level assignment, and every element is a string literal. Anything else + * -- an augmented assignment, an `append`/`extend`/`remove` call, a second + * assignment, or an assignment nested inside a conditional -- is reported + * present but not static, which routes the consumer to the underscore rule. + * + * Over-approximating there is safe; under-approximating is not. + */ + private readDunderAll(rootNode: Parser.SyntaxNode): { + present: boolean; + isStatic: boolean; + names: string[]; + } { + let sites = 0; + let topLevelAssignment: Parser.SyntaxNode | null = null; + + const worklist: Parser.SyntaxNode[] = [rootNode]; + while (worklist.length > 0) { + const node = worklist.pop(); + if (!node) { + continue; + } + if (node.type === 'assignment' || node.type === 'augmented_assignment') { + if (node.childForFieldName('left')?.text === '__all__') { + sites += 1; + // Only a plain assignment written directly in the module body can be + // read literally. `expression_statement` is its parent, the module is + // its grandparent. + if ( + node.type === 'assignment' && + node.parent?.type === 'expression_statement' && + node.parent.parent?.id === rootNode.id + ) { + topLevelAssignment = node; + } + } + } + // `__all__.append(...)`, `.extend(...)`, `.remove(...)` mutate it just as + // surely as `+=` does, and a literal read after one of them is wrong. + if (node.type === 'call') { + const fn = node.childForFieldName('function'); + if (fn?.type === 'attribute' && fn.childForFieldName('object')?.text === '__all__') { + sites += 1; + } + } + for (let i = 0; i < node.namedChildCount; i += 1) { + const child = node.namedChild(i); + if (child) { + worklist.push(child); + } + } + } + + if (sites === 0) { + return { present: false, isStatic: false, names: [] }; + } + if (sites > 1 || topLevelAssignment === null) { + return { present: true, isStatic: false, names: [] }; + } + + const value = topLevelAssignment.childForFieldName('right'); + if (!value || (value.type !== 'list' && value.type !== 'tuple')) { + return { present: true, isStatic: false, names: [] }; + } + const names: string[] = []; + let allLiterals = true; + for (let j = 0; j < value.namedChildCount; j += 1) { + const element = value.namedChild(j); + if (element?.type !== 'string') { + allLiterals = false; + continue; + } + names.push(this.stringLiteralValue(element)); + } + return { present: true, isStatic: allLiterals, names: allLiterals ? names : [] }; + } + + private stringLiteralValue(stringNode: Parser.SyntaxNode): string { + for (let i = 0; i < stringNode.namedChildCount; i++) { + const child = stringNode.namedChild(i); + if (child?.type === 'string_content') { + return child.text; + } + } + return ''; + } +} + + +/** + * The source spelling of a mangled class-private name, or null. + * + * CPython rewrites `__x` inside `class C` to `_C__x`. The rule is narrow, and + * matching it loosely would alias ordinary names: mangling applies only to a + * name with two or more leading underscores and at most one trailing one, so + * `__init__` and `_x` are untouched. The prefix is the class name with its own + * leading underscores stripped, which is why the pattern requires a leading `_` + * followed by a non-underscore. + */ +function unmangledSpelling(name: string): string | null { + const match = /^_[A-Za-z0-9][A-Za-z0-9_]*?(__[A-Za-z0-9][A-Za-z0-9_]*)$/.exec(name); + if (!match) { + return null; + } + const raw = match[1]!; + if (raw.endsWith('__')) { + return null; + } + return raw; +} + +/** Re-exported so callers can name the module scope without re-deriving it. */ +export { PYTHON_MODULE_SCOPE_NAME }; diff --git a/parser/src/parsers/python/extractors/python-symbol-table.ts b/parser/src/parsers/python/extractors/python-symbol-table.ts new file mode 100644 index 000000000..6c4a31f57 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-symbol-table.ts @@ -0,0 +1,329 @@ +import { PythonScopeKind } from '@/enums/python/scopes/PythonScopeKind'; +import { SymbolBlockType } from '@/enums/python/scopes'; +import { SymbolBlock } from '@/parsers/python/types'; + +/** + * CPython's symbol-table flags, verbatim from `Include/internal/pycore_symtable.h`. + * + * These are reproduced as literal bit values rather than re-invented because the + * whole point of this module is to compute what CPython computes. A flag set is + * accumulated per `(block, name)` in pass 1; pass 2 turns it into one of the + * {@link SymbolScope} values. + */ +export const SymbolFlags = { + /** `global x` appeared in this block. */ + DEF_GLOBAL: 1, + /** The name is assigned to in this block. */ + DEF_LOCAL: 2, + /** The name is a parameter of this block. */ + DEF_PARAM: 2 << 1, + /** `nonlocal x` appeared in this block. */ + DEF_NONLOCAL: 2 << 2, + /** The name is read in this block. */ + USE: 2 << 3, + /** Free-variable marker used while splicing children's free sets. */ + DEF_FREE: 2 << 4, + /** A class body's free variable that shadows a class-level binding. */ + DEF_FREE_CLASS: 2 << 5, + /** The name is bound by an `import` statement. */ + DEF_IMPORT: 2 << 6, + /** The name carries an annotation. */ + DEF_ANNOT: 2 << 7, + /** Comprehension iteration variable marker. */ + DEF_COMP_ITER: 2 << 8, +} as const; + +/** + * "Bound" means the name is genuinely established in this block, by any of the + * three mechanisms that do so. CPython treats these as one test in several + * places, so it is one constant here too. + */ +export const DEF_BOUND = + SymbolFlags.DEF_LOCAL | SymbolFlags.DEF_PARAM | SymbolFlags.DEF_IMPORT; + +/** + * The resolved scope of a name, computed in pass 2. CPython's own values. + */ +export const SymbolScope = { + NONE: 0, + LOCAL: 1, + GLOBAL_EXPLICIT: 2, + GLOBAL_IMPLICIT: 3, + FREE: 4, + CELL: 5, +} as const; + +export type SymbolScopeValue = (typeof SymbolScope)[keyof typeof SymbolScope]; + +/** Creates an empty block. Kept in one place so no field is ever forgotten. */ +export function createSymbolBlock(init: { + nodeId: number; + privateNamePrefix: string; + blockType: SymbolBlockType; + scopeKind: PythonScopeKind; + name: string; + qualifiedName: string; + parent: SymbolBlock | null; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + nestingDepth: number; +}): SymbolBlock { + return { + ...init, + children: [], + scopeOrdinal: 0, + symbols: new Map(), + origins: new Map(), + // ste_nested: set when the immediate parent is nested, or is a function + // block. A method of a module-level class is therefore NOT nested, while a + // function inside a function is. + isNested: + init.parent !== null && + (init.parent.isNested || init.parent.blockType === SymbolBlockType.FUNCTION), + usesWildcardImport: false, + declaresGlobal: false, + declaresNonlocal: false, + isGenerator: false, + isCoroutine: false, + scopes: new Map(), + hasFree: false, + hasChildFree: false, + }; +} + +/** Adds flags to a name in a block, creating the entry if needed. */ +export function addSymbolFlags(block: SymbolBlock, name: string, flags: number): void { + block.symbols.set(name, (block.symbols.get(name) ?? 0) | flags); +} + +/** + * `SymbolTable.is_optimized()` — true exactly for function blocks. + * + * Lambdas and comprehensions are function blocks, so they are optimized too. + * The Python 2 `exec`-statement de-optimisation that used to complicate this is + * gone: Python 3's `exec()` is an ordinary function call. + */ +export function isOptimized(block: SymbolBlock): boolean { + return block.blockType === SymbolBlockType.FUNCTION; +} + +/** + * Pass 2 — CPython's `analyze_block`, transcribed. + * + * Pass 1 records only what each block says about each name. It cannot decide + * scope, because "is this name free?" depends on blocks that have not been + * visited yet, and "is this local a cell?" depends on whether any *descendant* + * captures it. So resolution is a separate top-down walk that threads three sets + * through the tree: + * + * - `bound` — names bound in enclosing **function** scopes, i.e. what a nested + * block may capture. Class bodies deliberately do not contribute. + * - `global` — names declared `global` somewhere up the chain. + * - `free` — names a block or its descendants failed to bind locally and + * therefore captured; spliced back up on the way out. + * + * The class-body asymmetry is the subtle part and it is load-bearing: a class + * namespace has **no effect** on names visible in nested functions, so a method + * cannot see its class's attributes as locals. That is why the `bound` set is + * populated *before* class variables are added, and why `drop_class_free` + * exists. + */ +export function analyzeSymbolTable(root: SymbolBlock): void { + analyzeBlock(root, new Set(), new Set(), new Set()); +} + +function analyzeBlock( + block: SymbolBlock, + bound: Set, + free: Set, + global: Set +): void { + const local = new Set(); + const scopes = new Map(); + const newGlobal = new Set(); + const newFree = new Set(); + const newBound = new Set(); + + // A class namespace has no effect on names visible in nested functions, so + // the sets handed to children are populated BEFORE the class's own variables + // are analysed below. + if (block.blockType === SymbolBlockType.CLASS) { + for (const name of global) { + newGlobal.add(name); + } + for (const name of bound) { + newBound.add(name); + } + } + + for (const [name, flags] of block.symbols) { + analyzeName(block, scopes, name, flags, bound, local, free, global); + } + + if (block.blockType !== SymbolBlockType.CLASS) { + // Only a function's locals are visible to nested scopes. + if (block.blockType === SymbolBlockType.FUNCTION) { + for (const name of local) { + newBound.add(name); + } + } + for (const name of bound) { + newBound.add(name); + } + for (const name of global) { + newGlobal.add(name); + } + } else { + // `__class__` is implicitly available to methods that use `super()` or + // reference it directly. + newBound.add('__class__'); + } + + for (const child of block.children) { + // Each child gets its own copies: these sets are consumed by every block + // enclosed by this one, and a child must not mutate its siblings' view. + const childFree = new Set(); + analyzeBlock(child, new Set(newBound), childFree, new Set(newGlobal)); + for (const name of childFree) { + newFree.add(name); + } + if (child.hasFree || child.hasChildFree) { + block.hasChildFree = true; + } + } + + if (block.blockType === SymbolBlockType.FUNCTION) { + analyzeCells(scopes, newFree); + } else if (block.blockType === SymbolBlockType.CLASS) { + dropClassFree(block, newFree); + } + + updateSymbols(block, scopes, bound, newFree, block.blockType === SymbolBlockType.CLASS); + + // Propagate this block's unresolved free names to the caller. + for (const name of newFree) { + free.add(name); + } + + block.scopes = scopes; +} + +/** + * CPython's `analyze_name`. The order of these tests is the specification — + * `global` beats `nonlocal` beats a local binding beats capture beats an + * enclosing `global` beats the implicit-global fallback. + */ +function analyzeName( + block: SymbolBlock, + scopes: Map, + name: string, + flags: number, + bound: Set, + local: Set, + free: Set, + global: Set +): void { + if (flags & SymbolFlags.DEF_GLOBAL) { + scopes.set(name, SymbolScope.GLOBAL_EXPLICIT); + global.add(name); + bound.delete(name); + return; + } + + if (flags & SymbolFlags.DEF_NONLOCAL) { + // A `nonlocal` with no binding in any enclosing scope is a SyntaxError in + // CPython. We record the declared intent rather than throwing: rejecting the + // file would lose every other fact in it, and the engine can see the + // NONLOCAL binding with no target. + scopes.set(name, SymbolScope.FREE); + block.hasFree = true; + free.add(name); + return; + } + + if (flags & DEF_BOUND) { + scopes.set(name, SymbolScope.LOCAL); + local.add(name); + global.delete(name); + return; + } + + // Not bound here: if an enclosing function scope binds it, this is a capture. + if (bound.has(name)) { + scopes.set(name, SymbolScope.FREE); + block.hasFree = true; + free.add(name); + return; + } + + // An enclosing `global` declaration makes it global; otherwise the fallback + // is the module namespace, which is also where builtins are found. + scopes.set(name, SymbolScope.GLOBAL_IMPLICIT); +} + +/** + * `analyze_cells` — a local that some descendant captured is not a frame slot + * but a closure cell. + * + * This is why pass 2 cannot be merged into pass 1: the promotion depends on + * children that pass 1 has not yet reached. + */ +function analyzeCells(scopes: Map, free: Set): void { + for (const [name, scope] of scopes) { + if (scope !== SymbolScope.LOCAL) { + continue; + } + if (!free.has(name)) { + continue; + } + scopes.set(name, SymbolScope.CELL); + free.delete(name); + } +} + +/** + * `drop_class_free` — a class body does not provide closure cells, so a name a + * method captured must keep travelling outward past the class. + */ +function dropClassFree(block: SymbolBlock, free: Set): void { + if (free.delete('__class__')) { + block.hasFree = true; + } +} + +/** + * `update_symbols` — writes the resolved scopes back, then records the free + * variables the children could not resolve. + * + * The second loop is what makes a captured name appear as a `FREE` symbol in + * every intermediate scope between the capture and the binding, which is how + * `nonlocal count` two levels down still resolves to one binding. + */ +function updateSymbols( + block: SymbolBlock, + scopes: Map, + bound: Set, + free: Set, + isClassBlock: boolean +): void { + for (const name of free) { + const existing = block.symbols.get(name); + if (existing !== undefined) { + // A free variable in a method whose name is also a class-level binding. + if (isClassBlock && existing & (DEF_BOUND | SymbolFlags.DEF_GLOBAL)) { + block.symbols.set(name, existing | SymbolFlags.DEF_FREE_CLASS); + } + // Already a cell, or already free in this scope — nothing to add. + continue; + } + if (!bound.has(name)) { + // Not free in this scope either; it keeps travelling outward. + continue; + } + // Propagate the new free symbol up the lexical stack. + block.symbols.set(name, SymbolFlags.DEF_FREE); + scopes.set(name, SymbolScope.FREE); + } +} diff --git a/parser/src/parsers/python/extractors/python-type-parameter-extractor.ts b/parser/src/parsers/python/extractors/python-type-parameter-extractor.ts new file mode 100644 index 000000000..146788943 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-type-parameter-extractor.ts @@ -0,0 +1,311 @@ +import Parser from 'tree-sitter'; + +import { + PyMethodRegistry, + PyModuleRegistry, + PyTypeParameterRegistry, + PyTypeRegistry, +} from '@/analysis-types/python'; +import { PythonExpressionOwnerKind } from '@/enums/python/expressions'; +import { + PythonTypeParameterKind, + PythonTypeParameterVariance, +} from '@/enums/python/type-parameters'; + +export interface PythonTypeParameterInput { + module: PyModuleRegistry; + rootNode: Parser.SyntaxNode; + filePath: string; + serviceVersionLinkHash: string; + types: PyTypeRegistry[]; + methods: PyMethodRegistry[]; + typeHashByNodeId: Map; + methodHashByNodeId: Map; + scopeHashByNodeId: Map; +} + +/** + * Extracts PEP 695 type parameters — the `T` in `class Box[T]`. + * + * Only the 3.12 SYNTAX produces rows. On 3.11 and earlier a `TypeVar` is a + * runtime assignment rather than a declaration, and §2.20 puts those in + * `py_binding` with `targetEntityKind=TYPE_VAR` — a different fact, correctly + * modelled differently. + * + * Three things tree-sitter 0.21 does that the grammar reference does not warn + * about, each verified against the tree rather than assumed: + * + * 1. `type_parameter` is NOT unique to PEP 695. `Dict[str, int]` uses the same + * node under `generic_type`, so a blind search for it would report every + * subscript generic in the file as a declared parameter. Only a DIRECT child + * of a class, function or type-alias statement counts. + * 2. `*Ts` and `**P` both parse to `splat_type` with no distinction between + * them. A TypeVarTuple stands for a SEQUENCE of types and a ParamSpec for a + * whole parameter LIST, so the two are told apart by reading the source text. + * 3. PEP 696 defaults — `class B[T = int]` — do NOT parse at all in 0.21; the + * tree carries an ERROR node. `defaultText` is therefore always empty here, + * and the failure is not hidden: it surfaces as a `py_parse_gap` ERROR_NODE + * row, which is exactly what that relation is for. + */ +export class PythonTypeParameterExtractor { + extract(input: PythonTypeParameterInput): PyTypeParameterRegistry[] { + const parameters: PyTypeParameterRegistry[] = []; + const typeByHash = new Map(input.types.map(t => [t.getHash(), t])); + const methodByHash = new Map(input.methods.map(m => [m.getHash(), m])); + + const worklist: Parser.SyntaxNode[] = [input.rootNode]; + while (worklist.length > 0) { + const node = worklist.shift()!; + const owner = this.ownerOf(node, input, typeByHash, methodByHash); + if (owner) { + for (const list of this.declaredParameterLists(node)) { + this.emitFrom(list, owner, input, parameters); + } + } + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child && !child.isExtra) { + worklist.push(child); + } + } + } + return parameters; + } + + /** + * The `type_parameter` lists that DECLARE parameters on this node. + * + * A direct child for a class or function. A TYPE ALIAS is different and the + * difference is easy to miss: `type Alias[T] = list[T]` nests its parameters + * under `type > generic_type`, so a direct-child rule finds none and the alias + * silently contributes nothing. + * + * The rule stays strict everywhere else, because `type_parameter` is NOT + * unique to PEP 695 — `Dict[str, int]` uses the same node under + * `generic_type` — so an unrestricted search would report every subscript + * generic in the file as a declared parameter. + */ + private declaredParameterLists(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const found: Parser.SyntaxNode[] = []; + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (!child || child.isExtra) { + continue; + } + if (child.type === 'type_parameter') { + found.push(child); + continue; + } + // The alias shape only, and only its FIRST `type` child. A + // `type_alias_statement` has two: the left side `Alias[T]`, which + // DECLARES, and the right side `list[T]`, which USES. Both are + // `generic_type` with a `type_parameter` under them, so descending into + // both reported every alias parameter twice. + if (node.type === 'type_alias_statement' && child.type === 'type' && found.length === 0 && index === 0) { + const generic = child.namedChild(0); + if (generic?.type === 'generic_type') { + for (let inner = 0; inner < generic.namedChildCount; inner += 1) { + const candidate = generic.namedChild(inner); + if (candidate?.type === 'type_parameter') { + found.push(candidate); + } + } + } + } + } + return found; + } + + private ownerOf( + node: Parser.SyntaxNode, + input: PythonTypeParameterInput, + typeByHash: Map, + methodByHash: Map + ): { + hash: string; + name: string; + qualifiedName: string; + kind: PythonExpressionOwnerKind; + scopeHash: string; + } | null { + if (node.type === 'class_definition') { + const hash = input.typeHashByNodeId.get(node.id) ?? ''; + const type = typeByHash.get(hash); + return { + hash, + name: type?.getName() ?? node.childForFieldName('name')?.text ?? '', + qualifiedName: type?.getQualifiedName() ?? '', + kind: PythonExpressionOwnerKind.TYPE, + scopeHash: this.typeParamScopeHash( + input, node, input.scopeHashByNodeId.get(node.id) ?? ''), + }; + } + if (node.type === 'function_definition') { + const hash = input.methodHashByNodeId.get(node.id) ?? ''; + const method = methodByHash.get(hash); + return { + hash, + name: method?.getName() ?? node.childForFieldName('name')?.text ?? '', + qualifiedName: method?.getQualifiedName() ?? '', + kind: PythonExpressionOwnerKind.METHOD, + scopeHash: this.typeParamScopeHash( + input, node, input.scopeHashByNodeId.get(node.id) ?? ''), + }; + } + if (node.type === 'type_alias_statement') { + // `type Alias[T] = list[T]` — the alias is not a py_type or py_method, so + // the owner is the MODULE. The parameter is still real and still scoped. + return { + hash: input.module.getHash(), + name: this.aliasNameOf(node), + qualifiedName: `${input.module.getQualifiedName()}.${this.aliasNameOf(node)}`, + kind: PythonExpressionOwnerKind.MODULE, + scopeHash: this.typeParamScopeHash( + input, node, input.scopeHashByNodeId.get(node.id) ?? ''), + }; + } + return null; + } + + /** + * The scope a type parameter LIVES in, which is the annotation scope its list opens — + * not the class, function or alias that list belongs to. + * + * `class C[T]` nests `class C` inside `type parameter C`, and `T` binds in the wrapper. + * Linking to the inner scope put the parameter one level below the scope that actually + * holds its binding, so a consumer resolving `T` inside an annotation looked in the wrong + * table. Falls back to the owner's own scope, which is what happens on any tree where the + * wrapper was not created. + */ + private typeParamScopeHash( + input: PythonTypeParameterInput, + node: Parser.SyntaxNode, + ownerScopeHash: string + ): string { + const direct = node.children.find(c => c.type === 'type_parameter'); + if (direct) { + return input.scopeHashByNodeId.get(direct.id) ?? ownerScopeHash; + } + // `type A[W] = …` carries the list inside `type` -> `generic_type`. + let left = node.namedChild(0); + while (left && left.type === 'type') { + left = left.namedChild(0); + } + const nested = left?.children.find(c => c.type === 'type_parameter'); + return (nested && input.scopeHashByNodeId.get(nested.id)) || ownerScopeHash; + } + + /** + * A `type_alias_statement` nests its name inside a `generic_type` when it has + * parameters, so the name is not a direct `name` field. + */ + private aliasNameOf(node: Parser.SyntaxNode): string { + const first = node.namedChild(0); + if (!first) { + return ''; + } + if (first.type === 'identifier') { + return first.text; + } + const inner = first.namedChild(0); + if (inner?.type === 'identifier') { + return inner.text; + } + if (inner?.type === 'generic_type') { + return inner.namedChild(0)?.text ?? ''; + } + return ''; + } + + /** One `type_parameter` list may hold several parameters. */ + private emitFrom( + list: Parser.SyntaxNode, + owner: { + hash: string; + name: string; + qualifiedName: string; + kind: PythonExpressionOwnerKind; + scopeHash: string; + }, + input: PythonTypeParameterInput, + out: PyTypeParameterRegistry[] + ): void { + let position = 0; + for (let index = 0; index < list.namedChildCount; index += 1) { + const entry = list.namedChild(index); + if (!entry || entry.isExtra || entry.type === 'ERROR') { + continue; + } + const detail = this.detailOf(entry); + if (detail.name === '') { + continue; + } + out.push( + new PyTypeParameterRegistry( + detail.name, + position++, + owner.name, + owner.qualifiedName, + input.filePath, + entry.startPosition.row + 1, + owner.hash, + owner.kind, + detail.bound, + // PEP 695 removed explicit variance: the checker infers it from usage, + // so the source genuinely does not say and neither do we. + PythonTypeParameterVariance.INFERRED, + // PEP 696 defaults do not parse in tree-sitter 0.21 — the construct + // becomes an ERROR node, which py_parse_gap records. Left empty rather + // than guessed. + '', + owner.scopeHash, + detail.kind, + input.serviceVersionLinkHash + ) + ); + } + } + + /** The parameter's name and bound, from whichever shape the entry has. */ + private detailOf(entry: Parser.SyntaxNode): { + name: string; + bound: string; + kind: PythonTypeParameterKind; + } { + const inner = entry.type === 'type' ? entry.namedChild(0) : entry; + if (!inner) { + return { name: '', bound: '', kind: PythonTypeParameterKind.TYPE_VAR }; + } + if (inner.type === 'identifier') { + return { name: inner.text, bound: '', kind: PythonTypeParameterKind.TYPE_VAR }; + } + if (inner.type === 'splat_type') { + // `*Ts` and `**P` are the SAME node — the stars are the only difference and + // they live in the text rather than the tree. A TypeVarTuple stands for a + // sequence of types and a ParamSpec for a whole parameter list, so reading + // the text is the only way to keep them apart. + return { + name: inner.namedChild(0)?.text ?? '', + bound: '', + kind: inner.text.startsWith('**') + ? PythonTypeParameterKind.PARAM_SPEC + : PythonTypeParameterKind.TYPE_VAR_TUPLE, + }; + } + if (inner.type === 'constrained_type') { + const nameNode = inner.namedChild(0); + const boundNode = inner.namedChild(1); + const named = this.detailOf(nameNode ?? inner); + return { + name: named.name, + bound: boundNode ? boundNode.text.replace(/\s+/g, ' ').trim() : '', + kind: named.kind, + }; + } + return { + name: inner.text.replace(/\s+/g, ' ').trim(), + bound: '', + kind: PythonTypeParameterKind.TYPE_VAR, + }; + } +} diff --git a/parser/src/parsers/python/extractors/python-type-reference-extractor.ts b/parser/src/parsers/python/extractors/python-type-reference-extractor.ts new file mode 100644 index 000000000..4eb5634c4 --- /dev/null +++ b/parser/src/parsers/python/extractors/python-type-reference-extractor.ts @@ -0,0 +1,863 @@ +import Parser from 'tree-sitter'; + +import { PyTypeReferenceRegistry } from '@/analysis-types/python'; +import { + PythonTypeRefContext, + PythonTypeRefKind, + PythonTypeRefOwnerKind, + PythonWildcardVariance, +} from '@/enums/python/type-references'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** A `X = TypeVar("X", ...)` declaration: its variance, and its bound if it has one. */ +interface TypeVariableDeclaration { + variance: string; + bound: Parser.SyntaxNode | null; +} + +/** One type position to walk: a node plus who owns it and in what role. */ +export interface TypePositionInput { + node: Parser.SyntaxNode; + context: PythonTypeRefContext; + ownerHash: string; + ownerKind: PythonTypeRefOwnerKind; + /** Enclosing class, or `''`. */ + enclosingTypeHash: string; + /** The scope the annotation is evaluated in. */ + scopeHash: string; +} + +/** + * Everything needed to recover the type positions that live inside + * EXPRESSIONS rather than declarations. + */ +export interface NarrowingInput { + rootNode: Parser.SyntaxNode; + /** `start:end` byte range -> py_expression hash, as the expression stage mints it. */ + expressionByByteRange: Map; + /** py_expression hash -> its scope and enclosing type, for the owning row. */ + scopeByExpressionHash: Map; + typeByExpressionHash: Map; + /** py_expression hash -> the binding it resolves to, where it resolves to one. */ + bindingByExpressionHash: Map; +} + +export interface TypeReferenceInput { + positions: TypePositionInput[]; + /** The module root, scanned once for `X = TypeVar("X")` declarations. */ + rootNode: Parser.SyntaxNode; + /** `scopeHash::name` -> binding hash. A TypeVar's bound is owned by its BINDING. */ + bindingHashByScopeAndName: Map; + /** The module scope, where a TypeVar is conventionally declared. */ + moduleScopeHash: string; + pyModuleLinkHash: string; + serviceVersionLinkHash: string; +} + +/** `Optional[X]` admits None; `Union[..., None]` does too. */ +const OPTIONAL_NAMES: ReadonlySet = new Set(['Optional']); +const UNION_NAMES: ReadonlySet = new Set(['Union']); +const CALLABLE_NAMES: ReadonlySet = new Set(['Callable']); +const TUPLE_NAMES: ReadonlySet = new Set(['Tuple', 'tuple']); +const LITERAL_NAMES: ReadonlySet = new Set(['Literal']); +const ANY_NAMES: ReadonlySet = new Set(['Any']); + +/** + * Calls that DECLARE a type variable. + * + * `typing.TypeVar` and a bare `TypeVar` are the same declaration, and an alias + * from `import typing as t` reaches here as `t.TypeVar`, so the comparison is + * on the simple name. PEP 612 `ParamSpec` and PEP 646 `TypeVarTuple` declare + * related things with different arity rules and are deliberately not folded in + * here -- they need their own kinds, not this one. + */ +const TYPE_VAR_FACTORIES: ReadonlySet = new Set(['TypeVar']); + +/** + * Builds the `py_type_reference` tree for every type position in a module. + * + * ## The shape, and why it is a tree + * + * A composite annotation references several types that are **related to each + * other**, so one row per annotation cannot carry it. Each reference gets its own + * row, and `parentReferenceHash` + `position` + `depth` link them: + * + * ```python + * def f(m: Dict[TypeA, TypeB]): ... + * + * d0 SUBSCRIPT Dict complete=Dict[TypeA, TypeB] + * d1 NAME TypeA parent= position=0 + * d1 NAME TypeB parent= position=1 + * ``` + * + * Note the shape differs deliberately from the `py_expression` tree for the same + * text. There, `SUBSCRIPT` is a node and `Dict` is its first child. Here `Dict` + * **is** the depth-0 reference and the subscript arguments are its children — + * which is what `java_type_reference` does, and what makes "the type being + * parameterised" and "its parameters" a parent/child pair rather than siblings. + * + * Nesting composes to any depth: + * + * ```python + * x: Dict[TypeA, List[Optional[TypeB]]] + * + * d0 SUBSCRIPT Dict + * d1 NAME TypeA parent=Dict position=0 + * d1 SUBSCRIPT List parent=Dict position=1 + * d2 OPTIONAL Optional parent=List position=0 isOptional + * d3 NAME TypeB parent=Optional position=0 + * ``` + */ +export class PythonTypeReferenceExtractor { + private references: PyTypeReferenceRegistry[] = []; + /** reference PK -> `start:end` of the node it came from, for the expression join. */ + readonly byteRangeByReference = new Map(); + private input!: TypeReferenceInput; + + /** + * Type positions that appear inside expressions, not declarations. + * + * These were entirely absent: on 600 stdlib modules the contexts + * ISINSTANCE_TYPE, ISSUBCLASS_TYPE and RAISE_TYPE were emitted ZERO times, + * and so was the owner kind EXPRESSION, while `isinstance(x, Foo)` and + * `raise ValueError(...)` appear in nearly every file. They are declared in + * the enums, so nothing about the schema was waiting on a decision -- the + * facts were simply never produced. + * + * They matter more than their column count suggests. A receiver whose type is + * unknowable from its declaration is often pinned exactly once by an + * `isinstance` guard, and that guard is the only static evidence there will + * ever be. Emitting it turns a receiver that no join could resolve into one + * that can be, without inventing anything: the reference is resolved by the + * same pass that resolves an annotation, so it either names a type in the + * corpus or stays empty. + * + * The owner is the CALL expression rather than the narrowed variable. Both + * are defensible, but the call carries the position, and a consumer that + * wants the variable can read the call's first argument -- whereas owning by + * the variable would throw away WHERE the narrowing holds, which is the part + * that makes it sound to use. + */ + collectNarrowingPositions(input: NarrowingInput): TypePositionInput[] { + const positions: TypePositionInput[] = []; + this.walkNarrowing(input.rootNode, input, positions); + return positions; + } + + private walkNarrowing( + node: Parser.SyntaxNode, + input: NarrowingInput, + positions: TypePositionInput[] + ): void { + if (node.type === 'call') { + const callee = node.childForFieldName('function'); + const name = callee && callee.type === 'identifier' ? callee.text : ''; + if (name === 'isinstance' || name === 'issubclass') { + const args = node.childForFieldName('arguments'); + const second = args ? this.positionalArgument(args, 1) : null; + if (second) { + const owner = input.expressionByByteRange.get(`${node.startIndex}:${node.endIndex}`); + if (owner !== undefined) { + const context = + name === 'isinstance' + ? PythonTypeRefContext.ISINSTANCE_TYPE + : PythonTypeRefContext.ISSUBCLASS_TYPE; + // `isinstance(x, (A, B))` is a tuple of alternatives, and each is a + // separate candidate type rather than one composite type. Flattening + // keeps every alternative individually resolvable. + for (const candidate of this.tupleAlternatives(second)) { + positions.push({ + node: candidate, + context, + ownerHash: owner, + ownerKind: PythonTypeRefOwnerKind.EXPRESSION, + enclosingTypeHash: input.typeByExpressionHash.get(owner) ?? '', + scopeHash: input.scopeByExpressionHash.get(owner) ?? '', + }); + } + } + } + } + } + + if (node.type === 'except_clause' || node.type === 'except_group_clause') { + this.collectExceptPositions(node, input, positions); + } + + if (node.type === 'raise_statement') { + const raised = node.namedChild(0); + if (raised) { + // `raise ValueError(...)` names the type through the callee; `raise err` + // and `raise ValueError` name it directly. + const typeNode = + raised.type === 'call' ? raised.childForFieldName('function') : raised; + const ownerNode = raised; + const owner = input.expressionByByteRange.get( + `${ownerNode.startIndex}:${ownerNode.endIndex}` + ); + if (typeNode && owner !== undefined) { + positions.push({ + node: typeNode, + context: PythonTypeRefContext.RAISE_TYPE, + ownerHash: owner, + ownerKind: PythonTypeRefOwnerKind.EXPRESSION, + enclosingTypeHash: input.typeByExpressionHash.get(owner) ?? '', + scopeHash: input.scopeByExpressionHash.get(owner) ?? '', + }); + } + } + } + + for (let index = 0; index < node.namedChildCount; index += 1) { + const child = node.namedChild(index); + if (child) { + this.walkNarrowing(child, input, positions); + } + } + } + + /** + * `except ValueError as e:` is the one narrowing construct that types a + * VARIABLE outright, with no inference and no guard to reason about: inside + * that handler `e` IS a ValueError. So where the handler binds a name, the + * reference is owned by that BINDING rather than by an expression -- which is + * what lets a consumer resolve `e.args` without having to notice that an + * except clause was involved. + * + * Where there is no `as` the type is still worth recording, and it is owned by + * the exception expression instead. + * + * `except (A, B) as e:` is flattened to one reference per alternative, as + * isinstance is: `e` is one of them and each is separately resolvable. + */ + private collectExceptPositions( + node: Parser.SyntaxNode, + input: NarrowingInput, + positions: TypePositionInput[] + ): void { + const first = node.namedChild(0); + if (!first) { + return; + } + const isAs = first.type === 'as_pattern'; + const typeNode = isAs ? first.namedChild(0) : first; + if (!typeNode || typeNode.type === 'block') { + return; + } + + let ownerHash = ''; + let ownerKind = PythonTypeRefOwnerKind.EXPRESSION; + if (isAs) { + const alias = first.namedChild(1); + const target = + alias && alias.type === 'as_pattern_target' ? alias.namedChild(0) : alias; + const targetExpression = target + ? input.expressionByByteRange.get(`${target.startIndex}:${target.endIndex}`) + : undefined; + const binding = + targetExpression !== undefined + ? input.bindingByExpressionHash.get(targetExpression) + : undefined; + if (binding !== undefined && binding !== '') { + ownerHash = binding; + ownerKind = PythonTypeRefOwnerKind.BINDING; + } + } + if (ownerHash === '') { + ownerHash = + input.expressionByByteRange.get(`${typeNode.startIndex}:${typeNode.endIndex}`) ?? ''; + ownerKind = PythonTypeRefOwnerKind.EXPRESSION; + } + if (ownerHash === '') { + return; + } + + for (const candidate of this.tupleAlternatives(typeNode)) { + positions.push({ + node: candidate, + context: PythonTypeRefContext.EXCEPT_TYPE, + ownerHash, + ownerKind, + enclosingTypeHash: + ownerKind === PythonTypeRefOwnerKind.EXPRESSION + ? input.typeByExpressionHash.get(ownerHash) ?? '' + : '', + scopeHash: + ownerKind === PythonTypeRefOwnerKind.EXPRESSION + ? input.scopeByExpressionHash.get(ownerHash) ?? '' + : '', + }); + } + } + + /** The nth POSITIONAL argument, skipping keywords and splats. */ + private positionalArgument(args: Parser.SyntaxNode, wanted: number): Parser.SyntaxNode | null { + let seen = 0; + for (let index = 0; index < args.namedChildCount; index += 1) { + const child = args.namedChild(index); + if (!child || child.isExtra) { + continue; + } + if ( + child.type === 'keyword_argument' || + child.type === 'list_splat' || + child.type === 'dictionary_splat' + ) { + continue; + } + if (seen === wanted) { + return child; + } + seen += 1; + } + return null; + } + + /** The members of `(A, B)`, or the node itself when it is not a tuple. */ + private tupleAlternatives(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const inner = this.unwrap(node); + if (!inner) { + return []; + } + if (inner.type !== 'tuple') { + return [inner]; + } + const members: Parser.SyntaxNode[] = []; + for (let index = 0; index < inner.namedChildCount; index += 1) { + const child = inner.namedChild(index); + if (child && !child.isExtra) { + members.push(child); + } + } + return members; + } + + private typeVariables: Map = new Map(); + + /** + * One reference per bounded TypeVar, owned by the variable's own BINDING. + * + * A bound is a type position like any other, but it is not reachable from the + * declaration walk: it sits inside a CALL on the right of an assignment, not + * in an annotation, so nothing upstream collects it. It is synthesised here + * because this is already the only place that knows which names are type + * variables. + * + * The owner is the binding rather than the module, which is what the schema's + * `referenceOwnerKind = BINDING` is for: the bound belongs to `B`, not to the + * file `B` happens to sit in, and a consumer asking what constrains `B` joins + * from the binding. + * + * A TypeVar whose binding cannot be found is skipped rather than owned by + * something else. An unowned reference would be a row pointing at nothing, + * which is worse than the absence it replaces. + */ + private emitTypeVariableBounds(): void { + for (const [name, declaration] of this.typeVariables) { + if (declaration.bound === null) { + continue; + } + const ownerHash = this.bindingHashForTypeVariable(name); + if (ownerHash === null) { + continue; + } + const node = this.unwrap(declaration.bound); + if (!node) { + continue; + } + this.emit( + node, + { + node, + context: PythonTypeRefContext.TYPEVAR_BOUND, + ownerHash, + ownerKind: PythonTypeRefOwnerKind.BINDING, + enclosingTypeHash: '', + scopeHash: this.input.moduleScopeHash, + }, + PythonTypeRefContext.TYPEVAR_BOUND, + '', + 0, + 0 + ); + } + } + + extract(input: TypeReferenceInput): PyTypeReferenceRegistry[] { + this.typeVariables = this.collectTypeVariables(input.rootNode); + this.input = input; + this.references = []; + this.byteRangeByReference.clear(); + + for (const position of input.positions) { + const node = this.unwrap(position.node); + if (node) { + this.emit(node, position, position.context, '', 0, 0); + } + } + this.emitTypeVariableBounds(); + return this.references; + } + + /** + * Strips the wrappers that carry no type of their own: the grammar's `type` + * node and redundant parentheses. + */ + private unwrap(node: Parser.SyntaxNode | null): Parser.SyntaxNode | null { + let current = node; + while ( + current && + (current.type === 'type' || current.type === 'parenthesized_expression') + ) { + current = current.namedChild(0); + } + return current; + } + + /** + * Emits one reference and recurses into its arguments. + * + * `context` is `GENERIC_ARGUMENT` for anything nested, so a query can tell "the + * declared type of this parameter" from "a type mentioned inside it". + */ + private emit( + node: Parser.SyntaxNode, + position: TypePositionInput, + context: PythonTypeRefContext, + parentHash: string, + index: number, + depth: number + ): PyTypeReferenceRegistry | null { + if (depth > 12) { + // Annotations nest a few levels in practice; this only guards pathological + // or malformed input. + return null; + } + + const base = this.subscriptBase(node); + const typeName = this.simpleNameOf(base ?? node); + // A name bound by TypeVar() is a type VARIABLE, not a reference to a class + // of that name. Only a bare name is reclassified: in `List[T]` the head is + // List and the variable is the argument, each of which gets its own row. + const declaredVariance = + base === null ? this.typeVariables.get(typeName)?.variance : undefined; + const kind = + declaredVariance !== undefined + ? PythonTypeRefKind.TYPE_VAR + : this.kindOf(node, base); + const complete = EntityUtils.normalizeWhitespace(node.text) + .replace(/\[\s+/g, '[') + .replace(/\s+\]/g, ']') + .replace(/\s+,/g, ','); + + const reference = PyTypeReferenceRegistry.builder( + kind, + context, + typeName, + complete, + position.ownerHash, + position.ownerKind, + this.input.pyModuleLinkHash, + node.startPosition.row + 1, + this.input.serviceVersionLinkHash + ) + .withNesting(parentHash, index, depth) + .withEnclosingType(position.enclosingTypeHash) + .withScope(position.scopeHash) + .withSpan(node.startPosition.row + 1, node.endPosition.row + 1) + .withFlags({ + isStringForwardRef: kind === PythonTypeRefKind.STRING_FORWARD_REF, + isOptional: this.admitsNone(node, kind), + }) + .withTypeVariable( + declaredVariance !== undefined ? typeName : '', + declaredVariance ?? '' + ) + .build(); + + this.references.push(reference); + this.byteRangeByReference.set(reference.getHash(), `${node.startIndex}:${node.endIndex}`); + + // Children: subscript arguments, or the operands of a PEP 604 union. + const children = this.argumentsOf(node); + children.forEach((child, childIndex) => { + const inner = this.unwrap(child); + if (inner) { + this.emit( + inner, + position, + PythonTypeRefContext.GENERIC_ARGUMENT, + reference.getHash(), + childIndex, + depth + 1 + ); + } + }); + + return reference; + } + + /** + * The arguments of a composite type, in source order. + * + * Three shapes, each nesting differently: a subscript's indices, a PEP 604 + * union's operands, and `Callable[[A, B], R]` where the parameter list is a + * LIST one level deeper — its elements are flattened in so `A` and `B` are + * arguments of `Callable` rather than of an anonymous list. + */ + private argumentsOf(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + + if (node.type === 'binary_operator') { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left) { + out.push(left); + } + if (right) { + out.push(right); + } + return out; + } + + // `union_type` carries no `left`/`right` fields — its operands are `type` wrappers with + // the `|` token between them, so take the named children and let `emit` unwrap each. + if (node.type === 'union_type') { + for (const child of node.namedChildren) { + if (!child.isExtra) { + out.push(child); + } + } + return out; + } + + if (node.type === 'generic_type') { + for (let i = 1; i < node.namedChildCount; i++) { + const parameterList = node.namedChild(i); + if (parameterList?.type !== 'type_parameter') { + continue; + } + for (let j = 0; j < parameterList.namedChildCount; j++) { + const argument = parameterList.namedChild(j); + if (argument && !argument.isExtra) { + out.push(...this.flattenCallableList(argument)); + } + } + } + return out; + } + + if (node.type === 'subscript') { + const value = node.childForFieldName('value'); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.id === value?.id || child.isExtra) { + continue; + } + out.push(...this.flattenCallableList(child)); + } + return out; + } + + return out; + } + + /** `Callable[[A, B], R]` — the bracketed parameter list is not itself a type. */ + private flattenCallableList(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const inner = this.unwrap(node); + if (!inner || inner.type !== 'list') { + return inner ? [inner] : []; + } + const out: Parser.SyntaxNode[] = []; + for (let i = 0; i < inner.namedChildCount; i++) { + const element = inner.namedChild(i); + if (element && !element.isExtra) { + out.push(element); + } + } + return out; + } + + /** The base of a subscript: the `Dict` in `Dict[str, int]`. */ + private subscriptBase(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + if (node.type === 'generic_type') { + return node.namedChild(0); + } + if (node.type === 'subscript') { + return node.childForFieldName('value') ?? node.namedChild(0); + } + return null; + } + + /** + * Names bound by `X = TypeVar("X")`, mapped to their declared variance. + * + * Without this a type variable is indistinguishable from an ordinary class: + * `List[T]` and `List[Options]` both emit `kind=NAME`, so a consumer + * resolving the element type looks for a class named `T`, finds nothing, and + * records an unresolved reference -- or worse, finds an unrelated class that + * happens to share the name. + * + * The whole module is scanned rather than only its top level. A TypeVar is + * conventionally declared at module scope, but nothing requires it, and one + * declared inside a function is still a type variable everywhere it is used. + */ + private collectTypeVariables(root: Parser.SyntaxNode): Map { + const found = new Map(); + const worklist: Parser.SyntaxNode[] = [root]; + while (worklist.length > 0) { + const node = worklist.pop(); + if (!node) { + continue; + } + if (node.type === 'assignment') { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left?.type === 'identifier' && right?.type === 'call') { + const fn = right.childForFieldName('function'); + if (fn && TYPE_VAR_FACTORIES.has(this.simpleNameOf(fn))) { + found.set(left.text, { + variance: this.varianceOf(right), + bound: this.boundOf(right), + }); + } + } + } + for (let i = 0; i < node.namedChildCount; i += 1) { + const child = node.namedChild(i); + if (child) { + worklist.push(child); + } + } + } + return found; + } + + /** + * The `bound=` argument of a TypeVar declaration, or null. + * + * `TypeVar("B", bound=Base)` constrains B to Base and subclasses, and the + * schema has a context for exactly this. Without it the constraint is dropped + * on the floor: the variable is emitted, the class is emitted, and nothing + * records that one bounds the other. + * + * The CONSTRAINT form `TypeVar("C", int, str)` is a different thing -- a + * closed set of alternatives rather than an upper bound -- and the schema has + * no context for it, so those positional arguments are deliberately ignored + * rather than reported as bounds, which would be a wrong answer rather than a + * missing one. + */ + private boundOf(call: Parser.SyntaxNode): Parser.SyntaxNode | null { + const args = call.childForFieldName('arguments'); + if (!args) { + return null; + } + for (let i = 0; i < args.namedChildCount; i += 1) { + const arg = args.namedChild(i); + if (arg?.type !== 'keyword_argument') { + continue; + } + if ((arg.childForFieldName('name')?.text ?? '') === 'bound') { + return arg.childForFieldName('value') ?? null; + } + } + return null; + } + + /** + * The binding a TypeVar name belongs to, or null if it cannot be pinned down. + * + * The module scope is tried first, because that is where a TypeVar is + * conventionally declared and the answer is then unambiguous. Nothing + * requires it though, and one declared inside a function is still a type + * variable with a real bound: + * + * ```python + * def scoped(): + * Inner = TypeVar("Inner", bound=Base) + * ``` + * + * That binding lives in the function's scope, so the module lookup missed and + * the bound was silently dropped. The fallback searches every scope, and + * accepts the result only when EXACTLY ONE scope binds the name. Two scopes + * binding the same TypeVar name is a genuine ambiguity, and picking either + * would attach the bound to a variable that may not have it -- an unowned or + * wrongly owned reference is worse than the absence it replaces, which is the + * same reason a missing binding is skipped rather than owned by the module. + */ + private bindingHashForTypeVariable(name: string): string | null { + const atModule = this.input.bindingHashByScopeAndName.get( + `${this.input.moduleScopeHash}::${name}` + ); + if (atModule !== undefined && atModule !== '') { + return atModule; + } + const suffix = `::${name}`; + let found: string | null = null; + for (const [key, hash] of this.input.bindingHashByScopeAndName) { + if (!key.endsWith(suffix) || hash === '') { + continue; + } + if (found !== null) { + return null; + } + found = hash; + } + return found; + } + + /** `covariant=True` / `contravariant=True`; invariant is the default. */ + private varianceOf(call: Parser.SyntaxNode): string { + const args = call.childForFieldName('arguments'); + if (!args) { + return PythonWildcardVariance.INVARIANT; + } + for (let i = 0; i < args.namedChildCount; i += 1) { + const arg = args.namedChild(i); + if (arg?.type !== 'keyword_argument') { + continue; + } + const name = arg.childForFieldName('name')?.text ?? ''; + const value = arg.childForFieldName('value')?.text ?? ''; + if (value !== 'True') { + continue; + } + if (name === 'covariant') { + return PythonWildcardVariance.COVARIANT; + } + if (name === 'contravariant') { + return PythonWildcardVariance.CONTRAVARIANT; + } + } + return PythonWildcardVariance.INVARIANT; + } + + private kindOf( + node: Parser.SyntaxNode, + base: Parser.SyntaxNode | null + ): PythonTypeRefKind { + // tree-sitter-python spells a PEP 604 union with TWO different node types, and which + // one you get depends on the operands. All-bare-name (`Payload | None`) comes through + // the expression grammar as `binary_operator`; give any operand a subscript + // (`Payload[str] | None`) and the typed-annotation grammar produces `union_type` + // instead. Only the first was handled, so the second fell past every branch here to + // UNKNOWN — no head type to resolve, no GENERIC_ARGUMENT child to take an element from, + // and `isOptional` false, understating what the annotation says. 12.6% of the PEP 604 + // unions in a five-project census, and up to 22.6% on a project whose style leans on + // subscripted operands. + if (node.type === 'binary_operator' || node.type === 'union_type') { + return PythonTypeRefKind.UNION_PEP604; + } + if (node.type === 'string' || node.type === 'concatenated_string') { + return PythonTypeRefKind.STRING_FORWARD_REF; + } + if (node.type === 'none') { + return PythonTypeRefKind.NONE_TYPE; + } + if (node.type === 'ellipsis') { + return PythonTypeRefKind.ELLIPSIS_TYPE; + } + + if (base !== null) { + const name = this.simpleNameOf(base); + if (OPTIONAL_NAMES.has(name)) { + return PythonTypeRefKind.OPTIONAL; + } + if (UNION_NAMES.has(name)) { + return PythonTypeRefKind.UNION_PEP604; + } + if (CALLABLE_NAMES.has(name)) { + return PythonTypeRefKind.CALLABLE; + } + if (TUPLE_NAMES.has(name)) { + return PythonTypeRefKind.TUPLE_TYPE; + } + if (LITERAL_NAMES.has(name)) { + return PythonTypeRefKind.LITERAL_TYPE; + } + return PythonTypeRefKind.SUBSCRIPT; + } + + if (node.type === 'identifier') { + return ANY_NAMES.has(node.text) ? PythonTypeRefKind.ANY : PythonTypeRefKind.NAME; + } + if (node.type === 'attribute' || node.type === 'member_type') { + return PythonTypeRefKind.DOTTED_NAME; + } + return PythonTypeRefKind.UNKNOWN; + } + + /** + * Whether this reference admits `None`. + * + * `Optional[X]` by definition, and `Union[..., None]` or `X | None` by having a + * `None` member. `isOptional` is the #1 subscript in the corpus at 3,517 + * occurrences, so it earns a column rather than being re-derived. + */ + private admitsNone(node: Parser.SyntaxNode, kind: PythonTypeRefKind): boolean { + if (kind === PythonTypeRefKind.OPTIONAL) { + return true; + } + if (kind !== PythonTypeRefKind.UNION_PEP604) { + return false; + } + // Looked for through NESTED unions, not just among the direct operands, because the two + // spellings nest in opposite directions: `binary_operator` is left-associative, so + // `A | B | None` has `None` as its direct right operand, while `union_type` is + // right-nested, so the same annotation with a subscript anywhere puts `None` one level + // down inside `B | None`. Reading only the direct operands would answer `isOptional` + // differently for two annotations that mean the same thing. + return this.argumentsOf(node).some(argument => { + const inner = this.unwrap(argument); + if (!inner) { + return false; + } + if (inner.type === 'none') { + return true; + } + if (inner.type === 'binary_operator' || inner.type === 'union_type') { + return this.admitsNone(inner, PythonTypeRefKind.UNION_PEP604); + } + return false; + }); + } + + private simpleNameOf(node: Parser.SyntaxNode): string { + switch (node.type) { + case 'identifier': { + return node.text; + } + case 'attribute': { + return node.childForFieldName('attribute')?.text ?? ''; + } + case 'member_type': { + return node.namedChild(node.namedChildCount - 1)?.text ?? ''; + } + case 'dotted_name': { + return node.namedChild(node.namedChildCount - 1)?.text ?? ''; + } + case 'string': + case 'concatenated_string': { + for (let i = 0; i < node.namedChildCount; i++) { + const part = node.namedChild(i); + if (part?.type === 'string_content') { + // A forward reference names a type; the quotes are not part of it. + return part.text.split('[')[0]!.trim(); + } + } + return ''; + } + case 'none': { + return 'None'; + } + case 'generic_type': + case 'subscript': { + const base = this.subscriptBase(node); + return base ? this.simpleNameOf(base) : ''; + } + default: { + return ''; + } + } + } +} diff --git a/parser/src/parsers/python/index.ts b/parser/src/parsers/python/index.ts new file mode 100644 index 000000000..d4157edca --- /dev/null +++ b/parser/src/parsers/python/index.ts @@ -0,0 +1,7 @@ +export { PythonDialectDetector } from '@/parsers/python/python-dialect-detector'; +export { PythonParser } from '@/parsers/python/python-parser'; +export { + PythonDeclarationExtractor, + PythonFactExtractor, + PythonScopeExtractor, +} from '@/parsers/python/extractors'; diff --git a/parser/src/parsers/python/python-dialect-detector.ts b/parser/src/parsers/python/python-dialect-detector.ts new file mode 100644 index 000000000..0dc42c4bc --- /dev/null +++ b/parser/src/parsers/python/python-dialect-detector.ts @@ -0,0 +1,310 @@ +import Parser from 'tree-sitter'; + +import { PythonDialect } from '@/enums/python/modules'; +import { DialectDetectionResult, Python2Finding } from '@/parsers/python/types'; +import { PythonSourcePositions } from '@/utils/python'; + +/** + * Node types that exist **only** in Python 2 and are first-class in + * `tree-sitter-python@0.21.0`'s grammar. Tier 1. + */ +const PY2_ONLY_NODE_TYPES: ReadonlySet = new Set([ + 'print_statement', + 'exec_statement', +]); + +/** + * Detects Python 2 source that `tree-sitter-python` parses **without erroring**. + * + * ## Why this exists + * + * Python 2 is out of scope, and "out of scope" is not the same as "unsupported". + * `tree-sitter-python@0.21.0` still carries the Python 2 grammar, so + * `print "x"` parses **cleanly** — `hasError` is `false`, no ERROR node, no + * MISSING node — and a full, confident, plausible-looking fact set comes out + * the other side, computed under Python 3 scoping rules that do not apply to it. + * That is a silent wrong answer, so Python 2 must be **rejected explicitly**. + * + * ## Three tiers, because one is not enough + * + * **Tier 1 — Py2-only node types.** `print_statement` and `exec_statement`, + * but only where the same bytes could not also be valid Python 3. + * + * ```python + * print "x" # print_statement -> SyntaxError in Python 3 + * exec "code" # exec_statement -> SyntaxError in Python 3 + * ``` + * + * `chevron` is deliberately NOT in this set, and its presence *exonerates* a + * `print_statement` rather than condemning it. `print >>sys.stderr, "x"` is + * byte-identical in the two dialects: Python 2 reads a print statement, and + * Python 3 reads the tuple `(print.__rshift__(sys.stderr), "x")`, which is + * syntactically valid and merely raises TypeError when evaluated. CPython 3.10 + * accepts the line, and Lib/test/test_print.py contains it precisely to assert + * that TypeError. Rejecting on `chevron` therefore threw out a valid Python 3 + * file. No tree query can separate the two readings, because there is nothing + * to separate -- the grammar is ambiguous here and only the runtime differs, so + * the tie goes to the dialect we support. + * + * **Tier 2 — Py2-only *shapes* of node types that are legal in Python 3.** The + * node type alone proves nothing here; the shape does. + * + * ```python + * except ValueError, e: # except_clause with a direct `,` child. + * # Python 3 requires `as`, which parses to an + * # as_pattern instead — so the comma is decisive. + * def f((a, b)): ... # tuple_pattern whose parent is `parameters`. + * # tuple_pattern is perfectly legal Python 3 + * # elsewhere — `(a, b) = x` is a tuple_pattern + * # under `assignment` — so the PARENT is what + * # distinguishes them, not the type. + * ``` + * + * **Tier 3 — undetectable from the tree at all.** Backtick repr parses to + * `(string (string_start) (string_content) (string_end))` with + * `hasError === false`, structurally **indistinguishable from a real string + * literal**. No tree query can find it, so this tier scans raw source, skipping + * string and comment spans so that a backtick inside a docstring or a Markdown + * comment is not a false positive. + * + * ```python + * x = `repr(y)` # BACKTICK_REPR — raw-source scan only + * ``` + */ +export class PythonDialectDetector { + /** + * Runs all three tiers over an already-built tree plus its source. + * + * Detection is a node-type and shape test on an existing tree, so tiers 1 and + * 2 cost one pass and no new dependency. + * + * @param rootNode Root of the parsed tree + * @param sourceCode The original source, required for tier 3 + * @returns The dialect and every finding, in source order + */ + detect(rootNode: Parser.SyntaxNode, sourceCode: string): DialectDetectionResult { + // Positions are reported in CPython's convention (UTF-8 byte columns) so a + // recorded rejection can be compared against ast output directly. + const positions = new PythonSourcePositions(sourceCode); + const findings: Python2Finding[] = [ + ...this.scanTree(rootNode, positions), + ...this.scanRawSourceForBackticks(sourceCode, positions), + ]; + + findings.sort( + (a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn || + a.construct.localeCompare(b.construct) + ); + + return { + dialect: findings.length > 0 ? PythonDialect.PY2_DETECTED_REJECTED : PythonDialect.PY3, + findings, + }; + } + + /** + * Tiers 1 and 2, in a single explicit worklist traversal. + * + * An explicit stack rather than recursion: Python files in the wild nest + * deeply enough that a recursive walk risks the call stack on the largest + * inputs, and this walk must complete for rejection to be trustworthy. + */ + private scanTree( + rootNode: Parser.SyntaxNode, + positions: PythonSourcePositions + ): Python2Finding[] { + const findings: Python2Finding[] = []; + const worklist: Parser.SyntaxNode[] = [rootNode]; + + while (worklist.length > 0) { + const node = worklist.pop(); + if (!node) { + continue; + } + + // ---- Tier 1: node types that only Python 2 has ---------------------- + if (PY2_ONLY_NODE_TYPES.has(node.type) && !this.isValidPython3Chevron(node)) { + findings.push(this.toFinding(node, node.type, 1, positions)); + } + + // ---- Tier 2: Python-2-only shapes of legal Python 3 node types ------ + if (node.type === 'except_clause' && this.hasDirectCommaChild(node)) { + findings.push(this.toFinding(node, 'except_clause_comma_target', 2, positions)); + } + if (node.type === 'tuple_pattern' && this.isInParameterPosition(node)) { + findings.push(this.toFinding(node, 'tuple_pattern_parameter', 2, positions)); + } + + for (let i = node.childCount - 1; i >= 0; i--) { + const child = node.child(i); + if (child) { + worklist.push(child); + } + } + } + + return findings; + } + + /** + * `except E, e:` — a direct `,` token under the clause. + * + * Python 3's `except E as e:` parses the target into an `as_pattern`, and + * `except (A, B):` wraps the alternatives in a `tuple` node, so neither + * produces a comma at this level. Checked on the **direct** children only: + * a comma nested inside a tuple or a call argument list is irrelevant. + */ + /** + * True when a `print_statement` is really a Python 3 right-shift expression. + * + * The grammar builds `print_statement` with a `chevron` as + * `seq('print', $.chevron, repeat(seq(',', $.expression)))`, so a chevron + * guarantees the remainder is comma-separated and the whole line re-reads as + * a tuple of expressions under Python 3 rules. Chevron presence is therefore + * sufficient on its own; no further shape check is needed. + */ + private isValidPython3Chevron(node: Parser.SyntaxNode): boolean { + if (node.type !== 'print_statement') { + return false; + } + for (let i = 0; i < node.childCount; i += 1) { + const child = node.child(i); + if (child && child.type === 'chevron') { + return true; + } + } + return false; + } + + private hasDirectCommaChild(exceptClause: Parser.SyntaxNode): boolean { + for (let i = 0; i < exceptClause.childCount; i++) { + if (exceptClause.child(i)?.type === ',') { + return true; + } + } + return false; + } + + /** + * `def f((a, b)):` / `lambda (a, b): ...` — a tuple_pattern directly under a + * parameter list. + * + * The parent is the whole test. `(a, b) = x` is also a `tuple_pattern` and is + * valid Python 3; its parent is an `assignment`. Reading only the node type + * here would reject correct Python 3. + */ + private isInParameterPosition(tuplePattern: Parser.SyntaxNode): boolean { + const parentType = tuplePattern.parent?.type; + return parentType === 'parameters' || parentType === 'lambda_parameters'; + } + + /** + * Tier 3: backtick repr, which no tree query can find. + * + * `x = ` + '`repr`' + ` parses to a string node with `hasError === false`, so the + * only evidence is the raw byte. The scan tracks string and comment state so + * a backtick inside a docstring — common in reStructuredText and Markdown — + * does not reject a perfectly good Python 3 file. + */ + private scanRawSourceForBackticks( + sourceCode: string, + positions: PythonSourcePositions + ): Python2Finding[] { + const findings: Python2Finding[] = []; + let line = 1; + let column = 0; + let index = 0; + let inComment = false; + let stringDelimiter: string | null = null; + + while (index < sourceCode.length) { + const char = sourceCode[index]; + + if (char === '\n') { + line += 1; + column = 0; + index += 1; + inComment = false; + // A newline terminates a single-quoted string; triple-quoted strings + // survive it, which is exactly why the delimiter is tracked verbatim. + if (stringDelimiter !== null && stringDelimiter.length === 1) { + stringDelimiter = null; + } + continue; + } + + if (inComment) { + index += 1; + column += 1; + continue; + } + + if (stringDelimiter !== null) { + if (char === '\\') { + index += 2; + column += 2; + continue; + } + if (sourceCode.startsWith(stringDelimiter, index)) { + column += stringDelimiter.length; + index += stringDelimiter.length; + stringDelimiter = null; + continue; + } + index += 1; + column += 1; + continue; + } + + if (char === '#') { + inComment = true; + index += 1; + column += 1; + continue; + } + + if (char === '"' || char === "'") { + const triple = char.repeat(3); + stringDelimiter = sourceCode.startsWith(triple, index) ? triple : char; + column += stringDelimiter.length; + index += stringDelimiter.length; + continue; + } + + if (char === '`') { + const byteColumn = positions.byteColumn(line - 1, column); + findings.push({ + construct: 'backtick_repr', + tier: 3, + startLine: line, + startColumn: byteColumn, + endLine: line, + endColumn: byteColumn + 1, + sourceText: '`', + }); + } + + index += 1; + column += 1; + } + + return findings; + } + + private toFinding( + node: Parser.SyntaxNode, + construct: string, + tier: 1 | 2 | 3, + positions: PythonSourcePositions + ): Python2Finding { + return { + construct, + tier, + startLine: node.startPosition.row + 1, + startColumn: positions.byteColumn(node.startPosition.row, node.startPosition.column), + endLine: node.endPosition.row + 1, + endColumn: positions.byteColumn(node.endPosition.row, node.endPosition.column), + sourceText: node.text.replace(/\s+/g, ' ').trim(), + }; + } +} diff --git a/parser/src/parsers/python/python-parser.ts b/parser/src/parsers/python/python-parser.ts new file mode 100644 index 000000000..1f7ccbbbc --- /dev/null +++ b/parser/src/parsers/python/python-parser.ts @@ -0,0 +1,127 @@ +import Parser from 'tree-sitter'; +import Python from 'tree-sitter-python'; + +import { FILE_EXTENSIONS } from '@/constants/consts'; +import { + PYTHON_CALLBACK_PARSE_THRESHOLD, + PYTHON_PARSE_CHUNK_SIZE, +} from '@/constants/python-constants'; +import { LanguageParser } from '@/parsers/language-parser'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { withRetry } from '@/utils/retry-decorator'; + +/** + * Python-specific tree-sitter parser implementation. + * + * ## The 32,767-character parse limit + * + * tree-sitter cannot parse a buffer longer than **32,767 characters** + * (2^15 - 1) in one shot. This parser owns the workaround so that every + * consumer inherits it, rather than each extractor rediscovering it: three of + * five stdlib packages fail to parse without the callback path, so this is the + * common path for real Python, not an edge case. + * + * Two properties of the limit are worth stating because both are easy to get + * wrong: + * + * - **Characters, not bytes.** 29k characters of CJK is 62KB of UTF-8 and + * parses fine. A byte-length guard is wrong in both directions. + * - **The callback's returned chunk carries the same ceiling.** Streaming does + * not lift the limit, it only keeps each buffer under it, so a 65536-byte + * chunk throws exactly as a direct parse would. Hence the 8KB chunk size. + * + * ## Why the file is never split + * + * Splitting the source and parsing the halves would produce two roots with no + * module containment between them, and counts that look plausible while the + * scope forest is silently wrong — the failure mode this parser is organised + * against. The callback streams one logical parse over one tree instead. + */ +export class PythonParser implements LanguageParser { + readonly language = ProjectLanguage.PYTHON; + readonly fileExtension = FILE_EXTENSIONS.PYTHON; + + private parser: Parser; + + constructor() { + this.parser = new Parser(); + this.parser.setLanguage(Python); + } + + /** + * Parses Python source code into a syntax tree. + * + * Uses callback-based streaming above {@link PYTHON_CALLBACK_PARSE_THRESHOLD} + * characters to stay under tree-sitter's internal buffer ceiling. + * + * @param sourceCode Python source code as string + * @returns Parsed syntax tree + * @throws Error if sourceCode is invalid + */ + parse(sourceCode: string): Parser.Tree { + if (typeof sourceCode !== 'string') { + throw new Error('Invalid source code: must be a string'); + } + // An EMPTY file is deliberately accepted. `__init__.py` is empty in 438 of + // the 10,769 files in the local stdlib and site-packages, and every one of + // them is a legal module with a real module scope and an empty binding set. + // Rejecting them would drop a fact set that CPython produces happily. + if (sourceCode.length === 0) { + return this.parser.parse(''); + } + + // Measured in CHARACTERS, matching the limit's own unit. `.length` is UTF-16 + // code units, which is the conservative direction: it never under-counts + // relative to tree-sitter's accounting for the BMP text Python source is. + const useCallbackParsing = sourceCode.length > PYTHON_CALLBACK_PARSE_THRESHOLD; + + const parseWithRetry = withRetry( + (code: string) => { + if (useCallbackParsing) { + // Callback-based streaming parser (works for large files) + return this.parser.parse((index: number) => { + if (index >= code.length) { + return null; + } + // Each returned chunk is itself subject to the 32,767-char ceiling, + // so the chunk size is a hard requirement, not a tuning knob. + return code.substring(index, Math.min(index + PYTHON_PARSE_CHUNK_SIZE, code.length)); + }); + } else { + // Direct string parsing (faster for small files) + return this.parser.parse(code); + } + }, + { + maxAttempts: 3, + delayMs: 1500, + exponentialBackoff: true, + onRetry: (attempt, error) => { + console.warn(`[PythonParser] Parse attempt ${attempt} failed: ${error.message}, retrying...`); + }, + } + ); + + return parseWithRetry(sourceCode); + } + + /** + * Gets the root node of a parsed tree + * @param tree Parsed syntax tree + * @returns Root syntax node + */ + getRootNode(tree: Parser.Tree): Parser.SyntaxNode { + return tree.rootNode; + } + + /** + * Queries the syntax tree using tree-sitter query syntax + * @param node Starting node for the query + * @param queryString Tree-sitter query string + * @returns Query matches + */ + query(node: Parser.SyntaxNode, queryString: string): Parser.QueryMatch[] { + const query = this.parser.getLanguage().query(queryString); + return query.matches(node); + } +} diff --git a/parser/src/parsers/python/python-soft-keywords.ts b/parser/src/parsers/python/python-soft-keywords.ts new file mode 100644 index 000000000..c1ee834c8 --- /dev/null +++ b/parser/src/parsers/python/python-soft-keywords.ts @@ -0,0 +1,88 @@ +/** + * Corrections for tree-sitter-python's handling of Python's SOFT keywords. + * + * A soft keyword is only a keyword where the grammar cannot read it as an + * ordinary name. tree-sitter resolves that ambiguity greedily in at least one + * place, and the result is not a parse error — it is a clean parse of the wrong + * statement, which is the failure mode that costs the most to find later. + */ +import type Parser from 'tree-sitter'; + +/** + * True when a `type_alias_statement` node is really an ASSIGNMENT that the + * grammar mistook for a PEP 695 type alias. + * + * `type(obj).attr = value` — the ordinary idiom for setting an attribute on an + * object's class — parses as `type_alias_statement`, because the grammar takes + * the leading `type` as the soft keyword and then accepts `(obj).attr` as the + * alias name. The whole statement is then modelled wrongly: + * + * type_alias_statement + * type -> attribute -> parenthesized_expression -> identifier "obj" + * -> identifier "attr" + * type -> integer "1" + * + * The call node is GONE, so `type` is never recorded as a name, `obj` looks + * like a parenthesised expression rather than an argument, and the assigned + * value is presented as a type. CPython disagrees on every count: symtable + * lists `type` as a global reference in the enclosing scope. + * + * A genuine alias names itself with a bare `identifier` (`type X = int`) or a + * `generic_type` when it carries type parameters (`type X[T] = list[T]`). + * Anything else in that position — `attribute` for both `type(o).x` and + * `type[o].x` — means the soft keyword was applied where it should not have + * been. Note that `type = 5` and `x.type(o).y = 1` already parse correctly as + * expression statements, so this is specifically the leading-`type`-plus- + * trailing-attribute shape. + */ +export function isMisparsedTypeAlias(node: Parser.SyntaxNode): boolean { + if (node.type !== 'type_alias_statement') { + return false; + } + const left = node.namedChild(0); + if (left === null || left.type !== 'type') { + return false; + } + const target = left.namedChild(0); + if (target === null) { + return false; + } + return target.type !== 'identifier' && target.type !== 'generic_type'; +} + +/** + * The three real parts of a misparsed `type(obj).attr[: ann] = value`. + * + * Kept in one place so the scope builder, the expression extractor and the + * parse-gap extractor cannot drift: each one has to dig through the same two or + * three layers of `type` wrapper nodes, and an annotated target adds a + * `constrained_type` in the middle because `x: y` is also PEP 695's bound + * syntax. Picking the wrong layer marks the ANNOTATION as an assignment target, + * which is what happened before this existed. + */ +export interface MisparsedTypeAliasParts { + /** The real assignment target: an `attribute` or `subscript`. */ + target: Parser.SyntaxNode | null; + /** The annotation, when the statement was `type(o).a: ann = v`. */ + annotation: Parser.SyntaxNode | null; + /** The assigned value. */ + value: Parser.SyntaxNode | null; +} + +export function decomposeMisparsedTypeAlias( + node: Parser.SyntaxNode +): MisparsedTypeAliasParts { + const unwrap = (wrapper: Parser.SyntaxNode | null): Parser.SyntaxNode | null => + wrapper !== null && wrapper.type === 'type' ? wrapper.namedChild(0) : wrapper; + + const left = unwrap(node.namedChild(0)); + const value = unwrap(node.namedChild(1)); + if (left !== null && left.type === 'constrained_type') { + return { + target: unwrap(left.namedChild(0)), + annotation: unwrap(left.namedChild(1)), + value, + }; + } + return { target: left, annotation: null, value }; +} diff --git a/parser/src/parsers/python/types/Python2Finding.ts b/parser/src/parsers/python/types/Python2Finding.ts new file mode 100644 index 000000000..1f290a252 --- /dev/null +++ b/parser/src/parsers/python/types/Python2Finding.ts @@ -0,0 +1,37 @@ +import { PythonDialect } from '@/enums/python/modules'; + +/** + * A single Python-2 construct found in a file, carrying the span needed to + * record it as a `py_parse_gap` row. + * + * The span matters as much as the construct: a rejection that says only "this + * file is Python 2" is not auditable, and the whole point of the rejection path + * is that it names exactly what was found and where. + */ +export interface Python2Finding { + /** The offending node type, or a synthetic name for raw-source findings. */ + construct: string; + /** + * Which detection tier found it. + * + * - `1` — a node type only Python 2 has (`print_statement`, `exec_statement`, `chevron`) + * - `2` — a Python-2-only *shape* of a node type that is legal in Python 3 + * - `3` — invisible in the tree; found only by scanning raw source + */ + tier: 1 | 2 | 3; + /** 1-based line, matching CPython's `ast` and every position column. */ + startLine: number; + /** 0-based column. */ + startColumn: number; + endLine: number; + endColumn: number; + /** The offending source text, normalized to a single line. */ + sourceText: string; +} + +/** The outcome of dialect detection for one file. */ +export interface DialectDetectionResult { + dialect: PythonDialect; + /** Every finding, in source order. Empty when the file is Python 3. */ + findings: Python2Finding[]; +} diff --git a/parser/src/parsers/python/types/SymbolBlock.ts b/parser/src/parsers/python/types/SymbolBlock.ts new file mode 100644 index 000000000..021e051f2 --- /dev/null +++ b/parser/src/parsers/python/types/SymbolBlock.ts @@ -0,0 +1,99 @@ +import { PythonBindingOrigin } from '@/enums/python/bindings'; +import { PythonScopeKind, SymbolBlockType } from '@/enums/python/scopes'; + +/** + * Where a name's binding came from syntactically, accumulated per + * `(block, name)` alongside the flag set so `py_binding.bindingOrigin`, + * `bindingCount`, `firstBindingLine` and `lastBindingLine` can all be reported + * without a second traversal. + */ +export interface SymbolOriginRecord { + /** Every distinct syntactic form that bound this name in this scope. */ + origins: Set; + firstLine: number; + lastLine: number; + /** Distinct binding *sites* for this name in this scope. */ + bindingCount: number; + /** Annotation text, when the name was annotated; `''` otherwise. */ + declaredTypeName: string; +} + +/** + * One symbol-table block: a module, class body, function, lambda, or + * comprehension. + * + * ## Why this is a side table and never a property on tree-sitter nodes + * + * node-tree-sitter hands out **transient wrapper objects**. Its internal node + * cache evicts entries, so a property assigned to a node during one traversal is + * simply gone by the next, and subsequent `.parent` walks return untagged + * objects. That failure works on small files and breaks silently at scale — + * the worst possible shape for a bug. Every block therefore keys off `nodeId` + * (the numeric `node.id`) and all cross-pass state lives here. + */ +export interface SymbolBlock { + /** `node.id` of the scope-introducing node; the root's id for a module. */ + nodeId: number; + blockType: SymbolBlockType; + scopeKind: PythonScopeKind; + /** symtable's own name: a def/class name, or `lambda`/`listcomp`/`genexpr`/`top`. */ + name: string; + /** CPython `__qualname__` semantics, including the `` marker. */ + qualifiedName: string; + parent: SymbolBlock | null; + children: SymbolBlock[]; + /** 1-based line of the scope-introducing node; 0 for the module block. */ + startLine: number; + /** 0-based column. Required for PK uniqueness — two lambdas can share a line. */ + startColumn: number; + endLine: number; + endColumn: number; + nestingDepth: number; + /** Index among siblings of the same parent, in source order. */ + scopeOrdinal: number; + + /** + * The private-name mangling prefix in force inside this block — `_Outer` for + * a block lexically inside `class Outer`, or `''` where no class encloses it. + * + * CPython rewrites `__x` to `_Outer__x` for every identifier in a class body + * **and in every scope nested within it**, however deep: + * + * ```python + * class Outer: + * __secret = 1 # binds _Outer__secret + * def m(self): + * return [__deep for _ in x] # binds _Outer__deep in the listcomp + * ``` + * + * The prefix is the *nearest* enclosing class with leading underscores + * stripped, which is why it is carried per block rather than recomputed. + */ + privateNamePrefix: string; + + /** name -> accumulated symbol flags. Insertion-ordered, as CPython's dict is. */ + symbols: Map; + /** name -> syntactic provenance. */ + origins: Map; + + /** `SymbolTable.is_nested()`. */ + isNested: boolean; + /** A `from x import *` occurs directly in this block. */ + usesWildcardImport: boolean; + /** A `global` statement occurs directly in this block. */ + declaresGlobal: boolean; + /** A `nonlocal` statement occurs directly in this block. */ + declaresNonlocal: boolean; + /** Contains a `yield` / `yield from` — ast-derived, not symtable-derived. */ + isGenerator: boolean; + /** An `async def` — ast-derived. */ + isCoroutine: boolean; + + // ---- pass 2 results --------------------------------------------------- + /** name -> resolved symbol scope. Empty until the analysis pass runs. */ + scopes: Map; + /** This block references a free variable. */ + hasFree: boolean; + /** A descendant block references a free variable. */ + hasChildFree: boolean; +} diff --git a/parser/src/parsers/python/types/index.ts b/parser/src/parsers/python/types/index.ts new file mode 100644 index 000000000..6545148e5 --- /dev/null +++ b/parser/src/parsers/python/types/index.ts @@ -0,0 +1,2 @@ +export type { DialectDetectionResult, Python2Finding } from '@/parsers/python/types/Python2Finding'; +export type { SymbolBlock, SymbolOriginRecord } from '@/parsers/python/types/SymbolBlock'; diff --git a/parser/src/parsers/services/services-parser.ts b/parser/src/parsers/services/services-parser.ts new file mode 100644 index 000000000..89794016d --- /dev/null +++ b/parser/src/parsers/services/services-parser.ts @@ -0,0 +1,299 @@ +import * as path from 'path'; + +import { ServiceDescriptor } from '@/analysis-types/services/ServiceDescriptor'; +import { ServiceProvider } from '@/analysis-types/services/ServiceProvider'; + +/** + * One segment of a Java binary name: a legal Java identifier. + * + * `$` is deliberately allowed INSIDE a segment rather than treated as a + * separator, because it is a legal identifier character. That is what makes a + * nested name ambiguous on its face and why splitting is done separately, under + * the guard in {@link ServicesParser.resolveBinaryName}. + */ +const IDENTIFIER_SEGMENT = String.raw`[\p{L}_$][\p{L}\p{N}_$]*`; + +/** A fully-qualified binary name: one or more identifier segments joined by dots. */ +const BINARY_NAME = new RegExp(`^${IDENTIFIER_SEGMENT}(\\.${IDENTIFIER_SEGMENT})*$`, 'u'); + +/** + * A `$`-delimited part that names a real nested type. + * + * No `$` (the split consumed them), must not start with a digit, must not be + * empty. The two exclusions are what keep the normalisation from inventing + * names: `Outer$1` is a compiler-generated anonymous class and `Outer.1` is not + * a name at all, and `A$$B` is one legal identifier that splits into an empty + * part. Both are left in binary form rather than rewritten. + */ +const NESTED_PART = /^[\p{L}_][\p{L}\p{N}_]*(\.[\p{L}_][\p{L}\p{N}_]*)*$/u; + +/** The comment character, per the `ServiceLoader` provider-configuration format. */ +const COMMENT_CHAR = '#'; + +/** A UTF-8 BOM. The format mandates UTF-8, and a BOM is legal in a UTF-8 stream. */ +const BOM = ''; + +/** A binary name resolved into the parts every consumer would otherwise re-derive. */ +export interface ResolvedBinaryName { + /** Nested separators normalised to dots — the form the rest of the schema uses. */ + qualifiedName: string; + /** The innermost segment. */ + simpleName: string; + /** Everything before the outermost type name. Empty for a name in the default package. */ + packageName: string; + /** The dotted name of the enclosing type, or empty when the name is not nested. */ + enclosingTypeName: string; + /** The name as written contained a `$`. */ + isNested: boolean; + /** The name as written is a legal Java binary name. */ + isWellFormed: boolean; +} + +/** One provider name found on one line, before it becomes an entity. */ +interface ParsedProviderLine { + binaryName: string; + line: number; + startCol: number; + endCol: number; + hasInlineComment: boolean; +} + +/** + * Parser for `META-INF/services` provider-configuration files. + * + * The format is specified by `java.util.ServiceLoader` and is deceptively small: + * + * - the file name is the binary name of the service being configured; + * - each line names at most one concrete provider class; + * - `#` starts a comment, and everything after the first `#` on a line is ignored; + * - surrounding space and tab characters are ignored, and blank lines are ignored; + * - the file is UTF-8; + * - a class named twice is loaded once. + * + * Every one of those clauses is a row that a naive line reader gets wrong, and + * the comment clause dominates in practice: on one corpus, 2,839 of the 3,736 + * lines across 272 provider-configuration files were comment lines, almost all + * of them Apache licence headers. Reading lines verbatim would have produced + * three garbage rows for every real one. + */ +export class ServicesParser { + /** + * Parses one provider-configuration file. + * + * @param content File content, UTF-8 decoded + * @param filePath Absolute path to the file + * @param baseMservPath Project root path + * @param serviceVersionLinkHash Service version hash + * @returns Tuple of [ServiceDescriptor, ServiceProvider[]] + */ + parse( + content: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): [ServiceDescriptor, ServiceProvider[]] { + const fileName = path.basename(filePath); + const service = this.resolveBinaryName(fileName); + const lines = this.split(content); + const parsedLines = this.parseProviderLines(lines); + + // Built before the providers because every provider row chains off its hash. + const descriptor = ServiceDescriptor.builder( + fileName, + filePath, + baseMservPath, + serviceVersionLinkHash + ) + .withServiceInterface(service.qualifiedName) + .withSimpleName(service.simpleName) + .withPackageName(service.packageName) + .withIsNestedServiceName(service.isNested) + .withIsWellFormedServiceName(service.isWellFormed) + .withLineCount(lines.length) + .withRelativePath(this.toPosix(path.relative(baseMservPath, filePath)) || fileName) + .build(); + + const seen = new Set(); + const providers: ServiceProvider[] = []; + + parsedLines.forEach((parsed, position) => { + const resolved = this.resolveBinaryName(parsed.binaryName); + const isDuplicateInFile = seen.has(parsed.binaryName); + seen.add(parsed.binaryName); + + providers.push( + ServiceProvider.builder( + parsed.binaryName, + position, + parsed.line, + parsed.startCol, + parsed.endCol, + descriptor.getHash(), + filePath, + baseMservPath, + serviceVersionLinkHash + ) + .withProviderClass(resolved.qualifiedName) + .withSimpleName(resolved.simpleName) + .withPackageName(resolved.packageName) + .withEnclosingTypeName(resolved.enclosingTypeName) + .withIsNestedName(resolved.isNested) + .withIsWellFormedName(resolved.isWellFormed) + .withIsDuplicateInFile(isDuplicateInFile) + .withHasInlineComment(parsed.hasInlineComment) + .build() + ); + }); + + descriptor.setProviderCounts( + providers.length, + providers.filter((p) => p.getIsWellFormedName()).length + ); + + return [descriptor, providers]; + } + + /** + * Splits file content into lines. + * + * The BOM is stripped from the first line only. Left in place it becomes part + * of the first provider's name, which then fails the binary-name check — a + * one-character invisible difference that turns a valid descriptor's first + * provider into a malformed row. + */ + private split(content: string): string[] { + const withoutBom = content.startsWith(BOM) ? content.slice(BOM.length) : content; + if (withoutBom.length === 0) { + return []; + } + const lines = withoutBom.split(/\r\n|\r|\n/); + // A trailing newline TERMINATES the last line, it does not start another. + // Left in, every well-formed file reports one line more than it has, and + // `lineCount` becomes a number that is wrong by one everywhere rather than + // a measurement a consumer can compare against anything. + if (lines[lines.length - 1] === '') { + lines.pop(); + } + return lines; + } + + /** + * Finds the provider name on each line, if any. + * + * Comment stripping happens BEFORE trimming, in that order, because the format + * says the comment runs from the first `#` to end of line — so a line reading + * ` org.acme.Codec # the default` yields `org.acme.Codec`, and a line whose + * only content is a comment yields nothing. + */ + private parseProviderLines(lines: string[]): ParsedProviderLine[] { + const parsed: ParsedProviderLine[] = []; + + lines.forEach((raw, index) => { + const commentAt = raw.indexOf(COMMENT_CHAR); + const hasInlineComment = commentAt !== -1; + const code = hasInlineComment ? raw.slice(0, commentAt) : raw; + + const trimmed = code.trim(); + if (trimmed.length === 0) { + return; + } + + // The whole remaining text is the name, interior whitespace included. The + // format allows at most one provider per line, so `a.B c.D` is ONE + // malformed name and not two providers; splitting it would fabricate an + // instantiation the JVM never performs — it throws instead. + const startCol = code.indexOf(trimmed[0]!); + parsed.push({ + binaryName: trimmed, + line: index + 1, + startCol, + endCol: startCol + trimmed.length, + // Only reported when the line ALSO carries a name; a pure comment line + // produces no row at all, so there is nothing for the flag to describe. + hasInlineComment, + }); + }); + + return parsed; + } + + /** + * Resolves a binary name into its dotted form and its parts. + * + * Nested separators are rewritten to dots only when every `$`-delimited part + * is itself a plain identifier — see {@link NESTED_PART}. When the guard fails + * the name is reported verbatim, so a synthetic name survives as + * `org.acme.Outer$1` rather than being rewritten into something that names + * nothing. + */ + resolveBinaryName(binaryName: string): ResolvedBinaryName { + const isWellFormed = BINARY_NAME.test(binaryName); + const isNested = binaryName.includes('$'); + + // A malformed token has no parts to report, and reporting them anyway is + // the worse failure: splitting `!org.acme.Suppressed` on '.' yields the + // package `!org.acme` and splitting `a.B c.D` yields the package + // `a.B c` — names that exist nowhere, in the columns a consumer joins on. + // The token itself is still carried, because a configuration error has to + // be quotable back to the user. + if (!isWellFormed) { + return { + qualifiedName: binaryName, + simpleName: '', + packageName: '', + enclosingTypeName: '', + isNested, + isWellFormed: false, + }; + } + + if (!isNested) { + const lastDot = binaryName.lastIndexOf('.'); + return { + qualifiedName: binaryName, + simpleName: lastDot === -1 ? binaryName : binaryName.slice(lastDot + 1), + packageName: lastDot === -1 ? '' : binaryName.slice(0, lastDot), + enclosingTypeName: '', + isNested: false, + isWellFormed, + }; + } + + const parts = binaryName.split('$'); + const outermost = parts[0]!; + const outermostLastDot = outermost.lastIndexOf('.'); + const packageName = outermostLastDot === -1 ? '' : outermost.slice(0, outermostLastDot); + const normalisable = parts.every((part) => NESTED_PART.test(part)); + + if (!normalisable) { + // The `$` structure is not trustworthy here, so only what survives + // independently of it is reported: the package, and the binary simple + // name — the last DOTTED segment, which is unambiguous because a package + // segment never contains a `$` in practice. `enclosingTypeName` is + // withheld rather than guessed: joining the leading parts back with `$` + // turns `org.acme.A$$B` into the enclosing type `org.acme.A$`. + const lastDot = binaryName.lastIndexOf('.'); + return { + qualifiedName: binaryName, + simpleName: lastDot === -1 ? binaryName : binaryName.slice(lastDot + 1), + packageName, + enclosingTypeName: '', + isNested: true, + isWellFormed, + }; + } + + return { + qualifiedName: parts.join('.'), + simpleName: parts[parts.length - 1]!, + packageName, + enclosingTypeName: parts.slice(0, -1).join('.'), + isNested: true, + isWellFormed, + }; + } + + private toPosix(p: string): string { + return p.split(path.sep).join('/'); + } +} diff --git a/parser/src/parsers/typescript/extractors/ts-binder.ts b/parser/src/parsers/typescript/extractors/ts-binder.ts new file mode 100644 index 000000000..9adb7f7a4 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-binder.ts @@ -0,0 +1,1002 @@ +import * as ts from 'typescript'; + +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { TS_DEFAULT_EXPORT_NAME } from '@/constants/typescript-constants'; +import { TsMergeScopePrefix } from '@/enums/typescript/modules'; +import { TsDeclarationSpace } from '@/enums/typescript/types'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * A SYNTACTIC binder — scopes, symbol tables and merge-scope keys, with no checker. + * + * ## What this is for, and what it is not + * + * It is not a typechecker and it does not become one. It answers two questions + * that pure syntax-directed extraction cannot answer on its own: + * + * 1. **Which declarations are the same symbol?** That is §3.1, and it is the + * thing that must be right before any other key exists. TypeScript's own rule + * is that a symbol *is* a `(symbol table, escaped name)` pair and merging *is* + * "same table, same name", so reproducing the table is reproducing the rule — + * right by construction rather than by approximation. + * 2. **What does this identifier refer to?** Needed for + * `ts_expression.referencedEntityHash` and for the syntactically decidable + * half of call resolution. + * + * Both are pure functions of the AST plus module resolution, which is why this + * is parser-legal. Nothing here calls `getTypeAtLocation`, and nothing here + * needs a `ts.Program`. + * + * ## Two tables, not one, because TypeScript has two + * + * `var` and function declarations go to the nearest FUNCTION scope; `let`, + * `const`, classes, enums, interfaces, type aliases, namespaces and imports go + * to the nearest BLOCK scope. Collapsing them into one table merges two + * `const x` declarations in sibling blocks of one function into a single symbol, + * which tsc does not do — and the failure is silent, because the resulting + * partition still looks like a partition. + * + * ## A note on the schema's `LOCALS:` + * + * §3.1 writes the local merge key as `LOCALS:`. This + * implementation uses `LOCALS:` — the same prefix, keyed on the + * BINDER SCOPE rather than the enclosing method. That is a refinement, not a + * divergence: keying on the method would merge two same-named block-scoped + * declarations inside one function into one symbol, and the oracle adjudicates + * the partition by set equality in both directions, so the coarser key would + * fail the very gate the formula exists to pass. The scope hash is derived from + * the module hash and the scope node's BYTE RANGE, so it is stable across runs + * and independent of traversal order. + */ + +/** What a bound declaration is, in the vocabulary the oracle's partition uses. */ +export enum TsBoundKind { + ClassDeclaration = 'ClassDeclaration', + EnumDeclaration = 'EnumDeclaration', + FunctionDeclaration = 'FunctionDeclaration', + InterfaceDeclaration = 'InterfaceDeclaration', + ModuleDeclaration = 'ModuleDeclaration', + TypeAliasDeclaration = 'TypeAliasDeclaration', + VariableDeclaration = 'VariableDeclaration', + /** Binding-pattern elements. A symbol in tsc, but NOT a `VariableDeclaration` node. */ + BindingElement = 'BindingElement', + Parameter = 'Parameter', + ImportBinding = 'ImportBinding', + ClassExpression = 'ClassExpression', + FunctionExpression = 'FunctionExpression', + TypeParameter = 'TypeParameter', +} + +/** Which scopes exist. A scope may be a var scope, a block scope, or both. */ +export enum TsScopeKind { + SOURCE_FILE = 'SOURCE_FILE', + /** `declare module "x" { … }` — its own importable namespace and merge table. */ + AMBIENT_MODULE = 'AMBIENT_MODULE', + /** `declare global { … }` — declarations land in GLOBAL. */ + GLOBAL_AUGMENTATION = 'GLOBAL_AUGMENTATION', + NAMESPACE = 'NAMESPACE', + FUNCTION = 'FUNCTION', + BLOCK = 'BLOCK', + /** Holds type parameters and, for a named class expression, the class's own name. */ + TYPE_CONTAINER = 'TYPE_CONTAINER', +} + +export interface BoundDeclaration { + readonly name: string; + readonly escapedName: string; + readonly kind: TsBoundKind; + readonly node: ts.Node; + /** The node whose position identifies the declaration SITE, per the oracle's convention. */ + readonly siteNode: ts.Node; + readonly mergeScopeKey: string; + readonly declarationGroupKey: string; + readonly declarationSpaces: ReadonlySet; + readonly isExported: boolean; + readonly scope: TsScope; +} + +export interface TsScope { + readonly kind: TsScopeKind; + readonly node: ts.Node; + readonly parent: TsScope | undefined; + readonly depth: number; + readonly scopeHash: string; + readonly isVarScope: boolean; + readonly isBlockScope: boolean; + /** `var` and function declarations. */ + readonly varTable: Map; + /** `let`, `const`, class, enum, interface, type alias, namespace, import. */ + readonly blockTable: Map; + /** + * The merge-scope key declarations in this scope receive. + * + * A pair, because exported and non-exported declarations of one name in one + * module are two symbols, not one — tsc rejects mixing them (TS2395), which + * is the evidence that the tables really are separate. + */ + readonly exportedMergeScopeKey: string; + readonly localMergeScopeKey: string; + /** For a namespace scope: the group key of the namespace declaration itself. */ + readonly namespaceGroupKey: string; +} + +/** + * How the binder learns a module specifier's target without a Program. + * + * Returns both the target module's hash — which is what an augmentation's + * declarations must be keyed under — and a PROJECT-RELATIVE path, which is what + * the augmentation's own symbol is named after. tsc names that symbol with the + * absolute resolved path; this parser uses the relative one, because an absolute + * path in a fact table is machine-specific and would make two identical + * analyses on two machines produce different keys. + */ +export interface ResolvedModuleTarget { + readonly moduleHash: string; + /** Project-relative, extension stripped, `/` separated. */ + readonly relativePath: string; +} +export type ModuleHashResolver = ( + specifier: string, + fromFile: string +) => ResolvedModuleTarget | undefined; + +export interface BinderResult { + readonly fileScope: TsScope; + /** Every bound declaration, by node identity. */ + readonly bindingByNode: ReadonlyMap; + /** The innermost scope containing each node that opens one, by node identity. */ + readonly scopeByNode: ReadonlyMap; + /** Class and interface member tables, by the declaration node's identity. */ + readonly memberTableByOwner: ReadonlyMap>; + /** Every declaration in source order — the emission order, and therefore the byte order. */ + readonly declarationsInOrder: readonly BoundDeclaration[]; +} + +/** + * Node identity: the byte RANGE, never the start offset. + * + * A start offset alone is ambiguous — a `ParenthesizedExpression` and its + * operand can begin at the same character, and so can a declaration and its + * name. The node kind is included because a node with exactly one child can + * span exactly the child's range, and two entries under one identity is the + * silent-eviction failure that side tables keyed on wrappers produce at scale. + */ +export function nodeId(node: ts.Node, sourceFile: ts.SourceFile): string { + return `${node.kind}:${node.getStart(sourceFile)}:${node.end}`; +} + +/** md5 over `mergeScopeKey ‖ escapedName` — the merged entity's identity (§3.1). */ +export function declarationGroupKeyFor(mergeScopeKey: string, escapedName: string): string { + return EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `${mergeScopeKey}||${escapedName}` + ); +} + +export interface BinderOptions { + readonly sourceFile: ts.SourceFile; + readonly moduleHash: string; + readonly isExternalModule: boolean; + readonly filePath: string; + /** + * Resolves `declare module "./x"` to the augmented module's hash. + * + * This is why `ts.resolveModuleName` being parser-legal matters to the KEY and + * not merely to `ts_import`: a module augmentation's declarations must land in + * the TARGET module's table, or `Request` in `augmented-base.ts` and `Request` + * inside `declare module "./augmented-base"` are two symbols instead of one. + */ + readonly resolveModuleHash: ModuleHashResolver; + /** Hashes minted for ambient-module and global-augmentation `ts_module` rows. */ + readonly ambientModuleHashes: ReadonlyMap; +} + +export function bindSourceFile(options: BinderOptions): BinderResult { + return new Binder(options).run(); +} + +class Binder { + private readonly sf: ts.SourceFile; + private readonly bindingByNode = new Map(); + private readonly scopeByNode = new Map(); + private readonly memberTableByOwner = new Map>(); + private readonly declarationsInOrder: BoundDeclaration[] = []; + private ambientScope: TsScope | undefined; + + constructor(private readonly options: BinderOptions) { + this.sf = options.sourceFile; + } + + run(): BinderResult { + const fileScope = this.makeScope( + this.sf, + this.options.isExternalModule ? TsScopeKind.SOURCE_FILE : TsScopeKind.SOURCE_FILE, + undefined, + true, + true, + this.options.isExternalModule + ? `${TsMergeScopePrefix.MODULE_EXPORTS}:${this.options.moduleHash}` + : TsMergeScopePrefix.GLOBAL, + this.options.isExternalModule + ? `${TsMergeScopePrefix.MODULE_LOCALS}:${this.options.moduleHash}` + : TsMergeScopePrefix.GLOBAL, + '' + ); + this.scopeByNode.set(nodeId(this.sf, this.sf), fileScope); + for (const statement of this.sf.statements) { + this.visit(statement, fileScope, fileScope, fileScope); + } + return { + fileScope, + bindingByNode: this.bindingByNode, + scopeByNode: this.scopeByNode, + memberTableByOwner: this.memberTableByOwner, + declarationsInOrder: this.declarationsInOrder, + }; + } + + // ------------------------------------------------------------------------- + // scope construction + // ------------------------------------------------------------------------- + + private makeScope( + node: ts.Node, + kind: TsScopeKind, + parent: TsScope | undefined, + isVarScope: boolean, + isBlockScope: boolean, + exportedMergeScopeKey: string, + localMergeScopeKey: string, + namespaceGroupKey: string + ): TsScope { + return { + kind, + node, + parent, + depth: parent ? parent.depth + 1 : 0, + // Derived from the module hash and the scope node's BYTE RANGE, so it is + // stable across runs and does not depend on traversal order. + scopeHash: EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `SCOPE||${this.options.moduleHash}||${node.kind}||${node.getStart(this.sf)}||${node.end}` + ), + isVarScope, + isBlockScope, + varTable: new Map(), + blockTable: new Map(), + exportedMergeScopeKey, + localMergeScopeKey, + namespaceGroupKey, + }; + } + + /** A plain lexical scope: everything declared in it is local, so both keys agree. */ + private makeLocalScope(node: ts.Node, kind: TsScopeKind, parent: TsScope, + isVarScope: boolean, isBlockScope: boolean): TsScope { + const scope = this.makeScope(node, kind, parent, isVarScope, isBlockScope, '', '', ''); + const key = `${TsMergeScopePrefix.LOCALS}:${scope.scopeHash}`; + const withKeys: TsScope = { ...scope, exportedMergeScopeKey: key, localMergeScopeKey: key }; + this.scopeByNode.set(nodeId(node, this.sf), withKeys); + return withKeys; + } + + // ------------------------------------------------------------------------- + // traversal + // ------------------------------------------------------------------------- + + /** + * @param varScope where `var` and function declarations land + * @param blockScope where `let`/`const`/class/interface/type/enum/namespace land + * @param nearest the innermost scope, for reference resolution + */ + private visit(node: ts.Node, varScope: TsScope, blockScope: TsScope, nearest: TsScope): void { + switch (node.kind) { + case ts.SyntaxKind.FunctionDeclaration: { + this.bindNamed(node as ts.FunctionDeclaration, TsBoundKind.FunctionDeclaration, varScope, + new Set([TsDeclarationSpace.VALUE])); + this.visitFunctionLike(node as ts.FunctionDeclaration, nearest); + return; + } + case ts.SyntaxKind.ClassDeclaration: { + this.bindNamed(node as ts.ClassDeclaration, TsBoundKind.ClassDeclaration, blockScope, + new Set([TsDeclarationSpace.TYPE, TsDeclarationSpace.VALUE])); + this.visitClassLike(node as ts.ClassDeclaration, nearest); + return; + } + case ts.SyntaxKind.InterfaceDeclaration: { + this.bindNamed(node as ts.InterfaceDeclaration, TsBoundKind.InterfaceDeclaration, blockScope, + new Set([TsDeclarationSpace.TYPE])); + this.recordMembers(node, (node as ts.InterfaceDeclaration).members); + this.visitTypeContainerChildren(node as ts.InterfaceDeclaration, nearest); + return; + } + case ts.SyntaxKind.TypeAliasDeclaration: { + this.bindNamed(node as ts.TypeAliasDeclaration, TsBoundKind.TypeAliasDeclaration, blockScope, + new Set([TsDeclarationSpace.TYPE])); + this.visitTypeContainerChildren(node as ts.TypeAliasDeclaration, nearest); + return; + } + case ts.SyntaxKind.EnumDeclaration: { + // An enum occupies all three spaces: it is a type, a value, and a + // namespace whose members are reachable by qualified name. + this.bindNamed(node as ts.EnumDeclaration, TsBoundKind.EnumDeclaration, blockScope, + new Set([ + TsDeclarationSpace.NAMESPACE, + TsDeclarationSpace.TYPE, + TsDeclarationSpace.VALUE, + ])); + return; + } + case ts.SyntaxKind.ModuleDeclaration: { + this.visitModuleDeclaration(node as ts.ModuleDeclaration, varScope, blockScope, nearest); + return; + } + case ts.SyntaxKind.VariableStatement: { + const statement = node as ts.VariableStatement; + this.bindVariableDeclarationList(statement.declarationList, varScope, blockScope, nearest, + hasModifier(statement, ts.SyntaxKind.ExportKeyword)); + return; + } + case ts.SyntaxKind.ImportDeclaration: + case ts.SyntaxKind.ImportEqualsDeclaration: { + this.bindImport(node, blockScope); + return; + } + case ts.SyntaxKind.Block: { + const scope = this.makeLocalScope(node, TsScopeKind.BLOCK, nearest, false, true); + for (const child of (node as ts.Block).statements) { + this.visit(child, varScope, scope, scope); + } + return; + } + case ts.SyntaxKind.ForStatement: + case ts.SyntaxKind.ForInStatement: + case ts.SyntaxKind.ForOfStatement: { + this.visitForStatement(node as ts.IterationStatement, varScope, nearest); + return; + } + case ts.SyntaxKind.CatchClause: { + this.visitCatchClause(node as ts.CatchClause, varScope, nearest); + return; + } + case ts.SyntaxKind.CaseBlock: { + // One scope for the whole switch body, matching the binder: a `let` in + // `case 1:` is visible in `case 2:`, which is why the classic + // fall-through redeclaration is an error rather than a shadow. + const scope = this.makeLocalScope(node, TsScopeKind.BLOCK, nearest, false, true); + for (const clause of (node as ts.CaseBlock).clauses) { + for (const statement of clause.statements) { + this.visit(statement, varScope, scope, scope); + } + } + return; + } + case ts.SyntaxKind.FunctionExpression: + case ts.SyntaxKind.ArrowFunction: + case ts.SyntaxKind.MethodDeclaration: + case ts.SyntaxKind.Constructor: + case ts.SyntaxKind.GetAccessor: + case ts.SyntaxKind.SetAccessor: + case ts.SyntaxKind.ClassStaticBlockDeclaration: { + this.visitFunctionLike(node as ts.SignatureDeclaration, nearest); + return; + } + case ts.SyntaxKind.ClassExpression: { + this.visitClassLike(node as ts.ClassExpression, nearest); + return; + } + default: { + ts.forEachChild(node, (child) => { + this.visit(child, varScope, blockScope, nearest); + }); + return; + } + } + } + + private visitModuleDeclaration( + node: ts.ModuleDeclaration, + varScope: TsScope, + blockScope: TsScope, + nearest: TsScope + ): void { + const body = node.body; + if (ts.isStringLiteral(node.name)) { + // `declare module "x"` — an ambient module or an augmentation. Its + // declarations belong to the TARGET module's table, which is what makes + // `Request` here and `Request` in the augmented file one symbol. + const specifier = node.name.text; + const resolved = this.options.resolveModuleHash(specifier, this.options.filePath); + const targetHash = + resolved?.moduleHash ?? + this.options.ambientModuleHashes.get(specifier) ?? + this.options.moduleHash; + + // The declaration NODE is itself a declaration site, of the module + // symbol. tsc names that symbol with the quoted specifier for a bare + // module and with the quoted resolved path for an augmentation, and both + // live in the one global table of ambient modules — which is why two + // files declaring `declare module "*.svg"` are one symbol, and why two + // files augmenting the same module are too. + this.bindLocal( + node, + `"${resolved ? resolved.relativePath : specifier}"`, + TsBoundKind.ModuleDeclaration, + this.globalAmbientScope(), + node, + new Set([TsDeclarationSpace.NAMESPACE, TsDeclarationSpace.VALUE]), + false, + true + ); + + const scope = this.makeScope( + node, + TsScopeKind.AMBIENT_MODULE, + nearest, + true, + true, + `${TsMergeScopePrefix.MODULE_EXPORTS}:${targetHash}`, + `${TsMergeScopePrefix.MODULE_EXPORTS}:${targetHash}`, + '' + ); + this.scopeByNode.set(nodeId(node, this.sf), scope); + if (body && ts.isModuleBlock(body)) { + for (const statement of body.statements) { + this.visit(statement, scope, scope, scope); + } + } + return; + } + + if (node.flags & ts.NodeFlags.GlobalAugmentation) { + // `declare global { … }` — everything inside lands in GLOBAL, from inside + // a module. This is how a module contributes to the global scope without + // being a script. + // + // The `global` node is itself a declaration of tsc's reserved `__global` + // symbol, so N `declare global` blocks across N files are one symbol. + // The name is passed unescaped: `__global` is already an internal name + // and escaping it again would produce `___global` and split the group. + this.bindLocal( + node, + TS_GLOBAL_SYMBOL_NAME, + TsBoundKind.ModuleDeclaration, + this.globalAmbientScope(), + node, + new Set([TsDeclarationSpace.NAMESPACE, TsDeclarationSpace.VALUE]), + false, + true + ); + const scope = this.makeScope( + node, + TsScopeKind.GLOBAL_AUGMENTATION, + nearest, + true, + true, + TsMergeScopePrefix.GLOBAL, + TsMergeScopePrefix.GLOBAL, + '' + ); + this.scopeByNode.set(nodeId(node, this.sf), scope); + if (body && ts.isModuleBlock(body)) { + for (const statement of body.statements) { + this.visit(statement, scope, scope, scope); + } + } + return; + } + + // A named namespace. It occupies NAMESPACE always, and VALUE only when it + // is INSTANTIATED — a namespace holding nothing but types is erased + // entirely and has no runtime existence to record. + const spaces = new Set([TsDeclarationSpace.NAMESPACE]); + if (isInstantiatedNamespace(node)) { + spaces.add(TsDeclarationSpace.VALUE); + } + const binding = this.bindNamed(node, TsBoundKind.ModuleDeclaration, blockScope, spaces); + if (!body) { + return; + } + if (ts.isModuleDeclaration(body)) { + // `namespace A.B.C {}` — the dotted form nests one namespace per segment. + this.visitModuleDeclaration(body, varScope, blockScope, nearest); + return; + } + if (!ts.isModuleBlock(body)) { + return; + } + const scope = this.makeScope( + body, + TsScopeKind.NAMESPACE, + nearest, + true, + true, + // Exported members belong to the NAMESPACE's table, keyed by the + // namespace's own group key, so a member survives the namespace merging + // with a class, a function or an enum of the same name. + `${TsMergeScopePrefix.NS}:${binding?.declarationGroupKey ?? ''}`, + '', + binding?.declarationGroupKey ?? '' + ); + // Non-exported members are locals of the module block, not of the + // namespace symbol, so they can never be reached by qualified name. + const withLocal: TsScope = { + ...scope, + localMergeScopeKey: `${TsMergeScopePrefix.LOCALS}:${scope.scopeHash}`, + }; + this.scopeByNode.set(nodeId(body, this.sf), withLocal); + for (const statement of body.statements) { + this.visit(statement, withLocal, withLocal, withLocal); + } + } + + private visitForStatement(node: ts.IterationStatement, varScope: TsScope, nearest: TsScope): void { + // The loop header is its own block scope: `for (let i = ...)` re-binds `i` + // per iteration and `i` is not visible outside the loop. + const scope = this.makeLocalScope(node, TsScopeKind.BLOCK, nearest, false, true); + if (ts.isForStatement(node)) { + if (node.initializer && ts.isVariableDeclarationList(node.initializer)) { + this.bindVariableDeclarationList(node.initializer, varScope, scope, scope, false); + } else if (node.initializer) { + this.visit(node.initializer, varScope, scope, scope); + } + for (const part of [node.condition, node.incrementor]) { + if (part) { + this.visit(part, varScope, scope, scope); + } + } + } else if (ts.isForInStatement(node) || ts.isForOfStatement(node)) { + if (ts.isVariableDeclarationList(node.initializer)) { + this.bindVariableDeclarationList(node.initializer, varScope, scope, scope, false); + } else { + this.visit(node.initializer, varScope, scope, scope); + } + this.visit(node.expression, varScope, scope, scope); + } + this.visit(node.statement, varScope, scope, scope); + } + + private visitCatchClause(node: ts.CatchClause, varScope: TsScope, nearest: TsScope): void { + const scope = this.makeLocalScope(node, TsScopeKind.BLOCK, nearest, false, true); + if (node.variableDeclaration) { + // tsc's node for a catch binding IS a VariableDeclaration, so it appears + // in the merge partition as one. Its scope is the catch clause, never the + // enclosing block. + this.bindVariableDeclaration(node.variableDeclaration, scope, scope, false); + } + for (const statement of node.block.statements) { + this.visit(statement, varScope, scope, scope); + } + } + + private visitFunctionLike(node: ts.SignatureDeclaration | ts.ClassStaticBlockDeclaration, + nearest: TsScope): void { + const scope = this.makeLocalScope(node, TsScopeKind.FUNCTION, nearest, true, true); + // A NAMED function expression binds its own name inside its own body and + // nowhere else — that is how `(function scan(d) { … scan(d) … })(root)` + // recurses. Without it the recursive call resolves to nothing, which is a + // break in the hop chain rather than a wrong answer. + if (ts.isFunctionExpression(node) && node.name) { + this.bindLocal(node, node.name.text, TsBoundKind.FunctionExpression, scope, node, + new Set([TsDeclarationSpace.VALUE]), false); + } + if (!ts.isClassStaticBlockDeclaration(node)) { + for (const typeParameter of node.typeParameters ?? []) { + this.bindLocal(typeParameter, typeParameter.name.text, TsBoundKind.TypeParameter, scope, + typeParameter, new Set(), false); + } + for (const parameter of node.parameters) { + this.bindBindingName(parameter.name, parameter, TsBoundKind.Parameter, scope, scope, false); + if (parameter.initializer) { + this.visit(parameter.initializer, scope, scope, scope); + } + } + } + const body = (node as { body?: ts.Node }).body; + if (!body) { + return; + } + if (ts.isBlock(body)) { + // The function body shares the function's scope: `var` in a body belongs + // to the function, not to a nested block. + for (const statement of body.statements) { + this.visit(statement, scope, scope, scope); + } + return; + } + // A concise arrow body is an expression, not a block. + this.visit(body, scope, scope, scope); + } + + private visitClassLike(node: ts.ClassLikeDeclaration, nearest: TsScope): void { + const scope = this.makeLocalScope(node, TsScopeKind.TYPE_CONTAINER, nearest, false, true); + for (const typeParameter of node.typeParameters ?? []) { + this.bindLocal(typeParameter, typeParameter.name.text, TsBoundKind.TypeParameter, scope, + typeParameter, new Set(), false); + } + this.recordMembers(node, node.members); + for (const member of node.members) { + this.visit(member, scope, scope, scope); + } + for (const clause of node.heritageClauses ?? []) { + for (const type of clause.types) { + this.visit(type, scope, scope, scope); + } + } + } + + private visitTypeContainerChildren( + node: ts.InterfaceDeclaration | ts.TypeAliasDeclaration, + nearest: TsScope + ): void { + const scope = this.makeLocalScope(node, TsScopeKind.TYPE_CONTAINER, nearest, false, true); + for (const typeParameter of node.typeParameters ?? []) { + this.bindLocal(typeParameter, typeParameter.name.text, TsBoundKind.TypeParameter, scope, + typeParameter, new Set(), false); + } + } + + private recordMembers(owner: ts.Node, members: readonly ts.ClassElement[] | readonly ts.TypeElement[]): void { + const table = new Map(); + for (const member of members) { + const name = memberName(member); + if (name === undefined) { + continue; + } + const existing = table.get(name); + if (existing) { + existing.push(member); + } else { + table.set(name, [member]); + } + } + this.memberTableByOwner.set(nodeId(owner, this.sf), table); + } + + // ------------------------------------------------------------------------- + // binding + // ------------------------------------------------------------------------- + + private bindVariableDeclarationList( + list: ts.VariableDeclarationList, + varScope: TsScope, + blockScope: TsScope, + nearest: TsScope, + isExported: boolean + ): void { + // `var` is function-scoped and `let`/`const` are block-scoped. That is the + // whole reason there are two tables. + const isVar = (list.flags & ts.NodeFlags.BlockScoped) === 0; + const target = isVar ? varScope : blockScope; + for (const declaration of list.declarations) { + this.bindVariableDeclaration(declaration, target, nearest, isExported); + } + } + + private bindVariableDeclaration( + declaration: ts.VariableDeclaration, + target: TsScope, + nearest: TsScope, + isExported: boolean + ): void { + if (ts.isIdentifier(declaration.name)) { + this.bindLocal(declaration, declaration.name.text, TsBoundKind.VariableDeclaration, target, + declaration, new Set([TsDeclarationSpace.VALUE]), isExported); + } else { + // A binding pattern declares symbols whose declaration node is a + // BindingElement, NOT a VariableDeclaration. tsc's symbol partition says + // so, and the distinction is why destructured names are bound with their + // own kind rather than folded in. + this.bindBindingName(declaration.name, declaration, TsBoundKind.BindingElement, target, + target, isExported); + } + if (declaration.initializer) { + this.visit(declaration.initializer, nearest, nearest, nearest); + } + } + + private bindBindingName( + name: ts.BindingName, + owner: ts.Node, + kind: TsBoundKind, + target: TsScope, + nearest: TsScope, + isExported: boolean + ): void { + if (ts.isIdentifier(name)) { + this.bindLocal(owner, name.text, kind, target, kind === TsBoundKind.Parameter ? owner : owner, + new Set([TsDeclarationSpace.VALUE]), isExported); + return; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) { + continue; + } + this.bindBindingName(element.name, element, TsBoundKind.BindingElement, target, nearest, + isExported); + if (element.initializer) { + this.visit(element.initializer, nearest, nearest, nearest); + } + } + } + + private bindImport(node: ts.Node, blockScope: TsScope): void { + if (ts.isImportEqualsDeclaration(node)) { + this.bindLocal(node, node.name.text, TsBoundKind.ImportBinding, blockScope, node, new Set(), + hasModifier(node, ts.SyntaxKind.ExportKeyword)); + return; + } + if (!ts.isImportDeclaration(node) || !node.importClause) { + return; + } + const clause = node.importClause; + if (clause.name) { + this.bindLocal(clause, clause.name.text, TsBoundKind.ImportBinding, blockScope, clause, + new Set(), false); + } + const bindings = clause.namedBindings; + if (!bindings) { + return; + } + if (ts.isNamespaceImport(bindings)) { + this.bindLocal(bindings, bindings.name.text, TsBoundKind.ImportBinding, blockScope, bindings, + new Set(), false); + return; + } + for (const specifier of bindings.elements) { + this.bindLocal(specifier, specifier.name.text, TsBoundKind.ImportBinding, blockScope, + specifier, new Set(), false); + } + } + + private bindNamed( + node: ts.NamedDeclaration, + kind: TsBoundKind, + target: TsScope, + spaces: ReadonlySet + ): BoundDeclaration | undefined { + const isExported = hasModifier(node, ts.SyntaxKind.ExportKeyword); + const isDefault = hasModifier(node, ts.SyntaxKind.DefaultKeyword); + // `export default class {}` binds under the binder's own reserved name. + // This is `InternalSymbolName.Default`, not an invention of this parser. + const name = isDefault + ? TS_DEFAULT_EXPORT_NAME + : node.name && ts.isIdentifier(node.name) + ? node.name.text + : node.name && ts.isStringLiteral(node.name) + ? node.name.text + : ''; + if (name === '') { + return undefined; + } + return this.bindLocal(node, name, kind, target, node, spaces, isExported); + } + + /** + * The one global table of ambient module declarations. + * + * Ambient modules do not live in the declaring file's scope — `declare module + * "*.svg"` in two files is one symbol — so they are bound under `GLOBAL` + * regardless of which file or which module scope they were written in. + */ + private globalAmbientScope(): TsScope { + if (!this.ambientScope) { + this.ambientScope = this.makeScope( + this.sf, + TsScopeKind.AMBIENT_MODULE, + undefined, + true, + true, + TsMergeScopePrefix.GLOBAL, + TsMergeScopePrefix.GLOBAL, + '' + ); + } + return this.ambientScope; + } + + private bindLocal( + node: ts.Node, + name: string, + kind: TsBoundKind, + target: TsScope, + siteNode: ts.Node, + spaces: ReadonlySet, + isExported: boolean, + preEscaped = false + ): BoundDeclaration { + const escapedName = preEscaped ? name : escapeName(name); + // Which of the scope's two keys applies is decided by `export`, because in + // a module or a namespace exported and local declarations of one name are + // two symbols. tsc rejects mixing them (TS2395), which is the evidence. + const mergeScopeKey = isExported && target.exportedMergeScopeKey !== '' + ? target.exportedMergeScopeKey + : target.localMergeScopeKey !== '' + ? target.localMergeScopeKey + : target.exportedMergeScopeKey; + const binding: BoundDeclaration = { + name, + escapedName, + kind, + node, + siteNode, + mergeScopeKey, + declarationGroupKey: declarationGroupKeyFor(mergeScopeKey, escapedName), + declarationSpaces: spaces, + isExported, + scope: target, + }; + const table = kind === TsBoundKind.FunctionDeclaration + || (kind === TsBoundKind.VariableDeclaration && isVarDeclaration(node)) + ? target.varTable + : target.blockTable; + const existing = table.get(escapedName); + if (existing) { + existing.push(binding); + } else { + table.set(escapedName, [binding]); + } + this.bindingByNode.set(nodeId(node, this.sf), binding); + this.declarationsInOrder.push(binding); + return binding; + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +function isVarDeclaration(node: ts.Node): boolean { + if (!ts.isVariableDeclaration(node)) { + return false; + } + const list = node.parent; + if (!list || !ts.isVariableDeclarationList(list)) { + return false; + } + return (list.flags & ts.NodeFlags.BlockScoped) === 0; +} + +export function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { + const modifiers = (node as { modifiers?: ts.NodeArray }).modifiers; + if (!modifiers) { + return false; + } + for (const modifier of modifiers) { + if (modifier.kind === kind) { + return true; + } + } + return false; +} + +/** + * TypeScript's `escapeLeadingUnderscores`. + * + * A name beginning with two underscores gets a third in the symbol table, so + * `__proto__` cannot collide with `Object.prototype.__proto__`. Reproducing it + * matters because `escapedName` is half of the merge key: getting it wrong + * splits or joins symbols in exactly the cases the escape exists to separate. + */ +/** + * tsc's `InternalSymbolName.Global` — the symbol every `declare global` block + * declares. Reproduced verbatim so N blocks across N files form one group. + */ +export const TS_GLOBAL_SYMBOL_NAME = '__global'; + +export function escapeName(name: string): string { + return name.length >= 2 && name.charCodeAt(0) === 95 && name.charCodeAt(1) === 95 + ? `_${name}` + : name; +} + +/** + * Is this namespace INSTANTIATED — does it exist at runtime? + * + * `namespace N { interface I {} }` is erased entirely: it occupies the NAMESPACE + * space but not VALUE. A parser that treats every namespace as a value invents a + * runtime entity for the erased ones; one that treats none as a value loses the + * real ones. tsc calls this `ModuleInstantiationState` and decides it from the + * body alone, which is why it is decidable here. + */ +export function isInstantiatedNamespace(node: ts.ModuleDeclaration): boolean { + const body = node.body; + if (!body) { + return false; + } + if (ts.isModuleDeclaration(body)) { + return isInstantiatedNamespace(body); + } + if (!ts.isModuleBlock(body)) { + return false; + } + for (const statement of body.statements) { + switch (statement.kind) { + case ts.SyntaxKind.InterfaceDeclaration: + case ts.SyntaxKind.TypeAliasDeclaration: { + continue; + } + case ts.SyntaxKind.ModuleDeclaration: { + if (isInstantiatedNamespace(statement as ts.ModuleDeclaration)) { + return true; + } + continue; + } + default: { + return true; + } + } + } + return false; +} + +/** The member's name as written, or `undefined` for a computed or unnamed member. */ +export function memberName(member: ts.Node): string | undefined { + const name = (member as { name?: ts.PropertyName }).name; + if (!name) { + return undefined; + } + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) { + return name.text; + } + if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + if (ts.isComputedPropertyName(name)) { + return computedMemberName(name.expression); + } + return undefined; +} + +/** + * The ECMAScript well-known symbols — a closed, spec-defined list. + * + * Not a maintenance burden and not a heuristic: these are the only symbols + * whose identity the language fixes, which is what makes `[Symbol.iterator]` + * statically nameable while `[someConst]` is not. + */ +const WELL_KNOWN_SYMBOLS = new Set([ + 'asyncDispose', 'asyncIterator', 'dispose', 'hasInstance', 'isConcatSpreadable', + 'iterator', 'match', 'matchAll', 'replace', 'search', 'species', 'split', + 'toPrimitive', 'toStringTag', 'unscopables', +]); + +/** + * The name of a COMPUTED member key, when syntax alone fixes its value. + * + * Three cases are decidable and one is not: + * + * ["strLit"] -> `strLit` tsc's escapedName is exactly this + * [42] -> `42` likewise + * [Symbol.iterator] -> `[Symbol.iterator]` the language fixes the identity + * [someConst] -> undefined needs the const's VALUE + * + * The literal cases match tsc's declaration symbol outright — verified, + * `escapedName` is `"strLit"` and `"42"`, so returning `undefined` for them was + * simply losing a name tsc already had. + * + * For a well-known symbol tsc offers three names and none can be copied: the + * declaration symbol says `__computed`, which cannot tell `[Symbol.iterator]` + * from `[someConst]`; the late-bound type member says `__@iterator@6`, whose + * trailing id is per-`Program` and so not reproducible without one; and + * `symbolToString` says `[Symbol.iterator]`. The display form is the only one + * that is both stable and derivable from syntax, so that is what this emits. + * + * `[someConst]` stays unnamed on purpose. tsc late-binds it by FOLDING the + * constant — for `const k = "dyn"` the member becomes `dyn` — and folding is a + * checker computation. An unnamed member must then carry NO group key, or + * every dynamic key on one owner collides into a single false overload set. + */ +function computedMemberName(expression: ts.Expression): string | undefined { + if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression)) { + return expression.text; + } + if (ts.isPropertyAccessExpression(expression) + && ts.isIdentifier(expression.expression) + && expression.expression.text === 'Symbol' + && ts.isIdentifier(expression.name) + && WELL_KNOWN_SYMBOLS.has(expression.name.text)) { + return `[Symbol.${expression.name.text}]`; + } + return undefined; +} diff --git a/parser/src/parsers/typescript/extractors/ts-comment-extractor.ts b/parser/src/parsers/typescript/extractors/ts-comment-extractor.ts new file mode 100644 index 000000000..1dd1ab2a0 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-comment-extractor.ts @@ -0,0 +1,172 @@ +import * as ts from 'typescript'; + +import { TsCommentRegistry } from '@/analysis-types/typescript/TsCommentRegistry'; +import { TsCommentKind, TsDirectiveKind } from '@/enums/typescript/comments'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Emits `ts_comment` rows — schema §4.17. + * + * ## Two of the five kinds are not commentary at all + * + * A `/// ` is a MODULE EDGE; in ambient code it is frequently the + * only edge a file has. A `@ts-ignore` SUPPRESSES A DIAGNOSTIC, and + * `@ts-expect-error` REQUIRES one — a codebase migrating between the two is + * measurably tightening, and a fact base that folds them cannot see it. + * + * Both change what the program means, so both carry a `directiveKind` rather + * than being recorded as prose. + * + * ## Comments are scanned, not walked + * + * A comment is TRIVIA: it is not in the AST, and no `forEachChild` reaches it. + * `ts.getLeadingCommentRanges` over the full text is the only way to see them + * all, including the ones attached to nothing — a trailing block at end of file, + * or a suppression above a statement the extractor does not model. + */ +export interface CommentExtractorOptions { + readonly sourceFile: ts.SourceFile; + readonly filePath: string; + readonly tsModuleLinkHash: string; + readonly serviceVersionLinkHash: string; + /** Byte offset of a declaration's start -> its row hash, for `ownerHash`. */ + readonly ownerHashByStart: ReadonlyMap; +} + +export function extractComments(options: CommentExtractorOptions): TsCommentRegistry[] { + const sf = options.sourceFile; + const text = sf.getFullText(); + const out: TsCommentRegistry[] = []; + const seen = new Set(); + let index = 0; + + const emitRange = (range: ts.CommentRange, ownerStart: number | undefined): void => { + const key = `${range.pos}:${range.end}`; + if (seen.has(key)) { + // A comment between two declarations is BOTH the leading trivia of one and + // the trailing trivia of the other, so the scan reaches it twice. The PK + // would be identical, and a duplicate key doubles a count rather than + // colliding. + return; + } + seen.add(key); + const body = text.slice(range.pos, range.end); + const start = sf.getLineAndCharacterOfPosition(range.pos); + const end = sf.getLineAndCharacterOfPosition(range.end); + const directive = directiveKindOf(body); + out.push(new TsCommentRegistry({ + commentKind: commentKindOf(body, range, directive), + commentText: EntityUtils.normalizeWhitespace(body), + startLine: start.line + 1, + startColumn: start.character + 1, + endLine: end.line + 1, + endColumn: end.character + 1, + ownerHash: ownerStart === undefined + ? '' + : options.ownerHashByStart.get(ownerStart) ?? '', + commentIndex: index, + filePath: options.filePath, + tsModuleLinkHash: options.tsModuleLinkHash, + jsDocTags: jsDocTagsOf(body), + directiveKind: directive, + serviceVersionLinkHash: options.serviceVersionLinkHash, + })); + index += 1; + }; + + // Every declaration's leading trivia, so a JSDoc block gets an owner. + const visit = (node: ts.Node): void => { + const start = node.getFullStart(); + for (const range of ts.getLeadingCommentRanges(text, start) ?? []) { + emitRange(range, node.getStart(sf)); + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + + // Then the whole file, so nothing attached to nothing is lost — a suppression + // above an unmodelled statement, or a trailing block at end of file. + for (const range of ts.getLeadingCommentRanges(text, 0) ?? []) { + emitRange(range, undefined); + } + // Trailing trivia, at each statement's end rather than at every offset in the + // file. Scanning every offset works and costs a call per character; the ends + // are where a trailing comment can actually be. + const trailingFrom = (node: ts.Node): void => { + for (const range of ts.getTrailingCommentRanges(text, node.end) ?? []) { + emitRange(range, undefined); + } + ts.forEachChild(node, trailingFrom); + }; + ts.forEachChild(sf, trailingFrom); + for (const range of ts.getLeadingCommentRanges(text, sf.endOfFileToken.getFullStart()) ?? []) { + emitRange(range, undefined); + } + return out; +} + +function commentKindOf( + body: string, + range: ts.CommentRange, + directive: TsDirectiveKind | '' +): TsCommentKind { + if (directive === TsDirectiveKind.REFERENCE_PATH + || directive === TsDirectiveKind.REFERENCE_TYPES + || directive === TsDirectiveKind.REFERENCE_LIB) { + return TsCommentKind.TRIPLE_SLASH_DIRECTIVE; + } + if (directive !== '') { + return TsCommentKind.TS_DIRECTIVE; + } + if (range.kind === ts.SyntaxKind.SingleLineCommentTrivia) { + return TsCommentKind.LINE; + } + // JSDoc opens with exactly two asterisks. `/***` is a block comment, and + // treating it as JSDoc would attach tags to something the compiler ignores. + return body.startsWith('/**') && !body.startsWith('/***') + ? TsCommentKind.JSDOC + : TsCommentKind.BLOCK; +} + +function directiveKindOf(body: string): TsDirectiveKind | '' { + if (/^\/\/\/\s* { + const out = new Set(); + if (!body.startsWith('/**')) { + return out; + } + for (const match of body.matchAll(/^\s*\*?\s*@([A-Za-z][A-Za-z0-9-]*)/gm)) { + const tag = match[1]; + if (tag !== undefined) { + out.add(tag); + } + } + return out; +} diff --git a/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts new file mode 100644 index 000000000..7df65b22e --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts @@ -0,0 +1,3469 @@ +import * as ts from 'typescript'; + +import { TsBlockRegistry } from '@/analysis-types/typescript/TsBlockRegistry'; +import { TsEnumMemberRegistry } from '@/analysis-types/typescript/TsEnumMemberRegistry'; +import { TsFieldPositionRegistry } from '@/analysis-types/typescript/TsFieldPositionRegistry'; +import { TsFieldRegistry } from '@/analysis-types/typescript/TsFieldRegistry'; +import { TsMethodParameterRegistry } from '@/analysis-types/typescript/TsMethodParameterRegistry'; +import { TsMethodRegistry } from '@/analysis-types/typescript/TsMethodRegistry'; +import { TsTypeHeritageRegistry } from '@/analysis-types/typescript/TsTypeHeritageRegistry'; +import { TsTypeParameterRegistry } from '@/analysis-types/typescript/TsTypeParameterRegistry'; +import { TsTypeRegistry } from '@/analysis-types/typescript/TsTypeRegistry'; +import { TsVariableRegistry } from '@/analysis-types/typescript/TsVariableRegistry'; +import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; +import { + TS_ANONYMOUS_METHOD_NAMES, + TS_MODULE_INITIALIZER_NAME, +} from '@/constants/typescript-constants'; +import { TsBlockKind } from '@/enums/typescript/blocks'; +import { TsEnumMemberValueKind } from '@/enums/typescript/enum-members'; +import { TsFieldAccess, TsFieldModifier, TsMemberKind } from '@/enums/typescript/fields'; +import { TsClauseToken, TsHeritageKind } from '@/enums/typescript/heritage'; +import { + TsDefaultValueKind, + TsParamKind, + TsParameterPropertyModifier, +} from '@/enums/typescript/method-parameters'; +import { + TsBodyPresence, + TsMethodAccess, + TsMethodKind, + TsMethodModifier, + TsSignatureRole, +} from '@/enums/typescript/methods'; +import { + TsTypeParameterOwnerKind, + TsVarianceAnnotation, +} from '@/enums/typescript/type-parameters'; +import { + TsReferenceOwnerKind, + TsTypeRefContext, +} from '@/enums/typescript/type-references'; +import { + TsDeclarationSpace, + TsTypeAccess, + TsTypeCategory, + TsTypeModifier, + TsTypePlacement, +} from '@/enums/typescript/types'; +import { + TsVariableDeclarationKind, + TsVariableInitializerKind, + TsBindingSourceKind, + TsVariableScopeKind, +} from '@/enums/typescript/variables'; +import { BinderResult, BoundDeclaration, escapeName, hasModifier, memberName, nodeId } from + '@/parsers/typescript/extractors/ts-binder'; +import { + qualifiedPathOf, + simpleNameOf, + TsTypeReferenceExtractor, +} from '@/parsers/typescript/extractors/ts-type-reference-extractor'; +import { EntityUtils } from '@/utils/entity-utils'; +import { TS_DEFAULT_EXPORT_NAME } from '@/constants/typescript-constants'; + +/** + * Emits every DECLARATION relation for one source file. + * + * This is the Java port: `type-registry-extractor`, `type-method-extractor`, + * `field-extractor` and `method-parameter-extractor` map across close to 1:1, + * because TypeScript — like Java and unlike Python — writes its types at the + * declaration site. 85.3% of parameters carry an annotation, so a syntax-directed + * walk recovers most of the semantic model from the tree alone. + * + * What does NOT port is anything that assumes one declaration per name. Every + * row here carries the binder's `declarationGroupKey`, and the group key is + * deliberately not unique. + */ +export interface DeclarationExtractionResult { + readonly types: readonly TsTypeRegistry[]; + readonly methods: readonly TsMethodRegistry[]; + readonly methodParameters: readonly TsMethodParameterRegistry[]; + readonly fields: readonly TsFieldRegistry[]; + readonly variables: readonly TsVariableRegistry[]; + readonly heritages: readonly TsTypeHeritageRegistry[]; + readonly typeParameters: readonly TsTypeParameterRegistry[]; + readonly enumMembers: readonly TsEnumMemberRegistry[]; + readonly fieldPositions: readonly TsFieldPositionRegistry[]; + readonly blocks: readonly TsBlockRegistry[]; + readonly typeReferenceExtractor: TsTypeReferenceExtractor; +} + +/** Where an emission currently is, in the FK sense rather than the lexical one. */ +interface EmitContext { + readonly typeHash: string; + readonly methodHash: string; + readonly blockHash: string; + readonly ownerTypeName: string; + readonly ownerQualifiedName: string; + /** + * The owning type's `declarationGroupKey`, when there is an owning type. + * + * Optional so no other context literal changes: only {@link contextForType} + * can know it, and only a MEMBER needs it. + */ + readonly ownerGroupKey?: string; + /** Namespace / nested-type path, for qualified names. */ + readonly namePath: readonly string[]; + readonly scopeDepth: number; + readonly isAmbient: boolean; + readonly moduleHash: string; + readonly moduleQualifiedName: string; +} + +export interface DeclarationExtractorOptions { + readonly sourceFile: ts.SourceFile; + readonly binder: BinderResult; + readonly filePath: string; + readonly baseMservPath: string; + readonly fileName: string; + readonly moduleHash: string; + readonly moduleQualifiedName: string; + readonly isDeclarationFile: boolean; + readonly serviceVersionLinkHash: string; + /** For a `declare module "x"` body, the module row that body belongs to. */ + readonly moduleHashForNode: (node: ts.Node) => string; +} + +export class TsDeclarationExtractor { + readonly types: TsTypeRegistry[] = []; + readonly methods: TsMethodRegistry[] = []; + readonly methodParameters: TsMethodParameterRegistry[] = []; + readonly fields: TsFieldRegistry[] = []; + readonly variables: TsVariableRegistry[] = []; + readonly heritages: TsTypeHeritageRegistry[] = []; + readonly typeParameters: TsTypeParameterRegistry[] = []; + readonly enumMembers: TsEnumMemberRegistry[] = []; + readonly fieldPositions: TsFieldPositionRegistry[] = []; + readonly blocks: TsBlockRegistry[] = []; + readonly typeReferenceExtractor: TsTypeReferenceExtractor; + + /** + * Declaration-to-expression FKs, resolved after the expression pass. + * + * A declaration row is minted before any expression exists, so its FK to the + * expression that initialises or guards it cannot be filled in place. Queuing + * the setter with the NODE is what lets the fact extractor close the link once + * the walker has recorded a root hash for it — and the alternative, leaving + * nine FKs permanently empty, silently drops nine chains an engine can follow. + */ + readonly pendingExpressionLinks: { node: ts.Node; link: (hash: string) => void }[] = []; + /** + * Node -> the DECLARATION HASH that node introduces, for `ts_expression` c16. + * + * Polymorphic, discriminated by the expression's own `kind`: a `ts_type` hash + * for a class expression, a `ts_method` hash for an arrow or a function + * expression. c16 was widened for the second case after this parser reported + * that an IIFE had a `ts_method` row and an `ts_expression` row with no FK + * between them — the engine's only route was a position match. + */ + readonly anonymousDeclarationByNode = new Map(); + + /** Emitted hashes by node identity, so later passes never re-derive a key. */ + readonly typeHashByNode = new Map(); + readonly methodHashByNode = new Map(); + readonly fieldHashByNode = new Map(); + readonly variableHashByNode = new Map(); + readonly parameterHashByNode = new Map(); + readonly blockHashByNode = new Map(); + readonly typeParameterHashByNode = new Map(); + readonly enumMemberHashByNode = new Map(); + /** Rows by node identity, for back-patching FKs that only exist later. */ + readonly typeRowByNode = new Map(); + readonly methodRowByNode = new Map(); + readonly variableRowByNode = new Map(); + readonly fieldRowByNode = new Map(); + readonly blockRowByNode = new Map(); + /** The synthetic `` initializer, owner of top-level executable code. */ + moduleInitMethodHash = ''; + + private readonly sf: ts.SourceFile; + /** + * Type parameters in lexical scope, innermost frame last. + * + * Holds the DECLARATIONS, not just their names, so a reference to `T` can name + * the row that declares it. A method's `T` shadows its class's `T` and they + * are different entities, so the innermost frame must win -- searching from + * the end is what makes that true. + */ + private readonly typeParameterStack: Map[] = []; + private blockOrder = 0; + /** + * Overload sets, keyed by `(owner hash, escaped name, static-ness)`. + * + * Collected during the walk and resolved afterwards, because whether a + * declaration is SOLE or one of N is only knowable once its siblings have all + * been seen — and the sibling can appear later in the file. + */ + private readonly overloadSets = new Map(); + + /** + * Signature rows whose return reference is emitted by someone else. + * + * A signature declared INSIDE a type -- a call signature, a function type, a + * type-literal method -- does not create its own return reference; the + * enclosing type's tree does, at depth 1. Linked after the walk rather than + * during it, because the reference may be emitted before or after the + * signature row depending on which side of the tree the walk reaches first. + */ + private readonly pendingReturnLinks: { row: TsMethodRegistry; node: ts.TypeNode }[] = []; + + /** + * Shape members whose declared type is emitted by the enclosing type's tree. + * + * The same shape as pendingReturnLinks and for the same reason: a member of an + * anonymous type literal does not create its own type reference, so it had the + * type only as TEXT and a consumer had to parse `Record` out of a + * string to follow it anywhere. + */ + private readonly pendingMemberTypeLinks: { row: TsFieldRegistry; node: ts.TypeNode }[] = []; + + /** + * Object-literal members awaiting the hash of the literal that owns them. + * + * §4.8.1 widened this FK to "the owning type OR shape". An object literal is a + * third kind of owner -- a `ts_expression` row -- and OQ-10 left it open + * pending a measurement. The literal's row is emitted by the expression pass, + * so the link is made after it. + */ + readonly pendingLiteralOwnerLinks: { row: TsMethodRegistry; node: ts.Node }[] = []; + + /** Type-level parameters whose constraint the enclosing type's tree emits. */ + private readonly pendingConstraintLinks: + { row: TsTypeParameterRegistry; node: ts.TypeNode }[] = []; + + constructor(private readonly options: DeclarationExtractorOptions) { + this.sf = options.sourceFile; + this.typeReferenceExtractor = new TsTypeReferenceExtractor( + this.sf, + options.serviceVersionLinkHash, + () => this.typeParametersInScope(), + (name) => this.typeParameterDeclarationFor(name) + ); + // A function type is a callable signature as well as a type node, so it + // gets a `ts_method` row. Minting it here — from inside the type-reference + // walk — is what guarantees EVERY function type gets one, wherever it was + // written: an annotation, a type alias RHS, a nested union member, a type + // argument. Enumerating those positions by hand would miss one. + this.typeReferenceExtractor.onFunctionType = (node, selfReferenceHash) => { + this.emitFunctionTypeSignature(node, selfReferenceHash); + }; + // `[K in keyof T]` and `infer U` declare real type parameters that no + // declaration walk reaches — they are inside type NODES. Minting them from + // the type-reference walk is what guarantees every one gets a row, wherever + // it was written. + // A type literal's members are declarations with no `ts_type` to own them, + // and they are real call targets. See `emitAnonymousMember`. + this.typeReferenceExtractor.onTypeLiteralMember = (member, typeLiteralHash) => { + this.emitAnonymousMember(member, typeLiteralHash); + }; + this.typeReferenceExtractor.onTypeLevelParameter = (typeParameter, ownerHash, ownerKind) => { + this.emitTypeLevelParameter(typeParameter, ownerHash, ownerKind, + this.options.moduleHash); + }; + } + + /** Anonymous members already emitted, so a shared type node is not counted twice. */ + private readonly anonymousMembers = new Set(); + + /** + * A member of an anonymous TYPE LITERAL — `{ toCsv(): string; name: string }`. + * + * ## Why these need rows + * + * `rows: { toCsv(): string }[]` followed by `r.toCsv()` is a call with a real + * target, and the target is this member. A type literal has no `ts_type` row — + * 5,015 of them measured, none with a name, a declaration or a merge identity — + * so the member has no owner to hang off and no other pass reaches it. It was + * the single cause of the entire syntactic-recall shortfall: 1,052 of 20,313 + * declaration-bearing nodes on this repository, all of them type-literal + * members or their parameters. + * + * ## What these rows can and cannot carry + * + * `tsTypeLinkHash` is `""`, because the owner genuinely is not a `ts_type`. + * The annotation travels as TEXT in `fieldTypeName` / `returnTypeName`, which + * is the same mechanism `ts_call_site.receiverTypeName` uses and the same one + * the engine already joins on. + * + * What is NOT here is an FK from the type literal to its members. No column + * exists for it: `ts_field.tsTypeLinkHash` points at `ts_type`, and an + * anonymous shape has none. Raised with ts-oracle; emitting the rows without + * it is still strictly better than emitting nothing, because name, arity, + * optionality and position are exactly what a member lookup needs. + * + * `typeReferenceLinkHash` is left empty ON PURPOSE. The member's annotation is + * already in the tree as a `TYPE_ELEMENT` child of the type literal, at the + * same position; extracting it again under the member as owner would duplicate + * every annotation inside every anonymous shape. + */ + private emitAnonymousMember(member: ts.TypeElement, typeLiteralHash: string): void { + const id = nodeId(member, this.sf); + if (this.anonymousMembers.has(id)) { + return; + } + this.anonymousMembers.add(id); + const startPos = this.sf.getLineAndCharacterOfPosition(member.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(member.end); + const isOptional = member.questionToken !== undefined; + const annotation = (member as { type?: ts.TypeNode }).type; + const annotationText = annotation + ? EntityUtils.normalizeWhitespace(annotation.getText(this.sf)) + : ''; + const ownerText = EntityUtils.normalizeWhitespace( + member.parent.getText(this.sf) + ).slice(0, 120); + + if (ts.isPropertySignature(member) || ts.isIndexSignatureDeclaration(member)) { + const isIndex = ts.isIndexSignatureDeclaration(member); + const name = isIndex ? '' : memberName(member) ?? ''; + const row = new TsFieldRegistry({ + name, + fieldTypeName: annotationText, + fieldBaseType: baseTypeOf(annotationText), + potentialQualifiedName: '', + isAmbiguous: false, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + // The SHAPE owns it — §4.8.1. Not a `ts_type`: an anonymous shape has no + // declaration and §4.2 forbids inventing one. The FK points at the type + // literal's own `ts_type_reference` row, and the differing PK prefixes + // (`TS_TYPE_` vs `TS_TYPE_REFERENCE_`) make a rule that joins against + // `ts_type` find NO match rather than a wrong one. + tsTypeLinkHash: typeLiteralHash, + ownerTypeName: ownerText, + ownerQualifiedName: '', + fieldAccess: TsFieldAccess.PUBLIC_ACCESS, + fieldModifiers: fieldModifiersOf(member, isOptional), + // Owner-qualified, because this column IS the discriminator for where + // `tsTypeLinkHash` points. An interface member keeps + // PROPERTY_SIGNATURE / INDEX_SIGNATURE and a `ts_type` owner. + memberKind: isIndex + ? TsMemberKind.TYPE_LITERAL_INDEX_SIGNATURE + : TsMemberKind.TYPE_LITERAL_PROPERTY, + tsModuleLinkHash: this.options.moduleHash, + isOptional, + hasDefiniteAssignment: false, + isReadonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword), + isStatic: false, + indexKeyTypeName: isIndex + ? indexKeyTypeNameOf(member as ts.IndexSignatureDeclaration, this.sf) + : '', + isTypeOnly: true, + // Keyed off the TYPE LITERAL's own reference hash, which is the only + // identity an anonymous shape has. Keying off an empty owner would make + // every `name: string` in the program one member. + memberGroupKey: EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `${typeLiteralHash}||${name}||false` + ), + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + if (annotation) { this.pendingMemberTypeLinks.push({ row, node: annotation }); } + this.fields.push(row); + this.fieldHashByNode.set(id, row.getHash()); + this.fieldRowByNode.set(id, row); + this.recordFieldPosition(typeLiteralHash, row.getHash()); + return; + } + + if (!ts.isMethodSignature(member) && !ts.isCallSignatureDeclaration(member) + && !ts.isConstructSignatureDeclaration(member)) { + return; + } + const methodKind = ts.isMethodSignature(member) + ? TsMethodKind.TYPE_LITERAL_METHOD_SIGNATURE + : ts.isCallSignatureDeclaration(member) + ? TsMethodKind.TYPE_LITERAL_CALL_SIGNATURE + : TsMethodKind.TYPE_LITERAL_CONSTRUCT_SIGNATURE; + const name = ts.isMethodSignature(member) + ? memberName(member) ?? '' + : methodKind === TsMethodKind.TYPE_LITERAL_CALL_SIGNATURE + ? TS_ANONYMOUS_METHOD_NAMES.CALL_SIGNATURE + : TS_ANONYMOUS_METHOD_NAMES.CONSTRUCT_SIGNATURE; + const restIndex = member.parameters.findIndex((p) => p.dotDotDotToken !== undefined); + const row = new TsMethodRegistry({ + name, + signature: signatureOf(name, member.parameters, this.sf), + detailedSignature: detailedSignatureOf(name, member.parameters, member.type, this.sf), + qualifiedName: `${this.options.moduleQualifiedName}#${name}@${startPos.line + 1}:${startPos.character + 1}`, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + // The SHAPE owns it — §4.8.1, same reasoning as the field case above. + tsTypeLinkHash: typeLiteralHash, + ownerTypeName: ownerText, + ownerQualifiedName: '', + methodAccess: TsMethodAccess.PUBLIC_ACCESS, + methodModifiers: methodModifiersOf(member), + returnTypeName: annotationText, + isVarArgs: restIndex >= 0, + hasReceiverParameter: false, + methodKind, + parameterCount: member.parameters.length, + hasTypeParameters: (member.typeParameters?.length ?? 0) > 0, + throwsExceptions: new Set(), + enclosingMemberLinkHash: '', + tsModuleLinkHash: this.options.moduleHash, + declarationGroupKey: '', + mergeScopeKey: '', + escapedName: escapeName(name), + signatureRole: TsSignatureRole.SOLE, + overloadIndex: 0, + // Can NEVER carry a body under any compiler options, so it must never be + // read as the code that runs — while still being a legitimate target. + bodyPresence: TsBodyPresence.NO_BODY_INTERFACE, + isTypeOnly: true, + isAsync: false, + isGenerator: false, + isAbstract: false, + isStatic: false, + optionalParameterCount: member.parameters.filter((p) => p.questionToken !== undefined).length, + restParameterIndex: restIndex >= 0 ? restIndex : undefined, + typeParameterCount: member.typeParameters?.length ?? 0, + thisParameterTypeName: '', + isTypePredicateReturn: member.type !== undefined && ts.isTypePredicateNode(member.type), + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.methods.push(row); + // The return reference belongs to the enclosing type's tree, so it is + // linked after the walk rather than created here. + if (annotation) { this.pendingReturnLinks.push({ row, node: annotation }); } + this.methodHashByNode.set(id, row.getHash()); + this.methodRowByNode.set(id, row); + this.recordAnonymousOverloadCandidate(row, typeLiteralHash); + const memberContext: EmitContext = { + typeHash: '', + methodHash: row.getHash(), + blockHash: '', + ownerTypeName: ownerText, + ownerQualifiedName: this.options.moduleQualifiedName, + namePath: [], + scopeDepth: 0, + isAmbient: true, + moduleHash: this.options.moduleHash, + moduleQualifiedName: this.options.moduleQualifiedName, + }; + // The PARAMETERS of an anonymous signature were the other half of the gap: + // 48 on this repository, every one a parameter of a type-literal method. + this.emitParameters(member.parameters, row, memberContext); + // An anonymous signature can be GENERIC — `{ new(x: T): C }`, which is + // how `declare var CustomEvent` is written in lib.dom.d.ts. 25 such + // signatures in the holdout corpus and none in application code. + this.emitTypeParameters(member.typeParameters, row.getHash(), + ts.isConstructSignatureDeclaration(member) + ? TsTypeParameterOwnerKind.CONSTRUCT_SIGNATURE + : ts.isCallSignatureDeclaration(member) + ? TsTypeParameterOwnerKind.CALL_SIGNATURE + : TsTypeParameterOwnerKind.METHOD, + memberContext, TsTypeRefContext.METHOD_TYPE_PARAM_BOUND); + } + + /** Every function type already given a `ts_method` row, so none is minted twice. */ + private readonly functionTypeSignatures = new Set(); + /** Type-alias name -> its RHS node, for `const f: Callback = …; f()`. */ + readonly typeAliasTargetByName = new Map(); + + /** + * Mints the `ts_method` row for a `(a: T) => R` written in type position. + * + * `bodyPresence` is NO_BODY_INTERFACE and `isTypeOnly` is true: this + * declaration can never carry a body under any compiler options, so it must + * never be read as the code that runs. It is a legitimate call TARGET — 44.3% + * of real targets are bodiless — and the two facts are not in tension. + */ + private emitFunctionTypeSignature( + node: ts.FunctionTypeNode | ts.ConstructorTypeNode, + selfReferenceHash: string + ): void { + const id = nodeId(node, this.sf); + if (this.functionTypeSignatures.has(id)) { + return; + } + this.functionTypeSignatures.add(id); + const isConstructor = ts.isConstructorTypeNode(node); + const name = isConstructor + ? TS_ANONYMOUS_METHOD_NAMES.CONSTRUCTOR_TYPE + : TS_ANONYMOUS_METHOD_NAMES.FUNCTION_TYPE; + const startPos = this.sf.getLineAndCharacterOfPosition(node.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(node.end); + const restIndex = node.parameters.findIndex((p) => p.dotDotDotToken !== undefined); + const row = new TsMethodRegistry({ + name, + signature: signatureOf(name, node.parameters, this.sf), + detailedSignature: detailedSignatureOf(name, node.parameters, node.type, this.sf), + qualifiedName: `${this.options.moduleQualifiedName}#${name}@${startPos.line + 1}:${startPos.character + 1}`, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + // The type NODE owns the signature — §4.8.1. A function type has no + // declaration to belong to, and it is the only thing that can own one, so + // the owner FK is its own `ts_type_reference` row. `""` here left + // `ts_method`'s key chaining broken, which is the §1 discipline this + // repairs rather than a cosmetic fill. + tsTypeLinkHash: selfReferenceHash, + ownerTypeName: '', + ownerQualifiedName: this.options.moduleQualifiedName, + methodAccess: TsMethodAccess.PUBLIC_ACCESS, + methodModifiers: new Set(), + returnTypeName: node.type + ? EntityUtils.normalizeWhitespace(node.type.getText(this.sf)) + : '', + isVarArgs: restIndex >= 0, + hasReceiverParameter: false, + methodKind: isConstructor + ? TsMethodKind.CONSTRUCTOR_TYPE_SIGNATURE + : TsMethodKind.FUNCTION_TYPE_SIGNATURE, + parameterCount: node.parameters.length, + hasTypeParameters: (node.typeParameters?.length ?? 0) > 0, + throwsExceptions: new Set(), + enclosingMemberLinkHash: '', + tsModuleLinkHash: this.options.moduleHash, + declarationGroupKey: '', + mergeScopeKey: '', + escapedName: name, + signatureRole: TsSignatureRole.SOLE, + overloadIndex: 0, + bodyPresence: TsBodyPresence.NO_BODY_INTERFACE, + isTypeOnly: true, + isAsync: false, + isGenerator: false, + isAbstract: false, + isStatic: false, + optionalParameterCount: node.parameters.filter((p) => p.questionToken !== undefined).length, + restParameterIndex: restIndex >= 0 ? restIndex : undefined, + typeParameterCount: node.typeParameters?.length ?? 0, + thisParameterTypeName: '', + isTypePredicateReturn: node.type !== undefined && ts.isTypePredicateNode(node.type), + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.methods.push(row); + // The return reference belongs to the enclosing type's tree, so it is + // linked after the walk rather than created here. + if (node.type) { this.pendingReturnLinks.push({ row, node: node.type }); } + this.methodHashByNode.set(id, row.getHash()); + this.methodRowByNode.set(id, row); + const signatureContext: EmitContext = { + typeHash: '', + methodHash: row.getHash(), + blockHash: '', + ownerTypeName: '', + ownerQualifiedName: this.options.moduleQualifiedName, + namePath: [], + scopeDepth: 0, + isAmbient: true, + moduleHash: this.options.moduleHash, + moduleQualifiedName: this.options.moduleQualifiedName, + }; + // The PARAMETERS of a function type. They were the last of the syntactic + // recall gap: a signature row with no parameter rows cannot be arity-matched, + // so a call through `(node: N, ctx: C) => boolean` had a target and no shape. + this.emitParameters(node.parameters, row, signatureContext, false); + // A function type can be generic: `(x: T) => T`. Its parameters belong to + // this signature row, with METHOD_TYPE_PARAM_BOUND bounds like any other + // function-shaped declaration's. + this.emitTypeParameters(node.typeParameters, row.getHash(), + isConstructor + ? TsTypeParameterOwnerKind.CONSTRUCT_SIGNATURE + : TsTypeParameterOwnerKind.FUNCTION, + signatureContext, + TsTypeRefContext.METHOD_TYPE_PARAM_BOUND); + } + + run(): void { + const rootContext: EmitContext = { + typeHash: '', + methodHash: '', + blockHash: '', + ownerTypeName: '', + ownerQualifiedName: this.options.moduleQualifiedName, + namePath: [], + scopeDepth: 0, + isAmbient: this.options.isDeclarationFile, + moduleHash: this.options.moduleHash, + moduleQualifiedName: this.options.moduleQualifiedName, + }; + // The `` initializer is minted first and unconditionally. Top-level + // executable statements need an owner, and inventing one lazily would make + // a file with no top-level code structurally different from one with it. + this.moduleInitMethodHash = this.emitModuleInitializer(rootContext); + const withInit: EmitContext = { ...rootContext, methodHash: this.moduleInitMethodHash }; + for (const statement of this.sf.statements) { + this.visitStatement(statement, withInit); + } + this.assignOverloadIdentities(); + } + + // ------------------------------------------------------------------------- + // statements + // ------------------------------------------------------------------------- + + private visitStatement(node: ts.Statement, context: EmitContext): void { + switch (node.kind) { + case ts.SyntaxKind.ClassDeclaration: { + this.emitClassLike(node as ts.ClassDeclaration, context, TsTypeCategory.CLASS_TYPE); + return; + } + case ts.SyntaxKind.InterfaceDeclaration: { + this.emitInterface(node as ts.InterfaceDeclaration, context); + return; + } + case ts.SyntaxKind.TypeAliasDeclaration: { + this.emitTypeAlias(node as ts.TypeAliasDeclaration, context); + return; + } + case ts.SyntaxKind.EnumDeclaration: { + this.emitEnum(node as ts.EnumDeclaration, context); + return; + } + case ts.SyntaxKind.ModuleDeclaration: { + this.emitModuleDeclaration(node as ts.ModuleDeclaration, context); + return; + } + case ts.SyntaxKind.FunctionDeclaration: { + this.emitFunctionLike(node as ts.FunctionDeclaration, context, + TsMethodKind.FUNCTION_DECLARATION); + return; + } + case ts.SyntaxKind.VariableStatement: { + this.emitVariableStatement(node as ts.VariableStatement, context); + return; + } + case ts.SyntaxKind.Block: { + const block = this.emitBlock(node as ts.Block, TsBlockKind.BARE_BLOCK, context, ''); + const inner = { ...context, blockHash: block, scopeDepth: context.scopeDepth + 1 }; + for (const statement of (node as ts.Block).statements) { + this.visitStatement(statement, inner); + } + return; + } + case ts.SyntaxKind.IfStatement: { + this.emitIfStatement(node as ts.IfStatement, context); + return; + } + case ts.SyntaxKind.ForStatement: + case ts.SyntaxKind.ForInStatement: + case ts.SyntaxKind.ForOfStatement: + case ts.SyntaxKind.WhileStatement: + case ts.SyntaxKind.DoStatement: { + this.emitLoop(node as ts.IterationStatement, context); + return; + } + case ts.SyntaxKind.TryStatement: { + this.emitTryStatement(node as ts.TryStatement, context); + return; + } + case ts.SyntaxKind.SwitchStatement: { + this.emitSwitch(node as ts.SwitchStatement, context); + return; + } + case ts.SyntaxKind.LabeledStatement: { + // `outer: for (…) { … break outer; }` -- the label is the only thing + // that makes a non-local break or continue readable, so the block gets + // its own row rather than being flattened into the loop it labels. + this.emitBlock(node, TsBlockKind.LABELED, context, ''); + this.visitStatement((node as ts.LabeledStatement).statement, context); + return; + } + default: { + // Expression statements, returns, throws and the rest carry no + // declarations of their own; the expression extractor owns them. + this.visitNestedFunctionsAndClasses(node, context); + return; + } + } + } + + /** + * Emits `node` itself if it is a declaration, otherwise descends into it. + * + * The distinction matters for a CURRIED arrow: `(a) => (b) => c` has an arrow + * whose entire body is another arrow, and + * {@link visitNestedFunctionsAndClasses} descends through `forEachChild`, which + * visits a node's CHILDREN and therefore steps straight past the node itself. + * The inner arrow got an expression row and no `ts_method` — a callable with + * expression identity and no declaration, which is exactly the shape the + * decorator-argument gap had. + * + * Every caller that passes a node which might ITSELF be a declaration goes + * through here rather than through the descent. + */ + private emitDeclarationOrDescend(node: ts.Node, context: EmitContext): void { + if (ts.isArrowFunction(node)) { + this.emitFunctionLike(node, context, TsMethodKind.ARROW_FUNCTION); + return; + } + if (ts.isFunctionExpression(node)) { + this.emitFunctionLike(node, context, TsMethodKind.FUNCTION_EXPRESSION); + return; + } + if (ts.isClassExpression(node)) { + this.emitClassLike(node, context, TsTypeCategory.CLASS_EXPRESSION_TYPE); + return; + } + this.visitNestedFunctionsAndClasses(node, context); + } + + /** + * Descends into a statement looking only for function- and class-shaped + * declarations. + * + * Arrows and function expressions are `ts_method` rows, not expression detail + * — 703 arrows measured, 161 of them resolved call targets — so they must be + * reached even when they are buried inside an expression the declaration + * extractor otherwise ignores. + */ + private visitNestedFunctionsAndClasses(node: ts.Node, context: EmitContext): void { + ts.forEachChild(node, (child) => { + // A DECORATOR is never descended from here, because + // `visitDecoratorDeclarations` has already descended it — and + // `forEachChild` on a decorated node yields its decorators alongside its + // initialiser, so descending both emits everything inside a decorator + // TWICE. + // + // `@Column(() => PostCounter) counters: PostCounter = ...` is the shape: + // the arrow reached `emitFunctionLike` once through + // `emitClassMember -> visitDecoratorDeclarations -> emitDeclarationOrDescend` + // and again through `emitClassMember -> emitField -> ` this descent. Two + // `ts_method` rows at one position, differing only in `overloadIndex`, + // and `TS_METHOD_md5(tsModuleLinkHash ‖ tsTypeLinkHash ‖ qualifiedName ‖ + // signature ‖ startLine ‖ startColumn)` does not include that column — so + // they collided on one primary key. Measured: 7 keys on typeorm, 6 on + // nest, plus the duplicated arrows' own `ts_method_parameter` rows. + // + // Skipping is safe because every decorator-bearing position has an + // explicit `visitDecoratorDeclarations` call already: the class itself, + // each class member, and each parameter. Nothing reaches a decorator only + // through this generic descent. + if (ts.isDecorator(child)) { + return; + } + if (ts.isFunctionExpression(child)) { + this.emitFunctionLike(child, context, TsMethodKind.FUNCTION_EXPRESSION); + return; + } + if (ts.isArrowFunction(child)) { + this.emitFunctionLike(child, context, TsMethodKind.ARROW_FUNCTION); + return; + } + if (ts.isClassExpression(child)) { + this.emitClassLike(child, context, TsTypeCategory.CLASS_EXPRESSION_TYPE); + return; + } + // An object-literal method is a function-shaped declaration with a body, + // and it is callable — `jobUtils.format(job)`. It is reached only through + // this generic descent, because it is not a class member and not an + // initialiser. Missing it loses every call INSIDE those bodies as well as + // the method row itself. + if (child.parent && ts.isObjectLiteralExpression(child.parent)) { + if (ts.isMethodDeclaration(child)) { + this.emitFunctionLike(child, context, TsMethodKind.OBJECT_LITERAL_METHOD); + return; + } + if (ts.isGetAccessor(child)) { + this.emitFunctionLike(child, context, TsMethodKind.GETTER); + return; + } + if (ts.isSetAccessor(child)) { + this.emitFunctionLike(child, context, TsMethodKind.SETTER); + return; + } + } + this.visitNestedFunctionsAndClasses(child, context); + }); + } + + // ------------------------------------------------------------------------- + // types + // ------------------------------------------------------------------------- + + private emitClassLike( + node: ts.ClassLikeDeclaration, + context: EmitContext, + category: TsTypeCategory + ): string { + const row = this.emitTypeRow(node, context, category, new Set([ + TsDeclarationSpace.TYPE, + TsDeclarationSpace.VALUE, + ])); + if (!row) { + return ''; + } + const inner = this.contextForType(row, node, context); + this.pushTypeParameters(node.typeParameters); + this.emitTypeParameters(node.typeParameters, row.getHash(), + TsTypeParameterOwnerKind.CLASS, inner, TsTypeRefContext.TYPE_PARAM_BOUND); + this.emitHeritage(node, row, context); + this.visitDecoratorDeclarations(node, context); + + let memberCount = 0; + let requiredMemberCount = 0; + let hasIndexSignature = false; + const shapeParts: string[] = []; + for (const member of node.members) { + const summary = this.emitClassMember(member, inner, row); + if (summary) { + memberCount += 1; + if (!summary.isOptional) { + requiredMemberCount += 1; + } + if (summary.isIndexSignature) { + hasIndexSignature = true; + } + shapeParts.push(summary.shapePart); + } + } + row.setShape(memberCount, requiredMemberCount, shapeDigestOf(shapeParts)); + if (hasIndexSignature) { + this.indexSignatureOwners.add(row.getHash()); + } + this.popTypeParameters(); + return row.getHash(); + } + + private emitInterface(node: ts.InterfaceDeclaration, context: EmitContext): void { + const row = this.emitTypeRow(node, context, TsTypeCategory.INTERFACE_TYPE, + new Set([TsDeclarationSpace.TYPE])); + if (!row) { + return; + } + const inner = this.contextForType(row, node, context); + this.pushTypeParameters(node.typeParameters); + this.emitTypeParameters(node.typeParameters, row.getHash(), + TsTypeParameterOwnerKind.INTERFACE, inner, TsTypeRefContext.TYPE_PARAM_BOUND); + this.emitHeritage(node, row, context); + + let memberCount = 0; + let requiredMemberCount = 0; + let hasIndexSignature = false; + const shapeParts: string[] = []; + for (const member of node.members) { + const summary = this.emitTypeMember(member, inner, row); + if (summary) { + memberCount += 1; + if (!summary.isOptional) { + requiredMemberCount += 1; + } + if (summary.isIndexSignature) { + hasIndexSignature = true; + } + shapeParts.push(summary.shapePart); + } + } + row.setShape(memberCount, requiredMemberCount, shapeDigestOf(shapeParts)); + if (hasIndexSignature) { + this.indexSignatureOwners.add(row.getHash()); + } + this.popTypeParameters(); + } + + private emitTypeAlias(node: ts.TypeAliasDeclaration, context: EmitContext): void { + const row = this.emitTypeRow(node, context, TsTypeCategory.TYPE_ALIAS_TYPE, + new Set([TsDeclarationSpace.TYPE])); + if (!row) { + return; + } + // `type Callback = (v: string) => number` makes the alias NAME a call + // target: tsc resolves a call on a `Callback`-annotated variable to this + // RHS signature. Recorded by name so the resolver can make that hop + // without re-walking the tree. + this.typeAliasTargetByName.set(row.name, node.type); + this.pushTypeParameters(node.typeParameters); + this.emitTypeParameters(node.typeParameters, row.getHash(), + TsTypeParameterOwnerKind.TYPE_ALIAS, this.contextForType(row, node, context), + TsTypeRefContext.TYPE_PARAM_BOUND); + // The RHS hangs off `aliasTargetReferenceLinkHash` into the type-reference + // tree. A type alias gets a `ts_type` row because it is a named declaration + // that merges and can be extended — 2,491 measured — but it gets no path + // into `ts_call_site`, and this FK is the only edge it has. + const target = this.typeReferenceExtractor.extract(node.type, TsTypeRefContext.TYPE_ALIAS_RHS, { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.TYPE, + tsTypeLinkHash: row.getHash(), + tsModuleLinkHash: context.moduleHash, + }); + row.setAliasTargetReferenceLinkHash(target); + row.setShape(0, 0, shapeDigestOf([])); + this.popTypeParameters(); + } + + private emitEnum(node: ts.EnumDeclaration, context: EmitContext): void { + const isConst = hasModifier(node, ts.SyntaxKind.ConstKeyword); + const row = this.emitTypeRow( + node, + context, + isConst ? TsTypeCategory.CONST_ENUM_TYPE : TsTypeCategory.ENUM_TYPE, + new Set([TsDeclarationSpace.NAMESPACE, TsDeclarationSpace.TYPE, TsDeclarationSpace.VALUE]) + ); + if (!row) { + return; + } + row.setShape(node.members.length, node.members.length, + shapeDigestOf(node.members.map((m) => `${memberName(m) ?? ''}:ENUM_MEMBER:0:false`))); + + // An implicit member's value continues from the previous one, so the running + // ordinal is not enough — `Closed = 3` followed by `Archived` makes Archived + // 4, not 2. Tracking the last known numeric value is what keeps + // `constantValue` right, and a COMPUTED member breaks the chain because + // nothing after it is knowable either. + let ordinal = 0; + let nextImplicit: number | undefined = 0; + for (const member of node.members) { + const name = memberName(member) ?? ''; + const startPos = this.sf.getLineAndCharacterOfPosition(member.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(member.end); + const value = enumMemberValueOf(member, nextImplicit, this.sf); + const memberRow = new TsEnumMemberRegistry({ + name, + qualifiedName: `${row.qualifiedName}.${name}`, + ordinal, + initializerText: member.initializer + ? EntityUtils.normalizeWhitespace(member.initializer.getText(this.sf)) + : '', + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + tsTypeLinkHash: row.getHash(), + ownerTypeName: row.name, + ownerQualifiedName: row.qualifiedName, + valueKind: value.kind, + constantValue: value.value, + // A `const enum` member is INLINED at use sites, so a reference to it may + // have no runtime member to link to at all. + isConstEnumMember: isConst, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.enumMembers.push(memberRow); + this.enumMemberHashByNode.set(nodeId(member, this.sf), memberRow.getHash()); + if (member.initializer) { + this.pendingExpressionLinks.push({ + node: member.initializer, + link: (hash) => memberRow.setTsExpressionLinkHash(hash), + }); + } + nextImplicit = value.kind === TsEnumMemberValueKind.COMPUTED + || value.kind === TsEnumMemberValueKind.EXPLICIT_STRING + ? undefined + : Number(value.value) + 1; + ordinal += 1; + } + } + + private emitModuleDeclaration(node: ts.ModuleDeclaration, context: EmitContext): void { + const body = node.body; + if (ts.isStringLiteral(node.name) || (node.flags & ts.NodeFlags.GlobalAugmentation) !== 0) { + // An ambient module or `declare global`. + // + // It gets BOTH rows, and they are not duplicates. The `ts_module` row says + // "this is an importable namespace and a merge TABLE"; this `ts_type` row + // says "this is a declaration SITE of a symbol that merges", which is what + // carries the `declarationGroupKey`. Two files declaring + // `declare module "*.svg"` are one symbol, and two files augmenting the + // same module are too — and only a row with a group key can express that. + // Without it the partition is missing every ambient module declaration, + // which on the fixture corpus is six sites tsc counts and the fact base + // would not. + this.emitTypeRow(node, context, TsTypeCategory.NAMESPACE_TYPE, undefined) + ?.setShape(0, 0, shapeDigestOf([])); + if (body && ts.isModuleBlock(body)) { + const inner: EmitContext = { + ...context, + moduleHash: this.options.moduleHashForNode(node), + isAmbient: true, + namePath: [], + }; + // MODULE_BODY, not NAMESPACE_BODY. `declare module "pkg" { }` and + // `declare global { }` are importable/global scopes keyed by specifier; + // `namespace N { }` is an ordinary named scope. They are separate kinds + // because a consumer walking blocks must not treat an ambient module's + // contents as if they were nested under a namespace name. + this.emitBlock(body, TsBlockKind.MODULE_BODY, inner, ''); + for (const statement of body.statements) { + this.visitStatement(statement, inner); + } + } + return; + } + + const row = this.emitTypeRow(node, context, TsTypeCategory.NAMESPACE_TYPE, undefined); + if (!row) { + return; + } + row.setShape(0, 0, shapeDigestOf([])); + if (!body) { + return; + } + const inner: EmitContext = { + ...context, + typeHash: row.getHash(), + ownerTypeName: row.name, + ownerQualifiedName: row.qualifiedName, + namePath: [...context.namePath, row.name], + isAmbient: context.isAmbient || hasModifier(node, ts.SyntaxKind.DeclareKeyword), + }; + if (ts.isModuleDeclaration(body)) { + // `namespace A.B.C {}` nests one namespace per dotted segment. + this.emitModuleDeclaration(body, inner); + return; + } + if (!ts.isModuleBlock(body)) { + return; + } + // A namespace body and an ambient module body are both scopes that hold + // statements, so both get a block row. They are distinguished because + // `declare module "x" { }` is a MODULE declaration keyed by specifier while + // `namespace N { }` is an ordinary named scope, and a consumer walking + // blocks must not confuse the two. + this.emitBlock( + body, + ts.isStringLiteral(node.name) ? TsBlockKind.MODULE_BODY : TsBlockKind.NAMESPACE_BODY, + inner, + '' + ); + for (const statement of body.statements) { + this.visitStatement(statement, inner); + } + } + + /** Owners that declare an index signature, so `hasIndexSignature` can be set after members. */ + private readonly indexSignatureOwners = new Set(); + + private emitTypeRow( + node: ts.NamedDeclaration, + context: EmitContext, + category: TsTypeCategory, + spacesOverride: ReadonlySet | undefined + ): TsTypeRegistry | undefined { + const binding = this.options.binder.bindingByNode.get(nodeId(node, this.sf)); + const start = node.getStart(this.sf); + const startPos = this.sf.getLineAndCharacterOfPosition(start); + const endPos = this.sf.getLineAndCharacterOfPosition(node.end); + const name = recordedNameOf(node, binding?.name + ?? (node.name && ts.isIdentifier(node.name) ? node.name.text : '')); + // A class EXPRESSION has no binding — it declares nothing in any table — so + // its merge key is its own byte range. It cannot merge with anything, which + // is correct: two `class {}` expressions are two types even with one name. + const mergeScopeKey = binding?.mergeScopeKey + ?? `LOCALS:${EntityUtils.generateEntityHash(ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `EXPR||${context.moduleHash}||${start}||${node.end}`)}`; + const escapedName = binding?.escapedName ?? name; + const groupKey = binding?.declarationGroupKey + ?? EntityUtils.generateEntityHash(ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `${mergeScopeKey}||${escapedName}`); + const spaces = spacesOverride ?? binding?.declarationSpaces ?? new Set(); + const isAmbient = context.isAmbient || hasModifier(node, ts.SyntaxKind.DeclareKeyword); + const dotted = [...context.namePath, name].filter((p) => p !== '').join('.'); + + const row = new TsTypeRegistry({ + name, + qualifiedName: `${context.moduleQualifiedName}#${dotted}`, + fileName: this.options.fileName, + typeCategory: category, + typeAccess: typeAccessOf(node, binding), + typeModifiers: typeModifiersOf(node), + typePlacement: placementOf(node, context), + filePath: this.options.filePath, + baseMservPath: this.options.baseMservPath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + tsModuleLinkHash: context.moduleHash, + enclosingTypeLinkHash: context.typeHash, + enclosingMethodLinkHash: context.typeHash === '' ? context.methodHash : '', + declarationGroupKey: groupKey, + mergeScopeKey, + escapedName, + declarationSpaces: spaces, + isAmbientDeclaration: isAmbient, + // The hard column of §3.3: an interface and a type alias have no runtime + // entity, so no call-graph rule may traverse these rows. + isTypeOnly: category === TsTypeCategory.INTERFACE_TYPE + || category === TsTypeCategory.TYPE_ALIAS_TYPE, + typeParameterCount: (node as { typeParameters?: ts.NodeArray }) + .typeParameters?.length ?? 0, + heritageCount: heritageCountOf(node), + isExported: binding?.isExported ?? false, + hasIndexSignature: false, + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.types.push(row); + this.typeHashByNode.set(nodeId(node, this.sf), row.getHash()); + this.typeRowByNode.set(nodeId(node, this.sf), row); + if (ts.isClassExpression(node)) { + // The reverse direction of every other link here: the EXPRESSION row needs + // the DECLARATION's hash, so `class { }` in a value position is joinable to + // the type it creates. + this.anonymousDeclarationByNode.set(nodeId(node, this.sf), row.getHash()); + } + return row; + } + + private contextForType( + row: TsTypeRegistry, + node: ts.Node, + context: EmitContext + ): EmitContext { + return { + ...context, + typeHash: row.getHash(), + methodHash: '', + ownerTypeName: row.name, + ownerQualifiedName: row.qualifiedName, + ownerGroupKey: row.declarationGroupKey, + namePath: [...context.namePath, row.name].filter((p) => p !== ''), + isAmbient: context.isAmbient || hasModifier(node, ts.SyntaxKind.DeclareKeyword), + }; + } + + // ------------------------------------------------------------------------- + // heritage + // ------------------------------------------------------------------------- + + private emitHeritage( + node: ts.ClassLikeDeclaration | ts.InterfaceDeclaration, + owner: TsTypeRegistry, + context: EmitContext + ): void { + for (const clause of node.heritageClauses ?? []) { + const isExtends = clause.token === ts.SyntaxKind.ExtendsKeyword; + const clauseToken = isExtends ? TsClauseToken.EXTENDS : TsClauseToken.IMPLEMENTS; + let position = 0; + for (const type of clause.types) { + const isNameShaped = ts.isIdentifier(type.expression) + || ts.isPropertyAccessExpression(type.expression); + const kind = !isExtends + ? TsHeritageKind.IMPLEMENTS_CLAUSE + : isNameShaped + ? (ts.isInterfaceDeclaration(node) + ? TsHeritageKind.EXTENDS_INTERFACE + : TsHeritageKind.EXTENDS_CLASS) + // `class C extends mixin(Base) {}` — a computed base. The parser + // cannot name it, and says so rather than guessing at the callee. + : TsHeritageKind.EXTENDS_EXPRESSION; + const startPos = this.sf.getLineAndCharacterOfPosition(type.getStart(this.sf)); + const heritage = new TsTypeHeritageRegistry({ + heritageKind: kind, + clauseToken, + position, + heritageText: EntityUtils.normalizeWhitespace(type.getText(this.sf)), + heritageSimpleName: simpleNameOf(type), + heritageQualifiedPath: qualifiedPathOf(type, this.sf), + typeArgumentCount: type.typeArguments?.length ?? 0, + // The column Java does not need. `extends` really does inherit + // members; `implements` asserts and inherits NOTHING, and 60.4% of + // classes satisfy their interfaces with no clause at all. + inheritsMembers: isExtends, + tsTypeLinkHash: owner.getHash(), + tsModuleLinkHash: context.moduleHash, + isDynamic: kind === TsHeritageKind.EXTENDS_EXPRESSION, + startLine: startPos.line + 1, + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + // Every entry also mints a type-reference twin, so heritage names + // resolve through the same name-to-type machinery as everything else + // and this relation adds only ordering and `inheritsMembers`. + heritage.setTsTypeReferenceLinkHash( + this.typeReferenceExtractor.extract( + type, + isExtends ? TsTypeRefContext.SUPER_TYPE : TsTypeRefContext.IMPLEMENTS_INTERFACE, + { + ownerHash: heritage.getHash(), + ownerKind: TsReferenceOwnerKind.HERITAGE, + tsTypeLinkHash: owner.getHash(), + tsModuleLinkHash: context.moduleHash, + } + ) + ); + if (isExtends && !ts.isInterfaceDeclaration(node)) { + // A class `extends` clause is EVALUATED, including the mixin form, so + // the base has an expression row and the heritage row can point at it. + this.pendingExpressionLinks.push({ + node: type.expression, + link: (hash) => heritage.setTsExpressionLinkHash(hash), + }); + } + this.heritages.push(heritage); + position += 1; + } + } + } + + // ------------------------------------------------------------------------- + // members + // ------------------------------------------------------------------------- + + /** + * Declarations written inside a DECORATOR EXPRESSION. + * + * `@record((v) => v.trim(), function named() {}, class Inline {})` declares an + * arrow, a function and a class — three callable or constructable entities — + * and no other path in this walk reaches them. The expression pass emitted + * rows for all three while the declaration pass emitted none, so they had + * expression identity and no declaration: an arrow with no `ts_method`, a + * class with no `ts_type`, and therefore no members, no parameters and no + * `anonymousTypeHash` to link back to. + * + * A decorator is an expression that RUNS, so anything declared inside one is + * as real as anything declared anywhere else. + */ + private visitDecoratorDeclarations(node: ts.Node, context: EmitContext): void { + if (!ts.canHaveDecorators(node)) { + return; + } + for (const decorator of ts.getDecorators(node) ?? []) { + this.emitDeclarationOrDescend(decorator, context); + } + } + + private emitClassMember( + member: ts.ClassElement, + context: EmitContext, + owner: TsTypeRegistry + ): MemberSummary | undefined { + this.visitDecoratorDeclarations(member, context); + if (ts.isPropertyDeclaration(member)) { + const isAccessor = hasModifier(member, ts.SyntaxKind.AccessorKeyword); + return this.emitField(member, context, owner, + isAccessor ? TsMemberKind.AUTO_ACCESSOR : TsMemberKind.PROPERTY_DECLARATION); + } + if (ts.isIndexSignatureDeclaration(member)) { + return this.emitField(member, context, owner, TsMemberKind.INDEX_SIGNATURE); + } + if (ts.isMethodDeclaration(member)) { + const hash = this.emitFunctionLike(member, context, TsMethodKind.METHOD_DECLARATION); + return methodSummary(member, hash); + } + if (ts.isConstructorDeclaration(member)) { + this.emitFunctionLike(member, context, TsMethodKind.CONSTRUCTOR); + return undefined; + } + if (ts.isGetAccessor(member)) { + const hash = this.emitFunctionLike(member, context, TsMethodKind.GETTER); + return methodSummary(member, hash); + } + if (ts.isSetAccessor(member)) { + const hash = this.emitFunctionLike(member, context, TsMethodKind.SETTER); + return methodSummary(member, hash); + } + if (ts.isClassStaticBlockDeclaration(member)) { + this.emitFunctionLike(member, context, TsMethodKind.CLASS_STATIC_BLOCK); + return undefined; + } + return undefined; + } + + private emitTypeMember( + member: ts.TypeElement, + context: EmitContext, + owner: TsTypeRegistry + ): MemberSummary | undefined { + if (ts.isPropertySignature(member)) { + return this.emitField(member, context, owner, TsMemberKind.PROPERTY_SIGNATURE); + } + if (ts.isIndexSignatureDeclaration(member)) { + return this.emitField(member, context, owner, TsMemberKind.INDEX_SIGNATURE); + } + if (ts.isMethodSignature(member)) { + const hash = this.emitFunctionLike(member, context, TsMethodKind.METHOD_SIGNATURE); + return methodSummary(member, hash); + } + if (ts.isCallSignatureDeclaration(member)) { + this.emitFunctionLike(member, context, TsMethodKind.CALL_SIGNATURE); + return undefined; + } + if (ts.isConstructSignatureDeclaration(member)) { + this.emitFunctionLike(member, context, TsMethodKind.CONSTRUCT_SIGNATURE); + return undefined; + } + // `get x(): T` / `set x(v: T)` INSIDE AN INTERFACE — legal since TypeScript + // 5.1, and a `GetAccessorDeclaration` is both a ClassElement and a + // TypeElement, so it turns up here as well as in a class body. + // + // 65 of them in `lib.dom.d.ts` alone, and not one in 1,084 files of + // application code — which is exactly why a holdout corpus of declaration + // files finds what application code cannot. + if (ts.isGetAccessor(member)) { + const hash = this.emitFunctionLike(member, context, TsMethodKind.GETTER); + return methodSummary(member, hash); + } + if (ts.isSetAccessor(member)) { + const hash = this.emitFunctionLike(member, context, TsMethodKind.SETTER); + return methodSummary(member, hash); + } + return undefined; + } + + private emitField( + node: ts.PropertyDeclaration | ts.PropertySignature | ts.IndexSignatureDeclaration, + context: EmitContext, + owner: TsTypeRegistry, + memberKind: TsMemberKind + ): MemberSummary { + const isIndexSignature = memberKind === TsMemberKind.INDEX_SIGNATURE; + const name = isIndexSignature ? '' : memberName(node) ?? ''; + const annotation = (node as { type?: ts.TypeNode }).type; + const isOptional = (node as { questionToken?: ts.QuestionToken }).questionToken !== undefined; + const isStatic = hasModifier(node, ts.SyntaxKind.StaticKeyword); + const start = node.getStart(this.sf); + const startPos = this.sf.getLineAndCharacterOfPosition(start); + const endPos = this.sf.getLineAndCharacterOfPosition(node.end); + const typeName = annotation + ? EntityUtils.normalizeWhitespace(annotation.getText(this.sf)) + : ''; + + const row = new TsFieldRegistry({ + name, + fieldTypeName: typeName, + fieldBaseType: baseTypeOf(typeName), + potentialQualifiedName: '', + isAmbiguous: false, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + tsTypeLinkHash: owner.getHash(), + ownerTypeName: owner.name, + ownerQualifiedName: owner.qualifiedName, + fieldAccess: fieldAccessOf(node), + fieldModifiers: fieldModifiersOf(node, isOptional), + memberKind, + tsModuleLinkHash: context.moduleHash, + // Load-bearing for structural satisfaction: an ABSENT optional member + // does not break assignability, so a satisfaction rule that ignores this + // column rejects classes that legitimately satisfy an interface. + isOptional, + hasDefiniteAssignment: + (node as { exclamationToken?: ts.ExclamationToken }).exclamationToken !== undefined, + isReadonly: hasModifier(node, ts.SyntaxKind.ReadonlyKeyword), + isStatic, + indexKeyTypeName: isIndexSignature + ? indexKeyTypeNameOf(node as ts.IndexSignatureDeclaration, this.sf) + : '', + isTypeOnly: memberKind === TsMemberKind.PROPERTY_SIGNATURE, + // The member's identity ACROSS a merged owner, so a property declared in + // a module augmentation joins the same member as one declared in the + // original interface. + memberGroupKey: EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `${owner.declarationGroupKey}||${name}||${isStatic}` + ), + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.fields.push(row); + this.fieldHashByNode.set(nodeId(node, this.sf), row.getHash()); + this.fieldRowByNode.set(nodeId(node, this.sf), row); + this.recordFieldPosition(owner.getHash(), row.getHash()); + + if (annotation) { + row.setTypeReferenceLinkHash( + this.typeReferenceExtractor.extract(annotation, TsTypeRefContext.FIELD_TYPE, { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.FIELD, + tsTypeLinkHash: owner.getHash(), + tsModuleLinkHash: context.moduleHash, + }) + ); + } + // A property initialiser can carry an arrow, and an arrow can be a call + // target, so the walk continues rather than stopping at the field row. + const initializer = (node as { initializer?: ts.Expression }).initializer; + if (initializer) { + this.pendingExpressionLinks.push({ + node: initializer, + link: (hash) => row.setInitializerExpressionLinkHash(hash), + }); + this.visitNestedFunctionsAndClasses(node, context); + } + return { + isOptional, + isIndexSignature, + shapePart: `${name}:${memberKind}:0:${isOptional}`, + }; + } + + // ------------------------------------------------------------------------- + // functions + // ------------------------------------------------------------------------- + + private emitModuleInitializer(context: EmitContext): string { + const endPos = this.sf.getLineAndCharacterOfPosition(this.sf.end); + const row = new TsMethodRegistry({ + name: TS_MODULE_INITIALIZER_NAME, + signature: `${TS_MODULE_INITIALIZER_NAME}()`, + detailedSignature: `${TS_MODULE_INITIALIZER_NAME}(): void`, + qualifiedName: `${context.moduleQualifiedName}#${TS_MODULE_INITIALIZER_NAME}`, + filePath: this.options.filePath, + startLine: 1, + endLine: endPos.line + 1, + tsTypeLinkHash: '', + ownerTypeName: '', + ownerQualifiedName: context.moduleQualifiedName, + methodAccess: TsMethodAccess.MODULE_LOCAL_ACCESS, + methodModifiers: new Set(), + returnTypeName: '', + isVarArgs: false, + hasReceiverParameter: false, + methodKind: TsMethodKind.MODULE_INITIALIZER, + parameterCount: 0, + hasTypeParameters: false, + throwsExceptions: new Set(), + enclosingMemberLinkHash: '', + tsModuleLinkHash: context.moduleHash, + declarationGroupKey: '', + mergeScopeKey: '', + escapedName: TS_MODULE_INITIALIZER_NAME, + signatureRole: TsSignatureRole.SOLE, + overloadIndex: 0, + bodyPresence: TsBodyPresence.HAS_BODY, + isTypeOnly: false, + isAsync: false, + isGenerator: false, + isAbstract: false, + isStatic: false, + optionalParameterCount: 0, + restParameterIndex: undefined, + typeParameterCount: 0, + thisParameterTypeName: '', + isTypePredicateReturn: false, + startColumn: 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.methods.push(row); + return row.getHash(); + } + + private emitFunctionLike( + node: ts.SignatureDeclaration | ts.ClassStaticBlockDeclaration, + context: EmitContext, + methodKind: TsMethodKind + ): string { + const binding = this.options.binder.bindingByNode.get(nodeId(node, this.sf)); + const start = node.getStart(this.sf); + const startPos = this.sf.getLineAndCharacterOfPosition(start); + const endPos = this.sf.getLineAndCharacterOfPosition(node.end); + const name = methodNameOf(node, methodKind, binding); + const parameters = ts.isClassStaticBlockDeclaration(node) + ? ([] as readonly ts.ParameterDeclaration[]) + : node.parameters; + const typeParameters = ts.isClassStaticBlockDeclaration(node) + ? undefined + : node.typeParameters; + const returnType = ts.isClassStaticBlockDeclaration(node) ? undefined : node.type; + const body = (node as { body?: ts.Node }).body; + const isAmbient = context.isAmbient || hasModifier(node, ts.SyntaxKind.DeclareKeyword); + + this.pushTypeParameters(typeParameters); + const thisParameter = parameters.find( + (p) => ts.isIdentifier(p.name) && p.name.text === 'this' + ); + const restIndex = parameters.findIndex((p) => p.dotDotDotToken !== undefined); + const dotted = [...context.namePath, name].filter((p) => p !== '').join('.'); + + const row = new TsMethodRegistry({ + name, + signature: signatureOf(name, parameters, this.sf), + // What distinguishes overloads. Two signatures of one name differ only + // here, so a coarser signature would collapse an overload set into one + // row and lose the 77.6% of calls that pick a non-first declaration. + detailedSignature: detailedSignatureOf(name, parameters, returnType, this.sf), + qualifiedName: `${context.moduleQualifiedName}#${dotted}`, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + tsTypeLinkHash: context.typeHash, + ownerTypeName: context.ownerTypeName, + ownerQualifiedName: context.ownerQualifiedName, + methodAccess: methodAccessOf(node, binding), + methodModifiers: methodModifiersOf(node), + returnTypeName: returnType + ? EntityUtils.normalizeWhitespace(returnType.getText(this.sf)) + : '', + isVarArgs: restIndex >= 0, + hasReceiverParameter: thisParameter !== undefined, + methodKind, + parameterCount: parameters.length, + hasTypeParameters: (typeParameters?.length ?? 0) > 0, + throwsExceptions: thrownTypeNamesOf(body, this.sf), + enclosingMemberLinkHash: context.methodHash, + tsModuleLinkHash: context.moduleHash, + // A lexical binding when there is one; otherwise the MEMBER's identity + // across a merged owner. `ts_field` has carried exactly this since it was + // written -- `memberGroupKey`, "the member's identity ACROSS a merged + // owner, so a property declared in a module augmentation joins the same + // member as one declared in the original interface" -- and `ts_method` + // never got it, which left every interface member with an EMPTY group + // key. §4.7 c22 defines the column as "also the overload set's + // identity", and for a reopened interface that set spans files. + // + // Adjudicated against tsc, via the MERGED symbol rather than the + // declaration-local one: for an interface declared in two files, + // `getSymbolAtLocation(name)` -> `getDeclaredTypeOfSymbol` reports + // `make` with 2 declarations and construct/call signatures from both + // files. `(member as any).symbol` reports 1 declaration each, which is + // the trap that makes this look like a non-merge. + declarationGroupKey: binding?.declarationGroupKey + ?? (isMergeableMember(node) + ? memberGroupKeyOf(context.ownerGroupKey, name, isStaticMember(node)) + : ''), + mergeScopeKey: binding?.mergeScopeKey ?? '', + escapedName: binding?.escapedName ?? name, + // Provisional. Overload identity needs the whole set, and the sibling + // signature may come later in the file, so it is assigned after the walk. + signatureRole: TsSignatureRole.SOLE, + overloadIndex: 0, + bodyPresence: bodyPresenceOf(node, methodKind, body !== undefined, isAmbient), + // A GETTER or SETTER is type-only when it sits in a TYPE position — an + // interface or a type literal — and runtime-bearing in a class. The kind + // alone cannot say which, so the owner decides. + isTypeOnly: TYPE_ONLY_METHOD_KINDS.has(methodKind) || isTypePositionMember(node), + isAsync: hasModifier(node, ts.SyntaxKind.AsyncKeyword), + isGenerator: (node as { asteriskToken?: ts.AsteriskToken }).asteriskToken !== undefined, + isAbstract: hasModifier(node, ts.SyntaxKind.AbstractKeyword), + isStatic: hasModifier(node, ts.SyntaxKind.StaticKeyword), + optionalParameterCount: parameters.filter((p) => p.questionToken !== undefined).length, + restParameterIndex: restIndex >= 0 ? restIndex : undefined, + typeParameterCount: typeParameters?.length ?? 0, + thisParameterTypeName: thisParameter?.type + ? EntityUtils.normalizeWhitespace(thisParameter.type.getText(this.sf)) + : '', + isTypePredicateReturn: returnType !== undefined && ts.isTypePredicateNode(returnType), + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.methods.push(row); + this.methodHashByNode.set(nodeId(node, this.sf), row.getHash()); + this.methodRowByNode.set(nodeId(node, this.sf), row); + if (methodKind === TsMethodKind.ARROW_FUNCTION + || methodKind === TsMethodKind.FUNCTION_EXPRESSION) { + // An arrow or function expression IS a declaration in a value position, so + // its expression row points at the `ts_method` it introduces. This is the + // half of c16 the widening added: an IIFE's callee is now reachable by FK + // rather than by matching positions. + this.anonymousDeclarationByNode.set(nodeId(node, this.sf), row.getHash()); + } + // A member of an object LITERAL is owned by that literal -- a ts_expression + // row, the third owner kind §4.8.1 anticipated. Without it the member has no + // owner at all, so its parameters cannot be typed from the literal's + // contextual annotation: `const ctx: Ctx = { push(code) { … } }` types `code` + // through the literal, and that was the one hop missing from a chain whose + // other four links already exist. + if (node.parent !== undefined && ts.isObjectLiteralExpression(node.parent)) { + this.pendingLiteralOwnerLinks.push({ row, node: node.parent }); + } + this.recordOverloadCandidate(row, context, body !== undefined); + + // METHOD_TYPE_PARAM_BOUND, not TYPE_PARAM_BOUND: the split Java makes, kept + // so a query about method type parameters does not have to join back to the + // owner to find out what kind it was. + this.emitTypeParameters(typeParameters, row.getHash(), + typeParameterOwnerKindFor(methodKind), context, + TsTypeRefContext.METHOD_TYPE_PARAM_BOUND); + if (returnType) { + row.setReturnTypeReferenceLinkHash( + this.typeReferenceExtractor.extract(returnType, TsTypeRefContext.METHOD_RETURN, { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.METHOD, + tsTypeLinkHash: context.typeHash, + tsModuleLinkHash: context.moduleHash, + }) + ); + } + this.emitParameters(parameters, row, context); + + const inner: EmitContext = { + ...context, + methodHash: row.getHash(), + scopeDepth: context.scopeDepth + 1, + isAmbient, + }; + if (body && ts.isBlock(body)) { + const blockKind = methodKind === TsMethodKind.ARROW_FUNCTION + ? TsBlockKind.ARROW_BODY + : methodKind === TsMethodKind.CLASS_STATIC_BLOCK + ? TsBlockKind.STATIC_BLOCK + : TsBlockKind.FUNCTION_BODY; + const blockHash = this.emitBlock(body, blockKind, inner, row.getHash()); + const bodyContext = { ...inner, blockHash }; + for (const statement of body.statements) { + this.visitStatement(statement, bodyContext); + } + } else if (body) { + // A concise arrow body: an expression, so no block row — and it may BE a + // declaration rather than merely contain one, as in `(a) => (b) => c`. + this.emitDeclarationOrDescend(body, inner); + } + this.popTypeParameters(); + return row.getHash(); + } + + /** + * @param extractTypeReferences + * `false` when the parameter annotations are ALREADY in the type tree. + * + * A `FunctionType` node's `plannedChildren` emits one `METHOD_PARAM` child + * per parameter, owned by the type reference. Extracting them again under + * the parameter row would duplicate every annotation inside every function + * type — 21,956 of them measured in one corpus — so the row is emitted and + * the annotation is not re-walked. The text is still on the row, and the + * tree child sits at the same position and ordinal. + */ + /** + * One row per name a parameter's binding pattern binds, nested included. + * + * Shares `position` with the pattern row it came from -- they are the same + * argument, and a bound name has no argument position of its own. The PK + * separates them by name and column, so the rows do not collide. + */ + private emitBoundParameterNames( + pattern: ts.BindingName, + parameterProps: ConstructorParameters[0], + position: number + ): void { + if (ts.isIdentifier(pattern)) { + return; + } + const isArray = ts.isArrayBindingPattern(pattern); + let index = -1; + for (const element of pattern.elements) { + index += 1; + if (ts.isOmittedExpression(element)) { + continue; + } + if (!ts.isIdentifier(element.name)) { + this.emitBoundParameterNames(element.name, parameterProps, position); + continue; + } + const isRest = element.dotDotDotToken !== undefined; + const sourceKind = isRest + ? (isArray ? TsBindingSourceKind.ARRAY_REST : TsBindingSourceKind.OBJECT_REST) + : (isArray ? TsBindingSourceKind.INDEX : TsBindingSourceKind.PROPERTY); + const source = isArray + ? String(index) + : isRest ? '' : propertyNameTextOf(element, this.sf); + const startPos = this.sf.getLineAndCharacterOfPosition(element.getStart(this.sf)); + const row = new TsMethodParameterRegistry({ + ...parameterProps, + paramName: element.name.text, + // The pattern's annotation types the WHOLE object; the bound name is + // one member of it, and naming which member is `bindingSource`'s job. + bindingPatternText: '', + bindingSourceKind: sourceKind, + bindingSource: source, + hasDefault: element.initializer !== undefined, + startLine: startPos.line + 1, + startColumn: startPos.character + 1, + position, + }); + this.methodParameters.push(row); + this.parameterHashByNode.set(nodeId(element, this.sf), row.getHash()); + } + } + + private emitParameters( + parameters: readonly ts.ParameterDeclaration[], + method: TsMethodRegistry, + context: EmitContext, + extractTypeReferences = true + ): void { + let position = 0; + for (const parameter of parameters) { + const isThis = ts.isIdentifier(parameter.name) && parameter.name.text === 'this'; + const isRest = parameter.dotDotDotToken !== undefined; + const isOptional = parameter.questionToken !== undefined; + const propertyModifiers = parameterPropertyModifiersOf(parameter); + const isParameterProperty = propertyModifiers.size > 0; + const startPos = this.sf.getLineAndCharacterOfPosition(parameter.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(parameter.end); + const typeName = parameter.type + ? EntityUtils.normalizeWhitespace(parameter.type.getText(this.sf)) + : ''; + const paramKind = isThis + ? TsParamKind.THIS + : isParameterProperty + ? TsParamKind.PARAMETER_PROPERTY + : isRest + ? TsParamKind.REST + : ts.isObjectBindingPattern(parameter.name) + ? TsParamKind.BINDING_OBJECT + : ts.isArrayBindingPattern(parameter.name) + ? TsParamKind.BINDING_ARRAY + : isOptional + ? TsParamKind.OPTIONAL + : TsParamKind.REQUIRED; + + const parameterProps = { + // `""` for a binding pattern: a destructured parameter binds several + // names and none of them is the parameter's name. + paramName: ts.isIdentifier(parameter.name) ? parameter.name.text : '', + position, + tsMethodLinkHash: method.getHash(), + parameterBaseType: baseTypeOf(typeName), + parameterTypeName: typeName, + potentialQualifiedName: '', + isAmbiguous: false, + isVarArgs: isRest, + isReceiverParameter: isThis, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + paramKind, + // Changes ARITY MATCHING, so overload selection that compares counts + // without it selects the wrong signature. + isOptional: isOptional || parameter.initializer !== undefined, + hasDefault: parameter.initializer !== undefined, + defaultValueText: parameter.initializer + ? EntityUtils.normalizeWhitespace(parameter.initializer.getText(this.sf)) + : '', + defaultValueKind: defaultValueKindOf(parameter.initializer), + isParameterProperty, + parameterPropertyModifiers: propertyModifiers, + bindingPatternText: ts.isIdentifier(parameter.name) + ? '' + : EntityUtils.normalizeWhitespace(parameter.name.getText(this.sf)), + decoratorCount: (ts.getDecorators(parameter) ?? []).length, + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }; + const row = new TsMethodParameterRegistry(parameterProps); + this.methodParameters.push(row); + this.parameterHashByNode.set(nodeId(parameter, this.sf), row.getHash()); + // `function f({ helper, nested }: Ctx)` declares helper and nested. Only + // the pattern was emitted, with an empty name, so neither binding existed + // anywhere -- and unlike the variable case there was nothing to fall back + // on, because a consumer cannot resolve a name that was never recorded. + // The pattern row stays: it is the parameter, and it carries the position + // and the annotated type the bound names are read out of. + if (!ts.isIdentifier(parameter.name)) { + this.emitBoundParameterNames(parameter.name, parameterProps, position); + } + // A PARAMETER decorator can declare a function too, and under + // experimentalDecorators these are where DI tokens and taint sources live. + this.visitDecoratorDeclarations(parameter, context); + + if (parameter.type && extractTypeReferences) { + row.setTypeReferenceLinkHash( + this.typeReferenceExtractor.extract(parameter.type, TsTypeRefContext.METHOD_PARAM, { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.METHOD_PARAM, + tsTypeLinkHash: context.typeHash, + tsModuleLinkHash: context.moduleHash, + }) + ); + } + if (parameter.initializer) { + this.pendingExpressionLinks.push({ + node: parameter.initializer, + link: (hash) => row.setTsExpressionLinkHash(hash), + }); + // A default value can BE a declaration: `getUrlParams = () => ({})`. + // Same class as the curried arrow — a callable with an expression row + // and no `ts_method` — and the same helper closes it. + this.emitDeclarationOrDescend(parameter.initializer, context); + } + // A DESTRUCTURED parameter carries its defaults on the binding elements, + // not on the parameter: `constructor({ getUrlParams = () => ({}) })` has + // no `parameter.initializer` at all. Every element's default is walked, + // recursively, because a pattern can nest. + if (!ts.isIdentifier(parameter.name)) { + this.emitBindingPatternDefaults(parameter.name, context); + } + if (isParameterProperty) { + // `constructor(private x: T)` declares a FIELD as well as a parameter. + // Recorded as a cross-FK rather than a duplicated row, so the field is + // counted once in the owning type's shape. + this.emitParameterProperty(parameter, row, context, typeName); + } + position += 1; + } + } + + /** Defaults on binding-pattern elements, at any nesting depth. */ + private emitBindingPatternDefaults(name: ts.BindingName, context: EmitContext): void { + if (ts.isIdentifier(name)) { + return; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) { + continue; + } + if (element.initializer) { + this.emitDeclarationOrDescend(element.initializer, context); + } + this.emitBindingPatternDefaults(element.name, context); + } + } + + private emitParameterProperty( + parameter: ts.ParameterDeclaration, + parameterRow: TsMethodParameterRegistry, + context: EmitContext, + typeName: string + ): void { + const owner = this.typeRowByNode.get( + nodeId(parameter.parent.parent, this.sf) + ); + if (!owner) { + return; + } + const startPos = this.sf.getLineAndCharacterOfPosition(parameter.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(parameter.end); + const name = ts.isIdentifier(parameter.name) ? parameter.name.text : ''; + const row = new TsFieldRegistry({ + name, + fieldTypeName: typeName, + fieldBaseType: baseTypeOf(typeName), + potentialQualifiedName: '', + isAmbiguous: false, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + tsTypeLinkHash: owner.getHash(), + ownerTypeName: owner.name, + ownerQualifiedName: owner.qualifiedName, + fieldAccess: fieldAccessOf(parameter), + fieldModifiers: fieldModifiersOf(parameter, parameter.questionToken !== undefined), + memberKind: TsMemberKind.PARAMETER_PROPERTY, + tsModuleLinkHash: context.moduleHash, + isOptional: parameter.questionToken !== undefined, + hasDefiniteAssignment: false, + isReadonly: hasModifier(parameter, ts.SyntaxKind.ReadonlyKeyword), + isStatic: false, + indexKeyTypeName: '', + isTypeOnly: false, + memberGroupKey: EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `${owner.declarationGroupKey}||${name}||false` + ), + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + row.setOriginParameterLinkHash(parameterRow.getHash()); + parameterRow.setDeclaredFieldLinkHash(row.getHash()); + // `constructor(private dep: Dep)` declares a field whose type is the + // parameter's. The parameter already carries the reference and the two rows + // are linked both ways, so a consumer COULD reach it -- but only this field + // kind would need the extra hop, and "every annotated field names its type" + // is a better invariant than one with an exception in it. + row.setTypeReferenceLinkHash(parameterRow.getTypeReferenceLinkHash()); + this.fields.push(row); + // A parameter property is exactly why this relation exists: the field ORDER + // is the constructor's positional shape. + this.recordFieldPosition(owner.getHash(), row.getHash()); + } + + /** Next declaration index per owning type, so field order survives one-line declarations. */ + private readonly fieldCountByOwner = new Map(); + + private recordFieldPosition(ownerHash: string, fieldHash: string): void { + const position = this.fieldCountByOwner.get(ownerHash) ?? 0; + this.fieldPositions.push(new TsFieldPositionRegistry({ + tsFieldLinkHash: fieldHash, + position, + })); + this.fieldCountByOwner.set(ownerHash, position + 1); + } + + // ------------------------------------------------------------------------- + // variables + // ------------------------------------------------------------------------- + + private emitVariableStatement(node: ts.VariableStatement, context: EmitContext): void { + const isExported = hasModifier(node, ts.SyntaxKind.ExportKeyword); + const isDeclare = hasModifier(node, ts.SyntaxKind.DeclareKeyword); + for (const declaration of node.declarationList.declarations) { + this.emitVariable(declaration, node.declarationList, context, isExported, + isDeclare || context.isAmbient); + } + } + + /** + * One row per name a binding pattern binds, nested patterns included. + * + * Carries NO declarationGroupKey. A BindingElement is not one of tsc's + * mergeable declaration kinds, so these names take no part in the merge + * partition -- they are declarations, but not ones that can merge with + * anything. + */ + private emitBoundNames( + pattern: ts.BindingName, + list: ts.VariableDeclarationList | undefined, + context: EmitContext, + isExported: boolean, + isAmbient: boolean, + scopeKind: TsVariableScopeKind, + collected: TsVariableRegistry[], + declarationKindOverride?: TsVariableDeclarationKind + ): void { + if (ts.isIdentifier(pattern)) { + return; + } + const isArray = ts.isArrayBindingPattern(pattern); + let index = -1; + for (const element of pattern.elements) { + index += 1; + // A hole in `const [, second] = xs` still advances the position, so the + // index is counted before the skip rather than after it. + if (ts.isOmittedExpression(element)) { + continue; + } + // What this element reads from the thing being destructured. The name + // alone cannot say: `{ a: renamed }` and `[renamed]` produce the same + // name from completely different sources, and shorthand `{ a }` only + // looks recoverable because the two coincide there. + const isRest = element.dotDotDotToken !== undefined; + const sourceKind = isRest + ? (isArray ? TsBindingSourceKind.ARRAY_REST : TsBindingSourceKind.OBJECT_REST) + : (isArray ? TsBindingSourceKind.INDEX : TsBindingSourceKind.PROPERTY); + const source = isArray + ? String(index) + : isRest + ? '' + : propertyNameTextOf(element, this.sf); + if (!ts.isIdentifier(element.name)) { + // `const { a: { b } } = o` -- recurse; only leaves bind a name, and the + // leaf's source is its own property within the INNER pattern. + this.emitBoundNames(element.name, list, context, isExported, isAmbient, + scopeKind, collected, declarationKindOverride); + continue; + } + const startPos = this.sf.getLineAndCharacterOfPosition(element.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(element.end); + const row = new TsVariableRegistry({ + name: element.name.text, + variableTypeName: '', + variableBaseType: '', + potentialQualifiedName: '', + isAmbiguous: false, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + scopeKind, + scopeDepth: context.scopeDepth, + isConst: list !== undefined + && (list.flags & ts.NodeFlags.BlockScoped) === ts.NodeFlags.Const, + // A binding element never carries an annotation of its own. + isTypeInferred: true, + tsTypeLinkHash: context.typeHash, + tsMethodLinkHash: context.methodHash, + tsModuleLinkHash: context.moduleHash, + tsBlockLinkHash: context.blockHash, + declarationKind: declarationKindOverride ?? variableDeclarationKindOf(list), + // `= fallback` on the element, not on the declaration. + hasInitializer: element.initializer !== undefined, + initializerKind: initializerKindOf(element.initializer), + isExported, + isAmbientDeclare: isAmbient, + isDestructuring: true, + bindingSourceKind: sourceKind, + bindingSource: source, + declarationGroupKey: '', + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.variables.push(row); + this.variableHashByNode.set(nodeId(element, this.sf), row.getHash()); + this.variableRowByNode.set(nodeId(element, this.sf), row); + collected.push(row); + } + } + + emitVariable( + declaration: ts.VariableDeclaration, + list: ts.VariableDeclarationList | undefined, + context: EmitContext, + isExported: boolean, + isAmbient: boolean, + declarationKindOverride?: TsVariableDeclarationKind + ): void { + const binding = this.options.binder.bindingByNode.get(nodeId(declaration, this.sf)); + const startPos = this.sf.getLineAndCharacterOfPosition(declaration.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(declaration.end); + const typeName = declaration.type + ? EntityUtils.normalizeWhitespace(declaration.type.getText(this.sf)) + : ''; + const isDestructuring = !ts.isIdentifier(declaration.name); + const row = new TsVariableRegistry({ + name: ts.isIdentifier(declaration.name) ? declaration.name.text : '', + variableTypeName: typeName, + variableBaseType: baseTypeOf(typeName), + potentialQualifiedName: '', + isAmbiguous: false, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + scopeKind: variableScopeKindOf(context, declaration), + scopeDepth: context.scopeDepth, + isConst: list !== undefined && (list.flags & ts.NodeFlags.Const) !== 0, + isTypeInferred: declaration.type === undefined, + tsTypeLinkHash: context.typeHash, + tsMethodLinkHash: context.methodHash, + tsModuleLinkHash: context.moduleHash, + tsBlockLinkHash: context.blockHash, + declarationKind: declarationKindOverride + ?? variableDeclarationKindOf(list), + hasInitializer: declaration.initializer !== undefined, + initializerKind: initializerKindOf(declaration.initializer), + isExported, + isAmbientDeclare: isAmbient, + isDestructuring, + declarationGroupKey: binding?.declarationGroupKey ?? '', + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.variables.push(row); + this.variableHashByNode.set(nodeId(declaration, this.sf), row.getHash()); + this.variableRowByNode.set(nodeId(declaration, this.sf), row); + + // `const { a, b: renamed } = o` declares a and renamed. Only the enclosing + // VariableDeclaration was emitted, with an empty name, so the bound names + // existed nowhere in the fact base -- the binder knew them, because + // resolution needs them, and nothing ever wrote them down. A consumer could + // not tell that a variable called `a` exists at all. + const boundRows: TsVariableRegistry[] = []; + if (isDestructuring) { + this.emitBoundNames(declaration.name, list, context, isExported, isAmbient, + variableScopeKindOf(context, declaration), boundRows, declarationKindOverride); + } + + if (declaration.type) { + row.setTypeReferenceLinkHash( + this.typeReferenceExtractor.extract(declaration.type, TsTypeRefContext.VARIABLE_TYPE, { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.VARIABLE, + tsTypeLinkHash: context.typeHash, + tsModuleLinkHash: context.moduleHash, + }) + ); + } + const initializer = declaration.initializer; + if (!initializer) { + return; + } + this.pendingExpressionLinks.push({ + node: initializer, + link: (hash) => row.setInitializerExpressionLinkHash(hash), + }); + // Each bound name gets the SAME initializer, because it is the same value: + // `const { a } = ctx()` reads `a` out of what `ctx()` returned. Without it a + // bound name is a declaration with a property name and nothing to apply it + // to, so a call through one cannot resolve -- the pattern row held the + // value, the bound rows held the property, and the two shared no key. + // + // A nested leaf gets the ROOT initializer, which is the right answer: + // `const { a: { b } } = ctx()` means b comes out of ctx() by way of `a`, + // and the intermediate has no name of its own to key on. + for (const bound of boundRows) { + this.pendingExpressionLinks.push({ + node: initializer, + link: (hash) => bound.setInitializerExpressionLinkHash(hash), + }); + } + // THE link that makes `const f = () => {}; f()` resolvable. 161 measured + // call targets are arrow functions, and an arrow has no name of its own for + // a call site to match — it is reached only through the variable. + if (ts.isArrowFunction(initializer)) { + row.setBoundFunctionLinkHash( + this.emitFunctionLike(initializer, context, TsMethodKind.ARROW_FUNCTION) + ); + return; + } + if (ts.isFunctionExpression(initializer)) { + row.setBoundFunctionLinkHash( + this.emitFunctionLike(initializer, context, TsMethodKind.FUNCTION_EXPRESSION) + ); + return; + } + if (ts.isClassExpression(initializer)) { + this.emitClassLike(initializer, context, TsTypeCategory.CLASS_EXPRESSION_TYPE); + return; + } + this.emitDeclarationOrDescend(initializer, context); + } + + // ------------------------------------------------------------------------- + // blocks + // ------------------------------------------------------------------------- + + private emitBlock( + node: ts.Node, + blockKind: TsBlockKind, + context: EmitContext, + methodOwnerOverride: string + ): string { + const startPos = this.sf.getLineAndCharacterOfPosition(node.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(node.end); + const methodOwner = methodOwnerOverride !== '' ? methodOwnerOverride : context.methodHash; + const row = new TsBlockRegistry({ + blockKind, + order: this.blockOrder, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + nestingDepth: context.scopeDepth, + tsTypeLinkHash: context.typeHash, + methodOwnerHash: methodOwner, + parentContainerHash: context.blockHash !== '' ? context.blockHash : methodOwner, + tryStatementHash: '', + resourceCount: usingDeclarationCountOf(node), + // A TypeScript `catch` binding is `unknown` and cannot be typed, so unlike + // Java there is nothing to put here. Parity slot, not information. + caughtExceptionTypes: new Set(), + ownerTypeName: context.ownerTypeName, + ownerQualifiedName: context.ownerQualifiedName, + ownerMethodName: '', + tsModuleLinkHash: context.moduleHash, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.blocks.push(row); + this.blockOrder += 1; + this.blockHashByNode.set(nodeId(node, this.sf), row.getHash()); + this.blockRowByNode.set(nodeId(node, this.sf), row); + return row.getHash(); + } + + /** + * Links a block to the expression that GUARDS it. + * + * This is the column the engine narrows on: `typeof x === "string"`, + * `x instanceof C`, and a type-predicate call — 440 predicates measured, and + * every one of them is a fact about the receiver inside the block. Without the + * link the guard is an expression floating next to a block with nothing + * connecting them. + */ + private linkGuard(node: ts.Node, blockHash: string, guard: ts.Expression | undefined): void { + if (!guard) { + return; + } + const row = this.blockRowByNode.get(nodeId(node, this.sf)); + if (!row || row.getHash() !== blockHash) { + return; + } + this.pendingExpressionLinks.push({ + node: guard, + link: (hash) => row.setConditionExpressionLinkHash(hash), + }); + } + + private emitIfStatement(node: ts.IfStatement, context: EmitContext): void { + // The CONDITION, not just the branches. `if (xs.some((e) => e.ok))` puts an + // arrow in the header, and an arrow is a `ts_method` row whose parameters + // other passes resolve against. Skipping it left the arrow with no row and + // `e` resolving to a PARAMETER with an empty hash — a break in the hop chain + // that no resolution percentage would show, because the OUTER call resolved + // fine. The IR-completeness measure is what found it. + this.emitDeclarationOrDescend(node.expression, context); + this.emitBranch(node.thenStatement, TsBlockKind.IF, context, node.expression); + const elseStatement = node.elseStatement; + if (!elseStatement) { + return; + } + if (ts.isIfStatement(elseStatement)) { + // `else if` is a nested IfStatement in the AST, and flattening it would + // lose which guard governs which body. + this.emitBranch(elseStatement.thenStatement, TsBlockKind.ELSE_IF, context, + elseStatement.expression); + const tail = elseStatement.elseStatement; + if (tail) { + this.emitIfTail(tail, context); + } + return; + } + this.emitBranch(elseStatement, TsBlockKind.ELSE, context); + } + + private emitIfTail(node: ts.Statement, context: EmitContext): void { + if (ts.isIfStatement(node)) { + this.emitIfStatement(node, context); + return; + } + this.emitBranch(node, TsBlockKind.ELSE, context); + } + + private emitBranch( + node: ts.Statement, + kind: TsBlockKind, + context: EmitContext, + guard?: ts.Expression + ): void { + const hash = this.emitBlock(node, kind, context, ''); + this.linkGuard(node, hash, guard); + const inner = { ...context, blockHash: hash, scopeDepth: context.scopeDepth + 1 }; + if (ts.isBlock(node)) { + for (const statement of node.statements) { + this.visitStatement(statement, inner); + } + return; + } + this.visitStatement(node, inner); + } + + private emitLoop(node: ts.IterationStatement, context: EmitContext): void { + const kind = loopBlockKindOf(node); + const hash = this.emitBlock(node, kind, context, ''); + this.linkGuard(node, hash, loopGuardOf(node)); + const inner = { ...context, blockHash: hash, scopeDepth: context.scopeDepth + 1 }; + // Loop HEADERS hold expressions too, and the same reasoning applies as for + // an `if` condition. + for (const part of loopHeaderExpressionsOf(node)) { + this.emitDeclarationOrDescend(part, inner); + } + if (ts.isForStatement(node) && node.initializer + && ts.isVariableDeclarationList(node.initializer)) { + for (const declaration of node.initializer.declarations) { + this.emitVariable(declaration, node.initializer, inner, false, context.isAmbient, + TsVariableDeclarationKind.FOR_INIT); + } + } + if ((ts.isForInStatement(node) || ts.isForOfStatement(node)) + && ts.isVariableDeclarationList(node.initializer)) { + for (const declaration of node.initializer.declarations) { + this.emitVariable(declaration, node.initializer, inner, false, context.isAmbient, + ts.isForOfStatement(node) + ? TsVariableDeclarationKind.FOR_OF + : TsVariableDeclarationKind.FOR_IN); + } + } + if (ts.isBlock(node.statement)) { + for (const statement of node.statement.statements) { + this.visitStatement(statement, inner); + } + return; + } + this.visitStatement(node.statement, inner); + } + + private emitTryStatement(node: ts.TryStatement, context: EmitContext): void { + const tryHash = this.emitBlock(node.tryBlock, TsBlockKind.TRY, context, ''); + const tryContext = { ...context, blockHash: tryHash, scopeDepth: context.scopeDepth + 1 }; + for (const statement of node.tryBlock.statements) { + this.visitStatement(statement, tryContext); + } + if (node.catchClause) { + const catchHash = this.emitBlock(node.catchClause, TsBlockKind.CATCH, context, ''); + const catchContext = { + ...context, + blockHash: catchHash, + scopeDepth: context.scopeDepth + 1, + }; + if (node.catchClause.variableDeclaration) { + // tsc's node for a catch binding IS a VariableDeclaration, so it is one + // here too — and it appears in the merge partition as one. + this.emitVariable(node.catchClause.variableDeclaration, undefined, catchContext, false, + context.isAmbient, TsVariableDeclarationKind.CATCH); + } + for (const statement of node.catchClause.block.statements) { + this.visitStatement(statement, catchContext); + } + } + if (node.finallyBlock) { + const finallyHash = this.emitBlock(node.finallyBlock, TsBlockKind.FINALLY, context, ''); + const finallyContext = { + ...context, + blockHash: finallyHash, + scopeDepth: context.scopeDepth + 1, + }; + for (const statement of node.finallyBlock.statements) { + this.visitStatement(statement, finallyContext); + } + } + } + + private emitSwitch(node: ts.SwitchStatement, context: EmitContext): void { + this.emitDeclarationOrDescend(node.expression, context); + for (const clause of node.caseBlock.clauses) { + if (ts.isCaseClause(clause)) { + this.emitDeclarationOrDescend(clause.expression, context); + } + } + for (const clause of node.caseBlock.clauses) { + const kind = ts.isCaseClause(clause) + ? TsBlockKind.SWITCH_CASE + : TsBlockKind.SWITCH_DEFAULT; + const hash = this.emitBlock(clause, kind, context, ''); + this.linkGuard(clause, hash, ts.isCaseClause(clause) ? clause.expression : undefined); + const inner = { ...context, blockHash: hash, scopeDepth: context.scopeDepth + 1 }; + for (const statement of clause.statements) { + this.visitStatement(statement, inner); + } + } + } + + // ------------------------------------------------------------------------- + // overload identity + // ------------------------------------------------------------------------- + + /** + * Registers a member of an anonymous SHAPE, which has no EmitContext. + * + * `declare var Promise: { resolve(): …; resolve(v: T): … }` is two + * signatures of one member, and every one of them shipped as SOLE -- "the + * only declaration of its name in its table" -- which is false whenever there + * are two. A consumer reading SOLE treats each row as a complete member and + * fans out across them. Keyed on the shape hash, so two literals that each + * declare `resolve` stay separate sets. + */ + private recordAnonymousOverloadCandidate( + row: TsMethodRegistry, + typeLiteralHash: string + ): void { + if (row.escapedName === '' || typeLiteralHash === '') { + return; + } + const key = `${typeLiteralHash}||shape||${row.escapedName}`; + const existing = this.overloadSets.get(key); + if (existing) { + existing.push({ row, hasBody: false }); + } else { + this.overloadSets.set(key, [{ row, hasBody: false }]); + } + } + + private recordOverloadCandidate( + row: TsMethodRegistry, + context: EmitContext, + hasBody: boolean + ): void { + if (row.escapedName === '') { + return; + } + // Keyed on OWNER plus name plus static-ness. Two methods of one name on the + // same class, one static and one not, are two members and not an overload + // set — and a key without static-ness silently merges them. + const key = `${context.typeHash}||${row.tsModuleLinkHash}||${row.mergeScopeKey}||${row.escapedName}||${row.isStatic}`; + const existing = this.overloadSets.get(key); + if (existing) { + existing.push({ row, hasBody }); + } else { + this.overloadSets.set(key, [{ row, hasBody }]); + } + } + + /** + * Assigns `signatureRole` and `overloadIndex` once every sibling has been seen. + * + * Cannot happen during the walk: whether a declaration is `SOLE` or one of N + * depends on declarations that may appear later in the file. Safe to + * back-patch because neither column is in the primary key — which is exactly + * why the key deliberately excludes them. + */ + /** + * Points each type-level signature at the return reference already emitted. + * + * Without it, a call resolving to a callable shape found its exact target and + * then had no result type, so every chained call through one died. The + * reference was always there -- 8,528 signature rows simply never named it. + * + * Called by the orchestrator AFTER the expression walk, not at the end of + * `run()`. A function type can appear as a type ARGUMENT -- `vi.fn<(x: string) + * => string>(…)` -- and that reference is emitted by the expression pass, so + * linking any earlier finds nothing for exactly those rows. + */ + linkSignatureReturnTypes(): void { + for (const pending of this.pendingReturnLinks) { + const hash = this.typeReferenceExtractor.hashForTypeNode(pending.node); + if (hash !== '') { + pending.row.setReturnTypeReferenceLinkHash(hash); + } + } + for (const pending of this.pendingMemberTypeLinks) { + const hash = this.typeReferenceExtractor.hashForTypeNode(pending.node); + if (hash !== '') { + pending.row.setTypeReferenceLinkHash(hash); + } + } + for (const pending of this.pendingConstraintLinks) { + const hash = this.typeReferenceExtractor.hashForTypeNode(pending.node); + if (hash !== '') { + pending.row.setConstraintReferenceLinkHash(hash); + } + } + // A reference to `T` names the parameter that declares it. Linked here + // because a reference can precede that parameter's own row. + for (const pending of this.typeReferenceExtractor.pendingTypeVariableLinks) { + const hash = this.typeParameterHashByNode.get(nodeId(pending.declaration, this.sf)); + if (hash !== undefined && hash !== '') { + pending.row.setTypeParameterLinkHash(hash); + } + } + } + + private assignOverloadIdentities(): void { + for (const set of this.overloadSets.values()) { + if (set.length === 1) { + const only = set[0]; + if (!only) { + continue; + } + // A lone bodiless declaration is AMBIENT only when there is no + // implementation anywhere in source; an interface member is SOLE + // because there is no set to be one of. + const role = only.hasBody + ? TsSignatureRole.SOLE + : only.row.bodyPresence === TsBodyPresence.NO_BODY_AMBIENT + ? TsSignatureRole.AMBIENT + : TsSignatureRole.SOLE; + // A lone shape member keeps SOLE: there is genuinely no set to be one of. + only.row.setOverloadIdentity(role, 0); + continue; + } + const anyBody = set.some((entry) => entry.hasBody); + let index = 0; + for (const entry of set) { + const role = entry.hasBody + ? TsSignatureRole.IMPLEMENTATION + : anyBody + ? TsSignatureRole.OVERLOAD_SIGNATURE + : TsSignatureRole.AMBIENT; + entry.row.setOverloadIdentity(role, index); + index += 1; + } + } + } + + // ------------------------------------------------------------------------- + // type parameters + // ------------------------------------------------------------------------- + + private pushTypeParameters( + typeParameters: ts.NodeArray | undefined + ): void { + const frame = new Map(); + for (const typeParameter of typeParameters ?? []) { + frame.set(typeParameter.name.text, typeParameter); + } + this.typeParameterStack.push(frame); + } + + private popTypeParameters(): void { + this.typeParameterStack.pop(); + } + + /** + * Every type parameter name currently in lexical scope. + * + * Needed so `T` inside `class Box` becomes a `TYPE_VARIABLE` row and not a + * `TYPE_REFERENCE` to a type named `T` that does not exist. Without it the + * resolution layer chases 42,032 phantom names. + */ + private typeParametersInScope(): ReadonlySet { + const all = new Set(); + for (const frame of this.typeParameterStack) { + for (const name of frame.keys()) { + all.add(name); + } + } + return all; + } + + /** + * The declaration a type-variable reference names, innermost scope first. + * + * `typeVariableName` gave the name and nothing pointed at the declaration, so + * 4,772 references on one library named a string. A generic substitution has + * to start from the parameter row, and the name alone cannot distinguish a + * method's `T` from the class `T` it shadows. + */ + private typeParameterDeclarationFor(name: string): ts.TypeParameterDeclaration | undefined { + for (let i = this.typeParameterStack.length - 1; i >= 0; i -= 1) { + const found = this.typeParameterStack[i]!.get(name); + if (found !== undefined) { + return found; + } + } + return undefined; + } + + /** + * Emits `ts_type_parameter` rows, their BOUNDS and their DEFAULTS. + * + * The bound's context is `TYPE_PARAM_BOUND` for a type owner and + * `METHOD_TYPE_PARAM_BOUND` for a function-shaped one — the same split Java + * makes, so "every bound on a method type parameter" stays one predicate even + * though the declaration rows share a relation. + * + * `T extends A & B` produces ONE parameter row whose bound is an + * `INTERSECTION` reference with two `TYPE_ELEMENT` children, rather than two + * bound rows. That is the tree this schema uses everywhere, and it keeps + * `A & B` distinguishable from `A | B`, which two flat rows would not. + */ + private emitTypeParameters( + typeParameters: ts.NodeArray | undefined, + ownerHash: string, + ownerKind: TsTypeParameterOwnerKind, + context: EmitContext, + boundContext: TsTypeRefContext + ): void { + let position = 0; + for (const typeParameter of typeParameters ?? []) { + const startPos = this.sf.getLineAndCharacterOfPosition(typeParameter.getStart(this.sf)); + const row = new TsTypeParameterRegistry({ + paramName: typeParameter.name.text, + position, + ownerTypeName: context.ownerTypeName, + ownerQualifiedName: context.ownerQualifiedName, + filePath: this.options.filePath, + startLine: startPos.line + 1, + tsTypeLinkHash: ownerKind === TsTypeParameterOwnerKind.CLASS + || ownerKind === TsTypeParameterOwnerKind.INTERFACE + || ownerKind === TsTypeParameterOwnerKind.TYPE_ALIAS + ? ownerHash + : context.typeHash, + ownerKind, + ownerLinkHash: ownerHash, + constraintText: typeParameter.constraint + ? EntityUtils.normalizeWhitespace(typeParameter.constraint.getText(this.sf)) + : '', + defaultText: typeParameter.default + ? EntityUtils.normalizeWhitespace(typeParameter.default.getText(this.sf)) + : '', + varianceAnnotation: varianceAnnotationOf(typeParameter), + isConst: hasModifier(typeParameter, ts.SyntaxKind.ConstKeyword), + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.typeParameters.push(row); + this.typeParameterHashByNode.set(nodeId(typeParameter, this.sf), row.getHash()); + + const owner = { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.TYPE_PARAMETER, + tsTypeLinkHash: context.typeHash, + tsModuleLinkHash: context.moduleHash, + }; + if (typeParameter.constraint) { + row.setConstraintReferenceLinkHash( + this.typeReferenceExtractor.extract(typeParameter.constraint, boundContext, owner) + ); + } + if (typeParameter.default) { + row.setDefaultReferenceLinkHash( + this.typeReferenceExtractor.extract( + typeParameter.default, + TsTypeRefContext.TYPE_PARAM_DEFAULT, + owner + ) + ); + } + position += 1; + } + } + + /** + * Emits the type parameters a TYPE-LEVEL construct declares. + * + * `[K in keyof T]` and `infer U` declare real parameters with real scopes, and + * neither has a Java analogue — so neither is reachable from the declaration + * walk. They are minted from inside the type-reference walk, which is the only + * traversal that visits every type node wherever it was written. + */ + emitTypeLevelParameter( + typeParameter: ts.TypeParameterDeclaration, + ownerHash: string, + ownerKind: TsTypeParameterOwnerKind, + moduleHash: string + ): void { + const id = nodeId(typeParameter, this.sf); + if (this.typeParameterHashByNode.has(id)) { + return; + } + const startPos = this.sf.getLineAndCharacterOfPosition(typeParameter.getStart(this.sf)); + const row = new TsTypeParameterRegistry({ + paramName: typeParameter.name.text, + position: 0, + ownerTypeName: '', + ownerQualifiedName: this.options.moduleQualifiedName, + filePath: this.options.filePath, + startLine: startPos.line + 1, + tsTypeLinkHash: '', + ownerKind, + ownerLinkHash: ownerHash, + constraintText: typeParameter.constraint + ? EntityUtils.normalizeWhitespace(typeParameter.constraint.getText(this.sf)) + : '', + defaultText: '', + varianceAnnotation: '', + isConst: false, + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.typeParameters.push(row); + // `{ [K in keyof T]: … }` and `infer U extends string` declare a constraint + // that the ENCLOSING type's tree emits -- as MAPPED_CONSTRAINT or inside the + // infer node -- so the parameter row had the constraint as TEXT and nothing + // pointing at the reference. Same shape as the signature-return and shape- + // member cases, and it uses the same back-patch: the extractor records a row + // per type node, and this registers the constraint for linking after the + // walk. 57 of 928 constrained parameters on one library. + if (typeParameter.constraint) { + this.pendingConstraintLinks.push({ row, node: typeParameter.constraint }); + } + this.typeParameterHashByNode.set(id, row.getHash()); + void moduleHash; + } +} + +interface MemberSummary { + readonly isOptional: boolean; + readonly isIndexSignature: boolean; + readonly shapePart: string; +} + +function methodSummary(member: ts.Node, hash: string): MemberSummary | undefined { + if (hash === '') { + return undefined; + } + const isOptional = (member as { questionToken?: ts.QuestionToken }).questionToken !== undefined; + const arity = (member as { parameters?: ts.NodeArray }) + .parameters?.length ?? 0; + return { + isOptional, + isIndexSignature: false, + shapePart: `${memberName(member) ?? ''}:METHOD:${arity}:${isOptional}`, + }; +} + +/** + * TIER 3, and labelled as such where it is computed. + * + * A pruning aid with no semantic claim: equal digests make two types + * CANDIDATES for structural satisfaction, never a satisfaction fact. 91.9% of + * interfaces have no implementer at all and the empty shape is satisfied by + * everything, so an engine that does not prune first computes noise at + * O(classes x interfaces). Deciding satisfaction needs `isTypeAssignableTo`, + * which the parser does not have and must not pretend to. + */ +function shapeDigestOf(parts: readonly string[]): string { + return EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + [...parts].sort().join('|') + ); +} + +/** + * Kinds that have no runtime existence, so no call-graph rule may traverse them + * as an implementation (§3.3). + * + * The `TYPE_LITERAL_*` twins belong here for the same reason as their interface + * counterparts: they are written in type position and can never carry a body. + * Omitting them would leave 259 rows claiming runtime existence they do not have. + */ +const TYPE_ONLY_METHOD_KINDS = new Set([ + TsMethodKind.METHOD_SIGNATURE, + TsMethodKind.CALL_SIGNATURE, + TsMethodKind.CONSTRUCT_SIGNATURE, + TsMethodKind.TYPE_LITERAL_METHOD_SIGNATURE, + TsMethodKind.TYPE_LITERAL_CALL_SIGNATURE, + TsMethodKind.TYPE_LITERAL_CONSTRUCT_SIGNATURE, + TsMethodKind.FUNCTION_TYPE_SIGNATURE, + TsMethodKind.CONSTRUCTOR_TYPE_SIGNATURE, +]); + +/** + * An enum member's value kind, and its value when statically known. + * + * `CONSTANT_EXPRESSION` is separated from `COMPUTED` because the compiler FOLDS + * the former and refuses the latter in a `const enum` — so the distinction + * decides whether a reference to the member can have a runtime target at all. + * Folding is not attempted here: `1 << 0` is recorded as a constant expression + * with no value, because evaluating it would be running the program. + */ +function enumMemberValueOf( + member: ts.EnumMember, + nextImplicit: number | undefined, + sourceFile: ts.SourceFile +): { kind: TsEnumMemberValueKind; value: string } { + const initializer = member.initializer; + if (!initializer) { + return nextImplicit === undefined + ? { kind: TsEnumMemberValueKind.COMPUTED, value: '' } + : { kind: TsEnumMemberValueKind.IMPLICIT_NUMERIC, value: String(nextImplicit) }; + } + if (ts.isNumericLiteral(initializer)) { + return { kind: TsEnumMemberValueKind.EXPLICIT_NUMERIC, value: initializer.text }; + } + if (ts.isPrefixUnaryExpression(initializer) + && initializer.operator === ts.SyntaxKind.MinusToken + && ts.isNumericLiteral(initializer.operand)) { + return { + kind: TsEnumMemberValueKind.EXPLICIT_NUMERIC, + value: `-${initializer.operand.text}`, + }; + } + if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) { + return { kind: TsEnumMemberValueKind.EXPLICIT_STRING, value: initializer.text }; + } + if (isFoldableConstantExpression(initializer)) { + return { + kind: TsEnumMemberValueKind.CONSTANT_EXPRESSION, + value: '', + }; + } + void sourceFile; + return { kind: TsEnumMemberValueKind.COMPUTED, value: '' }; +} + +/** + * Is this an expression the compiler will fold? + * + * Numeric literals, references to other enum members, and the arithmetic and + * bitwise operators over them. A call, a property access outside the enum, or a + * template with substitutions is COMPUTED — and the difference is what decides + * whether the member is legal in a `const enum`. + */ +function isFoldableConstantExpression(node: ts.Expression): boolean { + if (ts.isNumericLiteral(node) || ts.isIdentifier(node)) { + return true; + } + if (ts.isParenthesizedExpression(node)) { + return isFoldableConstantExpression(node.expression); + } + if (ts.isPrefixUnaryExpression(node)) { + return isFoldableConstantExpression(node.operand); + } + if (ts.isPropertyAccessExpression(node)) { + return ts.isIdentifier(node.expression); + } + if (ts.isBinaryExpression(node)) { + return FOLDABLE_OPERATORS.has(node.operatorToken.kind) + && isFoldableConstantExpression(node.left) + && isFoldableConstantExpression(node.right); + } + return false; +} + +const FOLDABLE_OPERATORS = new Set([ + ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken, ts.SyntaxKind.AsteriskToken, + ts.SyntaxKind.SlashToken, ts.SyntaxKind.PercentToken, ts.SyntaxKind.AsteriskAsteriskToken, + ts.SyntaxKind.AmpersandToken, ts.SyntaxKind.BarToken, ts.SyntaxKind.CaretToken, + ts.SyntaxKind.LessThanLessThanToken, ts.SyntaxKind.GreaterThanGreaterThanToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken, +]); + +/** + * Is this member written in a TYPE position — an interface or a type literal? + * + * The question the KIND cannot answer. `get x(): T` is runtime-bearing in a class + * and type-only in an interface, and the same `GetAccessorDeclaration` node type + * serves both since TypeScript 5.1. + */ +function isTypePositionMember(node: ts.Node): boolean { + const parent = node.parent; + return parent !== undefined + && (ts.isInterfaceDeclaration(parent) || ts.isTypeLiteralNode(parent)); +} + +/** `in` / `out` on a type parameter. TypeScript 4.7; 562 measured. */ +function varianceAnnotationOf( + typeParameter: ts.TypeParameterDeclaration +): TsVarianceAnnotation | '' { + const hasIn = hasModifier(typeParameter, ts.SyntaxKind.InKeyword); + const hasOut = hasModifier(typeParameter, ts.SyntaxKind.OutKeyword); + if (hasIn && hasOut) { + return TsVarianceAnnotation.IN_OUT; + } + if (hasIn) { + return TsVarianceAnnotation.IN; + } + if (hasOut) { + return TsVarianceAnnotation.OUT; + } + return ''; +} + +/** + * Which of the nine owner kinds a function-shaped declaration is. + * + * The distinction is what lets one relation stand in for Java's two: a + * projection filters on it instead of choosing a relation. + */ +function typeParameterOwnerKindFor(methodKind: TsMethodKind): TsTypeParameterOwnerKind { + switch (methodKind) { + case TsMethodKind.FUNCTION_DECLARATION: + case TsMethodKind.FUNCTION_EXPRESSION: + case TsMethodKind.FUNCTION_TYPE_SIGNATURE: { + return TsTypeParameterOwnerKind.FUNCTION; + } + case TsMethodKind.ARROW_FUNCTION: { + return TsTypeParameterOwnerKind.ARROW; + } + case TsMethodKind.CALL_SIGNATURE: { + return TsTypeParameterOwnerKind.CALL_SIGNATURE; + } + case TsMethodKind.CONSTRUCT_SIGNATURE: + case TsMethodKind.CONSTRUCTOR_TYPE_SIGNATURE: { + return TsTypeParameterOwnerKind.CONSTRUCT_SIGNATURE; + } + default: { + return TsTypeParameterOwnerKind.METHOD; + } + } +} + +function methodNameOf( + node: ts.Node, + methodKind: TsMethodKind, + binding: BoundDeclaration | undefined +): string { + if (binding) { + return recordedNameOf(node, binding.name); + } + switch (methodKind) { + case TsMethodKind.CONSTRUCTOR: { + return TS_ANONYMOUS_METHOD_NAMES.CONSTRUCTOR; + } + case TsMethodKind.ARROW_FUNCTION: { + return TS_ANONYMOUS_METHOD_NAMES.ARROW; + } + case TsMethodKind.FUNCTION_EXPRESSION: { + return (node as ts.FunctionExpression).name?.text + ?? TS_ANONYMOUS_METHOD_NAMES.FUNCTION_EXPRESSION; + } + case TsMethodKind.CALL_SIGNATURE: { + return TS_ANONYMOUS_METHOD_NAMES.CALL_SIGNATURE; + } + case TsMethodKind.CONSTRUCT_SIGNATURE: { + return TS_ANONYMOUS_METHOD_NAMES.CONSTRUCT_SIGNATURE; + } + case TsMethodKind.CLASS_STATIC_BLOCK: { + return TS_ANONYMOUS_METHOD_NAMES.STATIC_BLOCK; + } + default: { + return memberName(node) ?? ''; + } + } +} + +/** + * A member's identity across a MERGED owner, mirroring `ts_field`'s + * `memberGroupKey` formula exactly so a method and a field of the same name on + * the same owner agree. + * + * Empty when there is no owning type: a free function's identity is its + * lexical binding, which the binder already supplies. + */ +function memberGroupKeyOf(ownerGroupKey: string | undefined, name: string, + isStatic: boolean): string { + if (ownerGroupKey === undefined || ownerGroupKey === '') { + return ''; + } + // An UNNAMED member has no identity to share. `[someConst]() {}` needs the + // constant folded to be named, so hashing its empty name grouped every + // dynamic key on one owner into a single false overload set — six distinct + // members of one class read as one six-member group. No name, no group. + if (name === '') { + return ''; + } + return EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `${ownerGroupKey}||${name}||${isStatic}` + ); +} + +/** + * Is this callable a MEMBER of its owner, and therefore mergeable? + * + * The member group key answers "which member of this owner is this", so it + * belongs only to something that IS a member. An arrow assigned to a `const` + * inside a method is not: it is an expression that merely occurs inside the + * class, and its `tsTypeLinkHash` names the class only because that is the + * enclosing emit context. + * + * Keying those was a regression. Every arrow shares the sentinel name + * ``, so `md5(ownerGroupKey || "" || false)` is one value for + * every arrow in a class body — five distinct callables in three different + * methods emitted with one `declarationGroupKey` and consecutive + * `overloadIndex`, as though they were overloads of each other. A consumer + * then commits a call through one `const` to a different method's arrow. The + * module-scope arrows beside them were always right, because there is no + * owner there at all. + * + * `isClassElement` / `isTypeElement` are tsc's own predicates for membership, + * so a method, an accessor, a constructor, and the call and construct + * signatures of a reopened interface all keep the key they need. + * + * A static block is the one ClassElement excluded: tsc gives it no symbol, and + * several in one class would collide on `` for the same reason + * arrows did. + */ +function isMergeableMember(node: ts.Node): boolean { + if (ts.isClassStaticBlockDeclaration(node)) { + return false; + } + return ts.isClassElement(node) || ts.isTypeElement(node); +} + +function isStaticMember(node: ts.Node): boolean { + return hasModifier(node, ts.SyntaxKind.StaticKeyword); +} + +/** + * The name to RECORD for a declaration, which is not always the name it MERGES + * under. + * + * `export default class NamedClass {}` binds as `default` — that is + * `InternalSymbolName.Default` and tsc agrees, its symbol's `escapedName` is + * literally `"default"` — so the MERGE identity is right and must not move. + * But recording `default` as the declaration's own name made a NAMED default + * export indistinguishable from an anonymous one: two rows identical apart + * from the module, when `ts_export` had kept both names all along + * (`exportedName=default`, `localName=NamedClass`). + * + * So the two names are separated at the one place they differ. `escapedName` + * and `declarationGroupKey` keep the binding's `default`; `name` and the + * `qualifiedName` built from it take the declaration's own identifier when it + * has one. An anonymous `export default class {}` still reads `default`, + * because there is nothing else it could be. + */ +function recordedNameOf(node: ts.Node, bindingName: string): string { + if (bindingName !== TS_DEFAULT_EXPORT_NAME) { + return bindingName; + } + const own = (node as { name?: ts.Node }).name; + return own !== undefined && ts.isIdentifier(own) ? own.text : bindingName; +} + +function signatureOf( + name: string, + parameters: readonly ts.ParameterDeclaration[], + sourceFile: ts.SourceFile +): string { + const names = parameters.map((p) => + ts.isIdentifier(p.name) ? p.name.text : EntityUtils.normalizeWhitespace( + p.name.getText(sourceFile))); + return `${name}(${names.join(', ')})`; +} + +function detailedSignatureOf( + name: string, + parameters: readonly ts.ParameterDeclaration[], + returnType: ts.TypeNode | undefined, + sourceFile: ts.SourceFile +): string { + const parts = parameters.map((p) => EntityUtils.normalizeWhitespace(p.getText(sourceFile))); + const suffix = returnType + ? `: ${EntityUtils.normalizeWhitespace(returnType.getText(sourceFile))}` + : ''; + return `${name}(${parts.join(', ')})${suffix}`; +} + +/** + * The column that stops a `.d.ts` line being read as an implementation. + * + * Order matters. A method signature inside a `.d.ts` interface is + * NO_BODY_INTERFACE, not NO_BODY_AMBIENT: the more specific reason is the one + * worth recording, because an interface member can never have a body under any + * compiler options while an ambient function merely does not have one here. + */ +function bodyPresenceOf( + node: ts.Node, + methodKind: TsMethodKind, + hasBody: boolean, + isAmbient: boolean +): TsBodyPresence { + if (hasBody) { + return TsBodyPresence.HAS_BODY; + } + // The OWNER first, then the kind. An accessor in an interface can never carry + // a body under any compiler options, which is a stronger statement than + // "it happens to be in a .d.ts" — so NO_BODY_INTERFACE, not NO_BODY_AMBIENT. + if (TYPE_ONLY_METHOD_KINDS.has(methodKind) || isTypePositionMember(node)) { + return TsBodyPresence.NO_BODY_INTERFACE; + } + if (hasModifier(node, ts.SyntaxKind.AbstractKeyword)) { + return TsBodyPresence.NO_BODY_ABSTRACT; + } + if (isAmbient || hasModifier(node, ts.SyntaxKind.DeclareKeyword)) { + return TsBodyPresence.NO_BODY_AMBIENT; + } + return TsBodyPresence.NO_BODY_OVERLOAD; +} + +function typeAccessOf(node: ts.Node, binding: BoundDeclaration | undefined): TsTypeAccess { + if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) { + return TsTypeAccess.DEFAULT_EXPORT_ACCESS; + } + if (binding?.isExported === true) { + return TsTypeAccess.EXPORTED_ACCESS; + } + if (binding?.mergeScopeKey === 'GLOBAL') { + return TsTypeAccess.GLOBAL_ACCESS; + } + if (binding?.mergeScopeKey.startsWith('NS:') === true) { + return TsTypeAccess.NAMESPACE_LOCAL_ACCESS; + } + return TsTypeAccess.MODULE_LOCAL_ACCESS; +} + +function typeModifiersOf(node: ts.Node): ReadonlySet { + const out = new Set(); + if (hasModifier(node, ts.SyntaxKind.AbstractKeyword)) { + out.add(TsTypeModifier.ABSTRACT); + } + if (hasModifier(node, ts.SyntaxKind.DeclareKeyword)) { + out.add(TsTypeModifier.DECLARE); + } + if (hasModifier(node, ts.SyntaxKind.ConstKeyword)) { + out.add(TsTypeModifier.CONST); + } + if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) { + out.add(TsTypeModifier.EXPORT); + } + if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) { + out.add(TsTypeModifier.DEFAULT_EXPORT); + } + const typeParameters = (node as { + typeParameters?: ts.NodeArray; + }).typeParameters; + if (typeParameters && typeParameters.length > 0) { + out.add(TsTypeModifier.GENERIC); + } + return out; +} + +function placementOf(node: ts.Node, context: EmitContext): TsTypePlacement { + if (ts.isClassExpression(node)) { + return TsTypePlacement.EXPRESSION_PLACEMENT; + } + if (context.isAmbient && context.typeHash === '' && context.methodHash !== '') { + return TsTypePlacement.AMBIENT_MODULE_PLACEMENT; + } + if (context.namePath.length > 0) { + return TsTypePlacement.NAMESPACE_PLACEMENT; + } + if (context.typeHash !== '') { + return TsTypePlacement.NESTED_PLACEMENT; + } + if (context.blockHash !== '') { + return TsTypePlacement.LOCAL_PLACEMENT; + } + return TsTypePlacement.TOP_LEVEL_PLACEMENT; +} + +function heritageCountOf(node: ts.Node): number { + const clauses = (node as { heritageClauses?: ts.NodeArray }).heritageClauses; + if (!clauses) { + return 0; + } + let count = 0; + for (const clause of clauses) { + count += clause.types.length; + } + return count; +} + +function methodAccessOf(node: ts.Node, binding: BoundDeclaration | undefined): TsMethodAccess { + const name = (node as { name?: ts.PropertyName }).name; + if (name && ts.isPrivateIdentifier(name)) { + // `#m()` is a HARD runtime private. `private` is erased at emit and is not + // the same fact, so the two never share a value. + return TsMethodAccess.PRIVATE_NAME_ACCESS; + } + if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) { + return TsMethodAccess.PRIVATE_ACCESS; + } + if (hasModifier(node, ts.SyntaxKind.ProtectedKeyword)) { + return TsMethodAccess.PROTECTED_ACCESS; + } + if (hasModifier(node, ts.SyntaxKind.PublicKeyword)) { + return TsMethodAccess.PUBLIC_ACCESS; + } + if (binding?.isExported === true) { + return TsMethodAccess.EXPORTED_ACCESS; + } + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) + || ts.isArrowFunction(node)) { + return TsMethodAccess.MODULE_LOCAL_ACCESS; + } + return TsMethodAccess.PUBLIC_ACCESS; +} + +function methodModifiersOf(node: ts.Node): ReadonlySet { + const out = new Set(); + if (hasModifier(node, ts.SyntaxKind.StaticKeyword)) { + out.add(TsMethodModifier.STATIC); + } + if (hasModifier(node, ts.SyntaxKind.AbstractKeyword)) { + out.add(TsMethodModifier.ABSTRACT); + } + if (hasModifier(node, ts.SyntaxKind.AsyncKeyword)) { + out.add(TsMethodModifier.ASYNC); + } + if ((node as { asteriskToken?: ts.AsteriskToken }).asteriskToken !== undefined) { + out.add(TsMethodModifier.GENERATOR); + } + if (hasModifier(node, ts.SyntaxKind.DeclareKeyword)) { + out.add(TsMethodModifier.DECLARE); + } + if (hasModifier(node, ts.SyntaxKind.OverrideKeyword)) { + out.add(TsMethodModifier.OVERRIDE); + } + if ((node as { questionToken?: ts.QuestionToken }).questionToken !== undefined) { + out.add(TsMethodModifier.OPTIONAL); + } + if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) { + out.add(TsMethodModifier.EXPORT); + } + if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) { + out.add(TsMethodModifier.DEFAULT_EXPORT); + } + return out; +} + +function fieldAccessOf(node: ts.Node): TsFieldAccess { + const name = (node as { name?: ts.PropertyName | ts.BindingName }).name; + if (name && ts.isPrivateIdentifier(name as ts.Node)) { + return TsFieldAccess.PRIVATE_NAME_ACCESS; + } + if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) { + return TsFieldAccess.PRIVATE_ACCESS; + } + if (hasModifier(node, ts.SyntaxKind.ProtectedKeyword)) { + return TsFieldAccess.PROTECTED_ACCESS; + } + return TsFieldAccess.PUBLIC_ACCESS; +} + +function fieldModifiersOf(node: ts.Node, isOptional: boolean): ReadonlySet { + const out = new Set(); + if (hasModifier(node, ts.SyntaxKind.StaticKeyword)) { + out.add(TsFieldModifier.STATIC); + } + if (hasModifier(node, ts.SyntaxKind.ReadonlyKeyword)) { + out.add(TsFieldModifier.READONLY); + } + if (hasModifier(node, ts.SyntaxKind.DeclareKeyword)) { + out.add(TsFieldModifier.DECLARE); + } + if (hasModifier(node, ts.SyntaxKind.AbstractKeyword)) { + out.add(TsFieldModifier.ABSTRACT); + } + if (hasModifier(node, ts.SyntaxKind.OverrideKeyword)) { + out.add(TsFieldModifier.OVERRIDE); + } + if (hasModifier(node, ts.SyntaxKind.AccessorKeyword)) { + out.add(TsFieldModifier.ACCESSOR); + } + if (isOptional) { + out.add(TsFieldModifier.OPTIONAL); + } + if ((node as { exclamationToken?: ts.ExclamationToken }).exclamationToken !== undefined) { + out.add(TsFieldModifier.DEFINITE_ASSIGNMENT); + } + return out; +} + +function parameterPropertyModifiersOf( + parameter: ts.ParameterDeclaration +): ReadonlySet { + const out = new Set(); + if (hasModifier(parameter, ts.SyntaxKind.PrivateKeyword)) { + out.add(TsParameterPropertyModifier.PRIVATE); + } + if (hasModifier(parameter, ts.SyntaxKind.ProtectedKeyword)) { + out.add(TsParameterPropertyModifier.PROTECTED); + } + if (hasModifier(parameter, ts.SyntaxKind.PublicKeyword)) { + out.add(TsParameterPropertyModifier.PUBLIC); + } + if (hasModifier(parameter, ts.SyntaxKind.ReadonlyKeyword)) { + out.add(TsParameterPropertyModifier.READONLY); + } + return out; +} + +function indexKeyTypeNameOf(node: ts.IndexSignatureDeclaration, sourceFile: ts.SourceFile): string { + const parameter = node.parameters[0]; + return parameter?.type + ? EntityUtils.normalizeWhitespace(parameter.type.getText(sourceFile)) + : ''; +} + +/** The annotation minus its type arguments — `Map` for `Map`. */ +function baseTypeOf(typeName: string): string { + const index = typeName.indexOf('<'); + return index < 0 ? typeName : typeName.slice(0, index); +} + +/** + * Type names appearing in `throw new X` inside the body. + * + * INFERRED, and labelled as such: TypeScript has no `throws` clause, so unlike + * Java this column is a syntactic observation about one body rather than a + * declared contract. It does not see what a callee throws. + */ +function thrownTypeNamesOf(body: ts.Node | undefined, sourceFile: ts.SourceFile): Set { + const out = new Set(); + if (!body) { + return out; + } + const walk = (node: ts.Node): void => { + if (ts.isThrowStatement(node) && node.expression && ts.isNewExpression(node.expression) + && ts.isIdentifier(node.expression.expression)) { + out.add(node.expression.expression.text); + } + // Nested functions have their own row and their own throws; descending into + // them would attribute a closure's throw to its enclosing function. + if (ts.isFunctionLike(node) && node !== body) { + return; + } + ts.forEachChild(node, walk); + }; + ts.forEachChild(body, walk); + void sourceFile; + return out; +} + +/** + * The expressions in a loop header. + * + * Enumerated rather than reached by a generic descent, because the loop's BODY + * is walked separately and a generic descent would visit it twice — emitting + * every nested function in it under two owners. + */ +function loopHeaderExpressionsOf(node: ts.IterationStatement): ts.Expression[] { + const out: ts.Expression[] = []; + if (ts.isForStatement(node)) { + if (node.initializer && !ts.isVariableDeclarationList(node.initializer)) { + out.push(node.initializer); + } + if (node.condition) { + out.push(node.condition); + } + if (node.incrementor) { + out.push(node.incrementor); + } + return out; + } + if (ts.isForInStatement(node) || ts.isForOfStatement(node)) { + out.push(node.expression); + return out; + } + if (ts.isWhileStatement(node) || ts.isDoStatement(node)) { + out.push(node.expression); + } + return out; +} + +/** The expression a loop tests or iterates — the guard, for narrowing purposes. */ +function loopGuardOf(node: ts.IterationStatement): ts.Expression | undefined { + if (ts.isForStatement(node)) { + return node.condition; + } + if (ts.isForInStatement(node) || ts.isForOfStatement(node) + || ts.isWhileStatement(node) || ts.isDoStatement(node)) { + return node.expression; + } + return undefined; +} + +function loopBlockKindOf(node: ts.IterationStatement): TsBlockKind { + if (ts.isForStatement(node)) { + return TsBlockKind.FOR; + } + if (ts.isForInStatement(node)) { + return TsBlockKind.FOR_IN; + } + if (ts.isForOfStatement(node)) { + return node.awaitModifier ? TsBlockKind.FOR_AWAIT_OF : TsBlockKind.FOR_OF; + } + if (ts.isWhileStatement(node)) { + return TsBlockKind.WHILE; + } + return TsBlockKind.DO_WHILE; +} + +/** `using` / `await using` — TypeScript 5.2's analogue of try-with-resources. */ +function usingDeclarationCountOf(node: ts.Node): number { + let count = 0; + const statements = (node as { statements?: ts.NodeArray }).statements; + for (const statement of statements ?? []) { + if (ts.isVariableStatement(statement)) { + const flags = statement.declarationList.flags; + if ((flags & ts.NodeFlags.Using) !== 0 || (flags & ts.NodeFlags.AwaitUsing) !== 0) { + count += statement.declarationList.declarations.length; + } + } + } + return count; +} + +function variableDeclarationKindOf( + list: ts.VariableDeclarationList | undefined +): TsVariableDeclarationKind { + if (!list) { + return TsVariableDeclarationKind.CATCH; + } + // AwaitUsing is a COMPOSITE flag -- Const | Using, the value 6 -- not a bit of + // its own. `flags & AwaitUsing` is therefore non-zero for an ordinary `const` + // (2 & 6 === 2), so every const in the corpus was labelled AWAIT_USING and + // CONST was emitted zero times. `let` and `var` were unaffected, which is why + // it looked like a rare-construct bug rather than the common case being wrong. + // + // Masking to the block-scope bits and comparing for EQUALITY is what a + // composite flag requires; a truthiness test cannot distinguish a compound + // value from either of its parts. + const blockScoped = list.flags & ts.NodeFlags.BlockScoped; + if (blockScoped === ts.NodeFlags.AwaitUsing) { + return TsVariableDeclarationKind.AWAIT_USING; + } + if (blockScoped === ts.NodeFlags.Using) { + return TsVariableDeclarationKind.USING; + } + if (blockScoped === ts.NodeFlags.Const) { + return TsVariableDeclarationKind.CONST; + } + if (blockScoped === ts.NodeFlags.Let) { + return TsVariableDeclarationKind.LET; + } + return TsVariableDeclarationKind.VAR; +} + +/** + * The property a binding element reads, as written. + * + * `{ a }` and `{ a: renamed }` both read `a`; the shorthand simply has no + * propertyName node, so the element's own name is the property. A computed key + * `{ [k]: v }` has no static answer, and returns the text rather than a guess. + */ +function propertyNameTextOf(element: ts.BindingElement, sf: ts.SourceFile): string { + const property = element.propertyName; + if (property === undefined) { + return ts.isIdentifier(element.name) ? element.name.text : ''; + } + if (ts.isIdentifier(property) || ts.isStringLiteral(property) || ts.isNumericLiteral(property)) { + return property.text; + } + return EntityUtils.normalizeWhitespace(property.getText(sf)); +} + +function variableScopeKindOf( + context: EmitContext, + declaration: ts.VariableDeclaration +): TsVariableScopeKind { + if (declaration.parent && ts.isCatchClause(declaration.parent)) { + return TsVariableScopeKind.CATCH_BINDING; + } + const list = declaration.parent; + if (list && ts.isVariableDeclarationList(list) && list.parent + && (ts.isForStatement(list.parent) || ts.isForInStatement(list.parent) + || ts.isForOfStatement(list.parent))) { + return TsVariableScopeKind.FOR_BINDING; + } + if (context.isAmbient) { + return TsVariableScopeKind.AMBIENT_SCOPE; + } + if (context.blockHash !== '') { + return TsVariableScopeKind.BLOCK_SCOPE; + } + if (context.namePath.length > 0) { + return TsVariableScopeKind.NAMESPACE_SCOPE; + } + if (context.typeHash !== '') { + return TsVariableScopeKind.FUNCTION_BODY; + } + return TsVariableScopeKind.MODULE_SCOPE; +} + +function initializerKindOf(node: ts.Expression | undefined): TsVariableInitializerKind { + if (!node) { + return TsVariableInitializerKind.NONE; + } + if (ts.isArrowFunction(node)) { + return TsVariableInitializerKind.ARROW; + } + if (ts.isFunctionExpression(node)) { + return TsVariableInitializerKind.FUNCTION_EXPRESSION; + } + if (ts.isNewExpression(node)) { + return TsVariableInitializerKind.NEW; + } + if (ts.isCallExpression(node)) { + return TsVariableInitializerKind.CALL; + } + if (ts.isObjectLiteralExpression(node)) { + return TsVariableInitializerKind.OBJECT_LITERAL; + } + if (ts.isArrayLiteralExpression(node)) { + return TsVariableInitializerKind.ARRAY_LITERAL; + } + if (ts.isAsExpression(node)) { + return TsVariableInitializerKind.AS_EXPRESSION; + } + if (ts.isSatisfiesExpression(node)) { + return TsVariableInitializerKind.SATISFIES; + } + if (ts.isAwaitExpression(node)) { + return TsVariableInitializerKind.AWAIT; + } + if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return TsVariableInitializerKind.TEMPLATE; + } + if (ts.isClassExpression(node)) { + return TsVariableInitializerKind.CLASS_EXPRESSION; + } + if (ts.isIdentifier(node)) { + return TsVariableInitializerKind.IDENTIFIER; + } + if (ts.isLiteralExpression(node) || node.kind === ts.SyntaxKind.TrueKeyword + || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword) { + return TsVariableInitializerKind.LITERAL; + } + return TsVariableInitializerKind.UNKNOWN; +} + +function defaultValueKindOf(node: ts.Expression | undefined): TsDefaultValueKind { + if (!node) { + return TsDefaultValueKind.NONE; + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return TsDefaultValueKind.STRING; + } + if (ts.isNumericLiteral(node)) { + return TsDefaultValueKind.NUMBER; + } + if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) { + return TsDefaultValueKind.BOOL; + } + if (node.kind === ts.SyntaxKind.NullKeyword) { + return TsDefaultValueKind.NULL; + } + if (ts.isIdentifier(node) && node.text === 'undefined') { + return TsDefaultValueKind.UNDEFINED; + } + if (ts.isObjectLiteralExpression(node)) { + return TsDefaultValueKind.OBJECT; + } + if (ts.isArrayLiteralExpression(node)) { + return TsDefaultValueKind.ARRAY; + } + if (ts.isNewExpression(node)) { + return TsDefaultValueKind.NEW; + } + if (ts.isCallExpression(node)) { + return TsDefaultValueKind.CALL; + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return TsDefaultValueKind.ARROW; + } + if (ts.isTemplateExpression(node)) { + return TsDefaultValueKind.TEMPLATE; + } + if (ts.isIdentifier(node)) { + return TsDefaultValueKind.IDENTIFIER; + } + return TsDefaultValueKind.UNKNOWN; +} diff --git a/parser/src/parsers/typescript/extractors/ts-decorator-extractor.ts b/parser/src/parsers/typescript/extractors/ts-decorator-extractor.ts new file mode 100644 index 000000000..619e6acc5 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-decorator-extractor.ts @@ -0,0 +1,377 @@ +import * as ts from 'typescript'; + +import { TsDecoratorArgumentRegistry } from + '@/analysis-types/typescript/TsDecoratorArgumentRegistry'; +import { TsDecoratorRegistry } from '@/analysis-types/typescript/TsDecoratorRegistry'; +import { TsExpressionRegistry } from '@/analysis-types/typescript/TsExpressionRegistry'; +import { + TsDecoratorArgumentValueType, + TsDecoratorContext, + TsDecoratorKind, + TsDecoratorSemantics, + TsDecoratorSystem, +} from '@/enums/typescript/decorators'; +import { TsReferenceOwnerKind, TsTypeRefContext } from '@/enums/typescript/type-references'; +import { nodeId } from '@/parsers/typescript/extractors/ts-binder'; +import { unwrapParentheses } from '@/parsers/typescript/extractors/ts-expression-extractor'; +import { TsTypeReferenceExtractor } from '@/parsers/typescript/extractors/ts-type-reference-extractor'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Emits `ts_decorator` and `ts_decorator_argument` — schema §4.18, §4.19. + * + * ## `decoratorSystem` comes from the GOVERNING tsconfig, per file + * + * This is the one correctness item that cannot be got right by reasoning about + * the source alone, because the source is IDENTICAL under both systems and the + * facts are not. Standard TC39 decorators and legacy `experimentalDecorators` + * differ in evaluation order, in what the decorator function receives, and in + * whether parameter decorators are legal at all — and the only thing that says + * which one applies is the tsconfig that governs the file. + * + * This repository's own corpus proves the point: `annotations/legacy/` sits + * three directories below `annotations/standard-decorators.ts`, compiles under + * its own config with `experimentalDecorators: true`, and its facts + * legitimately differ. A run-wide assumption gets one of the two wrong, and + * gets it wrong in a way that looks entirely plausible. + * + * ## Runs AFTER expressions, on purpose + * + * A decorator is an expression that RUNS, so `tsExpressionLinkHash` must point + * at a row that already exists. That is also why `@Component({...})` produces a + * `ts_call_site` like any other call: it is one. + */ +export interface DecoratorExtractorOptions { + readonly sourceFile: ts.SourceFile; + readonly moduleHash: string; + readonly serviceVersionLinkHash: string; + /** From the tsconfig that governs THIS file. Never a run-wide constant. */ + readonly decoratorSystem: TsDecoratorSystem; + readonly typeHashByNode: ReadonlyMap; + readonly methodHashByNode: ReadonlyMap; + readonly fieldHashByNode: ReadonlyMap; + readonly parameterHashByNode: ReadonlyMap; + readonly expressionRowByNode: ReadonlyMap; + /** + * So a decorator can contribute to the TYPE graph as well as the call graph. + * + * Java emits `ANNOTATION_TYPE` for the annotation a usage names and + * `ANNOTATION_PARAM` for types inside its arguments. Both port directly: a + * decorator names a function or class, and `@Inject(UserRepository)` names a + * type as a DI token. Without them a decorator is a call with no type edge, + * and the DI pattern the relation exists to surface is unqueryable from the + * type side. + */ + readonly typeReferenceExtractor: TsTypeReferenceExtractor; +} + +export interface DecoratorExtractionResult { + readonly decorators: readonly TsDecoratorRegistry[]; + readonly decoratorArguments: readonly TsDecoratorArgumentRegistry[]; +} + +export function extractDecorators( + options: DecoratorExtractorOptions +): DecoratorExtractionResult { + const decorators: TsDecoratorRegistry[] = []; + const decoratorArguments: TsDecoratorArgumentRegistry[] = []; + const sf = options.sourceFile; + + const emitFor = (node: ts.Node, context: TsDecoratorContext, ownerHash: string, + typeHash: string): void => { + if (ownerHash === '') { + return; + } + let position = 0; + for (const decorator of ts.getDecorators(node as ts.HasDecorators) ?? []) { + const expression = unwrapParentheses(decorator.expression); + const callee = ts.isCallExpression(expression) + ? unwrapParentheses(expression.expression) + : expression; + const startPos = sf.getLineAndCharacterOfPosition(decorator.getStart(sf)); + const endPos = sf.getLineAndCharacterOfPosition(decorator.end); + const argumentsList = ts.isCallExpression(expression) ? expression.arguments : undefined; + + const row = new TsDecoratorRegistry({ + decoratorName: decoratorNameOf(callee), + kind: ts.isCallExpression(expression) + ? TsDecoratorKind.CALL + : ts.isPropertyAccessExpression(callee) + ? TsDecoratorKind.MEMBER_EXPRESSION + : ts.isElementAccessExpression(callee) + ? TsDecoratorKind.COMPUTED + : TsDecoratorKind.MARKER, + context, + ownerHash, + tsTypeLinkHash: typeHash, + // Application ORDER, and it is load-bearing: standard decorators apply + // bottom-up, so `@a @b` and `@b @a` are different facts. + position, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + argumentCount: argumentsList?.length ?? 0, + decoratorSystem: options.decoratorSystem, + // UNKNOWN, and honestly so. Whether a decorator REPLACES its target + // depends on whether its implementation returns a value, which is a + // property of the decorator FUNCTION and not of this application — and + // that function is usually in another package. Guessing from the use + // site would be a fact about code this parser has not read. + decoratorSemantics: TsDecoratorSemantics.UNKNOWN, + tsExpressionLinkHash: + options.expressionRowByNode.get(nodeId(expression, sf))?.getHash() ?? '', + tsModuleLinkHash: options.moduleHash, + startColumn: startPos.character + 1, + serviceVersionLinkHash: options.serviceVersionLinkHash, + }); + decorators.push(row); + + // The type the decorator NAMES — Java's ANNOTATION_TYPE. Links the usage + // to the declaration that implements it. + const decoratorTypeNode = synthesiseDecoratorTypeNode(callee); + if (decoratorTypeNode) { + options.typeReferenceExtractor.extract( + decoratorTypeNode, + TsTypeRefContext.DECORATOR_TYPE, + { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.DECORATOR, + tsTypeLinkHash: typeHash, + tsModuleLinkHash: options.moduleHash, + }, + false + ); + } + + let argumentPosition = 0; + for (const argument of argumentsList ?? []) { + emitArgument(argument, row, argumentPosition, decoratorArguments, options); + argumentPosition += 1; + } + position += 1; + } + }; + + const visit = (node: ts.Node): void => { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + emitFor(node, TsDecoratorContext.CLASS_DECLARATION, + options.typeHashByNode.get(nodeId(node, sf)) ?? '', + options.typeHashByNode.get(nodeId(node, sf)) ?? ''); + const ownerType = options.typeHashByNode.get(nodeId(node, sf)) ?? ''; + for (const member of node.members) { + const id = nodeId(member, sf); + if (ts.isPropertyDeclaration(member)) { + emitFor(member, + member.modifiers?.some((m) => m.kind === ts.SyntaxKind.AccessorKeyword) === true + ? TsDecoratorContext.AUTO_ACCESSOR + : TsDecoratorContext.FIELD_DECLARATION, + options.fieldHashByNode.get(id) ?? '', ownerType); + } else if (ts.isGetAccessor(member) || ts.isSetAccessor(member)) { + emitFor(member, TsDecoratorContext.ACCESSOR_DECLARATION, + options.methodHashByNode.get(id) ?? '', ownerType); + } else if (ts.isMethodDeclaration(member) || ts.isConstructorDeclaration(member)) { + emitFor(member, TsDecoratorContext.METHOD_DECLARATION, + options.methodHashByNode.get(id) ?? '', ownerType); + } + // Parameter decorators exist ONLY under experimentalDecorators. They are + // emitted whenever they are present rather than gated on the system + // token, because their presence is a syntactic fact and a mismatch + // between the two is exactly the kind of thing a fact base should be + // able to show rather than silently normalise. + const parameters = (member as { parameters?: ts.NodeArray }) + .parameters; + for (const parameter of parameters ?? []) { + emitFor(parameter, TsDecoratorContext.PARAMETER_DECLARATION, + options.parameterHashByNode.get(nodeId(parameter, sf)) ?? '', ownerType); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + + return { decorators, decoratorArguments }; +} + +function emitArgument( + argument: ts.Expression, + decorator: TsDecoratorRegistry, + position: number, + out: TsDecoratorArgumentRegistry[], + options: DecoratorExtractorOptions +): void { + const sf = options.sourceFile; + const startPos = sf.getLineAndCharacterOfPosition(argument.getStart(sf)); + const endPos = sf.getLineAndCharacterOfPosition(argument.end); + const expressionHash = + options.expressionRowByNode.get(nodeId(argument, sf))?.getHash() ?? ''; + + if (ts.isObjectLiteralExpression(argument)) { + // An options object is where framework configuration lives — `@Column({ + // type: "varchar", nullable: true })` — so each property becomes its own + // NAMED row rather than one opaque blob a rule would have to re-parse. + let index = 0; + for (const property of argument.properties) { + if (!ts.isPropertyAssignment(property)) { + index += 1; + continue; + } + const name = property.name.getText(sf).replace(/^["']|["']$/g, ''); + const value = property.initializer; + out.push(new TsDecoratorArgumentRegistry({ + argumentName: name, + argumentValue: EntityUtils.normalizeWhitespace(value.getText(sf)), + valueType: valueTypeOf(value), + position, + parentDecoratorHash: decorator.getHash(), + nestedObjectHash: '', + arrayIndex: index, + startLine: sf.getLineAndCharacterOfPosition(property.getStart(sf)).line + 1, + endLine: sf.getLineAndCharacterOfPosition(property.end).line + 1, + tsExpressionLinkHash: + options.expressionRowByNode.get(nodeId(value, sf))?.getHash() ?? '', + serviceVersionLinkHash: options.serviceVersionLinkHash, + })); + index += 1; + } + return; + } + + // A capitalised bare name in a decorator argument is the DI-token pattern — + // Java's ANNOTATION_PARAM. Emitting it into the type graph is what makes + // `@Inject(UserRepository)` joinable from the type side rather than only as + // free text in `argumentValue`. + if (ts.isIdentifier(argument) && /^[A-Z]/.test(argument.text)) { + const tokenType = synthesiseDecoratorTypeNode(argument); + if (tokenType) { + options.typeReferenceExtractor.extract( + tokenType, + TsTypeRefContext.DECORATOR_ARGUMENT_TYPE, + { + ownerHash: decorator.getHash(), + ownerKind: TsReferenceOwnerKind.DECORATOR, + tsTypeLinkHash: '', + tsModuleLinkHash: options.moduleHash, + }, + false + ); + } + } + + out.push(new TsDecoratorArgumentRegistry({ + argumentName: '', + argumentValue: EntityUtils.normalizeWhitespace(argument.getText(sf)), + valueType: valueTypeOf(argument), + position, + parentDecoratorHash: decorator.getHash(), + nestedObjectHash: '', + arrayIndex: 0, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + tsExpressionLinkHash: expressionHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + })); +} + +/** + * A type node over the name a decorator uses, borrowing its source range. + * + * The decorator's callee is an EXPRESSION, so there is no `TypeNode` in the tree + * to point at. The synthesised node reuses the original identifiers and their + * positions, so the emitted row names real source text rather than an empty + * string at offset zero. + */ +function synthesiseDecoratorTypeNode(callee: ts.Node): ts.TypeNode | undefined { + if (!ts.isIdentifier(callee) && !ts.isPropertyAccessExpression(callee)) { + return undefined; + } + const name = entityNameOf(callee); + if (!name) { + return undefined; + } + const node = ts.factory.createTypeReferenceNode(name, undefined) as ts.TypeNode & { + pos: number; end: number; parent: ts.Node; + }; + node.pos = callee.pos; + node.end = callee.end; + node.parent = callee.parent; + return node; +} + +function entityNameOf( + expression: ts.Identifier | ts.PropertyAccessExpression +): ts.EntityName | undefined { + if (ts.isIdentifier(expression)) { + return expression; + } + const left = unwrapParentheses(expression.expression); + if (!ts.isIdentifier(left) && !ts.isPropertyAccessExpression(left)) { + return undefined; + } + const qualifier = entityNameOf(left); + if (!qualifier || ts.isPrivateIdentifier(expression.name)) { + return undefined; + } + const qualified = ts.factory.createQualifiedName(qualifier, expression.name) as + ts.QualifiedName & { pos: number; end: number; parent: ts.Node }; + qualified.pos = expression.pos; + qualified.end = expression.end; + qualified.parent = expression.parent; + return qualified; +} + +function decoratorNameOf(callee: ts.Node): string { + if (ts.isIdentifier(callee)) { + return callee.text; + } + if (ts.isPropertyAccessExpression(callee)) { + return callee.name.text; + } + return ''; +} + +function valueTypeOf(node: ts.Expression): TsDecoratorArgumentValueType { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return TsDecoratorArgumentValueType.STRING; + } + if (ts.isNumericLiteral(node) || ts.isBigIntLiteral(node)) { + return TsDecoratorArgumentValueType.NUMBER; + } + if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) { + return TsDecoratorArgumentValueType.BOOLEAN; + } + if (node.kind === ts.SyntaxKind.NullKeyword) { + return TsDecoratorArgumentValueType.NULL; + } + if (ts.isObjectLiteralExpression(node)) { + return TsDecoratorArgumentValueType.OBJECT; + } + if (ts.isArrayLiteralExpression(node)) { + return TsDecoratorArgumentValueType.ARRAY; + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return TsDecoratorArgumentValueType.ARROW; + } + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + return TsDecoratorArgumentValueType.CALL; + } + if (ts.isTemplateExpression(node)) { + return TsDecoratorArgumentValueType.TEMPLATE; + } + if (ts.isIdentifier(node)) { + if (node.text === 'undefined') { + return TsDecoratorArgumentValueType.UNDEFINED; + } + // A bare capitalised identifier in a decorator argument is overwhelmingly a + // DI TOKEN — `@Inject(UserRepository)` — which is the pattern this column + // exists to surface. It is a heuristic and stays labelled as one: the + // `referencedTypeHash` FK is what carries the claim, and it is filled only + // when the name actually resolves to a type. + return /^[A-Z]/.test(node.text) + ? TsDecoratorArgumentValueType.CLASS_REFERENCE + : TsDecoratorArgumentValueType.IDENTIFIER; + } + if (ts.isPropertyAccessExpression(node)) { + return TsDecoratorArgumentValueType.IDENTIFIER; + } + return TsDecoratorArgumentValueType.UNKNOWN; +} diff --git a/parser/src/parsers/typescript/extractors/ts-export-extractor.ts b/parser/src/parsers/typescript/extractors/ts-export-extractor.ts new file mode 100644 index 000000000..b0ae6de08 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-export-extractor.ts @@ -0,0 +1,302 @@ +import * as ts from 'typescript'; + +import { TsExportRegistry } from '@/analysis-types/typescript/TsExportRegistry'; +import { TS_DEFAULT_EXPORT_NAME } from '@/constants/typescript-constants'; +import { TsExportedEntityKind, TsExportKind } from '@/enums/typescript/exports'; +import { hasModifier, nodeId } from '@/parsers/typescript/extractors/ts-binder'; + +/** + * Emits `ts_export` rows — schema §4.13. + * + * ## Why this relation is not optional + * + * A re-export chain is the ONLY path from an importer to the real declaration. + * `import { Thing } from "./index"` resolves to a barrel that declares nothing + * and forwards everything; without export rows, Path 2 stops there. 1,251 export + * declarations and 86 `export *` were measured, so barrels are the normal shape + * of a TypeScript package rather than an exception. + * + * ## Both forms of exporting, and they are different facts + * + * ```ts + * export class C { } // INLINE_DECLARATION — declaration and export in one + * class D { } + * export { D }; // NAMED_EXPORT — the declaration is elsewhere in the file + * export { E } from "./m"; // NAMED_EXPORT + isReExport — the declaration is in ANOTHER file + * ``` + * + * The first two point at a local declaration through `exportedEntityLinkHash`; + * the third points at a module through `resolvedSourceModuleLinkHash` and leaves + * the entity for the engine to find. Recording them alike would make a barrel + * look as though it declared its own exports. + */ +export interface ExportExtractorOptions { + readonly sourceFile: ts.SourceFile; + readonly tsModuleLinkHash: string; + readonly isDeclarationFile: boolean; + readonly serviceVersionLinkHash: string; + /** Declaration name -> its row hash and merge group, for a local export. */ + readonly declarationByName: ReadonlyMap< + string, + { hash: string; groupKey: string; kind: TsExportedEntityKind } + >; + readonly typeHashByNode: ReadonlyMap; + readonly methodHashByNode: ReadonlyMap; + readonly variableHashByNode: ReadonlyMap; + /** Queued so `export default compute()` can point at the expression it exports. */ + readonly pendingExpressionLinks: { node: ts.Node; link: (hash: string) => void }[]; +} + +export function extractExports(options: ExportExtractorOptions): TsExportRegistry[] { + const sf = options.sourceFile; + const out: TsExportRegistry[] = []; + let position = 0; + + const positionOf = (node: ts.Node): { startLine: number; startColumn: number; endLine: number } => { + const start = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + const end = sf.getLineAndCharacterOfPosition(node.end); + return { startLine: start.line + 1, startColumn: start.character + 1, endLine: end.line + 1 }; + }; + + const emit = (props: { + node: ts.Node; + exportedName: string; + localName: string; + exportKind: TsExportKind; + isTypeOnly: boolean; + isDefault: boolean; + sourceSpecifier: string; + entityKind: TsExportedEntityKind; + entityHash?: string; + entityGroupKey?: string; + }): TsExportRegistry => { + const where = positionOf(props.node); + const row = new TsExportRegistry({ + exportedName: props.exportedName, + localName: props.localName, + exportKind: props.exportKind, + isTypeOnly: props.isTypeOnly, + isDefault: props.isDefault, + isReExport: props.sourceSpecifier !== '', + sourceSpecifier: props.sourceSpecifier, + tsModuleLinkHash: options.tsModuleLinkHash, + exportedEntityKind: props.entityKind, + position, + startLine: where.startLine, + endLine: where.endLine, + startColumn: where.startColumn, + isAmbient: options.isDeclarationFile || hasModifier(props.node, ts.SyntaxKind.DeclareKeyword), + serviceVersionLinkHash: options.serviceVersionLinkHash, + }); + if (props.entityHash !== undefined && props.entityHash !== '') { + row.setExportedEntity(props.entityHash, props.entityGroupKey ?? ''); + } + out.push(row); + position += 1; + return row; + }; + + for (const statement of sf.statements) { + // `export class C` / `export function f` / `export const x` — declaration and + // export in one statement, so the entity is right here. + if (hasModifier(statement, ts.SyntaxKind.ExportKeyword)) { + emitInlineDeclaration(statement, emit, options, sf); + } + + if (ts.isExportDeclaration(statement)) { + const specifier = ts.isStringLiteral(statement.moduleSpecifier ?? sf) + ? (statement.moduleSpecifier as ts.StringLiteral).text + : ''; + const clause = statement.exportClause; + + if (!clause) { + // `export * from "./m"` — exports a set THIS ROW CANNOT NAME. The engine + // expands it by joining the source module's exports; 86 measured, so it + // is a real soundness surface rather than a curiosity. + emit({ + node: statement, + exportedName: '', + localName: '', + exportKind: statement.isTypeOnly ? TsExportKind.TYPE_ONLY_STAR : TsExportKind.EXPORT_STAR, + isTypeOnly: statement.isTypeOnly, + isDefault: false, + sourceSpecifier: specifier, + entityKind: TsExportedEntityKind.UNKNOWN, + }); + continue; + } + if (ts.isNamespaceExport(clause)) { + emit({ + node: statement, + exportedName: clause.name.text, + localName: '', + exportKind: TsExportKind.EXPORT_STAR_AS_NAMESPACE, + isTypeOnly: statement.isTypeOnly, + isDefault: false, + sourceSpecifier: specifier, + entityKind: TsExportedEntityKind.MODULE, + }); + continue; + } + for (const element of clause.elements) { + const localName = element.propertyName?.text ?? element.name.text; + const isDefault = element.name.text === TS_DEFAULT_EXPORT_NAME; + // A re-export names nothing local: the declaration is in the source + // module and only the chain reaches it. + const local = specifier === '' ? options.declarationByName.get(localName) : undefined; + emit({ + node: element, + exportedName: element.name.text, + localName, + exportKind: statement.isTypeOnly || element.isTypeOnly + ? TsExportKind.TYPE_ONLY_NAMED + : element.propertyName + ? TsExportKind.NAMED_ALIAS + : TsExportKind.NAMED_EXPORT, + isTypeOnly: statement.isTypeOnly || element.isTypeOnly === true, + isDefault, + sourceSpecifier: specifier, + entityKind: local?.kind ?? TsExportedEntityKind.UNKNOWN, + entityHash: local?.hash, + entityGroupKey: local?.groupKey, + }); + } + continue; + } + + if (ts.isExportAssignment(statement)) { + // `export = X` is the CommonJS whole-module form; `export default ` + // is an expression with no declaration to point at. + const isDefaultExport = statement.isExportEquals !== true; + const named = ts.isIdentifier(statement.expression) + ? options.declarationByName.get(statement.expression.text) + : undefined; + const assignment = emit({ + node: statement, + exportedName: isDefaultExport ? TS_DEFAULT_EXPORT_NAME : '', + localName: ts.isIdentifier(statement.expression) ? statement.expression.text : '', + exportKind: isDefaultExport + ? TsExportKind.DEFAULT_EXPRESSION + : TsExportKind.EXPORT_ASSIGNMENT, + isTypeOnly: false, + isDefault: isDefaultExport, + sourceSpecifier: '', + entityKind: named?.kind ?? TsExportedEntityKind.EXPRESSION, + entityHash: named?.hash, + entityGroupKey: named?.groupKey, + }); + // `export default compute()` exports the VALUE of an expression, so the + // expression is the only thing there is to point at. + options.pendingExpressionLinks.push({ + node: statement.expression, + link: (hash) => assignment.setTsExpressionLinkHash(hash), + }); + continue; + } + + if (ts.isImportEqualsDeclaration(statement) + && hasModifier(statement, ts.SyntaxKind.ExportKeyword)) { + emit({ + node: statement, + exportedName: statement.name.text, + localName: statement.name.text, + exportKind: TsExportKind.EXPORT_IMPORT_EQUALS, + isTypeOnly: statement.isTypeOnly, + isDefault: false, + sourceSpecifier: '', + entityKind: TsExportedEntityKind.UNKNOWN, + }); + } + } + return out; +} + +/** + * `export class C`, `export function f`, `export const x`, `export default …`. + * + * One statement may export several names — `export const a = 1, b = 2` — so a + * variable statement emits one row per declaration rather than one per statement. + */ +function emitInlineDeclaration( + statement: ts.Statement, + emit: (props: { + node: ts.Node; + exportedName: string; + localName: string; + exportKind: TsExportKind; + isTypeOnly: boolean; + isDefault: boolean; + sourceSpecifier: string; + entityKind: TsExportedEntityKind; + entityHash?: string; + entityGroupKey?: string; + }) => TsExportRegistry, + options: ExportExtractorOptions, + sf: ts.SourceFile +): void { + const isDefault = hasModifier(statement, ts.SyntaxKind.DefaultKeyword); + const kind = isDefault ? TsExportKind.DEFAULT_EXPORT : TsExportKind.INLINE_DECLARATION; + + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) { + continue; + } + emit({ + node: declaration, + exportedName: declaration.name.text, + localName: declaration.name.text, + exportKind: kind, + isTypeOnly: false, + isDefault, + sourceSpecifier: '', + entityKind: TsExportedEntityKind.VARIABLE, + entityHash: options.variableHashByNode.get(nodeId(declaration, sf)), + }); + } + return; + } + + const name = (statement as { name?: ts.Node }).name; + const named = name !== undefined && ts.isIdentifier(name) ? name : undefined; + const exportedName = isDefault + ? TS_DEFAULT_EXPORT_NAME + : named?.text ?? ''; + if (exportedName === '') { + return; + } + const id = nodeId(statement, sf); + const isFunction = ts.isFunctionDeclaration(statement); + emit({ + node: statement, + exportedName, + localName: named?.text ?? '', + exportKind: kind, + // `export interface` and `export type` have no runtime existence, so they + // must create no call-graph edge even though they are exported. + isTypeOnly: ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement), + isDefault, + sourceSpecifier: '', + entityKind: entityKindOf(statement), + entityHash: isFunction + ? options.methodHashByNode.get(id) + : options.typeHashByNode.get(id), + }); +} + +function entityKindOf(statement: ts.Statement): TsExportedEntityKind { + if (ts.isFunctionDeclaration(statement)) { + return TsExportedEntityKind.METHOD; + } + if (ts.isEnumDeclaration(statement)) { + return TsExportedEntityKind.ENUM; + } + if (ts.isModuleDeclaration(statement)) { + return TsExportedEntityKind.NAMESPACE; + } + if (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) + || ts.isTypeAliasDeclaration(statement)) { + return TsExportedEntityKind.TYPE; + } + return TsExportedEntityKind.UNKNOWN; +} diff --git a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts new file mode 100644 index 000000000..d5b054498 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts @@ -0,0 +1,977 @@ +import * as ts from 'typescript'; + +import { TsCallSiteRegistry } from '@/analysis-types/typescript/TsCallSiteRegistry'; +import { TsExpressionRegistry } from '@/analysis-types/typescript/TsExpressionRegistry'; +import { TS_EXPRESSION_MAX_DEPTH } from '@/constants/typescript-constants'; +import { TsCallKind, TsReceiverKind } from '@/enums/typescript/call-sites'; +import { + TsEdgeRole, + TsExpressionKind, + TsExpressionOwnerKind, + TsLiteralType, + TsRootContext, + TsUnaryFixity, +} from '@/enums/typescript/expressions'; +import { TsTypeRefContext, TsReferenceOwnerKind } from '@/enums/typescript/type-references'; +import { nodeId } from '@/parsers/typescript/extractors/ts-binder'; +import { TsTypeReferenceExtractor } from '@/parsers/typescript/extractors/ts-type-reference-extractor'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Emits `ts_expression` and `ts_call_site` — schema §4.14, §4.15. + * + * Positions 0–24 of `ts_expression` are byte-for-byte `java_expression` 0–24, so + * `expr_kind`, `expr_child` and `expr_owner` port as renames. + * + * ## Two passes, and the reason is invariant 8 + * + * Pass one drains a FIFO worklist — the shape ported from + * `expression-reference-extractor.ts` — emitting one row per expression node and + * recording node identity to row hash. Pass two walks the recorded nodes and + * emits one `ts_call_site` per CALL / NEW / TAGGED_TEMPLATE row. + * + * Splitting them is what makes invariant 8 — `ts_call_site` count equals the + * count of `ts_expression` rows of those three kinds — true by construction + * rather than by agreement between two branches. It also lets a call site name + * its RECEIVER, which is a grandchild: breadth-first, the receiver row does not + * exist yet when the call row is built, and back-patching a column that sits in + * no key would have worked but would have hidden the ordering dependency. + * + * ## What is deliberately NOT here + * + * No type node ever becomes an expression. `as T` and `satisfies T` put their + * type on `assertedTypeReferenceLinkHash`, which is a FK into + * `ts_type_reference` — the ONE expression-to-type edge in the schema, and a + * type FK precisely so that no call-graph rule can cross it (§3.3). + * + * `JSX_COMPONENT_CALL`, `JSX_ELEMENT`, `JSX_SELF_CLOSING`, + * `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` are reserved and emitted by nothing. + * TSX is out of freeze 1, the representation is decided (§4.15.1), and the gate + * asserts the emptiness so that switching TSX on shows up as a gate failure + * rather than as new rows appearing unremarked. + */ + +/** One enqueued child: the node plus the edge that reaches it. */ +interface PendingExpression { + readonly node: ts.Node; + readonly edgeRole: TsEdgeRole; + readonly parentHash: string; + readonly position: number; + readonly depth: number; + readonly owner: ExpressionOwner; + readonly rootContext: TsRootContext; +} + +export interface ExpressionOwner { + readonly ownerHash: string; + readonly ownerKind: TsExpressionOwnerKind; + readonly typeHash: string; + readonly moduleHash: string; + /** The enclosing function, for `ts_call_site.callerMethodLinkHash`. */ + readonly callerMethodHash: string; + readonly callerTypeHash: string; +} + +export interface ExpressionExtractorOptions { + readonly sourceFile: ts.SourceFile; + readonly serviceVersionLinkHash: string; + readonly typeReferenceExtractor: TsTypeReferenceExtractor; +} + +export class TsExpressionExtractor { + readonly expressions: TsExpressionRegistry[] = []; + readonly callSites: TsCallSiteRegistry[] = []; + /** Node identity -> emitted row, so no later pass re-derives a key. */ + readonly rowByNode = new Map(); + /** + * Every emitted row paired with its node, in emission order. + * + * The resolution pass needs to get from a ROW back to its NODE, and a map + * keyed by node identity cannot do that. Keeping the pair is the alternative + * to storing state on the node object, which is the failure mode this + * codebase has already paid for twice: wrapper caches evict and the loss is + * silent at scale. + */ + readonly emitted: { node: ts.Node; row: TsExpressionRegistry }[] = []; + /** Every call-shaped node that produced a row, in emission order. */ + private readonly callNodes: { node: ts.Node; owner: ExpressionOwner }[] = []; + /** Call node -> the emitted call-site row, so the resolution pass can fill columns 12-18. */ + readonly callSiteByNode = new Map(); + private readonly pending: PendingExpression[] = []; + private readonly sf: ts.SourceFile; + + constructor(private readonly options: ExpressionExtractorOptions) { + this.sf = options.sourceFile; + } + + /** Starts a new expression tree at `node` and drains the worklist. */ + extractRoot(node: ts.Expression, owner: ExpressionOwner, rootContext: TsRootContext): string { + const rootHash = this.emit({ + node, + edgeRole: TsEdgeRole.ROOT, + parentHash: '', + position: 0, + depth: 0, + owner, + rootContext, + }); + this.drain(); + return rootHash; + } + + private drain(): void { + while (this.pending.length > 0) { + const next = this.pending.shift(); + if (!next) { + continue; + } + this.emit(next); + } + } + + private emit(item: PendingExpression): string { + const kind = expressionKindOf(item.node); + if (kind === undefined) { + return ''; + } + const start = item.node.getStart(this.sf); + const startPos = this.sf.getLineAndCharacterOfPosition(start); + const endPos = this.sf.getLineAndCharacterOfPosition(item.node.end); + const literal = literalOf(item.node, this.sf); + const argumentsList = argumentsOf(item.node); + + const row = new TsExpressionRegistry({ + kind, + edgeRole: item.edgeRole, + rootContext: item.rootContext, + expressionOwnerKind: item.owner.ownerKind, + tsTypeLinkHash: item.owner.typeHash, + expressionOwnerHash: item.owner.ownerHash, + parentExpressionHash: item.parentHash, + position: item.position, + depth: item.depth, + literalType: literal.type, + literalValue: literal.value, + unaryFixity: unaryFixityOf(item.node), + operatorString: operatorOf(item.node, this.sf), + returnStatementIndex: 0, + startLine: startPos.line + 1, + startColumn: startPos.character + 1, + endLine: endPos.line + 1, + endColumn: endPos.character + 1, + tsModuleLinkHash: item.owner.moduleHash, + isOptionalChain: isOptionalChainNode(item.node), + isNonNullAsserted: ts.isNonNullExpression(item.node), + // Marks where positional argument flow is PROVABLY imprecise. A fact base + // that silently renumbers the arguments after a spread is wrong in a way + // nothing downstream can detect. + isSpread: ts.isSpreadElement(item.node) || ts.isSpreadAssignment(item.node), + argumentCount: argumentsList.length, + typeArgumentCount: typeArgumentsOf(item.node)?.length ?? 0, + // §3.3's tripwire. Always false on this path: a type node has no route + // into this relation, so a `true` row would mean the containment broke. + isTypeOnlyReachable: false, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.expressions.push(row); + this.rowByNode.set(nodeId(item.node, this.sf), row); + this.emitted.push({ node: item.node, row }); + if (kind === TsExpressionKind.CALL_EXPRESSION || kind === TsExpressionKind.NEW_EXPRESSION + || kind === TsExpressionKind.TAGGED_TEMPLATE) { + this.callNodes.push({ node: item.node, owner: item.owner }); + } + + // `as T` / `satisfies T` / `x` — the one place a type node hangs off an + // expression, and it hangs off it as a TYPE FK. + const asserted = assertedTypeOf(item.node); + if (asserted) { + row.setAssertedTypeReferenceLinkHash( + this.options.typeReferenceExtractor.extract( + asserted.type, + asserted.context, + { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.EXPRESSION, + tsTypeLinkHash: item.owner.typeHash, + tsModuleLinkHash: item.owner.moduleHash, + } + ) + ); + } + // ---- type references written in VALUE positions ----------------------- + // + // Java emits all of these and an earlier version of this parser emitted + // none, which was a recall gap rather than a design choice: `new Repo()`, + // `x instanceof Widget` and `makeList()` all NAME A TYPE, and the + // name is evaluated at runtime. They are the only contexts for which + // `isTypeOnlyPosition` is false. + const owner = { + ownerHash: row.getHash(), + ownerKind: TsReferenceOwnerKind.EXPRESSION, + tsTypeLinkHash: item.owner.typeHash, + tsModuleLinkHash: item.owner.moduleHash, + }; + + // `makeList()` — Java's METHOD_TYPE_ARGUMENT, distinguished from a + // type argument in a TYPE position because this one appears in an expression. + for (const typeArgument of typeArgumentsOf(item.node) ?? []) { + this.options.typeReferenceExtractor.extract( + typeArgument, + TsTypeRefContext.METHOD_TYPE_ARGUMENT, + owner, + false + ); + } + + // `new Repo()` — Java's OBJECT_CREATION_TYPE. The constructed type is named + // in the source, so it is a type reference AND a value reference. Without it + // the type graph has no edge for the single most common way a class is used. + if (ts.isNewExpression(item.node)) { + const constructed = constructedTypeNodeOf(item.node); + if (constructed) { + this.options.typeReferenceExtractor.extract( + constructed, + TsTypeRefContext.OBJECT_CREATION_TYPE, + owner, + false + ); + } + } + + // `x instanceof Widget` — Java's INSTANCEOF_TYPE, and the main narrowing + // lever the engine has. The right operand is a VALUE expression in the + // grammar (a constructor), and it names a type; both facts are recorded. + if (ts.isBinaryExpression(item.node) + && item.node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword) { + const tested = instanceofTypeNodeOf(item.node); + if (tested) { + this.options.typeReferenceExtractor.extract( + tested, + TsTypeRefContext.INSTANCEOF_TYPE, + owner, + false + ); + } + } + + if (item.depth >= TS_EXPRESSION_MAX_DEPTH) { + return row.getHash(); + } + for (const child of childEdgesOf(item.node)) { + this.pending.push({ + node: child.node, + edgeRole: child.edgeRole, + parentHash: row.getHash(), + position: child.position, + depth: item.depth + 1, + owner: item.owner, + rootContext: item.rootContext, + }); + } + return row.getHash(); + } + + /** + * Pass two: one `ts_call_site` per call-shaped expression row. + * + * Runs after every expression row exists, so a call site can name its + * receiver — which is its callee's child, and therefore its own grandchild. + */ + emitCallSites(): void { + for (const entry of this.callNodes) { + const row = this.rowByNode.get(nodeId(entry.node, this.sf)); + if (!row) { + continue; + } + const callee = calleeOf(entry.node); + const receiverNode = receiverNodeOf(callee); + const receiverRow = receiverNode + ? this.rowByNode.get(nodeId(receiverNode, this.sf)) + : undefined; + const argumentsList = argumentsOf(entry.node); + const spreadIndex = argumentsList.findIndex((a) => ts.isSpreadElement(a)); + const startPos = this.sf.getLineAndCharacterOfPosition(entry.node.getStart(this.sf)); + + const callSite = new TsCallSiteRegistry({ + callKind: callKindOf(entry.node, callee), + calleeName: calleeNameOf(callee), + receiverKind: receiverKindOf(callee, receiverNode), + receiverExpressionLinkHash: receiverRow?.getHash() ?? '', + // Filled by the resolution pass, which is the only place that can say + // what a receiver's DECLARED type is. + receiverTypeName: '', + tsExpressionLinkHash: row.getHash(), + tsModuleLinkHash: entry.owner.moduleHash, + callerMethodLinkHash: entry.owner.callerMethodHash, + callerTypeLinkHash: entry.owner.callerTypeHash, + argumentCount: argumentsList.length, + // Where this is set, positional argument flow is provably imprecise. + spreadArgumentIndex: spreadIndex >= 0 ? spreadIndex : undefined, + typeArgumentCount: typeArgumentsOf(entry.node)?.length ?? 0, + // MUST always be false. A true row means a type-only construct reached + // the call graph, and the gate fails on it by name. + isTypeOnlyTarget: false, + startLine: startPos.line + 1, + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.callSites.push(callSite); + this.callSiteByNode.set(nodeId(entry.node, this.sf), callSite); + } + } + + /** The call-shaped nodes, so the resolution pass can revisit them in emission order. */ + getCallNodes(): readonly { node: ts.Node; owner: ExpressionOwner }[] { + return this.callNodes; + } +} + +// --------------------------------------------------------------------------- +// node classification +// --------------------------------------------------------------------------- + +/** + * `undefined` means "not an expression this relation represents". + * + * Returning `undefined` rather than an `UNKNOWN` member is deliberate: an + * UNKNOWN row is a row, and rows are what rules join on. A node the schema does + * not model should produce nothing, not a row that means nothing. + */ +export function expressionKindOf(node: ts.Node): TsExpressionKind | undefined { + switch (node.kind) { + case ts.SyntaxKind.CallExpression: { + return TsExpressionKind.CALL_EXPRESSION; + } + case ts.SyntaxKind.NewExpression: { + return TsExpressionKind.NEW_EXPRESSION; + } + case ts.SyntaxKind.PropertyAccessExpression: { + return TsExpressionKind.PROPERTY_ACCESS; + } + case ts.SyntaxKind.ElementAccessExpression: { + return TsExpressionKind.ELEMENT_ACCESS; + } + case ts.SyntaxKind.Identifier: + case ts.SyntaxKind.PrivateIdentifier: { + return TsExpressionKind.IDENTIFIER_REFERENCE; + } + case ts.SyntaxKind.ThisKeyword: { + return TsExpressionKind.THIS_REFERENCE; + } + case ts.SyntaxKind.SuperKeyword: { + return TsExpressionKind.SUPER_REFERENCE; + } + case ts.SyntaxKind.StringLiteral: + case ts.SyntaxKind.NumericLiteral: + case ts.SyntaxKind.BigIntLiteral: + case ts.SyntaxKind.TrueKeyword: + case ts.SyntaxKind.FalseKeyword: + case ts.SyntaxKind.NullKeyword: + case ts.SyntaxKind.RegularExpressionLiteral: + case ts.SyntaxKind.NoSubstitutionTemplateLiteral: { + return TsExpressionKind.LITERAL; + } + case ts.SyntaxKind.TemplateExpression: { + return TsExpressionKind.TEMPLATE_EXPRESSION; + } + case ts.SyntaxKind.TaggedTemplateExpression: { + return TsExpressionKind.TAGGED_TEMPLATE; + } + case ts.SyntaxKind.ArrowFunction: { + return TsExpressionKind.ARROW_FUNCTION; + } + case ts.SyntaxKind.FunctionExpression: { + return TsExpressionKind.FUNCTION_EXPRESSION; + } + case ts.SyntaxKind.ClassExpression: { + return TsExpressionKind.CLASS_EXPRESSION; + } + case ts.SyntaxKind.ObjectLiteralExpression: { + return TsExpressionKind.OBJECT_LITERAL; + } + case ts.SyntaxKind.ArrayLiteralExpression: { + return TsExpressionKind.ARRAY_LITERAL; + } + case ts.SyntaxKind.BinaryExpression: { + const operator = (node as ts.BinaryExpression).operatorToken.kind; + if (operator === ts.SyntaxKind.EqualsToken) { + return TsExpressionKind.ASSIGNMENT_EXPRESSION; + } + if (COMPOUND_ASSIGNMENT_OPERATORS.has(operator)) { + return TsExpressionKind.COMPOUND_ASSIGNMENT; + } + if (operator === ts.SyntaxKind.CommaToken) { + return TsExpressionKind.SEQUENCE_EXPRESSION; + } + return TsExpressionKind.BINARY_EXPRESSION; + } + case ts.SyntaxKind.PrefixUnaryExpression: + case ts.SyntaxKind.PostfixUnaryExpression: { + return TsExpressionKind.UNARY_EXPRESSION; + } + case ts.SyntaxKind.ConditionalExpression: { + return TsExpressionKind.TERNARY_EXPRESSION; + } + case ts.SyntaxKind.AsExpression: { + return TsExpressionKind.AS_EXPRESSION; + } + case ts.SyntaxKind.SatisfiesExpression: { + return TsExpressionKind.SATISFIES_EXPRESSION; + } + case ts.SyntaxKind.TypeAssertionExpression: { + return TsExpressionKind.TYPE_ASSERTION; + } + case ts.SyntaxKind.NonNullExpression: { + return TsExpressionKind.NON_NULL_EXPRESSION; + } + case ts.SyntaxKind.AwaitExpression: { + return TsExpressionKind.AWAIT_EXPRESSION; + } + case ts.SyntaxKind.YieldExpression: { + return TsExpressionKind.YIELD_EXPRESSION; + } + case ts.SyntaxKind.SpreadElement: + case ts.SyntaxKind.SpreadAssignment: { + return TsExpressionKind.SPREAD_ELEMENT; + } + case ts.SyntaxKind.DeleteExpression: + case ts.SyntaxKind.TypeOfExpression: + case ts.SyntaxKind.VoidExpression: { + return TsExpressionKind.DELETE_TYPEOF_VOID; + } + case ts.SyntaxKind.ParenthesizedExpression: { + // A parenthesised expression is not a fact — it is punctuation. Emitting + // a row for it would put a node between a call and its receiver that no + // resolution rule expects, so it is transparent and its operand takes + // its place. + return undefined; + } + default: { + return undefined; + } + } +} + +const COMPOUND_ASSIGNMENT_OPERATORS = new Set([ + ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, ts.SyntaxKind.CaretEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.AmpersandAmpersandEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, +]); + +interface ChildEdge { + readonly node: ts.Node; + readonly edgeRole: TsEdgeRole; + readonly position: number; +} + +/** + * The child edges of an expression, in SOURCE order with their roles. + * + * Parentheses are unwrapped on the way through, so `(a).b()` has the same shape + * as `a.b()`. That is not cosmetic: `receiverKind` dispatches on the receiver's + * shape, and a `PARENTHESIZED` receiver that is really an identifier would fall + * out of the resolvable set for no semantic reason. + */ +function childEdgesOf(node: ts.Node): ChildEdge[] { + const out: ChildEdge[] = []; + const push = (child: ts.Node | undefined, edgeRole: TsEdgeRole, position: number): void => { + if (child) { + out.push({ node: unwrapParentheses(child), edgeRole, position }); + } + }; + + if (ts.isCallExpression(node)) { + push(node.expression, TsEdgeRole.METHOD_NAME, 0); + let index = 0; + for (const argument of node.arguments) { + push(argument, TsEdgeRole.ARGUMENT, index); + index += 1; + } + return out; + } + if (ts.isNewExpression(node)) { + push(node.expression, TsEdgeRole.METHOD_NAME, 0); + let index = 0; + for (const argument of node.arguments ?? []) { + push(argument, TsEdgeRole.ARGUMENT, index); + index += 1; + } + return out; + } + if (ts.isTaggedTemplateExpression(node)) { + push(node.tag, TsEdgeRole.TAG_EXPRESSION, 0); + if (ts.isTemplateExpression(node.template)) { + let index = 0; + for (const span of node.template.templateSpans) { + push(span.expression, TsEdgeRole.TEMPLATE_SPAN, index); + index += 1; + } + } + return out; + } + if (ts.isPropertyAccessExpression(node)) { + push(node.expression, TsEdgeRole.RECEIVER, 0); + push(node.name, TsEdgeRole.PROPERTY_NAME, 0); + return out; + } + if (ts.isElementAccessExpression(node)) { + push(node.expression, TsEdgeRole.RECEIVER, 0); + push(node.argumentExpression, TsEdgeRole.INDEX_ARGUMENT, 0); + return out; + } + if (ts.isBinaryExpression(node)) { + push(node.left, TsEdgeRole.LEFT_OPERAND, 0); + push(node.right, TsEdgeRole.RIGHT_OPERAND, 0); + return out; + } + if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { + push(node.operand, TsEdgeRole.UNARY_OPERAND, 0); + return out; + } + if (ts.isConditionalExpression(node)) { + push(node.condition, TsEdgeRole.TERNARY_CONDITION, 0); + push(node.whenTrue, TsEdgeRole.TERNARY_THEN, 0); + push(node.whenFalse, TsEdgeRole.TERNARY_ELSE, 0); + return out; + } + if (ts.isAsExpression(node)) { + push(node.expression, TsEdgeRole.AS_OPERAND, 0); + return out; + } + if (ts.isSatisfiesExpression(node)) { + push(node.expression, TsEdgeRole.SATISFIES_OPERAND, 0); + return out; + } + if (ts.isTypeAssertionExpression(node)) { + push(node.expression, TsEdgeRole.AS_OPERAND, 0); + return out; + } + if (ts.isNonNullExpression(node) || ts.isAwaitExpression(node) || ts.isYieldExpression(node) + || ts.isDeleteExpression(node) || ts.isTypeOfExpression(node) || ts.isVoidExpression(node)) { + push((node as { expression?: ts.Expression }).expression, TsEdgeRole.UNARY_OPERAND, 0); + return out; + } + if (ts.isSpreadElement(node) || ts.isSpreadAssignment(node)) { + push(node.expression, TsEdgeRole.SPREAD_OPERAND, 0); + return out; + } + if (ts.isTemplateExpression(node)) { + let index = 0; + for (const span of node.templateSpans) { + push(span.expression, TsEdgeRole.TEMPLATE_SPAN, index); + index += 1; + } + return out; + } + if (ts.isArrayLiteralExpression(node)) { + let index = 0; + for (const element of node.elements) { + if (!ts.isOmittedExpression(element)) { + push(element, TsEdgeRole.ARRAY_ELEMENT, index); + } + index += 1; + } + return out; + } + /** + * The key of an object-literal property, when syntax alone names it. + * + * A COMPUTED key is skipped rather than guessed: `{ [k]: 1 }` needs the value + * of `k`, and the walker already reaches that expression through + * `COMPUTED_PROPERTY_NAME`. Emitting nothing leaves the value row standing + * alone at its position, which is how a consumer sees "dynamic" rather than + * "absent". + */ + const pushStaticKey = (name: ts.PropertyName, index: number): void => { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + push(name, TsEdgeRole.OBJECT_PROPERTY_KEY, index); + } + }; + + if (ts.isObjectLiteralExpression(node)) { + let index = 0; + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + pushStaticKey(property.name, index); + push(property.initializer, TsEdgeRole.OBJECT_PROPERTY_VALUE, index); + } else if (ts.isShorthandPropertyAssignment(property)) { + // `{ method }` is a key AND a value reference. Both rows, same + // position: the value binds to the variable, the key does not. + pushStaticKey(property.name, index); + push(property.name, TsEdgeRole.OBJECT_PROPERTY_VALUE, index); + } else if (ts.isSpreadAssignment(property)) { + push(property, TsEdgeRole.SPREAD_OPERAND, index); + } + index += 1; + } + return out; + } + // Arrow and function expressions have a `ts_method` row of their own, and + // their bodies are extracted under that owner. Descending here would emit + // every statement of the body twice, attributed to the wrong owner. + return out; +} + +/** Parentheses are punctuation, not structure. */ +export function unwrapParentheses(node: ts.Node): ts.Node { + let current = node; + while (ts.isParenthesizedExpression(current)) { + current = current.expression; + } + return current; +} + +function argumentsOf(node: ts.Node): readonly ts.Expression[] { + if (ts.isCallExpression(node)) { + return node.arguments; + } + if (ts.isNewExpression(node)) { + return node.arguments ?? []; + } + return []; +} + +/** + * The type node a `new` expression constructs, synthesised from its callee. + * + * `new Repo()` has no `TypeNode` in the tree — the callee is an EXPRESSION, and + * `ts.factory` is the only way to obtain a type node for it. The synthesised node + * is given the callee's position so the emitted row points at the source text + * that named the type, not at a position of nothing. + * + * Returns nothing for a computed callee (`new registry[name]()`): there is no + * name to record, and inventing one would be a claim about a value the parser + * cannot read. + */ +function constructedTypeNodeOf(node: ts.NewExpression): ts.TypeNode | undefined { + const callee = unwrapParentheses(node.expression); + if (!ts.isIdentifier(callee) && !ts.isPropertyAccessExpression(callee)) { + return undefined; + } + return synthesiseTypeReference(callee); +} + +/** The type node tested by `x instanceof C`, synthesised from the right operand. */ +function instanceofTypeNodeOf(node: ts.BinaryExpression): ts.TypeNode | undefined { + const right = unwrapParentheses(node.right); + if (!ts.isIdentifier(right) && !ts.isPropertyAccessExpression(right)) { + return undefined; + } + return synthesiseTypeReference(right); +} + +/** + * Builds a `TypeReferenceNode` over an existing name expression. + * + * The synthesised node borrows the source node's `pos`/`end` and parent so that + * `getStart`, `getText` and `getLineAndCharacterOfPosition` all answer about the + * REAL source range. Without that the row would carry position 0 and text `""`, + * which is worse than not emitting it: a row that names nothing still joins. + */ +function synthesiseTypeReference( + name: ts.Identifier | ts.PropertyAccessExpression +): ts.TypeNode | undefined { + const entityName = toEntityName(name); + if (!entityName) { + return undefined; + } + const node = ts.factory.createTypeReferenceNode(entityName, undefined) as ts.TypeNode & { + pos: number; end: number; parent: ts.Node; + }; + node.pos = name.pos; + node.end = name.end; + node.parent = name.parent; + return node; +} + +/** `a.b.C` as an entity name, reusing the ORIGINAL identifier nodes so text survives. */ +function toEntityName( + expression: ts.Identifier | ts.PropertyAccessExpression +): ts.EntityName | undefined { + if (ts.isIdentifier(expression)) { + return expression; + } + const left = unwrapParentheses(expression.expression); + if (!ts.isIdentifier(left) && !ts.isPropertyAccessExpression(left)) { + return undefined; + } + const qualifier = toEntityName(left); + if (!qualifier || ts.isPrivateIdentifier(expression.name)) { + return undefined; + } + const qualified = ts.factory.createQualifiedName(qualifier, expression.name) as + ts.QualifiedName & { pos: number; end: number; parent: ts.Node }; + qualified.pos = expression.pos; + qualified.end = expression.end; + qualified.parent = expression.parent; + return qualified; +} + +function typeArgumentsOf(node: ts.Node): readonly ts.TypeNode[] | undefined { + if (ts.isCallExpression(node) || ts.isNewExpression(node) + || ts.isTaggedTemplateExpression(node)) { + return node.typeArguments; + } + return undefined; +} + +function assertedTypeOf( + node: ts.Node +): { type: ts.TypeNode; context: TsTypeRefContext } | undefined { + if (ts.isAsExpression(node)) { + return { type: node.type, context: TsTypeRefContext.AS_TARGET }; + } + if (ts.isSatisfiesExpression(node)) { + return { type: node.type, context: TsTypeRefContext.SATISFIES_TARGET }; + } + if (ts.isTypeAssertionExpression(node)) { + return { type: node.type, context: TsTypeRefContext.TYPE_ASSERTION }; + } + return undefined; +} + +function isOptionalChainNode(node: ts.Node): boolean { + const questionDot = (node as { questionDotToken?: ts.QuestionDotToken }).questionDotToken; + return questionDot !== undefined; +} + +function literalOf(node: ts.Node, sourceFile: ts.SourceFile): { type: string; value: string } { + switch (node.kind) { + case ts.SyntaxKind.StringLiteral: { + return { type: TsLiteralType.STRING, value: (node as ts.StringLiteral).text }; + } + case ts.SyntaxKind.NumericLiteral: { + return { type: TsLiteralType.NUMBER, value: (node as ts.NumericLiteral).text }; + } + case ts.SyntaxKind.BigIntLiteral: { + return { type: TsLiteralType.BIGINT, value: (node as ts.BigIntLiteral).text }; + } + case ts.SyntaxKind.TrueKeyword: { + return { type: TsLiteralType.BOOLEAN, value: 'true' }; + } + case ts.SyntaxKind.FalseKeyword: { + return { type: TsLiteralType.BOOLEAN, value: 'false' }; + } + case ts.SyntaxKind.NullKeyword: { + return { type: TsLiteralType.NULL, value: 'null' }; + } + case ts.SyntaxKind.RegularExpressionLiteral: { + return { + type: TsLiteralType.REGEX, + value: (node as ts.RegularExpressionLiteral).text, + }; + } + case ts.SyntaxKind.NoSubstitutionTemplateLiteral: { + return { + type: TsLiteralType.NO_SUBSTITUTION_TEMPLATE, + value: (node as ts.NoSubstitutionTemplateLiteral).text, + }; + } + case ts.SyntaxKind.TemplateExpression: { + return { + type: TsLiteralType.TEMPLATE, + value: EntityUtils.normalizeWhitespace(node.getText(sourceFile)), + }; + } + case ts.SyntaxKind.Identifier: { + // `undefined` is an identifier in the grammar and a literal in practice. + // Recording it as a literal is what lets an optionality rule see it. + return (node as ts.Identifier).text === 'undefined' + ? { type: TsLiteralType.UNDEFINED, value: 'undefined' } + : { type: '', value: (node as ts.Identifier).text }; + } + case ts.SyntaxKind.PrivateIdentifier: { + return { type: '', value: (node as ts.PrivateIdentifier).text }; + } + default: { + return { type: '', value: '' }; + } + } +} + +function unaryFixityOf(node: ts.Node): string { + if (ts.isPrefixUnaryExpression(node)) { + return TsUnaryFixity.PREFIX; + } + if (ts.isPostfixUnaryExpression(node)) { + return TsUnaryFixity.POSTFIX; + } + return ''; +} + +function operatorOf(node: ts.Node, sourceFile: ts.SourceFile): string { + if (ts.isBinaryExpression(node)) { + return node.operatorToken.getText(sourceFile); + } + if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { + return ts.tokenToString(node.operator) ?? ''; + } + if (ts.isDeleteExpression(node)) { + return 'delete'; + } + if (ts.isTypeOfExpression(node)) { + return 'typeof'; + } + if (ts.isVoidExpression(node)) { + return 'void'; + } + return ''; +} + +// --------------------------------------------------------------------------- +// call-site shape +// --------------------------------------------------------------------------- + +/** The callee of a call-shaped node, parentheses unwrapped. */ +export function calleeOf(node: ts.Node): ts.Node | undefined { + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + return unwrapParentheses(node.expression); + } + if (ts.isTaggedTemplateExpression(node)) { + return unwrapParentheses(node.tag); + } + return undefined; +} + +/** The receiver of a callee, or `undefined` for an unqualified call. */ +export function receiverNodeOf(callee: ts.Node | undefined): ts.Node | undefined { + if (!callee) { + return undefined; + } + if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + return unwrapParentheses(callee.expression); + } + return undefined; +} + +export function calleeNameOf(callee: ts.Node | undefined): string { + if (!callee) { + return ''; + } + if (ts.isIdentifier(callee)) { + return callee.text; + } + if (ts.isPropertyAccessExpression(callee)) { + return ts.isPrivateIdentifier(callee.name) ? callee.name.text : callee.name.text; + } + if (ts.isElementAccessExpression(callee)) { + // A computed callee has no simple name unless the index is a string + // literal. Guessing one from the source text would make `""` — the honest + // "computed callee" marker — indistinguishable from a real name. + const argument = callee.argumentExpression; + return ts.isStringLiteral(argument) ? argument.text : ''; + } + if (callee.kind === ts.SyntaxKind.SuperKeyword) { + return 'super'; + } + return ''; +} + +/** + * Whether this call IS the decorator, rather than a call inside one. + * + * `@Get("/x")` runs at class-definition time, which is the fact the kind + * carries; `@Foo(bar())` contains an ordinary call as an argument, and that one + * is not a decorator call. So only the IMMEDIATE parent counts -- parentheses + * unwrapped, since `@(record("x"))` has been legal since TypeScript 5.0. + */ +function isDecoratorCall(node: ts.Node): boolean { + let current: ts.Node | undefined = node.parent; + while (current !== undefined && ts.isParenthesizedExpression(current)) { + current = current.parent; + } + return current !== undefined && ts.isDecorator(current); +} + +function callKindOf(node: ts.Node, callee: ts.Node | undefined): TsCallKind { + if (ts.isNewExpression(node)) { + return TsCallKind.CONSTRUCTOR_CALL; + } + // Checked before the callee shape, because `@a.b.Get("/x")` is a decorator + // call first and a property-access callee second. Which one wins decides + // whether an engine sees a call that happens at class-definition time. + if (isDecoratorCall(node)) { + return TsCallKind.DECORATOR_CALL; + } + if (ts.isTaggedTemplateExpression(node)) { + return TsCallKind.TAGGED_TEMPLATE_CALL; + } + if (callee && callee.kind === ts.SyntaxKind.SuperKeyword) { + return TsCallKind.SUPER_CALL; + } + if (callee && callee.kind === ts.SyntaxKind.ImportKeyword) { + return TsCallKind.DYNAMIC_IMPORT_CALL; + } + // Optional-call is its own kind rather than a flag, because `a?.b()` and + // `a.b()` differ in nullability but NOT in target, and a rule that treats + // them alike is right about the target and wrong about reachability. + if (isOptionalChainNode(node)) { + return TsCallKind.OPTIONAL_CALL; + } + // An element-access callee is still a METHOD CALL: `ops[name](a, b)` calls a + // member, and the only thing the brackets change is that the member name is + // computed. INDEX_CALL is reserved for a call THROUGH AN INDEX SIGNATURE, + // which is a fact about the receiver's TYPE and therefore a resolution + // outcome — syntax cannot tell the two apart, so syntax does not try. + if (callee && (ts.isPropertyAccessExpression(callee) + || ts.isElementAccessExpression(callee))) { + return TsCallKind.METHOD_CALL; + } + return TsCallKind.FUNCTION_CALL; +} + +/** + * The SHAPE of the receiver, which is what resolution dispatches on. + * + * Reported precisely rather than collapsed to a boolean, because the resolution + * rate per receiver shape is the number that tells a working parser from one + * that resolves only the easy half. + */ +function receiverKindOf( + callee: ts.Node | undefined, + receiver: ts.Node | undefined +): TsReceiverKind { + if (callee && callee.kind === ts.SyntaxKind.SuperKeyword) { + return TsReceiverKind.SUPER; + } + if (!receiver) { + return TsReceiverKind.NONE; + } + if (receiver.kind === ts.SyntaxKind.ThisKeyword) { + return TsReceiverKind.THIS; + } + if (receiver.kind === ts.SyntaxKind.SuperKeyword) { + return TsReceiverKind.SUPER; + } + if (ts.isIdentifier(receiver)) { + return TsReceiverKind.IDENTIFIER; + } + if (ts.isPropertyAccessExpression(receiver)) { + return TsReceiverKind.PROPERTY_CHAIN; + } + if (ts.isCallExpression(receiver) || ts.isNewExpression(receiver)) { + return TsReceiverKind.CALL_RESULT; + } + if (ts.isElementAccessExpression(receiver)) { + return TsReceiverKind.ELEMENT_ACCESS; + } + if (ts.isNonNullExpression(receiver)) { + return TsReceiverKind.NON_NULL; + } + if (ts.isAsExpression(receiver) || ts.isSatisfiesExpression(receiver) + || ts.isTypeAssertionExpression(receiver)) { + return TsReceiverKind.AS_EXPRESSION; + } + if (ts.isAwaitExpression(receiver)) { + return TsReceiverKind.AWAIT_RESULT; + } + if (ts.isParenthesizedExpression(receiver)) { + return TsReceiverKind.PARENTHESIZED; + } + return TsReceiverKind.UNKNOWN; +} diff --git a/parser/src/parsers/typescript/extractors/ts-expression-walker.ts b/parser/src/parsers/typescript/extractors/ts-expression-walker.ts new file mode 100644 index 000000000..bb653b628 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-expression-walker.ts @@ -0,0 +1,518 @@ +import * as ts from 'typescript'; + +import { + ExpressionOwner, + TsExpressionExtractor, +} from '@/parsers/typescript/extractors/ts-expression-extractor'; +import { TsExpressionOwnerKind, TsRootContext } from '@/enums/typescript/expressions'; +import { nodeId } from '@/parsers/typescript/extractors/ts-binder'; + +/** Parentheses are punctuation. A tree must never be rooted at one. */ +function unwrapParenthesesExpression(node: ts.Expression): ts.Expression { + let current = node; + while (ts.isParenthesizedExpression(current)) { + current = current.expression; + } + return current; +} + +/** + * Finds the EXPRESSION POSITIONS in a file and starts one tree at each. + * + * ## Why this is an explicit allowlist and not a generic tree walk + * + * A generic walk that asks "is this node an expression?" is wrong in this + * language, and wrong in the direction that fills the call graph with phantom + * edges. `ts.forEachChild` descends into type annotations, and a + * `TypeReferenceNode`'s `typeName` is an `Identifier` — so a generic walk emits + * an `IDENTIFIER_REFERENCE` row for every type name in the file. So does the + * NAME of every declaration, and so does every `implements` clause entry. + * + * That is precisely the failure §3.3 exists to prevent, and it would not show + * up as an error anywhere: the rows are well-formed, the counts look plausible, + * and the call graph quietly acquires targets that have no runtime existence. + * + * So expression positions are enumerated, one statement kind at a time. The + * cost is a walk that has to be extended when a statement kind is added; the + * benefit is that a type node cannot reach `ts_expression` by accident, only by + * someone writing it down. + * + * ## The one heritage subtlety + * + * `class C extends B` evaluates `B` **at runtime** — it is a real value + * reference and a real edge. `class C implements I` and `interface X extends Y` + * evaluate nothing. So the extends clause of a CLASS produces an expression and + * the other two produce none, which is the same distinction + * `ts_type_heritage.inheritsMembers` draws one relation over. + */ +export interface ExpressionWalkerOptions { + readonly sourceFile: ts.SourceFile; + readonly extractor: TsExpressionExtractor; + readonly moduleHash: string; + readonly moduleInitMethodHash: string; + readonly moduleHashForNode: (node: ts.Node) => string; + readonly methodHashByNode: ReadonlyMap; + readonly typeHashByNode: ReadonlyMap; + readonly blockHashByNode: ReadonlyMap; + readonly variableHashByNode: ReadonlyMap; + readonly fieldHashByNode: ReadonlyMap; + readonly parameterHashByNode: ReadonlyMap; +} + +export class TsExpressionWalker { + /** + * Node identity -> the hash of the expression tree ROOTED at it. + * + * The schema defines nine FKs from a declaration to an expression — a + * variable's initializer, a field's, a parameter default, a block's guard, a + * mixin base, an enum member's value, `export default `, a dynamic + * import. Each one is a chain an engine can follow and every one of them was + * empty, because the declaration pass runs BEFORE expressions exist and had no + * way to learn the hash afterwards. This map is that way. + */ + readonly rootHashByNode = new Map(); + private readonly sf: ts.SourceFile; + + constructor(private readonly options: ExpressionWalkerOptions) { + this.sf = options.sourceFile; + } + + run(): void { + for (const statement of this.sf.statements) { + this.visitStatement(statement); + } + this.options.extractor.emitCallSites(); + } + + // ------------------------------------------------------------------------- + // owner derivation + // ------------------------------------------------------------------------- + + /** + * Who owns the expression tree rooted at `node`. + * + * Derived by walking ANCESTORS against the hashes the declaration pass + * recorded, rather than threaded through the walk. Threading it means two + * walks that must agree about context, and when they drift the expressions + * are attributed to the wrong owner — which reads as a resolution failure + * rather than as an attribution bug. + */ + private ownerFor(node: ts.Node): ExpressionOwner { + let callerMethodHash = ''; + let callerTypeHash = ''; + let ownerHash = ''; + let ownerKind: TsExpressionOwnerKind | undefined; + + let current: ts.Node | undefined = node.parent; + while (current) { + const id = nodeId(current, this.sf); + + if (ownerKind === undefined) { + if (ts.isVariableDeclaration(current)) { + const hash = this.options.variableHashByNode.get(id); + if (hash) { + ownerHash = hash; + ownerKind = TsExpressionOwnerKind.VARIABLE; + } + } else if (ts.isPropertyDeclaration(current) || ts.isPropertySignature(current)) { + const hash = this.options.fieldHashByNode.get(id); + if (hash) { + ownerHash = hash; + ownerKind = TsExpressionOwnerKind.FIELD; + } + } else if (ts.isParameter(current)) { + const hash = this.options.parameterHashByNode.get(id); + if (hash) { + ownerHash = hash; + ownerKind = TsExpressionOwnerKind.PARAMETER_DEFAULT; + } + } else if (ts.isDecorator(current)) { + ownerKind = TsExpressionOwnerKind.DECORATOR; + } else if (ts.isEnumMember(current)) { + ownerKind = TsExpressionOwnerKind.ENUM_MEMBER; + } else if (ts.isExportAssignment(current)) { + ownerKind = TsExpressionOwnerKind.EXPORT; + } else { + const blockHash = this.options.blockHashByNode.get(id); + if (blockHash) { + ownerHash = blockHash; + ownerKind = TsExpressionOwnerKind.BLOCK; + } + } + } + + if (callerMethodHash === '') { + const methodHash = this.options.methodHashByNode.get(id); + if (methodHash) { + callerMethodHash = methodHash; + } + } + if (callerTypeHash === '' && (ts.isClassLike(current) || ts.isInterfaceDeclaration(current))) { + callerTypeHash = this.options.typeHashByNode.get(id) ?? ''; + } + current = current.parent; + } + + // Top-level executable code belongs to the synthetic `` initializer. + // Every call site has a caller; there is no such thing as an orphan call. + if (callerMethodHash === '') { + callerMethodHash = this.options.moduleInitMethodHash; + } + if (ownerKind === undefined) { + ownerHash = callerMethodHash; + ownerKind = callerMethodHash === this.options.moduleInitMethodHash + ? TsExpressionOwnerKind.MODULE_INIT + : TsExpressionOwnerKind.METHOD; + } + if (ownerHash === '') { + ownerHash = callerMethodHash; + } + return { + ownerHash, + ownerKind, + typeHash: callerTypeHash, + moduleHash: this.options.moduleHashForNode(node), + callerMethodHash, + callerTypeHash, + }; + } + + /** + * Starts a tree at `node` and then follows into any nested function bodies. + * + * The descent is not optional and forgetting it is silent. The worklist stops + * at an arrow or function expression — the body belongs to that function's own + * `ts_method` row, not to the enclosing tree — so without a second step every + * call inside `return function () { … }` or `return () => { … }` vanishes. It + * cost 45 of 691 call sites on the fixture corpus, all of them in decorator + * factories, and nothing about the output looked wrong: the rows that were + * emitted were correct, there were just fewer of them. + */ + private root(nodeIn: ts.Expression | undefined, rootContext: TsRootContext): void { + if (!nodeIn) { + return; + } + // UNWRAP FIRST. Parentheses produce no expression row — they are + // punctuation — so a tree rooted at one is rooted at nothing and dies + // before its children are enqueued. `return ( a && b.c() )`, `if ((x))` + // and `const y = (f())` all lost their ENTIRE tree, which is why the + // decorator path already unwrapped explicitly. Doing it here makes that + // special case unnecessary and closes every other position at once. + const node = unwrapParenthesesExpression(nodeIn); + const hash = this.options.extractor.extractRoot(node, this.ownerFor(node), rootContext); + this.rootHashByNode.set(nodeId(node, this.sf), hash); + this.descend(node); + } + + /** Follows a node into the function and class bodies the worklist did not enter. */ + private descend(node: ts.Node): void { + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + this.visitFunctionLike(node); + return; + } + if (ts.isClassExpression(node)) { + this.visitClassLike(node); + return; + } + this.visitNestedFunctions(node); + } + + // ------------------------------------------------------------------------- + // statements + // ------------------------------------------------------------------------- + + private visitStatement(node: ts.Statement): void { + switch (node.kind) { + case ts.SyntaxKind.ExpressionStatement: { + this.root((node as ts.ExpressionStatement).expression, + TsRootContext.EXPRESSION_STATEMENT); + return; + } + case ts.SyntaxKind.VariableStatement: { + for (const declaration of (node as ts.VariableStatement).declarationList.declarations) { + this.visitVariableDeclaration(declaration); + } + return; + } + case ts.SyntaxKind.ReturnStatement: { + this.root((node as ts.ReturnStatement).expression, TsRootContext.RETURN_VALUE); + return; + } + case ts.SyntaxKind.ThrowStatement: { + this.root((node as ts.ThrowStatement).expression, TsRootContext.THROW_VALUE); + return; + } + case ts.SyntaxKind.IfStatement: { + const statement = node as ts.IfStatement; + this.root(statement.expression, TsRootContext.CONDITION); + this.visitStatement(statement.thenStatement); + if (statement.elseStatement) { + this.visitStatement(statement.elseStatement); + } + return; + } + case ts.SyntaxKind.Block: { + for (const statement of (node as ts.Block).statements) { + this.visitStatement(statement); + } + return; + } + case ts.SyntaxKind.ForStatement: { + const statement = node as ts.ForStatement; + if (statement.initializer) { + if (ts.isVariableDeclarationList(statement.initializer)) { + for (const declaration of statement.initializer.declarations) { + this.visitVariableDeclaration(declaration); + } + } else { + this.root(statement.initializer, TsRootContext.LOOP_HEADER); + } + } + this.root(statement.condition, TsRootContext.CONDITION); + this.root(statement.incrementor, TsRootContext.LOOP_HEADER); + this.visitStatement(statement.statement); + return; + } + case ts.SyntaxKind.ForInStatement: + case ts.SyntaxKind.ForOfStatement: { + const statement = node as ts.ForInStatement | ts.ForOfStatement; + if (ts.isVariableDeclarationList(statement.initializer)) { + for (const declaration of statement.initializer.declarations) { + this.visitVariableDeclaration(declaration); + } + } else { + this.root(statement.initializer, TsRootContext.LOOP_HEADER); + } + this.root(statement.expression, TsRootContext.LOOP_HEADER); + this.visitStatement(statement.statement); + return; + } + case ts.SyntaxKind.WhileStatement: + case ts.SyntaxKind.DoStatement: { + const statement = node as ts.WhileStatement | ts.DoStatement; + this.root(statement.expression, TsRootContext.CONDITION); + this.visitStatement(statement.statement); + return; + } + case ts.SyntaxKind.SwitchStatement: { + const statement = node as ts.SwitchStatement; + this.root(statement.expression, TsRootContext.SWITCH_SUBJECT); + for (const clause of statement.caseBlock.clauses) { + if (ts.isCaseClause(clause)) { + this.root(clause.expression, TsRootContext.CASE_LABEL); + } + for (const inner of clause.statements) { + this.visitStatement(inner); + } + } + return; + } + case ts.SyntaxKind.TryStatement: { + const statement = node as ts.TryStatement; + this.visitStatement(statement.tryBlock); + if (statement.catchClause) { + this.visitStatement(statement.catchClause.block); + } + if (statement.finallyBlock) { + this.visitStatement(statement.finallyBlock); + } + return; + } + case ts.SyntaxKind.LabeledStatement: { + this.visitStatement((node as ts.LabeledStatement).statement); + return; + } + case ts.SyntaxKind.FunctionDeclaration: { + this.visitFunctionLike(node as ts.FunctionDeclaration); + return; + } + case ts.SyntaxKind.ClassDeclaration: { + this.visitClassLike(node as ts.ClassDeclaration); + return; + } + case ts.SyntaxKind.EnumDeclaration: { + for (const member of (node as ts.EnumDeclaration).members) { + this.visitDecorators(member); + this.root(member.initializer, TsRootContext.ENUM_MEMBER_VALUE); + } + return; + } + case ts.SyntaxKind.ModuleDeclaration: { + const body = (node as ts.ModuleDeclaration).body; + if (body && ts.isModuleBlock(body)) { + for (const statement of body.statements) { + this.visitStatement(statement); + } + } else if (body && ts.isModuleDeclaration(body)) { + this.visitStatement(body); + } + return; + } + case ts.SyntaxKind.ExportAssignment: { + this.root((node as ts.ExportAssignment).expression, TsRootContext.EXPORT_VALUE); + return; + } + default: { + // An interface, a type alias, an import or an export declaration. All + // type-level or binding-level; none of them contains an expression, and + // descending would put type names into `ts_expression`. + return; + } + } + } + + private visitVariableDeclaration(node: ts.VariableDeclaration): void { + // The function itself is a `ts_method` row and its body belongs to that + // method — but the function expression is still a value in the initialiser + // position, so the tree starts here and `root` follows into the body. + this.root(node.initializer, TsRootContext.VARIABLE_INITIALIZER); + } + + private visitFunctionLike(node: ts.SignatureDeclaration | ts.ClassStaticBlockDeclaration): void { + this.visitDecorators(node); + if (!ts.isClassStaticBlockDeclaration(node)) { + for (const parameter of node.parameters) { + this.visitDecorators(parameter); + this.root(parameter.initializer, TsRootContext.PARAMETER_DEFAULT); + } + } + const body = (node as { body?: ts.Node }).body; + if (!body) { + return; + } + if (ts.isBlock(body)) { + for (const statement of body.statements) { + this.visitStatement(statement); + } + return; + } + // A concise arrow body is an expression in its own right, owned by the + // arrow's method row. + this.root(body as ts.Expression, TsRootContext.ARROW_BODY_EXPRESSION); + } + + private visitClassLike(node: ts.ClassLikeDeclaration): void { + this.visitDecorators(node); + for (const clause of node.heritageClauses ?? []) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword || ts.isInterfaceDeclaration(node.parent)) { + continue; + } + for (const type of clause.types) { + // A class `extends` clause is EVALUATED at runtime, including the mixin + // form `extends mixin(Base)`. `implements` is not, and is skipped + // above — emitting it would put a type-only name in the call graph. + this.root(type.expression, TsRootContext.HERITAGE_EXPRESSION); + } + } + for (const member of node.members) { + // Decorators are visited by exactly ONE path per member. Visiting them + // here AND inside visitFunctionLike emitted every method decorator's call + // twice — and because the two rows had identical owner, role, position and + // position-in-file, they had the identical PRIMARY KEY. Duplicate rows + // under one key do not fail a join, they double a count, which is why the + // fact-base invariants check exists and why it found this before the + // resolution comparison did. + if (ts.isPropertyDeclaration(member)) { + this.visitDecorators(member); + this.visitComputedName(member.name); + this.root(member.initializer, TsRootContext.FIELD_INITIALIZER); + continue; + } + if (ts.isMethodDeclaration(member) || ts.isConstructorDeclaration(member) + || ts.isGetAccessor(member) || ts.isSetAccessor(member)) { + this.visitComputedName(member.name); + this.visitFunctionLike(member); + continue; + } + if (ts.isClassStaticBlockDeclaration(member)) { + this.visitFunctionLike(member); + continue; + } + } + } + + private visitComputedName(name: ts.PropertyName | undefined): void { + if (name && ts.isComputedPropertyName(name)) { + this.root(name.expression, TsRootContext.COMPUTED_PROPERTY_NAME); + } + } + + /** + * A decorator is an EXPRESSION THAT RUNS. + * + * That is the whole difference from a Java annotation, which is inert + * metadata. `@Component({...})` is a call at class-definition time, so it + * belongs in the call graph and its arguments are real values. + */ + private visitDecorators(node: ts.Node): void { + for (const decorator of ts.canHaveDecorators(node) ? ts.getDecorators(node) ?? [] : []) { + // `@(record("x"))` has been legal since TypeScript 5.0. The parentheses + // are punctuation and produce no row, so a tree rooted at them would be + // rooted at nothing and the call inside would never be emitted. + this.root(decorator.expression, TsRootContext.DECORATOR_EXPRESSION); + } + } + + /** + * Descends an already-extracted tree looking only for nested function bodies. + * + * The tree itself was emitted by the worklist, which stops at an arrow rather + * than descending into it — an arrow's body belongs to the arrow's own + * `ts_method` row, not to the enclosing expression. This finds those bodies + * without re-emitting the tree above them. + */ + private visitNestedFunctions(node: ts.Node): void { + ts.forEachChild(node, (child) => { + if (ts.isArrowFunction(child) || ts.isFunctionExpression(child)) { + this.visitFunctionLike(child); + return; + } + if (ts.isClassExpression(child)) { + this.visitClassLike(child); + return; + } + // An object literal's COMPUTED KEY is an expression that runs. + // `{ [Symbol.for("k")]: v }` calls Symbol.for before the object exists. + // visitComputedName covers class members already; object-literal members + // are reached only through this recursion, which walked method BODIES and + // never member NAMES, so every computed key in a literal was dropped -- + // property, method and accessor alike. + if (child.parent && ts.isObjectLiteralExpression(child.parent) + && (ts.isPropertyAssignment(child) || ts.isMethodDeclaration(child) + || ts.isGetAccessor(child) || ts.isSetAccessor(child))) { + this.visitComputedName(child.name); + } + // An object-literal method has its own `ts_method` row, so its body is + // walked under that owner rather than descended into as part of the + // enclosing expression tree. + if (child.parent && ts.isObjectLiteralExpression(child.parent) + && (ts.isMethodDeclaration(child) || ts.isGetAccessor(child) + || ts.isSetAccessor(child))) { + this.visitFunctionLike(child); + return; + } + if (ts.isTypeNode(child)) { + // Never descend into a type node. This is the structural guarantee of + // §3.3 restated at the one place a walk could violate it. + return; + } + // A JSX brace holds an ordinary expression. This recursion already ran + // THROUGH JSX -- which is why an arrow in `onClick={() => save()}` has + // always been walked -- but a `{t(msg)}` container is not a function, so + // nothing ever rooted it and the call vanished. Rooting it here covers + // attribute values, children, and spreads at once, wherever the JSX sits. + // + // The tag is deliberately NOT walked: `` as a call to Badge is + // JSX_COMPONENT_CALL, which is reserved and stays at zero rows. + if (ts.isJsxExpression(child)) { + this.root(child.expression, TsRootContext.JSX_EMBEDDED_EXPRESSION); + return; + } + if (ts.isJsxSpreadAttribute(child)) { + this.root(child.expression, TsRootContext.JSX_EMBEDDED_EXPRESSION); + return; + } + this.visitNestedFunctions(child); + }); + } +} diff --git a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts new file mode 100644 index 000000000..6c07f5461 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts @@ -0,0 +1,508 @@ +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { TsBlockRegistry } from '@/analysis-types/typescript/TsBlockRegistry'; +import { TsCommentRegistry } from '@/analysis-types/typescript/TsCommentRegistry'; +import { TsEnumMemberRegistry } from '@/analysis-types/typescript/TsEnumMemberRegistry'; +import { TsExportRegistry } from '@/analysis-types/typescript/TsExportRegistry'; +import { TsFieldPositionRegistry } from '@/analysis-types/typescript/TsFieldPositionRegistry'; +import { TsParseGapRegistry } from '@/analysis-types/typescript/TsParseGapRegistry'; +import { TsCallSiteRegistry } from '@/analysis-types/typescript/TsCallSiteRegistry'; +import { TsExpressionRegistry } from '@/analysis-types/typescript/TsExpressionRegistry'; +import { TsFieldRegistry } from '@/analysis-types/typescript/TsFieldRegistry'; +import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry'; +import { TsMethodParameterRegistry } from '@/analysis-types/typescript/TsMethodParameterRegistry'; +import { TsMethodRegistry } from '@/analysis-types/typescript/TsMethodRegistry'; +import { TsModuleRegistry } from '@/analysis-types/typescript/TsModuleRegistry'; +import { TsTypeHeritageRegistry } from '@/analysis-types/typescript/TsTypeHeritageRegistry'; +import { TsTypeParameterRegistry } from '@/analysis-types/typescript/TsTypeParameterRegistry'; +import { TsTypeReferenceRegistry } from '@/analysis-types/typescript/TsTypeReferenceRegistry'; +import { TsTypeRegistry } from '@/analysis-types/typescript/TsTypeRegistry'; +import { TsVariableRegistry } from '@/analysis-types/typescript/TsVariableRegistry'; +import { TsDecoratorArgumentRegistry } from + '@/analysis-types/typescript/TsDecoratorArgumentRegistry'; +import { TsDecoratorRegistry } from '@/analysis-types/typescript/TsDecoratorRegistry'; +import { TsDecoratorSystem } from '@/enums/typescript/decorators'; +import { TsExportedEntityKind } from '@/enums/typescript/exports'; +import { TsParseGapKind } from '@/enums/typescript/parse-gaps'; +import { TsModuleResolutionMode } from '@/enums/typescript/modules'; +import { bindSourceFile, BinderResult, nodeId } from '@/parsers/typescript/extractors/ts-binder'; +import { TsDeclarationExtractor } from + '@/parsers/typescript/extractors/ts-declaration-extractor'; +import { extractComments } from '@/parsers/typescript/extractors/ts-comment-extractor'; +import { extractDecorators } from '@/parsers/typescript/extractors/ts-decorator-extractor'; +import { extractExports } from '@/parsers/typescript/extractors/ts-export-extractor'; +import { TsExpressionExtractor } from + '@/parsers/typescript/extractors/ts-expression-extractor'; +import { TsExpressionWalker } from '@/parsers/typescript/extractors/ts-expression-walker'; +import { TsImportExtractor } from '@/parsers/typescript/extractors/ts-import-extractor'; +import { extractModules } from '@/parsers/typescript/extractors/ts-module-extractor'; +import { + EngineHandoff, + ResolutionStats, + TsLocalResolver, +} from '@/parsers/typescript/extractors/ts-resolution-linker'; + +/** + * Extracts the whole fact spine for ONE TypeScript file. + * + * ## The order is a dependency order, not a preference + * + * 1. **Modules.** The file's `ts_module` hash is the root of every FK chain, and + * it is computable from the path alone — which is what lets step 2 key a + * module augmentation under a file that has not been parsed. + * 2. **Binder.** Scopes, symbol tables and merge-scope keys. Nothing downstream + * is trustworthy until §3.1's partition is right, which is why the + * merge-partition gate is the first check that runs against output. + * 3. **Imports.** `ts.resolveModuleName` runs here, and the binder already used + * its answer for augmentations. + * 4. **Declarations.** Types, methods, parameters, fields, variables, heritage, + * blocks and the type-reference tree. + * 5. **Expressions and call sites.** Last, because they reference everything + * above by hash and re-deriving any of those keys would collide. + * 6. **Local resolution.** Only what syntax decides; the rest is deferred to the + * project pass or left honestly empty. + * + * No `ts.Program` is created at any step. `ts.createSourceFile` is text to AST + * and `ts.resolveModuleName` is a pure function of a specifier and options — + * both verified to work with no `node_modules` resolved and no typecheck. + */ +export interface TsFileExtractionOptions { + readonly absoluteFilePath: string; + readonly filePath: string; + readonly baseMservPath: string; + readonly moduleQualifiedName: string; + readonly sourceText: string; + readonly serviceVersionLinkHash: string; + readonly tsConfigPath: string; + readonly moduleResolutionMode: TsModuleResolutionMode; + /** + * From the tsconfig that GOVERNS this file — never a run-wide constant. + * + * Two files three directories apart can legitimately compile under different + * decorator systems, and the source is identical either way. See + * `ts-decorator-extractor.ts`. + */ + readonly decoratorSystem: TsDecoratorSystem; + readonly compilerOptions: ts.CompilerOptions; + readonly packageName: string; + /** Absolute path -> `ts_module` hash for every file in the analysis. */ + readonly projectModuleHashes: ReadonlyMap; + /** Absolute path -> project-relative path, extension stripped. */ + readonly toProjectRelative: (absolutePath: string) => string; +} + +export interface TsFileFacts { + readonly modules: readonly TsModuleRegistry[]; + readonly types: readonly TsTypeRegistry[]; + readonly methods: readonly TsMethodRegistry[]; + readonly methodParameters: readonly TsMethodParameterRegistry[]; + readonly fields: readonly TsFieldRegistry[]; + readonly variables: readonly TsVariableRegistry[]; + readonly heritages: readonly TsTypeHeritageRegistry[]; + readonly typeParameters: readonly TsTypeParameterRegistry[]; + readonly typeReferences: readonly TsTypeReferenceRegistry[]; + readonly imports: readonly TsImportRegistry[]; + readonly expressions: readonly TsExpressionRegistry[]; + readonly callSites: readonly TsCallSiteRegistry[]; + readonly blocks: readonly TsBlockRegistry[]; + readonly decorators: readonly TsDecoratorRegistry[]; + readonly decoratorArguments: readonly TsDecoratorArgumentRegistry[]; + readonly enumMembers: readonly TsEnumMemberRegistry[]; + readonly fieldPositions: readonly TsFieldPositionRegistry[]; + readonly exports: readonly TsExportRegistry[]; + readonly comments: readonly TsCommentRegistry[]; + readonly parseGaps: readonly TsParseGapRegistry[]; + /** + * Calls the parser deliberately left to the engine, with the hop it needs. + * + * Not a work queue. It is what `ts-ir-completeness.ts` reads to ask whether + * the FACTS the engine needs were emitted — which is the parser's actual + * obligation. + */ + readonly engineHandoffs: readonly EngineHandoff[]; + /** Local name -> the row that binds it, for the project pass. */ + readonly importByLocalName: ReadonlyMap; + readonly resolvedTargetByLocalName: ReadonlyMap; + readonly stats: ResolutionStats; + readonly binder: BinderResult; + readonly sourceFile: ts.SourceFile; + readonly fileModuleHash: string; + readonly filePath: string; +} + +export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileFacts { + // `ts.createSourceFile` with `setParentNodes = true`. The parent pointers are + // not a convenience: owner derivation and scope lookup both walk ANCESTORS, + // and the alternative — comparing positions — picks the wrong scope whenever + // two of them begin at the same offset. + const sourceFile = ts.createSourceFile( + options.absoluteFilePath, + options.sourceText, + ts.ScriptTarget.Latest, + true, + scriptKindFor(options.absoluteFilePath) + ); + + const modules = extractModules({ + sourceFile, + filePath: options.filePath, + baseMservPath: options.baseMservPath, + moduleQualifiedName: options.moduleQualifiedName, + tsConfigPath: options.tsConfigPath, + moduleResolutionMode: options.moduleResolutionMode, + strictBindCallApply: resolvedStrictBindCallApply(options.compilerOptions), + serviceVersionLinkHash: options.serviceVersionLinkHash, + packageName: options.packageName, + }); + const fileModuleHash = modules.fileModule.getHash(); + + const binder = bindSourceFile({ + sourceFile, + moduleHash: fileModuleHash, + isExternalModule: modules.fileModule.isExternalModule, + filePath: options.filePath, + // A module augmentation's declarations belong to the AUGMENTED module's + // table. Without this, `Request` in `augmented-base.ts` and `Request` inside + // `declare module "./augmented-base"` are two symbols instead of one. + resolveModuleHash: (specifier) => { + const resolved = ts.resolveModuleName( + specifier, + options.absoluteFilePath, + options.compilerOptions, + ts.sys + ); + const fileName = resolved.resolvedModule?.resolvedFileName; + if (!fileName) { + return undefined; + } + const absolute = path.normalize(fileName); + const moduleHash = options.projectModuleHashes.get(absolute); + if (!moduleHash) { + return undefined; + } + return { moduleHash, relativePath: options.toProjectRelative(absolute) }; + }, + ambientModuleHashes: modules.ambientModuleHashes, + }); + + const importExtractor = new TsImportExtractor({ + sourceFile, + filePath: options.filePath, + absoluteFilePath: options.absoluteFilePath, + tsModuleLinkHash: fileModuleHash, + compilerOptions: options.compilerOptions, + serviceVersionLinkHash: options.serviceVersionLinkHash, + projectModuleHashes: options.projectModuleHashes, + toProjectRelative: options.toProjectRelative, + }); + const importResult = importExtractor.run(); + + const declarations = new TsDeclarationExtractor({ + sourceFile, + binder, + filePath: options.filePath, + baseMservPath: options.baseMservPath, + fileName: path.basename(options.filePath), + moduleHash: fileModuleHash, + moduleQualifiedName: options.moduleQualifiedName, + isDeclarationFile: sourceFile.isDeclarationFile, + serviceVersionLinkHash: options.serviceVersionLinkHash, + moduleHashForNode: modules.moduleHashForNode, + }); + declarations.run(); + modules.fileModule.setModuleInitMethodLinkHash(declarations.moduleInitMethodHash); + + // Exports need every local declaration's hash, so this runs after the + // declaration pass. A re-export chain is the only path from an importer to the + // real declaration, which is why the relation is not optional. + const declarationByName = new Map< + string, + { hash: string; groupKey: string; kind: TsExportedEntityKind } + >(); + for (const type of declarations.types) { + if (type.name !== '') { + declarationByName.set(type.name, { + hash: type.getHash(), + groupKey: type.declarationGroupKey, + kind: TsExportedEntityKind.TYPE, + }); + } + } + for (const method of declarations.methods) { + if (method.tsTypeLinkHash === '' && method.escapedName !== '' + && !declarationByName.has(method.escapedName)) { + declarationByName.set(method.escapedName, { + hash: method.getHash(), + groupKey: method.declarationGroupKey, + kind: TsExportedEntityKind.METHOD, + }); + } + } + for (const variable of declarations.variables) { + if (variable.name !== '' && !declarationByName.has(variable.name)) { + declarationByName.set(variable.name, { + hash: variable.getHash(), + groupKey: variable.declarationGroupKey, + kind: TsExportedEntityKind.VARIABLE, + }); + } + } + const exports = extractExports({ + sourceFile, + tsModuleLinkHash: fileModuleHash, + isDeclarationFile: sourceFile.isDeclarationFile, + serviceVersionLinkHash: options.serviceVersionLinkHash, + declarationByName, + typeHashByNode: declarations.typeHashByNode, + methodHashByNode: declarations.methodHashByNode, + variableHashByNode: declarations.variableHashByNode, + pendingExpressionLinks: declarations.pendingExpressionLinks, + }); + for (const row of exports) { + if (row.exportKind === 'DEFAULT_EXPORT' || row.exportKind === 'DEFAULT_EXPRESSION') { + modules.fileModule.setDefaultExportLinkHash(row.getHash()); + } + if (row.exportKind === 'EXPORT_ASSIGNMENT') { + modules.fileModule.setExportAssignmentLinkHash(row.getHash()); + } + } + + const expressions = new TsExpressionExtractor({ + sourceFile, + serviceVersionLinkHash: options.serviceVersionLinkHash, + typeReferenceExtractor: declarations.typeReferenceExtractor, + }); + const walker = new TsExpressionWalker({ + sourceFile, + extractor: expressions, + moduleHash: fileModuleHash, + moduleInitMethodHash: declarations.moduleInitMethodHash, + moduleHashForNode: modules.moduleHashForNode, + methodHashByNode: declarations.methodHashByNode, + typeHashByNode: declarations.typeHashByNode, + blockHashByNode: declarations.blockHashByNode, + variableHashByNode: declarations.variableHashByNode, + fieldHashByNode: declarations.fieldHashByNode, + parameterHashByNode: declarations.parameterHashByNode, + }); + walker.run(); + // After the walk: a function type used as a type ARGUMENT has its return + // reference emitted by the expression pass, so the signature rows can only be + // linked once that has run. + declarations.linkSignatureReturnTypes(); + + const resolver = new TsLocalResolver({ + sourceFile, + binder, + moduleHash: fileModuleHash, + types: declarations.types, + methods: declarations.methods, + fields: declarations.fields, + variables: declarations.variables, + imports: importResult.importByLocalName, + typeHashByNode: declarations.typeHashByNode, + methodHashByNode: declarations.methodHashByNode, + variableHashByNode: declarations.variableHashByNode, + fieldHashByNode: declarations.fieldHashByNode, + parameterHashByNode: declarations.parameterHashByNode, + importRowByNode: importResult.importRowByNode, + emittedExpressions: expressions.emitted, + expressionRowByNode: expressions.rowByNode, + callSiteByNode: expressions.callSiteByNode, + callNodes: expressions.getCallNodes(), + typeAliasTargetByName: declarations.typeAliasTargetByName, + }); + const resolution = resolver.run(); + + // Close the declaration-to-expression FKs. Nine of them: a variable's + // initializer, a field's, a parameter default, a block's GUARD (the narrowing + // lever — 440 type predicates measured), a mixin base, an enum member's value, + // `export default `, a dynamic import. Each is a chain an engine can + // follow, and each was empty until the hash existed to fill it. + // An object-literal member's owner is the literal itself -- a ts_expression + // row. Resolved from the extractor's per-node index rather than the walker's + // ROOT index, so a literal that is an argument or nested inside another + // literal is reached too, not only one that initialises a variable. + for (const pending of declarations.pendingLiteralOwnerLinks) { + const owner = expressions.rowByNode.get(nodeId(pending.node, sourceFile)); + if (owner !== undefined) { + pending.row.setTsTypeLinkHash(owner.getHash()); + } + } + for (const pending of declarations.pendingExpressionLinks) { + const hash = walker.rootHashByNode.get(nodeId(pending.node, sourceFile)); + if (hash !== undefined && hash !== '') { + pending.link(hash); + } + } + // c16, the one link that runs the other way: an expression that INTRODUCES a + // declaration points at it. A `ts_type` for a class expression, a `ts_method` + // for an arrow or function expression — discriminated by the expression's own + // kind, which is what the widening made possible. + for (const [id, declarationHash] of declarations.anonymousDeclarationByNode) { + expressions.rowByNode.get(id)?.setAnonymousDeclarationHash(declarationHash); + } + // `import("m")` and `require("m")` are module edges written inside an + // expression, so the import row points at the call that performs them. + for (const importRow of importResult.imports) { + if (importRow.importKind !== 'DYNAMIC_IMPORT' && importRow.importKind !== 'REQUIRE_CALL') { + continue; + } + const call = importResult.dynamicImportNodeByRow.get(importRow.getHash()); + if (call) { + const hash = expressions.rowByNode.get(nodeId(call, sourceFile))?.getHash(); + if (hash !== undefined) { + importRow.setTsExpressionLinkHash(hash); + } + } + } + + // After expressions, because a decorator IS an expression that runs and its + // FK must point at a row that already exists. + const decorators = extractDecorators({ + sourceFile, + moduleHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + decoratorSystem: options.decoratorSystem, + typeHashByNode: declarations.typeHashByNode, + methodHashByNode: declarations.methodHashByNode, + fieldHashByNode: declarations.fieldHashByNode, + parameterHashByNode: declarations.parameterHashByNode, + expressionRowByNode: expressions.rowByNode, + typeReferenceExtractor: declarations.typeReferenceExtractor, + }); + + // Comments are TRIVIA: not in the AST, so no walk reaches them. The owner map + // is keyed by a declaration's start OFFSET, because that is what the comment + // scan knows about the node it precedes. + const ownerHashByStart = new Map(); + for (const [id, hash] of declarations.typeHashByNode) { + recordOwnerStart(ownerHashByStart, id, hash); + } + for (const [id, hash] of declarations.methodHashByNode) { + recordOwnerStart(ownerHashByStart, id, hash); + } + for (const [id, hash] of declarations.fieldHashByNode) { + recordOwnerStart(ownerHashByStart, id, hash); + } + for (const [id, hash] of declarations.variableHashByNode) { + recordOwnerStart(ownerHashByStart, id, hash); + } + const comments = extractComments({ + sourceFile, + filePath: options.filePath, + tsModuleLinkHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + ownerHashByStart, + }); + + // Should always be empty: zero parse failures measured over 25.9 MB. Emitted + // anyway, because an always-empty relation that suddenly has rows is a signal + // and a missing relation is a silence. + const parseGaps: TsParseGapRegistry[] = []; + for (const diagnostic of parseDiagnosticsOf(sourceFile)) { + const at = sourceFile.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + const end = sourceFile.getLineAndCharacterOfPosition( + (diagnostic.start ?? 0) + (diagnostic.length ?? 0) + ); + parseGaps.push(new TsParseGapRegistry({ + gapKind: TsParseGapKind.PARSE_DIAGNOSTIC, + diagnosticCode: String(diagnostic.code), + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '), + filePath: options.filePath, + startLine: at.line + 1, + startColumn: at.character + 1, + endLine: end.line + 1, + tsModuleLinkHash: fileModuleHash, + serviceVersionLinkHash: options.serviceVersionLinkHash, + })); + } + + return { + modules: [modules.fileModule, ...modules.nestedModules], + types: declarations.types, + methods: declarations.methods, + methodParameters: declarations.methodParameters, + fields: declarations.fields, + variables: declarations.variables, + heritages: declarations.heritages, + typeParameters: declarations.typeParameters, + typeReferences: declarations.typeReferenceExtractor.getRows(), + imports: importResult.imports, + expressions: expressions.expressions, + callSites: expressions.callSites, + blocks: declarations.blocks, + decorators: decorators.decorators, + decoratorArguments: decorators.decoratorArguments, + enumMembers: declarations.enumMembers, + fieldPositions: declarations.fieldPositions, + exports, + comments, + parseGaps, + engineHandoffs: resolution.handoffs, + importByLocalName: importResult.importByLocalName, + resolvedTargetByLocalName: importResult.resolvedTargetByLocalName, + stats: resolution.stats, + binder, + sourceFile, + fileModuleHash, + filePath: options.filePath, + }; +} + +/** + * `ts.ScriptKind` decides whether `<` opens JSX, so it cannot be guessed. + * + * `.mts` and `.cts` have no `ScriptKind` of their own — they are `TS` with a + * different module resolution, which `ts_module.scriptKind` records separately. + * Only `.tsx` changes how the file PARSES. + */ +/** + * A node identity is `kind:start:end`; the comment scan knows only the start. + * + * Recorded first-wins, because several nodes can begin at one offset — a + * declaration and its own name — and the OUTERMOST is the one a preceding + * comment documents. + */ +function recordOwnerStart( + target: Map, + nodeIdentity: string, + hash: string +): void { + const start = Number(nodeIdentity.split(':')[1] ?? ''); + if (!Number.isNaN(start) && !target.has(start)) { + target.set(start, hash); + } +} + +/** + * Parse diagnostics, without a Program. + * + * `ts.createSourceFile` records syntactic diagnostics on the source file itself, + * under an internal property. Reading it is the only way to see them without a + * Program — and the alternative, reporting no gaps ever, would make the relation + * a decoration rather than a signal. + */ +function parseDiagnosticsOf(sourceFile: ts.SourceFile): readonly ts.Diagnostic[] { + return (sourceFile as unknown as { parseDiagnostics?: ts.Diagnostic[] }) + .parseDiagnostics ?? []; +} + +function scriptKindFor(filePath: string): ts.ScriptKind { + return filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; +} + +/** + * `strictBindCallApply` as the CHECKER sees it. + * + * `ts.parseJsonConfigFileContent` does NOT apply the implication -- given + * `{"strict": true}` it leaves this `undefined` -- so the parsed option cannot + * be emitted as-is. The checker resolves every strict-family flag as + * `flag ?? strict ?? false`, and an explicit `false` beats an implying + * `strict: true`, which is why the nullish coalesce is not an `||`. + */ +function resolvedStrictBindCallApply(compilerOptions: ts.CompilerOptions): boolean { + return compilerOptions.strictBindCallApply ?? compilerOptions.strict ?? false; +} diff --git a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts new file mode 100644 index 000000000..27c96967b --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts @@ -0,0 +1,576 @@ +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry'; +import { TsImportKind, TsImportResolutionKind } from '@/enums/typescript/imports'; +import { nodeId } from '@/parsers/typescript/extractors/ts-binder'; + +/** + * Emits `ts_import` rows — schema §4.12. + * + * **One declaration with N named specifiers emits N ROWS.** `import { a, type B }` + * is one declaration and two facts with different runtime existence, so a + * per-declaration row would have to pick one answer for `isTypeOnly` and be + * wrong about the other. 45.6% of ecosystem imports are type-only and 34 use the + * inline `{ type X }` form, so this is the common case, not an edge one. + * + * ## Resolution happens here, and it is parser-legal + * + * `ts.resolveModuleName` needs **no `ts.Program`**: it is a pure function of a + * specifier, compiler options and a host, and it returns `undefined` for an + * unresolvable specifier rather than guessing. Verified on 6.0.3, `"./a.js"` + * resolves to `a.ts` with extension `.ts` — which is why `resolvedFilePath` is a + * tier-2 column rather than engine work. + * + * It matters beyond imports. §3.1's merge key for a module augmentation is + * keyed on the RESOLVED target module, so without this the `Request` interface + * in `augmented-base.ts` and the one inside `declare module "./augmented-base"` + * would be two symbols instead of one. + */ +export interface ImportExtractorOptions { + readonly sourceFile: ts.SourceFile; + readonly filePath: string; + readonly absoluteFilePath: string; + readonly tsModuleLinkHash: string; + readonly compilerOptions: ts.CompilerOptions; + readonly serviceVersionLinkHash: string; + /** Absolute resolved path -> `ts_module` hash, for project-internal targets. */ + readonly projectModuleHashes: ReadonlyMap; + readonly toProjectRelative: (absolutePath: string) => string; +} + +export interface ImportExtractionResult { + readonly imports: readonly TsImportRegistry[]; + /** Bound local name -> the row that binds it, for reference resolution. */ + readonly importByLocalName: ReadonlyMap; + /** Bound local name -> the resolved absolute file, when it resolved at all. */ + readonly resolvedTargetByLocalName: ReadonlyMap; + readonly importRowByNode: ReadonlyMap; + /** Import row hash -> the `import()`/`require()` call node, for its expression FK. */ + readonly dynamicImportNodeByRow: ReadonlyMap; +} + +export class TsImportExtractor { + private readonly imports: TsImportRegistry[] = []; + private readonly importByLocalName = new Map(); + private readonly resolvedTargetByLocalName = new Map(); + private readonly importRowByNode = new Map(); + private readonly dynamicImportNodeByRow = new Map(); + /** The `import()`/`require()` call whose row is being minted, for its expression FK. */ + private pendingDynamicImport: ts.Node | undefined; + /** One resolution per specifier per file; the same specifier repeats often. */ + private readonly resolutionCache = new Map(); + + constructor(private readonly options: ImportExtractorOptions) {} + + run(): ImportExtractionResult { + const sf = this.options.sourceFile; + for (const statement of sf.statements) { + if (ts.isImportDeclaration(statement)) { + this.emitImportDeclaration(statement); + continue; + } + if (ts.isImportEqualsDeclaration(statement)) { + this.emitImportEquals(statement); + continue; + } + } + this.emitTripleSlashReferences(); + this.emitDynamicImports(); + return { + imports: this.imports, + importByLocalName: this.importByLocalName, + resolvedTargetByLocalName: this.resolvedTargetByLocalName, + importRowByNode: this.importRowByNode, + dynamicImportNodeByRow: this.dynamicImportNodeByRow, + }; + } + + /** + * `import("./x")` and `require("./x")` anywhere in the file. + * + * Real module edges that no top-level statement declares, so a pass that only + * walks statements misses them entirely — and with them the only record that + * the target file is reachable. `ts_import` has enum values and an expression + * FK for exactly this (`DYNAMIC_IMPORT`, `REQUIRE_CALL`, c22), because a lazy + * route is still a route. + */ + private emitDynamicImports(): void { + const sf = this.options.sourceFile; + let index = 0; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword; + const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require'; + const first = node.arguments[0]; + if ((isDynamicImport || isRequire) && first && ts.isStringLiteral(first)) { + this.pendingDynamicImport = node; + this.emit(node, node, first.text, + isDynamicImport ? TsImportKind.DYNAMIC_IMPORT : TsImportKind.REQUIRE_CALL, + '', '', '', { + isTypeOnly: false, + isWildcard: false, + isDefaultImport: false, + isSideEffectOnly: false, + // Its own index space: a dynamic import shares no clause with the + // static imports, and the PK includes the clause index. + clauseIndex: 1000 + index, + }); + index += 1; + } + } + // `import("m").T` and `typeof import("m")["x"]` in a TYPE position. + // + // A real module edge that no statement declares. TYPE_IMPORT_NODE has + // existed in the enum for exactly this and carried zero rows, so the + // specifier survived only inside `completeTypeName` as text -- a consumer + // had to find the substring and resolve it themselves, with no + // resolvedFilePath and no way to tell a project file from a package. + // `const expect: typeof import('vitest')['expect']` is the shape ambient + // test globals take, and it is common enough to matter. + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) + && ts.isStringLiteral(node.argument.literal)) { + const qualifier = node.qualifier; + // Two spellings of the same thing, and the member name lives in a + // different node for each. `import("m").T` puts it on the qualifier; + // `typeof import("m")["T"]` puts it on the INDEXED ACCESS above, so + // reading only the qualifier names the module and not the member -- + // which is the half that a consumer actually needs. + const indexed = node.parent; + const indexedName = indexed !== undefined + && ts.isIndexedAccessTypeNode(indexed) + && indexed.objectType === node + && ts.isLiteralTypeNode(indexed.indexType) + && ts.isStringLiteral(indexed.indexType.literal) + ? indexed.indexType.literal.text + : ''; + const name = qualifier !== undefined + ? (ts.isIdentifier(qualifier) ? qualifier.text : qualifier.getText(sf)) + : indexedName; + this.emit(node, node, node.argument.literal.text, + TsImportKind.TYPE_IMPORT_NODE, name, name, '', { + // Erased by construction: a type position emits nothing at runtime. + isTypeOnly: true, + isWildcard: false, + isDefaultImport: false, + isSideEffectOnly: false, + // Its own index space, like the dynamic imports above, because the + // clause index is in the primary key and these share no clause. + clauseIndex: 2000 + index, + }); + index += 1; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + } + + private emitImportDeclaration(node: ts.ImportDeclaration): void { + if (!ts.isStringLiteral(node.moduleSpecifier)) { + return; + } + const specifier = node.moduleSpecifier.text; + const declarationIsTypeOnly = node.importClause?.isTypeOnly === true; + const clause = node.importClause; + + if (!clause) { + // `import "./polyfill"` — no binding, but a real module edge, and the + // only reason the target file is in the program at all. + this.emit(node, node, specifier, TsImportKind.SIDE_EFFECT, '', '', '', { + isTypeOnly: false, + isWildcard: false, + isDefaultImport: false, + isSideEffectOnly: true, + clauseIndex: 0, + }); + return; + } + + let clauseIndex = 0; + if (clause.name) { + this.emit(node, clause, specifier, + declarationIsTypeOnly ? TsImportKind.TYPE_ONLY_DEFAULT : TsImportKind.DEFAULT, + clause.name.text, 'default', '', { + isTypeOnly: declarationIsTypeOnly, + isWildcard: false, + isDefaultImport: true, + isSideEffectOnly: false, + clauseIndex, + }); + clauseIndex += 1; + } + + const bindings = clause.namedBindings; + if (!bindings) { + return; + } + if (ts.isNamespaceImport(bindings)) { + // Java's `isOnDemand` slot. The projection stays `import_wildcard` in both + // languages even though the enum value differs (§2). + this.emit(node, bindings, specifier, + declarationIsTypeOnly ? TsImportKind.TYPE_ONLY_NAMESPACE : TsImportKind.NAMESPACE, + bindings.name.text, '', '', { + isTypeOnly: declarationIsTypeOnly, + isWildcard: true, + isDefaultImport: false, + isSideEffectOnly: false, + clauseIndex, + }); + return; + } + if (bindings.elements.length === 0 && clauseIndex === 0) { + // `import type {} from "pkg"` and `import {} from "pkg"`. + // + // Both bind NOTHING, so the per-binding loop below emitted no row at all + // and the specifier appeared nowhere in the IR. The MODULE EDGE is real + // either way: the type-only form is the idiom for pulling in a package's + // ambient declarations (a `declare global`, an interface reopened), and + // the value form is a runtime load identical in effect to `import "pkg"`. + // + // Library staging is derived from the client IR's own imports, so a + // package reachable only through one of these could not be staged and + // every target it declares was charged as a miss. + // + // `SIDE_EFFECT` is the right kind rather than a new one: both it and + // `isSideEffectOnly` are defined as "no binding, but a real module + // edge", which is exactly this. Whether the edge has runtime existence + // is carried by `isTypeOnly`, which is the column for it. + // + // `clauseIndex === 0` matters because `import def, {} from "pkg"` is + // legal and its default row already carries the edge. + this.emit(node, bindings, specifier, TsImportKind.SIDE_EFFECT, '', '', '', { + isTypeOnly: declarationIsTypeOnly, + isWildcard: false, + isDefaultImport: false, + isSideEffectOnly: true, + clauseIndex, + }); + return; + } + for (const element of bindings.elements) { + const originalName = element.propertyName?.text ?? element.name.text; + const isAliased = element.propertyName !== undefined; + // Specifier-level type-only. `import { a, type B }` means these two rows + // differ in whether they have runtime existence at all, which is why the + // relation is per bound name rather than per declaration. + const specifierIsTypeOnly = element.isTypeOnly === true; + const kind = declarationIsTypeOnly + ? TsImportKind.TYPE_ONLY_NAMED + : specifierIsTypeOnly + ? TsImportKind.INLINE_TYPE_SPECIFIER + : isAliased + ? TsImportKind.NAMED_ALIAS + : TsImportKind.NAMED; + this.emit(node, element, specifier, kind, element.name.text, originalName, + isAliased ? element.name.text : '', { + isTypeOnly: declarationIsTypeOnly || specifierIsTypeOnly, + isWildcard: false, + isDefaultImport: false, + isSideEffectOnly: false, + clauseIndex, + }); + clauseIndex += 1; + } + } + + private emitImportEquals(node: ts.ImportEqualsDeclaration): void { + const reference = node.moduleReference; + if (ts.isExternalModuleReference(reference)) { + const specifier = ts.isStringLiteral(reference.expression) ? reference.expression.text : ''; + this.emit(node, node, specifier, TsImportKind.IMPORT_EQUALS_REQUIRE, node.name.text, '', '', { + isTypeOnly: node.isTypeOnly, + isWildcard: false, + isDefaultImport: false, + isSideEffectOnly: false, + clauseIndex: 0, + }); + return; + } + // `import x = a.b.C` — an ENTITY alias, not a module edge. The specifier + // column carries the dotted entity path because there is no module to name. + this.emit(node, node, reference.getText(this.options.sourceFile), + TsImportKind.IMPORT_EQUALS_ENTITY, node.name.text, '', '', { + isTypeOnly: node.isTypeOnly, + isWildcard: false, + isDefaultImport: false, + isSideEffectOnly: false, + clauseIndex: 0, + }); + } + + /** + * `/// ` and `/// `. + * + * Real module edges that carry no `import` statement, so a fact base built + * only from import declarations loses them — and in ambient code they are + * often the only edge there is. + */ + private emitTripleSlashReferences(): void { + const sf = this.options.sourceFile; + for (const reference of sf.referencedFiles) { + this.emitReference(reference.fileName, reference.pos); + } + for (const reference of sf.typeReferenceDirectives) { + this.emitReference(reference.fileName, reference.pos); + } + } + + private emitReference(specifier: string, pos: number): void { + const sf = this.options.sourceFile; + const startPos = sf.getLineAndCharacterOfPosition(pos); + const resolved = this.resolveSpecifier(specifier); + const row = new TsImportRegistry({ + importKind: TsImportKind.TRIPLE_SLASH_REFERENCE, + importedPath: specifier, + moduleOrEntityName: specifier, + simpleName: '', + filePath: this.options.filePath, + lineNumber: startPos.line + 1, + isTypeOnly: true, + isWildcard: false, + originalName: '', + aliasName: '', + isDefaultImport: false, + isSideEffectOnly: true, + tsModuleLinkHash: this.options.tsModuleLinkHash, + resolvedFilePath: resolved.relativePath, + resolutionKind: resolved.kind, + resolvedExtension: resolved.extension, + isExternalTarget: resolved.moduleHash === '', + packageName: resolved.packageName, + specifierHasExtension: /\.[a-z]+$/i.test(specifier), + importClauseIndex: 0, + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + if (resolved.moduleHash !== '') { + row.setResolvedModuleLinkHash(resolved.moduleHash); + } + this.imports.push(row); + } + + private emit( + declaration: ts.Node, + bindingNode: ts.Node, + specifier: string, + kind: TsImportKind, + simpleName: string, + originalName: string, + aliasName: string, + flags: { + isTypeOnly: boolean; + isWildcard: boolean; + isDefaultImport: boolean; + isSideEffectOnly: boolean; + clauseIndex: number; + } + ): void { + const sf = this.options.sourceFile; + const startPos = sf.getLineAndCharacterOfPosition(declaration.getStart(sf)); + const isEntityAlias = kind === TsImportKind.IMPORT_EQUALS_ENTITY; + const resolved = isEntityAlias ? UNRESOLVED : this.resolveSpecifier(specifier); + const row = new TsImportRegistry({ + importKind: kind, + importedPath: specifier, + moduleOrEntityName: specifier, + simpleName, + filePath: this.options.filePath, + lineNumber: startPos.line + 1, + isTypeOnly: flags.isTypeOnly, + isWildcard: flags.isWildcard, + originalName, + aliasName, + isDefaultImport: flags.isDefaultImport, + isSideEffectOnly: flags.isSideEffectOnly, + tsModuleLinkHash: this.options.tsModuleLinkHash, + resolvedFilePath: resolved.relativePath, + resolutionKind: resolved.kind, + resolvedExtension: resolved.extension, + // An HONEST negative about this analysis, not a claim about the outside + // world: the specifier did not resolve to a `ts_module` row here, which + // is precisely the set the engine closes from `lib_ts_*`. + isExternalTarget: resolved.moduleHash === '', + packageName: resolved.packageName, + specifierHasExtension: /\.[a-z]+$/i.test(specifier), + importClauseIndex: flags.clauseIndex, + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + if (resolved.moduleHash !== '') { + row.setResolvedModuleLinkHash(resolved.moduleHash); + } + this.imports.push(row); + this.importRowByNode.set(nodeId(bindingNode, sf), row); + if (this.pendingDynamicImport) { + this.dynamicImportNodeByRow.set(row.getHash(), this.pendingDynamicImport); + this.pendingDynamicImport = undefined; + } + if (simpleName !== '') { + this.importByLocalName.set(simpleName, row); + if (resolved.absolutePath !== '') { + this.resolvedTargetByLocalName.set(simpleName, resolved.absolutePath); + } + } + } + + private resolveSpecifier(specifier: string): ResolvedSpecifier { + const cached = this.resolutionCache.get(specifier); + if (cached) { + return cached; + } + const result = this.doResolve(specifier); + this.resolutionCache.set(specifier, result); + return result; + } + + private doResolve(specifier: string): ResolvedSpecifier { + // A Node builtin, with or without the `node:` prefix. Both forms must be + // classified, and the unprefixed form is the common one: `import * as path + // from "path"` accounts for 241 of the 259 incomplete hand-offs measured on + // this repository's own source. Without the classification the engine sees + // an empty `resolvedFilePath` and cannot tell a Node builtin from a project + // import that failed to resolve — which are different facts needing + // different treatment, and only one of them is a problem. + if (specifier.startsWith('node:') || NODE_BUILTIN_SPECIFIERS.has(specifier)) { + return { ...UNRESOLVED, kind: TsImportResolutionKind.BUILTIN_NODE }; + } + const resolved = ts.resolveModuleName( + specifier, + this.options.absoluteFilePath, + this.options.compilerOptions, + ts.sys + ); + const module = resolved.resolvedModule; + if (!module) { + // `undefined` is an honest answer, not a failure to try. It is also the + // right answer for a wildcard ambient specifier like `"*.svg"`, which + // names no file anywhere. + // + // The PACKAGE is still knowable. `packageName` normally comes from the + // resolved module's packageId, which exists only when node_modules was + // present -- so on a client-only run it was empty on every unresolved + // import, and a consumer had to re-derive it from the specifier to answer + // "which package would close these hops". The name is pure syntax, so it + // is filled here whether resolution succeeded or not. + return { ...UNRESOLVED, packageName: packageNameOf(specifier) }; + } + const absolute = path.normalize(module.resolvedFileName); + const moduleHash = this.options.projectModuleHashes.get(absolute) ?? ''; + const isNodeModules = absolute.includes(`${path.sep}node_modules${path.sep}`); + return { + absolutePath: absolute, + relativePath: this.options.toProjectRelative(absolute), + moduleHash, + kind: isNodeModules + ? (isDeclarationExtension(module.extension) + ? TsImportResolutionKind.NODE_MODULES_TYPES + : TsImportResolutionKind.NODE_MODULES_SOURCE) + : specifier.startsWith('.') + ? TsImportResolutionKind.RELATIVE_FILE + : TsImportResolutionKind.PATHS_ALIAS, + extension: module.extension, + // `packageId` first, then the SPECIFIER. tsc declines to mint a + // `packageId` when the nearest `package.json` carries a name that is not + // a legal package name -- which is exactly what a node10-compatibility + // stub does: + // + // node_modules/@tt/srv/standalone/package.json + // { "name": "@tt/srv/standalone", "types": "../types/standalone/index.d.ts" } + // + // Verified against `ts.resolveModuleName`: `@tt/srv` yields + // `packageId.name = "@tt/srv"`, `@tt/srv/standalone` yields + // `packageId = undefined`, and both resolve their file correctly. So the + // row had a right path and no package name, and library discovery -- + // which reads THIS column to decide what to stage -- could not see a + // dependency reached only through a subpath. + // + // The specifier is unambiguous where `packageId` is absent: the package + // part is the first two segments when scoped, the first otherwise. + // `packageNameOf` already computed exactly that for the UNRESOLVED path; + // the resolved path simply never used it. + // + // Only as a FALLBACK, and only under `node_modules`: `packageId.name` + // stays authoritative when tsc supplies it, and a `paths` alias into + // first-party source keeps an empty package name because it is not a + // published package. + packageName: module.packageId?.name + ?? (isNodeModules ? packageNameOf(specifier) : ''), + }; + } +} + +/** + * Whether a resolved extension names a DECLARATIONS-ONLY file. + * + * `ts.Extension.Dts` is only one of the three. `.d.mts` and `.d.cts` are + * `Dmts` and `Dcts`, and comparing against `Dts` alone classified them as + * `NODE_MODULES_SOURCE` — asserting that a file with no bodies is project + * source, which is the opposite of what the column is for: the engine reads it + * to decide whether to stage the target into `lib_ts_*`. Measured: 1,378 rows + * across nine projects, 1,170 of them in rxjs alone. + */ +function isDeclarationExtension(extension: string): boolean { + return extension === ts.Extension.Dts + || extension === ts.Extension.Dmts + || extension === ts.Extension.Dcts; +} + +interface ResolvedSpecifier { + readonly absolutePath: string; + readonly relativePath: string; + readonly moduleHash: string; + readonly kind: TsImportResolutionKind; + readonly extension: string; + readonly packageName: string; +} + +/** + * Node's builtin module specifiers, unprefixed. + * + * Enumerated rather than pattern-matched because there is no pattern: `path` is + * a builtin and `pathe` is a package, and guessing from the absence of a slash + * or a dot would misclassify every bare package name in the ecosystem. + */ +const NODE_BUILTIN_SPECIFIERS = new Set([ + 'assert', 'assert/strict', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', + 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'dns/promises', 'domain', + 'events', 'fs', 'fs/promises', 'http', 'http2', 'https', 'inspector', 'inspector/promises', + 'module', 'net', 'os', 'path', 'path/posix', 'path/win32', 'perf_hooks', 'process', + 'punycode', 'querystring', 'readline', 'readline/promises', 'repl', 'stream', + 'stream/consumers', 'stream/promises', 'stream/web', 'string_decoder', 'timers', + 'timers/promises', 'tls', 'trace_events', 'tty', 'url', 'util', 'util/types', 'v8', 'vm', + 'wasi', 'worker_threads', 'zlib', +]); + +/** + * The package a bare specifier names, from syntax alone. + * + * `@scope/name/deep` is the package `@scope/name` -- a scoped name is the FIRST + * TWO segments, and taking one would give `@scope`, which is not a package. + * `name/deep` is `name`. A relative or absolute specifier names no package, and + * neither does a wildcard ambient one like `*.svg`. + * + * Node builtins never reach this: they are classified BUILTIN_NODE before + * resolution is attempted, and they are not packages to stage. + */ +function packageNameOf(specifier: string): string { + if (specifier === '' || specifier.startsWith('.') || specifier.startsWith('/') + || specifier.includes('*')) { + return ''; + } + const segments = specifier.split('/'); + if (specifier.startsWith('@')) { + return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : ''; + } + return segments[0] ?? ''; +} + +const UNRESOLVED: ResolvedSpecifier = { + absolutePath: '', + relativePath: '', + moduleHash: '', + kind: TsImportResolutionKind.UNRESOLVED, + extension: '', + packageName: '', +}; diff --git a/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts b/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts new file mode 100644 index 000000000..189051d2f --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts @@ -0,0 +1,1034 @@ +import { TsFieldRegistry } from '@/analysis-types/typescript/TsFieldRegistry'; +import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry'; +import { TsTypeRegistry } from '@/analysis-types/typescript/TsTypeRegistry'; +import { TsResolvedTargetKind } from '@/enums/typescript/call-sites'; +import { TsFileFacts } from '@/parsers/typescript/extractors/ts-fact-extractor'; +import { stripTsOrJsExtension } from '@/parsers/typescript/ts-module-paths'; + +/** + * Measures whether the IR is COMPLETE — not whether the parser resolved. + * + * ## Why resolution rate is the wrong measure + * + * The parser emits IR; the engine builds the call graph. The Java precedent is + * unambiguous and stronger than any count: `java_type_reference`'s + * `referencedTypeRegistryLinkHash` is not merely empty in every output — **no + * Java extractor contains a statement that fills it.** The engine joins + * `typeName` against `java_type` in `type-resolution.dl`, and that is the design. + * + * So "26 of 11,529 call sites use the declared-receiver-type path" is not a + * hole. The question that matters is different, and this file answers it: + * + * For every call the parser did not resolve, are the facts an engine needs in + * order to resolve it actually present? + * + * ## The triple, for a receiver whose type lives in another file + * + * 1. the receiver's declared type NAME AS WRITTEN — `ts_call_site.receiverTypeName` + * 2. the importing module — `ts_call_site.tsModuleLinkHash` + * 3. `ts_import.resolvedFilePath` for the import that binds that name + * + * With those three the engine joins and the parser is done. A call site missing + * any of them is a genuine parser gap. A call site missing NONE of them, and + * still unresolved, is the parser working exactly as intended. + * + * ## And the hop chain, for the receiver itself + * + * Path 1 needs more than a name: it needs to get from the call site to the + * receiver's DECLARATION and from there to its annotation. Each link is a column + * the parser owns, so each is checkable here: + * + * ts_call_site.receiverExpressionLinkHash + * -> ts_expression.referencedEntityHash + * -> ts_variable | ts_method_parameter | ts_field [.typeReferenceLinkHash] + * -> ts_type_reference.completeTypeName + * + * A break anywhere in that chain is unrecoverable downstream, and invisible in + * any resolution percentage. + */ +export interface IrCompletenessReport { + readonly callSites: number; + /** Links the parser emitted itself — strictly more than Java provides. */ + readonly sameFileLinks: number; + /** Terminals: target exists and is outside the analysis, or is synthesized. */ + readonly terminals: number; + /** Handed to the engine WITH every hop present. This is the success case. */ + readonly handedOffComplete: number; + /** Handed to the engine with a hop MISSING. This is the number to drive down. */ + readonly handedOffIncomplete: number; + /** Receiver type is genuinely inferred, so there is no annotation to emit. */ + readonly inferredReceiver: number; + /** + * The receiver's type is not derivable from SYNTAX at all — a computed member + * on an unannotated value. Counted apart so it is neither claimed as complete + * nor reported as a gap the parser could close. + */ + readonly notDerivable: number; + readonly gaps: readonly IrGap[]; + /** Per-shape outcome. PROVENANCE, not a quality measure. */ + readonly byReceiverKind: Record; +} + +export interface ReceiverShapeCounts { + readonly total: number; + readonly sameFileLinks: number; + readonly terminals: number; + readonly complete: number; + readonly incomplete: number; + readonly inferred: number; + readonly notDerivable: number; +} + +export interface IrGap { + readonly where: string; + readonly reason: string; + readonly detail: string; +} + +type Mutable = { -readonly [K in keyof T]: T[K] }; + +/** + * Links every import that names an AMBIENT MODULE declared in this analysis. + * + * `declare module "untyped-legacy-package" { … }` is its own `ts_module` row + * (§3.5) precisely so that an import of that specifier has somewhere to point. + * `ts.resolveModuleName` cannot help — there is no file — so the link comes from + * the fact base itself, matching the specifier against `declaredSpecifier`. + * + * This is the MODULE graph, not the call graph, and `ts_import.resolvedModuleLinkHash` + * is the parser's own column (§4.12 c14, FK to `ts_module`). Filling it is not + * the retracted cross-file resolution: it stops one hop short, at the module, + * which is exactly where the parser's job ends and `type-resolution.dl` begins. + * + * Wildcards are honoured because the corpus uses them: `declare module "*.svg"` + * is the asset-import idiom and matching it literally would leave every + * `import icon from "./x.svg"` pointing at nothing. + */ +/** + * The MODULE-graph slice of a file's facts. + * + * The link passes need only these four, and naming that lets extraction stream + * every other relation the moment a file is finished instead of holding all of + * them until the last one is parsed. + */ +export interface ModuleGraphFacts { + readonly filePath: string; + readonly modules: TsFileFacts['modules']; + readonly imports: TsFileFacts['imports']; + readonly exports: TsFileFacts['exports']; +} + +export function linkAmbientModuleImports(files: readonly ModuleGraphFacts[]): number { + const exact = new Map(); + const patterns: { prefix: string; suffix: string; hash: string }[] = []; + for (const facts of files) { + for (const module of facts.modules) { + if (module.declaredSpecifier === '') { + continue; + } + const star = module.declaredSpecifier.indexOf('*'); + if (star < 0) { + exact.set(module.declaredSpecifier, module.getHash()); + continue; + } + patterns.push({ + prefix: module.declaredSpecifier.slice(0, star), + suffix: module.declaredSpecifier.slice(star + 1), + hash: module.getHash(), + }); + } + } + let linked = 0; + for (const facts of files) { + for (const importRow of facts.imports) { + if (importRow.getResolvedModuleLinkHash() !== '' || importRow.importedPath === '') { + continue; + } + const direct = exact.get(importRow.importedPath); + if (direct !== undefined) { + importRow.setResolvedModuleLinkHash(direct); + importRow.setAmbientModuleResolution(); + linked += 1; + continue; + } + for (const pattern of patterns) { + if (importRow.importedPath.startsWith(pattern.prefix) + && importRow.importedPath.endsWith(pattern.suffix) + && importRow.importedPath.length >= pattern.prefix.length + pattern.suffix.length) { + importRow.setResolvedModuleLinkHash(pattern.hash); + importRow.setAmbientModuleResolution(); + linked += 1; + break; + } + } + } + } + return linked; +} + +/** + * Links every re-export to the module it re-exports FROM. + * + * `export { Thing } from "./m"` names a module, and the file declaring `Thing` + * may be parsed after this one — so like ambient module imports, the link is made + * once every file is in. Matching is by the specifier's resolved path, which the + * import extractor already computed for the same specifier when one exists; + * otherwise the relative path is resolved against the re-exporting module. + * + * Still the MODULE graph. `ts_export.resolvedSourceModuleLinkHash` is the + * parser's own column (§4.13 c8), and this stops one hop short of the call + * graph — at the module, which is where `type-resolution.dl` takes over. + */ +export function linkReExportSources(files: readonly ModuleGraphFacts[]): number { + const modulesByPath = new Map(); + for (const facts of files) { + for (const module of facts.modules) { + if (module.declaredSpecifier === '') { + modulesByPath.set(stripKnownExtension(module.filePath), module.getHash()); + } + } + } + let linked = 0; + for (const facts of files) { + // The import extractor already resolved every specifier this file mentions, + // so a re-export from the same specifier reuses that answer rather than + // resolving it a second time and risking a different one. + const resolvedBySpecifier = new Map(); + for (const importRow of facts.imports) { + if (importRow.resolvedFilePath !== '') { + resolvedBySpecifier.set(importRow.importedPath, + stripKnownExtension(importRow.resolvedFilePath)); + } + } + for (const exportRow of facts.exports) { + if (exportRow.sourceSpecifier === '' + || exportRow.getResolvedSourceModuleLinkHash() !== '') { + continue; + } + const viaImport = resolvedBySpecifier.get(exportRow.sourceSpecifier); + const target = viaImport !== undefined + ? modulesByPath.get(viaImport) + : modulesByPath.get(resolveRelative(facts.filePath, exportRow.sourceSpecifier)); + if (target !== undefined) { + exportRow.setResolvedSourceModuleLinkHash(target); + linked += 1; + } + } + } + return linked; +} + +/** A project-relative path minus its extension, so `./a.js` and `a.ts` compare equal. */ +function stripKnownExtension(filePath: string): string { + return stripTsOrJsExtension(filePath); +} + +/** A relative specifier resolved against the re-exporting file, without touching disk. */ +function resolveRelative(fromFilePath: string, specifier: string): string { + if (!specifier.startsWith('.')) { + return specifier; + } + const base = fromFilePath.split('/').slice(0, -1); + for (const part of stripKnownExtension(specifier).split('/')) { + if (part === '.' || part === '') { + continue; + } + if (part === '..') { + base.pop(); + continue; + } + base.push(part); + } + return base.join('/'); +} + +/** + * A dynamic import whose verdict waits for the module link passes. + * + * Holds no row the extraction can stream: a shape, a location, a name, and the + * import row -- which is retained regardless, because the link passes mutate it. + */ +interface DeferredBase { + readonly shape: string; + readonly where: string; + readonly calleeName: string; + /** Reasons already established from facts the link passes cannot change. */ + readonly missingSoFar: readonly string[]; +} + +/** + * A verdict that waits for the module link passes. + * + * Only a verdict that FAILS right now is held. The link passes fill + * `resolvedModuleLinkHash` and never clear it, and every hop reports a reason + * only when the resolution columns are empty -- so linking can remove a reason + * and never add one. A hop that already passes has its final answer, and + * holding it would retain a record per call site for nothing. + * + * Every hop that consults a `ts_import` row is here, because + * `resolvedModuleLinkHash` is filled only once every file has been parsed -- + * an import of `declare module "x"` cannot be linked until the file declaring + * it has been read. Deciding in-loop would read an empty column and call a + * linked import incomplete. + * + * These are DATA, deliberately, and not closures. A closure written inside the + * per-file walk captures its enclosing context, and V8 may keep that whole + * context alive -- which would retain the expression rows the streaming exists + * to release. The failure would be invisible except at scale. + * + * What each holds is a reference to a per-file map that is retained anyway: + * the import rows themselves outlive the walk because the link passes mutate + * them. + */ +export type DeferredVerdict = + | (DeferredBase & { readonly hop: 'DYNAMIC_IMPORT'; readonly importRow: TsImportRegistry | undefined }) + | (DeferredBase & { + readonly hop: 'IMPORTED_NAME'; + readonly imports: ReadonlyMap; + readonly localName: string; + }) + | (DeferredBase & { + readonly hop: 'RECEIVER_TYPE'; + readonly imports: ReadonlyMap; + readonly localTypeNames: ReadonlySet; + readonly typeName: string; + }); + +/** The mutable state an incremental measurement carries between files. */ +export interface CompletenessAccumulator { + readonly report: Mutable; + readonly gaps: IrGap[]; + readonly deferred: DeferredVerdict[]; +} + +export function newCompletenessAccumulator(): CompletenessAccumulator { + return { + report: { + callSites: 0, + sameFileLinks: 0, + terminals: 0, + handedOffComplete: 0, + handedOffIncomplete: 0, + inferredReceiver: 0, + notDerivable: 0, + gaps: [], + byReceiverKind: {}, + }, + gaps: [], + deferred: [], + }; +} + +/** + * Measures ONE file, so extraction need not hold every row to be measured. + * + * The whole measurement was already per-file: the two "cross-file" indexes it + * built were keyed by a file's own module hash and read back under that same + * key, so they indexed each file against itself. Making that explicit is what + * lets the caller stream a relation the moment its file is done. + */ +export function accumulateFileCompleteness( + facts: TsFileFacts, + accumulator: CompletenessAccumulator +): void { + const { report, gaps, deferred } = accumulator; + measureOneFile(facts, report, gaps, deferred); +} + +/** + * Settles the deferred dynamic imports and returns the finished report. + * + * Must run after `linkAmbientModuleImports`, which is the whole reason those + * verdicts were held. + */ +export function finishCompleteness(accumulator: CompletenessAccumulator): IrCompletenessReport { + const { report, gaps, deferred } = accumulator; + for (const pending of deferred) { + const missing: string[] = [...pending.missingSoFar]; + switch (pending.hop) { + case 'DYNAMIC_IMPORT': { + checkDeferredDynamicImportHop(pending.importRow, missing); + break; + } + case 'IMPORTED_NAME': { + checkImportHop(pending.imports, pending.localName, missing); + break; + } + case 'RECEIVER_TYPE': { + checkDeclaredTypeTriple(pending.typeName, pending.localTypeNames, pending.imports, missing); + break; + } + } + const bucket = (report.byReceiverKind[pending.shape] ?? { + total: 0, sameFileLinks: 0, terminals: 0, complete: 0, incomplete: 0, inferred: 0, + notDerivable: 0, + }) as Mutable; + report.byReceiverKind[pending.shape] = bucket; + if (missing.length === 0) { + report.handedOffComplete += 1; + bucket.complete += 1; + continue; + } + report.handedOffIncomplete += 1; + bucket.incomplete += 1; + for (const reason of missing) { + gaps.push({ where: pending.where, reason, detail: pending.calleeName }); + } + } + report.gaps = gaps; + return report; +} + +export function measureIrCompleteness( + files: readonly TsFileFacts[] +): IrCompletenessReport { + const accumulator = newCompletenessAccumulator(); + for (const facts of files) { + accumulateFileCompleteness(facts, accumulator); + } + return finishCompleteness(accumulator); +} + +/** The measurement for one file. See `accumulateFileCompleteness`. */ +function measureOneFile( + facts: TsFileFacts, + report: Mutable, + gaps: IrGap[], + deferred: DeferredVerdict[] +): void { + const expressionByHash = new Map(facts.expressions.map((e) => [e.getHash(), e])); + const declarationTypeRefByHash = new Map(); + for (const variable of facts.variables) { + declarationTypeRefByHash.set(variable.getHash(), variable.getTypeReferenceLinkHash()); + } + const annotatedByHash = new Map(); + for (const variable of facts.variables) { + annotatedByHash.set(variable.getHash(), variable.variableTypeName !== ''); + } + for (const parameter of facts.methodParameters) { + annotatedByHash.set(parameter.getHash(), parameter.parameterTypeName !== ''); + } + for (const field of facts.fields as readonly TsFieldRegistry[]) { + annotatedByHash.set(field.getHash(), field.fieldTypeName !== ''); + } + + const handoffBySite = new Map(facts.engineHandoffs.map((h) => [h.callSite.getHash(), h])); + const childrenByParent = new Map(); + for (const expression of facts.expressions) { + const parent = expression.parentExpressionHash; + if (parent === '') { + continue; + } + const list = childrenByParent.get(parent); + if (list) { + list.push(expression); + } else { + childrenByParent.set(parent, [expression]); + } + } + const callSiteByExpression = new Set( + facts.callSites.map((c) => c.tsExpressionLinkHash)); + const heritageByType = new Map(); + for (const heritage of facts.heritages) { + const list = heritageByType.get(heritage.tsTypeLinkHash); + if (list) { + list.push(heritage); + } else { + heritageByType.set(heritage.tsTypeLinkHash, [heritage]); + } + } + const localTypeNames = new Set( + facts.types.map((t: TsTypeRegistry) => t.name).filter((n) => n !== '')); + const imports: ReadonlyMap = new Map(facts.importByLocalName); + const dynamicImportSpecifiers = new Map( + facts.imports + .filter((i) => i.importKind === 'DYNAMIC_IMPORT' || i.importKind === 'REQUIRE_CALL') + .map((i) => [`${i.lineNumber}:${i.startColumn}`, i])); + + for (const callSite of facts.callSites) { + report.callSites += 1; + const shape = callSite.receiverKind; + const bucket = (report.byReceiverKind[shape] ?? { + total: 0, sameFileLinks: 0, terminals: 0, complete: 0, incomplete: 0, inferred: 0, + notDerivable: 0, + }) as Mutable; + bucket.total += 1; + report.byReceiverKind[shape] = bucket; + + const where = `${callSite.startLine}:${callSite.startColumn}`; + if (callSite.getResolvedSignatureLinkHash() !== '') { + report.sameFileLinks += 1; + bucket.sameFileLinks += 1; + continue; + } + const kind = callSite.getResolvedTargetKind(); + if (kind === TsResolvedTargetKind.LIB_SIGNATURE + || kind === TsResolvedTargetKind.AMBIENT_SIGNATURE + || kind === TsResolvedTargetKind.SYNTHESIZED_NO_DECLARATION) { + report.terminals += 1; + bucket.terminals += 1; + continue; + } + + const handoff = handoffBySite.get(callSite.getHash()); + const missing: string[] = []; + + switch (callSite.receiverKind) { + case 'NONE': { + // An unqualified call. The engine starts from the callee identifier's + // own resolution, or from the import row the parser handed it. + if (handoff?.hop === 'IMPORTED_NAME') { + // The hop reads `resolvedModuleLinkHash`, which the link passes + // fill after every file is parsed. Probed now and held only if it + // fails, since linking can only turn a failure into a pass. + const probe: string[] = []; + checkImportHop(imports, handoff.localName, probe); + if (probe.length === 0) { + break; + } + deferred.push({ + hop: 'IMPORTED_NAME', + shape, + where: `${facts.filePath}:${where} (${callSite.receiverKind})`, + calleeName: callSite.calleeName, + missingSoFar: missing, + imports, + localName: handoff.localName, + }); + continue; + } + if (callSite.callKind === 'DYNAMIC_IMPORT_CALL') { + // `import("./x")` has no callee EXPRESSION — the callee is a + // keyword. Its target is a MODULE, and the hop is the `ts_import` + // row the parser emits for it with `resolvedFilePath` filled. + // + // Unless the specifier is COMPUTED. `await import(packageName)` + // names its module at runtime, so there is no module edge for any + // parser to emit and no `ts_import` row to point at. That is not an + // incomplete hand-off, it is not derivable from syntax — the same + // bucket as a `new (X as any)()` callee. + if (!hasLiteralSpecifier(callSite, expressionByHash, childrenByParent)) { + report.notDerivable += 1; + bucket.notDerivable += 1; + continue; + } + // DEFERRED, and this is the only verdict that is. + // + // The hop reads `resolvedModuleLinkHash`, which + // `linkAmbientModuleImports` fills after every file is parsed -- + // so deciding here would read an empty column and call a linked + // import incomplete. Everything else about this call site is + // already counted, including its bucket total, so what is held + // over is one boolean per dynamic import and not the row. + const dynamicProbe: string[] = []; + checkDeferredDynamicImportHop( + dynamicImportSpecifiers.get(`${callSite.startLine}:${callSite.startColumn}`), + dynamicProbe); + if (dynamicProbe.length === 0) { + break; + } + deferred.push({ + hop: 'DYNAMIC_IMPORT', + shape, + where: `${facts.filePath}:${where} (${callSite.receiverKind})`, + calleeName: callSite.calleeName, + missingSoFar: missing, + importRow: dynamicImportSpecifiers.get( + `${callSite.startLine}:${callSite.startColumn}`), + }); + continue; + } + const callee = calleeExpressionOf(callSite, expressionByHash, childrenByParent); + if (!callee) { + missing.push('a call with no callee expression row'); + break; + } + if (callee.kind === 'ARROW_FUNCTION' || callee.kind === 'FUNCTION_EXPRESSION') { + // An IIFE. The target is right there in the file with its own + // `ts_method` row, and c16 now points at it — the widening ts-oracle + // made after this parser reported that the two rows existed with no + // FK between them and the engine's only route was a position match. + // + // So this is a checkable hop now, not a category to be excused. An + // EMPTY c16 on a callee that introduces a declaration is a parser + // gap, and it is reported as one. + if (callee.getAnonymousDeclarationHash() === '') { + missing.push('an IIFE callee introduces a ts_method but c16 ' + + 'anonymousDeclarationHash is empty — the engine is back to matching positions'); + } + break; + } + if (callee.kind !== 'IDENTIFIER_REFERENCE') { + // `new (X as any)(…)`, a computed callee. The callee's type is not + // derivable from a NAME, and in the `as any` case not derivable at + // all. + report.notDerivable += 1; + bucket.notDerivable += 1; + continue; + } + if (callee.getReferencedEntityHash() === '' + && callee.getReferencedEntityKind() === 'UNKNOWN') { + missing.push('the callee identifier has no referencedEntityHash and is not ' + + 'classified AMBIENT_GLOBAL, so the engine has no starting point'); + } + break; + } + case 'IDENTIFIER': { + if (handoff?.hop === 'IMPORTED_NAME') { + // The hop reads `resolvedModuleLinkHash`, which the link passes + // fill after every file is parsed. Probed now and held only if it + // fails, since linking can only turn a failure into a pass. + const probe: string[] = []; + checkImportHop(imports, handoff.localName, probe); + if (probe.length === 0) { + break; + } + deferred.push({ + hop: 'IMPORTED_NAME', + shape, + where: `${facts.filePath}:${where} (${callSite.receiverKind})`, + calleeName: callSite.calleeName, + missingSoFar: missing, + imports, + localName: handoff.localName, + }); + continue; + } + const typeName = handoff?.receiverTypeName ?? ''; + if (typeName === '') { + // No annotation anywhere on the receiver's declaration. Nothing for + // the parser to emit, and no amount of parser work changes it. + // Counted here rather than inside a helper, because a helper that + // both counts and returns void let this case be counted AND fall + // through to be counted again — 6,770 outcomes for 5,271 call sites. + report.inferredReceiver += 1; + bucket.inferred += 1; + continue; + } + // The receiver-to-declaration leg is decidable now; the type's own + // third leg consults an import row, so the VERDICT waits. + checkReceiverToDeclaration(callSite, expressionByHash, annotatedByHash, + declarationTypeRefByHash, missing); + const typeProbe: string[] = []; + checkDeclaredTypeTriple(typeName, localTypeNames, imports, typeProbe); + if (typeProbe.length === 0) { + break; + } + deferred.push({ + hop: 'RECEIVER_TYPE', + shape, + where: `${facts.filePath}:${where} (${callSite.receiverKind})`, + calleeName: callSite.calleeName, + missingSoFar: missing, + imports, + localTypeNames, + typeName, + }); + continue; + } + case 'THIS': { + // `this.m()` needs the enclosing TYPE, which is a column on the row. + if (callSite.callerTypeLinkHash === '') { + missing.push('a `this` receiver with no callerTypeLinkHash — the engine cannot ' + + 'tell which type the member is on'); + } + break; + } + case 'SUPER': { + // `super.m()` needs a heritage row that INHERITS MEMBERS. An + // `implements` row will not do: it inherits nothing. + const inherited = (heritageByType.get(callSite.callerTypeLinkHash) ?? []) + .some((h) => h.inheritsMembers); + if (!inherited) { + missing.push('a `super` receiver with no inheritsMembers heritage row on the ' + + 'enclosing type — the base class is unreachable'); + } + break; + } + case 'PROPERTY_CHAIN': { + // `this.repo.find()`, `config.db.connect()`. The engine walks the + // chain: each PROPERTY_ACCESS row must have both its RECEIVER child + // and its PROPERTY_NAME child, and the root must be either `this` or + // an identifier that resolved. That is a walk over emitted rows, so + // it is checkable here — and writing these off as "inferred", which + // an earlier version of this file did, hid 1,782 call sites behind a + // label that said no work was possible. + checkPropertyChain(callSite, expressionByHash, childrenByParent, missing); + break; + } + case 'CALL_RESULT': { + // The receiver's type is the inner call's RETURN type. The engine + // chains through that call's own resolution, so what has to be + // present is the inner `ts_call_site` row. + const receiverRow = expressionByHash.get(callSite.receiverExpressionLinkHash); + if (!receiverRow) { + missing.push('a CALL_RESULT receiver with no ts_expression row'); + } else if (!callSiteByExpression.has(receiverRow.getHash())) { + missing.push('the inner call has no ts_call_site row, so its return type is ' + + 'unreachable and the chain stops'); + } + break; + } + case 'NON_NULL': + case 'AWAIT_RESULT': + case 'PARENTHESIZED': + case 'AS_EXPRESSION': { + // A wrapper. Complete when the wrapped expression is emitted; for + // `as T` the asserted type is the receiver's type outright. + const receiverRow = expressionByHash.get(callSite.receiverExpressionLinkHash); + if (!receiverRow) { + missing.push(`a ${callSite.receiverKind} receiver with no ts_expression row`); + } else if (childrenByParent.get(receiverRow.getHash())?.length === undefined) { + missing.push(`a ${callSite.receiverKind} receiver whose operand was not emitted`); + } + break; + } + default: { + // ELEMENT_ACCESS and UNKNOWN. `receiverKind` has no member for a + // literal or an array-literal receiver, but the receiver's type is + // still fully derivable from columns the parser emitted: a LITERAL row + // carries `literalType`, and an ARRAY_LITERAL row names `Array` + // outright. `"a,b".split(",")` and `[1, 2].map(f)` need nothing + // further from the parser, so counting them as not-derivable would + // understate the IR. + const receiverRow = expressionByHash.get(callSite.receiverExpressionLinkHash); + if (receiverRow && (receiverRow.kind === 'ARRAY_LITERAL' + || receiverRow.kind === 'OBJECT_LITERAL' + || receiverRow.kind === 'TEMPLATE_EXPRESSION' + || receiverRow.literalType !== '')) { + report.handedOffComplete += 1; + bucket.complete += 1; + continue; + } + // What remains is genuinely not derivable from syntax: a computed + // member, or a receiver whose type comes from an operator's operands. + // Counted apart so it is neither claimed as complete nor reported as a + // gap the parser could close. + report.notDerivable += 1; + bucket.notDerivable += 1; + continue; + } + } + + if (missing.length === 0) { + report.handedOffComplete += 1; + bucket.complete += 1; + continue; + } + report.handedOffIncomplete += 1; + bucket.incomplete += 1; + for (const reason of missing) { + gaps.push({ + where: `${facts.filePath}:${where} (${callSite.receiverKind})`, + reason, + detail: callSite.calleeName, + }); + } + } + +} + +/** + * The module hop for a dynamic import, once the link passes have run. + * + * A Node builtin legitimately resolves to no file -- the classification IS the + * hop -- so it is not a gap. + */ +function checkDeferredDynamicImportHop( + importRow: TsImportRegistry | undefined, + missing: string[] +): void { + if (!importRow) { + missing.push('a dynamic import with no ts_import row — the module edge is unrecorded'); + return; + } + if (importRow.resolvedFilePath === '' && importRow.getResolvedModuleLinkHash() === '' + && importRow.getResolutionKind() !== 'BUILTIN_NODE') { + missing.push(`dynamic import of "${importRow.importedPath}" resolved to nothing`); + } +} + +/** + * The leading identifier of a type expression, for a REACHABILITY question only. + * + * `Row[]` -> `Row`, `Promise` -> `Promise`, `Parser.SyntaxNode` -> `Parser`, + * `Row | null` -> `Row`. This asks "can the engine find where this name comes + * from", which is a different question from "which type is this" — and it is + * emphatically NOT used to produce a link. The column keeps the text as written; + * reducing a name and then resolving it is the mistake this file exists to + * measure rather than repeat. + */ +// --------------------------------------------------------------------------- +// the individual hop checks +// --------------------------------------------------------------------------- + +type ExpressionRow = TsFileFacts['expressions'][number]; +type CallSiteRow = TsFileFacts['callSites'][number]; + +/** + * The engine starts an imported call from the `ts_import` row. + * + * A Node builtin legitimately has no `resolvedFilePath` — the classification IS + * the hop, and the engine stages it from `lib_ts_*`. An empty path with no + * classification is a real gap: the engine cannot tell a builtin from a project + * import that failed to resolve, and those need different treatment. + */ +function checkImportHop( + imports: ReadonlyMap, + localName: string, + missing: string[] +): void { + const importRow = imports.get(localName); + if (!importRow) { + missing.push('no ts_import row binds the callee name'); + return; + } + if (importRow.importKind === 'IMPORT_EQUALS_ENTITY') { + // `import Units = Geometry.Units` aliases an ENTITY, not a module. There is + // no file to resolve and an empty `resolvedFilePath` is the correct answer; + // the hop is the dotted entity path in `moduleOrEntityName`, which the + // engine resolves against declarations in this same module. + if (importRow.moduleOrEntityName === '') { + missing.push('an entity alias with no moduleOrEntityName — the alias target is unnamed'); + } + return; + } + if (importRow.resolvedFilePath === '' + && importRow.getResolvedModuleLinkHash() === '' + && importRow.getResolutionKind() !== 'BUILTIN_NODE') { + missing.push(`ts_import has neither resolvedFilePath nor resolvedModuleLinkHash for ` + + `"${importRow.importedPath}"`); + } +} + +/** + * A dynamic import's hop is its `ts_import` row, matched by POSITION. + * + * The row and the call site are minted from the same node, so they share a + * position exactly. Matching on it is not a heuristic — it is the same identity + * both rows were keyed from. + */ +/** + * Whether a dynamic import's specifier is a STRING LITERAL written in source. + * + * `import("./x")` names a module the parser can resolve; `import(name)` names + * one only the runtime knows. + */ +function hasLiteralSpecifier( + callSite: CallSiteRow, + expressionByHash: ReadonlyMap, + childrenByParent: ReadonlyMap +): boolean { + const callRow = expressionByHash.get(callSite.tsExpressionLinkHash); + if (!callRow) { + return false; + } + const args = (childrenByParent.get(callRow.getHash()) ?? []) + .filter((c) => c.edgeRole === 'ARGUMENT'); + const first = args[0]; + return first !== undefined && first.kind === 'LITERAL'; +} + + +/** The callee expression of a call, whatever shape it takes. */ +function calleeExpressionOf( + callSite: CallSiteRow, + expressionByHash: ReadonlyMap, + childrenByParent: ReadonlyMap +): ExpressionRow | undefined { + const callRow = expressionByHash.get(callSite.tsExpressionLinkHash); + if (!callRow) { + return undefined; + } + return (childrenByParent.get(callRow.getHash()) ?? []) + .find((child) => child.edgeRole === 'METHOD_NAME'); +} + +/** + * The three facts an engine needs when the receiver's type lives elsewhere. + * + * 1. the declared type NAME AS WRITTEN ts_call_site.receiverTypeName + * 2. the importing module ts_call_site.tsModuleLinkHash + * 3. ts_import.resolvedFilePath for the import binding that name + * + * (2) is a non-empty FK on every row and is checked by the invariants check, so + * what is verified here is that (1) is present and that (3) exists for it — + * either because the name is declared in this module, or bound by an import, or + * ambient. + */ +function checkDeclaredTypeTriple( + typeName: string, + localTypeNames: ReadonlySet, + imports: ReadonlyMap, + missing: string[] +): void { + if (receiverTypeShapeOf(typeName) !== 'NAMED') { + // ARRAY and ANONYMOUS need no join. `T[]` and `{ a: string }[]` have `Array` + // as their receiver type, which is ambient; `{ getHash(): string }` names no + // declaration at all and its full shape is already in the + // `ts_type_reference` tree. Counting either as a gap would be asking the + // parser to invent a declaration the source does not contain. + return; + } + const head = headIdentifierOf(typeName); + if (head === '') { + missing.push(`receiver type "${typeName}" contains no identifier the engine can look up`); + return; + } + const importRow = imports.get(head); + if (importRow) { + if (importRow.importKind === 'IMPORT_EQUALS_ENTITY') { + return; + } + // Bound by an import: the third leg of the triple must be there. + if (importRow.resolvedFilePath === '' + && importRow.getResolvedModuleLinkHash() === '' + && importRow.getResolutionKind() !== 'BUILTIN_NODE') { + missing.push(`receiver type "${typeName}" is imported from "${importRow.importedPath}" ` + + 'but that import resolved to nothing — the engine has no file to look in'); + } + return; + } + if (localTypeNames.has(head)) { + return; + } + // Neither declared here nor imported. That makes it an AMBIENT name — a + // `lib.*.d.ts` type or a global — and the engine finds it by name in + // `lib_ts_*`. The parser cannot tell an ambient type from a dangling one + // without a checker, and does not need to: the name as written IS the + // complete fact. An earlier version gated on a curated list of lib names, + // which failed on `ClassMethodDecoratorContext` and would have failed on + // every subsequent lib release — a list that must grow forever is a list + // that hides gaps rather than finding them. +} + +/** + * The walk from a call site to the DECLARATION that carries the annotation. + * + * ts_call_site.receiverExpressionLinkHash + * -> ts_expression.referencedEntityHash + * -> ts_variable | ts_method_parameter | ts_field [.typeReferenceLinkHash] + * + * A break anywhere here is unrecoverable downstream and invisible in any + * resolution percentage, which is why it is checked link by link. + */ +function checkReceiverToDeclaration( + callSite: CallSiteRow, + expressionByHash: ReadonlyMap, + annotatedByHash: ReadonlyMap, + declarationTypeRefByHash: ReadonlyMap, + missing: string[] +): void { + const receiverHash = callSite.receiverExpressionLinkHash; + if (receiverHash === '') { + missing.push('receiverExpressionLinkHash is empty on a method call'); + return; + } + const receiverRow = expressionByHash.get(receiverHash); + if (!receiverRow) { + missing.push('receiverExpressionLinkHash points at no ts_expression row'); + return; + } + const declaration = receiverRow.getReferencedEntityHash(); + if (declaration === '') { + missing.push('the receiver expression has no referencedEntityHash, so the engine cannot ' + + 'reach the declaration that carries the annotation'); + return; + } + if (annotatedByHash.get(declaration) === true + && declarationTypeRefByHash.has(declaration) + && declarationTypeRefByHash.get(declaration) === '') { + missing.push('the receiver declaration is annotated but has no typeReferenceLinkHash, ' + + 'so the annotation is unreachable structurally'); + } +} + +/** + * The walk down a property chain, over emitted rows. + * + * Every `PROPERTY_ACCESS` row must carry both children the engine needs — the + * RECEIVER it reads from and the PROPERTY_NAME it reads — and the root must be + * `this` or an identifier that resolved. Everything in that description is a row + * the parser emitted, so it is checkable without following a single import. + */ +function checkPropertyChain( + callSite: CallSiteRow, + expressionByHash: ReadonlyMap, + childrenByParent: ReadonlyMap, + missing: string[] +): void { + let current: ExpressionRow | undefined = + expressionByHash.get(callSite.receiverExpressionLinkHash); + if (!current) { + missing.push('a PROPERTY_CHAIN receiver with no ts_expression row'); + return; + } + let guard = 0; + while (current && guard < 64) { + guard += 1; + if (current.kind === 'THIS_REFERENCE' || current.kind === 'SUPER_REFERENCE') { + return; + } + if (current.kind === 'IDENTIFIER_REFERENCE') { + if (current.getReferencedEntityHash() === '' + && current.getReferencedEntityKind() === 'UNKNOWN') { + missing.push('the root of a property chain resolved to nothing and is not classified ' + + 'AMBIENT_GLOBAL — the chain has no starting point'); + } + return; + } + if (current.kind !== 'PROPERTY_ACCESS' && current.kind !== 'ELEMENT_ACCESS' + && current.kind !== 'NON_NULL_EXPRESSION') { + // A call result or an await inside the chain. Its own row exists, and the + // engine chains through that node's type; nothing is missing here. + return; + } + const children: ExpressionRow[] = childrenByParent.get(current.getHash()) ?? []; + // `x!.y()` -- a non-null assertion is a postfix UNARY, so its operand + // carries UNARY_OPERAND, not RECEIVER. Looking only for RECEIVER reported + // the emitted row as an unwalkable chain: the IR was right and this check + // was wrong, which is the more dangerous direction of the two. + const nextRole: string = current.kind === 'NON_NULL_EXPRESSION' ? 'UNARY_OPERAND' : 'RECEIVER'; + const next: ExpressionRow | undefined = + children.find((c) => c.edgeRole === nextRole); + if (!next) { + missing.push(`a ${current.kind} row in a property chain has no ${nextRole} child, so the ` + + 'chain cannot be walked'); + return; + } + if (current.kind === 'PROPERTY_ACCESS' + && !children.some((c) => c.edgeRole === 'PROPERTY_NAME')) { + missing.push('a PROPERTY_ACCESS row in a property chain has no PROPERTY_NAME child, so ' + + 'the engine cannot tell which member is being read'); + return; + } + current = next; + } +} + +/** + * Does a receiver's declared type NAME something the engine can look up? + * + * Three answers, and only the first needs a join: + * NAMED `User`, `Promise`, `Parser.SyntaxNode` — resolve the head + * ARRAY `T[]`, `readonly Row[]`, `{ a: string }[]` — the type IS `Array` + * ANONYMOUS `{ … }`, `(a: T) => R` — names no declaration; the shape is in + * `ts_type_reference` and there is nothing to import + */ +function receiverTypeShapeOf(annotation: string): 'NAMED' | 'ARRAY' | 'ANONYMOUS' { + const text = annotation.trim().replace(/^readonly\s+/, ''); + if (text.endsWith('[]')) { + return 'ARRAY'; + } + if (text.startsWith('{') || text.startsWith('(')) { + return 'ANONYMOUS'; + } + return 'NAMED'; +} + +function headIdentifierOf(annotation: string): string { + const match = /[A-Za-z_$][A-Za-z0-9_$]*/.exec( + annotation.trim().replace(/^readonly\s+/, '') + ); + return match?.[0] ?? ''; +} diff --git a/parser/src/parsers/typescript/extractors/ts-module-extractor.ts b/parser/src/parsers/typescript/extractors/ts-module-extractor.ts new file mode 100644 index 000000000..9aea3d8f7 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-module-extractor.ts @@ -0,0 +1,329 @@ +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { TsModuleRegistry } from '@/analysis-types/typescript/TsModuleRegistry'; +import { TS_EMISSION_REGIME, TS_TARGET_VERSION } from '@/constants/typescript-constants'; +import { + TsEmissionRegime, + TsModuleKind, + TsModuleResolutionMode, + TsScriptKind, + TsMergeScopePrefix, +} from '@/enums/typescript/modules'; +import { nodeId } from '@/parsers/typescript/extractors/ts-binder'; +import { stripTsOrJsonExtension } from '@/parsers/typescript/ts-module-paths'; + +/** + * Mints the `ts_module` rows for one file — schema §4.1. + * + * A file is one row, and so is every `declare module "x" { … }` and every + * `declare global { … }` inside it. That is not decoration: an ambient module + * declaration is an independently importable namespace AND a merge scope of its + * own, and one file may hold many — 173 measured — which is why + * `declaredSpecifier` and `startLine` are both in the primary key. + * + * The file's own row is minted first and its hash is the root of every FK chain + * in the file, so it must be computable from the path alone. It is: the key is + * `filePath ‖ baseMservPath ‖ declaredSpecifier ‖ startLine ‖ emissionRegime ‖ + * serviceVersionLinkHash` and nothing in it depends on having parsed anything. + * That property is what lets a module augmentation in file B key its + * declarations under file A's module hash without file A having been read yet. + */ +export interface ModuleExtractionInput { + readonly sourceFile: ts.SourceFile; + readonly filePath: string; + readonly baseMservPath: string; + readonly moduleQualifiedName: string; + readonly tsConfigPath: string; + readonly moduleResolutionMode: TsModuleResolutionMode; + /** Resolved by the caller, which holds the compiler options. */ + readonly strictBindCallApply: boolean; + readonly serviceVersionLinkHash: string; + readonly packageName: string; +} + +export interface ModuleExtractionResult { + readonly fileModule: TsModuleRegistry; + /** Ambient-module and global-augmentation rows, in source order. */ + readonly nestedModules: readonly TsModuleRegistry[]; + /** Module hash for any node, so a `declare module` body's FKs point at the right row. */ + readonly moduleHashForNode: (node: ts.Node) => string; + /** `declare module "x"` specifier -> that row's hash, for the binder. */ + readonly ambientModuleHashes: ReadonlyMap; +} + +export function extractModules(input: ModuleExtractionInput): ModuleExtractionResult { + const sf = input.sourceFile; + const isDeclarationFile = sf.isDeclarationFile; + // `ts.isExternalModule` is the compiler's own answer to the question that + // decides module scope versus GLOBAL scope, and therefore which merge table + // every top-level declaration in this file lands in. Guessing it from the + // presence of the word `import` would be wrong for `export {}` and for + // `import type` under `isolatedModules`. + const isExternalModule = ts.isExternalModule(sf); + const endPos = sf.getLineAndCharacterOfPosition(sf.end); + + const fileModule = new TsModuleRegistry({ + name: moduleStemOf(input.filePath), + qualifiedName: input.moduleQualifiedName, + fileName: path.basename(input.filePath), + filePath: input.filePath, + baseMservPath: input.baseMservPath, + moduleKind: isDeclarationFile + ? TsModuleKind.DECLARATION_FILE + : isExternalModule + ? TsModuleKind.SOURCE_MODULE + : TsModuleKind.SCRIPT_GLOBAL, + scriptKind: scriptKindOf(input.filePath, isDeclarationFile), + declaredSpecifier: '', + isDeclarationFile, + isExternalModule, + isAmbient: isDeclarationFile || allTopLevelDeclare(sf), + packageName: input.packageName, + // The scope-key prefix this module MINTS. A module file mints + // MODULE_EXPORTS; a global script contributes to GLOBAL and mints nothing + // of its own, which is exactly why two scripts' declarations merge. + mergeTableKey: isExternalModule + ? `${TsMergeScopePrefix.MODULE_EXPORTS}:${''}` + : TsMergeScopePrefix.GLOBAL, + moduleResolutionMode: input.moduleResolutionMode, + strictBindCallApply: input.strictBindCallApply, + tsConfigPath: input.tsConfigPath, + targetTsVersion: TS_TARGET_VERSION, + emissionRegime: TS_EMISSION_REGIME as TsEmissionRegime, + startLine: 1, + endLine: endPos.line + 1, + hasTopLevelAwait: hasTopLevelAwait(sf), + hasJsxContent: hasJsxContent(sf), + serviceVersionLinkHash: input.serviceVersionLinkHash, + }); + + // The mergeTableKey needs the module's own hash, which needs the row. Rebuild + // it once the hash exists rather than leaving a self-reference dangling: the + // key is not part of the PK, so the second construction is identical in every + // column that identifies the row. + const fileModuleFinal = new TsModuleRegistry({ + name: fileModule.name, + qualifiedName: fileModule.qualifiedName, + fileName: fileModule.fileName, + filePath: fileModule.filePath, + baseMservPath: fileModule.baseMservPath, + moduleKind: fileModule.moduleKind, + scriptKind: fileModule.scriptKind, + declaredSpecifier: fileModule.declaredSpecifier, + isDeclarationFile: fileModule.isDeclarationFile, + isExternalModule: fileModule.isExternalModule, + isAmbient: fileModule.isAmbient, + packageName: fileModule.packageName, + mergeTableKey: isExternalModule + ? `${TsMergeScopePrefix.MODULE_EXPORTS}:${fileModule.getHash()}` + : TsMergeScopePrefix.GLOBAL, + moduleResolutionMode: fileModule.moduleResolutionMode, + strictBindCallApply: fileModule.strictBindCallApply, + tsConfigPath: fileModule.tsConfigPath, + targetTsVersion: fileModule.targetTsVersion, + emissionRegime: fileModule.emissionRegime, + startLine: fileModule.startLine, + endLine: fileModule.endLine, + hasTopLevelAwait: fileModule.hasTopLevelAwait, + hasJsxContent: fileModule.hasJsxContent, + serviceVersionLinkHash: fileModule.serviceVersionLinkHash, + }); + + const nestedModules: TsModuleRegistry[] = []; + const byNode = new Map(); + const ambientModuleHashes = new Map(); + + const visit = (node: ts.Node): void => { + if (ts.isModuleDeclaration(node)) { + const isAmbientModule = ts.isStringLiteral(node.name); + const isGlobalAugmentation = (node.flags & ts.NodeFlags.GlobalAugmentation) !== 0; + if (isAmbientModule || isGlobalAugmentation) { + const specifier = isAmbientModule ? (node.name as ts.StringLiteral).text : ''; + const startPos = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + const nestedEnd = sf.getLineAndCharacterOfPosition(node.end); + const row = new TsModuleRegistry({ + name: isAmbientModule ? `"${specifier}"` : 'global', + qualifiedName: isAmbientModule ? specifier : 'global', + fileName: fileModuleFinal.fileName, + filePath: input.filePath, + baseMservPath: input.baseMservPath, + moduleKind: isGlobalAugmentation + ? TsModuleKind.GLOBAL_AUGMENTATION + // A relative specifier reopens an EXISTING module; a bare one + // declares a new ambient namespace. Different rows because they are + // different facts: one merges into a module in this analysis, the + // other names something outside it. + : specifier.startsWith('.') + ? TsModuleKind.MODULE_AUGMENTATION + : TsModuleKind.AMBIENT_MODULE_DECLARATION, + scriptKind: fileModuleFinal.scriptKind, + declaredSpecifier: specifier, + isDeclarationFile, + isExternalModule: false, + isAmbient: true, + packageName: input.packageName, + mergeTableKey: isGlobalAugmentation ? TsMergeScopePrefix.GLOBAL : '', + moduleResolutionMode: input.moduleResolutionMode, + strictBindCallApply: input.strictBindCallApply, + tsConfigPath: input.tsConfigPath, + targetTsVersion: TS_TARGET_VERSION, + emissionRegime: TS_EMISSION_REGIME as TsEmissionRegime, + startLine: startPos.line + 1, + endLine: nestedEnd.line + 1, + hasTopLevelAwait: false, + hasJsxContent: false, + serviceVersionLinkHash: input.serviceVersionLinkHash, + }); + nestedModules.push(row); + byNode.set(nodeId(node, sf), row.getHash()); + if (isAmbientModule) { + ambientModuleHashes.set(specifier, row.getHash()); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + + return { + fileModule: fileModuleFinal, + nestedModules, + moduleHashForNode: (node) => byNode.get(nodeId(node, sf)) ?? fileModuleFinal.getHash(), + ambientModuleHashes, + }; +} + +/** + * The `ts_module` PK, computable from the path ALONE. + * + * Exposed because a module augmentation in file B must key its declarations + * under file A's module hash, and file A may not have been parsed yet. If this + * needed anything from the parsed file, cross-file merging would need a + * dependency-ordered traversal that a file list cannot provide. + */ +export function moduleHashFor( + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string +): string { + return new TsModuleRegistry({ + name: moduleStemOf(filePath), + qualifiedName: filePath, + fileName: path.basename(filePath), + filePath, + baseMservPath, + moduleKind: TsModuleKind.SOURCE_MODULE, + scriptKind: TsScriptKind.TS, + declaredSpecifier: '', + isDeclarationFile: false, + isExternalModule: true, + isAmbient: false, + packageName: '', + mergeTableKey: '', + moduleResolutionMode: TsModuleResolutionMode.NODE10, + strictBindCallApply: false, + tsConfigPath: '', + targetTsVersion: TS_TARGET_VERSION, + emissionRegime: TS_EMISSION_REGIME as TsEmissionRegime, + startLine: 1, + endLine: 1, + hasTopLevelAwait: false, + hasJsxContent: false, + serviceVersionLinkHash, + }).getHash(); +} + +/** `views` for `app/web/views.ts`, `views.d.ts` and `views.d.cts`. */ +export function moduleStemOf(filePath: string): string { + return stripTsOrJsonExtension(path.basename(filePath)); +} + +function scriptKindOf(filePath: string, isDeclarationFile: boolean): TsScriptKind { + if (filePath.endsWith('.tsx')) { + return TsScriptKind.TSX; + } + if (filePath.endsWith('.mts')) { + return TsScriptKind.MTS; + } + if (filePath.endsWith('.cts')) { + return TsScriptKind.CTS; + } + if (filePath.endsWith('.json')) { + return TsScriptKind.JSON; + } + return isDeclarationFile ? TsScriptKind.DTS : TsScriptKind.TS; +} + +/** Every top-level statement carries `declare`, so the file contributes no runtime code. */ +function allTopLevelDeclare(sf: ts.SourceFile): boolean { + let sawDeclarable = false; + for (const statement of sf.statements) { + if (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement) + || ts.isImportEqualsDeclaration(statement)) { + continue; + } + sawDeclarable = true; + const modifiers = (statement as { modifiers?: ts.NodeArray }).modifiers; + const declared = modifiers?.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword) === true; + if (!declared) { + return false; + } + } + return sawDeclarable; +} + +/** + * Does this file contain JSX? + * + * It was hardcoded `false`, so the column could never be true — while §4.1 + * relies on it, saying "`ts_module.scriptKind = TSX` and `hasJsxContent` + * already carry the file-level facts". `scriptKind` only reports the + * EXTENSION: a `.tsx` with no JSX and a `.tsx` full of it were + * indistinguishable, which is the difference between "this file needs the + * JSX work" and "this file merely could". + * + * Unlike {@link hasTopLevelAwait} the walk does not stop at a function or a + * class, because JSX inside a component body is exactly the case that matters + * — it is a property of the FILE, not of a scope. + */ +function hasJsxContent(sf: ts.SourceFile): boolean { + let found = false; + const walk = (node: ts.Node): void => { + if (found) { + return; + } + if (ts.isJsxElement(node) + || ts.isJsxSelfClosingElement(node) + || ts.isJsxFragment(node)) { + found = true; + return; + } + ts.forEachChild(node, walk); + }; + ts.forEachChild(sf, walk); + return found; +} + +/** A top-level `await` forces module semantics regardless of imports. */ +function hasTopLevelAwait(sf: ts.SourceFile): boolean { + let found = false; + const walk = (node: ts.Node): void => { + if (found) { + return; + } + if (ts.isAwaitExpression(node)) { + found = true; + return; + } + // A nested function has its own await context, so an await inside one is + // not a top-level await. + if (ts.isFunctionLike(node) || ts.isClassLike(node) || ts.isModuleDeclaration(node)) { + return; + } + ts.forEachChild(node, walk); + }; + ts.forEachChild(sf, walk); + return found; +} diff --git a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts new file mode 100644 index 000000000..3b0324355 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts @@ -0,0 +1,1287 @@ +import * as ts from 'typescript'; +import { TsSignatureRole } from '@/enums/typescript/methods/TsSignatureRole'; + +import { TsCallSiteRegistry } from '@/analysis-types/typescript/TsCallSiteRegistry'; +import { TsExpressionRegistry } from '@/analysis-types/typescript/TsExpressionRegistry'; +import { TsFieldRegistry } from '@/analysis-types/typescript/TsFieldRegistry'; +import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry'; +import { TsMethodRegistry } from '@/analysis-types/typescript/TsMethodRegistry'; +import { TsTypeRegistry } from '@/analysis-types/typescript/TsTypeRegistry'; +import { TsVariableRegistry } from '@/analysis-types/typescript/TsVariableRegistry'; +import { TsReferencedEntityKind, TsEdgeRole } from '@/enums/typescript/expressions'; +import { + TsResolutionEvidence, + TsResolvedTargetKind, +} from '@/enums/typescript/call-sites'; +import { TsBodyPresence } from '@/enums/typescript/methods'; +import { + BinderResult, + BoundDeclaration, + declarationGroupKeyFor, + escapeName, + nodeId, + TsBoundKind, + TsScope, +} from '@/parsers/typescript/extractors/ts-binder'; +import { + calleeOf, + unwrapParentheses, +} from '@/parsers/typescript/extractors/ts-expression-extractor'; + +/** + * Fills the SAME-FILE resolution links, and stops there. + * + * ## The scope of this class is a settled boundary, not a limitation + * + * The parser emits IR. Building the call graph is the engine's job. The Java + * precedent settles it and is worth stating precisely, because it is stronger + * than a count: `java_type_reference.referencedTypeRegistryLinkHash` is not + * merely empty in every output on disk — **no Java extractor contains a single + * statement that fills it.** There is no code path. The column is a slot the + * engine populates by joining `typeName` against `java_type`, in + * `type-resolution.dl`. + * + * Following an import to a declaring file, walking an `extends` chain across + * modules, or hopping `a.b.c` through three files' annotations is + * `type-resolution.dl` rewritten in TypeScript. It was attempted here and is + * retracted. What remains is the set of links that need ONE lookup inside one + * file and no import resolution: + * + * - a call to a function declared in this file + * - a call through a variable bound to an arrow in this file + * - `this.m()` where `m` is declared on the enclosing class in this file + * - `new C()` where `C` is declared in this file, including its implicit + * constructor + * - a namespace-qualified call inside the namespace's own file + * + * These are kept because they are strictly more than Java provides and they save + * the engine a lookup. They are not kept because resolution is the goal. + * + * ## What the parser owes the engine instead + * + * For everything else — and it is the majority — the obligation is COMPLETENESS, + * not resolution. A receiver whose declared type lives in another file needs + * three facts present and nothing more: the receiver's declared type name AS + * WRITTEN (`ts_call_site.receiverTypeName`), the importing module + * (`tsModuleLinkHash`), and `ts_import.resolvedFilePath`. With those three the + * engine joins. `ts-ir-completeness.ts` measures whether they are there. + * + * ## Where a link IS emitted, it must be right + * + * A parser-filled `resolvedSignatureLinkHash` that disagrees with + * `getResolvedSignature` is a hard failure at the gate; an unfilled one is not a + * failure at all. So overloads are chosen by ARITY or not at all — picking the + * first is wrong on 77.6% of real overloaded calls — and a receiver annotation + * that is not a BARE IDENTIFIER naming a declaration in this file resolves to + * nothing (see {@link localTypeNameOf}). + */ +export interface LocalResolutionInput { + readonly sourceFile: ts.SourceFile; + readonly binder: BinderResult; + readonly moduleHash: string; + readonly types: readonly TsTypeRegistry[]; + readonly methods: readonly TsMethodRegistry[]; + readonly fields: readonly TsFieldRegistry[]; + readonly variables: readonly TsVariableRegistry[]; + readonly imports: ReadonlyMap; + readonly typeHashByNode: ReadonlyMap; + readonly methodHashByNode: ReadonlyMap; + readonly variableHashByNode: ReadonlyMap; + readonly fieldHashByNode: ReadonlyMap; + readonly parameterHashByNode: ReadonlyMap; + readonly importRowByNode: ReadonlyMap; + readonly emittedExpressions: readonly { node: ts.Node; row: TsExpressionRegistry }[]; + readonly expressionRowByNode: ReadonlyMap; + readonly callSiteByNode: ReadonlyMap; + readonly callNodes: readonly { node: ts.Node }[]; + /** Type-alias name -> RHS node, so `const f: Callback = …; f()` has a target. */ + readonly typeAliasTargetByName: ReadonlyMap; +} + +/** + * A call the parser deliberately left for the engine, with the hop it needs named. + * + * Not a work queue any more. It was one when this parser tried to follow imports + * itself; now it exists so `ts-ir-completeness.ts` can ask, per call site, + * whether the FACTS an engine needs to finish the join were actually emitted. + * The distinction matters: an unresolved call with a complete hop chain is the + * parser working as designed, and an unresolved call with a missing hop is a + * parser bug. Only the second is worth fixing here. + */ +export interface EngineHandoff { + readonly callSite: TsCallSiteRegistry; + /** Which hop the engine has to make. */ + readonly hop: 'IMPORTED_NAME' | 'RECEIVER_DECLARED_TYPE' | 'INFERRED_RECEIVER'; + /** The locally bound name the engine starts from — an import binding, or `""`. */ + readonly localName: string; + /** + * The receiver's declared type AS WRITTEN — `Row[]`, `Promise`, + * `Parser.SyntaxNode`. Not reduced: reduction is what produced the one wrong + * link this parser has emitted, and it is the engine that knows how to read a + * type expression. + */ + readonly receiverTypeName: string; + readonly declaringModuleHash: string; +} + +export interface LocalResolutionResult { + readonly handoffs: readonly EngineHandoff[]; + readonly stats: ResolutionStats; +} + +export interface ResolutionStats { + callSites: number; + resolvedLocally: number; + externalTerminal: number; + synthesized: number; + unresolved: number; + /** Resolution outcome by receiver shape — the number a fixture-only win hides. */ + byReceiverKind: Map; +} + +export class TsLocalResolver { + private readonly sf: ts.SourceFile; + /** + * Declared type name -> a local `ts_type` row. + * + * Used ONLY for the same-file `extends` chain, where the base name was written + * in the same declaration and no shadowing question arises. Never for a + * receiver's type: see {@link localTypeAt} for why a flat table is the wrong + * instrument there. + */ + private readonly typeByName = new Map(); + /** `ts_type` hash -> its methods, so a member lookup is one map hit. */ + private readonly methodsByOwner = new Map(); + /** + * Owner `declarationGroupKey` -> every method of the MERGED type. + * + * The one keyed on a single declaration is not enough and the difference is + * not academic. `interface Store { read(k): string|undefined }` and + * `interface Store { read(k, f): string }` are ONE type with an overloaded + * `read`; looking members up on one declaration finds one overload and + * resolves `store.read("a","b")` to the single-parameter signature — a wrong + * answer that is indistinguishable from a right one downstream. + */ + private readonly methodsByOwnerGroup = new Map(); + /** Declared type name -> the group key of the merged type it names. */ + private readonly groupKeyByTypeName = new Map(); + private readonly fieldsByOwner = new Map(); + /** `declarationGroupKey` -> every declaration in the group. This IS the overload set. */ + private readonly methodsByGroup = new Map(); + private readonly variableByHash = new Map(); + /** Group key -> every declaration of the merged type, for heritage walking. */ + private readonly typeDeclarationsByGroup = new Map(); + private readonly methodByHash = new Map(); + private readonly handoffs: EngineHandoff[] = []; + private readonly stats: ResolutionStats = { + callSites: 0, + resolvedLocally: 0, + externalTerminal: 0, + synthesized: 0, + unresolved: 0, + byReceiverKind: new Map(), + }; + + constructor(private readonly input: LocalResolutionInput) { + this.sf = input.sourceFile; + const groupByTypeHash = new Map(); + for (const type of input.types) { + groupByTypeHash.set(type.getHash(), type.declarationGroupKey); + if (type.name === '') { + continue; + } + if (!this.typeByName.has(type.name)) { + this.typeByName.set(type.name, type); + this.groupKeyByTypeName.set(type.name, type.declarationGroupKey); + } + const declarations = this.typeDeclarationsByGroup.get(type.declarationGroupKey); + if (declarations) { + declarations.push(type); + } else { + this.typeDeclarationsByGroup.set(type.declarationGroupKey, [type]); + } + } + for (const method of input.methods) { + const owner = method.tsTypeLinkHash; + const list = this.methodsByOwner.get(owner); + if (list) { + list.push(method); + } else { + this.methodsByOwner.set(owner, [method]); + } + if (method.declarationGroupKey !== '') { + const group = this.methodsByGroup.get(method.declarationGroupKey); + if (group) { + group.push(method); + } else { + this.methodsByGroup.set(method.declarationGroupKey, [method]); + } + } + const ownerGroup = groupByTypeHash.get(owner); + if (ownerGroup !== undefined) { + const list = this.methodsByOwnerGroup.get(ownerGroup); + if (list) { + list.push(method); + } else { + this.methodsByOwnerGroup.set(ownerGroup, [method]); + } + } + this.methodByHash.set(method.getHash(), method); + } + for (const field of input.fields) { + const list = this.fieldsByOwner.get(field.tsTypeLinkHash); + if (list) { + list.push(field); + } else { + this.fieldsByOwner.set(field.tsTypeLinkHash, [field]); + } + } + for (const variable of input.variables) { + this.variableByHash.set(variable.getHash(), variable); + } + } + + run(): LocalResolutionResult { + this.resolveIdentifierReferences(); + this.resolveCalls(); + return { handoffs: this.handoffs, stats: this.stats }; + } + + // ------------------------------------------------------------------------- + // identifier references + // ------------------------------------------------------------------------- + + private resolveIdentifierReferences(): void { + for (const { node, row } of this.input.emittedExpressions) { + if (!ts.isIdentifier(node)) { + continue; + } + // A property NAME is not a scope lookup: `a.length` does not resolve + // `length` against the lexical chain, and doing so would bind it to any + // local variable of that name — a wrong answer that looks like a right one. + // An object-literal KEY is the same shape of mistake: `{ amount_cents: 1 }` + // beside a local `amount_cents` would bind the key to the variable. + if (row.edgeRole === TsEdgeRole.PROPERTY_NAME + || row.edgeRole === TsEdgeRole.OBJECT_PROPERTY_KEY) { + continue; + } + const binding = this.lookup(node, node.text); + if (!binding) { + if (AMBIENT_GLOBALS.has(node.text)) { + // Declared outside this analysis. An honest terminal, and the engine + // closes it from `lib_ts_*`. + row.setReference(TsReferencedEntityKind.AMBIENT_GLOBAL, '', node.text); + } + continue; + } + const resolved = this.rowFor(binding); + row.setReference(resolved.kind, resolved.hash, binding.name); + } + } + + /** + * The innermost binder scope containing `node`. + * + * Found by walking AST parents against the binder's scope table, never by + * position comparison: two scopes can begin at the same offset, and a range + * test would pick whichever was inserted first. + */ + private scopeFor(node: ts.Node): TsScope | undefined { + let current: ts.Node | undefined = node.parent; + while (current) { + const scope = this.input.binder.scopeByNode.get(nodeId(current, this.sf)); + if (scope) { + return scope; + } + current = current.parent; + } + return this.input.binder.fileScope; + } + + /** Walks the scope chain outward. Block table first: a `let` shadows a `var` of one name. */ + private lookup(node: ts.Node, name: string): BoundDeclaration | undefined { + const escaped = escapeName(name); + let scope = this.scopeFor(node); + while (scope) { + const blockMatch = scope.blockTable.get(escaped); + if (blockMatch && blockMatch.length > 0) { + return blockMatch[0]; + } + const varMatch = scope.varTable.get(escaped); + if (varMatch && varMatch.length > 0) { + return varMatch[0]; + } + scope = scope.parent; + } + return undefined; + } + + private rowFor(binding: BoundDeclaration): { + kind: TsReferencedEntityKind; + hash: string; + } { + const id = nodeId(binding.node, this.sf); + switch (binding.kind) { + case TsBoundKind.VariableDeclaration: { + return { + kind: TsReferencedEntityKind.VARIABLE, + hash: this.input.variableHashByNode.get(id) ?? '', + }; + } + case TsBoundKind.BindingElement: { + // A destructured VARIABLE name now has a row of its own, so resolve to + // it: `const { a } = o` gives a reference to `a` the declaration of `a` + // rather than the whole pattern. + const own = this.input.variableHashByNode.get(id); + if (own !== undefined && own !== '') { + return { kind: TsReferencedEntityKind.VARIABLE, hash: own }; + } + // A destructured PARAMETER name now has a row of its own too, so + // `function f({ helper }: Ctx)` gives a reference to `helper` the + // declaration of `helper` rather than the pattern that contains it. + const ownParameter = this.input.parameterHashByNode.get(id); + if (ownParameter !== undefined && ownParameter !== '') { + return { kind: TsReferencedEntityKind.PARAMETER, hash: ownParameter }; + } + // A PARAMETER pattern still has no per-element row -- parameters are a + // different relation -- so it resolves to the parameter that binds it. It resolves to whichever declaration binds the + // pattern -- a VariableDeclaration for `const {a} = x`, a Parameter for + // `({a}) => ...`. Both forms occur, and a walk that looks only for the + // former does not stop at the arrow: `([k]) => ...` inside + // `const values = ...` resolved `k` to `values`. + const owner = enclosingBindingOwner(binding.node); + if (owner === undefined) { + return { kind: TsReferencedEntityKind.VARIABLE, hash: '' }; + } + if (ts.isParameter(owner)) { + return { + kind: TsReferencedEntityKind.PARAMETER, + hash: this.input.parameterHashByNode.get(nodeId(owner, this.sf)) ?? '', + }; + } + return { + kind: TsReferencedEntityKind.VARIABLE, + hash: this.input.variableHashByNode.get(nodeId(owner, this.sf)) ?? '', + }; + } + case TsBoundKind.Parameter: { + return { + kind: TsReferencedEntityKind.PARAMETER, + hash: this.input.parameterHashByNode.get(id) ?? '', + }; + } + case TsBoundKind.FunctionDeclaration: + // A NAMED function expression's own name, visible only inside its body. + // It has a `ts_method` row like any other function-shaped declaration, so + // it resolves to one — without this case the recursive call in + // `(function scan(d) { … scan(d) … })(root)` resolves to nothing. + case TsBoundKind.FunctionExpression: { + return { + kind: TsReferencedEntityKind.METHOD, + hash: this.input.methodHashByNode.get(id) ?? '', + }; + } + case TsBoundKind.ClassDeclaration: + case TsBoundKind.InterfaceDeclaration: + case TsBoundKind.TypeAliasDeclaration: + case TsBoundKind.EnumDeclaration: { + return { + kind: TsReferencedEntityKind.TYPE, + hash: this.input.typeHashByNode.get(id) ?? '', + }; + } + case TsBoundKind.ModuleDeclaration: { + return { + kind: TsReferencedEntityKind.NAMESPACE, + hash: this.input.typeHashByNode.get(id) ?? '', + }; + } + case TsBoundKind.ImportBinding: { + return { + kind: TsReferencedEntityKind.IMPORT_BINDING, + hash: this.input.importRowByNode.get(id)?.getHash() ?? '', + }; + } + default: { + return { kind: TsReferencedEntityKind.UNKNOWN, hash: '' }; + } + } + } + + // ------------------------------------------------------------------------- + // calls + // ------------------------------------------------------------------------- + + private resolveCalls(): void { + for (const { node } of this.input.callNodes) { + const callSite = this.input.callSiteByNode.get(nodeId(node, this.sf)); + if (!callSite) { + continue; + } + this.stats.callSites += 1; + const before = this.stats.resolvedLocally + this.stats.externalTerminal + + this.stats.synthesized; + this.resolveOneCall(node, callSite); + const after = this.stats.resolvedLocally + this.stats.externalTerminal + + this.stats.synthesized; + if (after === before) { + this.stats.unresolved += 1; + } + const shape = callSite.receiverKind; + const bucket = this.stats.byReceiverKind.get(shape) + ?? { total: 0, resolved: 0 }; + bucket.total += 1; + if (after !== before) { + bucket.resolved += 1; + } + this.stats.byReceiverKind.set(shape, bucket); + } + } + + private resolveOneCall(node: ts.Node, callSite: TsCallSiteRegistry): void { + const callee = calleeOf(node); + if (!callee) { + return; + } + const argumentCount = callArgumentCount(node); + + if (ts.isNewExpression(node)) { + this.resolveConstruction(callee, callSite, argumentCount); + return; + } + if (callee.kind === ts.SyntaxKind.SuperKeyword) { + this.resolveSuperConstruction(node, callSite, argumentCount); + return; + } + if (ts.isIdentifier(callee)) { + this.resolveDirectCall(callee, callSite, argumentCount); + return; + } + if (ts.isPropertyAccessExpression(callee)) { + this.resolveMemberCall(callee, callSite, argumentCount); + return; + } + // An element-access callee, a call on a call result, an IIFE. All need an + // inferred type, so the parser records the shape and stops. + } + + private resolveDirectCall( + callee: ts.Identifier, + callSite: TsCallSiteRegistry, + argumentCount: number + ): void { + const binding = this.lookup(callee, callee.text); + if (!binding) { + if (AMBIENT_GLOBALS.has(callee.text)) { + callSite.setExternalTarget(TsResolvedTargetKind.LIB_SIGNATURE, + TsResolutionEvidence.AMBIENT_GLOBAL); + this.stats.externalTerminal += 1; + } + return; + } + if (binding.kind === TsBoundKind.ImportBinding) { + // Handed to the engine, NOT followed. The `ts_expression` row for this + // identifier already points at the `ts_import` row, and that row carries + // `resolvedFilePath` from `ts.resolveModuleName` — which is the whole hop. + // Chasing it here is `type-resolution.dl` in TypeScript. + this.handoffs.push({ + callSite, + hop: 'IMPORTED_NAME', + localName: callee.text, + receiverTypeName: '', + declaringModuleHash: this.input.moduleHash, + }); + return; + } + if (binding.kind === TsBoundKind.FunctionDeclaration + || binding.kind === TsBoundKind.FunctionExpression) { + const hash = this.input.methodHashByNode.get(nodeId(binding.node, this.sf)); + const method = hash ? this.methodByHash.get(hash) : undefined; + if (method) { + this.chooseFromGroup(method, callSite, argumentCount, + TsResolutionEvidence.LOCAL_BINDING); + } + return; + } + if (binding.kind === TsBoundKind.VariableDeclaration + || binding.kind === TsBoundKind.Parameter) { + // The DECLARED type wins over whatever was assigned, and this is not a + // preference — it is what tsc does. For + // `const f: (s: S) => string = (s) => s.id`, `getResolvedSignature` + // returns the ANNOTATION's signature, not the arrow's. A parser that + // offers the arrow disagrees with the oracle on every such call, and the + // fixture corpus has five of them. + const declared = this.declaredCallSignaturesOf(binding); + // One arm and several go through the same choice, because `chooseByArity` + // returns a sole candidate unchanged — so the single-signature path + // behaves exactly as it did. + const chosen = declared.length > 0 + ? chooseByArity(declared, argumentCount) + : undefined; + if (chosen) { + const isSet = declared.length > 1; + this.applyTarget(callSite, chosen, isSet ? declared.indexOf(chosen) : undefined, + declared.length, isSet, TsResolutionEvidence.DECLARED_RECEIVER_TYPE); + return; + } + if (binding.kind !== TsBoundKind.VariableDeclaration) { + return; + } + const variableHash = this.input.variableHashByNode.get(nodeId(binding.node, this.sf)); + const variable = variableHash ? this.variableByHash.get(variableHash) : undefined; + // THE arrow-function path, for an UNANNOTATED binding: `const f = () => {}; + // f()`. 161 measured targets are arrows and none has a name a call site + // could match — the variable is the only route to them. + // + // It is ALSO the path for an annotation that declares a set arity cannot + // narrow: `MessagePattern`'s four arms are all-optional, so every one + // admits the call and only the ARGUMENT TYPES separate them, which is the + // checker's job. The target stays the initialiser — that is the body that + // runs, and it is what the engine's `parser_resolved` projection reads + // (`overloadCandidateCount` is bound to `_` there, so dropping the target + // to report a count would cost the engine a resolution and give it + // nothing). What changes is that the row no longer claims ONE candidate + // when the annotation declares four. + const bound = variable?.getBoundFunctionLinkHash() ?? ''; + if (bound !== '') { + const method = this.methodByHash.get(bound); + if (method) { + this.applyTarget(callSite, method, undefined, Math.max(declared.length, 1), false, + TsResolutionEvidence.LOCAL_BINDING); + } + } + return; + } + if (binding.kind === TsBoundKind.ClassDeclaration) { + // A class called without `new` is an error in TypeScript, so there is + // nothing to resolve; recording a constructor here would be a fact about + // code that does not compile. + return; + } + } + + private resolveConstruction( + callee: ts.Node, + callSite: TsCallSiteRegistry, + argumentCount: number + ): void { + if (!ts.isIdentifier(callee)) { + return; + } + const binding = this.lookup(callee, callee.text); + if (!binding) { + if (AMBIENT_GLOBALS.has(callee.text)) { + callSite.setExternalTarget(TsResolvedTargetKind.LIB_SIGNATURE, + TsResolutionEvidence.AMBIENT_GLOBAL); + this.stats.externalTerminal += 1; + } + return; + } + if (binding.kind === TsBoundKind.ImportBinding) { + this.handoffs.push({ + callSite, + hop: 'IMPORTED_NAME', + localName: callee.text, + receiverTypeName: '', + declaringModuleHash: this.input.moduleHash, + }); + return; + } + if (binding.kind !== TsBoundKind.ClassDeclaration) { + return; + } + const typeHash = this.input.typeHashByNode.get(nodeId(binding.node, this.sf)); + if (!typeHash) { + return; + } + this.resolveConstructorOf(typeHash, callSite, argumentCount); + } + + private resolveConstructorOf( + typeHash: string, + callSite: TsCallSiteRegistry, + argumentCount: number + ): void { + const constructors = (this.methodsByOwner.get(typeHash) ?? []) + .filter((m) => m.name === ''); + if (constructors.length === 0) { + // 2.3% of measured call sites: an IMPLICIT constructor. There is no + // declaration node anywhere, so a missing row would be indistinguishable + // from a resolution failure. This is the honest terminal for it. + callSite.setResolution({ + resolvedSignatureLinkHash: '', + resolvedGroupKey: '', + resolvedTargetKind: TsResolvedTargetKind.SYNTHESIZED_NO_DECLARATION, + resolvedOverloadIndex: undefined, + overloadCandidateCount: 1, + isOverloadResolved: false, + resolutionEvidence: TsResolutionEvidence.LOCAL_BINDING, + isAmbientTarget: false, + }); + this.stats.synthesized += 1; + return; + } + const chosen = chooseByArity(constructors, argumentCount); + if (chosen) { + this.applyTarget(callSite, chosen, constructors.length > 1 + ? constructors.indexOf(chosen) + : undefined, constructors.length, constructors.length > 1, + TsResolutionEvidence.LOCAL_BINDING); + return; + } + this.recordCandidatesOnly(callSite, constructors.length); + } + + private resolveSuperConstruction( + node: ts.Node, + callSite: TsCallSiteRegistry, + argumentCount: number + ): void { + const baseName = this.extendsBaseNameOf(node); + if (baseName === '') { + return; + } + // The base class NAME AS WRITTEN. That plus this module and the import row + // is everything the engine needs; whether the base is in this file or + // imported is its join to make. + callSite.setReceiverTypeName(baseName); + const local = this.localTypeAt(node, baseName); + if (local) { + this.resolveConstructorOf(local.getHash(), callSite, argumentCount); + return; + } + this.handoffs.push({ + callSite, + hop: 'RECEIVER_DECLARED_TYPE', + localName: '', + receiverTypeName: baseName, + declaringModuleHash: this.input.moduleHash, + }); + } + + /** The `extends` clause's base NAME as written, for the class enclosing `node`. */ + private extendsBaseNameOf(node: ts.Node): string { + let current: ts.Node | undefined = node.parent; + while (current) { + if (ts.isClassLike(current)) { + for (const clause of current.heritageClauses ?? []) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue; + } + const first = clause.types[0]; + if (first) { + return first.expression.getText(this.sf); + } + } + return ''; + } + current = current.parent; + } + return ''; + } + + private resolveMemberCall( + callee: ts.PropertyAccessExpression, + callSite: TsCallSiteRegistry, + argumentCount: number + ): void { + const receiver = unwrapParentheses(callee.expression); + const member = ts.isPrivateIdentifier(callee.name) ? callee.name.text : callee.name.text; + + if (receiver.kind === ts.SyntaxKind.ThisKeyword) { + const owner = this.enclosingType(callee); + if (!owner) { + return; + } + callSite.setReceiverTypeName(owner.name); + // Same file, one lookup: the member is on the enclosing class or on a base + // declared alongside it. When it is on a base that is IMPORTED, this finds + // nothing and the call is handed off — the engine follows the heritage row + // and the import, which is what `ts_type_heritage.inheritsMembers` is for. + if (!this.resolveMemberOfType(owner, member, callSite, argumentCount, + TsResolutionEvidence.THIS_MEMBER)) { + this.handoffs.push({ + callSite, + hop: 'RECEIVER_DECLARED_TYPE', + localName: '', + receiverTypeName: owner.name, + declaringModuleHash: this.input.moduleHash, + }); + } + return; + } + if (ts.isPropertyAccessExpression(receiver) || ts.isNonNullExpression(receiver)) { + // A property CHAIN: `this.repo.find()`, `config.db.connect()`. Every hop + // is a join from a declared type name to a declaration and then to the + // next annotation — which is exactly what the engine's name-to-type layer + // does, over a fact base that has all the files. The parser's job is to + // make sure the hops are PRESENT: the receiver expression row, its + // `referencedEntityHash`, and the field's `typeReferenceLinkHash` are all + // emitted, so the chain is walkable without the parser walking it. + this.handoffs.push({ + callSite, + hop: 'RECEIVER_DECLARED_TYPE', + localName: '', + receiverTypeName: '', + declaringModuleHash: this.input.moduleHash, + }); + return; + } + if (receiver.kind === ts.SyntaxKind.SuperKeyword) { + const baseName = this.extendsBaseNameOf(callee); + if (baseName === '') { + return; + } + callSite.setReceiverTypeName(baseName); + const base = this.localTypeAt(callee, baseName); + if (base && this.resolveMemberOfType(base, member, callSite, argumentCount, + TsResolutionEvidence.SUPER_MEMBER)) { + return; + } + this.handoffs.push({ + callSite, + hop: 'RECEIVER_DECLARED_TYPE', + localName: '', + receiverTypeName: baseName, + declaringModuleHash: this.input.moduleHash, + }); + return; + } + if (!ts.isIdentifier(receiver)) { + // A call result, an element access, an IIFE. Each needs the receiver's + // INFERRED type, which is the checker's answer and not the parser's. + return; + } + + const binding = this.lookup(receiver, receiver.text); + if (!binding) { + if (AMBIENT_GLOBALS.has(receiver.text)) { + callSite.setReceiverTypeName(receiver.text); + callSite.setExternalTarget(TsResolvedTargetKind.LIB_SIGNATURE, + TsResolutionEvidence.AMBIENT_GLOBAL); + this.stats.externalTerminal += 1; + } + return; + } + if (binding.kind === TsBoundKind.ImportBinding) { + this.handoffs.push({ + callSite, + hop: 'IMPORTED_NAME', + localName: receiver.text, + receiverTypeName: '', + declaringModuleHash: this.input.moduleHash, + }); + return; + } + if (binding.kind === TsBoundKind.ModuleDeclaration) { + // `Namespace.fn()` — the member lives in the namespace's own table, so + // it is a group-key lookup rather than a member lookup. + const nsGroup = binding.declarationGroupKey; + const candidates = this.methodsByGroup.get( + groupKeyForNamespaceMember(nsGroup, member) + ); + callSite.setReceiverTypeName(binding.name); + if (candidates && candidates.length > 0) { + const chosen = chooseByArity(candidates, argumentCount); + if (chosen) { + this.applyTarget(callSite, chosen, + candidates.length > 1 ? candidates.indexOf(chosen) : undefined, + candidates.length, candidates.length > 1, + TsResolutionEvidence.NAMESPACE_QUALIFIED); + return; + } + this.recordCandidatesOnly(callSite, candidates.length); + } + return; + } + if (binding.kind === TsBoundKind.EnumDeclaration + || binding.kind === TsBoundKind.ClassDeclaration) { + // A STATIC member call: the receiver names the type itself. + callSite.setReceiverTypeName(binding.name); + const typeHash = this.input.typeHashByNode.get(nodeId(binding.node, this.sf)); + const type = typeHash ? this.typeByHash(typeHash) : undefined; + if (type && this.resolveMemberOfType(type, member, callSite, argumentCount, + TsResolutionEvidence.DECLARED_RECEIVER_TYPE, true)) { + return; + } + this.handoffs.push({ + callSite, + hop: 'RECEIVER_DECLARED_TYPE', + localName: '', + receiverTypeName: binding.name, + declaringModuleHash: this.input.moduleHash, + }); + return; + } + + // PATH 1 — the primary mechanism, and the reason this schema is Java-shaped. + // The receiver's DECLARED type is written at its declaration site, so the + // hop from receiver to type is syntax rather than inference. + const declaredTypeName = this.declaredTypeNameOf(binding); + if (declaredTypeName === '') { + return; + } + // AS WRITTEN. `Row[]`, `Promise` and `Parser.SyntaxNode` go into the + // column verbatim, because the engine is what knows how to read a type + // expression and reducing it here is what produced the one wrong link this + // parser has ever emitted. + callSite.setReceiverTypeName(declaredTypeName); + const type = this.localTypeAt(receiver, declaredTypeName); + if (type && this.resolveMemberOfType(type, member, callSite, argumentCount, + TsResolutionEvidence.DECLARED_RECEIVER_TYPE)) { + return; + } + this.handoffs.push({ + callSite, + hop: 'RECEIVER_DECLARED_TYPE', + localName: '', + receiverTypeName: declaredTypeName, + declaringModuleHash: this.input.moduleHash, + }); + } + + /** + * The `ts_type` an annotation names, resolved THROUGH THE SCOPE CHAIN. + * + * Two guards, and each closes a whole class of wrong link. + * + * **Only a bare identifier counts.** `Row[]`, `readonly Row[]`, + * `Promise`, `Map`, `Row | null`, `Parser.SyntaxNode` and + * `string` all return nothing, because none of them NAMES a declaration — + * they name `Array`, `Promise`, `Map`, a union, an imported namespace member + * and a primitive. An earlier version reduced `Row[]` to `Row` and looked + * `Row` up, so a project type with a member colliding with an `Array` method + * would have taken a call belonging to `Array`. Refusing anything that is not + * a bare identifier fixes the class with no list to maintain. + * + * **The name is resolved from the RECEIVER'S POSITION, not from a flat table.** + * This is the guard the corpus caught: + * + * ```ts + * const map = new Map(); // the GLOBAL Map + * map.get("k"); // → lib Map.get + * + * function elsewhere() { + * class Map { get(k: string) { … } } // a LOCAL Map, in another scope + * } + * ``` + * + * A per-file map keyed by name finds the local `class Map` and resolves + * `map.get` to it — a wrong target that looks exactly like a right one. Asking + * the binder what `Map` means AT THAT POSITION gives the answer tsc gives, + * and makes shadowing correct by construction rather than by exclusion list. + */ + private localTypeAt(node: ts.Node, annotation: string): TsTypeRegistry | undefined { + const text = annotation.trim(); + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(text)) { + return undefined; + } + const binding = this.lookup(node, text); + if (!binding) { + return undefined; + } + // Only a TYPE-space declaration can be a receiver's type. A variable or a + // parameter of the same name shadows the type for VALUE lookups and must + // not be mistaken for one here. + if (binding.kind !== TsBoundKind.ClassDeclaration + && binding.kind !== TsBoundKind.InterfaceDeclaration + && binding.kind !== TsBoundKind.TypeAliasDeclaration + && binding.kind !== TsBoundKind.EnumDeclaration) { + return undefined; + } + const hash = this.input.typeHashByNode.get(nodeId(binding.node, this.sf)); + return hash ? this.typeByHash(hash) : undefined; + } + + /** + * Finds a member on a type declared IN THIS FILE, over the merged group and + * the part of the `extends` chain that is also in this file. + * + * Returns whether it resolved, so the caller can hand the call to the engine + * instead of leaving it silently empty. A base class one import away is the + * common miss and it is not a gap: `ts_type_heritage.inheritsMembers` plus the + * import row is the hop, and the engine makes it. + * + * Walking an `IMPLEMENTS_CLAUSE` row here would be correct in Java and wrong + * here — `implements` inherits nothing — so only `extends` is followed. + */ + private resolveMemberOfType( + type: TsTypeRegistry, + member: string, + callSite: TsCallSiteRegistry, + argumentCount: number, + evidence: TsResolutionEvidence, + staticOnly = false + ): boolean { + const escaped = escapeName(member); + const seen = new Set(); + let group: string | undefined = type.declarationGroupKey; + while (group !== undefined && !seen.has(group)) { + seen.add(group); + // Over the MERGED type: an overload contributed by a second declaration of + // the same interface in this file is a candidate like any other. + const candidates = (this.methodsByOwnerGroup.get(group) ?? []) + .filter((m) => m.escapedName === escaped && m.isStatic === staticOnly); + if (candidates.length > 0) { + const chosen = chooseByArity(candidates, argumentCount); + if (!chosen) { + this.recordCandidatesOnly(callSite, candidates.length); + return true; + } + this.applyTarget(callSite, chosen, + candidates.length > 1 ? candidates.indexOf(chosen) : undefined, + candidates.length, candidates.length > 1, evidence); + return true; + } + group = this.extendsGroupInThisFile(group); + } + return false; + } + + /** The `extends` base of a merged type, only when that base is declared in this file. */ + private extendsGroupInThisFile(group: string): string | undefined { + for (const declaration of this.typeDeclarationsByGroup.get(group) ?? []) { + const node = this.input.binder.declarationsInOrder + .find((d) => d.declarationGroupKey === declaration.declarationGroupKey)?.node; + const clauses = (node as { heritageClauses?: ts.NodeArray } | undefined) + ?.heritageClauses; + for (const clause of clauses ?? []) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue; + } + for (const type of clause.types) { + if (!ts.isIdentifier(type.expression)) { + continue; + } + const base = this.typeByName.get(type.expression.text); + if (base && base.declarationGroupKey !== group) { + return base.declarationGroupKey; + } + } + } + } + return undefined; + } + + private enclosingType(node: ts.Node): TsTypeRegistry | undefined { + let current: ts.Node | undefined = node.parent; + while (current) { + if (ts.isClassLike(current)) { + const hash = this.input.typeHashByNode.get(nodeId(current, this.sf)); + return hash ? this.typeByHash(hash) : undefined; + } + current = current.parent; + } + return undefined; + } + + private typeByHash(hash: string): TsTypeRegistry | undefined { + for (const type of this.input.types) { + if (type.getHash() === hash) { + return type; + } + } + return undefined; + } + + /** + * The receiver's DECLARED type name — the annotation written at its + * declaration site. + * + * 85.3% of parameters and 99.998% of ambient parameters carry one, which is + * the inverse of Python's 31.8% and the whole reason a syntax-directed parser + * gets useful resolution here without a binder-driven inference pass. + */ + private declaredTypeNameOf(binding: BoundDeclaration): string { + const annotated = binding.node as { type?: ts.TypeNode }; + if (annotated.type) { + return annotated.type.getText(this.sf); + } + if (binding.kind === TsBoundKind.VariableDeclaration + && ts.isVariableDeclaration(binding.node)) { + const initializer = binding.node.initializer; + // No annotation, but `new C()` names the type as plainly as an annotation + // would. This is a syntactic fact, not an inference: the constructor call + // is written down. + if (initializer && ts.isNewExpression(initializer) + && ts.isIdentifier(initializer.expression)) { + return initializer.expression.text; + } + } + return ''; + } + + /** + * The call signatures a binding's ANNOTATION denotes, in source order. + * + * Three shapes, all purely syntactic: + * `const f: (a: T) => R` the annotation IS a function type + * `const f: Callback` the annotation names an alias whose RHS is one + * `const f: { (a: T): R; ... }` the annotation is a type LITERAL whose + * members are call or construct signatures + * + * The third shape is why this returns a LIST. An overload set is often + * written as several call signatures in one type literal, and the arms are + * right there in the source: + * + * export const MessagePattern: { + * (metadata?: T): MethodDecorator; + * (metadata?: T, transport?: Transport): MethodDecorator; + * (metadata?: T, extras?: Record): MethodDecorator; + * (metadata?: T, transport?: Transport, extras?: ...): MethodDecorator; + * } = (metadata?, transportOrExtras?, maybeExtras?) => { ... }; + * + * Returning only a single signature meant every one of those fell through to + * the arrow initialiser, and the row then said `overloadCandidateCount = 1` + * and `isOverloadResolved = false` — asserting one candidate where tsc sees + * four, so a consumer concludes no overload choice is needed and never + * revisits. Measured on nest: three `MessagePattern` sites, each naming the + * implementation while `getResolvedSignature` named the two-parameter arm. + * + * No checker is involved: the signatures are already `ts_method` rows and + * `chooseByArity` picks among them the same way it does for `function` + * overloads. + * + * Still returns nothing, rather than a plausible guess, for anything that + * needs the checker — a generic instantiation, a NAMED interface with a call + * signature, an intersection, or an indexed access such as + * `StoreApi['setState']` (#88). + */ + private declaredCallSignaturesOf(binding: BoundDeclaration): readonly TsMethodRegistry[] { + const annotation = (binding.node as { type?: ts.TypeNode }).type; + if (!annotation) { + return []; + } + // One hop through a type alias, exactly as before; an alias to an alias + // still needs the checker. + const target = ts.isTypeReferenceNode(annotation) && ts.isIdentifier(annotation.typeName) + ? this.input.typeAliasTargetByName.get(annotation.typeName.text) + : annotation; + if (!target) { + return []; + } + if (ts.isFunctionTypeNode(target) || ts.isConstructorTypeNode(target)) { + return this.methodsForNodes([target]); + } + if (ts.isTypeLiteralNode(target)) { + return this.methodsForNodes(target.members.filter( + (member) => ts.isCallSignatureDeclaration(member) + || ts.isConstructSignatureDeclaration(member) + )); + } + return []; + } + + /** The `ts_method` rows already minted for these signature nodes, in order. */ + private methodsForNodes(nodes: readonly ts.Node[]): readonly TsMethodRegistry[] { + const out: TsMethodRegistry[] = []; + for (const node of nodes) { + const hash = this.input.methodHashByNode.get(nodeId(node, this.sf)); + const method = hash ? this.methodByHash.get(hash) : undefined; + if (method) { + out.push(method); + } + } + return out; + } + + private chooseFromGroup( + method: TsMethodRegistry, + callSite: TsCallSiteRegistry, + argumentCount: number, + evidence: TsResolutionEvidence + ): void { + const group = method.declarationGroupKey !== '' + ? this.methodsByGroup.get(method.declarationGroupKey) ?? [method] + : [method]; + const chosen = chooseByArity(group, argumentCount); + if (!chosen) { + this.recordCandidatesOnly(callSite, group.length); + return; + } + this.applyTarget(callSite, chosen, group.length > 1 ? group.indexOf(chosen) : undefined, + group.length, group.length > 1, evidence); + } + + private applyTarget( + callSite: TsCallSiteRegistry, + method: TsMethodRegistry, + overloadIndex: number | undefined, + candidateCount: number, + isOverloadResolved: boolean, + evidence: TsResolutionEvidence + ): void { + const bodiless = method.bodyPresence !== TsBodyPresence.HAS_BODY; + callSite.setResolution({ + // ONE SIGNATURE, never a name. + resolvedSignatureLinkHash: method.getHash(), + resolvedGroupKey: method.declarationGroupKey, + resolvedTargetKind: method.bodyPresence === TsBodyPresence.NO_BODY_AMBIENT + ? TsResolvedTargetKind.AMBIENT_SIGNATURE + : bodiless + ? TsResolvedTargetKind.PROJECT_SIGNATURE + : TsResolvedTargetKind.PROJECT_IMPLEMENTATION, + resolvedOverloadIndex: overloadIndex, + overloadCandidateCount: candidateCount, + isOverloadResolved, + resolutionEvidence: evidence, + // The column that stops a bodiless target being read as the code that + // runs. 44.3% of real targets are bodiless. + isAmbientTarget: bodiless, + }); + this.stats.resolvedLocally += 1; + } + + /** + * Records that a real overload set was found and NOT chosen from. + * + * This is the honest half of the 77.6% number: where arity admits several + * signatures, choosing needs argument TYPES. Recording the candidate count + * with an empty target says "a set was seen, none was picked" — which a rule + * can distinguish from "nothing was found", and a gate can measure. + */ + private recordCandidatesOnly(callSite: TsCallSiteRegistry, candidateCount: number): void { + callSite.setResolution({ + resolvedSignatureLinkHash: '', + resolvedGroupKey: '', + resolvedTargetKind: TsResolvedTargetKind.UNRESOLVED, + resolvedOverloadIndex: undefined, + overloadCandidateCount: candidateCount, + isOverloadResolved: false, + resolutionEvidence: TsResolutionEvidence.NONE, + isAmbientTarget: false, + }); + } +} + +/** + * The declaration that owns a binding pattern: a VariableDeclaration or a + * Parameter. + * + * The walk crosses ONLY binding-pattern nodes, so it cannot leave the + * declaration it started inside. An unbounded walk to the nearest + * VariableDeclaration leaves the function entirely when the pattern belongs to + * a parameter, and attributes the name to an unrelated outer variable. + */ +function enclosingBindingOwner(node: ts.Node): ts.VariableDeclaration | ts.ParameterDeclaration | undefined { + let current: ts.Node | undefined = node.parent; + while (current) { + if (ts.isVariableDeclaration(current)) { + return current; + } + if (ts.isParameter(current)) { + return current; + } + if (!ts.isBindingElement(current) + && !ts.isObjectBindingPattern(current) + && !ts.isArrayBindingPattern(current)) { + return undefined; + } + current = current.parent; + } + return undefined; +} + +function callArgumentCount(node: ts.Node): number { + if (ts.isCallExpression(node)) { + return node.arguments.length; + } + if (ts.isNewExpression(node)) { + return node.arguments?.length ?? 0; + } + return 0; +} + +/** + * Selects the ONE signature an argument count admits, or nothing. + * + * Deliberately refuses to pick when several remain. A parser that resolves by + * name and takes the first declaration is wrong on 77.6% of real overloaded + * calls, so a wrong pick here is worse than an empty column: the empty column + * becomes a measured rate, and the wrong pick becomes a fact nothing downstream + * can question. + */ +function chooseByArity( + candidatesIn: readonly TsMethodRegistry[], + argumentCount: number +): TsMethodRegistry | undefined { + if (candidatesIn.length === 1) { + return candidatesIn[0]; + } + // The IMPLEMENTATION is never the answer. §4.6: "it is NOT the signature a + // call resolves to -- tsc resolves to one of the overload signatures." Its + // parameter list is the UNION of the overloads it serves, so it admits every + // arity any of them admits, and counting it as a candidate makes an + // unambiguous set look ambiguous: `pick(a: string)` and + // `pick(a: string, b?: number)` both admit one argument, so a call with one + // argument resolved to nothing when exactly one real signature accepted it. + // + // Only dropped when a real signature remains -- a lone function is SOLE, not + // IMPLEMENTATION, so an ordinary call is untouched. + const withoutImplementation = candidatesIn.filter( + (candidate) => candidate.getSignatureRole() !== TsSignatureRole.IMPLEMENTATION + ); + const candidates = withoutImplementation.length > 0 ? withoutImplementation : candidatesIn; + if (candidates.length === 1) { + return candidates[0]; + } + const viable = candidates.filter((candidate) => { + const required = candidate.parameterCount - candidate.optionalParameterCount; + if (argumentCount < required) { + return false; + } + if (candidate.restParameterIndex !== undefined) { + return true; + } + return argumentCount <= candidate.parameterCount; + }); + return viable.length === 1 ? viable[0] : undefined; +} + + +/** + * A namespace member's group key. + * + * Rebuilt from the namespace's own group key and the member name, exactly as + * the binder built it, because `NS:` is the merge scope a namespace + * mints for its exported members. + */ +function groupKeyForNamespaceMember(namespaceGroupKey: string, member: string): string { + return declarationGroupKeyFor(`NS:${namespaceGroupKey}`, escapeName(member)); +} + +/** + * Names that resolve outside this analysis. + * + * A curated set, not "anything not found". The difference matters: an unfound + * name that is genuinely a project name is a resolution GAP and should be + * counted as one, while `console` is a resolution TERMINAL that the engine + * closes from `lib_ts_*`. Collapsing the two would report the gap as success. + */ +const AMBIENT_GLOBALS = new Set([ + 'console', 'Math', 'JSON', 'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', + 'BigInt', 'Date', 'RegExp', 'Error', 'TypeError', 'RangeError', 'SyntaxError', + 'EvalError', 'ReferenceError', 'URIError', 'AggregateError', 'Promise', 'Map', 'Set', + 'WeakMap', 'WeakSet', 'WeakRef', 'Proxy', 'Reflect', 'globalThis', 'Function', + 'ArrayBuffer', 'SharedArrayBuffer', 'DataView', 'Int8Array', 'Uint8Array', + 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', 'Int32Array', 'Uint32Array', + 'Float32Array', 'Float64Array', 'BigInt64Array', 'BigUint64Array', 'Atomics', + 'Intl', 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURI', 'decodeURI', + 'encodeURIComponent', 'decodeURIComponent', 'structuredClone', 'queueMicrotask', + 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'setImmediate', + 'process', 'Buffer', 'require', 'module', 'exports', '__dirname', '__filename', + 'fetch', 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'AbortController', + 'AbortSignal', 'Event', 'EventTarget', 'performance', 'crypto', + // Web/DOM globals. A curated set is the ONLY mechanism available for globals — + // unlike a type name, a global has no import row to point at — so this list + // exists where the equivalent list for TYPE names was deliberately removed. + // Measured on a third-party corpus: `btoa`, `atob` and `Headers` were 7 of 12 + // reported gaps, and all three are globals with nothing for the parser to link. + 'btoa', 'atob', 'Headers', 'Request', 'Response', 'FormData', 'Blob', 'File', + 'FileReader', 'ReadableStream', 'WritableStream', 'TransformStream', 'WebSocket', + 'BroadcastChannel', 'MessageChannel', 'MessagePort', 'Worker', 'navigator', 'window', + 'document', 'location', 'history', 'localStorage', 'sessionStorage', 'indexedDB', + 'alert', 'confirm', 'prompt', 'requestAnimationFrame', 'cancelAnimationFrame', + 'requestIdleCallback', 'MutationObserver', 'IntersectionObserver', 'ResizeObserver', + 'CustomEvent', 'DOMException', 'reportError', 'Image', 'Audio', 'XMLHttpRequest', +]); diff --git a/parser/src/parsers/typescript/extractors/ts-type-reference-extractor.ts b/parser/src/parsers/typescript/extractors/ts-type-reference-extractor.ts new file mode 100644 index 000000000..cf0cd72d4 --- /dev/null +++ b/parser/src/parsers/typescript/extractors/ts-type-reference-extractor.ts @@ -0,0 +1,732 @@ +import * as ts from 'typescript'; +import { nodeId } from '@/parsers/typescript/extractors/ts-binder'; + +import { TsTypeReferenceRegistry } from '@/analysis-types/typescript/TsTypeReferenceRegistry'; +import { TS_TYPE_REFERENCE_MAX_DEPTH } from '@/constants/typescript-constants'; +import { TsTypeParameterOwnerKind } from '@/enums/typescript/type-parameters'; +import { + TsReferenceOwnerKind, + TsTypeRefContext, + TsTypeRefKind, + TsWildcardVariance, +} from '@/enums/typescript/type-references'; +import { EntityUtils } from '@/utils/entity-utils'; + +/** + * Builds the `ts_type_reference` tree from a type node — schema §4.5. + * + * ## Why a tree and not a string + * + * `A | B | C` becomes one parent row with `childCount = 3` and three child rows, + * each carrying `position` and `parentReferenceHash`. The measurement settled + * this against the comma-set alternative three times over: the maximum union + * arity in real declaration files is **208**, members are arbitrary nested type + * nodes rather than names, and 45 nodes contain a nested union or intersection. + * + * `position` is **source** order and `childCount` is **source** arity. The + * checker normalises `boolean` into `true | false` and reorders members by type + * id, so `string | number | boolean` has source order `[string, number, + * boolean]` and checker order `[string, number, false, true]`. An oracle + * comparing member-wise against the checker would fail on correct output; it + * compares type-node trees instead. + * + * ## This is also a containment boundary + * + * Conditional, mapped, template-literal, `infer`, `keyof`, `typeof` and + * indexed-access nodes — 10,436 measured — live here and in no other relation. + * There is no path from any of them into `ts_expression`, so §3.3 holds + * structurally rather than by a filter that has to be remembered. + */ +export interface TypeReferenceOwner { + readonly ownerHash: string; + readonly ownerKind: TsReferenceOwnerKind; + readonly tsTypeLinkHash: string; + readonly tsModuleLinkHash: string; +} + +/** A planned child edge: the node and the context it occupies in its parent. */ +interface PlannedChild { + readonly node: ts.TypeNode; + readonly context: TsTypeRefContext; + readonly isOptionalElement?: boolean; + readonly isRestElement?: boolean; +} + +export class TsTypeReferenceExtractor { + private readonly rows: TsTypeReferenceRegistry[] = []; + + /** + * Called for every `FunctionType` / `ConstructorType` node encountered. + * + * A function type is BOTH a node in the type graph and a callable signature. + * It stays in this relation as a type node, and the declaration extractor + * mints a `ts_method` row for it so a call site can point at it — because tsc + * resolves `const f: (x: T) => R = (x) => …; f(x)` to the SIGNATURE, not to + * the arrow. A parser that offers only the arrow disagrees with + * `getResolvedSignature` on every such call. + */ + onFunctionType: + | (( + node: ts.FunctionTypeNode | ts.ConstructorTypeNode, + /** + * This node's OWN `ts_type_reference` hash — the signature's owner. + * + * A function type node is the only thing that can own a + * `FUNCTION_TYPE_SIGNATURE`, so the owner FK is the node itself rather + * than any enclosing declaration (§4.8.1). + */ + selfReferenceHash: string + ) => void) + | undefined; + + /** + * Called for every type parameter a TYPE-LEVEL construct declares. + * + * `[K in keyof T]` and `infer U` declare real parameters with real scopes and + * have no Java analogue, so no declaration walk reaches them — they live + * inside type nodes. 1,337 `infer` and 605 mapped types measured, so this is + * not a corner: without the hook, 1,942 declarations have no row. + */ + /** + * Called for every MEMBER of an anonymous type literal. + * + * `{ toCsv(): string }` declares a method that is a real call target — `r.toCsv()` + * resolves to it — and a type literal has no `ts_type` row for the member to + * hang off, so nothing else in the walk reaches it. Measured on this + * repository: 1,052 of 20,313 declaration-bearing nodes had no row, and every + * one of them was a type-literal member or a parameter of one. + * + * Fired from here rather than from the declaration walk because a type literal + * can be written anywhere a type can — an annotation, a type alias RHS, a + * union member, a type argument — and this is the only traversal that visits + * all of those. + */ + onTypeLiteralMember: + | ((member: ts.TypeElement, typeLiteralReferenceHash: string) => void) + | undefined; + + onTypeLevelParameter: + | (( + typeParameter: ts.TypeParameterDeclaration, + ownerHash: string, + ownerKind: TsTypeParameterOwnerKind + ) => void) + | undefined; + + constructor( + private readonly sourceFile: ts.SourceFile, + private readonly serviceVersionLinkHash: string, + /** Type-parameter names in lexical scope, so `T` is a TYPE_VARIABLE and not a TYPE_REFERENCE. */ + private readonly typeParametersInScope: () => ReadonlySet, + /** The declaration a type-variable name refers to, innermost scope first. */ + private readonly typeParameterDeclarationFor: + (name: string) => ts.TypeParameterDeclaration | undefined = () => undefined + ) {} + + /** + * Type-variable references awaiting their declaration's hash. + * + * Linked after the walk, because a reference can precede the row for the + * parameter it names -- `B extends A` mentions `A` while both are still being + * emitted -- and a link made too early would silently be empty for exactly + * the shadowing cases that matter. + */ + /** + * Type parameters declared by a TYPE, not by a declaration. + * + * A mapped type's `[K in keyof T]` and an `infer U` each declare one, and + * neither arrives through the declaration walk: that pushes scopes from a + * `typeParameters` ARRAY, and these nodes carry a single `typeParameter`. So + * `K` and `U` were never in scope, and a reference to them fell through to + * TYPE_REFERENCE -- a consumer then looked for a declared type of that name, + * found none, and reported a missing type where the right answer is that an + * unconstrained parameter has no members. + * + * Held here rather than in the declaration extractor because the scope is + * exactly this node's subtree, and only this walk knows where that ends. + */ + private readonly typeLevelParameterScope: ts.TypeParameterDeclaration[] = []; + + readonly pendingTypeVariableLinks: + { row: TsTypeReferenceRegistry; declaration: ts.TypeParameterDeclaration }[] = []; + + getRows(): readonly TsTypeReferenceRegistry[] { + return this.rows; + } + + /** + * The row emitted for a type NODE, by node identity. + * + * A signature declared inside a type -- a call signature, a function type, a + * type-literal method -- does not create its own return reference: the + * reference is emitted as part of the ENCLOSING type's tree, at depth 1. The + * signature row therefore has nothing to link to unless the row that was + * already emitted can be found again, which is what this is for. + */ + hashForTypeNode(node: ts.TypeNode): string { + return this.hashByTypeNode.get(nodeId(node, this.sourceFile)) ?? ''; + } + + private readonly hashByTypeNode = new Map(); + + /** A type-level parameter this walk introduced, innermost first. */ + private typeLevelDeclarationFor(name: string): ts.TypeParameterDeclaration | undefined { + for (let i = this.typeLevelParameterScope.length - 1; i >= 0; i -= 1) { + const declared = this.typeLevelParameterScope[i]!; + if (declared.name.text === name) { + return declared; + } + } + return undefined; + } + + /** The declaration walk's scope, plus the type-level parameters this walk added. */ + private allTypeParametersInScope(): ReadonlySet { + if (this.typeLevelParameterScope.length === 0) { + return this.typeParametersInScope(); + } + const all = new Set(this.typeParametersInScope()); + for (const declared of this.typeLevelParameterScope) { + all.add(declared.name.text); + } + return all; + } + + /** + * Emits the whole tree rooted at `node` and returns the ROOT row's hash. + * + * The root always has `depth = 0` and an empty `parentReferenceHash`, which is + * invariant 7 and which `type-hierarchy.dl` depends on in Java. + */ + extract( + node: ts.TypeNode, + context: TsTypeRefContext, + owner: TypeReferenceOwner, + isTypeOnlyPosition = true + ): string { + return this.emit(node, context, owner, '', 0, 0, isTypeOnlyPosition, {}); + } + + private emit( + node: ts.TypeNode, + context: TsTypeRefContext, + owner: TypeReferenceOwner, + parentReferenceHash: string, + position: number, + depth: number, + isTypeOnlyPosition: boolean, + flags: { isOptionalElement?: boolean; isRestElement?: boolean } + ): string { + const start = node.getStart(this.sourceFile); + const startPos = this.sourceFile.getLineAndCharacterOfPosition(start); + const endPos = this.sourceFile.getLineAndCharacterOfPosition(node.end); + const children = plannedChildren(node); + // The depth cap is 32 (OQ-5), and the measured maximum in 25.9 MB of real + // TypeScript is 19 — so this never fires on anything observed. It stays + // because a cap that can never fire is a cap nobody maintains, and when it + // does fire the row says so instead of losing a subtree silently. + const isTruncated = depth >= TS_TYPE_REFERENCE_MAX_DEPTH && children.length > 0; + + // ONE scope for both the kind and the name. They were computed from + // different sets once, so a mapped `K` was a TYPE_VARIABLE whose + // typeVariableName was empty -- classified and unnamed. + const inScope = this.allTypeParametersInScope(); + const row = new TsTypeReferenceRegistry({ + kind: kindOf(node, inScope), + context, + tsTypeLinkHash: owner.tsTypeLinkHash, + parentReferenceHash, + position, + depth, + typeName: simpleNameOf(node), + completeTypeName: EntityUtils.normalizeWhitespace(node.getText(this.sourceFile)), + entityName: entityNameOf(node, this.sourceFile), + typeVariableName: typeVariableNameOf(node, inScope), + arrayDimensions: arrayDimensionsOf(node), + wildcardVariance: varianceOf(node), + startLine: startPos.line + 1, + endLine: endPos.line + 1, + typeReferenceOwnerHash: owner.ownerHash, + referenceOwnerKind: owner.ownerKind, + tsModuleLinkHash: owner.tsModuleLinkHash, + // SOURCE arity, always, even when truncated — so a truncated row still + // says how many children it should have had. + childCount: children.length, + isTypeOnlyPosition, + importSpecifier: importSpecifierOf(node), + isOptionalElement: flags.isOptionalElement === true, + isRestElement: flags.isRestElement === true, + literalValue: literalValueOf(node, this.sourceFile), + isTruncated, + startColumn: startPos.character + 1, + serviceVersionLinkHash: this.serviceVersionLinkHash, + }); + this.rows.push(row); + // Keyed on the byte range, so the two nodes that share a start offset -- + // a type and the first type inside it -- do not collide. + this.hashByTypeNode.set(nodeId(node, this.sourceFile), row.getHash()); + // A type-variable reference names the parameter that declares it. Recorded + // for a back-patch rather than resolved here, because the row for that + // parameter may not exist yet. + if (ts.isTypeReferenceNode(node) + && ts.isIdentifier(node.typeName) + && this.allTypeParametersInScope().has(node.typeName.text)) { + const declaration = this.typeLevelDeclarationFor(node.typeName.text) + ?? this.typeParameterDeclarationFor(node.typeName.text); + if (declaration !== undefined) { + this.pendingTypeVariableLinks.push({ row, declaration }); + } + } + if (this.onFunctionType && (ts.isFunctionTypeNode(node) || ts.isConstructorTypeNode(node))) { + this.onFunctionType(node, row.getHash()); + } + if (this.onTypeLiteralMember && ts.isTypeLiteralNode(node)) { + for (const member of node.members) { + this.onTypeLiteralMember(member, row.getHash()); + } + } + if (this.onTypeLevelParameter) { + if (ts.isMappedTypeNode(node)) { + this.onTypeLevelParameter(node.typeParameter, row.getHash(), + TsTypeParameterOwnerKind.MAPPED_TYPE); + } else if (ts.isInferTypeNode(node)) { + this.onTypeLevelParameter(node.typeParameter, row.getHash(), + TsTypeParameterOwnerKind.INFER_TYPE); + } + } + + if (isTruncated) { + return row.getHash(); + } + // A parameter declared INSIDE a type is in scope only for part of that + // type, so it is pushed here and popped below rather than added to the + // declaration walk's stack -- which only ever sees `typeParameters` arrays. + // + // A MAPPED type's `[K in …]` is in scope for its own subtree. An `infer U` + // is not: it is written in a conditional's `extends` clause and referenced + // in the TRUE BRANCH, which is a SIBLING of that clause, not a descendant. + // So the conditional -- not the infer node -- is where the name enters + // scope, and pushing at the infer node fixed `K` and left `U` misfiled. + const introduced: ts.TypeParameterDeclaration[] = []; + if (ts.isMappedTypeNode(node)) { + introduced.push(node.typeParameter); + } else if (ts.isConditionalTypeNode(node)) { + collectInferParameters(node.extendsType, introduced); + } + for (const declared of introduced) { + this.typeLevelParameterScope.push(declared); + } + try { + let index = 0; + for (const child of children) { + this.emit(child.node, child.context, owner, row.getHash(), index, depth + 1, + isTypeOnlyPosition, { + isOptionalElement: child.isOptionalElement, + isRestElement: child.isRestElement, + }); + index += 1; + } + } finally { + for (let i = 0; i < introduced.length; i += 1) { + this.typeLevelParameterScope.pop(); + } + } + return row.getHash(); + } +} + +/** + * The children a type node contributes, in SOURCE order, each with the context + * it occupies. + * + * Computed before the parent row is built, because `childCount` is a column on + * the parent and invariant 6 checks it against the rows that actually point + * back. Deriving it from the node rather than counting emitted rows keeps the + * two from drifting when a branch is added below. + */ +function plannedChildren(node: ts.TypeNode): PlannedChild[] { + const out: PlannedChild[] = []; + const push = (child: ts.TypeNode | undefined, context: TsTypeRefContext, + extra?: { isOptionalElement?: boolean; isRestElement?: boolean }): void => { + if (child) { + out.push({ node: child, context, ...extra }); + } + }; + + if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { + for (const member of node.types) { + push(member, TsTypeRefContext.TYPE_ELEMENT); + } + return out; + } + if (ts.isArrayTypeNode(node)) { + push(node.elementType, TsTypeRefContext.TYPE_ELEMENT); + return out; + } + if (ts.isTupleTypeNode(node)) { + for (const element of node.elements) { + push(element, TsTypeRefContext.TYPE_ELEMENT); + } + return out; + } + if (ts.isNamedTupleMember(node)) { + push(node.type, TsTypeRefContext.TYPE_ELEMENT, { + isOptionalElement: node.questionToken !== undefined, + isRestElement: node.dotDotDotToken !== undefined, + }); + return out; + } + if (ts.isOptionalTypeNode(node)) { + push(node.type, TsTypeRefContext.TYPE_ELEMENT, { isOptionalElement: true }); + return out; + } + if (ts.isRestTypeNode(node)) { + push(node.type, TsTypeRefContext.TYPE_ELEMENT, { isRestElement: true }); + return out; + } + if (ts.isParenthesizedTypeNode(node) || ts.isTypeOperatorNode(node)) { + push(node.type, TsTypeRefContext.TYPE_ELEMENT); + return out; + } + if (ts.isTypeReferenceNode(node) || ts.isExpressionWithTypeArguments(node)) { + for (const argument of node.typeArguments ?? []) { + push(argument, TsTypeRefContext.TYPE_ARGUMENT); + } + return out; + } + if (ts.isFunctionTypeNode(node) || ts.isConstructorTypeNode(node)) { + for (const parameter of node.parameters) { + push(parameter.type, TsTypeRefContext.METHOD_PARAM, { + isOptionalElement: parameter.questionToken !== undefined, + isRestElement: parameter.dotDotDotToken !== undefined, + }); + } + push(node.type, TsTypeRefContext.METHOD_RETURN); + return out; + } + if (ts.isConditionalTypeNode(node)) { + push(node.checkType, TsTypeRefContext.CONDITIONAL_CHECK); + push(node.extendsType, TsTypeRefContext.CONDITIONAL_EXTENDS); + push(node.trueType, TsTypeRefContext.CONDITIONAL_TRUE); + push(node.falseType, TsTypeRefContext.CONDITIONAL_FALSE); + return out; + } + if (ts.isMappedTypeNode(node)) { + push(node.typeParameter.constraint, TsTypeRefContext.MAPPED_CONSTRAINT); + push(node.nameType, TsTypeRefContext.MAPPED_TEMPLATE); + push(node.type, TsTypeRefContext.TYPE_ELEMENT); + return out; + } + if (ts.isIndexedAccessTypeNode(node)) { + push(node.objectType, TsTypeRefContext.TYPE_ELEMENT); + push(node.indexType, TsTypeRefContext.TYPE_ELEMENT); + return out; + } + if (ts.isTemplateLiteralTypeNode(node)) { + for (const span of node.templateSpans) { + push(span.type, TsTypeRefContext.TEMPLATE_SPAN); + } + return out; + } + if (ts.isTypePredicateNode(node)) { + push(node.type, TsTypeRefContext.TYPE_PREDICATE_TARGET); + return out; + } + if (ts.isImportTypeNode(node)) { + for (const argument of node.typeArguments ?? []) { + push(argument, TsTypeRefContext.TYPE_ARGUMENT); + } + return out; + } + if (ts.isInferTypeNode(node)) { + push(node.typeParameter.constraint, TsTypeRefContext.TYPE_PARAM_BOUND); + return out; + } + if (ts.isTypeLiteralNode(node)) { + // An anonymous structural shape. Its members have no `ts_type` row of their + // own — 5,015 type literals measured, none with a name, a declaration or a + // merge identity — so each member's annotation hangs here as an element. + for (const member of node.members) { + const memberType = (member as { type?: ts.TypeNode }).type; + push(memberType, contextForTypeElement(member), { + isOptionalElement: (member as { questionToken?: ts.QuestionToken }).questionToken !== undefined, + }); + } + return out; + } + return out; +} + +function contextForTypeElement(member: ts.TypeElement): TsTypeRefContext { + if (ts.isMethodSignature(member) || ts.isCallSignatureDeclaration(member) + || ts.isConstructSignatureDeclaration(member)) { + return TsTypeRefContext.METHOD_RETURN; + } + if (ts.isIndexSignatureDeclaration(member)) { + return TsTypeRefContext.INDEX_SIGNATURE_VALUE; + } + return TsTypeRefContext.FIELD_TYPE; +} + +/** The primitive keyword type nodes. `PRIMITIVE` rather than `TYPE_REFERENCE`: they name no declaration. */ +const PRIMITIVE_KINDS = new Set([ + ts.SyntaxKind.AnyKeyword, ts.SyntaxKind.UnknownKeyword, ts.SyntaxKind.NumberKeyword, + ts.SyntaxKind.BigIntKeyword, ts.SyntaxKind.ObjectKeyword, ts.SyntaxKind.BooleanKeyword, + ts.SyntaxKind.StringKeyword, ts.SyntaxKind.SymbolKeyword, ts.SyntaxKind.VoidKeyword, + ts.SyntaxKind.UndefinedKeyword, ts.SyntaxKind.NeverKeyword, +]); + +function kindOf(node: ts.TypeNode, typeParameters: ReadonlySet): TsTypeRefKind { + if (PRIMITIVE_KINDS.has(node.kind)) { + return TsTypeRefKind.PRIMITIVE; + } + if (node.kind === ts.SyntaxKind.IntrinsicKeyword) { + return TsTypeRefKind.INTRINSIC; + } + if (ts.isLiteralTypeNode(node)) { + // `null` is written as a literal type node but denotes a primitive, not a + // literal member of some wider type. + return node.literal.kind === ts.SyntaxKind.NullKeyword + ? TsTypeRefKind.PRIMITIVE + : TsTypeRefKind.LITERAL; + } + if (ts.isTypeReferenceNode(node)) { + return ts.isIdentifier(node.typeName) && typeParameters.has(node.typeName.text) + ? TsTypeRefKind.TYPE_VARIABLE + : TsTypeRefKind.TYPE_REFERENCE; + } + if (ts.isExpressionWithTypeArguments(node)) { + return TsTypeRefKind.TYPE_REFERENCE; + } + if (ts.isArrayTypeNode(node)) { + return TsTypeRefKind.ARRAY; + } + if (ts.isTupleTypeNode(node)) { + return TsTypeRefKind.TUPLE; + } + if (ts.isUnionTypeNode(node)) { + return TsTypeRefKind.UNION; + } + if (ts.isIntersectionTypeNode(node)) { + return TsTypeRefKind.INTERSECTION; + } + if (ts.isFunctionTypeNode(node)) { + return TsTypeRefKind.FUNCTION_TYPE; + } + if (ts.isConstructorTypeNode(node)) { + return TsTypeRefKind.CONSTRUCTOR_TYPE; + } + if (ts.isTypeLiteralNode(node)) { + return TsTypeRefKind.TYPE_LITERAL; + } + if (ts.isConditionalTypeNode(node)) { + return TsTypeRefKind.CONDITIONAL; + } + if (ts.isMappedTypeNode(node)) { + return TsTypeRefKind.MAPPED; + } + if (ts.isTemplateLiteralTypeNode(node)) { + return TsTypeRefKind.TEMPLATE_LITERAL; + } + if (ts.isIndexedAccessTypeNode(node)) { + return TsTypeRefKind.INDEXED_ACCESS; + } + if (ts.isTypeQueryNode(node)) { + return TsTypeRefKind.TYPE_QUERY; + } + if (ts.isTypeOperatorNode(node)) { + return TsTypeRefKind.TYPE_OPERATOR; + } + if (ts.isInferTypeNode(node)) { + return TsTypeRefKind.INFER; + } + if (ts.isTypePredicateNode(node)) { + return TsTypeRefKind.TYPE_PREDICATE; + } + if (ts.isImportTypeNode(node)) { + return TsTypeRefKind.IMPORT_TYPE; + } + if (ts.isThisTypeNode(node)) { + return TsTypeRefKind.THIS_TYPE; + } + if (ts.isParenthesizedTypeNode(node)) { + return TsTypeRefKind.PARENTHESIZED; + } + if (ts.isRestTypeNode(node)) { + return TsTypeRefKind.REST; + } + if (ts.isOptionalTypeNode(node)) { + return TsTypeRefKind.OPTIONAL; + } + if (ts.isNamedTupleMember(node)) { + return TsTypeRefKind.NAMED_TUPLE_MEMBER; + } + return TsTypeRefKind.TYPE_REFERENCE; +} + +/** The rightmost identifier of a name-shaped type; `""` when the node names nothing. */ +export function simpleNameOf(node: ts.TypeNode): string { + if (ts.isTypeReferenceNode(node)) { + return rightmostName(node.typeName); + } + if (ts.isExpressionWithTypeArguments(node)) { + return rightmostExpressionName(node.expression); + } + if (ts.isTypeQueryNode(node)) { + return rightmostName(node.exprName); + } + if (ts.isImportTypeNode(node) && node.qualifier) { + return rightmostName(node.qualifier); + } + if (ts.isNamedTupleMember(node)) { + return node.name.text; + } + if (PRIMITIVE_KINDS.has(node.kind)) { + return ts.tokenToString(node.kind) ?? ''; + } + return ''; +} + +/** The full dotted path of a name-shaped type; `""` otherwise. */ +export function qualifiedPathOf(node: ts.TypeNode, sourceFile: ts.SourceFile): string { + if (ts.isTypeReferenceNode(node)) { + return node.typeName.getText(sourceFile); + } + if (ts.isExpressionWithTypeArguments(node)) { + return ts.isIdentifier(node.expression) || ts.isPropertyAccessExpression(node.expression) + ? node.expression.getText(sourceFile) + : ''; + } + return ''; +} + +function rightmostName(name: ts.EntityName): string { + return ts.isIdentifier(name) ? name.text : name.right.text; +} + +function rightmostExpressionName(expression: ts.Expression): string { + if (ts.isIdentifier(expression)) { + return expression.text; + } + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text; + } + return ''; +} + +function typeVariableNameOf(node: ts.TypeNode, typeParameters: ReadonlySet): string { + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) + && typeParameters.has(node.typeName.text)) { + return node.typeName.text; + } + if (ts.isInferTypeNode(node)) { + return node.typeParameter.name.text; + } + return ''; +} + +/** `T[][]` records `[][]`; anything else records nothing. */ +function arrayDimensionsOf(node: ts.TypeNode): string { + let dimensions = ''; + let current: ts.TypeNode = node; + while (ts.isArrayTypeNode(current)) { + dimensions += '[]'; + current = current.elementType; + } + return dimensions; +} + +/** + * Java's `wildcardVariance` slot, repurposed. + * + * TypeScript has no use-site wildcards, so the slot carries the operators that + * modify a type in place: `readonly T[]` and `unique symbol`. Same position, + * different language — which is the cross-language naming rule working as + * intended rather than false parity. + */ +function varianceOf(node: ts.TypeNode): string { + if (!ts.isTypeOperatorNode(node)) { + return ''; + } + if (node.operator === ts.SyntaxKind.ReadonlyKeyword) { + return TsWildcardVariance.READONLY; + } + if (node.operator === ts.SyntaxKind.UniqueKeyword) { + return TsWildcardVariance.UNIQUE; + } + return ''; +} + +/** + * The name a reference writes, WITHOUT its type arguments. + * + * `typeName` is only the rightmost segment (`Node`) and `completeTypeName` + * carries the arguments (`Outer.Inner.Node`), so neither is the qualified + * name a scope lookup needs. The AST holds it directly: a TypeReferenceNode's + * `typeName` is an EntityName and its `typeArguments` are a separate property, + * so this is a read rather than a derivation. + * + * Deriving it downstream means finding the first "<" by hand, and the obvious + * shortcut -- treating `typeName == completeTypeName` as the + * qualified/unqualified test -- misfiles every generic reference, because + * `Map` differs from `Map` for a reason that has nothing to do + * with qualification. + */ +function entityNameOf(node: ts.TypeNode, sourceFile: ts.SourceFile): string { + if (ts.isTypeReferenceNode(node)) { + return EntityUtils.normalizeWhitespace(node.typeName.getText(sourceFile)); + } + if (ts.isExpressionWithTypeArguments(node)) { + return EntityUtils.normalizeWhitespace(node.expression.getText(sourceFile)); + } + // An import type writes its entity after the specifier: `import("m").T`. + if (ts.isImportTypeNode(node)) { + if (node.qualifier !== undefined) { + return EntityUtils.normalizeWhitespace(node.qualifier.getText(sourceFile)); + } + // `typeof import("m")["x"]` says the same thing with brackets, and puts the + // member on the INDEXED ACCESS above instead of on a qualifier. Reading only + // the qualifier names the module and not the member -- the half that a scope + // lookup actually needs -- and leaves the bracket form to string arithmetic. + const parent = node.parent; + if (parent !== undefined + && ts.isIndexedAccessTypeNode(parent) + && parent.objectType === node + && ts.isLiteralTypeNode(parent.indexType) + && ts.isStringLiteral(parent.indexType.literal)) { + return parent.indexType.literal.text; + } + } + return ''; +} + +/** + * Every `infer X` name written inside a conditional's `extends` clause. + * + * Nested is normal -- `T extends Promise ? … : …` puts the infer under + * a type argument -- so the whole clause is walked rather than its top level. + */ +function collectInferParameters(node: ts.TypeNode, into: ts.TypeParameterDeclaration[]): void { + const visit = (current: ts.Node): void => { + if (ts.isInferTypeNode(current)) { + into.push(current.typeParameter); + } + ts.forEachChild(current, visit); + }; + visit(node); +} + +function importSpecifierOf(node: ts.TypeNode): string { + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) + && ts.isStringLiteral(node.argument.literal)) { + return node.argument.literal.text; + } + return ''; +} + +function literalValueOf(node: ts.TypeNode, sourceFile: ts.SourceFile): string { + if (!ts.isLiteralTypeNode(node) || node.literal.kind === ts.SyntaxKind.NullKeyword) { + return ''; + } + return EntityUtils.normalizeWhitespace(node.literal.getText(sourceFile)); +} diff --git a/parser/src/parsers/typescript/ts-module-paths.ts b/parser/src/parsers/typescript/ts-module-paths.ts new file mode 100644 index 000000000..22310fe13 --- /dev/null +++ b/parser/src/parsers/typescript/ts-module-paths.ts @@ -0,0 +1,65 @@ +/** + * Stripping a TypeScript module extension, in ONE place. + * + * ## Why this file exists + * + * The alternation was written out three times — in the project analyzer, the + * module extractor and the IR-completeness checker — and each copy listed the + * arms in an order that is wrong for the two declaration extensions that are + * not `.d.ts`: + * + * /\.(d\.ts|tsx?|mts|cts)$/ + * + * For `index.d.cts` the `d\.ts` arm cannot match, `tsx?` cannot, `mts` cannot — + * and the bare `cts` arm **does**, stripping only `.cts` and leaving `index.d`. + * `.d.mts` fails the same way. A longest-first arm order is the whole fix, and + * #89 fixed exactly one of the three copies, which is what this file prevents: + * the module's `name` kept the stray `.d` for another release because a second + * copy was never touched. + * + * `.d.cts` and `.d.mts` are not exotic. They are what every dual-published + * package ships; rxjs alone accounted for 1,170 affected rows. + * + * ## The arm order is load-bearing + * + * `d\.ts|d\.mts|d\.cts` must precede `tsx?|mts|cts`. Alternation is ordered, so + * putting the two-part forms first is what makes `index.d.cts` match `d\.cts` + * and not `cts`. Verified unchanged for every extension already handled: + * `.d.ts`, `.ts`, `.tsx`, `.mts`, `.cts`, a bare `x.d` and `foo.bar.cts`. + * + * ## Three callers, three arm sets, deliberately + * + * They are not interchangeable and were never meant to be: + * + * - {@link stripTsExtension} — TypeScript source only. Feeds + * `toProjectRelative`, and therefore a module's `qualifiedName`. + * - {@link stripTsOrJsonExtension} — adds `.json`, for `resolveJsonModule`. + * Feeds a module's `name`. + * - {@link stripTsOrJsExtension} — adds the JavaScript forms, so a `./a.js` + * specifier and the `a.ts` it resolved to compare equal. + * + * Sharing the ARMS while keeping the sets distinct is the point; collapsing + * them into one would silently change which extensions each caller strips. + */ + +/** TypeScript's own source extensions, two-part declaration forms FIRST. */ +const TS_ARMS = String.raw`d\.ts|d\.mts|d\.cts|tsx?|mts|cts`; + +const TS_EXTENSION = new RegExp(String.raw`\.(${TS_ARMS})$`); +const TS_OR_JSON_EXTENSION = new RegExp(String.raw`\.(${TS_ARMS}|json)$`); +const TS_OR_JS_EXTENSION = new RegExp(String.raw`\.(${TS_ARMS}|jsx?|mjs|cjs)$`); + +/** `app/web/views` for `app/web/views.ts`, `app/web/views.d.ts` and `…/views.d.cts`. */ +export function stripTsExtension(filePath: string): string { + return filePath.replace(TS_EXTENSION, ''); +} + +/** As {@link stripTsExtension}, and also strips `.json` under `resolveJsonModule`. */ +export function stripTsOrJsonExtension(filePath: string): string { + return filePath.replace(TS_OR_JSON_EXTENSION, ''); +} + +/** As {@link stripTsExtension}, and also the JavaScript forms, so `./a.js` matches `a.ts`. */ +export function stripTsOrJsExtension(filePath: string): string { + return filePath.replace(TS_OR_JS_EXTENSION, ''); +} diff --git a/parser/src/parsers/typescript/tsconfig-resolver.ts b/parser/src/parsers/typescript/tsconfig-resolver.ts new file mode 100644 index 000000000..654a9b548 --- /dev/null +++ b/parser/src/parsers/typescript/tsconfig-resolver.ts @@ -0,0 +1,241 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { TsDecoratorSystem } from '@/enums/typescript/decorators'; +import { TsModuleResolutionMode } from '@/enums/typescript/modules'; + +/** + * Which tsconfig GOVERNS a file, and what it says. + * + * ## Why this is not "read the tsconfig at the root" + * + * A repository is not one program. In this repository's own fixture corpus, + * `staging/tsconfig.json` explicitly EXCLUDES `annotations/legacy`, + * `type-system/merging/two-files` and `.../three-files`, each of which has its + * own tsconfig — because they need options the rest of the corpus cannot have. + * `legacy/` needs `experimentalDecorators`; a global-script merging fixture + * cannot have `isolatedModules`. Neither option may be imposed on the others. + * + * The consequence for facts is concrete, and it is the reason this file exists: + * **`ts_decorator.decoratorSystem` differs between two files three directories + * apart**, and the difference is legitimate rather than noise. Standard TC39 + * decorators and legacy `experimentalDecorators` differ in evaluation order, in + * what the decorator function receives, and in whether parameter decorators are + * legal at all. A parser that assumes one system per run gets + * `annotations/legacy/` wrong — and gets it wrong in the direction that looks + * plausible, which is the kind of wrong nothing downstream notices. + * + * `moduleResolutionMode` and `tsConfigPath` on `ts_module` come from here for + * the same reason: resolution genuinely differs per project, so a specifier that + * resolves in one program may legitimately not resolve in another. + * + * ## No Program is created + * + * `ts.readConfigFile` and `ts.parseJsonConfigFileContent` are pure functions of + * the file system, and `ts.resolveModuleName` is a pure function of a specifier, + * options and a host. None typechecks anything, and none needs `node_modules` + * to have been installed for the answer to be honest — an unresolvable specifier + * returns `undefined`, which is a correct answer and not a missing one. + */ +export interface GoverningTsConfig { + /** Absolute path of the governing tsconfig; `""` when no config claims the file. */ + readonly configPath: string; + readonly options: ts.CompilerOptions; + readonly moduleResolutionMode: TsModuleResolutionMode; + /** + * The decorator system in force FOR THIS FILE. + * + * Read from `experimentalDecorators` on the governing config after `extends` + * has been followed. Never assumed, never inherited from a sibling directory. + */ + readonly decoratorSystem: TsDecoratorSystem; + /** The files this config claims, absolute and normalised. Empty when it could not be read. */ + readonly fileNames: ReadonlySet; +} + +/** + * The fallback when no tsconfig claims a file. + * + * Recorded as `tsConfigPath = ""` rather than silently borrowing a neighbour's + * options, because "no config governs this file" is a fact worth being able to + * see in the fact base. + */ +const DEFAULT_OPTIONS: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, +}; + +export class TsConfigResolver { + /** Parsed configs by absolute config path — parsing one is not cheap and repeats per file. */ + private readonly parsed = new Map(); + /** Governing config by absolute file path. */ + private readonly governing = new Map(); + + /** + * The config that governs `absoluteFilePath`. + * + * Nearest ancestor `tsconfig.json` that actually CLAIMS the file. Nearest + * alone is not enough: `staging/tsconfig.json` is the nearest ancestor of + * `staging/annotations/legacy/legacy-decorators.ts` and explicitly excludes + * it, so honouring `include`/`exclude` is what makes this the answer tsc would + * give. A config that disowns the file does not stop the walk. + * + * ## The walk is not bounded by the program being extracted + * + * It used to stop at the program root, and that made the answer depend on + * WHICH program was being extracted rather than on the file. `ts_module`'s + * primary key is `md5(filePath ‖ baseMservPath ‖ declaredSpecifier ‖ startLine + * ‖ emissionRegime ‖ serviceVersionLinkHash)` — no program, no config path — + * so a file reachable from two programs mints ONE key. If the two extractions + * disagree about the governing config they emit two rows under that one key + * with different payloads, and Souffle stores both: a single import then + * yields two contradictory `moduleResolutionMode` values and any count over + * it doubles. + * + * Measured on nest before the ceiling was removed: 7 module keys carrying + * `moduleResolutionMode {NODE16|NODE10}` and `tsConfigPath {tsconfig.json|""}`. + * Both shapes came from the ceiling — a nested root with no config of its own + * (`packages/core/test`), and one whose config claims only `src/**` and + * `e2e/**` while the file sits beside it + * (`integration/testing-module-override`). In each case the walk stopped at + * the program root and never reached the repository config that does claim + * the file, so the same file resolved NODE16 under the root program and + * NODE10 under the nested one. + * + * So the governing config must be a PURE FUNCTION OF THE FILE, which is what + * the unbounded walk gives. `fileNames.has()` is what keeps it safe: a config + * above the analysed tree has to name the file explicitly to win, and one + * that does is the answer tsc would give too. + */ + resolve(absoluteFilePath: string): GoverningTsConfig { + const normalised = path.normalize(absoluteFilePath); + const cached = this.governing.get(normalised); + if (cached) { + return cached; + } + + let found: GoverningTsConfig | undefined; + let dir = path.dirname(normalised); + for (;;) { + const configPath = path.join(dir, 'tsconfig.json'); + if (fs.existsSync(configPath)) { + const config = this.parseConfig(configPath); + if (config && config.fileNames.has(normalised)) { + found = config; + break; + } + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + const result: GoverningTsConfig = found ?? { + configPath: '', + options: DEFAULT_OPTIONS, + moduleResolutionMode: TsModuleResolutionMode.NODE10, + decoratorSystem: TsDecoratorSystem.STANDARD_TC39, + fileNames: new Set(), + }; + this.governing.set(normalised, result); + return result; + } + + /** Every tsconfig under a root, sorted, for the analyzer's per-program grouping. */ + findConfigs(rootDir: string, skipDirectories: ReadonlySet): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + if (entries.some((e) => e.isFile() && e.name === 'tsconfig.json')) { + out.push(path.join(dir, 'tsconfig.json')); + } + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.') && !skipDirectories.has(entry.name)) { + walk(path.join(dir, entry.name)); + } + } + }; + walk(rootDir); + return out.sort(); + } + + private parseConfig(configPathIn: string): GoverningTsConfig | undefined { + // Absolute, always. `parseJsonConfigFileContent` joins its base path onto + // `extends` and onto every `include` glob, so a relative base silently + // produces an EMPTY file list and the config appears to claim nothing — + // which reads exactly like "this config does not govern the file". + const configPath = path.resolve(configPathIn); + if (this.parsed.has(configPath)) { + return this.parsed.get(configPath); + } + let result: GoverningTsConfig | undefined; + const read = ts.readConfigFile(configPath, (p) => { + try { + return fs.readFileSync(p, 'utf-8'); + } catch { + return undefined; + } + }); + if (!read.error && read.config) { + // parseJsonConfigFileContent follows `extends`, applies include/exclude + // and produces the exact file list tsc would compile. Reimplementing that + // by hand is where a "governing config" usually goes wrong. + const parsed = ts.parseJsonConfigFileContent( + read.config, + ts.sys, + path.dirname(configPath), + undefined, + configPath + ); + result = { + configPath, + options: parsed.options, + moduleResolutionMode: mapResolutionMode(parsed.options), + decoratorSystem: + parsed.options.experimentalDecorators === true + ? TsDecoratorSystem.LEGACY_EXPERIMENTAL + : TsDecoratorSystem.STANDARD_TC39, + fileNames: new Set(parsed.fileNames.map((f) => path.normalize(f))), + }; + } + this.parsed.set(configPath, result); + return result; + } +} + +/** + * `ts.ModuleResolutionKind` to the schema's token. + * + * `Bundler`, `Node16` and `NodeNext` are kept apart because they genuinely + * resolve differently — `./a.js` means different things under each — and + * `ts_import.resolutionKind` is only interpretable next to this column. + */ +function mapResolutionMode(options: ts.CompilerOptions): TsModuleResolutionMode { + switch (options.moduleResolution) { + case ts.ModuleResolutionKind.Node16: { + return TsModuleResolutionMode.NODE16; + } + case ts.ModuleResolutionKind.NodeNext: { + return TsModuleResolutionMode.NODENEXT; + } + case ts.ModuleResolutionKind.Bundler: { + return TsModuleResolutionMode.BUNDLER; + } + case ts.ModuleResolutionKind.Classic: { + return TsModuleResolutionMode.CLASSIC; + } + default: { + return TsModuleResolutionMode.NODE10; + } + } +} diff --git a/parser/src/parsers/xml/index.ts b/parser/src/parsers/xml/index.ts new file mode 100644 index 000000000..5a902e181 --- /dev/null +++ b/parser/src/parsers/xml/index.ts @@ -0,0 +1 @@ +export { XmlParser } from '@/parsers/xml/xml-parser'; diff --git a/parser/src/parsers/xml/xml-parser.ts b/parser/src/parsers/xml/xml-parser.ts new file mode 100644 index 000000000..782720dda --- /dev/null +++ b/parser/src/parsers/xml/xml-parser.ts @@ -0,0 +1,437 @@ +import * as sax from 'sax'; + +import { XmlAttribute } from '@/analysis-types/xml/XmlAttribute'; +import { XmlElement } from '@/analysis-types/xml/XmlElement'; +import { XmlValueReference } from '@/analysis-types/xml/XmlValueReference'; +import { XmlValueReferenceType } from '@/enums/xml/XmlValueReferenceType'; + +/** + * Internal state for tracking an open element during SAX parsing. + */ +interface ElementState { + tagName: string; + localName: string; + prefix: string; + namespace: string; + xPath: string; + depth: number; + startLine: number; + childCount: number; + textContent: string; + parentHash: string; + elementHash: string; +} + +/** + * Universal XML parser using SAX streaming. + * + * Extracts three entity types from any XML document: + * - XmlElement: every element node with tag, namespace, xPath, depth, text, parent link + * - XmlAttribute: every attribute on every element, linked to its parent element + * - XmlValueReference: every ${...} and #{...} placeholder found in text content or attribute values + */ +export class XmlParser { + /** + * Parses an XML document and returns all extracted entities. + * + * @param content XML file content as string + * @param filePath Absolute path to the XML file + * @param baseMservPath Project root path + * @param serviceVersionLinkHash Service version hash + * @returns Tuple of [XmlElement[], XmlAttribute[], XmlValueReference[]] + */ + parse( + content: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): [XmlElement[], XmlAttribute[], XmlValueReference[]] { + const elements: XmlElement[] = []; + const attributes: XmlAttribute[] = []; + const valueReferences: XmlValueReference[] = []; + + const parser = sax.parser(true, { + xmlns: false, + trim: false, + normalize: false, + position: true, + }); + + // XPath tracking: stack of tag name counts at each level for sibling indexing + const elementStack: ElementState[] = []; + const xPathCounters: Map[] = []; + + // Manual namespace prefix -> URI tracking (xmlns:false means we do this ourselves) + const nsMap = new Map(); + + parser.onopentag = (node: sax.Tag) => { + const currentDepth = elementStack.length + 1; + const startLine = parser.line + 1; // sax uses 0-indexed lines + + // Manually parse prefix and local name from tag name + const colonIdx = node.name.indexOf(':'); + const prefix = colonIdx > 0 ? node.name.substring(0, colonIdx) : ''; + const localName = colonIdx > 0 ? node.name.substring(colonIdx + 1) : node.name; + + // Collect xmlns declarations from attributes before processing + for (const [attrName, attrValue] of Object.entries(node.attributes)) { + if (attrName === 'xmlns') { + nsMap.set('', attrValue as string); + } else if (attrName.startsWith('xmlns:')) { + nsMap.set(attrName.substring(6), attrValue as string); + } + } + + // Re-resolve namespace after collecting xmlns declarations + const resolvedUri = prefix ? (nsMap.get(prefix) || '') : (nsMap.get('') || ''); + + // Build xPath + const parentXPath = elementStack.length > 0 + ? elementStack[elementStack.length - 1]!.xPath + : ''; + + // Track sibling count for xPath indexing + if (xPathCounters.length < currentDepth) { + xPathCounters.push(new Map()); + } + const levelCounters = xPathCounters[currentDepth - 1]!; + const tagCount = (levelCounters.get(localName) || 0) + 1; + levelCounters.set(localName, tagCount); + + const xPath = `${parentXPath}/${localName}[${tagCount}]`; + + // Increment parent's child count + if (elementStack.length > 0) { + elementStack[elementStack.length - 1]!.childCount++; + } + + // Create a temporary element to get its hash for linking attributes + const tempElement = XmlElement.builder( + localName, + xPath, + currentDepth, + filePath, + baseMservPath, + startLine, + startLine, // endLine updated on close + serviceVersionLinkHash + ) + .withNamespace(resolvedUri) + .withNamespacePrefix(prefix) + .withParentElementHash( + elementStack.length > 0 + ? elementStack[elementStack.length - 1]!.elementHash + : '' + ) + .build(); + + const elementHash = tempElement.getHash(); + + // Push state onto stack + const state: ElementState = { + tagName: localName, + localName, + prefix, + namespace: resolvedUri, + xPath, + depth: currentDepth, + startLine, + childCount: 0, + textContent: '', + parentHash: elementStack.length > 0 + ? elementStack[elementStack.length - 1]!.elementHash + : '', + elementHash, + }; + elementStack.push(state); + + // Reset child-level counters (new children start fresh) + if (xPathCounters.length > currentDepth) { + xPathCounters[currentDepth] = new Map(); + } + + // Extract attributes (including xmlns declarations) + for (const [attrName, attrValue] of Object.entries(node.attributes)) { + const value = attrValue as string; + // Resolve attribute namespace prefix + const attrColonIdx = attrName.indexOf(':'); + const attrNsUri = attrColonIdx > 0 + ? (nsMap.get(attrName.substring(0, attrColonIdx)) || '') + : ''; + + const xmlAttr = XmlAttribute.builder( + attrName, + value, + filePath, + baseMservPath, + startLine, + startLine, + elementHash, + serviceVersionLinkHash + ) + .withNamespace(attrNsUri) + .build(); + + attributes.push(xmlAttr); + + // Extract value references from attribute values + const attrRefs = this.extractValueReferences( + value, + elementHash, + attrName, + filePath, + baseMservPath, + startLine, + startLine, + serviceVersionLinkHash + ); + valueReferences.push(...attrRefs); + } + }; + + parser.ontext = (text: string) => { + if (elementStack.length > 0) { + elementStack[elementStack.length - 1]!.textContent += text; + } + }; + + parser.oncdata = (cdata: string) => { + if (elementStack.length > 0) { + elementStack[elementStack.length - 1]!.textContent += cdata; + } + }; + + parser.onclosetag = () => { + const state = elementStack.pop(); + if (!state) return; + + const endLine = parser.line + 1; + const trimmedText = state.textContent.trim(); + const isSelfClosing = state.childCount === 0 && trimmedText.length === 0; + + // Build the final element with endLine and text + const xmlElement = XmlElement.builder( + state.tagName, + state.xPath, + state.depth, + filePath, + baseMservPath, + state.startLine, + endLine, + serviceVersionLinkHash + ) + .withNamespace(state.namespace) + .withNamespacePrefix(state.prefix) + .withTextContent(trimmedText) + .withIsSelfClosing(isSelfClosing) + .withChildCount(state.childCount) + .withParentElementHash(state.parentHash) + .build(); + + elements.push(xmlElement); + + // Extract value references from text content + if (trimmedText.length > 0) { + const textRefs = this.extractValueReferences( + trimmedText, + state.elementHash, + '', // no attribute name — this is text content + filePath, + baseMservPath, + state.startLine, + endLine, + serviceVersionLinkHash + ); + valueReferences.push(...textRefs); + } + + // Trim the xPath counters stack + while (xPathCounters.length > state.depth) { + xPathCounters.pop(); + } + }; + + parser.onerror = (err: Error) => { + // Reset parser on error and continue — we want best-effort extraction + console.warn(`XML parse warning in ${filePath}: ${err.message}`); + parser.resume(); + }; + + parser.write(content).close(); + + return [elements, attributes, valueReferences]; + } + + /** + * Extracts ${...} and #{...} value references from a string. + * + * @param value The string to scan for references + * @param ownerElementHash Hash of the element owning this value + * @param ownerAttributeName Attribute name (empty string if text content) + * @param filePath Absolute file path + * @param baseMservPath Project root path + * @param startLine Start line of the value + * @param endLine End line of the value + * @param serviceVersionLinkHash Service version hash + * @returns Array of XmlValueReference entities + */ + private extractValueReferences( + value: string, + ownerElementHash: string, + ownerAttributeName: string, + filePath: string, + baseMservPath: string, + startLine: number, + endLine: number, + serviceVersionLinkHash: string + ): XmlValueReference[] { + const references: XmlValueReference[] = []; + let pos = 0; + + while (pos < value.length) { + // Check for #{...} SpEL + if (value[pos] === '#' && pos + 1 < value.length && value[pos + 1] === '{') { + const closePos = this.findMatchingBrace(value, pos + 1); + const expression = value.substring(pos + 2, closePos); + const rawFragment = value.substring(pos, closePos + 1); + + const ref = XmlValueReference.builder( + expression, + XmlValueReferenceType.SPEL_EXPRESSION, + rawFragment, + ownerElementHash, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionLinkHash + ) + .withOwnerAttributeName(ownerAttributeName) + .build(); + + references.push(ref); + pos = closePos + 1; + continue; + } + + // Check for ${...} placeholder + if (value[pos] === '$' && pos + 1 < value.length && value[pos + 1] === '{') { + const closePos = this.findMatchingBrace(value, pos + 1); + const innerContent = value.substring(pos + 2, closePos); + const rawFragment = value.substring(pos, closePos + 1); + + // Check for default: ${name:default} + const colonPos = this.findTopLevelColon(innerContent); + let expression: string; + let defaultValue = ''; + let refType: XmlValueReferenceType; + + if (colonPos === -1) { + expression = innerContent; + refType = XmlValueReferenceType.PROPERTY_PLACEHOLDER; + } else { + expression = innerContent.substring(0, colonPos); + defaultValue = innerContent.substring(colonPos + 1); + refType = XmlValueReferenceType.PLACEHOLDER_WITH_DEFAULT; + } + + const refBuilder = XmlValueReference.builder( + expression, + refType, + rawFragment, + ownerElementHash, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionLinkHash + ) + .withOwnerAttributeName(ownerAttributeName) + .withDefaultValue(defaultValue); + + references.push(refBuilder.build()); + + // Recursively extract nested refs from the default value + if (defaultValue && (defaultValue.includes('${') || defaultValue.includes('#{'))) { + const nestedRefs = this.extractValueReferences( + defaultValue, + ownerElementHash, + ownerAttributeName, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionLinkHash + ); + // Set depth on nested refs + for (const nested of nestedRefs) { + const nestedWithDepth = XmlValueReference.builder( + nested.getReferenceExpression(), + nested.getReferenceType(), + nested.getRawValue(), + ownerElementHash, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionLinkHash + ) + .withOwnerAttributeName(ownerAttributeName) + .withDefaultValue(nested.getDefaultValue()) + .withDepth(nested.getDepth() + 1) + .build(); + references.push(nestedWithDepth); + } + } + + pos = closePos + 1; + continue; + } + + pos++; + } + + return references; + } + + /** + * Find the matching closing brace for an opening { at position pos. + */ + private findMatchingBrace(value: string, openBracePos: number): number { + let depth = 1; + let pos = openBracePos + 1; + + while (pos < value.length && depth > 0) { + const ch = value[pos]!; + if ((ch === '$' || ch === '#') && pos + 1 < value.length && value[pos + 1] === '{') { + depth++; + pos++; + } else if (ch === '}') { + depth--; + if (depth === 0) return pos; + } + pos++; + } + + return value.length - 1; + } + + /** + * Find the first colon not inside a nested ${...} or #{...}. + */ + private findTopLevelColon(content: string): number { + let braceDepth = 0; + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + if ((ch === '$' || ch === '#') && i + 1 < content.length && content[i + 1] === '{') { + braceDepth++; + i++; + } else if (ch === '}') { + if (braceDepth > 0) braceDepth--; + } else if (ch === ':' && braceDepth === 0) { + return i; + } + } + return -1; + } +} diff --git a/parser/src/parsers/yaml/index.ts b/parser/src/parsers/yaml/index.ts new file mode 100644 index 000000000..68046c525 --- /dev/null +++ b/parser/src/parsers/yaml/index.ts @@ -0,0 +1 @@ +export { YamlParser } from '@/parsers/yaml/yaml-parser'; diff --git a/parser/src/parsers/yaml/yaml-parser.ts b/parser/src/parsers/yaml/yaml-parser.ts new file mode 100644 index 000000000..96d0b70d8 --- /dev/null +++ b/parser/src/parsers/yaml/yaml-parser.ts @@ -0,0 +1,1073 @@ +import * as YAML from 'yaml'; + +import { YamlProperty } from '@/analysis-types/yaml/YamlProperty'; +import { YamlValueSegment } from '@/analysis-types/yaml/YamlValueSegment'; +import { LARGE_FILE_LINE_THRESHOLD } from '@/constants/consts'; +import { YamlValueSegmentType } from '@/enums/yaml/YamlValueSegmentType'; +import { YamlValueType } from '@/enums/yaml/YamlValueType'; + +/** + * Parser for YAML files (.yml / .yaml). + * + * Uses the `yaml` npm package (v2) which provides a full document model + * with source position tracking via character offsets. + * + * Two-pass approach (same as properties parser): + * 1. Parse all YAML keys to build a set of known property names + * 2. Parse all values, classifying ${...} references as PROPERTY_REFERENCE + * when the name matches a known key, or ENV_VARIABLE otherwise + */ +export class YamlParser { + private knownKeys: Set = new Set(); + private lineOffsets: number[] = []; + private keyHashMap: Map = new Map(); + + /** + * Parses a YAML file and returns YamlProperty and YamlValueSegment entities. + * + * @param content File content + * @param filePath Absolute path to the file + * @param baseMservPath Project root path + * @param serviceVersionLinkHash Service version hash + * @returns Tuple of [YamlProperty[], YamlValueSegment[]] + */ + // Files between CHUNK and SKIP thresholds use chunked parsing. + // Files above SKIP threshold are too large and are skipped entirely. + private static readonly CHUNK_LINE_THRESHOLD = 10_000; + + parse( + content: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): [YamlProperty[], YamlValueSegment[]] { + const lineCount = content.split('\n').length; + if (lineCount > LARGE_FILE_LINE_THRESHOLD) { + console.log(` ⏭️ Skipping very large file (${lineCount} lines): ${filePath}`); + return [[], []]; + } + if (lineCount > YamlParser.CHUNK_LINE_THRESHOLD) { + console.log(` ⚠️ Large file (${lineCount} lines), using chunked parse: ${filePath}`); + return this.parseChunked(content, filePath, baseMservPath, serviceVersionLinkHash); + } + return this.parseInternal(content, filePath, baseMservPath, serviceVersionLinkHash); + } + + /** + * Standard single-pass parse for normal-sized YAML files. + */ + private parseInternal( + content: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): [YamlProperty[], YamlValueSegment[]] { + this.buildLineOffsets(content); + + const documents = YAML.parseAllDocuments(content, { merge: false }); + const allProperties: YamlProperty[] = []; + const allSegments: YamlValueSegment[] = []; + + // Pass 1: Collect all known keys across all documents + this.knownKeys = new Set(); + for (let docIdx = 0; docIdx < documents.length; docIdx++) { + const doc = documents[docIdx]; + if (doc && doc.contents) { + this.collectKeys(doc.contents, '', this.knownKeys); + } + } + + // Pass 2: Extract YamlProperty and YamlValueSegment entities + this.keyHashMap = new Map(); + for (let docIdx = 0; docIdx < documents.length; docIdx++) { + const doc = documents[docIdx]; + if (doc && doc.contents) { + this.extractProperties( + doc.contents, + '', + 0, + docIdx, + filePath, + baseMservPath, + serviceVersionLinkHash, + allProperties, + allSegments + ); + } + } + + return [allProperties, allSegments]; + } + + /** + * Chunked parsing for files that blow the call stack in YAML.parseAllDocuments. + * + * Splits the file at top-level key boundaries (lines starting at column 0), + * groups them into chunks, and parses each chunk as a standalone YAML document. + * Line numbers are adjusted so entity positions are correct in the original file. + */ + private parseChunked( + content: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): [YamlProperty[], YamlValueSegment[]] { + const allProperties: YamlProperty[] = []; + const allSegments: YamlValueSegment[] = []; + + const chunks = this.splitIntoTopLevelChunks(content); + console.log(` 📦 Split into ${chunks.length} chunk(s)`); + + // Pass 1: Collect all known keys across all chunks + this.knownKeys = new Set(); + for (const chunk of chunks) { + try { + const docs = YAML.parseAllDocuments(chunk.text, { merge: false }); + for (const doc of docs) { + if (doc && doc.contents) { + this.collectKeys(doc.contents, '', this.knownKeys); + } + } + } catch { + // Skip chunks that still fail (extremely deeply nested single keys) + } + } + + // Pass 2: Extract entities from each chunk + this.keyHashMap = new Map(); + for (const chunk of chunks) { + try { + this.buildLineOffsets(chunk.text); + const docs = YAML.parseAllDocuments(chunk.text, { merge: false }); + + for (let docIdx = 0; docIdx < docs.length; docIdx++) { + const doc = docs[docIdx]; + if (doc && doc.contents) { + const chunkProperties: YamlProperty[] = []; + const chunkSegments: YamlValueSegment[] = []; + + this.extractProperties( + doc.contents, + '', + 0, + docIdx, + filePath, + baseMservPath, + serviceVersionLinkHash, + chunkProperties, + chunkSegments + ); + + // Adjust line numbers: shift by the chunk's starting line offset + for (const prop of chunkProperties) { + prop.adjustLineNumbers(chunk.startLine); + } + for (const seg of chunkSegments) { + seg.adjustLineNumbers(chunk.startLine); + } + + allProperties.push(...chunkProperties); + allSegments.push(...chunkSegments); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(` ❌ Chunk at line ${chunk.startLine + 1} failed: ${msg}`); + } + } + + return [allProperties, allSegments]; + } + + /** + * Splits YAML content at top-level key boundaries into chunks. + * A top-level boundary is a line that starts at column 0 with a non-whitespace, + * non-comment character (i.e., a top-level mapping key or document marker). + * + * Groups consecutive top-level keys into chunks of ~CHUNK_KEY_COUNT keys each. + */ + private splitIntoTopLevelChunks( + content: string + ): { text: string; startLine: number }[] { + const CHUNK_KEY_COUNT = 200; + const lines = content.split('\n'); + + // Find top-level key boundary line indices + const boundaries: number[] = [0]; // always start at line 0 + for (let i = 1; i < lines.length; i++) { + const line = lines[i]!; + if (line.length === 0) continue; + const ch = line[0]!; + // Top-level key: starts at column 0, not whitespace, not comment, not doc marker + if (ch !== ' ' && ch !== '\t' && ch !== '#' && ch !== '-' && ch !== '.' && ch !== '\r') { + boundaries.push(i); + } + } + + // Group boundaries into chunks + const chunks: { text: string; startLine: number }[] = []; + for (let i = 0; i < boundaries.length; i += CHUNK_KEY_COUNT) { + const startIdx = boundaries[i]!; + const endIdx = i + CHUNK_KEY_COUNT < boundaries.length + ? boundaries[i + CHUNK_KEY_COUNT]! + : lines.length; + const chunkLines = lines.slice(startIdx, endIdx); + chunks.push({ + text: chunkLines.join('\n'), + startLine: startIdx // 0-indexed line offset in original file + }); + } + + return chunks; + } + + /** + * Build character offset → line number lookup table. + */ + private buildLineOffsets(content: string): void { + this.lineOffsets = [0]; // line 1 starts at offset 0 + for (let i = 0; i < content.length; i++) { + if (content[i] === '\n') { + this.lineOffsets.push(i + 1); + } + } + } + + /** + * Convert a character offset to a 1-indexed line number. + */ + private offsetToLine(offset: number): number { + if (offset < 0) return 1; + // Binary search for the line + let lo = 0; + let hi = this.lineOffsets.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (this.lineOffsets[mid]! <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo + 1; // 1-indexed + } + + /** + * Convert a character offset to a 0-indexed column number. + */ + private offsetToCol(offset: number): number { + if (offset < 0) return 0; + const line = this.offsetToLine(offset); + const lineStart = this.lineOffsets[line - 1] ?? 0; + return offset - lineStart; + } + + /** + * Pass 1: Iteratively collect all flattened key paths (including container keys). + * Uses an explicit stack to avoid call-stack overflow on deeply nested files. + */ + private collectKeys(node: unknown, prefix: string, keys: Set): void { + const stack: { node: unknown; prefix: string }[] = [{ node, prefix }]; + + while (stack.length > 0) { + const frame = stack.pop()!; + const currentNode = frame.node; + const currentPrefix = frame.prefix; + + if (this.isYAMLMap(currentNode)) { + for (const pair of (currentNode as any).items) { + const key = this.getScalarValue(pair.key); + if (key === null) continue; + const fullKey = currentPrefix ? `${currentPrefix}.${key}` : key; + keys.add(fullKey); + if (this.isYAMLMap(pair.value) || this.isYAMLSeq(pair.value)) { + stack.push({ node: pair.value, prefix: fullKey }); + } + } + } else if (this.isYAMLSeq(currentNode)) { + const items = (currentNode as any).items; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const indexedKey = `${currentPrefix}[${i}]`; + keys.add(indexedKey); + if (this.isYAMLMap(item) || this.isYAMLSeq(item)) { + stack.push({ node: item, prefix: indexedKey }); + } + } + } + } + } + + /** + * Pass 2: Iteratively extract YamlProperty and YamlValueSegment entities. + * Uses an explicit stack to avoid call-stack overflow on deeply nested files. + */ + private extractProperties( + node: unknown, + prefix: string, + depth: number, + docIdx: number, + filePath: string, + baseMservPath: string, + serviceVersionHash: string, + properties: YamlProperty[], + segments: YamlValueSegment[] + ): void { + const stack: { node: unknown; prefix: string; depth: number }[] = + [{ node, prefix, depth }]; + + while (stack.length > 0) { + const frame = stack.pop()!; + const currentNode = frame.node; + const currentPrefix = frame.prefix; + const currentDepth = frame.depth; + const parentHash = this.lookupParentHash(currentPrefix, docIdx); + + if (this.isYAMLMap(currentNode)) { + for (const pair of (currentNode as any).items) { + const key = this.getScalarValue(pair.key); + if (key === null) continue; + const fullKey = currentPrefix ? `${currentPrefix}.${key}` : key; + + if (this.isYAMLMap(pair.value) || this.isYAMLSeq(pair.value)) { + // Register container key as a MAP or SEQUENCE property + this.createContainerProperty( + pair, + fullKey, + currentDepth, + false, + -1, + docIdx, + parentHash, + filePath, + baseMservPath, + serviceVersionHash, + properties + ); + stack.push({ node: pair.value, prefix: fullKey, depth: currentDepth + 1 }); + } else if (this.isAliasNode(pair.value)) { + // Alias value (e.g., <<: *db-defaults) — register with alias info + this.createAliasProperty( + pair, + fullKey, + currentDepth, + false, + -1, + docIdx, + parentHash, + filePath, + baseMservPath, + serviceVersionHash, + properties + ); + } else { + // Leaf scalar value + this.createPropertyAndSegments( + pair, + fullKey, + currentDepth, + false, + -1, + docIdx, + parentHash, + filePath, + baseMservPath, + serviceVersionHash, + properties, + segments + ); + } + } + } else if (this.isYAMLSeq(currentNode)) { + const items = (currentNode as any).items; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const indexedKey = `${currentPrefix}[${i}]`; + + if (this.isYAMLMap(item) || this.isYAMLSeq(item)) { + // Register container list item as a MAP or SEQUENCE property + this.createContainerProperty( + { key: null, value: item }, + indexedKey, + currentDepth, + true, + i, + docIdx, + parentHash, + filePath, + baseMservPath, + serviceVersionHash, + properties + ); + stack.push({ node: item, prefix: indexedKey, depth: currentDepth + 1 }); + } else if (this.isAliasNode(item)) { + // Alias in a list + this.createAliasProperty( + { key: null, value: item }, + indexedKey, + currentDepth, + true, + i, + docIdx, + parentHash, + filePath, + baseMservPath, + serviceVersionHash, + properties + ); + } else { + // Leaf scalar in a list + this.createLeafProperty( + item, + indexedKey, + currentDepth, + true, + i, + docIdx, + parentHash, + filePath, + baseMservPath, + serviceVersionHash, + properties, + segments + ); + } + } + } + } + } + + /** + * Lookup the parent property hash from the key-hash map. + * Root-level properties (prefix='') have no parent. + */ + private lookupParentHash(prefix: string, docIdx: number): string { + if (!prefix) return ''; + return this.keyHashMap.get(`${docIdx}||${prefix}`) ?? ''; + } + + /** + * Register a property's hash in the key-hash map for child lookup. + */ + private registerPropertyHash(fullKey: string, docIdx: number, hash: string): void { + this.keyHashMap.set(`${docIdx}||${fullKey}`, hash); + } + + /** + * Create a YamlProperty (and its value segments) from a Pair node. + */ + private createPropertyAndSegments( + pair: any, + fullKey: string, + depth: number, + isListItem: boolean, + listIndex: number, + docIdx: number, + parentHash: string, + filePath: string, + baseMservPath: string, + serviceVersionHash: string, + properties: YamlProperty[], + segments: YamlValueSegment[] + ): void { + const valueNode = pair.value; + const rawValue = this.getNodeStringValue(valueNode); + const valueType = this.classifyValueType(valueNode, rawValue); + + const startLine = this.getNodeLine(pair.key ?? pair.value ?? pair); + const endLine = this.getNodeEndLine(valueNode) || startLine; + + const anchorName = this.getAnchorName(valueNode); + const isAlias = this.isAliasNode(valueNode); + + const property = YamlProperty.builder( + fullKey, + rawValue, + valueType, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionHash + ) + .withDepth(depth) + .withIsListItem(isListItem) + .withListIndex(listIndex) + .withAnchorName(anchorName) + .withIsAlias(isAlias) + .withDocumentIndex(docIdx) + .withParentPropertyHash(parentHash) + .build(); + + properties.push(property); + this.registerPropertyHash(fullKey, docIdx, property.getHash()); + + // Parse value segments + this.extractValueSegments( + rawValue, + valueType, + property, + startLine, + this.getValueStartCol(valueNode), + segments + ); + } + + /** + * Create a YamlProperty for a leaf scalar inside a sequence. + */ + private createLeafProperty( + valueNode: any, + fullKey: string, + depth: number, + isListItem: boolean, + listIndex: number, + docIdx: number, + parentHash: string, + filePath: string, + baseMservPath: string, + serviceVersionHash: string, + properties: YamlProperty[], + segments: YamlValueSegment[] + ): void { + const rawValue = this.getNodeStringValue(valueNode); + const valueType = this.classifyValueType(valueNode, rawValue); + + const startLine = this.getNodeLine(valueNode); + const endLine = this.getNodeEndLine(valueNode) || startLine; + + const anchorName = this.getAnchorName(valueNode); + const isAlias = this.isAliasNode(valueNode); + + const property = YamlProperty.builder( + fullKey, + rawValue, + valueType, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionHash + ) + .withDepth(depth) + .withIsListItem(isListItem) + .withListIndex(listIndex) + .withAnchorName(anchorName) + .withIsAlias(isAlias) + .withDocumentIndex(docIdx) + .withParentPropertyHash(parentHash) + .build(); + + properties.push(property); + this.registerPropertyHash(fullKey, docIdx, property.getHash()); + + this.extractValueSegments( + rawValue, + valueType, + property, + startLine, + this.getValueStartCol(valueNode), + segments + ); + } + + /** + * Create a YamlProperty for a container key (MAP or SEQUENCE). + * No value segments are created — only the property itself. + */ + private createContainerProperty( + pair: any, + fullKey: string, + depth: number, + isListItem: boolean, + listIndex: number, + docIdx: number, + parentHash: string, + filePath: string, + baseMservPath: string, + serviceVersionHash: string, + properties: YamlProperty[] + ): void { + const valueNode = pair.value; + const valueType = this.isYAMLSeq(valueNode) + ? YamlValueType.SEQUENCE + : YamlValueType.MAP; + + const startLine = this.getNodeLine(pair.key ?? valueNode); + const endLine = this.getNodeEndLine(valueNode) || startLine; + + const anchorName = this.getAnchorName(valueNode); + + const property = YamlProperty.builder( + fullKey, + '', + valueType, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionHash + ) + .withDepth(depth) + .withIsListItem(isListItem) + .withListIndex(listIndex) + .withAnchorName(anchorName) + .withIsAlias(false) + .withDocumentIndex(docIdx) + .withParentPropertyHash(parentHash) + .build(); + + properties.push(property); + this.registerPropertyHash(fullKey, docIdx, property.getHash()); + } + + /** + * Create a YamlProperty for an alias reference (e.g., <<: *db-defaults). + * Records the alias source anchor name in the value field. + */ + private createAliasProperty( + pair: any, + fullKey: string, + depth: number, + isListItem: boolean, + listIndex: number, + docIdx: number, + parentHash: string, + filePath: string, + baseMservPath: string, + serviceVersionHash: string, + properties: YamlProperty[] + ): void { + const aliasNode = pair.value; + const aliasSource = (aliasNode as any).source ?? ''; + const aliasAnchorName = typeof aliasSource === 'string' + ? aliasSource + : this.getAnchorName(aliasSource) || String(aliasSource); + + const startLine = this.getNodeLine(pair.key ?? aliasNode); + const endLine = startLine; + + const property = YamlProperty.builder( + fullKey, + `*${aliasAnchorName}`, + YamlValueType.STRING, + filePath, + baseMservPath, + startLine, + endLine, + serviceVersionHash + ) + .withDepth(depth) + .withIsListItem(isListItem) + .withListIndex(listIndex) + .withAnchorName('') + .withIsAlias(true) + .withDocumentIndex(docIdx) + .withParentPropertyHash(parentHash) + .build(); + + properties.push(property); + this.registerPropertyHash(fullKey, docIdx, property.getHash()); + } + + /** + * Extract value segments (LITERAL, ENV_VARIABLE, etc.) from a scalar value. + */ + private extractValueSegments( + rawValue: string, + valueType: YamlValueType, + property: YamlProperty, + baseLine: number, + baseCol: number, + segments: YamlValueSegment[] + ): void { + if (valueType === YamlValueType.EMPTY || valueType === YamlValueType.NULL) { + const segment = YamlValueSegment.builder( + rawValue, + YamlValueSegmentType.EMPTY, + 0, + 0, + property.getHash(), + baseLine, + baseLine, + baseCol, + baseCol + ).build(); + segments.push(segment); + return; + } + + // Check if value contains any ${...} references + if (!rawValue.includes('${')) { + // Pure literal — split on commas for queryability + const litSegments = this.createLiteralSegments( + rawValue, 0, 0, property.getHash(), '', baseLine, baseCol + ); + segments.push(...litSegments); + return; + } + + // Parse mixed value into segments + const parsed = this.parseValueSegments( + rawValue, + property.getHash(), + baseLine, + baseCol, + 0, + '' + ); + segments.push(...parsed); + } + + /** + * Parse a value string into segments, recursively handling nested ${...} references. + * Same algorithm as the properties parser. + */ + private parseValueSegments( + value: string, + yamlPropertyLinkHash: string, + baseLine: number, + baseCol: number, + depth: number, + parentSegmentHash: string + ): YamlValueSegment[] { + const segments: YamlValueSegment[] = []; + let pos = 0; + let position = 0; + let literalStart = 0; + + while (pos < value.length) { + // Check for ${...} placeholder + if (value[pos] === '$' && pos + 1 < value.length && value[pos + 1] === '{') { + if (pos > literalStart) { + const litText = value.substring(literalStart, pos); + const litSegs = this.createLiteralSegments( + litText, position, depth, yamlPropertyLinkHash, parentSegmentHash, + baseLine, baseCol + literalStart + ); + segments.push(...litSegs); + position += litSegs.length; + } + + const refStart = pos; + const refEnd = this.findMatchingBrace(value, pos + 1); + const refContent = value.substring(pos + 2, refEnd); + + const refSegments = this.parseReference( + refContent, + position, + depth, + yamlPropertyLinkHash, + parentSegmentHash, + baseLine, + baseCol + refStart, + baseCol + refEnd + 1 + ); + segments.push(...refSegments); + + position++; + pos = refEnd + 1; + literalStart = pos; + continue; + } + + pos++; + } + + // Flush trailing literal + if (literalStart < value.length) { + const litText = value.substring(literalStart); + const litSegs = this.createLiteralSegments( + litText, position, depth, yamlPropertyLinkHash, parentSegmentHash, + baseLine, baseCol + literalStart + ); + segments.push(...litSegs); + } + + return segments; + } + + /** + * Parse a reference content (inside ${...}) into segments. + */ + private parseReference( + content: string, + position: number, + depth: number, + yamlPropertyLinkHash: string, + parentSegmentHash: string, + baseLine: number, + startCol: number, + endCol: number + ): YamlValueSegment[] { + const segments: YamlValueSegment[] = []; + + const colonPos = this.findTopLevelColon(content); + + let name: string; + let defaultText: string | undefined; + + if (colonPos === -1) { + name = content; + } else { + name = content.substring(0, colonPos); + defaultText = content.substring(colonPos + 1); + } + + // Determine segment type + let segmentType: YamlValueSegmentType; + if (defaultText !== undefined) { + if (this.knownKeys.has(name)) { + segmentType = YamlValueSegmentType.PROPERTY_REF_WITH_DEFAULT; + } else { + segmentType = YamlValueSegmentType.ENV_WITH_DEFAULT; + } + } else { + if (this.knownKeys.has(name)) { + segmentType = YamlValueSegmentType.PROPERTY_REFERENCE; + } else { + segmentType = YamlValueSegmentType.ENV_VARIABLE; + } + } + + const plainDefault = defaultText !== undefined ? defaultText : ''; + + const refSegment = YamlValueSegment.builder( + name, + segmentType, + position, + depth, + yamlPropertyLinkHash, + baseLine, + baseLine, + startCol, + endCol + ) + .withDefaultValue(plainDefault) + .withParentSegmentLinkHash(parentSegmentHash) + .build(); + segments.push(refSegment); + + // If default contains nested ${...}, recursively parse children + if (defaultText !== undefined && defaultText.includes('${')) { + const childSegments = this.parseValueSegments( + defaultText, + yamlPropertyLinkHash, + baseLine, + startCol + 2 + name.length + 1, + depth + 1, + refSegment.getHash() + ); + segments.push(...childSegments); + } + + return segments; + } + + /** + * Find the first colon at top level (not inside nested ${...}). + */ + private findTopLevelColon(content: string): number { + let braceDepth = 0; + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + if (ch === '$' && i + 1 < content.length && content[i + 1] === '{') { + braceDepth++; + i++; + } else if (ch === '#' && i + 1 < content.length && content[i + 1] === '{') { + braceDepth++; + i++; + } else if (ch === '}') { + if (braceDepth > 0) braceDepth--; + } else if (ch === ':' && braceDepth === 0) { + return i; + } + } + return -1; + } + + /** + * Find matching closing brace for an opening { at position pos. + */ + private findMatchingBrace(value: string, openBracePos: number): number { + let depth = 1; + let pos = openBracePos + 1; + + while (pos < value.length && depth > 0) { + const ch = value[pos]!; + if ((ch === '$' || ch === '#') && pos + 1 < value.length && value[pos + 1] === '{') { + depth++; + pos++; + } else if (ch === '}') { + depth--; + if (depth === 0) { + return pos; + } + } + pos++; + } + + return value.length - 1; + } + + /** + * Create LITERAL segment(s), splitting on commas for queryability. + * If the text contains commas, each comma-delimited part becomes its own segment. + */ + private createLiteralSegments( + text: string, + startPosition: number, + depth: number, + yamlPropertyLinkHash: string, + parentSegmentHash: string, + baseLine: number, + startCol: number + ): YamlValueSegment[] { + const parts = text.split(','); + + // No commas — return single segment + if (parts.length <= 1) { + return [YamlValueSegment.builder( + text, + YamlValueSegmentType.LITERAL, + startPosition, + depth, + yamlPropertyLinkHash, + baseLine, + baseLine, + startCol, + startCol + text.length + ) + .withParentSegmentLinkHash(parentSegmentHash) + .build()]; + } + + // Split on commas — each non-empty part becomes its own LITERAL segment + const segments: YamlValueSegment[] = []; + let colOffset = 0; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + const trimmed = part.trim(); + if (trimmed.length > 0) { + const trimStart = part.indexOf(trimmed[0]!); + segments.push(YamlValueSegment.builder( + trimmed, + YamlValueSegmentType.LITERAL, + startPosition + segments.length, + depth, + yamlPropertyLinkHash, + baseLine, + baseLine, + startCol + colOffset + trimStart, + startCol + colOffset + trimStart + trimmed.length + ) + .withParentSegmentLinkHash(parentSegmentHash) + .build()); + } + colOffset += part.length + 1; // +1 for the comma + } + return segments; + } + + // ========================================================================= + // YAML node type guards and utility methods + // ========================================================================= + + private isYAMLMap(node: unknown): boolean { + return node != null && typeof node === 'object' && (node as any).constructor?.name === 'YAMLMap'; + } + + private isYAMLSeq(node: unknown): boolean { + return node != null && typeof node === 'object' && (node as any).constructor?.name === 'YAMLSeq'; + } + + private isAliasNode(node: unknown): boolean { + return node != null && typeof node === 'object' && (node as any).constructor?.name === 'Alias'; + } + + private getScalarValue(node: unknown): string | null { + if (node == null) return null; + if (typeof node === 'object' && 'value' in (node as any)) { + const val = (node as any).value; + return val != null ? String(val) : null; + } + return typeof node === 'string' ? node : null; + } + + private getNodeStringValue(node: unknown): string { + if (node == null) return ''; + if (typeof node === 'object' && 'value' in (node as any)) { + const val = (node as any).value; + if (val == null) return ''; + // Handle binary data (!!binary tag) - keep as base64 + if (val instanceof Uint8Array || Buffer.isBuffer(val)) { + return '[binary:' + Buffer.from(val).toString('base64') + ']'; + } + return String(val); + } + if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') { + return String(node); + } + return ''; + } + + private classifyValueType(node: unknown, rawValue: string): YamlValueType { + if (node == null) return YamlValueType.EMPTY; + + const val = typeof node === 'object' && 'value' in (node as any) ? (node as any).value : node; + + if (val === null || val === undefined) return YamlValueType.NULL; + if (rawValue === '' || rawValue === '~') return rawValue === '~' ? YamlValueType.NULL : YamlValueType.EMPTY; + + if (typeof val === 'boolean') return YamlValueType.BOOLEAN; + if (typeof val === 'number') { + return Number.isInteger(val) ? YamlValueType.INTEGER : YamlValueType.FLOAT; + } + + // Check string value for boolean-like patterns the yaml parser may have kept as strings + const lower = rawValue.toLowerCase(); + if (['true', 'false', 'yes', 'no', 'on', 'off'].includes(lower)) { + return YamlValueType.BOOLEAN; + } + + // Check for integer pattern + if (/^-?\d+$/.test(rawValue)) return YamlValueType.INTEGER; + // Check for float pattern + if (/^-?\d+\.\d+$/.test(rawValue)) return YamlValueType.FLOAT; + + return YamlValueType.STRING; + } + + private getNodeLine(node: unknown): number { + if (node == null) return 1; + const range = (node as any).range; + if (Array.isArray(range) && range.length > 0) { + return this.offsetToLine(range[0]); + } + return 1; + } + + private getNodeEndLine(node: unknown): number { + if (node == null) return 1; + const range = (node as any).range; + if (Array.isArray(range) && range.length > 1) { + return this.offsetToLine(range[1] - 1); + } + return this.getNodeLine(node); + } + + private getValueStartCol(node: unknown): number { + if (node == null) return 0; + const range = (node as any).range; + if (Array.isArray(range) && range.length > 0) { + return this.offsetToCol(range[0]); + } + return 0; + } + + private getAnchorName(node: unknown): string { + if (node == null) return ''; + const anchor = (node as any).anchor; + return typeof anchor === 'string' ? anchor : ''; + } +} diff --git a/parser/src/schema/javascript/corpus/CORPUS.json b/parser/src/schema/javascript/corpus/CORPUS.json new file mode 100644 index 000000000..66c0256b2 --- /dev/null +++ b/parser/src/schema/javascript/corpus/CORPUS.json @@ -0,0 +1,80 @@ +{ + "$comment": [ + "PUBLISHED LAYER \u2014 shape and fingerprint. Contains NO package identity.", + "", + "Three layers, ruled 2026-09-12:", + " shape (here) strata, counts, exclusion rules", + " digest (here) SHA-256 over each stratum's sorted (relative path, file sha256) list", + " identity(PRIVATE) repo/tag/peeled-SHA/subtree, and the held-back names.", + " Read from $JS_CORPUS_IDENTITY. materialise.mjs FAILS LOUDLY when absent.", + "", + "The digest is what makes this a design rather than a concealment: a holder of the private", + "file can prove their materialised tree is byte-identical to the one measured, and a reader", + "without it can still see that such a proof exists and what it covers.", + "", + "!! BLOCKING DISCREPANCY \u2014 see reproduces.status below. This manifest does NOT currently", + "!! reproduce the population section 0.3 reports numbers against." + ], + "materialiseRoot": "../../corpus/javascript", + "identityFrom": { + "env": "JS_CORPUS_IDENTITY", + "onMissing": "fail loudly; never measure a partial corpus silently" + }, + "compiler": "typescript@6.0.3", + "emissionRegime": "js-ts6-inproc", + "reproduces": { + "status": "PARTIAL \u2014 BLOCKING FOR PUBLICATION", + "documentedPopulation": 2738, + "manifestMaterialises": 992, + "causes": [ + "one package is pinned from its GIT tag but was MEASURED from its published npm artifact; its ~1,000 per-method files are generated at publish time and are not in the git tree at any SHA. A git-SHA manifest cannot reproduce it by construction \u2014 it needs an npm tarball plus an integrity hash.", + "a runtime standard library (423 files) was measured and is absent from the manifest entirely", + "the JSX stratum (38 files) is absent from the manifest entirely", + "two packages are pinned to a narrower subtree than was measured", + "one package's pinned tag yields a different file count than the measured checkout, so the version drifted" + ], + "consequence": "every number in section 0.3 and the measurements derived from it are currently reproducible only from a corpus nobody holds a recipe for. That is the failure this manifest exists to prevent, found by computing the digest rather than by trusting that materialisation implied reproduction.", + "howItWasMissed": "materialise.mjs verified that eight SHAs resolved and that a second run was idempotent. Both were true. NEITHER is the claim that the tree matches the measured population \u2014 the mechanism was verified and the thing was not." + }, + "strata": { + "cjs-prototype": { + "files": 741, + "sha256": "c4a49ff7921d3edc0499f54da228052670ec202b476d369bd1240708c74cc133" + }, + "dual": { + "files": 56, + "sha256": "2c4c02fa5d1f8902e909b7794be4935a3dd56362a6796ff75e3d6c4bee6dee85" + }, + "esm": { + "files": 8, + "sha256": "9ed7d54df64a281c2ad38fdaf7ebba441bce6a14f6fc2d7709da722e0cc5c92b" + }, + "jsdoc-typed": { + "files": 187, + "sha256": "aa95d5c6d5312490b9808a55f374089a5d7356b5a8e1c1780a576534ca613a03" + } + }, + "exclusions": { + "$comment": "applied after materialisation, by content and path, never silently", + "MINIFIED_EXTENSION": "\\.min\\.(js|mjs|cjs)$", + "BUILD_DIR": "(^|/)(dist|build|bundle|vendor)(/|$)", + "BUNDLER_PREAMBLE": "bundler runtime preamble markers — a LABEL on emitted rows, not a drop (schema 3.1.1)", + "SOURCE_MAP_COMMENT": "//[#@]\\s*sourceMappingURL=", + "GENERATED_MONOLITH": "a concatenated build shipped beside the modules it was built from — a LABEL on emitted rows, not a drop (schema 3.1.1)", + "LONG_LINE": "any single line longer than 1000 characters" + }, + "heldBack": { + "$comment": [ + "Identities live in the PRIVATE manifest. What publishes is the DISCIPLINE, not the name.", + "A holdout was never cloned, so its name here would be an attestation about something", + "absent \u2014 and deleting the name outright would leave the census guard and the attestation", + "without a referent. So: named privately, attested publicly." + ], + "count": 2, + "attestation": "two named holdouts, identities in the private manifest, verified absent by seven checks, five of which have demonstrated synthetic positives", + "roles": [ + "a Flow-throughout framework from a different vendor than the Flow population \u2014 the DETECTOR holdout", + "an application codebase in the dialect this corpus otherwise lacks" + ] + } +} diff --git a/parser/src/schema/javascript/corpus/manifest-privacy.mjs b/parser/src/schema/javascript/corpus/manifest-privacy.mjs new file mode 100644 index 000000000..a86b31b79 --- /dev/null +++ b/parser/src/schema/javascript/corpus/manifest-privacy.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node +/** + * SPLIT A CORPUS MANIFEST INTO A PUBLISHABLE HALF AND A PRIVATE HALF. + * + * node manifest-privacy.mjs split --private [--public ] + * node manifest-privacy.mjs verify --root + * node manifest-privacy.mjs digest [--salt-file ] + * node manifest-privacy.mjs commit --salt-file + * + * WHY THE DIGEST LIVES HERE AND NOT ON materialise.mjs. It was offered as either, and + * one of the two is structurally wrong: THE HOLDOUT MUST NEVER BE MATERIALISED. A + * digest emitted as materialise.mjs's last act can only ever fingerprint something + * that was cloned, so it could not fingerprint the one thing the salted commitment + * exists for. A digest is a privacy primitive — a fingerprint without a name — and it + * belongs with the tool that separates names from shape. + * + * Generic on purpose. This is the mechanism the JavaScript corpus design (schema §0.3b, now in commit history) + * describes, extracted from the js-oracle corpus manifest so that any manifest + * naming corpora can use it — `src/test-data/javascript/CORPUS-MANIFEST.json` + * is the one it was extracted for. + * + * THE THREE LAYERS + * + * shape published counts, ratios, strata, anything derived from the corpus + * digest published SHA-256 over each group's sorted (relative path, file sha256) + * identity PRIVATE names, URLs, commits, refs, paths — everything that says WHICH + * + * WHY A DIGEST AND NOT JUST DELETION. Deleting the names leaves a document whose + * numbers nobody can check and whose holdout attestation has no referent. The digest + * is a fingerprint: a holder of the private half can prove their tree is byte-identical + * to the measured one, and a reader without it can still see that such a proof exists + * and what it covers. It converts "trust these numbers" into "this corpus has a + * fingerprint, and here it is". + * + * ONE DISTINCTION WORTH KEEPING, contributed by js-corpus when it classified the keys + * this tool refused on: a PARSER COMMIT SHA is shape, not identity. It identifies what + * the corpus was measured WITH, not what was measured. The same holds for a compiler + * version and a fixture revision. Only a value naming the corpus itself is identity. + * + * WHAT THIS TOOL WILL NOT DO. It will not guess which fields are identity. Identity + * keys are declared, because a tool that infers them will one day meet a new field + * and publish it. Unknown keys are reported, never silently classified. + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** Keys whose VALUE names a corpus. Declared, never inferred. */ +const IDENTITY_KEYS = new Set([ + 'package', 'url', 'repo', 'repository', 'commit', 'sha', 'ref', 'tag', + 'subtree', 'subtrees', 'path', 'paths', 'name', 'member', + 'source', 'origin', 'remote', 'clone', +]); + +/** + * CONTAINERS, split ELEMENT-WISE rather than sent wholesale to the private half. + * + * Getting this wrong is not a nuance. A first version treated `members` as an + * identity key, which sent the entire array private and took the per-member file + * counts and stratum splits with it — destroying exactly the shape the public half + * exists to carry. An entry is split like any other object: its identity keys go + * private, its shape keys publish, and ORDER is preserved so the two halves rejoin + * positionally. + */ +const CONTAINER_KEYS = new Set(['members', 'packages', 'corpora', 'entries']); + +/** + * LABEL MAPS — objects whose KEYS are data labels rather than schema fields, so the + * key names are shape and must not be reported as unclassified. `strata` is one: + * its keys are stratum names. Without this the tool refuses on every corpus that + * adds a stratum, which is a refusal that teaches nothing. + */ +const LABEL_MAP_KEYS = new Set(['strata', 'byStratum', 'counts', 'byOwner', 'bySubtree', + 'byStratumDirectory', // js-corpus: keys are stratum directory names, values are {files, sha256} + 'byMember', // js-corpus: keys are holdout ROLES ("flow-detector holdout"), never slugs +]); + +/** Keys that are pure shape and always publish. */ +const SHAPE_KEYS = new Set([ + 'files', 'callSites', 'strata', 'stratum', 'totals', 'bytes', 'count', 'counts', + 'discoveredFiles', 'walkedByParser', 'nonProjectProvenance', 'convergence', + 'compiler', 'parser', 'fixtures', 'generatedBy', 'measuredAgainst', 'attestation', + 'checksRun', 'conclusion', 'askedAt', 'note', 'scopeOfTheRecallNumber', + 'heldBack', 'digest', + // ---- declared by js-corpus for src/test-data/javascript/CORPUS-MANIFEST.json ---- + // Every key below was reported unclassified by this tool and classified by the + // manifest's owner, not inferred. Each is a COUNT, a RATE, a CHECK DESCRIPTION + // or a PARSER COMMIT SHA — none names a corpus. Parser commit SHAs are shape: + // they identify what was measured WITH, not what was measured. + 'projectFiles', 'flowExcluded', 'bundledExcluded', 'generatedMonolith', 'rows', + 'callSitesAdjudicated', 'recallMisses', 'requireEdgeMisses', 'kindMismatches', + 'optionalityMismatches', 'duplicatePrimaryKeys', 'extractionErrors', + 'shippedSourceFiles', 'testSpecFiles', // totals.*: counts + 'priorSweeps', 'reVerifiedAgainst', // parser commits measured with + 'adjudicationSweep', 'caveat', 'latestSweep', // convergence prose, name-free + 'opened', 'at', 'measuredRef', 'sequence', 'commitmentsMatched', 'verdict', 'verifiedAfterMeasurement', 'spent', 'openExposure', 'rerunAtFixedSha', + // js-corpus: heldBack.opened — WHEN, against WHICH parser SHA, + // in what order, whether the pre-registration held, and the + // verdict in class terms. None of it names a repository. + // The member digests are a label map (below). + 'check', 'result', 'canFail', 'summary', // attestation rows, rewritten name-free + 'roles', 'identityFrom', 'env', 'onMissing', // holdout roles; env-var pointer + 'corpus', 'sha256', 'identities', 'byStratumDirectory', 'byMember', + 'commitments', 'salted', // js-corpus: a list of hashes is publishable BY DESIGN, + // and `salted` tells a reader which kind they hold + 'algorithm', 'values', // js-corpus: commitments.* — the FORMULA is shape (it is the + // tool's own commitTo()), and the VALUES are salted hashes, + // which is the whole point of publishing them: checkable by + // a salt holder, opaque to everyone else. The slug and the + // salt are the identity, and neither is under this key. + // `byStratumDirectory` appears HERE and in LABEL_MAP_KEYS, and needs both: this entry + // classifies the KEY, the label-map entry classifies its CHILDREN. It looks redundant + // and is not — removing it from either list makes the tool refuse. js-corpus had it + // right; an attempt to tidy the duplicate away broke the split immediately. + // `identities` is a CONTAINER whose + // member `name`s go private — arity publishes, names do not. + // `byStratumDirectory` is declared above as a label map. + 'order', 'alsoSplit', // strata description +]); + +function classify(key) { + if (IDENTITY_KEYS.has(key)) return 'identity'; + if (SHAPE_KEYS.has(key)) return 'shape'; + return 'unknown'; +} + +/** Split, reporting every key it could not classify rather than guessing. */ +function split(node, unknown = new Set(), trail = []) { + if (Array.isArray(node)) { + const pub = [], priv = []; + for (const v of node) { const r = split(v, unknown, trail); pub.push(r.pub); priv.push(r.priv); } + return { pub, priv }; + } + if (typeof node === 'string' && trail.length && CONTAINER_KEYS.has(trail[trail.length - 1])) { + return { pub: null, priv: node }; // a bare name in a container IS the identity + } + if (node === null || typeof node !== 'object') return { pub: node, priv: node }; + const pub = {}, priv = {}; + const inLabelMap = trail.length > 0 && LABEL_MAP_KEYS.has(trail[trail.length - 1]); + for (const [k, v] of Object.entries(node)) { + if (inLabelMap) { pub[k] = v; continue; } // keys here are data labels, and shape + if (CONTAINER_KEYS.has(k)) { + const r = split(v, unknown, [...trail, k]); + pub[k] = r.pub; priv[k] = r.priv; // element-wise; order preserved + continue; + } + const kind = k.startsWith('$') ? 'shape' : classify(k); + if (kind === 'unknown') unknown.add([...trail, k].join('.')); + if (kind === 'identity') { priv[k] = v; continue; } + const r = split(v, unknown, [...trail, k]); + pub[k] = r.pub; + const pr = JSON.stringify(r.priv); + if (r.priv !== undefined && pr !== JSON.stringify(r.pub) && pr !== '{}' && pr !== '[]') priv[k] = r.priv; + } + return { pub, priv }; +} + +const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']); +const EXT = new Set(['.js', '.mjs', '.cjs', '.jsx']); +function walk(dir, out = []) { + let e; try { e = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const x of e) { + const f = path.join(dir, x.name); + if (x.isDirectory()) { if (!SKIP.has(x.name)) walk(f, out); } + else if (EXT.has(path.extname(x.name))) out.push(f); + } + return out; +} + +/** + * A TREE digest. Salted when a salt is given, and it should be. + * + * commitTo() salts a NAME; this salts a TREE, and the tree is the one that is actually + * enumerable: a stratum with two members has a candidate space small enough to brute + * force, so an unsalted tree digest identifies its corpus to anyone willing to clone a + * few dozen repositories and hash them. Same argument as the name commitment, one level + * down — a fingerprint is only a fingerprint if it cannot be reversed by guessing. + * + * The salt is prefixed to the JOINED rows rather than to each row, so the sorted + * (path, sha) input is identical either way and a salted digest cannot be matched to an + * unsalted one over the same tree. + */ +export function digestOf(root, salt) { + const rows = walk(root).map(f => [ + path.relative(root, f), + crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex'), + ]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + const joined = rows.map(r => `${r[0]}\t${r[1]}`).join('\n'); + if (salt !== undefined && salt !== null && String(salt).length < 16) { + throw new Error('refusing to salt a tree digest with a salt under 16 chars — a short salt is enumerable alongside the tree'); + } + const input = salt ? `${salt}\u0000${joined}` : joined; + return { files: rows.length, salted: Boolean(salt), + sha256: crypto.createHash('sha256').update(input).digest('hex') }; +} + +/** + * A SALTED COMMITMENT to a name that is not published. + * + * A bare sha256(name) conceals nothing: the space of plausible corpus and holdout + * names is a few thousand well-known repositories, enumerable in seconds. Salted, + * with the salt in the private manifest, the commitment is checkable by a holder and + * opaque to everyone else. A fingerprint is only a fingerprint if it cannot be + * reversed by guessing. + * + * Its purpose is PRE-REGISTRATION: when a holdout is finally opened, it can be proved + * to be the one named at the start, which is what stops a quiet swap for an easier + * one and turns "chosen before anyone believed they were finished" into evidence. + */ +export function commitTo(name, salt) { + if (!salt || salt.length < 16) { + throw new Error('refusing to commit with a short or absent salt — an unsalted digest of a well-known name is reversible by enumeration'); + } + return crypto.createHash('sha256').update(`${salt}\u0000${name}`).digest('hex'); +} + +function main() { + const [cmd, file, ...rest] = process.argv.slice(2); + const opt = (n) => { const i = rest.indexOf(n); return i === -1 ? undefined : rest[i + 1]; }; + if (cmd === 'split') { + if (!file || !opt('--private')) { + console.error('usage: manifest-privacy.mjs split --private [--public ]'); + return 2; + } + const src = JSON.parse(fs.readFileSync(file, 'utf8')); + const unknown = new Set(); + const { pub, priv } = split(src, unknown); + if (unknown.size) { + console.error('REFUSING TO SPLIT — unclassified keys. Declare each as identity or shape:'); + for (const k of [...unknown].sort()) console.error(` ${k}`); + console.error('\nA tool that guesses will one day meet a new field and publish it.'); + return 1; + } + fs.writeFileSync(opt('--private'), JSON.stringify(priv, null, 2) + '\n'); + const out = opt('--public') ?? file; + fs.writeFileSync(out, JSON.stringify(pub, null, 2) + '\n'); + console.log(` public -> ${out}`); + console.log(` private -> ${opt('--private')} (must NOT be committed)`); + return 0; + } + if (cmd === 'digest') { + if (!file) { console.error('usage: manifest-privacy.mjs digest [--salt-file ]'); return 2; } + const sf = opt('--salt-file'); + if (sf && !fs.existsSync(sf)) { console.error(`FATAL: salt file not found at ${sf}`); return 2; } + const salt = sf ? fs.readFileSync(sf, 'utf8').trim() : undefined; + let d; try { d = digestOf(path.resolve(file), salt); } catch (e) { console.error(`FATAL: ${e.message}`); return 2; } + console.log(` ${d.files} files sha256 ${d.sha256} salted=${d.salted}`); + if (!d.salted) { console.log(' NOTE: unsalted. A small stratum is enumerable — pass --salt-file before publishing.'); } + return 0; + } + if (cmd === 'commit') { + const saltFile = opt('--salt-file'); + if (!file || !saltFile) { console.error('usage: manifest-privacy.mjs commit --salt-file '); return 2; } + if (!fs.existsSync(saltFile)) { console.error(`FATAL: salt file not found at ${saltFile}`); return 2; } + const salt = fs.readFileSync(saltFile, 'utf8').trim(); + try { console.log(` ${commitTo(file, salt)}`); } catch (e) { console.error(`FATAL: ${e.message}`); return 2; } + return 0; + } + if (cmd === 'verify') { + const root = opt('--root'); + if (!file || !root) { console.error('usage: manifest-privacy.mjs verify --root [--salt-file ]'); return 2; } + const pub = JSON.parse(fs.readFileSync(file, 'utf8')); + const want = pub.digest; + const sf2 = opt('--salt-file'); + // A salted published digest cannot be checked without the salt, and pretending + // otherwise would report DIFFER on a tree that matches — a false accusation. + if (want && want.salted && !sf2) { + console.error('REFUSING TO VERIFY — the published digest is marked salted and no --salt-file was given.'); + console.error(' Recomputing unsalted would report a mismatch on a tree that matches.'); + return 2; + } + if (sf2 && !fs.existsSync(sf2)) { console.error(`FATAL: salt file not found at ${sf2}`); return 2; } + const got = digestOf(root, sf2 ? fs.readFileSync(sf2, 'utf8').trim() : undefined); + if (!want) { console.error('no `digest` in the public manifest — nothing to verify against'); return 1; } + const ok = got.sha256 === want.sha256 && got.files === want.files && Boolean(got.salted) === Boolean(want.salted); + console.log(` ${ok ? 'MATCH' : 'DIFFER'} ${got.files} files ${got.sha256.slice(0, 16)}… salted=${got.salted} (published ${want.files} / ${want.sha256.slice(0, 16)}… salted=${Boolean(want.salted)})`); + return ok ? 0 : 1; + } + console.error('usage: manifest-privacy.mjs split|verify|digest|commit …'); + return 2; +} +if (import.meta.url === `file://${process.argv[1]}`) process.exit(main()); diff --git a/parser/src/schema/javascript/corpus/materialise.mjs b/parser/src/schema/javascript/corpus/materialise.mjs new file mode 100644 index 000000000..8da969ff4 --- /dev/null +++ b/parser/src/schema/javascript/corpus/materialise.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * Materialise and verify the pinned measurement corpus. + * + * node corpus/materialise.mjs clone/verify against the private identity file + * node corpus/materialise.mjs --verify check only, no network + * node corpus/materialise.mjs --digest recompute stratum digests and compare + * + * THREE LAYERS. Shape and digest are in CORPUS.json and publish. IDENTITY — which + * package is which — is private and read from $JS_CORPUS_IDENTITY. This script + * FAILS LOUDLY when that file is absent rather than measuring a partial corpus, + * because a silently partial corpus is the failure the whole manifest exists to + * prevent. + * + * WHY THE DIGEST EXISTS. It is not decoration. Verifying that eight SHAs resolve + * and that a second run is idempotent proves the MECHANISM works; it does not + * prove the tree matches the population the numbers were measured on. Computing + * the digest is what caught that it does not — see CORPUS.json `reproduces`. + */ +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '../../../..'); +const PUBLIC = JSON.parse(fs.readFileSync(path.join(HERE, 'CORPUS.json'), 'utf8')); +const ROOT = path.resolve(REPO_ROOT, PUBLIC.materialiseRoot); +const mode = process.argv.includes('--digest') ? 'digest' + : process.argv.includes('--verify') ? 'verify' : 'materialise'; + +if (/\/(tmp|scratchpad)(\/|$)/.test(ROOT)) { + console.error(`FATAL: materialiseRoot resolves into a scratchpad (${ROOT}).`); + console.error(' That is the failure this manifest exists to prevent.'); + process.exit(2); +} + +function loadIdentity() { + const p = process.env[PUBLIC.identityFrom.env]; + if (!p) { + console.error(`FATAL: $${PUBLIC.identityFrom.env} is not set.`); + console.error(' The identity layer is private and out of tree. Without it this tool'); + console.error(' cannot name a single package, and measuring whatever happens to be on'); + console.error(' disk would report a number for a corpus nobody specified.'); + process.exit(2); + } + if (!fs.existsSync(p)) { console.error(`FATAL: identity file not found at ${p}`); process.exit(2); } + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']); +const EXT = new Set(['.js', '.mjs', '.cjs', '.jsx']); +function walk(dir, out = []) { + let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; } + for (const e of ents) { + const f = path.join(dir, e.name); + if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(f, out); } + else if (EXT.has(path.extname(e.name))) out.push(f); + } + return out; +} + +/** SHA-256 over the stratum's sorted (relative path, file sha256) list. */ +function digestStrata(identity) { + const byStratum = new Map(); + for (const p of identity.packages) { + const name = p.repo.split('/')[1]; + const pkgRoot = path.join(ROOT, name); + const base = p.subtree === '.' ? pkgRoot : path.join(pkgRoot, p.subtree); + const rows = walk(base).map(f => [ + path.relative(pkgRoot, f), + crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex'), + ]); + const acc = byStratum.get(p.stratum) ?? []; + acc.push(...rows); byStratum.set(p.stratum, acc); + } + const out = {}; + for (const [st, rows] of byStratum) { + rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + out[st] = { files: rows.length, + sha256: crypto.createHash('sha256').update(rows.map(r => `${r[0]}\t${r[1]}`).join('\n')).digest('hex') }; + } + return out; +} + +function git(args, cwd) { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +const identity = loadIdentity(); + +if (mode === 'digest') { + const got = digestStrata(identity); + let bad = 0; + console.log('stratum digests — computed against published:'); + for (const [st, want] of Object.entries(PUBLIC.strata)) { + const g = got[st]; + const ok = g && g.sha256 === want.sha256 && g.files === want.files; + if (!ok) bad++; + console.log(` ${ok ? 'ok ' : 'DIFF'} ${st.padEnd(16)} ${String(g?.files ?? 0).padStart(5)} files ${(g?.sha256 ?? '-').slice(0, 16)}… (published ${want.files} / ${want.sha256.slice(0, 16)}…)`); + } + const total = Object.values(got).reduce((a, s) => a + s.files, 0); + console.log(`\n materialised ${total} files; section 0.3 reports ${PUBLIC.reproduces.documentedPopulation}`); + if (PUBLIC.reproduces.status !== 'COMPLETE') + console.log(` reproduces: ${PUBLIC.reproduces.status} — see CORPUS.json 'reproduces.causes'`); + process.exit(bad ? 1 : 0); +} + +fs.mkdirSync(ROOT, { recursive: true }); +let ok = 0, missing = 0, drifted = 0; +for (const p of identity.packages) { + const name = p.repo.split('/')[1]; + const dir = path.join(ROOT, name); + if (fs.existsSync(path.join(dir, '.git'))) { + const head = git(['rev-parse', 'HEAD'], dir); + if (head === p.sha) { console.log(` ok ${name.padEnd(12)} ${p.sha.slice(0, 12)} ${p.stratum}`); ok++; continue; } + console.log(` DRIFTED ${name.padEnd(12)} head ${head.slice(0, 12)} != pinned ${p.sha.slice(0, 12)}`); + drifted++; + if (mode === 'verify') continue; + git(['fetch', '--depth', '1', 'origin', p.sha], dir); + git(['checkout', '--detach', p.sha], dir); + continue; + } + missing++; + if (mode === 'verify') { console.log(` MISSING ${name.padEnd(12)} ${p.stratum}`); continue; } + console.log(` cloning ${name.padEnd(12)} ${p.sha.slice(0, 12)}`); + fs.mkdirSync(dir, { recursive: true }); + git(['init', '--quiet'], dir); + // scrub-allow: a URL TEMPLATE is the mechanism by which names are fetched, not a + // reference to one. `p.repo` comes from the PRIVATE identity file and is never in + // this tree; the host is infrastructure. Ruled 2026-09-12. + git(['remote', 'add', 'origin', `https://github.com/${p.repo}`], dir); // scrub-allow: URL template, not a reference — the name comes from the private identity file + git(['fetch', '--depth', '1', '--quiet', 'origin', p.sha], dir); + git(['checkout', '--quiet', '--detach', p.sha], dir); +} +console.log(`\n root: ${ROOT}`); +console.log(` ${identity.packages.length} pinned: ${ok} at pin, ${drifted} drifted, ${missing} missing`); +console.log(` held back, not materialised: ${identity.heldBack.length} (${PUBLIC.heldBack.attestation})`); +if (PUBLIC.reproduces.status !== 'COMPLETE') + console.log(`\n !! reproduces: ${PUBLIC.reproduces.status} — ${PUBLIC.reproduces.manifestMaterialises} of ${PUBLIC.reproduces.documentedPopulation} files`); +if (mode === 'verify' && (drifted || missing)) process.exit(1); diff --git a/parser/src/schema/javascript/decls_base_js.dl b/parser/src/schema/javascript/decls_base_js.dl new file mode 100644 index 000000000..843baedd7 --- /dev/null +++ b/parser/src/schema/javascript/decls_base_js.dl @@ -0,0 +1,232 @@ +// ============================================================================ +// Base input relations — JAVASCRIPT parser IR. One relation pair per entity kind. +// +// GENERATED FROM schema.json BY gen_decls.py — DO NOT HAND-EDIT. +// Re-run `python3 gen_decls.py --check` in CI; drift here is a silent schema break. +// +// NAMING: one prefix per core language; `lib_` is the EXTERNAL marker on top of it. +// +// js_ JavaScript under analysis <- the PARSER emits only these +// lib_js_ external / third-party <- the ENGINE stages these +// +// COLUMN ORDER IS THE CONTRACT. All columns are `symbol`. New columns append ONLY. +// The last column is the entity's own unique hash for every relation EXCEPT +// js_expression, which has one column appended AFTER its hash (c32, +// introducesDeclarationLinkHash) because the schema was already frozen and appending +// is the only safe edit. So a check that locates a primary key BY POSITION is wrong +// for js_expression and must locate it by name. Column NAMES live only in the schema +// doc, so a rename is free after the freeze and a reorder is not. +// +// FIVE THINGS AN ENGINE AUTHOR MUST READ BEFORE JOINING ANYTHING: +// +// 1. THE MODULE GRAPH LIVES IN THE EXPRESSION RELATION. 83.6% of module edges are +// expression-borne: require() is a call, module.exports = X is an assignment. +// js_import/js_export rows are MINTED FROM js_expression in a second pass, and +// js_import.edgeBearer partitions the table (2,300 files wholly EXPRESSION, 431 +// wholly DECLARATION, 0 mixed). 13.6% of require() calls are not even top-level. +// +// 2. THE PARSER RESOLVES NOTHING ACROSS FILES, AND CANNOT. The oracle itself — +// tsc with checkJs — decides only 52.6% of call sites, because JavaScript types +// are inferred, not declared (0.165% syntactic annotations). resolvedMethodLinkHash +// and resolvedTypeLinkHash are TIER 3 and stay empty. What every row DOES carry is +// the name as written, the import hop, and resolvedFilePath. That is the contract. +// +// 3. TYPES LIVE IN COMMENTS. 36.4% of parameters are typed by JSDoc and 0.165% by +// syntax. js_type_reference is a TREE parsed out of comment text, isTypeOnly is +// always true there, and 677 js_type rows have evidenceKind = COMMENT_ONLY — +// declarations whose only evidence is a comment. None may reach the call graph. +// +// 4. NO lib_js_* ROW IS EVER STAGED. There is ONE ambient population and it is +// provenance-tagged, not language-tagged: a lib row describes a declaration read +// from a .d.ts, and whether a JavaScript or TypeScript call site references it is a +// property of the CALL SITE -- but the parser stages NO pointer to one, because a +// pointer to a specific lib row IS a resolved link and tier 3 forbids it. The row +// carries the target NAME AS WRITTEN plus resolutionOutcome = +// AMBIENT_BUILTIN_TARGET, and the ENGINE joins. This matters more than it sounds: +// 24.4% of all resolution declines are calls whose receiver is a Node builtin with +// no ambient declarations. +// +// 5. HOISTING IS TWO COLUMNS, NOT ONE. js_variable.declarationScopeLinkHash is where +// a name is VISIBLE FROM; syntacticScopeLinkHash is where it is WRITTEN. They +// differ for every `var` in a block. js_scope is a real relation with a parent FK +// because the binder, not the type system, is what resolves a name here. +// +// NOT STAGED (declared for symmetry only — library IR is declarations, not bodies): +// lib_js_module, lib_js_scope, lib_js_type, lib_js_type_heritage, lib_js_method, lib_js_method_parameter, lib_js_field, lib_js_variable, lib_js_import, lib_js_export, lib_js_expression, lib_js_call_site, lib_js_block, lib_js_type_reference, lib_js_comment, lib_js_parse_gap +// ============================================================================ + +// ========================================================================== +// SPINE — the frozen first cut. Enough to build a call graph. +// ========================================================================== + +// A .js/.mjs/.cjs/.jsx file. One row per file, always — JavaScript has no `declare module`. +// c7 moduleSystem is IN THE KEY: `import` under a CommonJS config is a different program +// from `import` under an ESM one. c8 moduleSystemSource says HOW it was decided, because +// 91.4% of files are CommonJS by DEFAULT and not by declaration — without it a defaulted +// CommonJS and a declared one are indistinguishable. c10 contradictsGoverningConfig is a +// FLAG, never a skip: 6.2% of real files contradict their package.json and all of them are +// bundler input, which package.json never governs. +// (28 columns) +.decl js_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol) +.decl lib_js_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol) + +// The binder's output, and the relation with NO ts_* analogue. TypeScript needs no scope +// relation because declared types carry resolution; here the oracle resolves only 52.6% of +// call sites, so the BINDER is the resolution mechanism. c3 isFunctionScope is what `var` +// hoists to; c6 isStrictMode decides whether assignment to an undeclared name creates a +// global or throws; c9 hasWithStatement marks a scope where no name is statically +// resolvable, which is the honest answer rather than confident wrong bindings. +// (17 columns) +.decl js_scope(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol) +.decl lib_js_scope(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol) + +// A type DECLARATION: ES class, constructor function with prototype members, or a JSDoc +// @typedef/@callback. Object literals are NOT here — they are values. There is deliberately +// NO declarationGroupKey: JavaScript has no declaration merging, so TypeScript's central +// problem does not exist. c12 evidenceKind = COMMENT_ONLY marks the 677 measured types whose +// ONLY evidence is a comment, and c13 isTypeOnly must keep them out of the call graph. +// (26 columns) +.decl js_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol) +.decl lib_js_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol) + +// Every callable: function declaration, function expression, arrow, class method, accessor, +// prototype-assigned method, and the synthetic initializer. c9 declarationForm +// carries PROTOTYPE_ASSIGNMENT / STATIC_ASSIGNMENT — members DECLARED BY ASSIGNMENT (361 and +// 521 measured), which is a declaration and an expression at once; c27 ties it back. c10 +// hoisting separates function declarations (hoisted) from function expressions (not), same +// syntax category, different behaviour. c17 thisBinding is LEXICAL for arrows: `this` is +// rebound by CALL FORM, 33,189 references measured. +// (36 columns) +.decl js_method(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol,c35:symbol) +.decl lib_js_method(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol,c35:symbol) + +// A formal parameter. Only 0.165% carry a SYNTACTIC annotation and all of those are Flow, not +// TypeScript; 36.4% carry a JSDoc one. So c4 declaredTypeSource is the column that matters +// and NONE is the majority value. A destructured parameter is ONE row with c11 +// patternBindingCount > 0 plus N js_variable rows — emitting N parameter rows would break +// c1 position, emitting one with no bindings would lose every name. +// (22 columns) +.decl js_method_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol) +.decl lib_js_method_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol) + +// Every binding that is not a parameter or a member. c2 bindingRegime and the PAIR c3/c4 are +// the hoisting model: c3 is where the name is VISIBLE FROM (function scope for var), c4 is +// the block it is WRITTEN IN. They differ for every `var` inside a block, and the difference +// is NOT recoverable from anything else — an engine would have to reimplement JavaScript +// scoping to derive it. c13 initializerKind = REQUIRE_CALL is how a local name becomes a +// module alias, which is the hop the engine walks for 83.6% of module edges. +// (25 columns) +.decl js_variable(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) +.decl lib_js_variable(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) + +// One module edge IN. 83.6% of these rows are MINTED FROM EXPRESSIONS, not declarations, which +// is the finding that made JavaScript its own front end: c2 edgeBearer partitions the table +// and c14 sourceExpressionLinkHash points back at the expression so the second pass is +// auditable rather than asserted. c4 isTopLevel is FALSE for 13.6% of require() calls, so a +// statement-list walk misses one in seven. c12 resolverAgreement records where tsc and Node +// disagree (exports maps, conditional exports, #-imports) instead of silently picking. +// (23 columns) +.decl js_import(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol) +.decl lib_js_import(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol) + +// The spine. c32 introducesDeclarationLinkHash and c33 resolvedParameterLinkHash sit AFTER +// the primary key: the schema was +// already frozen and appending is the only safe edit, so this is the ONE relation whose PK +// is not its last column -- find it by name, never by position. It links an arrow or +// function expression to the js_method row it introduces, which is what connects +// emitter.on('x', () => {...}) to the body it installs: 31.0% of callables sit in argument +// position and 98.2% of those are anonymous, so without it every call inside a callback is +// orphaned from the registration that reaches it. NOT c12/c13 -- those mean 'this assignment +// declares a member', and widening them would break existing readers silently. c33 is the +// SIBLING of c17: c17 is FK->js_variable and a parameter is a js_method_parameter row, so a +// reference to a parameter resolved correctly and had nowhere to point -- 137,960 of 158,759 +// resolved-but-unlinked references, and 58,483 unreachable parameter rows. NOT a widened c17: +// a polymorphic FK into two relations defeats the integrity gate, because 'the hash exists in +// one of them' is not integrity. No parameter EVER mints a js_variable row, simple or +// destructured, so the gap and the fix are both uniform. +// ORIGINAL: Reached by an ALLOWLIST of expression positions, never a generic tree walk — a +// generic walk puts JSDoc type names in here and type-only constructs then reach the call +// graph. c4 operatorString is why one ASSIGNMENT kind covers += -= ??= ||=: the operator is +// a COLUMN, not a kind, and the wrapper node with parented children is what makes +// `a += 1; b += 2` survive. c10 isModuleEdge marks the rows the import/export second pass +// reads; c12 isDeclarationBearing marks the assignments that DECLARE a member. +// (35 columns) +.decl js_expression(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol) +.decl lib_js_expression(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol) + +// A call site — 1:1 with its call-like expression, so the key is a pure chain off c16. +// require() is NOT here: it is a module edge, and counting it as an unresolved call is what +// made the raw resolution figure look worse than it is. c4 receiverPosition admits +// FIRST_ARGUMENT because .call/.apply move the receiver INTO an argument (859 sites) and an +// engine reading the syntactic receiver gets Function.prototype.call as the target. c12 +// resolvedMethodLinkHash is TIER 3 and stays empty: the oracle itself decides only 52.6%, +// so the parser emits the name, the declared receiver type and the import hop instead. +// (25 columns) +.decl js_call_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) +.decl lib_js_call_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) + +// ========================================================================== +// SECOND FREEZE — declared now so the column contract is fixed. +// ========================================================================== + +// One inheritance edge. Separate from js_type because IN JAVASCRIPT AN EXTENDS EDGE CAN BE A +// FUNCTION CALL: c2 heritageForm admits UTIL_INHERITS and OBJECT_CREATE_PROTOTYPE alongside +// EXTENDS_CLAUSE. c7 resolvedTypeLinkHash is TIER 3 and stays empty — the parser emits the +// name as written (c3), the file it came from (c8) and the import hop (c9), and the ENGINE +// resolves, exactly as Java's referencedTypeRegistryLinkHash is populated 0 times in 67,938. +// (16 columns) +.decl js_type_heritage(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol) +.decl lib_js_type_heritage(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol) + +// A class field, a prototype property, or a member installed by Object.defineProperty. c3 +// declarationForm is IN THE KEY because `this.x = 1` in a constructor and `Foo.prototype.x` +// at module level are two real declarations of one member. c12 accessorPairKind marks the +// members where a property READ invokes a function — which is why GETTER_INVOCATION is a +// RESERVED call kind with a zero-row assertion and never emitted from syntax. +// (23 columns) +.decl js_field(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol) +.decl lib_js_field(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol) + +// One module edge OUT. module.exports = X (2,102), module.exports.x (380), exports.x (118), +// plus ESM declarations. c13 overwritesPreviousExport exists because +// `exports.a = 1; module.exports = {b}` exports ONLY b — a fact base recording both edges +// with no ordering asserts an export that does not exist at runtime. c5 isReExport marks the +// 81 measured `module.exports = require('./y')`: one edge that is simultaneously in and out. +// (22 columns) +.decl js_export(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol) +.decl lib_js_export(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol) + +// A lexical block — SYNTAX, where js_scope is BINDING. One block may open no scope and one +// scope may span several blocks, which is why they are two relations. c1 label exists +// because TypeScript emitted `outer: for (...)` as the loop and DROPPED the label. Every +// block form gets a row including the ones that produce no other output: TS's enum audit +// found NAMESPACE_BODY and MODULE_BODY emitting no block row at all. +// (17 columns) +.decl js_block(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol) +.decl lib_js_block(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol) + +// A node in a JSDoc TYPE EXPRESSION — the JavaScript type system in its entirety, and it lives +// in comments. Array> is three rows with c2 parentReferenceLinkHash. +// c14 isTypeOnly is ALWAYS true and no call-graph rule may traverse this relation. c1 +// admits UNKNOWN_SYNTAX deliberately: JSDoc type syntax is not standardised (Closure, +// TypeScript and jsdoc.app differ), so an undecomposable expression gets one row with its +// text preserved rather than a guess or a dropped tag. Depth cap 32; max measured 67. +// (23 columns) +.decl js_type_reference(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol) +.decl lib_js_type_reference(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol) + +// A comment, JSDoc block, or directive. c5 declaresType marks a comment that IS a declaration +// (@typedef/@callback), which is what makes js_type.evidenceKind = COMMENT_ONLY checkable. +// c6 directiveKind carries USE_STRICT, which decides the enclosing scope's strictness and +// therefore whether an undeclared assignment binds a global or throws. +// (16 columns) +.decl js_comment(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol) +.decl lib_js_comment(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol) + +// One row per construct the parser could not handle. RECORDS the gap, never rewrites source. +// An always-empty relation that suddenly has rows is a signal; a missing relation is a +// silence. On one large project a nested config silently excluded 1,270 of 1,821 files +// because nothing counted them. +// (11 columns) +.decl js_parse_gap(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol) +.decl lib_js_parse_gap(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol) diff --git a/parser/src/schema/javascript/gen_decls.py b/parser/src/schema/javascript/gen_decls.py new file mode 100644 index 000000000..d3be97ae2 --- /dev/null +++ b/parser/src/schema/javascript/gen_decls.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Generate decls_base_js.dl FROM the schema doc's own column tables. + +Arities come out of the '### 3.N `js_x` / `lib_js_x` — N columns' headers and are +cross-checked against the numbered rows of each table, so the .dl cannot drift from +schema.json. Run with --check to diff instead of write (for CI). + +Why generated: a hand-maintained .dl drifts silently. Column ORDER is the contract +with the Souffle engine, and the .dl carries only c0..cN — so a column rename is free +post-freeze and a reorder is not. Generating it is what makes that asymmetry safe. +""" +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +DOC = os.path.join(HERE, "schema.json") +OUT = os.path.join(HERE, "decls_base_js.dl") + +#: One-line-plus purpose per relation. Kept HERE rather than in the doc because the +#: .dl is what an engine author reads first, and it must stand alone. +DOCS = { + "js_module": + "A .js/.mjs/.cjs/.jsx file. One row per file, always — JavaScript has no `declare module`.\n" + "// c7 moduleSystem is IN THE KEY: `import` under a CommonJS config is a different program\n" + "// from `import` under an ESM one. c8 moduleSystemSource says HOW it was decided, because\n" + "// 91.4% of files are CommonJS by DEFAULT and not by declaration — without it a defaulted\n" + "// CommonJS and a declared one are indistinguishable. c10 contradictsGoverningConfig is a\n" + "// FLAG, never a skip: 6.2% of real files contradict their package.json and all of them are\n" + "// bundler input, which package.json never governs.", + "js_scope": + "The binder's output, and the relation with NO ts_* analogue. TypeScript needs no scope\n" + "// relation because declared types carry resolution; here the oracle resolves only 52.6% of\n" + "// call sites, so the BINDER is the resolution mechanism. c3 isFunctionScope is what `var`\n" + "// hoists to; c6 isStrictMode decides whether assignment to an undeclared name creates a\n" + "// global or throws; c9 hasWithStatement marks a scope where no name is statically\n" + "// resolvable, which is the honest answer rather than confident wrong bindings.", + "js_type": + "A type DECLARATION: ES class, constructor function with prototype members, or a JSDoc\n" + "// @typedef/@callback. Object literals are NOT here — they are values. There is deliberately\n" + "// NO declarationGroupKey: JavaScript has no declaration merging, so TypeScript's central\n" + "// problem does not exist. c12 evidenceKind = COMMENT_ONLY marks the 677 measured types whose\n" + "// ONLY evidence is a comment, and c13 isTypeOnly must keep them out of the call graph.", + "js_type_heritage": + "One inheritance edge. Separate from js_type because IN JAVASCRIPT AN EXTENDS EDGE CAN BE A\n" + "// FUNCTION CALL: c2 heritageForm admits UTIL_INHERITS and OBJECT_CREATE_PROTOTYPE alongside\n" + "// EXTENDS_CLAUSE. c7 resolvedTypeLinkHash is TIER 3 and stays empty — the parser emits the\n" + "// name as written (c3), the file it came from (c8) and the import hop (c9), and the ENGINE\n" + "// resolves, exactly as Java's referencedTypeRegistryLinkHash is populated 0 times in 67,938.", + "js_method": + "Every callable: function declaration, function expression, arrow, class method, accessor,\n" + "// prototype-assigned method, and the synthetic initializer. c9 declarationForm\n" + "// carries PROTOTYPE_ASSIGNMENT / STATIC_ASSIGNMENT — members DECLARED BY ASSIGNMENT (361 and\n" + "// 521 measured), which is a declaration and an expression at once; c27 ties it back. c10\n" + "// hoisting separates function declarations (hoisted) from function expressions (not), same\n" + "// syntax category, different behaviour. c17 thisBinding is LEXICAL for arrows: `this` is\n" + "// rebound by CALL FORM, 33,189 references measured.", + "js_method_parameter": + "A formal parameter. Only 0.165% carry a SYNTACTIC annotation and all of those are Flow, not\n" + "// TypeScript; 36.4% carry a JSDoc one. So c4 declaredTypeSource is the column that matters\n" + "// and NONE is the majority value. A destructured parameter is ONE row with c11\n" + "// patternBindingCount > 0 plus N js_variable rows — emitting N parameter rows would break\n" + "// c1 position, emitting one with no bindings would lose every name.", + "js_field": + "A class field, a prototype property, or a member installed by Object.defineProperty. c3\n" + "// declarationForm is IN THE KEY because `this.x = 1` in a constructor and `Foo.prototype.x`\n" + "// at module level are two real declarations of one member. c12 accessorPairKind marks the\n" + "// members where a property READ invokes a function — which is why GETTER_INVOCATION is a\n" + "// RESERVED call kind with a zero-row assertion and never emitted from syntax.", + "js_variable": + "Every binding that is not a parameter or a member. c2 bindingRegime and the PAIR c3/c4 are\n" + "// the hoisting model: c3 is where the name is VISIBLE FROM (function scope for var), c4 is\n" + "// the block it is WRITTEN IN. They differ for every `var` inside a block, and the difference\n" + "// is NOT recoverable from anything else — an engine would have to reimplement JavaScript\n" + "// scoping to derive it. c13 initializerKind = REQUIRE_CALL is how a local name becomes a\n" + "// module alias, which is the hop the engine walks for 83.6% of module edges.", + "js_import": + "One module edge IN. 83.6% of these rows are MINTED FROM EXPRESSIONS, not declarations, which\n" + "// is the finding that made JavaScript its own front end: c2 edgeBearer partitions the table\n" + "// and c14 sourceExpressionLinkHash points back at the expression so the second pass is\n" + "// auditable rather than asserted. c4 isTopLevel is FALSE for 13.6% of require() calls, so a\n" + "// statement-list walk misses one in seven. c12 resolverAgreement records where tsc and Node\n" + "// disagree (exports maps, conditional exports, #-imports) instead of silently picking.", + "js_export": + "One module edge OUT. module.exports = X (2,102), module.exports.x (380), exports.x (118),\n" + "// plus ESM declarations. c13 overwritesPreviousExport exists because\n" + "// `exports.a = 1; module.exports = {b}` exports ONLY b — a fact base recording both edges\n" + "// with no ordering asserts an export that does not exist at runtime. c5 isReExport marks the\n" + "// 81 measured `module.exports = require('./y')`: one edge that is simultaneously in and out.", + "js_expression": + "The spine. c32 introducesDeclarationLinkHash and c33 resolvedParameterLinkHash sit AFTER\n" + "// the primary key: the schema was\n" + "// already frozen and appending is the only safe edit, so this is the ONE relation whose PK\n" + "// is not its last column -- find it by name, never by position. It links an arrow or\n" + "// function expression to the js_method row it introduces, which is what connects\n" + "// emitter.on('x', () => {...}) to the body it installs: 31.0% of callables sit in argument\n" + "// position and 98.2% of those are anonymous, so without it every call inside a callback is\n" + "// orphaned from the registration that reaches it. NOT c12/c13 -- those mean 'this assignment\n" + "// declares a member', and widening them would break existing readers silently. c33 is the\n" + "// SIBLING of c17: c17 is FK->js_variable and a parameter is a js_method_parameter row, so a\n" + "// reference to a parameter resolved correctly and had nowhere to point -- 137,960 of 158,759\n" + "// resolved-but-unlinked references, and 58,483 unreachable parameter rows. NOT a widened c17:\n" + "// a polymorphic FK into two relations defeats the integrity gate, because 'the hash exists in\n" + "// one of them' is not integrity. No parameter EVER mints a js_variable row, simple or\n" + "// destructured, so the gap and the fix are both uniform.\n" + "// ORIGINAL: Reached by an ALLOWLIST of expression positions, never a generic tree walk — a\n" + "// generic walk puts JSDoc type names in here and type-only constructs then reach the call\n" + "// graph. c4 operatorString is why one ASSIGNMENT kind covers += -= ??= ||=: the operator is\n" + "// a COLUMN, not a kind, and the wrapper node with parented children is what makes\n" + "// `a += 1; b += 2` survive. c10 isModuleEdge marks the rows the import/export second pass\n" + "// reads; c12 isDeclarationBearing marks the assignments that DECLARE a member.", + "js_call_site": + "A call site — 1:1 with its call-like expression, so the key is a pure chain off c16.\n" + "// require() is NOT here: it is a module edge, and counting it as an unresolved call is what\n" + "// made the raw resolution figure look worse than it is. c4 receiverPosition admits\n" + "// FIRST_ARGUMENT because .call/.apply move the receiver INTO an argument (859 sites) and an\n" + "// engine reading the syntactic receiver gets Function.prototype.call as the target. c12\n" + "// resolvedMethodLinkHash is TIER 3 and stays empty: the oracle itself decides only 52.6%,\n" + "// so the parser emits the name, the declared receiver type and the import hop instead.", + "js_block": + "A lexical block — SYNTAX, where js_scope is BINDING. One block may open no scope and one\n" + "// scope may span several blocks, which is why they are two relations. c1 label exists\n" + "// because TypeScript emitted `outer: for (...)` as the loop and DROPPED the label. Every\n" + "// block form gets a row including the ones that produce no other output: TS's enum audit\n" + "// found NAMESPACE_BODY and MODULE_BODY emitting no block row at all.", + "js_type_reference": + "A node in a JSDoc TYPE EXPRESSION — the JavaScript type system in its entirety, and it lives\n" + "// in comments. Array> is three rows with c2 parentReferenceLinkHash.\n" + "// c14 isTypeOnly is ALWAYS true and no call-graph rule may traverse this relation. c1\n" + "// admits UNKNOWN_SYNTAX deliberately: JSDoc type syntax is not standardised (Closure,\n" + "// TypeScript and jsdoc.app differ), so an undecomposable expression gets one row with its\n" + "// text preserved rather than a guess or a dropped tag. Depth cap 32; max measured 67.", + "js_comment": + "A comment, JSDoc block, or directive. c5 declaresType marks a comment that IS a declaration\n" + "// (@typedef/@callback), which is what makes js_type.evidenceKind = COMMENT_ONLY checkable.\n" + "// c6 directiveKind carries USE_STRICT, which decides the enclosing scope's strictness and\n" + "// therefore whether an undeclared assignment binds a global or throws.", + "js_parse_gap": + "One row per construct the parser could not handle. RECORDS the gap, never rewrites source.\n" + "// An always-empty relation that suddenly has rows is a signal; a missing relation is a\n" + "// silence. On one large project a nested config silently excluded 1,270 of 1,821 files\n" + "// because nothing counted them.", +} + +#: The frozen first cut. Order here is the order in the .dl. +SPINE = ["js_module", "js_scope", "js_type", "js_method", "js_method_parameter", + "js_variable", "js_import", "js_expression", "js_call_site"] + +#: NO lib_js_* relation is EVER staged. OQ-2 is ruled (schema doc 8.1): there is ONE ambient +#: population and it is provenance-tagged, not language-tagged, so a JavaScript call site +#: REFERENCES the existing lib_ts_* rows rather than minting a parallel population. The pairs +#: are declared so every projection keeps its two-rule shape, and that is all. +NOT_STAGED = ["lib_js_module", "lib_js_scope", "lib_js_type", "lib_js_type_heritage", + "lib_js_method", "lib_js_method_parameter", "lib_js_field", "lib_js_variable", + "lib_js_import", "lib_js_export", "lib_js_expression", "lib_js_call_site", + "lib_js_block", "lib_js_type_reference", "lib_js_comment", "lib_js_parse_gap"] + +#: Relations the PARSER never writes, in either provenance. None yet — js_type_heritage +#: and js_call_site have tier-3 COLUMNS, but the parser does write their rows. +ENGINE_ONLY = [] + + +def parse_doc(): + """(relations, errors). A relation is (name, arity), read from schema.json — the + frozen column list, one array per relation; arity is its length. The markdown + schema documents were retired; this file is the schema.""" + import json + schema = json.load(open(DOC)) + rels, errors, seen = [], [], set() + for name, spec in schema["relations"].items(): + if not name.startswith("js_"): + errors.append("%s: not a js_ relation" % name) + if name in seen: + errors.append("%s: declared twice" % name) + seen.add(name) + cols = spec.get("columns", []) + if not cols: + errors.append("%s: no columns — arity unverifiable" % name) + if len(set(cols)) != len(cols): + errors.append("%s: a column name repeats" % name) + rels.append((name, len(cols))) + if not rels: + errors.append("parsed 0 relations from %s" % DOC) + for name, _ in rels: + if name not in DOCS: + errors.append("%s: no purpose comment in gen_decls.py DOCS" % name) + for name in DOCS: + if name not in seen: + errors.append("%s: has a DOCS comment but no entry in schema.json" % name) + for name in SPINE: + if name not in seen: + errors.append("%s: listed in SPINE but absent from schema.json" % name) + return rels, errors + + +def decl(name, arity): + cols = ",".join("c%d:symbol" % i for i in range(arity)) + return ".decl %s(%s)" % (name, cols) + + +def render(rels): + by_name = dict(rels) + out = ['''// ============================================================================ +// Base input relations — JAVASCRIPT parser IR. One relation pair per entity kind. +// +// GENERATED FROM schema.json BY gen_decls.py — DO NOT HAND-EDIT. +// Re-run `python3 gen_decls.py --check` in CI; drift here is a silent schema break. +// +// NAMING: one prefix per core language; `lib_` is the EXTERNAL marker on top of it. +// +// js_ JavaScript under analysis <- the PARSER emits only these +// lib_js_ external / third-party <- the ENGINE stages these +// +// COLUMN ORDER IS THE CONTRACT. All columns are `symbol`. New columns append ONLY. +// The last column is the entity's own unique hash for every relation EXCEPT +// js_expression, which has one column appended AFTER its hash (c32, +// introducesDeclarationLinkHash) because the schema was already frozen and appending +// is the only safe edit. So a check that locates a primary key BY POSITION is wrong +// for js_expression and must locate it by name. Column NAMES live only in the schema +// doc, so a rename is free after the freeze and a reorder is not. +// +// FIVE THINGS AN ENGINE AUTHOR MUST READ BEFORE JOINING ANYTHING: +// +// 1. THE MODULE GRAPH LIVES IN THE EXPRESSION RELATION. 83.6% of module edges are +// expression-borne: require() is a call, module.exports = X is an assignment. +// js_import/js_export rows are MINTED FROM js_expression in a second pass, and +// js_import.edgeBearer partitions the table (2,300 files wholly EXPRESSION, 431 +// wholly DECLARATION, 0 mixed). 13.6% of require() calls are not even top-level. +// +// 2. THE PARSER RESOLVES NOTHING ACROSS FILES, AND CANNOT. The oracle itself — +// tsc with checkJs — decides only 52.6% of call sites, because JavaScript types +// are inferred, not declared (0.165% syntactic annotations). resolvedMethodLinkHash +// and resolvedTypeLinkHash are TIER 3 and stay empty. What every row DOES carry is +// the name as written, the import hop, and resolvedFilePath. That is the contract. +// +// 3. TYPES LIVE IN COMMENTS. 36.4% of parameters are typed by JSDoc and 0.165% by +// syntax. js_type_reference is a TREE parsed out of comment text, isTypeOnly is +// always true there, and 677 js_type rows have evidenceKind = COMMENT_ONLY — +// declarations whose only evidence is a comment. None may reach the call graph. +// +// 4. NO lib_js_* ROW IS EVER STAGED. There is ONE ambient population and it is +// provenance-tagged, not language-tagged: a lib row describes a declaration read +// from a .d.ts, and whether a JavaScript or TypeScript call site references it is a +// property of the CALL SITE -- but the parser stages NO pointer to one, because a +// pointer to a specific lib row IS a resolved link and tier 3 forbids it. The row +// carries the target NAME AS WRITTEN plus resolutionOutcome = +// AMBIENT_BUILTIN_TARGET, and the ENGINE joins. This matters more than it sounds: +// 24.4% of all resolution declines are calls whose receiver is a Node builtin with +// no ambient declarations. +// +// 5. HOISTING IS TWO COLUMNS, NOT ONE. js_variable.declarationScopeLinkHash is where +// a name is VISIBLE FROM; syntacticScopeLinkHash is where it is WRITTEN. They +// differ for every `var` in a block. js_scope is a real relation with a parent FK +// because the binder, not the type system, is what resolves a name here. +// +// NOT STAGED (declared for symmetry only — library IR is declarations, not bodies): +// @NOT_STAGED@ +// ============================================================================''' + .replace("@NOT_STAGED@", ", ".join(NOT_STAGED))] + + def block(title, names): + out.append("") + out.append("// " + "=" * 74) + out.append("// " + title) + out.append("// " + "=" * 74) + for n in names: + arity = by_name[n] + out.append("") + out.append("// " + DOCS[n]) + out.append("// (%d columns)" % arity) + out.append(decl(n, arity)) + out.append(decl("lib_" + n, arity)) + + block("SPINE — the frozen first cut. Enough to build a call graph.", SPINE) + rest = [n for n, _ in rels if n not in SPINE] + block("SECOND FREEZE — declared now so the column contract is fixed.", rest) + out.append("") + return "\n".join(out) + + +def main(): + check = "--check" in sys.argv + rels, errors = parse_doc() + if errors: + print("SCHEMA DOC ERRORS — refusing to generate:") + for e in errors: + print(" " + e) + return 2 + text = render(rels) + total = sum(a for _, a in rels) + spine = sum(a for n, a in rels if n in SPINE) + if check: + have = open(OUT).read() if os.path.exists(OUT) else "" + if have != text: + print("DRIFT: %s does not match %s" % (os.path.basename(OUT), os.path.basename(DOC))) + import difflib + for line in list(difflib.unified_diff( + have.split("\n"), text.split("\n"), + fromfile="decls_base_js.dl (on disk)", + tofile="decls_base_js.dl (from schema.json)", lineterm=""))[:40]: + print(" " + line) + print(" Fix: python3 src/schema/javascript/gen_decls.py") + return 1 + print("OK %d relation pairs, %d columns (spine %d) — .dl matches schema.json" + % (len(rels), total, spine)) + return 0 + open(OUT, "w").write(text) + print("wrote %s — %d relation pairs, %d columns (spine %d)" + % (os.path.basename(OUT), len(rels), total, spine)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/parser/src/schema/javascript/schema.json b/parser/src/schema/javascript/schema.json new file mode 100644 index 000000000..d24efeccf --- /dev/null +++ b/parser/src/schema/javascript/schema.json @@ -0,0 +1,846 @@ +{ + "$comment": "FROZEN javascript fact schema \u2014 relation column order is the contract; new columns append only. Enum domains where the schema declares one. Generated once from the retired JAVASCRIPT-FACT-SCHEMA.md; edit here.", + "relations": { + "js_module": { + "columns": [ + "name", + "qualifiedName", + "fileName", + "filePath", + "baseMservPath", + "moduleKind", + "scriptKind", + "moduleSystem", + "moduleSystemSource", + "governingPackageJsonPath", + "contradictsGoverningConfig", + "contradictionKind", + "packageName", + "isExternalModule", + "hasTopLevelAwait", + "hasJsxContent", + "hasFlowPragma", + "emissionRegime", + "targetTsVersion", + "startLine", + "endLine", + "moduleInitMethodLinkHash", + "moduleScopeLinkHash", + "defaultExportLinkHash", + "sourceProvenance", + "isExternal", + "serviceVersionLinkHash", + "jsModuleUniqueHash" + ], + "domains": { + "moduleKind": [ + ".json", + "JSON_MODULE", + "SCRIPT_GLOBAL", + "SOURCE_MODULE" + ], + "scriptKind": [ + "JS", + "JSX" + ], + "moduleSystem": [ + "COMMONJS", + "ESM" + ], + "moduleSystemSource": [ + "EXT_CJS", + "EXT_MJS", + "NO_PACKAGE_JSON_DEFAULT", + "PKG_TYPE_ABSENT_DEFAULT", + "PKG_TYPE_COMMONJS", + "PKG_TYPE_MODULE" + ], + "contradictionKind": [ + "ESM_SYNTAX_UNDER_COMMONJS", + "MIXED", + "NONE", + "REQUIRE_UNDER_ESM" + ], + "sourceProvenance": [ + "BUNDLED", + "FLOW_REJECTED", + "GENERATED_MONOLITH", + "PROJECT" + ] + } + }, + "js_type": { + "columns": [ + "name", + "qualifiedName", + "fileName", + "filePath", + "baseMservPath", + "startLine", + "endLine", + "startColumn", + "typeCategory", + "declarationForm", + "isAbstract", + "modifiers", + "evidenceKind", + "isTypeOnly", + "declaredMemberCount", + "hasPrototypeMembers", + "constructorMethodLinkHash", + "ownerModuleLinkHash", + "ownerScopeLinkHash", + "enclosingMethodLinkHash", + "sourceExpressionLinkHash", + "jsdocCommentLinkHash", + "isExported", + "isExternal", + "serviceVersionLinkHash", + "jsTypeUniqueHash" + ], + "domains": { + "typeCategory": [ + "ANONYMOUS_CLASS", + "CLASS", + "CONSTRUCTOR_FUNCTION", + "JSDOC_CALLBACK", + "JSDOC_TYPEDEF" + ], + "declarationForm": [ + "CLASS_DECLARATION", + "CLASS_EXPRESSION", + "JSDOC_TYPEDEF", + "PROTOTYPE_CONSTRUCTOR" + ], + "evidenceKind": [ + "COMMENT_ONLY", + "SYNTAX" + ] + } + }, + "js_type_heritage": { + "columns": [ + "ownerTypeLinkHash", + "position", + "heritageForm", + "superTypeName", + "superTypeExpressionText", + "isComputedSuperclass", + "inheritsMembers", + "resolvedTypeLinkHash", + "resolvedFilePath", + "importLinkHash", + "sourceExpressionLinkHash", + "startLine", + "ownerModuleLinkHash", + "isExternal", + "serviceVersionLinkHash", + "jsTypeHeritageUniqueHash" + ], + "domains": { + "heritageForm": [ + "EXTENDS_CLAUSE", + "OBJECT_CREATE_PROTOTYPE", + "PROTOTYPE_ASSIGNMENT", + "UTIL_INHERITS" + ] + } + }, + "js_method": { + "columns": [ + "name", + "qualifiedName", + "fileName", + "filePath", + "baseMservPath", + "startLine", + "endLine", + "startColumn", + "methodKind", + "declarationForm", + "hoisting", + "isAsync", + "isGenerator", + "isStatic", + "parameterCount", + "hasRestParameter", + "usesArguments", + "thisBinding", + "returnTypeName", + "declaredTypeSource", + "returnTypeReferenceLinkHash", + "bodyPresence", + "ownerTypeLinkHash", + "ownerModuleLinkHash", + "ownerScopeLinkHash", + "bodyScopeLinkHash", + "enclosingMethodLinkHash", + "sourceExpressionLinkHash", + "jsdocCommentLinkHash", + "isExported", + "isEntryPoint", + "methodReferenceKind", + "modifiers", + "isExternal", + "serviceVersionLinkHash", + "jsMethodUniqueHash" + ], + "domains": { + "methodKind": [ + "ARROW", + "CLASS_METHOD", + "CONSTRUCTOR", + "FUNCTION_DECLARATION", + "FUNCTION_EXPRESSION", + "GETTER", + "MODULE_INITIALIZER", + "SETTER", + "STATIC_BLOCK" + ], + "declarationForm": [ + "OBJECT_ASSIGN_PROTOTYPE", + "OBJECT_DEFINE_PROPERTY", + "PROTOTYPE_ASSIGNMENT", + "PROTOTYPE_OBJECT_LITERAL", + "STATIC_ASSIGNMENT", + "SYNTACTIC" + ], + "hoisting": [ + "HOISTED_FULLY", + "NOT_APPLICABLE", + "NOT_HOISTED", + "TDZ" + ], + "thisBinding": [ + ".bind", + "BOUND", + "DYNAMIC", + "LEXICAL", + "NONE" + ], + "declaredTypeSource": [ + "JSDOC", + "NONE", + "SYNTACTIC_FLOW" + ], + "bodyPresence": [ + "EXPRESSION_BODY", + "HAS_BODY", + "NO_BODY" + ] + } + }, + "js_method_parameter": { + "columns": [ + "name", + "position", + "ownerMethodLinkHash", + "declaredTypeName", + "declaredTypeSource", + "typeReferenceLinkHash", + "isOptional", + "hasDefault", + "defaultValueText", + "isRest", + "bindingForm", + "patternBindingCount", + "bindingRegime", + "scopeLinkHash", + "startLine", + "startColumn", + "isParameterProperty", + "jsdocCommentLinkHash", + "ownerModuleLinkHash", + "isExternal", + "serviceVersionLinkHash", + "jsMethodParameterUniqueHash" + ], + "domains": { + "declaredTypeSource": [ + "JSDOC", + "NONE", + "SYNTACTIC_FLOW" + ], + "bindingForm": [ + "ARRAY_PATTERN", + "ASSIGNMENT_PATTERN", + "IDENTIFIER", + "OBJECT_PATTERN" + ] + } + }, + "js_field": { + "columns": [ + "name", + "qualifiedName", + "ownerTypeLinkHash", + "declarationForm", + "isStatic", + "isPrivateName", + "isReadonly", + "declaredTypeName", + "declaredTypeSource", + "typeReferenceLinkHash", + "hasInitializer", + "initializerExpressionLinkHash", + "accessorPairKind", + "getterMethodLinkHash", + "setterMethodLinkHash", + "isComputedName", + "sourceExpressionLinkHash", + "startLine", + "startColumn", + "ownerModuleLinkHash", + "isExternal", + "serviceVersionLinkHash", + "jsFieldUniqueHash" + ], + "domains": { + "declarationForm": [ + "CLASS_FIELD", + "CONSTRUCTOR_THIS_ASSIGNMENT", + "OBJECT_DEFINE_PROPERTY", + "PROTOTYPE_ASSIGNMENT", + "STATIC_ASSIGNMENT" + ], + "accessorPairKind": [ + "GETTER_ONLY", + "GETTER_SETTER", + "NONE", + "SETTER_ONLY" + ] + } + }, + "js_variable": { + "columns": [ + "name", + "qualifiedName", + "bindingRegime", + "declarationScopeLinkHash", + "syntacticScopeLinkHash", + "hasTemporalDeadZone", + "bindingForm", + "patternRootVariableLinkHash", + "declaredTypeName", + "declaredTypeSource", + "typeReferenceLinkHash", + "hasInitializer", + "initializerExpressionLinkHash", + "initializerKind", + "importLinkHash", + "isReassigned", + "ownerMethodLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "endLine", + "isExported", + "isExternal", + "serviceVersionLinkHash", + "jsVariableUniqueHash" + ], + "domains": { + "bindingRegime": [ + "CATCH_PARAMETER", + "CLASS_TDZ", + "CONST_BLOCK_TDZ", + "FUNCTION_DECLARATION_HOISTED", + "GLOBAL_IMPLICIT", + "IMPORT_BINDING", + "LET_BLOCK_TDZ", + "VAR_FUNCTION_SCOPED_HOISTED" + ], + "bindingForm": [ + "ARRAY_PATTERN", + "IDENTIFIER", + "OBJECT_PATTERN" + ], + "initializerKind": [ + "CLASS", + "FUNCTION", + "IMPORT_BINDING", + "NONE", + "OBJECT_LITERAL", + "OTHER", + "REQUIRE_CALL" + ] + } + }, + "js_import": { + "columns": [ + "specifier", + "specifierKind", + "edgeBearer", + "importForm", + "isTopLevel", + "isConditional", + "bindingForm", + "importedName", + "localName", + "resolvedFilePath", + "resolutionOutcome", + "resolvedModuleLinkHash", + "isTypeOnly", + "sourceExpressionLinkHash", + "boundVariableLinkHash", + "ownerScopeLinkHash", + "ownerMethodLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "isExternal", + "serviceVersionLinkHash", + "jsImportUniqueHash" + ], + "domains": { + "specifierKind": [ + "NON_LITERAL", + "STRING_LITERAL", + "TEMPLATE" + ], + "edgeBearer": [ + "COMMENT", + "DECLARATION", + "EXPRESSION" + ], + "importForm": [ + "CREATE_REQUIRE", + "DYNAMIC_IMPORT", + "IMPORT_DECLARATION", + "IMPORT_EQUALS", + "JSDOC_IMPORT_TYPE", + "REQUIRE_CALL" + ], + "bindingForm": [ + "DEFAULT", + "DESTRUCTURED", + "NAMED", + "NAMESPACE", + "NO_LOCAL_BINDING", + "SIDE_EFFECT_ONLY" + ], + "resolutionOutcome": [ + "RESOLVED_BUILTIN", + "RESOLVED_EXTERNAL", + "RESOLVED_PROJECT", + "UNRESOLVED_MISSING", + "UNRESOLVED_NON_LITERAL" + ] + } + }, + "js_export": { + "columns": [ + "exportedName", + "localName", + "edgeBearer", + "exportForm", + "exportedValueKind", + "isReExport", + "reExportSpecifier", + "reExportImportLinkHash", + "isTopLevel", + "isConditional", + "targetKind", + "targetLinkHash", + "sourceExpressionLinkHash", + "overwritesPreviousExport", + "ownerScopeLinkHash", + "ownerMethodLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "isExternal", + "serviceVersionLinkHash", + "jsExportUniqueHash" + ], + "domains": { + "edgeBearer": [ + "DECLARATION", + "EXPRESSION" + ], + "exportForm": [ + "EXPORTS_MEMBER", + "EXPORT_ALL", + "EXPORT_DECLARATION", + "EXPORT_DEFAULT", + "MODULE_EXPORTS_ASSIGNMENT", + "MODULE_EXPORTS_MEMBER", + "OBJECT_DEFINE_PROPERTY" + ], + "exportedValueKind": [ + "CLASS", + "FUNCTION", + "IDENTIFIER", + "OBJECT_LITERAL", + "OTHER", + "REQUIRE_REEXPORT" + ], + "targetKind": [ + "EXPRESSION_VALUE", + "FIELD", + "METHOD", + "TYPE", + "VARIABLE" + ] + } + }, + "js_expression": { + "columns": [ + "expressionKind", + "text", + "name", + "isComputedName", + "operatorString", + "depth", + "parentExpressionLinkHash", + "edgeRole", + "childIndex", + "rootContext", + "isModuleEdge", + "moduleEdgeLinkHash", + "isDeclarationBearing", + "declarationLinkHash", + "callSiteLinkHash", + "referencedName", + "referenceKind", + "resolvedBindingLinkHash", + "bindingResolution", + "isTypeOnlyReachable", + "literalKind", + "isTruncated", + "ownerScopeLinkHash", + "ownerMethodLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "endLine", + "endColumn", + "isExternal", + "serviceVersionLinkHash", + "jsExpressionUniqueHash", + "introducesDeclarationLinkHash", + "resolvedParameterLinkHash", + "bindingPath" + ], + "domains": { + "edgeRole": [ + "ARGUMENT", + "ASSIGNMENT_TARGET", + "ASSIGNMENT_VALUE", + "CALLEE", + "CONDITION", + "ELEMENT", + "PROPERTY_VALUE", + "RECEIVER", + "SPREAD_OPERAND", + "TEMPLATE_SUBSTITUTION" + ], + "referenceKind": [ + "DELETE", + "READ", + "READ_WRITE", + "TYPEOF", + "WRITE" + ], + "bindingResolution": [ + "CLASS_PRIVATE", + "CLOSURE", + "GLOBAL_BUILTIN", + "IMPORTED", + "LOCAL", + "MODULE", + "UNRESOLVED_FREE" + ], + "literalKind": [ + "BIGINT", + "NONE", + "NULL", + "NUMBER", + "REGEX", + "STRING", + "TEMPLATE", + "UNDEFINED" + ] + } + }, + "js_call_site": { + "columns": [ + "callKind", + "calleeText", + "calleeName", + "receiverText", + "receiverPosition", + "receiverExpressionLinkHash", + "argumentCount", + "hasSpreadArgument", + "isOptionalCall", + "declaredReceiverTypeName", + "receiverTypeSource", + "importLinkHash", + "resolvedMethodLinkHash", + "resolutionOutcome", + "isDynamicCode", + "enclosingMethodLinkHash", + "expressionLinkHash", + "ownerScopeLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "isTypeOnlyTarget", + "isExternal", + "serviceVersionLinkHash", + "jsCallSiteUniqueHash" + ], + "domains": { + "receiverPosition": [ + ".apply", + ".call", + "FIRST_ARGUMENT", + "NONE", + "SYNTACTIC" + ], + "receiverTypeSource": [ + "IMPORT_ALIAS", + "JSDOC", + "LOCAL_CLASS", + "NODE_BUILTIN", + "NONE" + ], + "resolutionOutcome": [ + "AMBIENT_BUILTIN_TARGET", + "COMPUTED_NAME", + "DYNAMIC_CODE", + "IMPORT_HOP_AVAILABLE", + "RECEIVER_UNTYPED", + "SAME_FILE_RESOLVED" + ] + } + }, + "js_block": { + "columns": [ + "blockKind", + "label", + "parentBlockLinkHash", + "depth", + "childIndex", + "scopeLinkHash", + "opensScope", + "conditionExpressionLinkHash", + "ownerMethodLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "endLine", + "endColumn", + "isExternal", + "serviceVersionLinkHash", + "jsBlockUniqueHash" + ], + "domains": { + "blockKind": [ + "BLOCK", + "CATCH", + "CLASS_BODY", + "CLASS_STATIC_BLOCK", + "DO", + "ELSE", + "FINALLY", + "FOR", + "FOR_IN", + "FOR_OF", + "FUNCTION_BODY", + "IF", + "LABELED", + "MODULE_BODY", + "SWITCH", + "SWITCH_CASE", + "TRY", + "WHILE" + ] + } + }, + "js_scope": { + "columns": [ + "scopeKind", + "parentScopeLinkHash", + "depth", + "isFunctionScope", + "bindsThis", + "bindsArguments", + "isStrictMode", + "strictModeSource", + "declaredBindingCount", + "hasWithStatement", + "ownerMethodLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "isExternal", + "serviceVersionLinkHash", + "jsScopeUniqueHash" + ], + "domains": { + "scopeKind": [ + "ARROW", + "BLOCK", + "CATCH", + "CLASS", + "CLASS_STATIC_BLOCK", + "FUNCTION", + "GLOBAL", + "MODULE", + "WITH" + ], + "strictModeSource": [ + "CLASS_BODY_IMPLICIT", + "ESM_IMPLICIT", + "SLOPPY", + "USE_STRICT_DIRECTIVE" + ] + } + }, + "js_type_reference": { + "columns": [ + "typeName", + "referenceKind", + "parentReferenceLinkHash", + "depth", + "childIndex", + "childCount", + "isTruncated", + "contextKind", + "tagName", + "ownerKind", + "ownerLinkHash", + "resolvedTypeLinkHash", + "resolvedFilePath", + "importLinkHash", + "isTypeOnly", + "isBuiltinType", + "commentLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "isExternal", + "serviceVersionLinkHash", + "jsTypeReferenceUniqueHash" + ], + "domains": { + "referenceKind": [ + "ANY", + "ARRAY", + "FUNCTION_TYPE", + "GENERIC_APPLICATION", + "IMPORT_TYPE", + "INDEXED_ACCESS", + "INTERSECTION", + "NAMED", + "NON_NULLABLE", + "NULLABLE", + "OBJECT_TYPE", + "OPTIONAL", + "REST", + "TUPLE", + "TYPE_LITERAL", + "TYPE_PREDICATE", + "TYPE_QUERY", + "UNION", + "UNKNOWN_SYNTAX" + ], + "contextKind": [ + "CAST", + "EXTENDS", + "FIELD", + "IMPLEMENTS", + "PARAM", + "RETURN", + "TEMPLATE", + "THIS", + "THROWS", + "TYPEDEF", + "VARIABLE" + ], + "ownerKind": [ + "EXPRESSION", + "FIELD", + "METHOD", + "METHOD_PARAMETER", + "TYPE", + "VARIABLE" + ] + } + }, + "js_comment": { + "columns": [ + "commentKind", + "text", + "isJsdoc", + "jsdocTagNames", + "jsdocTagCount", + "declaresType", + "directiveKind", + "attachedToKind", + "attachedToLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "endLine", + "isExternal", + "serviceVersionLinkHash", + "jsCommentUniqueHash" + ], + "domains": { + "commentKind": [ + "BLOCK", + "DIRECTIVE", + "JSDOC", + "LINE", + "SHEBANG" + ], + "directiveKind": [ + "ESLINT", + "FLOW_PRAGMA", + "NONE", + "SOURCE_MAP", + "TS_CHECK", + "TS_NOCHECK", + "USE_STRICT" + ], + "attachedToKind": [ + "FIELD", + "METHOD", + "MODULE", + "NONE", + "TYPE", + "VARIABLE" + ] + } + }, + "js_parse_gap": { + "columns": [ + "gapKind", + "detail", + "relatedRelation", + "relatedLinkHash", + "ownerModuleLinkHash", + "startLine", + "startColumn", + "isRecoverable", + "isExternal", + "serviceVersionLinkHash", + "jsParseGapUniqueHash" + ], + "domains": { + "gapKind": [ + "DEPTH_CAP_REACHED", + "DYNAMIC_CODE", + "FLOW_SYNTAX", + "NON_LITERAL_SPECIFIER", + "PARSE_ERROR", + "UNKNOWN_JSDOC_SYNTAX", + "WITH_STATEMENT_SCOPE" + ] + } + } + } +} diff --git a/parser/src/schema/python/decls_base_py.dl b/parser/src/schema/python/decls_base_py.dl new file mode 100644 index 000000000..d2cbec9c9 --- /dev/null +++ b/parser/src/schema/python/decls_base_py.dl @@ -0,0 +1,164 @@ +// ============================================================================ +// Base input relations — PYTHON parser IR. One relation pair per entity kind. +// +// GENERATED FROM schema.json BY gen_decls.py — DO NOT HAND-EDIT. +// Re-run `python3 gen_decls.py --check` in CI; a drift here is a silent schema break. +// +// NAMING: one prefix per core language. java_* is Java, py_* is Python, the next +// language takes its own lan_*. `lib_` remains the EXTERNAL marker layered on top: +// +// py_ Python source under analysis <- the PARSER emits only these +// lib_py_ external / third-party Python <- the ENGINE populates these +// +// COLUMN ORDER IS THE CONTRACT. All columns are `symbol`. New columns append ONLY. +// Convention: the last column is the entity's own unique hash; serviceVersionLinkHash +// is immediately before it. +// +// UNIFIED PYTHON 2 / PYTHON 3 OUTPUT: both dialects emit into these same relations. +// The dialect is a COLUMN (py_module.pythonDialect, c9); consumers must read it before +// interpreting py_type.typeCategory, py_type.mroKind, or comprehension scoping. +// +// NOT STAGED (declared for symmetry only — library IR is declarations, not bodies): +// lib_py_expression, lib_py_call_site, lib_py_block, lib_py_binding, +// lib_py_field_write, lib_py_type_inference, lib_py_source_bridge_edit +// ============================================================================ + +// ========================================================================== +// SPINE — proposed for the first freeze (supports all four data-flow paths) +// ========================================================================== +// A Python source file or package __init__.py. The unit of import resolution and +// a first-class runtime namespace. No Java analogue. c9 pythonDialect discriminates +// Python 2 vs 3 — both dialects emit into THIS relation, never a separate one. +// (24 columns) +.decl py_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol) +.decl lib_py_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol) + +// A lexical scope, mirroring CPython symtable.SymbolTable. c4 parentScopeLinkHash is the +// spine the name-resolution layer walks upward. c20 startColumn is IN THE KEY: without it +// two lambdas or two comprehensions on one line collide (measured 0.60% of scopes), which +// would silently merge two scopes' entire binding sets. No Java analogue. +// (25 columns) +.decl py_scope(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) +.decl lib_py_scope(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) + +// One row per (scope, name) — CPython symtable.Symbol. Python's local_variable table plus +// its global / nonlocal / free / import / parameter tables, unified. Cols 4..14 are the +// COMPLETE symtable.Symbol predicate set (11 on Py3; Py2 lacks is_nonlocal/is_annotated, +// which are then always "false"). Resolves the 50.7% of calls with a bare-name receiver. +// (29 columns) +.decl py_binding(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol) +.decl lib_py_binding(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol) + +// A class statement. Cols 0..11 mirror java_type 0..11. c3 typeCategory and c21 mroKind are +// dialect-sensitive: a base-less class is OLD_STYLE_CLASS_TYPE / OLD_STYLE_DFS under Py2 and +// CLASS_TYPE / IMPLICIT_OBJECT under Py3. +// (25 columns) +.decl py_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) +.decl lib_py_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) + +// One base-class expression. ORDERED (c1 position) because C3 / MRO depends on it — 12.1% of +// classes have multiple bases. Also carries keyword bases (metaclass=) and dynamic bases, +// which a type_reference cannot express. +// (16 columns) +.decl py_type_base(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol) +.decl lib_py_type_base(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol) + +// A def / async def / lambda, plus the synthetic and initializers that +// give module-level and class-body code a caller. Cols 0..20 are byte-for-byte java_method +// 0..20; c32 startColumn is APPENDED (not placed by startLine) to preserve that parity, and +// is IN THE KEY because two lambdas can share line+qualname+signature. +// (36 columns) +.decl py_method(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol,c35:symbol) +.decl lib_py_method(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol,c35:symbol) + +// A formal parameter. Cols 0..11 mirror java_method_parameter 0..11. 68.2% carry no annotation, +// so argument->parameter flow, not declared type, is the primary receiver-typing mechanism. +// (22 columns) +.decl py_method_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol) +.decl lib_py_method_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol) + +// An import statement. Cols 0..8 mirror java_import 0..8. 38% of from-imports are relative, so +// c9 relativeLevel and parser-side in-repo resolution (c12) matter. c15 isExternalTarget means +// only 'did not resolve in this analysis'. +// (24 columns) +.decl py_import(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol) +.decl lib_py_import(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol) + +// An expression AST node — the spine of call resolution. Cols 0..23 mirror java_expression +// 0..23. c24 pyScopeLinkHash and c26 bindingLinkHash are the key additions: Python resolves +// names by scope chain, not by file. There is deliberately no OBJECT_CREATION kind — +// construction is recorded in py_type_inference instead. +// (39 columns) +.decl py_expression(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol,c35:symbol,c36:symbol,c37:symbol,c38:symbol) +.decl lib_py_expression(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol,c35:symbol,c36:symbol,c37:symbol,c38:symbol) + +// A call site. A BASE relation for Python (derived in Java) because the call shape is not +// recoverable from one positional pattern: keyword args, * / ** spreading, chained receivers, +// super(), and the receiver's syntactic shape (c4 receiverKind) which is all we honestly know +// about a duck-typed receiver. 1:1 with its CALL expression, so its key chains off c5. +// (26 columns) +.decl py_call_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol) +.decl lib_py_call_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol) + +// ========================================================================== +// DEFERRED — proposed for a second freeze +// ========================================================================== +// A use of a type: annotation, base class, isinstance / cast target, except or raise type, or +// a Python 2 '# type:' comment. Cols 0..16 mirror java_type_reference 0..16 so the name->type +// resolution layer ports as a relation rename. +// (25 columns) +.decl py_type_reference(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) +.decl lib_py_type_reference(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) + +// A class attribute or an instance attribute recovered from 'self.x = ...'. Cols 0..12 mirror +// java_field 0..12. Identity is (ownerType, name, origin) — all writes of one attribute merge +// into one row; see py_field_write for each site. +// (29 columns) +.decl py_field(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol) +.decl lib_py_field(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol) + +// A field's declaration order within its type. @dataclass / NamedTuple generate __init__ in +// this order, so positional argument flow depends on it. +// (3 columns) +.decl py_field_position(c0:symbol,c1:symbol,c2:symbol) +.decl lib_py_field_position(c0:symbol,c1:symbol,c2:symbol) + +// A decorator applied to a class or function. NOT an annotation: a decorator is a call that +// REPLACES the decorated object (c17 replacesTarget). Cols 0..3 mirror java_annotation 0..3. +// (21 columns) +.decl py_decorator(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol) +.decl lib_py_decorator(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol) + +// A positional or keyword argument of a decorator call — where framework routes and permissions +// live. Cols 0..9 mirror java_annotation_argument 0..9. +// (15 columns) +.decl py_decorator_argument(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol) +.decl lib_py_decorator_argument(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol) + +// A comment, shebang, encoding cookie, '# type:' comment, or docstring. Cols 0..8 mirror +// java_comment 0..8. c11 typeCommentPayload is Python 2's only annotation channel. +// (15 columns) +.decl py_comment(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol) +.decl lib_py_comment(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol) + +// An indentation-delimited suite. Cols 0..16 mirror java_block 0..16. c9 methodOwnerHash is +// NEVER empty — module-level blocks are owned by the synthetic method. c19 links the +// if/while condition, which is how isinstance() narrowing reaches the receiver. +// (27 columns) +.decl py_block(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol) +.decl lib_py_block(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol) + +// One row per construct the grammar could not represent. RECORDS the gap, does not repair it. +// Replaces v3's py_source_bridge_edit: tree-sitter parses 99.59% of the CPython 2.7 stdlib +// unaided, so rewriting source to recover 0.41% of files was the wrong trade. Positions stay +// measured, never mapped. Zero rows for ~99.6% of modules, Python 2 included. +// (10 columns) +.decl py_parse_gap(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol) +.decl lib_py_parse_gap(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol) + +// DEFERRED — PEP 695 (3.12+) only: 'class C[T]' / 'def f[T]()'. Declared now so the column +// contract is fixed; NOT emitted for <=3.11, where TypeVar is a runtime assignment captured as +// a py_binding with targetEntityKind=TYPE_VAR. +// (15 columns) +.decl py_type_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol) +.decl lib_py_type_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol) diff --git a/parser/src/schema/python/gen_decls.py b/parser/src/schema/python/gen_decls.py new file mode 100644 index 000000000..f27cfcc38 --- /dev/null +++ b/parser/src/schema/python/gen_decls.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Generate decls_base_py.dl FROM the schema doc's own column tables. + +Arities are parsed out of the '### 2.N `py_x` / `lib_py_x` — N columns' headers and +cross-checked against the number of numbered rows in each table, so the .dl cannot +drift from the document. Run with --check to diff instead of write (for CI). +""" +import os, re, sys, hashlib + +DOC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.json") +import json +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "decls_base_py.dl") + +DOCS = { + "py_module": "A Python source file or package __init__.py. The unit of import resolution and\n// a first-class runtime namespace. No Java analogue. c9 pythonDialect discriminates\n// Python 2 vs 3 — both dialects emit into THIS relation, never a separate one.", + "py_scope": "A lexical scope, mirroring CPython symtable.SymbolTable. c4 parentScopeLinkHash is the\n// spine the name-resolution layer walks upward. c20 startColumn is IN THE KEY: without it\n// two lambdas or two comprehensions on one line collide (measured 0.60% of scopes), which\n// would silently merge two scopes' entire binding sets. No Java analogue.", + "py_binding": "One row per (scope, name) — CPython symtable.Symbol. Python's local_variable table plus\n// its global / nonlocal / free / import / parameter tables, unified. Cols 4..14 are the\n// COMPLETE symtable.Symbol predicate set (11 on Py3; Py2 lacks is_nonlocal/is_annotated,\n// which are then always \"false\"). Resolves the 50.7% of calls with a bare-name receiver.", + "py_type": "A class statement. Cols 0..11 mirror java_type 0..11. c3 typeCategory and c21 mroKind are\n// dialect-sensitive: a base-less class is OLD_STYLE_CLASS_TYPE / OLD_STYLE_DFS under Py2 and\n// CLASS_TYPE / IMPLICIT_OBJECT under Py3.", + "py_type_base": "One base-class expression. ORDERED (c1 position) because C3 / MRO depends on it — 12.1% of\n// classes have multiple bases. Also carries keyword bases (metaclass=) and dynamic bases,\n// which a type_reference cannot express.", + "py_type_reference": "A use of a type: annotation, base class, isinstance / cast target, except or raise type, or\n// a Python 2 '# type:' comment. Cols 0..16 mirror java_type_reference 0..16 so the name->type\n// resolution layer ports as a relation rename.", + "py_method": "A def / async def / lambda, plus the synthetic and initializers that\n// give module-level and class-body code a caller. Cols 0..20 are byte-for-byte java_method\n// 0..20; c32 startColumn is APPENDED (not placed by startLine) to preserve that parity, and\n// is IN THE KEY because two lambdas can share line+qualname+signature.", + "py_method_parameter": "A formal parameter. Cols 0..11 mirror java_method_parameter 0..11. 68.2% carry no annotation,\n// so argument->parameter flow, not declared type, is the primary receiver-typing mechanism.", + "py_field": "A class attribute or an instance attribute recovered from 'self.x = ...'. Cols 0..12 mirror\n// java_field 0..12. Identity is (ownerType, name, origin) — all writes of one attribute merge\n// into one row; see py_field_write for each site.", + "py_field_write": "One write site of an attribute. Required for data flow: only 67% of self.x writes are in\n// __init__, so cross-method attribute state is common.", + "py_field_position": "A field's declaration order within its type. @dataclass / NamedTuple generate __init__ in\n// this order, so positional argument flow depends on it.", + "py_decorator": "A decorator applied to a class or function. NOT an annotation: a decorator is a call that\n// REPLACES the decorated object (c17 replacesTarget). Cols 0..3 mirror java_annotation 0..3.", + "py_decorator_argument": "A positional or keyword argument of a decorator call — where framework routes and permissions\n// live. Cols 0..9 mirror java_annotation_argument 0..9.", + "py_import": "An import statement. Cols 0..8 mirror java_import 0..8. 38% of from-imports are relative, so\n// c9 relativeLevel and parser-side in-repo resolution (c12) matter. c15 isExternalTarget means\n// only 'did not resolve in this analysis'.", + "py_expression": "An expression AST node — the spine of call resolution. Cols 0..23 mirror java_expression\n// 0..23. c24 pyScopeLinkHash and c26 bindingLinkHash are the key additions: Python resolves\n// names by scope chain, not by file. There is deliberately no OBJECT_CREATION kind —\n// construction is recorded in py_type_inference instead.", + "py_call_site": "A call site. A BASE relation for Python (derived in Java) because the call shape is not\n// recoverable from one positional pattern: keyword args, * / ** spreading, chained receivers,\n// super(), and the receiver's syntactic shape (c4 receiverKind) which is all we honestly know\n// about a duck-typed receiver. 1:1 with its CALL expression, so its key chains off c5.", + "py_comment": "A comment, shebang, encoding cookie, '# type:' comment, or docstring. Cols 0..8 mirror\n// java_comment 0..8. c11 typeCommentPayload is Python 2's only annotation channel.", + "py_block": "An indentation-delimited suite. Cols 0..16 mirror java_block 0..16. c9 methodOwnerHash is\n// NEVER empty — module-level blocks are owned by the synthetic method. c19 links the\n// if/while condition, which is how isinstance() narrowing reaches the receiver.", + "py_parse_gap": "One row per construct the grammar could not represent. RECORDS the gap, does not repair it.\n// Replaces v3's py_source_bridge_edit: tree-sitter parses 99.59% of the CPython 2.7 stdlib\n// unaided, so rewriting source to recover 0.41% of files was the wrong trade. Positions stay\n// measured, never mapped. Zero rows for ~99.6% of modules, Python 2 included.", + "py_type_parameter": "DEFERRED — PEP 695 (3.12+) only: 'class C[T]' / 'def f[T]()'. Declared now so the column\n// contract is fixed; NOT emitted for <=3.11, where TypeVar is a runtime assignment captured as\n// a py_binding with targetEntityKind=TYPE_VAR.", +} + +SPINE = ["py_module","py_scope","py_binding","py_type","py_type_base","py_method", + "py_method_parameter","py_import","py_expression","py_call_site"] + +#: relation -> the column NAMES the doc actually declares for it. Populated by +#: parse_doc and used to reject an enum documented for a column that does not exist. +COLUMNS_BY_REL = {} + + +def parse_doc(): + """(relations, errors) from schema.json — the frozen column list per relation.""" + schema = json.load(open(DOC)) + rels, errors = [], [] + for name, spec in schema["relations"].items(): + cols = spec.get("columns", []) + if not cols: + errors.append("%s: NO column list - cannot verify arity" % name) + rels.append((name, len(cols))) + COLUMNS_BY_REL[name] = set(cols) + return rels, errors + +def render(rels): + hdr = '''// ============================================================================ +// Base input relations — PYTHON parser IR. One relation pair per entity kind. +// +// GENERATED FROM schema.json BY gen_decls.py — DO NOT HAND-EDIT. +// Re-run `python3 gen_decls.py --check` in CI; a drift here is a silent schema break. +// +// NAMING: one prefix per core language. java_* is Java, py_* is Python, the next +// language takes its own lan_*. `lib_` remains the EXTERNAL marker layered on top: +// +// py_ Python source under analysis <- the PARSER emits only these +// lib_py_ external / third-party Python <- the ENGINE populates these +// +// COLUMN ORDER IS THE CONTRACT. All columns are `symbol`. New columns append ONLY. +// Convention: the last column is the entity's own unique hash; serviceVersionLinkHash +// is immediately before it. +// +// UNIFIED PYTHON 2 / PYTHON 3 OUTPUT: both dialects emit into these same relations. +// The dialect is a COLUMN (py_module.pythonDialect, c9); consumers must read it before +// interpreting py_type.typeCategory, py_type.mroKind, or comprehension scoping. +// +// NOT STAGED (declared for symmetry only — library IR is declarations, not bodies): +// lib_py_expression, lib_py_call_site, lib_py_block, lib_py_binding, +// lib_py_field_write, lib_py_type_inference, lib_py_source_bridge_edit +// ============================================================================ +''' + out = [hdr] + for group, label in ((SPINE, "SPINE — proposed for the first freeze (supports all four data-flow paths)"), + (None, "DEFERRED — proposed for a second freeze")): + out.append("// " + "="*74 + "\n// %s\n// %s" % (label, "="*74)) + for name, n in rels: + inspine = name in SPINE + if (group is SPINE) != inspine: continue + cols = ",".join("c%d:symbol" % i for i in range(n)) + out.append("// %s\n// (%d columns)\n.decl %s(%s)\n.decl lib_%s(%s)\n" + % (DOCS.get(name, name), n, name, cols, name, cols)) + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# ENUM MEMBERSHIP CHECK +# +# Arity alone cannot see this class of drift. ELEMENT, KEY and VALUE were added to +# PythonEdgeRole.ts and shipped while the frozen doc still listed 42 values; the +# column COUNT never moved, so --check stayed green and the divergence was found by +# reading a diff. A guard nobody can rely on for a whole class of change is worse +# than no guard, because it is trusted. +# +# The doc is authoritative. Code holding a value the doc does not name is drift in +# one direction; the doc naming a value the code cannot emit is drift in the other, +# and both matter — the first means goldens exist for facts nothing describes, the +# second means a consumer is written against a value it will never see. +# --------------------------------------------------------------------------- +import os, glob + +# Resolved from THIS file rather than the working directory: gen_decls now lives +# beside the schema in src/schema/python, and a relative "../src/enums" silently +# compared ZERO enums from the new location while still exiting 0 on the arity +# check. A guard that reports "0 enums compared" as success is not a guard. +_HERE = os.path.dirname(os.path.abspath(__file__)) +ENUM_DIR = os.path.join(_HERE, "..", "..", "enums", "python") + +def _pascal(camel): + return "Python" + camel[0].upper() + camel[1:] + +# A doc column name is NOT unique across relations - `kind` means PythonExpressionKind +# under 2.15 and PythonBlockKind under 2.18 - so enums are keyed by (relation, column) +# and resolved through this table. Anything unlisted falls back to _pascal(column). +# +# This table exists because the naming heuristic alone left 24 of 53 TS enums +# UNCOMPARED, including PythonExpressionKind. A mutation test proved it: injecting a +# bogus value into PythonExpressionKind did not trip the guard. A guard with a silent +# 45% blind spot is worse than none, because it is trusted. +ALIAS = { + ("py_expression", "kind"): "PythonExpressionKind", + ("py_expression", "rootContext"): "PythonRootContext", + ("py_expression", "unaryFixity"): "PythonUnaryFixity", + ("py_expression", "expressionOwnerKind"): "PythonExpressionOwnerKind", + ("py_expression", "referencedEntityKind"): "PythonReferencedEntityKind", + ("py_block", "kind"): "PythonBlockKind", + ("py_decorator", "kind"): "PythonDecoratorKind", + ("py_decorator", "context"): "PythonDecoratorContext", + ("py_decorator", "builtinKind"): "PythonBuiltinDecoratorKind", + ("py_decorator_argument", "valueType"): "PythonDecoratorArgumentValueType", + ("py_parse_gap", "kind"): "PythonParseGapKind", + ("py_parse_gap", "constructKind"): "PythonParseGapKind", + ("py_type_parameter", "kind"): "PythonTypeParameterKind", + ("py_type_parameter", "variance"): "PythonTypeParameterVariance", + ("py_comment", "kind"): "PythonCommentKind", + ("py_parse_gap", "disposition"): "PythonParseGapDisposition", + ("py_module", "pythonDialect"): "PythonDialect", + ("py_module", "emissionRegime"): "PythonEmissionRegime", + ("py_scope", "ownerKind"): "PythonScopeOwnerKind", + ("py_scope", "blockType"): "SymbolBlockType", + ("py_binding", "targetEntityKind"): "PythonBindingTargetKind", + ("py_method_parameter", "paramKind"): "PythonParameterKind", + ("py_import", "resolvedTargetKind"): "PythonImportTargetKind", + ("py_type_reference", "context"): "PythonTypeRefContext", + ("py_type_reference", "kind"): "PythonTypeRefKind", + ("py_type_reference", "referenceOwnerKind"): "PythonTypeRefOwnerKind", + ("py_type", "typeModifier"): "PythonTypeModifier", + ("py_field", "fieldModifier"): "PythonFieldModifier", + ("py_field", "writeKind"): "PythonFieldWriteKind", + ("py_method", "methodModifier"): "PythonMethodModifier", + ("py_field", "fieldAccess"): "PythonMethodAccess", # one access enum serves both + ("py_parse_gap", "disposition"): "PythonParseGapDisposition", +} + +# Enums the doc does not spell out, with the reason. Listed so that "not compared" +# is a decision on the record rather than an accident. +# Relations the schema declares but the parser does not emit yet. Their enums cannot +# exist, so their absence is a backlog item, not schema drift. +NOT_YET_EMITTED = set() # py_comment shipped; nothing is exempt now + +UNSPECIFIED_OK = { + # py_expression c2 says only "the statement form the root sits in" - the doc never + # enumerates it. That is a DOC DEFECT, tracked rather than waived, but failing the + # build on it would block A3 for something only the human can ratify. + "PythonRootContext", + "SymbolBlockType", # mirrors CPython symtable block types, defined upstream + "PythonFieldWriteKind", # survivor of the removed py_field_write (2.10) +} + +def parse_doc_enums(): + """Enum value sets the schema declares, keyed by (relation, column), from schema.json.""" + schema = json.load(open(DOC)) + found = {} + for rel, spec in schema["relations"].items(): + for col, vals in spec.get("domains", {}).items(): + found[(rel, col)] = set(vals) + return found + +def parse_code_enums(): + """Enum members declared in TypeScript, keyed by file basename.""" + out = {} + for f in glob.glob(os.path.join(ENUM_DIR, "**", "*.ts"), recursive=True): + base = os.path.basename(f)[:-3] + if base == "index": + continue + members = set(re.findall(r"^\s+([A-Z][A-Z0-9_]*) = '", open(f).read(), re.M)) + if members: + out[base] = members + return out +def _expand(docvals, codevals): + """Resolve `ASYNC_*` style wildcards against what the code actually declares. + + The doc abbreviates four comprehension kinds as ASYNC_*, which is legitimate + prose and became a phantom drift when compared literally. A wildcard is satisfied + by any code value matching the prefix, and is a real miss only when NOTHING + matches - otherwise the doc must be rewritten for every variant added, which is + how docs stop being written at all. + """ + out, wild = set(), [] + for v in docvals: + (wild.append(v) if v.endswith("*") else out.add(v)) + for w in wild: + hits = {c for c in codevals if c.startswith(w[:-1])} + out |= hits or {w} + return out + +def check_enums(): + doc_enums, code_enums = parse_doc_enums(), parse_code_enums() + problems, checked, seen = [], 0, set() + for (rel, col), docvals in sorted(doc_enums.items()): + # An enum block is only meaningful if the relation HAS that column. I wrote + # a `kind` enum into 2.20 during a sync and the relation has no kind column, + # so this guard happily compared a phantom against a real TS enum and passed. + # Membership agreement is worthless if the column does not exist. + known = COLUMNS_BY_REL.get(rel) + if known and col not in known: + problems.append("%s.%s: enum documented for a column %s DOES NOT HAVE " + "(columns: %s)" % (rel, col, rel, ", ".join(sorted(known)[:8]))) + continue + cls = ALIAS.get((rel, col), _pascal(col)) + if cls not in code_enums: + # A relation with no emitter yet has no enum yet, which is expected and + # is NOT drift. Distinguish it from a genuinely missing alias, or the + # guard cries wolf on work that has simply not started. + if rel in NOT_YET_EMITTED: + continue + problems.append("%s.%s: doc declares %d values but no TS enum found " + "(looked for %s.ts) - add an ALIAS entry" + % (rel, col, len(docvals), cls)) + continue + seen.add(cls) # count DISTINCT enums; several are reached by 2+ columns + codevals = code_enums[cls] + docvals = _expand(docvals, codevals) + only_code, only_doc = sorted(codevals - docvals), sorted(docvals - codevals) + if only_code: + problems.append("%s.%s (%s): IN CODE, NOT IN SCHEMA: %s" + % (rel, col, cls, ", ".join(only_code))) + if only_doc: + problems.append("%s.%s (%s): IN SCHEMA, NOT IN CODE: %s" + % (rel, col, cls, ", ".join(only_doc))) + # An enum nobody compares is an enum nobody guards, so this FAILS rather than + # printing a note. A mutation test is what proved the note was not enough. + unguarded = sorted(set(code_enums) - seen - UNSPECIFIED_OK) + if unguarded: + problems.append("UNGUARDED - declared in TS, never compared to the doc: %s" + % ", ".join(unguarded)) + return problems, len(seen), [], len(code_enums) + +rels, errors = parse_doc() +enum_problems, enum_checked, enum_unmapped, enum_total = check_enums() +if errors: + print("DOC INCONSISTENCIES:"); [print(" -", e) for e in errors]; sys.exit(1) +if enum_problems: + print("ENUM DRIFT between %s and src/enums/python (%d enums compared):" % (DOC, enum_checked)) + for e in enum_problems: print(" -", e) + print("\nschema.json is authoritative. Either sync it, or revert the code.") + sys.exit(1) +txt = render(rels) +if "--check" in sys.argv: + cur = open(OUT).read() if __import__("os").path.exists(OUT) else "" + if cur != txt: + print("DRIFT: %s is out of date with %s. Re-run gen_decls.py." % (OUT, DOC)); sys.exit(1) + print("OK: %s matches %s (%d relations)" % (OUT, DOC, len(rels))) + print("OK: %d/%d enums agree with the schema (%d waived, listed in UNSPECIFIED_OK)" + % (enum_checked, enum_total, enum_total - enum_checked)) + if enum_unmapped: + # Reported, never silent: an enum nobody compares is an enum nobody guards. + print("NOT COMPARED (%d) - no TS file matched:" % len(enum_unmapped)) + for u in enum_unmapped: print(" ", u) + sys.exit(0) +open(OUT,"w").write(txt) +print("wrote %s: %d relations, %d decls, %d columns total" + % (OUT, len(rels), len(rels)*2, sum(n for _, n in rels))) +print("spine: %d relations, %d columns" % (len(SPINE), sum(n for k,n in rels if k in SPINE))) diff --git a/parser/src/schema/python/schema.json b/parser/src/schema/python/schema.json new file mode 100644 index 000000000..b5d622521 --- /dev/null +++ b/parser/src/schema/python/schema.json @@ -0,0 +1,1211 @@ +{ + "$comment": "FROZEN python fact schema \u2014 relation column order is the contract; new columns append only. Enum domains where the schema declares one. Generated once from the retired PYTHON-FACT-SCHEMA.md; edit here.", + "relations": { + "py_module": { + "columns": [ + "name", + "qualifiedName", + "fileName", + "filePath", + "baseMservPath", + "moduleKind", + "packageQualifiedName", + "isPackage", + "isStub", + "pythonDialect", + "targetVersion", + "emissionRegime", + "grammarUsed", + "futureImports", + "encodingDeclared", + "hasModuleDocstring", + "hasDunderAll", + "dunderAllIsStatic", + "dunderAllNames", + "moduleInitMethodLinkHash", + "moduleScopeLinkHash", + "isExternal", + "serviceVersionLinkHash", + "pyModuleUniqueHash" + ], + "domains": { + "moduleKind": [ + "MAIN_GUARD_SCRIPT", + "MODULE", + "NAMESPACE_PACKAGE", + "PACKAGE_INIT", + "SCRIPT", + "STUB" + ], + "pythonDialect": [ + "PY2_DETECTED_REJECTED", + "PY3", + "PY_UNKNOWN" + ], + "emissionRegime": [ + "PY3_0_11", + "PY3_12_PLUS" + ], + "grammarUsed": [ + "TS_PYTHON3", + "TS_PYTHON3_PARTIAL", + "UNPARSED" + ] + } + }, + "py_scope": { + "columns": [ + "scopeKind", + "name", + "qualifiedName", + "nestingDepth", + "parentScopeLinkHash", + "pyModuleLinkHash", + "ownerKind", + "ownerHash", + "isNested", + "isOptimized", + "hasChildren", + "symtableId", + "usesWildcardImport", + "isGenerator", + "isCoroutine", + "declaresGlobal", + "declaresNonlocal", + "filePath", + "startLine", + "startColumn", + "endLine", + "endColumn", + "scopeOrdinal", + "serviceVersionLinkHash", + "pyScopeUniqueHash" + ], + "domains": { + "scopeKind": [ + "ANNOTATION", + "CLASS", + "COMPREHENSION_DICT", + "COMPREHENSION_LIST", + "COMPREHENSION_SET", + "FUNCTION", + "GENERATOR_EXPRESSION", + "LAMBDA", + "MODULE", + "TYPE_ALIAS", + "TYPE_PARAM", + "TYPE_PARAM_BOUND" + ], + "ownerKind": [ + "COMPREHENSION", + "LAMBDA", + "METHOD", + "MODULE", + "TYPE" + ] + } + }, + "py_binding": { + "columns": [ + "name", + "pyScopeLinkHash", + "bindingKind", + "bindingOrigin", + "isParameter", + "isLocal", + "isGlobal", + "isNonlocal", + "isFree", + "isImported", + "isAssigned", + "isReferenced", + "isDeclaredGlobal", + "isAnnotated", + "isNamespace", + "bindingCount", + "firstBindingLine", + "lastBindingLine", + "declaredTypeName", + "declaredBaseType", + "potentialQualifiedName", + "isAmbiguous", + "targetEntityKind", + "targetEntityHash", + "pyModuleLinkHash", + "pyMethodLinkHash", + "filePath", + "serviceVersionLinkHash", + "pyBindingUniqueHash" + ], + "domains": { + "bindingKind": [ + "ANNOTATED_ONLY", + "BUILTIN", + "CELL", + "CLASS_ATTRIBUTE", + "FREE", + "GLOBAL_EXPLICIT", + "GLOBAL_IMPLICIT", + "IMPORTED", + "LOCAL", + "MODULE_LEVEL", + "NONLOCAL", + "PARAMETER", + "UNKNOWN" + ], + "bindingOrigin": [ + "ANNOTATED_ASSIGNMENT", + "ANNOTATION_ONLY", + "ASSIGNMENT", + "AUGMENTED_ASSIGNMENT", + "CLASS_DEF", + "COMPREHENSION_TARGET", + "DEL", + "EXCEPT_TARGET", + "FOR_TARGET", + "FUNCTION_DEF", + "GLOBAL_STMT", + "IMPORT", + "LAMBDA_PARAM", + "MATCH_CAPTURE", + "MULTIPLE", + "NONLOCAL_STMT", + "PARAMETER", + "STAR_TARGET", + "TUPLE_UNPACK_TARGET", + "TYPE_ALIAS", + "TYPE_PARAM", + "WALRUS", + "WITH_TARGET" + ], + "targetEntityKind": [ + "IMPORT", + "METHOD", + "MODULE", + "NONE", + "PARAMETER", + "TYPE", + "TYPE_ALIAS", + "TYPE_VAR", + "VARIABLE" + ] + } + }, + "py_type": { + "columns": [ + "name", + "qualifiedName", + "fileName", + "typeCategory", + "typeAccess", + "typeModifier", + "typePlacement", + "filePath", + "baseMservPath", + "startLine", + "endLine", + "isExternal", + "pyModuleLinkHash", + "enclosingTypeLinkHash", + "enclosingMethodLinkHash", + "scopeLinkHash", + "classInitMethodLinkHash", + "declaringBindingLinkHash", + "metaclassName", + "baseCount", + "hasDynamicBase", + "mroKind", + "docstring", + "serviceVersionLinkHash", + "pyTypeUniqueHash" + ], + "domains": { + "typeCategory": [ + "ABC_TYPE", + "CLASS_TYPE", + "DATACLASS_TYPE", + "ENUM_CLASS_TYPE", + "EXCEPTION_CLASS_TYPE", + "GENERIC_TYPE", + "METACLASS_TYPE", + "NAMEDTUPLE_TYPE", + "PROTOCOL_TYPE", + "TYPEDDICT_TYPE" + ], + "typeAccess": [ + "PRIVATE_ACCESS", + "PROTECTED_ACCESS", + "PUBLIC_ACCESS" + ], + "typeModifier": [ + "ABSTRACT", + "CALLABLE_INSTANCE", + "FINAL", + "FROZEN", + "GENERIC", + "HAS_CALL", + "HAS_GETATTR", + "HAS_SETATTR", + "RUNTIME_CHECKABLE", + "SLOTS" + ], + "typePlacement": [ + "CONDITIONAL_PLACEMENT", + "LOCAL_PLACEMENT", + "NESTED_PLACEMENT", + "TOP_LEVEL_PLACEMENT", + "TYPE_CHECKING_PLACEMENT" + ], + "mroKind": [ + "C3_LINEARIZABLE", + "DYNAMIC_UNKNOWN", + "IMPLICIT_OBJECT", + "SINGLE_INHERITANCE" + ] + } + }, + "py_type_base": { + "columns": [ + "baseKind", + "position", + "baseText", + "baseSimpleName", + "baseDottedPath", + "keywordName", + "pyTypeLinkHash", + "pyModuleLinkHash", + "pyExpressionLinkHash", + "pyTypeReferenceLinkHash", + "resolvedTypeLinkHash", + "isResolvedLocally", + "isDynamic", + "startLine", + "serviceVersionLinkHash", + "pyTypeBaseUniqueHash" + ], + "domains": { + "baseKind": [ + "CALL", + "DOTTED_NAME", + "IMPLICIT_OBJECT", + "KEYWORD_METACLASS", + "KEYWORD_OTHER", + "NAME", + "STARRED", + "SUBSCRIPT" + ] + } + }, + "py_type_reference": { + "columns": [ + "kind", + "context", + "pyTypeLinkHash", + "typeParameterLinkHash", + "referencedTypeLinkHash", + "parentReferenceHash", + "position", + "depth", + "typeName", + "completeTypeName", + "typeVariableName", + "arrayDimensions", + "wildcardVariance", + "startLine", + "endLine", + "typeReferenceOwnerHash", + "referenceOwnerKind", + "pyScopeLinkHash", + "pyModuleLinkHash", + "isStringForwardRef", + "isTypeCommentDerived", + "isOptional", + "pyExpressionLinkHash", + "serviceVersionLinkHash", + "pyTypeReferenceUniqueHash" + ], + "domains": { + "kind": [ + "ANY", + "CALLABLE", + "DOTTED_NAME", + "ELLIPSIS_TYPE", + "LITERAL_TYPE", + "NAME", + "NONE_TYPE", + "OPTIONAL", + "STRING_FORWARD_REF", + "SUBSCRIPT", + "TUPLE_TYPE", + "TYPE_VAR", + "UNION_PEP604", + "UNKNOWN" + ], + "context": [ + "BASE_CLASS", + "CAST_TARGET", + "EXCEPT_TYPE", + "FIELD_TYPE", + "GENERIC_ARGUMENT", + "ISINSTANCE_TYPE", + "ISSUBCLASS_TYPE", + "METACLASS", + "METHOD_PARAM", + "METHOD_RETURN", + "OVERLOAD_SIGNATURE", + "RAISE_TYPE", + "TYPEVAR_BOUND", + "TYPE_ALIAS", + "TYPE_COMMENT", + "VARIABLE_ANNOTATION" + ], + "wildcardVariance": [ + "CONTRAVARIANT", + "COVARIANT", + "INVARIANT" + ], + "referenceOwnerKind": [ + "BINDING", + "BLOCK", + "DECORATOR", + "EXPRESSION", + "FIELD", + "METHOD", + "METHOD_PARAM", + "TYPE", + "TYPE_BASE" + ] + } + }, + "py_method": { + "columns": [ + "name", + "signature", + "detailedSignature", + "qualifiedName", + "filePath", + "startLine", + "endLine", + "pyTypeLinkHash", + "ownerTypeName", + "ownerQualifiedName", + "methodAccess", + "methodModifier", + "returnTypeName", + "isVarArgs", + "hasReceiverParameter", + "defaultValueExpression", + "methodKind", + "parameterCount", + "hasTypeParameters", + "throwsExceptions", + "enclosingMemberLinkHash", + "pyModuleLinkHash", + "scopeLinkHash", + "declaringBindingLinkHash", + "posOnlyCount", + "kwOnlyCount", + "hasKwArgs", + "isArgsKwargsPassthrough", + "isAsync", + "isGenerator", + "decoratorCount", + "bodyIsStub", + "startColumn", + "endColumn", + "serviceVersionLinkHash", + "pyMethodUniqueHash" + ], + "domains": { + "methodAccess": [ + "DUNDER_ACCESS", + "PRIVATE_ACCESS", + "PROTECTED_ACCESS", + "PUBLIC_ACCESS" + ], + "methodModifier": [ + "ABSTRACT", + "ASYNC", + "CACHED", + "CLASS", + "DELETER", + "FINAL", + "GENERATOR", + "OVERLOAD", + "PROPERTY", + "SETTER", + "STATIC", + "SYNTHETIC" + ], + "methodKind": [ + "ABSTRACT_METHOD", + "ALLOCATOR", + "ASYNC_FUNCTION", + "ASYNC_GENERATOR", + "CLASS_INITIALIZER", + "CLASS_METHOD", + "CONSTRUCTOR", + "DUNDER_METHOD", + "FUNCTION", + "GENERATOR", + "INSTANCE_METHOD", + "LAMBDA", + "MODULE_INITIALIZER", + "NESTED_FUNCTION", + "OVERLOAD_STUB", + "PROPERTY_DELETER", + "PROPERTY_GETTER", + "PROPERTY_SETTER", + "STATIC_METHOD" + ] + } + }, + "py_method_parameter": { + "columns": [ + "paramName", + "position", + "pyMethodLinkHash", + "parameterBaseType", + "parameterTypeName", + "potentialQualifiedName", + "isAmbiguous", + "isFinal", + "isVarArgs", + "isReceiverParameter", + "startLine", + "endLine", + "paramKind", + "hasDefault", + "defaultValueText", + "defaultValueKind", + "isMutableDefault", + "annotationIsString", + "bindingLinkHash", + "pyExpressionLinkHash", + "serviceVersionLinkHash", + "pyMethodParameterUniqueHash" + ], + "domains": { + "paramKind": [ + "KEYWORD_ONLY", + "KEYWORD_ONLY_MARKER", + "POSITIONAL_ONLY", + "POSITIONAL_ONLY_MARKER", + "POSITIONAL_OR_KEYWORD", + "VAR_KEYWORD", + "VAR_POSITIONAL" + ], + "defaultValueKind": [ + "BOOL", + "CALL", + "DICT", + "ELLIPSIS", + "LAMBDA", + "LIST", + "NAME", + "NONE", + "NONE_LITERAL", + "NUMBER", + "SET", + "STRING", + "TUPLE", + "UNKNOWN" + ] + } + }, + "py_field": { + "columns": [ + "name", + "fieldTypeName", + "fieldBaseType", + "potentialQualifiedName", + "isAmbiguous", + "filePath", + "startLine", + "endLine", + "pyTypeLinkHash", + "ownerTypeName", + "ownerQualifiedName", + "fieldAccess", + "fieldModifier", + "fieldOrigin", + "declaringMethodLinkHash", + "receiverName", + "isDeclaredInInit", + "writeCount", + "writtenInMethodCount", + "firstWriteLine", + "hasAnnotation", + "annotationIsString", + "initializerText", + "initializerKind", + "pyModuleLinkHash", + "bindingLinkHash", + "pyExpressionLinkHash", + "serviceVersionLinkHash", + "pyFieldUniqueHash" + ], + "domains": { + "fieldAccess": [ + "DUNDER_ACCESS", + "PRIVATE_ACCESS", + "PROTECTED_ACCESS", + "PUBLIC_ACCESS" + ], + "fieldModifier": [ + "CLASSVAR_ANNOTATED", + "CLASS_VAR", + "DATACLASS_FIELD", + "ENUM_MEMBER", + "FINAL", + "INSTANCE_VAR", + "PROPERTY_BACKED", + "READ_ONLY", + "SLOT" + ], + "fieldOrigin": [ + "CLASS_BODY_ANNOTATION_ONLY", + "CLASS_BODY_ASSIGN", + "DATACLASS_FIELD", + "ENUM_MEMBER", + "NAMEDTUPLE_FIELD", + "SELF_ASSIGN", + "SELF_AUGASSIGN", + "SETATTR_DYNAMIC", + "SLOTS_ENTRY", + "TYPEDDICT_KEY" + ], + "initializerKind": [ + "ATTRIBUTE", + "CALL", + "COMPREHENSION", + "LAMBDA", + "LITERAL", + "NAME", + "NONE", + "UNKNOWN" + ] + } + }, + "py_field_position": { + "columns": [ + "pyTypeLinkHash", + "pyFieldLinkHash", + "position" + ] + }, + "py_decorator": { + "columns": [ + "decoratorName", + "kind", + "context", + "ownerHash", + "pyTypeLinkHash", + "pyMethodLinkHash", + "position", + "applicationOrder", + "startLine", + "endLine", + "dottedPath", + "fullText", + "argumentCount", + "pyExpressionLinkHash", + "resolvedTargetHash", + "isKnownBuiltin", + "builtinKind", + "replacesTarget", + "pyModuleLinkHash", + "serviceVersionLinkHash", + "pyDecoratorUniqueHash" + ], + "domains": { + "kind": [ + "ATTRIBUTE", + "ATTRIBUTE_CALL", + "BARE", + "CALL", + "EXPRESSION", + "SUBSCRIPT" + ], + "context": [ + "METHOD_DECLARATION", + "NESTED_FUNCTION_DECLARATION", + "TYPE_DECLARATION" + ], + "builtinKind": [ + "ABSTRACTMETHOD", + "CACHED_PROPERTY", + "CLASSMETHOD", + "CONTEXTMANAGER", + "DATACLASS", + "DELETER", + "FINAL", + "LRU_CACHE", + "NONE", + "OVERLOAD", + "PROPERTY", + "SETTER", + "STATICMETHOD", + "WRAPS" + ] + } + }, + "py_decorator_argument": { + "columns": [ + "argumentName", + "argumentValue", + "valueType", + "position", + "parentDecoratorLinkHash", + "referencedTypeHash", + "nestedDecoratorHash", + "arrayIndex", + "startLine", + "endLine", + "isKeyword", + "isStarred", + "pyExpressionLinkHash", + "serviceVersionLinkHash", + "pyDecoratorArgumentUniqueHash" + ], + "domains": { + "valueType": [ + "ATTRIBUTE_REFERENCE", + "BOOLEAN_LITERAL", + "CALL", + "CLASS_REFERENCE", + "DICT", + "ENUM_CONSTANT", + "FSTRING", + "LAMBDA", + "LIST", + "NAME_REFERENCE", + "NONE_LITERAL", + "NUMBER_LITERAL", + "SET", + "STRING_LITERAL", + "TUPLE", + "UNKNOWN" + ] + } + }, + "py_import": { + "columns": [ + "importKind", + "importedPath", + "packageOrTypeName", + "simpleName", + "filePath", + "lineNumber", + "isStatic", + "isWildcard", + "isModuleImport", + "relativeLevel", + "originalName", + "aliasName", + "resolvedModuleLinkHash", + "resolvedTargetKind", + "resolvedTargetHash", + "isExternalTarget", + "distributionName", + "isTypeCheckingOnly", + "isConditional", + "pyScopeLinkHash", + "pyModuleLinkHash", + "bindingLinkHash", + "serviceVersionLinkHash", + "pyImportUniqueHash" + ], + "domains": { + "importKind": [ + "DYNAMIC", + "FROM_MEMBER", + "FROM_MEMBER_ALIAS", + "FROM_WILDCARD", + "FUTURE", + "MODULE_IMPORT", + "MODULE_IMPORT_ALIAS", + "RELATIVE_MEMBER", + "RELATIVE_WILDCARD" + ], + "resolvedTargetKind": [ + "AMBIGUOUS", + "FUNCTION", + "MODULE", + "PACKAGE", + "TYPE", + "UNRESOLVED", + "VARIABLE" + ] + } + }, + "py_expression": { + "columns": [ + "kind", + "edgeRole", + "rootContext", + "expressionOwnerKind", + "pyTypeLinkHash", + "expressionOwnerHash", + "parentExpressionHash", + "position", + "depth", + "literalType", + "literalValue", + "comprehensionKind", + "unaryFixity", + "operatorString", + "referencedEntityKind", + "referencedEntityHash", + "lambdaScopeHash", + "potentialQualifiedName", + "isAmbiguous", + "returnStatementIndex", + "startLine", + "startColumn", + "endLine", + "endColumn", + "pyScopeLinkHash", + "pyModuleLinkHash", + "bindingLinkHash", + "nameContext", + "isWrite", + "argumentKeywordName", + "isAwaited", + "isStarred", + "dottedPath", + "inferredTypeName", + "inferredTypeKind", + "inferenceEvidence", + "inferenceConfidence", + "serviceVersionLinkHash", + "pyExpressionUniqueHash" + ], + "domains": { + "kind": [ + "ANNOTATED_ASSIGNMENT", + "ASSIGNMENT", + "ASSIGNMENT_EXPRESSION", + "ATTRIBUTE_ACCESS", + "AUGMENTED_ASSIGNMENT", + "AWAIT", + "BINARY_OPERATION", + "BOOLEAN_OPERATION", + "CALL", + "CLS_REFERENCE", + "COMPARISON", + "CONDITIONAL_EXPRESSION", + "DICT", + "DICT_COMPREHENSION", + "DOUBLE_STARRED", + "ELLIPSIS", + "FSTRING", + "FSTRING_INTERPOLATION", + "GENERATOR_EXPRESSION", + "LAMBDA", + "LIST", + "LIST_COMPREHENSION", + "LITERAL", + "MATCH_PATTERN", + "NAME_REFERENCE", + "SELF_REFERENCE", + "SET", + "SET_COMPREHENSION", + "SLICE", + "STARRED", + "SUBSCRIPT", + "TUPLE", + "UNARY_OPERATION", + "YIELD", + "YIELD_FROM" + ], + "edgeRole": [ + "ANNOTATION", + "ARGUMENT", + "ASSIGNMENT_TARGET", + "ASSIGNMENT_VALUE", + "ATTRIBUTE_OBJECT", + "AWAIT_OPERAND", + "BASE_CLASS", + "BODY", + "CALLEE", + "COMPREHENSION_CONDITION", + "COMPREHENSION_ELEMENT", + "COMPREHENSION_ITERABLE", + "COMPREHENSION_TARGET", + "CONDITION", + "DECORATOR_EXPR", + "DEFAULT_VALUE", + "DOUBLE_STAR_ARGUMENT", + "ELEMENT", + "EXCEPT_TARGET", + "EXCEPT_TYPE", + "FSTRING_EXPRESSION", + "KEY", + "KEYWORD_ARGUMENT", + "LAMBDA_BODY", + "MATCH_PATTERN", + "MATCH_SUBJECT", + "OPERAND_LEFT", + "OPERAND_RIGHT", + "ORELSE", + "RAISE_CAUSE", + "RAISE_EXC", + "RECEIVER", + "RETURN_VALUE", + "ROOT", + "SLICE_LOWER", + "SLICE_STEP", + "SLICE_UPPER", + "STAR_ARGUMENT", + "SUBSCRIPT_INDEX", + "SUBSCRIPT_OBJECT", + "UNARY_OPERAND", + "VALUE", + "WITH_CONTEXT", + "WITH_TARGET", + "YIELD_VALUE" + ], + "rootContext": [ + "ANNOTATED_ASSIGNMENT", + "ANNOTATION", + "ASSERT_CONDITION", + "ASSERT_MESSAGE", + "ASSIGNMENT_TARGET", + "ASSIGNMENT_VALUE", + "ASYNC_FOR_ITERABLE", + "ASYNC_FOR_TARGET", + "ASYNC_WITH_CONTEXT", + "ASYNC_WITH_TARGET", + "AUGMENTED_ASSIGNMENT", + "BASE_CLASS_LIST", + "CASE_GUARD", + "CASE_PATTERN", + "CLASS_BODY_STATEMENT", + "COMPREHENSION", + "DECORATOR", + "DEFAULT_VALUE", + "DELETE_TARGET", + "EXCEPT_TYPE", + "EXPRESSION_STATEMENT", + "FOR_ITERABLE", + "FOR_TARGET", + "IF_CONDITION", + "LAMBDA_BODY", + "MATCH_SUBJECT", + "MODULE_LEVEL_STATEMENT", + "OTHER_STATEMENT", + "RAISE_VALUE", + "RETURN_VALUE", + "WHILE_CONDITION", + "WITH_CONTEXT", + "WITH_TARGET", + "YIELD_VALUE" + ], + "expressionOwnerKind": [ + "BINDING", + "BLOCK", + "COMPREHENSION_SCOPE", + "DECORATOR", + "FIELD", + "IMPORT", + "LAMBDA", + "METHOD", + "METHOD_PARAMETER", + "MODULE", + "TYPE" + ], + "literalType": [ + "BOOLEAN", + "BYTES", + "COMPLEX", + "ELLIPSIS", + "FLOAT", + "FSTRING", + "INTEGER", + "NONE", + "RAW_STRING", + "STRING" + ], + "comprehensionKind": [ + "ASYNC_*", + "DICT", + "GENERATOR", + "LIST", + "NONE", + "SET" + ], + "unaryFixity": [ + "NONE", + "PREFIX" + ], + "referencedEntityKind": [ + "ATTRIBUTE", + "BUILTIN", + "CLS", + "COMPREHENSION_VARIABLE", + "EXCEPT_VARIABLE", + "FIELD", + "FREE_VARIABLE", + "GLOBAL_VARIABLE", + "IMPORT", + "LOCAL_VARIABLE", + "METHOD", + "MODULE", + "NONLOCAL_VARIABLE", + "PARAMETER", + "SELF", + "SUPER", + "TYPE", + "UNKNOWN", + "WALRUS_TARGET" + ], + "nameContext": [ + "DEL", + "LOAD", + "STORE" + ], + "inferredTypeKind": [ + "BUILTIN_COLLECTION", + "BUILTIN_SCALAR", + "CALLABLE", + "NONE_TYPE", + "UNKNOWN", + "USER_CLASS" + ], + "inferenceEvidence": [ + "ANNOTATION", + "CAST", + "COLLECTION_LITERAL", + "COMPREHENSION", + "DEFAULT_VALUE", + "FSTRING", + "LITERAL", + "NONE" + ], + "inferenceConfidence": [ + "CERTAIN", + "NONE", + "PROBABLE" + ] + } + }, + "py_call_site": { + "columns": [ + "callKind", + "calleeName", + "calleeDottedPath", + "receiverText", + "receiverKind", + "pyExpressionLinkHash", + "receiverExpressionLinkHash", + "pyScopeLinkHash", + "pyMethodLinkHash", + "pyTypeLinkHash", + "pyModuleLinkHash", + "positionalArgCount", + "keywordArgCount", + "hasStarArgs", + "hasDoubleStarArgs", + "keywordNames", + "argFlowIsPrecise", + "resolvedCalleeKind", + "resolvedCalleeHash", + "isModuleLevelCall", + "isConditional", + "startLine", + "startColumn", + "endLine", + "serviceVersionLinkHash", + "pyCallSiteUniqueHash" + ], + "domains": { + "callKind": [ + "BUILTIN_CALL", + "CHAINED_CALL", + "CLS_CALL", + "DECORATOR_CALL", + "DYNAMIC_CALL", + "INSTANCE_CALL", + "METHOD_CALL", + "MODULE_CALL", + "SELF_CALL", + "SIMPLE_CALL", + "SUBSCRIPT_CALL", + "SUPER_CALL", + "UNKNOWN_CALLEE_CALL" + ], + "receiverKind": [ + "ATTRIBUTE", + "CALL_RESULT", + "CLS", + "LITERAL", + "MODULE", + "NAME", + "NONE", + "SELF", + "SUBSCRIPT", + "SUPER", + "TYPE", + "UNKNOWN" + ], + "resolvedCalleeKind": [ + "BUILTIN", + "IMPORTED", + "METHOD", + "MODULE_FUNCTION", + "TYPE", + "UNRESOLVED" + ] + } + }, + "py_comment": { + "columns": [ + "kind", + "text", + "filePath", + "startLine", + "startColumn", + "endLine", + "endColumn", + "ownerHash", + "commentIndex", + "ownerKind", + "pyModuleLinkHash", + "typeCommentPayload", + "isDocstring", + "serviceVersionLinkHash", + "pyCommentUniqueHash" + ], + "domains": { + "kind": [ + "BLOCK_COMMENT_RUN", + "DOCSTRING_ATTRIBUTE", + "DOCSTRING_CLASS", + "DOCSTRING_FUNCTION", + "DOCSTRING_MODULE", + "ENCODING_COOKIE", + "LINE_COMMENT", + "NOQA", + "PRAGMA", + "SHEBANG", + "TYPE_COMMENT" + ] + } + }, + "py_block": { + "columns": [ + "kind", + "order", + "filePath", + "startLine", + "endLine", + "startColumn", + "endColumn", + "nestingDepth", + "pyTypeLinkHash", + "methodOwnerHash", + "parentContainerHash", + "tryStatementHash", + "resourceCount", + "caughtExceptionTypes", + "ownerTypeName", + "ownerQualifiedName", + "ownerMethodName", + "pyScopeLinkHash", + "pyModuleLinkHash", + "conditionExpressionLinkHash", + "conditionText", + "exceptTargetName", + "hasElseClause", + "isModuleLevel", + "isTypeCheckingGuard", + "serviceVersionLinkHash", + "pyBlockUniqueHash" + ], + "domains": { + "kind": [ + "ASYNC_FOR", + "ASYNC_WITH", + "CASE", + "CLASS_BODY", + "COMPREHENSION_BODY", + "ELIF", + "ELSE", + "EXCEPT", + "EXCEPT_STAR", + "FINALLY", + "FOR", + "FUNCTION_BODY", + "IF", + "LAMBDA_BODY", + "MATCH", + "MODULE_BODY", + "TRY", + "WHILE", + "WITH" + ] + } + }, + "py_parse_gap": { + "columns": [ + "pyModuleLinkHash", + "constructKind", + "disposition", + "startLine", + "startColumn", + "endLine", + "endColumn", + "sourceText", + "serviceVersionLinkHash", + "pyParseGapUniqueHash" + ], + "domains": { + "constructKind": [ + "ERROR_NODE", + "EXEC_COMPLEX_EXPR", + "MISSING_NODE", + "PY2_CONSTRUCT_DETECTED", + "SOFT_KEYWORD_MISPARSE" + ], + "disposition": [ + "ERROR_NODE", + "MISPARSED_SILENTLY", + "SKIPPED" + ] + } + }, + "py_type_parameter": { + "columns": [ + "paramName", + "position", + "ownerName", + "ownerQualifiedName", + "filePath", + "startLine", + "ownerLinkHash", + "ownerKind", + "boundText", + "variance", + "defaultText", + "pyScopeLinkHash", + "kind", + "serviceVersionLinkHash", + "pyTypeParameterUniqueHash" + ], + "domains": { + "kind": [ + "PARAM_SPEC", + "TYPE_VAR", + "TYPE_VAR_TUPLE" + ], + "variance": [ + "CONTRAVARIANT", + "COVARIANT", + "INFERRED", + "INVARIANT" + ] + } + } + } +} diff --git a/parser/src/schema/typescript/decls_base_ts.dl b/parser/src/schema/typescript/decls_base_ts.dl new file mode 100644 index 000000000..379d0137c --- /dev/null +++ b/parser/src/schema/typescript/decls_base_ts.dl @@ -0,0 +1,241 @@ +// ============================================================================ +// Base input relations — TYPESCRIPT parser IR. One relation pair per entity kind. +// +// GENERATED FROM schema.json BY gen_decls.py — DO NOT HAND-EDIT. +// Re-run `python3 gen_decls.py --check` in CI; drift here is a silent schema break. +// +// NAMING: one prefix per core language; `lib_` is the EXTERNAL marker on top of it. +// java_*/lib_* is PROVENANCE, not language: the project under analysis vs everything +// else. So: +// +// ts_ TypeScript under analysis <- the PARSER emits only these +// lib_ts_ external / third-party <- the ENGINE stages these +// +// COLUMN ORDER IS THE CONTRACT. All columns are `symbol`. New columns append ONLY. +// The last column is always the entity's own unique hash; serviceVersionLinkHash is +// immediately before it. Column NAMES live only in the schema doc, so a rename is +// free after the freeze and a reorder is not. +// +// THREE THINGS AN ENGINE AUTHOR MUST READ BEFORE JOINING ANYTHING: +// +// 1. `name -> single entity` IS FALSE. TypeScript merges declarations: one interface +// name can have N declarations across N files (max 43 measured) that are ONE type. +// ts_type/ts_method/ts_field are keyed PER DECLARATION SITE; the merged entity is +// `declarationGroupKey`, which is deliberately NOT UNIQUE. Group on it. A rule that +// assumes one row per name is wrong by construction. +// +// 2. INHERITANCE EDGES DO NOT CAPTURE SUBTYPING. TypeScript is structural: 60.4% of +// classes satisfy their interfaces with no `implements` clause, and 21.5% of +// assignable pairs appear in no syntax at all. ts_type_heritage.inheritsMembers +// tells you whether an edge inherits members (`extends`) or merely asserts +// (`implements`). For subtyping use ts_type_satisfies, which the ENGINE derives. +// +// 3. THE TYPE GRAPH IS NOT THE CALL GRAPH. Type aliases, interfaces, conditional / +// mapped / template-literal types and `import type` have no runtime existence. +// They are confined to ts_type_reference and flagged isTypeOnly wherever they can +// be reached. ts_call_site.isTypeOnlyTarget must always be "false". +// +// NOT STAGED (declared for symmetry only — library IR is declarations, not bodies): +// lib_ts_expression, lib_ts_call_site, lib_ts_block, lib_ts_variable, lib_ts_parse_gap +// +// ENGINE-POPULATED (the parser writes no row, in either provenance): +// ts_type_satisfies +// ============================================================================ + +// ========================================================================== +// SPINE — the frozen first cut. Supports all four resolution paths. +// ========================================================================== + +// A .ts/.tsx/.d.ts file, OR an ambient `declare module "x"`, OR `declare global`. +// The unit of import resolution AND the symbol merge table (c12 mergeTableKey), which is +// why it is a relation at all: Java's package is implicit in a qualified name, a TypeScript +// module is not. c16 emissionRegime is a COARSE token (ts6-inproc) and is IN THE KEY — a +// version string there would cascade every child hash on a patch bump. +// (28 columns) +.decl ts_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol) +.decl lib_ts_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol) + +// A type DECLARATION — class / interface / enum / type alias / namespace. Cols 0..11 mirror +// java_type 0..11. Anonymous structural types are NOT here (21,956 function types measured): +// only declarations, which is what keeps this relation key-able. c15 declarationGroupKey is +// the MERGED entity and is deliberately NOT UNIQUE — one interface name, N declarations, one +// type (max 43 measured). Group on c15; key on the last column. c20 isTypeOnly is true for +// INTERFACE_TYPE and TYPE_ALIAS_TYPE: no call-graph rule may traverse those rows. +// (33 columns) +.decl ts_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol) +.decl lib_ts_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol) + +// Every function-shaped declaration: function, method, constructor, accessor, arrow, function +// expression, static block, AND every bodiless signature. Cols 0..20 are byte-for-byte +// java_method 0..20. c25 signatureRole and c27 bodyPresence carry what Java never needs: +// 11,599 overload signatures measured, and 44.3% of resolved call targets are BODILESS, so +// a target must never be read as an implementation without checking c27. c39 startColumn is +// IN THE KEY: 703 arrow functions, and two on one line share name, signature and line. +// c7, like ts_field c8, is the owning type OR SHAPE — a ts_type_reference for a +// TYPE_LITERAL_*, FUNCTION_TYPE_SIGNATURE or CONSTRUCTOR_TYPE_SIGNATURE row. +// (43 columns) +.decl ts_method(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol,c35:symbol,c36:symbol,c37:symbol,c38:symbol,c39:symbol,c40:symbol,c41:symbol,c42:symbol) +.decl lib_ts_method(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol,c34:symbol,c35:symbol,c36:symbol,c37:symbol,c38:symbol,c39:symbol,c40:symbol,c41:symbol,c42:symbol) + +// A formal parameter. Cols 0..11 mirror java_method_parameter 0..11. 85.3% carry an annotation +// (99.998% in ambient code), the inverse of Python's 31.8%, which is why declared-type +// receiver typing is the primary mechanism here. c13 isOptional changes ARITY MATCHING, and +// c17 isParameterProperty means this parameter also DECLARES A FIELD (c19 links it). +// (29 columns) +.decl ts_method_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol) +.decl lib_ts_method_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol) + +// A class property, interface property signature, index signature, auto-accessor, parameter +// property, or object-literal property. Cols 0..12 mirror java_field 0..12, and the key +// chains off c8 tsTypeLinkHash exactly as FieldRegistry chains off typeRegistryLinkHash — +// never off a re-derived qualified name. c8 is the owning type OR SHAPE: it points at a +// ts_type_reference row when memberKind is a TYPE_LITERAL_* value, because an anonymous +// { foo(): string } has members and no declaration to own them (schema 4.8.1). PK prefixes +// differ, so a rule joining c8 against ts_type finds NO MATCH for a shape member rather than +// a wrong one. c15 isOptional is load-bearing for structural satisfaction: an absent +// optional member does not break assignability. +// (29 columns) +.decl ts_field(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol) +.decl lib_ts_field(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol) + +// The TYPE-NODE TREE. Cols 0..16 mirror java_type_reference 0..16, so the whole name->type +// resolution layer ports as a rename. Every type-level construct lives here and NOWHERE +// else — conditional, mapped, template-literal, infer, keyof, typeof, indexed-access +// (10,436 nodes measured) — which is the structural guarantee that they cannot reach the +// call graph. A union is N ROWS with c5 parentReferenceHash, not one row with a list: +// max arity measured is 208. c6 position is SOURCE order; the checker reorders and +// normalises boolean to true|false, so never compare against checker members. +// (31 columns) +.decl ts_type_reference(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol) +.decl lib_ts_type_reference(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol) + +// One `extends` / `implements` clause entry, ORDERED (c2 position). c7 inheritsMembers is the +// column Java does not need: `extends` inherits members, `implements` asserts and inherits +// NOTHING, and 60.4% of classes declare no implements at all. Walking an IMPLEMENTS_CLAUSE +// row as a subtyping edge is correct in Java and WRONG here — see ts_type_satisfies. +// (20 columns) +.decl ts_type_heritage(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol) +.decl lib_ts_type_heritage(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol) + +// An import. Cols 0..8 mirror java_import 0..8 with two slots repurposed: c6 isStatic -> +// isTypeOnly, c7 isOnDemand -> isWildcard (the projection stays import_wildcard). 45.6% of +// ecosystem imports are type-only, so c6 is a first-class column, not a flag. c14/c15 are +// filled by ts.resolveModuleName, which needs NO Program and is therefore parser-legal. +// One declaration with N named specifiers emits N ROWS: each binds a name and each may be +// individually type-only. +// (27 columns) +.decl ts_import(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol) +.decl lib_ts_import(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol) + +// An expression AST node — the spine of call resolution. Cols 0..24 are byte-for-byte +// java_expression 0..24, so expr_kind / expr_child / expr_owner port as renames. c16 is +// WIDENED from Java: it is the declaration this expression INTRODUCES, discriminated by c0 — +// ts_type for CLASS_EXPRESSION, ts_method for ARROW_FUNCTION / FUNCTION_EXPRESSION. Without +// that an IIFE's target is reachable only by matching positions, which is what Java's own +// extractor does for lambdas and what a fact schema exists to prevent. c27 +// assertedTypeReferenceLinkHash is the ONLY expression->type edge (`as` / `satisfies`), and +// it is a TYPE FK, so no call-graph rule can cross it. c28 isSpread marks where positional +// argument flow is PROVABLY imprecise rather than silently wrong. +// (34 columns) +.decl ts_expression(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol) +.decl lib_ts_expression(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol,c31:symbol,c32:symbol,c33:symbol) + +// A call site — 1:1 with its CALL / NEW / TAGGED_TEMPLATE expression, so the key is a pure +// chain off c5. This is where the flagship gate lives: 100% of measured call sites have a +// getResolvedSignature answer, and 77.6% of overloaded calls resolve to a NON-FIRST +// declaration — so c12 points at ONE SIGNATURE, never at a name. c14 resolvedTargetKind +// admits SYNTHESIZED_NO_DECLARATION (2.3% measured: implicit constructors) as an honest +// terminal. c19 isTypeOnlyTarget must ALWAYS be false; a true row is a parser bug. +// (25 columns) +.decl ts_call_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) +.decl lib_ts_call_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol) + +// ========================================================================== +// SECOND FREEZE — declared now so the column contract is fixed. +// ========================================================================== + +// A generic type parameter. Cols 0..6 mirror java_type_parameter 0..6. ONE relation where Java +// has two, because TypeScript attaches type parameters to seven owner kinds (c7 ownerKind). +// c13 varianceAnnotation is in/out (TS 4.7, 562 measured); c14 isConst is TS 5.0 (87). +// (20 columns) +.decl ts_type_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol) +.decl lib_ts_type_parameter(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol) + +// A field's declaration order within its type. Parity with java_field_position. +// (3 columns) +.decl ts_field_position(c0:symbol,c1:symbol,c2:symbol) +.decl lib_ts_field_position(c0:symbol,c1:symbol,c2:symbol) + +// An enum member. Cols 0..11 mirror java_enum_constant 0..11. c12 valueKind admits COMPUTED +// (the value is not always statically known) and c14 isConstEnumMember marks a member that +// is INLINED at use sites, so a reference to it may have no runtime target. +// (18 columns) +.decl ts_enum_member(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol) +.decl lib_ts_enum_member(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol) + +// A variable declaration, at module, function, block or global scope. Cols 0..8 mirror +// java_local_variable 0..8, widened because a module-level const is a first-class +// declaration here. c19 boundFunctionLinkHash is the link that makes `const f = () => {}` +// callable: 161 resolved call targets were arrow functions. +// There is deliberately NO ts_scope relation — measured, 34,798 identifier references and +// the ts_block -> ts_method -> ts_type -> ts_module chain reaches every one of them. +// (31 columns) +.decl ts_variable(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol) +.decl lib_ts_variable(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol,c21:symbol,c22:symbol,c23:symbol,c24:symbol,c25:symbol,c26:symbol,c27:symbol,c28:symbol,c29:symbol,c30:symbol) + +// An export or re-export. NO JAVA ANALOGUE — Java visibility is a modifier and there is no +// re-export, but here a re-export chain is the only path from an importer to the real +// declaration (1,251 export declarations, 86 `export *` measured). c2 EXPORT_STAR exports a +// set this row cannot name; the engine expands it from the source module's exports. +// (21 columns) +.decl ts_export(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol) +.decl lib_ts_export(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol) + +// A statement block. Cols 0..17 mirror java_block 0..17. Needed for caller attribution, as in +// Java, AND as the lexical scope of a let/const, which is what lets ts_variable do without a +// scope relation. c13 caughtExceptionTypes is near-always "": a TypeScript catch binding is +// unknown and cannot be typed. c17 links the guard, so typeof/instanceof/type-predicate +// narrowing (440 predicates measured) reaches the receiver. +// (21 columns) +.decl ts_block(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol) +.decl lib_ts_block(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol) + +// A comment, JSDoc block, triple-slash directive, or ts-directive. Cols 0..9 mirror +// java_comment 0..9. c11 REFERENCE_* directives are real module edges and also feed +// ts_import. +// (14 columns) +.decl ts_comment(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol) +.decl lib_ts_comment(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol) + +// A decorator. Java's annotation relation, same slot, SHARED annotation_on projection: cols +// 0..12 mirror java_annotation. But a decorator is not an annotation — it is an expression +// that RUNS and may REPLACE its target (c14, c15). c13 decoratorSystem distinguishes the two +// incompatible systems (TC39 standard vs legacy experimental); without it a fact base mixes +// two evaluation semantics under one relation. +// (21 columns) +.decl ts_decorator(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol) +.decl lib_ts_decorator(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol,c16:symbol,c17:symbol,c18:symbol,c19:symbol,c20:symbol) + +// A positional or named argument of a decorator call — where framework routes and DI tokens +// live. Cols 0..10 mirror java_annotation_argument 0..10. +// (13 columns) +.decl ts_decorator_argument(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol) +.decl lib_ts_decorator_argument(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol) + +// One row per construct that could not be parsed. RECORDS the gap, never rewrites source. +// Measured ZERO rows over 25.9 MB with ts.createSourceFile — which is exactly why it must +// exist: an always-empty relation that suddenly has rows is a signal, a missing relation is +// a silence. +// (10 columns) +.decl ts_parse_gap(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol) +.decl lib_ts_parse_gap(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol) + +// Structural satisfaction. DECLARED BUT NEVER STAGED BY THE PARSER: satisfaction needs +// isTypeAssignableTo, the parser has no checker, and a parser-emitted row would be a guess +// dressed as a fact. The ENGINE derives it from ts_field/ts_method/ts_type_heritage; the +// ORACLE adjudicates it. 21.5% of measured assignable pairs exist in NO SYNTAX anywhere and +// 15.4% are bidirectional, so c4 direction is required and mutual assignability is NOT +// identity. c11 oracleAgreement carries the relation's own error term. +// (16 columns) +.decl ts_type_satisfies(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol) +.decl lib_ts_type_satisfies(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol,c8:symbol,c9:symbol,c10:symbol,c11:symbol,c12:symbol,c13:symbol,c14:symbol,c15:symbol) diff --git a/parser/src/schema/typescript/gen_decls.py b/parser/src/schema/typescript/gen_decls.py new file mode 100644 index 000000000..bdda3b566 --- /dev/null +++ b/parser/src/schema/typescript/gen_decls.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Generate decls_base_ts.dl FROM the schema doc's own column tables. + +Arities come out of the '### 4.N `ts_x` / `lib_ts_x` — N columns' headers and are +cross-checked against the numbered rows of each table, so the .dl cannot drift from +schema.json. Run with --check to diff instead of write (for CI). + +Why generated: a hand-maintained .dl drifts silently. Column ORDER is the contract +with the Souffle engine, and the .dl carries only c0..cN — so a column rename is free +post-freeze and a reorder is not. Generating it is what makes that asymmetry safe. +""" +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +DOC = os.path.join(HERE, "schema.json") +OUT = os.path.join(HERE, "decls_base_ts.dl") + +#: One-line-plus purpose per relation. Kept HERE rather than in the doc because the +#: .dl is what an engine author reads first, and it must stand alone. +DOCS = { + "ts_module": + "A .ts/.tsx/.d.ts file, OR an ambient `declare module \"x\"`, OR `declare global`.\n" + "// The unit of import resolution AND the symbol merge table (c12 mergeTableKey), which is\n" + "// why it is a relation at all: Java's package is implicit in a qualified name, a TypeScript\n" + "// module is not. c16 emissionRegime is a COARSE token (ts6-inproc) and is IN THE KEY — a\n" + "// version string there would cascade every child hash on a patch bump.", + "ts_type": + "A type DECLARATION — class / interface / enum / type alias / namespace. Cols 0..11 mirror\n" + "// java_type 0..11. Anonymous structural types are NOT here (21,956 function types measured):\n" + "// only declarations, which is what keeps this relation key-able. c15 declarationGroupKey is\n" + "// the MERGED entity and is deliberately NOT UNIQUE — one interface name, N declarations, one\n" + "// type (max 43 measured). Group on c15; key on the last column. c20 isTypeOnly is true for\n" + "// INTERFACE_TYPE and TYPE_ALIAS_TYPE: no call-graph rule may traverse those rows.", + "ts_type_heritage": + "One `extends` / `implements` clause entry, ORDERED (c2 position). c7 inheritsMembers is the\n" + "// column Java does not need: `extends` inherits members, `implements` asserts and inherits\n" + "// NOTHING, and 60.4% of classes declare no implements at all. Walking an IMPLEMENTS_CLAUSE\n" + "// row as a subtyping edge is correct in Java and WRONG here — see ts_type_satisfies.", + "ts_type_parameter": + "A generic type parameter. Cols 0..6 mirror java_type_parameter 0..6. ONE relation where Java\n" + "// has two, because TypeScript attaches type parameters to seven owner kinds (c7 ownerKind).\n" + "// c13 varianceAnnotation is in/out (TS 4.7, 562 measured); c14 isConst is TS 5.0 (87).", + "ts_type_reference": + "The TYPE-NODE TREE. Cols 0..16 mirror java_type_reference 0..16, so the whole name->type\n" + "// resolution layer ports as a rename. Every type-level construct lives here and NOWHERE\n" + "// else — conditional, mapped, template-literal, infer, keyof, typeof, indexed-access\n" + "// (10,436 nodes measured) — which is the structural guarantee that they cannot reach the\n" + "// call graph. A union is N ROWS with c5 parentReferenceHash, not one row with a list:\n" + "// max arity measured is 208. c6 position is SOURCE order; the checker reorders and\n" + "// normalises boolean to true|false, so never compare against checker members.", + "ts_method": + "Every function-shaped declaration: function, method, constructor, accessor, arrow, function\n" + "// expression, static block, AND every bodiless signature. Cols 0..20 are byte-for-byte\n" + "// java_method 0..20. c25 signatureRole and c27 bodyPresence carry what Java never needs:\n" + "// 11,599 overload signatures measured, and 44.3% of resolved call targets are BODILESS, so\n" + "// a target must never be read as an implementation without checking c27. c39 startColumn is\n" + "// IN THE KEY: 703 arrow functions, and two on one line share name, signature and line.\n" + "// c7, like ts_field c8, is the owning type OR SHAPE — a ts_type_reference for a\n" + "// TYPE_LITERAL_*, FUNCTION_TYPE_SIGNATURE or CONSTRUCTOR_TYPE_SIGNATURE row.", + "ts_method_parameter": + "A formal parameter. Cols 0..11 mirror java_method_parameter 0..11. 85.3% carry an annotation\n" + "// (99.998% in ambient code), the inverse of Python's 31.8%, which is why declared-type\n" + "// receiver typing is the primary mechanism here. c13 isOptional changes ARITY MATCHING, and\n" + "// c17 isParameterProperty means this parameter also DECLARES A FIELD (c19 links it).", + "ts_field": + "A class property, interface property signature, index signature, auto-accessor, parameter\n" + "// property, or object-literal property. Cols 0..12 mirror java_field 0..12, and the key\n" + "// chains off c8 tsTypeLinkHash exactly as FieldRegistry chains off typeRegistryLinkHash —\n" + "// never off a re-derived qualified name. c8 is the owning type OR SHAPE: it points at a\n" + "// ts_type_reference row when memberKind is a TYPE_LITERAL_* value, because an anonymous\n" + "// { foo(): string } has members and no declaration to own them (schema 4.8.1). PK prefixes\n" + "// differ, so a rule joining c8 against ts_type finds NO MATCH for a shape member rather than\n" + "// a wrong one. c15 isOptional is load-bearing for structural satisfaction: an absent\n" + "// optional member does not break assignability.", + "ts_field_position": + "A field's declaration order within its type. Parity with java_field_position.", + "ts_enum_member": + "An enum member. Cols 0..11 mirror java_enum_constant 0..11. c12 valueKind admits COMPUTED\n" + "// (the value is not always statically known) and c14 isConstEnumMember marks a member that\n" + "// is INLINED at use sites, so a reference to it may have no runtime target.", + "ts_variable": + "A variable declaration, at module, function, block or global scope. Cols 0..8 mirror\n" + "// java_local_variable 0..8, widened because a module-level const is a first-class\n" + "// declaration here. c19 boundFunctionLinkHash is the link that makes `const f = () => {}`\n" + "// callable: 161 resolved call targets were arrow functions.\n" + "// There is deliberately NO ts_scope relation — measured, 34,798 identifier references and\n" + "// the ts_block -> ts_method -> ts_type -> ts_module chain reaches every one of them.", + "ts_import": + "An import. Cols 0..8 mirror java_import 0..8 with two slots repurposed: c6 isStatic ->\n" + "// isTypeOnly, c7 isOnDemand -> isWildcard (the projection stays import_wildcard). 45.6% of\n" + "// ecosystem imports are type-only, so c6 is a first-class column, not a flag. c14/c15 are\n" + "// filled by ts.resolveModuleName, which needs NO Program and is therefore parser-legal.\n" + "// One declaration with N named specifiers emits N ROWS: each binds a name and each may be\n" + "// individually type-only.", + "ts_export": + "An export or re-export. NO JAVA ANALOGUE — Java visibility is a modifier and there is no\n" + "// re-export, but here a re-export chain is the only path from an importer to the real\n" + "// declaration (1,251 export declarations, 86 `export *` measured). c2 EXPORT_STAR exports a\n" + "// set this row cannot name; the engine expands it from the source module's exports.", + "ts_expression": + "An expression AST node — the spine of call resolution. Cols 0..24 are byte-for-byte\n" + "// java_expression 0..24, so expr_kind / expr_child / expr_owner port as renames. c16 is\n" + "// WIDENED from Java: it is the declaration this expression INTRODUCES, discriminated by c0 —\n" + "// ts_type for CLASS_EXPRESSION, ts_method for ARROW_FUNCTION / FUNCTION_EXPRESSION. Without\n" + "// that an IIFE's target is reachable only by matching positions, which is what Java's own\n" + "// extractor does for lambdas and what a fact schema exists to prevent. c27\n" + "// assertedTypeReferenceLinkHash is the ONLY expression->type edge (`as` / `satisfies`), and\n" + "// it is a TYPE FK, so no call-graph rule can cross it. c28 isSpread marks where positional\n" + "// argument flow is PROVABLY imprecise rather than silently wrong.", + "ts_call_site": + "A call site — 1:1 with its CALL / NEW / TAGGED_TEMPLATE expression, so the key is a pure\n" + "// chain off c5. This is where the flagship gate lives: 100% of measured call sites have a\n" + "// getResolvedSignature answer, and 77.6% of overloaded calls resolve to a NON-FIRST\n" + "// declaration — so c12 points at ONE SIGNATURE, never at a name. c14 resolvedTargetKind\n" + "// admits SYNTHESIZED_NO_DECLARATION (2.3% measured: implicit constructors) as an honest\n" + "// terminal. c19 isTypeOnlyTarget must ALWAYS be false; a true row is a parser bug.", + "ts_block": + "A statement block. Cols 0..17 mirror java_block 0..17. Needed for caller attribution, as in\n" + "// Java, AND as the lexical scope of a let/const, which is what lets ts_variable do without a\n" + "// scope relation. c13 caughtExceptionTypes is near-always \"\": a TypeScript catch binding is\n" + "// unknown and cannot be typed. c17 links the guard, so typeof/instanceof/type-predicate\n" + "// narrowing (440 predicates measured) reaches the receiver.", + "ts_comment": + "A comment, JSDoc block, triple-slash directive, or ts-directive. Cols 0..9 mirror\n" + "// java_comment 0..9. c11 REFERENCE_* directives are real module edges and also feed\n" + "// ts_import.", + "ts_decorator": + "A decorator. Java's annotation relation, same slot, SHARED annotation_on projection: cols\n" + "// 0..12 mirror java_annotation. But a decorator is not an annotation — it is an expression\n" + "// that RUNS and may REPLACE its target (c14, c15). c13 decoratorSystem distinguishes the two\n" + "// incompatible systems (TC39 standard vs legacy experimental); without it a fact base mixes\n" + "// two evaluation semantics under one relation.", + "ts_decorator_argument": + "A positional or named argument of a decorator call — where framework routes and DI tokens\n" + "// live. Cols 0..10 mirror java_annotation_argument 0..10.", + "ts_parse_gap": + "One row per construct that could not be parsed. RECORDS the gap, never rewrites source.\n" + "// Measured ZERO rows over 25.9 MB with ts.createSourceFile — which is exactly why it must\n" + "// exist: an always-empty relation that suddenly has rows is a signal, a missing relation is\n" + "// a silence.", + "ts_type_satisfies": + "Structural satisfaction. DECLARED BUT NEVER STAGED BY THE PARSER: satisfaction needs\n" + "// isTypeAssignableTo, the parser has no checker, and a parser-emitted row would be a guess\n" + "// dressed as a fact. The ENGINE derives it from ts_field/ts_method/ts_type_heritage; the\n" + "// ORACLE adjudicates it. 21.5% of measured assignable pairs exist in NO SYNTAX anywhere and\n" + "// 15.4% are bidirectional, so c4 direction is required and mutual assignability is NOT\n" + "// identity. c11 oracleAgreement carries the relation's own error term.", +} + +#: The frozen first cut. Order here is the order in the .dl. +SPINE = ["ts_module", "ts_type", "ts_method", "ts_method_parameter", "ts_field", + "ts_type_reference", "ts_type_heritage", "ts_import", "ts_expression", + "ts_call_site"] + +#: lib_ts_* relations declared for symmetry and intentionally NOT staged: library IR +#: is declarations, not bodies. 1,113 declaration files measured, zero function bodies. +NOT_STAGED = ["lib_ts_expression", "lib_ts_call_site", "lib_ts_block", + "lib_ts_variable", "lib_ts_parse_gap"] + +#: Relations the PARSER never writes, in either provenance. +ENGINE_ONLY = ["ts_type_satisfies"] + + +def parse_doc(): + """(relations, errors). A relation is (name, arity), read from schema.json — the + frozen column list, one array per relation; arity is its length. The markdown + schema documents were retired; this file is the schema.""" + import json + schema = json.load(open(DOC)) + rels, errors, seen = [], [], set() + for name, spec in schema["relations"].items(): + if not name.startswith("ts_"): + errors.append("%s: not a ts_ relation" % name) + if name in seen: + errors.append("%s: declared twice" % name) + seen.add(name) + cols = spec.get("columns", []) + if not cols: + errors.append("%s: no columns — arity unverifiable" % name) + if len(set(cols)) != len(cols): + errors.append("%s: a column name repeats" % name) + rels.append((name, len(cols))) + if not rels: + errors.append("parsed 0 relations from %s" % DOC) + for name, _ in rels: + if name not in DOCS: + errors.append("%s: no purpose comment in gen_decls.py DOCS" % name) + for name in DOCS: + if name not in seen: + errors.append("%s: has a DOCS comment but no entry in schema.json" % name) + for name in SPINE: + if name not in seen: + errors.append("%s: listed in SPINE but absent from schema.json" % name) + return rels, errors + + +def decl(name, arity): + cols = ",".join("c%d:symbol" % i for i in range(arity)) + return ".decl %s(%s)" % (name, cols) + + +def render(rels): + by_name = dict(rels) + out = ['''// ============================================================================ +// Base input relations — TYPESCRIPT parser IR. One relation pair per entity kind. +// +// GENERATED FROM schema.json BY gen_decls.py — DO NOT HAND-EDIT. +// Re-run `python3 gen_decls.py --check` in CI; drift here is a silent schema break. +// +// NAMING: one prefix per core language; `lib_` is the EXTERNAL marker on top of it. +// java_*/lib_* is PROVENANCE, not language: the project under analysis vs everything +// else. So: +// +// ts_ TypeScript under analysis <- the PARSER emits only these +// lib_ts_ external / third-party <- the ENGINE stages these +// +// COLUMN ORDER IS THE CONTRACT. All columns are `symbol`. New columns append ONLY. +// The last column is always the entity's own unique hash; serviceVersionLinkHash is +// immediately before it. Column NAMES live only in the schema doc, so a rename is +// free after the freeze and a reorder is not. +// +// THREE THINGS AN ENGINE AUTHOR MUST READ BEFORE JOINING ANYTHING: +// +// 1. `name -> single entity` IS FALSE. TypeScript merges declarations: one interface +// name can have N declarations across N files (max 43 measured) that are ONE type. +// ts_type/ts_method/ts_field are keyed PER DECLARATION SITE; the merged entity is +// `declarationGroupKey`, which is deliberately NOT UNIQUE. Group on it. A rule that +// assumes one row per name is wrong by construction. +// +// 2. INHERITANCE EDGES DO NOT CAPTURE SUBTYPING. TypeScript is structural: 60.4% of +// classes satisfy their interfaces with no `implements` clause, and 21.5% of +// assignable pairs appear in no syntax at all. ts_type_heritage.inheritsMembers +// tells you whether an edge inherits members (`extends`) or merely asserts +// (`implements`). For subtyping use ts_type_satisfies, which the ENGINE derives. +// +// 3. THE TYPE GRAPH IS NOT THE CALL GRAPH. Type aliases, interfaces, conditional / +// mapped / template-literal types and `import type` have no runtime existence. +// They are confined to ts_type_reference and flagged isTypeOnly wherever they can +// be reached. ts_call_site.isTypeOnlyTarget must always be "false". +// +// NOT STAGED (declared for symmetry only — library IR is declarations, not bodies): +// @NOT_STAGED@ +// +// ENGINE-POPULATED (the parser writes no row, in either provenance): +// @ENGINE_ONLY@ +// ============================================================================''' + .replace("@NOT_STAGED@", ", ".join(NOT_STAGED)) + .replace("@ENGINE_ONLY@", ", ".join(ENGINE_ONLY))] + + def block(title, names): + out.append("") + out.append("// " + "=" * 74) + out.append("// " + title) + out.append("// " + "=" * 74) + for n in names: + arity = by_name[n] + out.append("") + out.append("// " + DOCS[n]) + out.append("// (%d columns)" % arity) + out.append(decl(n, arity)) + out.append(decl("lib_" + n, arity)) + + block("SPINE — the frozen first cut. Supports all four resolution paths.", SPINE) + rest = [n for n, _ in rels if n not in SPINE] + block("SECOND FREEZE — declared now so the column contract is fixed.", rest) + out.append("") + return "\n".join(out) + + +def main(): + check = "--check" in sys.argv + rels, errors = parse_doc() + if errors: + print("SCHEMA DOC ERRORS — refusing to generate:") + for e in errors: + print(" " + e) + return 2 + text = render(rels) + total = sum(a for _, a in rels) + spine = sum(a for n, a in rels if n in SPINE) + if check: + have = open(OUT).read() if os.path.exists(OUT) else "" + if have != text: + print("DRIFT: %s does not match %s" % (os.path.basename(OUT), os.path.basename(DOC))) + import difflib + for line in list(difflib.unified_diff( + have.split("\n"), text.split("\n"), + fromfile="decls_base_ts.dl (on disk)", + tofile="decls_base_ts.dl (from schema.json)", lineterm=""))[:40]: + print(" " + line) + print(" Fix: python3 src/schema/typescript/gen_decls.py") + return 1 + print("OK %d relation pairs, %d columns (spine %d) — .dl matches schema.json" + % (len(rels), total, spine)) + return 0 + open(OUT, "w").write(text) + print("wrote %s — %d relation pairs, %d columns (spine %d)" + % (os.path.basename(OUT), len(rels), total, spine)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/parser/src/schema/typescript/schema.json b/parser/src/schema/typescript/schema.json new file mode 100644 index 000000000..fdc07dde8 --- /dev/null +++ b/parser/src/schema/typescript/schema.json @@ -0,0 +1,1233 @@ +{ + "$comment": "FROZEN typescript fact schema \u2014 relation column order is the contract; new columns append only. Enum domains where the schema declares one. Generated once from the retired TYPESCRIPT-FACT-SCHEMA.md; edit here.", + "relations": { + "ts_module": { + "columns": [ + "name", + "qualifiedName", + "fileName", + "filePath", + "baseMservPath", + "moduleKind", + "scriptKind", + "declaredSpecifier", + "isDeclarationFile", + "isExternalModule", + "isAmbient", + "packageName", + "mergeTableKey", + "moduleResolutionMode", + "tsConfigPath", + "targetTsVersion", + "emissionRegime", + "startLine", + "endLine", + "hasTopLevelAwait", + "hasJsxContent", + "moduleInitMethodLinkHash", + "exportAssignmentLinkHash", + "defaultExportLinkHash", + "isExternal", + "serviceVersionLinkHash", + "tsModuleUniqueHash", + "strictBindCallApply" + ], + "domains": { + "moduleKind": [ + "AMBIENT_MODULE_DECLARATION", + "DECLARATION_FILE", + "GLOBAL_AUGMENTATION", + "JSON_MODULE", + "MODULE_AUGMENTATION", + "SCRIPT_GLOBAL", + "SOURCE_MODULE" + ], + "scriptKind": [ + "CTS", + "DTS", + "JSON", + "MTS", + "TS", + "TSX" + ], + "moduleResolutionMode": [ + "BUNDLER", + "CLASSIC", + "NODE10", + "NODE16", + "NODENEXT" + ] + } + }, + "ts_type": { + "columns": [ + "name", + "qualifiedName", + "fileName", + "typeCategory", + "typeAccess", + "typeModifier", + "typePlacement", + "filePath", + "baseMservPath", + "startLine", + "endLine", + "isExternal", + "tsModuleLinkHash", + "enclosingTypeLinkHash", + "enclosingMethodLinkHash", + "declarationGroupKey", + "mergeScopeKey", + "escapedName", + "declarationSpaces", + "isAmbientDeclaration", + "isTypeOnly", + "typeParameterCount", + "heritageCount", + "memberCount", + "requiredMemberCount", + "shapeDigest", + "aliasTargetReferenceLinkHash", + "isExported", + "hasIndexSignature", + "startColumn", + "endColumn", + "serviceVersionLinkHash", + "tsTypeUniqueHash" + ], + "domains": { + "typeCategory": [ + "CLASS_EXPRESSION_TYPE", + "CLASS_TYPE", + "CONST_ENUM_TYPE", + "ENUM_TYPE", + "INTERFACE_TYPE", + "NAMESPACE_TYPE", + "TYPE_ALIAS_TYPE" + ], + "typeAccess": [ + "DEFAULT_EXPORT_ACCESS", + "EXPORTED_ACCESS", + "GLOBAL_ACCESS", + "MODULE_LOCAL_ACCESS", + "NAMESPACE_LOCAL_ACCESS", + "PACKAGE_ACCESS" + ], + "typePlacement": [ + "AMBIENT_MODULE_PLACEMENT", + "EXPRESSION_PLACEMENT", + "LOCAL_PLACEMENT", + "NAMESPACE_PLACEMENT", + "NESTED_PLACEMENT", + "TOP_LEVEL_PLACEMENT" + ] + } + }, + "ts_type_heritage": { + "columns": [ + "heritageKind", + "clauseToken", + "position", + "heritageText", + "heritageSimpleName", + "heritageQualifiedPath", + "typeArgumentCount", + "inheritsMembers", + "tsTypeLinkHash", + "tsModuleLinkHash", + "tsExpressionLinkHash", + "tsTypeReferenceLinkHash", + "resolvedTypeLinkHash", + "resolvedGroupKey", + "isResolvedLocally", + "isDynamic", + "startLine", + "startColumn", + "serviceVersionLinkHash", + "tsTypeHeritageUniqueHash" + ], + "domains": { + "heritageKind": [ + "EXTENDS_CLASS", + "EXTENDS_EXPRESSION", + "EXTENDS_INTERFACE", + "EXTENDS_TYPE_LITERAL", + "IMPLEMENTS_CLAUSE" + ], + "clauseToken": [ + "EXTENDS", + "IMPLEMENTS" + ] + } + }, + "ts_type_parameter": { + "columns": [ + "paramName", + "position", + "ownerTypeName", + "ownerQualifiedName", + "filePath", + "startLine", + "tsTypeLinkHash", + "ownerKind", + "ownerLinkHash", + "constraintReferenceLinkHash", + "constraintText", + "defaultReferenceLinkHash", + "defaultText", + "varianceAnnotation", + "isConst", + "hasConstraint", + "hasDefault", + "startColumn", + "serviceVersionLinkHash", + "tsTypeParameterUniqueHash" + ], + "domains": { + "ownerKind": [ + "ARROW", + "CALL_SIGNATURE", + "CLASS", + "CONSTRUCT_SIGNATURE", + "FUNCTION", + "INFER_TYPE", + "INTERFACE", + "MAPPED_TYPE", + "METHOD", + "TYPE_ALIAS" + ], + "varianceAnnotation": [ + "\"\"", + "IN", + "IN_OUT", + "OUT" + ] + } + }, + "ts_type_reference": { + "columns": [ + "kind", + "context", + "tsTypeLinkHash", + "typeParameterLinkHash", + "referencedTypeLinkHash", + "parentReferenceHash", + "position", + "depth", + "typeName", + "completeTypeName", + "entityName", + "typeVariableName", + "arrayDimensions", + "wildcardVariance", + "startLine", + "endLine", + "typeReferenceOwnerHash", + "referenceOwnerKind", + "tsModuleLinkHash", + "childCount", + "isTypeOnlyPosition", + "resolvedGroupKey", + "isResolvedLocally", + "importSpecifier", + "isOptionalElement", + "isRestElement", + "literalValue", + "isTruncated", + "startColumn", + "serviceVersionLinkHash", + "tsTypeReferenceUniqueHash" + ], + "domains": { + "kind": [ + "ARRAY", + "CONDITIONAL", + "CONSTRUCTOR_TYPE", + "FUNCTION_TYPE", + "IMPORT_TYPE", + "INDEXED_ACCESS", + "INFER", + "INTERSECTION", + "INTRINSIC", + "LITERAL", + "MAPPED", + "NAMED_TUPLE_MEMBER", + "OPTIONAL", + "PARENTHESIZED", + "PRIMITIVE", + "REST", + "TEMPLATE_LITERAL", + "THIS_TYPE", + "TUPLE", + "TYPE_LITERAL", + "TYPE_OPERATOR", + "TYPE_PREDICATE", + "TYPE_QUERY", + "TYPE_REFERENCE", + "TYPE_VARIABLE", + "UNION" + ], + "context": [ + "AS_TARGET", + "CONDITIONAL_CHECK", + "CONDITIONAL_EXTENDS", + "CONDITIONAL_FALSE", + "CONDITIONAL_TRUE", + "DECORATOR_ARGUMENT_TYPE", + "DECORATOR_TYPE", + "ENUM_MEMBER_TYPE", + "FIELD_TYPE", + "HERITAGE_TWIN", + "IMPLEMENTS_INTERFACE", + "IMPORT_TYPE_QUALIFIER", + "INDEX_SIGNATURE_KEY", + "INDEX_SIGNATURE_VALUE", + "INSTANCEOF_TYPE", + "MAPPED_CONSTRAINT", + "MAPPED_TEMPLATE", + "METHOD_PARAM", + "METHOD_RETURN", + "METHOD_TYPE_ARGUMENT", + "METHOD_TYPE_PARAM_BOUND", + "OBJECT_CREATION_TYPE", + "SATISFIES_TARGET", + "SUPER_TYPE", + "TEMPLATE_SPAN", + "TYPE_ALIAS_RHS", + "TYPE_ARGUMENT", + "TYPE_ASSERTION", + "TYPE_ELEMENT", + "TYPE_PARAM_BOUND", + "TYPE_PARAM_DEFAULT", + "TYPE_PREDICATE_TARGET", + "VARIABLE_TYPE" + ], + "referenceOwnerKind": [ + "DECORATOR", + "ENUM_MEMBER", + "EXPORT", + "EXPRESSION", + "FIELD", + "HERITAGE", + "METHOD", + "METHOD_PARAM", + "TYPE", + "TYPE_PARAMETER", + "TYPE_REFERENCE", + "VARIABLE" + ] + } + }, + "ts_method": { + "columns": [ + "name", + "signature", + "detailedSignature", + "qualifiedName", + "filePath", + "startLine", + "endLine", + "tsTypeLinkHash", + "ownerTypeName", + "ownerQualifiedName", + "methodAccess", + "methodModifier", + "returnTypeName", + "isVarArgs", + "hasReceiverParameter", + "defaultValueExpression", + "methodKind", + "parameterCount", + "hasTypeParameters", + "throwsExceptions", + "enclosingMemberLinkHash", + "tsModuleLinkHash", + "declarationGroupKey", + "mergeScopeKey", + "escapedName", + "signatureRole", + "overloadIndex", + "bodyPresence", + "isTypeOnly", + "isAsync", + "isGenerator", + "isAbstract", + "isStatic", + "optionalParameterCount", + "restParameterIndex", + "typeParameterCount", + "thisParameterTypeName", + "returnTypeReferenceLinkHash", + "isTypePredicateReturn", + "startColumn", + "endColumn", + "serviceVersionLinkHash", + "tsMethodUniqueHash" + ], + "domains": { + "methodAccess": [ + "EXPORTED_ACCESS", + "MODULE_LOCAL_ACCESS", + "PRIVATE_ACCESS", + "PRIVATE_NAME_ACCESS", + "PROTECTED_ACCESS", + "PUBLIC_ACCESS" + ], + "methodKind": [ + "ARROW_FUNCTION", + "CALL_SIGNATURE", + "CLASS_STATIC_BLOCK", + "CONSTRUCTOR", + "CONSTRUCTOR_TYPE_SIGNATURE", + "CONSTRUCT_SIGNATURE", + "FUNCTION_DECLARATION", + "FUNCTION_EXPRESSION", + "FUNCTION_TYPE_SIGNATURE", + "GETTER", + "METHOD_DECLARATION", + "METHOD_SIGNATURE", + "MODULE_INITIALIZER", + "OBJECT_LITERAL_METHOD", + "SETTER", + "TYPE_LITERAL_CALL_SIGNATURE", + "TYPE_LITERAL_CONSTRUCT_SIGNATURE", + "TYPE_LITERAL_METHOD_SIGNATURE" + ], + "signatureRole": [ + "AMBIENT", + "IMPLEMENTATION", + "OVERLOAD_SIGNATURE", + "SOLE" + ], + "bodyPresence": [ + "HAS_BODY", + "NO_BODY_ABSTRACT", + "NO_BODY_AMBIENT", + "NO_BODY_INTERFACE", + "NO_BODY_OVERLOAD" + ] + } + }, + "ts_method_parameter": { + "columns": [ + "paramName", + "position", + "tsMethodLinkHash", + "parameterBaseType", + "parameterTypeName", + "potentialQualifiedName", + "isAmbiguous", + "isFinal", + "isVarArgs", + "isReceiverParameter", + "startLine", + "endLine", + "paramKind", + "isOptional", + "hasDefault", + "defaultValueText", + "defaultValueKind", + "isParameterProperty", + "parameterPropertyModifier", + "declaredFieldLinkHash", + "bindingPatternText", + "bindingSourceKind", + "bindingSource", + "typeReferenceLinkHash", + "tsExpressionLinkHash", + "decoratorCount", + "startColumn", + "serviceVersionLinkHash", + "tsMethodParameterUniqueHash" + ], + "domains": { + "paramKind": [ + "BINDING_ARRAY", + "BINDING_OBJECT", + "OPTIONAL", + "PARAMETER_PROPERTY", + "REQUIRED", + "REST", + "THIS" + ], + "defaultValueKind": [ + "ARRAY", + "ARROW", + "BOOL", + "CALL", + "IDENTIFIER", + "NEW", + "NONE", + "NULL", + "NUMBER", + "OBJECT", + "STRING", + "TEMPLATE", + "UNDEFINED", + "UNKNOWN" + ], + "bindingSourceKind": [ + "ARRAY_REST", + "INDEX", + "NONE", + "OBJECT_REST", + "PROPERTY" + ] + } + }, + "ts_field": { + "columns": [ + "name", + "fieldTypeName", + "fieldBaseType", + "potentialQualifiedName", + "isAmbiguous", + "filePath", + "startLine", + "endLine", + "tsTypeLinkHash", + "ownerTypeName", + "ownerQualifiedName", + "fieldAccess", + "fieldModifier", + "memberKind", + "tsModuleLinkHash", + "isOptional", + "hasDefiniteAssignment", + "isReadonly", + "isStatic", + "indexKeyTypeName", + "isTypeOnly", + "typeReferenceLinkHash", + "initializerExpressionLinkHash", + "originParameterLinkHash", + "memberGroupKey", + "startColumn", + "endColumn", + "serviceVersionLinkHash", + "tsFieldUniqueHash" + ], + "domains": { + "fieldAccess": [ + "EXPORTED_ACCESS", + "MODULE_LOCAL_ACCESS", + "PRIVATE_ACCESS", + "PRIVATE_NAME_ACCESS", + "PROTECTED_ACCESS", + "PUBLIC_ACCESS" + ], + "memberKind": [ + "AUTO_ACCESSOR", + "INDEX_SIGNATURE", + "OBJECT_LITERAL_PROPERTY", + "PARAMETER_PROPERTY", + "PROPERTY_DECLARATION", + "PROPERTY_SIGNATURE", + "TYPE_LITERAL_INDEX_SIGNATURE", + "TYPE_LITERAL_PROPERTY" + ] + } + }, + "ts_field_position": { + "columns": [ + "tsFieldLinkHash", + "position", + "tsFieldPositionUniqueHash" + ] + }, + "ts_enum_member": { + "columns": [ + "name", + "qualifiedName", + "ordinal", + "initializerText", + "hasInitializer", + "hasBody", + "filePath", + "startLine", + "endLine", + "tsTypeLinkHash", + "ownerTypeName", + "ownerQualifiedName", + "valueKind", + "constantValue", + "isConstEnumMember", + "tsExpressionLinkHash", + "serviceVersionLinkHash", + "tsEnumMemberUniqueHash" + ], + "domains": { + "valueKind": [ + "COMPUTED", + "CONSTANT_EXPRESSION", + "EXPLICIT_NUMERIC", + "EXPLICIT_STRING", + "IMPLICIT_NUMERIC", + "NUMERIC_LITERAL" + ] + } + }, + "ts_variable": { + "columns": [ + "name", + "variableTypeName", + "variableBaseType", + "potentialQualifiedName", + "isAmbiguous", + "filePath", + "startLine", + "endLine", + "scopeKind", + "scopeDepth", + "isConst", + "isTypeInferred", + "tsTypeLinkHash", + "tsMethodLinkHash", + "tsModuleLinkHash", + "tsBlockLinkHash", + "declarationKind", + "hasInitializer", + "initializerKind", + "boundFunctionLinkHash", + "typeReferenceLinkHash", + "initializerExpressionLinkHash", + "isExported", + "isAmbientDeclare", + "isDestructuring", + "bindingSourceKind", + "bindingSource", + "declarationGroupKey", + "startColumn", + "serviceVersionLinkHash", + "tsVariableUniqueHash" + ], + "domains": { + "scopeKind": [ + "AMBIENT_SCOPE", + "ARROW_BODY", + "BLOCK_SCOPE", + "CATCH_BINDING", + "FOR_BINDING", + "FUNCTION_BODY", + "GLOBAL_SCOPE", + "MODULE_SCOPE", + "NAMESPACE_SCOPE" + ], + "declarationKind": [ + "AWAIT_USING", + "CATCH", + "CONST", + "FOR_IN", + "FOR_INIT", + "FOR_OF", + "LET", + "USING", + "VAR" + ], + "initializerKind": [ + "ARRAY_LITERAL", + "ARROW", + "AS_EXPRESSION", + "AWAIT", + "CALL", + "CLASS_EXPRESSION", + "FUNCTION_EXPRESSION", + "IDENTIFIER", + "LITERAL", + "NEW", + "NONE", + "OBJECT_LITERAL", + "SATISFIES", + "TEMPLATE", + "UNKNOWN" + ], + "bindingSourceKind": [ + "ARRAY_REST", + "INDEX", + "NONE", + "OBJECT_REST", + "PROPERTY" + ] + } + }, + "ts_import": { + "columns": [ + "importKind", + "importedPath", + "moduleOrEntityName", + "simpleName", + "filePath", + "lineNumber", + "isTypeOnly", + "isWildcard", + "isModuleImport", + "originalName", + "aliasName", + "isDefaultImport", + "isSideEffectOnly", + "tsModuleLinkHash", + "resolvedModuleLinkHash", + "resolvedFilePath", + "resolutionKind", + "resolvedExtension", + "isExternalTarget", + "packageName", + "specifierHasExtension", + "importClauseIndex", + "tsExpressionLinkHash", + "startColumn", + "isExternal", + "serviceVersionLinkHash", + "tsImportUniqueHash" + ], + "domains": { + "importKind": [ + "DEFAULT", + "DYNAMIC_IMPORT", + "IMPORT_EQUALS_ENTITY", + "IMPORT_EQUALS_REQUIRE", + "INLINE_TYPE_SPECIFIER", + "NAMED", + "NAMED_ALIAS", + "NAMESPACE", + "REQUIRE_CALL", + "SIDE_EFFECT", + "TRIPLE_SLASH_REFERENCE", + "TYPE_IMPORT_NODE", + "TYPE_ONLY_DEFAULT", + "TYPE_ONLY_NAMED", + "TYPE_ONLY_NAMESPACE" + ], + "resolutionKind": [ + "AMBIENT_MODULE", + "BUILTIN_NODE", + "NODE_MODULES_SOURCE", + "NODE_MODULES_TYPES", + "PACKAGE_EXPORTS", + "PATHS_ALIAS", + "RELATIVE_FILE", + "UNRESOLVED" + ], + "resolvedExtension": [ + "\"\"", + ".cts", + ".d.cts", + ".d.mts", + ".d.ts", + ".js", + ".json", + ".jsx", + ".mts", + ".ts", + ".tsx" + ] + } + }, + "ts_export": { + "columns": [ + "exportedName", + "localName", + "exportKind", + "isTypeOnly", + "isDefault", + "isReExport", + "sourceSpecifier", + "tsModuleLinkHash", + "resolvedSourceModuleLinkHash", + "exportedEntityKind", + "exportedEntityLinkHash", + "exportedGroupKey", + "position", + "startLine", + "endLine", + "startColumn", + "isAmbient", + "tsExpressionLinkHash", + "isExternal", + "serviceVersionLinkHash", + "tsExportUniqueHash" + ], + "domains": { + "exportKind": [ + "DEFAULT_EXPORT", + "DEFAULT_EXPRESSION", + "EXPORT_ASSIGNMENT", + "EXPORT_IMPORT_EQUALS", + "EXPORT_STAR", + "EXPORT_STAR_AS_NAMESPACE", + "INLINE_DECLARATION", + "NAMED_ALIAS", + "NAMED_EXPORT", + "TYPE_ONLY_NAMED", + "TYPE_ONLY_STAR" + ], + "exportedEntityKind": [ + "ENUM", + "EXPRESSION", + "FIELD", + "METHOD", + "MODULE", + "NAMESPACE", + "TYPE", + "UNKNOWN", + "VARIABLE" + ] + } + }, + "ts_expression": { + "columns": [ + "kind", + "edgeRole", + "rootContext", + "expressionOwnerKind", + "tsTypeLinkHash", + "expressionOwnerHash", + "parentExpressionHash", + "position", + "depth", + "literalType", + "literalValue", + "methodReferenceKind", + "unaryFixity", + "operatorString", + "referencedEntityKind", + "referencedEntityHash", + "anonymousDeclarationHash", + "potentialQualifiedName", + "isAmbiguous", + "returnStatementIndex", + "startLine", + "startColumn", + "endLine", + "endColumn", + "tsModuleLinkHash", + "isOptionalChain", + "isNonNullAsserted", + "assertedTypeReferenceLinkHash", + "isSpread", + "argumentCount", + "typeArgumentCount", + "isTypeOnlyReachable", + "serviceVersionLinkHash", + "tsExpressionUniqueHash" + ], + "domains": { + "kind": [ + "ARRAY_LITERAL", + "ARROW_FUNCTION", + "ASSIGNMENT_EXPRESSION", + "AS_EXPRESSION", + "AWAIT_EXPRESSION", + "BINARY_EXPRESSION", + "CALL_EXPRESSION", + "CLASS_EXPRESSION", + "COMPOUND_ASSIGNMENT", + "DELETE_TYPEOF_VOID", + "DYNAMIC_IMPORT", + "ELEMENT_ACCESS", + "FUNCTION_EXPRESSION", + "IDENTIFIER_REFERENCE", + "JSX_ELEMENT", + "JSX_SELF_CLOSING", + "LITERAL", + "NEW_EXPRESSION", + "NON_NULL_EXPRESSION", + "OBJECT_LITERAL", + "PROPERTY_ACCESS", + "SATISFIES_EXPRESSION", + "SEQUENCE_EXPRESSION", + "SPREAD_ELEMENT", + "SUPER_REFERENCE", + "TAGGED_TEMPLATE", + "TEMPLATE_EXPRESSION", + "TERNARY_EXPRESSION", + "THIS_REFERENCE", + "TYPE_ASSERTION", + "UNARY_EXPRESSION", + "YIELD_EXPRESSION" + ], + "edgeRole": [ + "ARGUMENT", + "ARRAY_ELEMENT", + "ARROW_BODY", + "AS_OPERAND", + "INDEX_ARGUMENT", + "JSX_ATTRIBUTE_VALUE", + "JSX_CHILD", + "LEFT_OPERAND", + "METHOD_NAME", + "OBJECT_PROPERTY_KEY", + "OBJECT_PROPERTY_VALUE", + "PROPERTY_NAME", + "QUALIFIER", + "RECEIVER", + "RIGHT_OPERAND", + "ROOT", + "SATISFIES_OPERAND", + "SPREAD_OPERAND", + "TAG_EXPRESSION", + "TEMPLATE_SPAN", + "TERNARY_*", + "UNARY_OPERAND" + ], + "expressionOwnerKind": [ + "BLOCK", + "DECORATOR", + "ENUM_MEMBER", + "EXPORT", + "FIELD", + "METHOD", + "MODULE_INIT", + "PARAMETER_DEFAULT", + "TYPE", + "VARIABLE" + ], + "literalType": [ + "\"\"", + "BIGINT", + "BOOLEAN", + "NO_SUBSTITUTION_TEMPLATE", + "NULL", + "NUMBER", + "REGEX", + "STRING", + "TEMPLATE", + "UNDEFINED" + ], + "unaryFixity": [ + "\"\"", + "POSTFIX", + "PREFIX" + ], + "referencedEntityKind": [ + "AMBIENT_GLOBAL", + "ENUM_MEMBER", + "FIELD", + "IMPORT_BINDING", + "METHOD", + "NAMESPACE", + "PARAMETER", + "SUPER", + "THIS", + "TYPE", + "UNKNOWN", + "VARIABLE" + ] + } + }, + "ts_call_site": { + "columns": [ + "callKind", + "calleeName", + "receiverKind", + "receiverExpressionLinkHash", + "receiverTypeName", + "tsExpressionLinkHash", + "tsModuleLinkHash", + "callerMethodLinkHash", + "callerTypeLinkHash", + "argumentCount", + "spreadArgumentIndex", + "typeArgumentCount", + "resolvedSignatureLinkHash", + "resolvedGroupKey", + "resolvedTargetKind", + "resolvedOverloadIndex", + "overloadCandidateCount", + "isOverloadResolved", + "resolutionEvidence", + "isTypeOnlyTarget", + "isAmbientTarget", + "startLine", + "startColumn", + "serviceVersionLinkHash", + "tsCallSiteUniqueHash" + ], + "domains": { + "callKind": [ + "CONSTRUCTOR_CALL", + "DECORATOR_CALL", + "DYNAMIC_IMPORT_CALL", + "FUNCTION_CALL", + "INDEX_CALL", + "JSX_COMPONENT_CALL", + "METHOD_CALL", + "OPTIONAL_CALL", + "SUPER_CALL", + "TAGGED_TEMPLATE_CALL" + ], + "receiverKind": [ + "AS_EXPRESSION", + "AWAIT_RESULT", + "CALL_RESULT", + "ELEMENT_ACCESS", + "IDENTIFIER", + "NONE", + "NON_NULL", + "PARENTHESIZED", + "PROPERTY_CHAIN", + "SUPER", + "THIS", + "UNKNOWN" + ], + "resolvedTargetKind": [ + "AMBIENT_SIGNATURE", + "INDEX_SIGNATURE", + "LIB_SIGNATURE", + "PROJECT_IMPLEMENTATION", + "PROJECT_SIGNATURE", + "SYNTHESIZED_NO_DECLARATION", + "UNRESOLVED" + ], + "resolutionEvidence": [ + "AMBIENT_GLOBAL", + "DECLARED_RECEIVER_TYPE", + "IMPORT_BINDING", + "INDEX_SIGNATURE", + "LOCAL_BINDING", + "NAMESPACE_QUALIFIED", + "NONE", + "SUPER_MEMBER", + "THIS_MEMBER" + ] + } + }, + "ts_block": { + "columns": [ + "blockKind", + "order", + "filePath", + "startLine", + "endLine", + "startColumn", + "endColumn", + "nestingDepth", + "tsTypeLinkHash", + "methodOwnerHash", + "parentContainerHash", + "tryStatementHash", + "resourceCount", + "caughtExceptionTypes", + "ownerTypeName", + "ownerQualifiedName", + "ownerMethodName", + "conditionExpressionLinkHash", + "tsModuleLinkHash", + "serviceVersionLinkHash", + "tsBlockUniqueHash" + ], + "domains": { + "blockKind": [ + "ARROW_BODY", + "BARE_BLOCK", + "CATCH", + "DO_WHILE", + "ELSE", + "ELSE_IF", + "FINALLY", + "FOR", + "FOR_AWAIT_OF", + "FOR_IN", + "FOR_OF", + "FUNCTION_BODY", + "IF", + "LABELED", + "MODULE_BODY", + "NAMESPACE_BODY", + "STATIC_BLOCK", + "SWITCH_CASE", + "SWITCH_DEFAULT", + "TRY", + "WHILE" + ] + } + }, + "ts_comment": { + "columns": [ + "commentKind", + "commentText", + "startLine", + "startColumn", + "endLine", + "endColumn", + "ownerHash", + "commentIndex", + "filePath", + "tsModuleLinkHash", + "jsDocTags", + "directiveKind", + "serviceVersionLinkHash", + "tsCommentUniqueHash" + ], + "domains": { + "commentKind": [ + "BLOCK", + "JSDOC", + "LINE", + "TRIPLE_SLASH_DIRECTIVE", + "TS_DIRECTIVE" + ], + "directiveKind": [ + "\"\"", + "REFERENCE_*", + "REFERENCE_LIB", + "REFERENCE_PATH", + "REFERENCE_TYPES", + "TS_EXPECT_ERROR", + "TS_IGNORE", + "TS_NOCHECK" + ] + } + }, + "ts_decorator": { + "columns": [ + "decoratorName", + "kind", + "context", + "ownerHash", + "tsTypeLinkHash", + "typeParameterHash", + "parentDecoratorHash", + "depth", + "position", + "startLine", + "endLine", + "isMetaDecorator", + "argumentCount", + "decoratorSystem", + "decoratorSemantics", + "tsExpressionLinkHash", + "resolvedDecoratorMethodLinkHash", + "tsModuleLinkHash", + "startColumn", + "serviceVersionLinkHash", + "tsDecoratorUniqueHash" + ], + "domains": { + "kind": [ + "CALL", + "COMPUTED", + "MARKER", + "MEMBER_EXPRESSION" + ], + "context": [ + "ACCESSOR_DECLARATION", + "AUTO_ACCESSOR", + "CLASS_DECLARATION", + "FIELD_DECLARATION", + "METHOD_DECLARATION", + "PARAMETER_DECLARATION" + ], + "decoratorSystem": [ + "LEGACY_EXPERIMENTAL", + "STANDARD_TC39" + ], + "decoratorSemantics": [ + "OBSERVES_TARGET", + "REPLACES_TARGET", + "UNKNOWN" + ] + } + }, + "ts_decorator_argument": { + "columns": [ + "argumentName", + "argumentValue", + "valueType", + "position", + "parentDecoratorHash", + "referencedTypeHash", + "nestedObjectHash", + "arrayIndex", + "startLine", + "endLine", + "tsExpressionLinkHash", + "serviceVersionLinkHash", + "tsDecoratorArgumentUniqueHash" + ], + "domains": { + "valueType": [ + "ARRAY", + "ARROW", + "BOOLEAN", + "CALL", + "CLASS_REFERENCE", + "IDENTIFIER", + "NULL", + "NUMBER", + "OBJECT", + "STRING", + "TEMPLATE", + "UNDEFINED", + "UNKNOWN" + ] + } + }, + "ts_parse_gap": { + "columns": [ + "gapKind", + "diagnosticCode", + "message", + "filePath", + "startLine", + "startColumn", + "endLine", + "tsModuleLinkHash", + "serviceVersionLinkHash", + "tsParseGapUniqueHash" + ], + "domains": { + "gapKind": [ + "ENCODING_ERROR", + "EXCLUDED_BY_CONFIG", + "FILE_TOO_LARGE", + "PARSE_DIAGNOSTIC", + "READ_ERROR", + "UNSUPPORTED_SYNTAX" + ] + } + }, + "ts_type_satisfies": { + "columns": [ + "sourceGroupKey", + "targetGroupKey", + "sourceTypeLinkHash", + "targetTypeLinkHash", + "direction", + "evidence", + "matchedMemberCount", + "targetRequiredMemberCount", + "isExactShape", + "isTrivialTarget", + "derivationMethod", + "oracleAgreement", + "sourceModuleLinkHash", + "targetModuleLinkHash", + "serviceVersionLinkHash", + "tsTypeSatisfiesUniqueHash" + ], + "domains": { + "direction": [ + "BIDIRECTIONAL", + "SOURCE_TO_TARGET" + ], + "evidence": [ + "DECLARED_EXTENDS", + "DECLARED_IMPLEMENTS", + "STRUCTURAL_DERIVED" + ], + "derivationMethod": [ + "DIGEST_PRUNED", + "HERITAGE_CLOSURE", + "MEMBER_WISE" + ], + "oracleAgreement": [ + "AGREES", + "FALSE_NEGATIVE", + "FALSE_POSITIVE", + "UNCHECKED" + ] + } + } + } +} diff --git a/parser/src/test-data/gradle/_golden/catalogs/catalog-entries.txt b/parser/src/test-data/gradle/_golden/catalogs/catalog-entries.txt new file mode 100644 index 000000000..8c9591c45 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/catalogs/catalog-entries.txt @@ -0,0 +1,14 @@ +entryKind=BUNDLE alias=everything accessorPath=everything notation=BUNDLE_LIST group= artifact= pluginId= version= versionRef= resolvedVersion= richVersionConstraint= bundleMembers=shorthand,module-literal catalogName=libs startLine=20 endLine=20 +entryKind=LIBRARY alias=group-name-literal accessorPath=group.name.literal notation=GROUP_NAME_LITERAL group=com.example artifact=gn-literal pluginId= version=3.0 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=13 endLine=13 +entryKind=LIBRARY alias=group-name-ref accessorPath=group.name.ref notation=GROUP_NAME_VERSION_REF group=com.example artifact=gn-ref pluginId= version= versionRef=plain resolvedVersion=1.0.0 richVersionConstraint= bundleMembers= catalogName=libs startLine=14 endLine=14 +entryKind=LIBRARY alias=module-literal accessorPath=module.literal notation=MODULE_LITERAL group=com.example artifact=module-literal pluginId= version=2.0 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=10 endLine=10 +entryKind=LIBRARY alias=module-no-version accessorPath=module.no.version notation=MODULE_NO_VERSION group=com.example artifact=bom-managed pluginId= version= versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=12 endLine=12 +entryKind=LIBRARY alias=module-ref accessorPath=module.ref notation=MODULE_VERSION_REF group=com.example artifact=module-ref pluginId= version= versionRef=plain resolvedVersion=1.0.0 richVersionConstraint= bundleMembers= catalogName=libs startLine=11 endLine=11 +entryKind=LIBRARY alias=shorthand accessorPath=shorthand notation=SHORTHAND_STRING group=com.example artifact=shorthand pluginId= version=1.0 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=9 endLine=9 +entryKind=LIBRARY alias=wrapped accessorPath=wrapped notation=MODULE_VERSION_REF group=com.example artifact=wrapped pluginId= version= versionRef=plain resolvedVersion=1.0.0 richVersionConstraint= bundleMembers= catalogName=libs startLine=15 endLine=16 +entryKind=PLUGIN alias=id-literal accessorPath=id.literal notation=PLUGIN_ID_LITERAL group= artifact= pluginId=com.example.plugin version=1.2.3 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=23 endLine=23 +entryKind=PLUGIN alias=id-ref accessorPath=id.ref notation=PLUGIN_ID_VERSION_REF group= artifact= pluginId=com.example.refplugin version= versionRef=plain resolvedVersion=1.0.0 richVersionConstraint= bundleMembers= catalogName=libs startLine=24 endLine=24 +entryKind=PLUGIN alias=shorthand-plugin accessorPath=shorthand.plugin notation=PLUGIN_SHORTHAND group= artifact= pluginId=com.example.short version=4.5.6 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=25 endLine=25 +entryKind=VERSION alias=plain accessorPath=plain notation=VERSION_LITERAL group= artifact= pluginId= version=1.0.0 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=4 endLine=4 +entryKind=VERSION alias=rich accessorPath=rich notation=VERSION_RICH group= artifact= pluginId= version= versionRef= resolvedVersion= richVersionConstraint=strictly=[1.0, 2.0[;reject=1.5 bundleMembers= catalogName=libs startLine=5 endLine=5 +entryKind=VERSION alias=with-equals accessorPath=with.equals notation=VERSION_LITERAL group= artifact= pluginId= version=1.0=beta versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=6 endLine=6 diff --git a/parser/src/test-data/gradle/_golden/catalogs/parse-gaps.txt b/parser/src/test-data/gradle/_golden/catalogs/parse-gaps.txt new file mode 100644 index 000000000..6d9433dc7 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/catalogs/parse-gaps.txt @@ -0,0 +1 @@ +reason=ERROR_NODE nodeType=catalog_entry originalText=this line is not an entry startLine=17 endLine=17 startColumn=0 endColumn=25 diff --git a/parser/src/test-data/gradle/_golden/catalogs/scripts.txt b/parser/src/test-data/gradle/_golden/catalogs/scripts.txt new file mode 100644 index 000000000..2a0dd054d --- /dev/null +++ b/parser/src/test-data/gradle/_golden/catalogs/scripts.txt @@ -0,0 +1 @@ +scriptKind=VERSION_CATALOG dslDialect=TOML gradleProjectPath= relativePath=libs.versions.toml fileName=libs.versions.toml parseStatus=PARTIAL lineCount=29 blockCount=0 declarationCount=0 valueReferenceCount=0 coordinateCount=14 commentCount=0 parseGapCount=1 diff --git a/parser/src/test-data/gradle/_golden/edge-cases/blocks.txt b/parser/src/test-data/gradle/_golden/edge-cases/blocks.txt new file mode 100644 index 000000000..5c4a32434 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/edge-cases/blocks.txt @@ -0,0 +1,8 @@ +blockType=CATCH blockName= expression= depth=0 childBlockCount=0 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes=e startLine=36 endLine=38 startColumn=2 endColumn=1 +blockType=CONFIGURATIONS blockName=configurations expression= depth=0 childBlockCount=0 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes= startLine=13 endLine=15 startColumn=0 endColumn=1 +blockType=DEPENDENCIES blockName=dependencies expression= depth=0 childBlockCount=0 declarationCount=2 dslDialect=GROOVY caughtExceptionTypes= startLine=23 endLine=26 startColumn=0 endColumn=1 +blockType=DSL_BLOCK blockName=withType expression= depth=1 childBlockCount=0 declarationCount=2 dslDialect=GROOVY caughtExceptionTypes= startLine=18 endLine=20 startColumn=4 endColumn=5 +blockType=FINALLY blockName= expression= depth=0 childBlockCount=0 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes= startLine=38 endLine=40 startColumn=2 endColumn=1 +blockType=IF blockName= expression=project.hasProperty('ci') depth=0 childBlockCount=0 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes= startLine=28 endLine=34 startColumn=0 endColumn=0 +blockType=SUBPROJECTS blockName=subprojects expression= depth=0 childBlockCount=1 declarationCount=0 dslDialect=GROOVY caughtExceptionTypes= startLine=17 endLine=21 startColumn=0 endColumn=1 +blockType=TRY blockName= expression= depth=0 childBlockCount=0 declarationCount=2 dslDialect=GROOVY caughtExceptionTypes= startLine=34 endLine=40 startColumn=0 endColumn=1 diff --git a/parser/src/test-data/gradle/_golden/edge-cases/comments.txt b/parser/src/test-data/gradle/_golden/edge-cases/comments.txt new file mode 100644 index 000000000..33beed354 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/edge-cases/comments.txt @@ -0,0 +1,3 @@ +commentKind=LINE text=Each must either round-trip intact or leave a parse gap saying what was lost. isCommentedOutCode=false startLine=2 endLine=2 startColumn=0 endColumn=80 +commentKind=LINE text=Every construct here is one tree-sitter-groovy cannot parse unaided. isCommentedOutCode=false startLine=1 endLine=1 startColumn=0 endColumn=71 +commentKind=LINE text=─── box drawing above is deliberately non-ASCII ─── isCommentedOutCode=false startLine=10 endLine=10 startColumn=0 endColumn=54 diff --git a/parser/src/test-data/gradle/_golden/edge-cases/declarations.txt b/parser/src/test-data/gradle/_golden/edge-cases/declarations.txt new file mode 100644 index 000000000..20e55cdf7 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/edge-cases/declarations.txt @@ -0,0 +1,17 @@ +declarationType=CONFIGURATION name=config value= notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=14 endLine=14 startColumn=4 endColumn=66 +declarationType=DEPENDENCY name="""com.example:lib:${-> project.version}""" value="""com.example:lib:${-> project.version}""" notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=24 endLine=24 startColumn=4 endColumn=47 +declarationType=DEPENDENCY name='com.example:multi:1.0:linux-x86@zip' value='com.example:multi:1.0:linux-x86@zip' notation=STRING_WITH_EXTENSION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=25 endLine=25 startColumn=4 endColumn=56 +declarationType=PLUGIN name=ci.gradle value= notation=APPLY_FROM_LOCAL qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=29 endLine=29 startColumn=4 endColumn=27 +declarationType=PROPERTY name=emptyDefault value=project.findProperty('missing') || '' notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=4 endLine=6 startColumn=0 endColumn=0 +declarationType=PROPERTY name=envToken value=System.getenv('BUILD_TOKEN') notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=6 endLine=7 startColumn=0 endColumn=0 +declarationType=PROPERTY name=ext.ciBuild value=true notation= qualifier=EXT_SINGLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=1 endLine=2 startColumn=0 endColumn=0 +declarationType=PROPERTY name=ext.optionalApplied value=true notation= qualifier=EXT_SINGLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=1 endLine=2 startColumn=0 endColumn=0 +declarationType=PROPERTY name=sysProp value=System.getProperty('java.version') notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=7 endLine=8 startColumn=0 endColumn=0 +declarationType=PROPERTY name=unicodeNote value='h_llo w_rld' notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=11 endLine=13 startColumn=0 endColumn=0 +declarationType=PROPERTY name=viaProvider value=providers.gradleProperty('nexusUser') notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=8 endLine=10 startColumn=0 endColumn=0 +declarationType=STATEMENT name="expression_statement: ""optional.gradle""" value="""optional.gradle""" notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=35 endLine=36 startColumn=16 endColumn=0 +declarationType=STATEMENT name=logger value="""optional.gradle missing""" notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=37 endLine=37 startColumn=4 endColumn=42 +declarationType=STATEMENT name=logger value='done' notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=39 endLine=39 startColumn=4 endColumn=23 +declarationType=STATEMENT name=proj.tasks.withType value=Test notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=18 endLine=20 startColumn=4 endColumn=5 +declarationType=STATEMENT name=t value= notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=19 endLine=19 startColumn=8 endColumn=28 +declarationType=STATEMENT name=type_identifier: apply value=apply notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=35 endLine=35 startColumn=4 endColumn=9 diff --git a/parser/src/test-data/gradle/_golden/edge-cases/dependency-coordinates.txt b/parser/src/test-data/gradle/_golden/edge-cases/dependency-coordinates.txt new file mode 100644 index 000000000..8c882a09d --- /dev/null +++ b/parser/src/test-data/gradle/_golden/edge-cases/dependency-coordinates.txt @@ -0,0 +1,2 @@ +configuration=implementation notation=INTERPOLATED_STRING group=com.example artifact=lib version=${-> project.version} classifier= extension= versionSource=INTERPOLATED resolvedVersion= catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=24 endLine=24 +configuration=implementation notation=STRING_WITH_EXTENSION group=com.example artifact=multi version=1.0 classifier=linux-x86 extension=zip versionSource=LITERAL resolvedVersion=1.0 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=25 endLine=25 diff --git a/parser/src/test-data/gradle/_golden/edge-cases/parse-gaps.txt b/parser/src/test-data/gradle/_golden/edge-cases/parse-gaps.txt new file mode 100644 index 000000000..f5cb3c786 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/edge-cases/parse-gaps.txt @@ -0,0 +1,9 @@ +reason=DROPPED_CLOSURE_PARAMETERS nodeType=preprocessor originalText={ config -> startLine=13 endLine=13 startColumn=19 endColumn=30 +reason=DROPPED_CLOSURE_PARAMETERS nodeType=preprocessor originalText={ proj -> startLine=17 endLine=17 startColumn=12 endColumn=21 +reason=DROPPED_CLOSURE_PARAMETERS nodeType=preprocessor originalText={ t -> startLine=18 endLine=18 startColumn=24 endColumn=30 +reason=ERROR_NODE nodeType=ERROR originalText=apply from: 'optional.gradle' startLine=35 endLine=35 startColumn=4 endColumn=15 +reason=REPLACED_NON_ASCII nodeType=preprocessor originalText=é startLine=11 endLine=11 startColumn=20 endColumn=21 +reason=REPLACED_NON_ASCII nodeType=preprocessor originalText=ö startLine=11 endLine=11 startColumn=26 endColumn=27 +reason=REPLACED_NON_ASCII nodeType=preprocessor originalText=─── startLine=10 endLine=10 startColumn=3 endColumn=6 +reason=REPLACED_NON_ASCII nodeType=preprocessor originalText=─── startLine=10 endLine=10 startColumn=51 endColumn=54 +reason=REWRITTEN_ELVIS nodeType=preprocessor originalText=?: startLine=4 endLine=4 startColumn=51 endColumn=53 diff --git a/parser/src/test-data/gradle/_golden/edge-cases/scripts.txt b/parser/src/test-data/gradle/_golden/edge-cases/scripts.txt new file mode 100644 index 000000000..1314c7860 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/edge-cases/scripts.txt @@ -0,0 +1,3 @@ +scriptKind=SCRIPT_PLUGIN dslDialect=GROOVY gradleProjectPath= relativePath=ci.gradle fileName=ci.gradle parseStatus=OK lineCount=2 blockCount=0 declarationCount=1 valueReferenceCount=0 coordinateCount=0 commentCount=0 parseGapCount=0 +scriptKind=SCRIPT_PLUGIN dslDialect=GROOVY gradleProjectPath= relativePath=optional.gradle fileName=optional.gradle parseStatus=OK lineCount=2 blockCount=0 declarationCount=1 valueReferenceCount=0 coordinateCount=0 commentCount=0 parseGapCount=0 +scriptKind=SCRIPT_PLUGIN dslDialect=GROOVY gradleProjectPath= relativePath=preprocessor.gradle fileName=preprocessor.gradle parseStatus=PARTIAL lineCount=41 blockCount=8 declarationCount=15 valueReferenceCount=5 coordinateCount=2 commentCount=3 parseGapCount=9 diff --git a/parser/src/test-data/gradle/_golden/edge-cases/value-references.txt b/parser/src/test-data/gradle/_golden/edge-cases/value-references.txt new file mode 100644 index 000000000..4b9f11d10 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/edge-cases/value-references.txt @@ -0,0 +1,5 @@ +referenceExpression=-> project.version referenceType=EXT_PROPERTY_ACCESS rawFragment=${-> project.version} defaultValue= resolutionKind=EXTERNAL resolvedContext= startLine=24 endLine=24 startColumn=4 endColumn=47 +referenceExpression=BUILD_TOKEN referenceType=ENV_VARIABLE rawFragment=System.getenv('BUILD_TOKEN') defaultValue= resolutionKind=EXTERNAL resolvedContext= startLine=6 endLine=6 startColumn=15 endColumn=43 +referenceExpression=java.version referenceType=SYSTEM_PROPERTY rawFragment=System.getProperty('java.version') defaultValue= resolutionKind=EXTERNAL resolvedContext= startLine=7 endLine=7 startColumn=14 endColumn=48 +referenceExpression=missing referenceType=FIND_PROPERTY rawFragment=project.findProperty('missing') defaultValue= resolutionKind=EXTERNAL resolvedContext= startLine=4 endLine=4 startColumn=19 endColumn=50 +referenceExpression=nexusUser referenceType=GRADLE_PROPERTY_PROVIDER rawFragment=providers.gradleProperty('nexusUser') defaultValue= resolutionKind=EXTERNAL resolvedContext= startLine=8 endLine=8 startColumn=18 endColumn=55 diff --git a/parser/src/test-data/gradle/_golden/kotlin-dsl/blocks.txt b/parser/src/test-data/gradle/_golden/kotlin-dsl/blocks.txt new file mode 100644 index 000000000..97fe8d136 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/kotlin-dsl/blocks.txt @@ -0,0 +1,7 @@ +blockType=DEPENDENCIES blockName=dependencies expression= depth=0 childBlockCount=0 declarationCount=4 dslDialect=KOTLIN caughtExceptionTypes= startLine=23 endLine=28 startColumn=0 endColumn=1 +blockType=DSL_BLOCK blockName=credentials expression= depth=2 childBlockCount=0 declarationCount=2 dslDialect=KOTLIN caughtExceptionTypes= startLine=17 endLine=19 startColumn=8 endColumn=9 +blockType=DSL_BLOCK blockName=maven expression= depth=1 childBlockCount=1 declarationCount=2 dslDialect=KOTLIN caughtExceptionTypes= startLine=15 endLine=20 startColumn=4 endColumn=5 +blockType=DSL_BLOCK blockName=tasks expression= depth=0 childBlockCount=0 declarationCount=1 dslDialect=KOTLIN caughtExceptionTypes= startLine=35 endLine=38 startColumn=0 endColumn=1 +blockType=DSL_BLOCK blockName=tasks expression= depth=0 childBlockCount=0 declarationCount=2 dslDialect=KOTLIN caughtExceptionTypes= startLine=30 endLine=33 startColumn=0 endColumn=1 +blockType=PLUGINS blockName=plugins expression= depth=0 childBlockCount=0 declarationCount=3 dslDialect=KOTLIN caughtExceptionTypes= startLine=1 endLine=5 startColumn=0 endColumn=1 +blockType=REPOSITORIES blockName=repositories expression= depth=0 childBlockCount=1 declarationCount=1 dslDialect=KOTLIN caughtExceptionTypes= startLine=13 endLine=21 startColumn=0 endColumn=1 diff --git a/parser/src/test-data/gradle/_golden/kotlin-dsl/declarations.txt b/parser/src/test-data/gradle/_golden/kotlin-dsl/declarations.txt new file mode 100644 index 000000000..b60b83cd2 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/kotlin-dsl/declarations.txt @@ -0,0 +1,22 @@ +declarationType=DEPENDENCY name="""org.springframework:spring-core:$springVersion""" value="""org.springframework:spring-core:$springVersion""" notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=KOTLIN startLine=24 endLine=24 startColumn=4 endColumn=68 +declarationType=DEPENDENCY name="kotlin(""test"")" value="kotlin(""test"")" notation=STRING_NOTATION qualifier=testImplementation hasConfigBlock=false reason= dslDialect=KOTLIN startLine=26 endLine=26 startColumn=4 endColumn=38 +declarationType=DEPENDENCY name="platform(""org.springframework.boot:spring-boot-dependencies:3.2.2"")" value="platform(""org.springframework.boot:spring-boot-dependencies:3.2.2"")" notation=PLATFORM qualifier=implementation hasConfigBlock=false reason= dslDialect=KOTLIN startLine=25 endLine=25 startColumn=4 endColumn=87 +declarationType=DEPENDENCY name=libs.jackson.databind value=libs.jackson.databind notation=VERSION_CATALOG_ACCESSOR qualifier=implementation hasConfigBlock=false reason= dslDialect=KOTLIN startLine=27 endLine=27 startColumn=4 endColumn=41 +declarationType=INCLUDE name=:service value=:service notation= qualifier=include hasConfigBlock=false reason= dslDialect=KOTLIN startLine=3 endLine=3 startColumn=0 endColumn=19 +declarationType=PLUGIN name=org.springframework.boot value=3.2.2 notation=PLUGINS_BLOCK_ID qualifier=id hasConfigBlock=false reason= dslDialect=KOTLIN startLine=3 endLine=4 startColumn=4 endColumn=0 +declarationType=PROPERTY name=buildNumber value="(findProperty(""buildNumber"")) || ""0""" notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=KOTLIN startLine=40 endLine=41 startColumn=0 endColumn=0 +declarationType=PROPERTY name=group value="""com.example""" notation= qualifier=PROJECT hasConfigBlock=false reason= dslDialect=KOTLIN startLine=10 endLine=11 startColumn=0 endColumn=0 +declarationType=PROPERTY name=java value= notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=KOTLIN startLine=2 endLine=3 startColumn=4 endColumn=0 +declarationType=PROPERTY name=name value="""Authorization""" notation= qualifier=PROJECT hasConfigBlock=false reason= dslDialect=KOTLIN startLine=18 endLine=19 startColumn=12 endColumn=0 +declarationType=PROPERTY name=nexusUrl value=project notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=KOTLIN startLine=8 endLine=10 startColumn=0 endColumn=0 +declarationType=PROPERTY name=rootProject.name value="""kotlin-fixture""" notation= qualifier=PROJECT hasConfigBlock=false reason= dslDialect=KOTLIN startLine=1 endLine=3 startColumn=0 endColumn=0 +declarationType=PROPERTY name=springVersion value="extra(""6.1.3"")" notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=KOTLIN startLine=7 endLine=8 startColumn=0 endColumn=0 +declarationType=PROPERTY name=url value="uri(""https://repo.example.com/releases"")" notation= qualifier=PROJECT hasConfigBlock=false reason= dslDialect=KOTLIN startLine=16 endLine=17 startColumn=8 endColumn=0 +declarationType=PROPERTY name=version value="""2.0.0""" notation= qualifier=PROJECT hasConfigBlock=false reason= dslDialect=KOTLIN startLine=11 endLine=13 startColumn=0 endColumn=0 +declarationType=REPOSITORY name=https://repo.example.com/releases value=https://repo.example.com/releases notation=MAVEN_CUSTOM qualifier=maven hasConfigBlock=true reason= dslDialect=KOTLIN startLine=15 endLine=20 startColumn=4 endColumn=5 +declarationType=REPOSITORY name=mavenCentral value= notation=MAVEN_CENTRAL qualifier= hasConfigBlock=false reason= dslDialect=KOTLIN startLine=14 endLine=14 startColumn=4 endColumn=18 +declarationType=STATEMENT name=alias value=libs.plugins.boot notation= qualifier= hasConfigBlock=false reason= dslDialect=KOTLIN startLine=4 endLine=4 startColumn=4 endColumn=28 +declarationType=STATEMENT name=credentials value=HttpHeaderCredentials notation= qualifier= hasConfigBlock=false reason= dslDialect=KOTLIN startLine=17 endLine=19 startColumn=8 endColumn=9 +declarationType=STATEMENT name=from value="""build/reports""" notation= qualifier= hasConfigBlock=false reason= dslDialect=KOTLIN startLine=37 endLine=37 startColumn=4 endColumn=25 +declarationType=STATEMENT name=useJUnitPlatform value= notation= qualifier= hasConfigBlock=false reason= dslDialect=KOTLIN startLine=32 endLine=32 startColumn=4 endColumn=22 +declarationType=TASK name=test value="""test""" notation=TASKS_NAMED qualifier= hasConfigBlock=true reason= dslDialect=KOTLIN startLine=30 endLine=33 startColumn=0 endColumn=1 diff --git a/parser/src/test-data/gradle/_golden/kotlin-dsl/dependency-coordinates.txt b/parser/src/test-data/gradle/_golden/kotlin-dsl/dependency-coordinates.txt new file mode 100644 index 000000000..01f7f7633 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/kotlin-dsl/dependency-coordinates.txt @@ -0,0 +1,4 @@ +configuration=implementation notation=INTERPOLATED_STRING group=org.springframework artifact=spring-core version=$springVersion classifier= extension= versionSource=INTERPOLATED resolvedVersion= catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=24 endLine=24 +configuration=implementation notation=PLATFORM group=org.springframework.boot artifact=spring-boot-dependencies version=3.2.2 classifier= extension= versionSource=LITERAL resolvedVersion=3.2.2 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=25 endLine=25 +configuration=implementation notation=VERSION_CATALOG_ACCESSOR group= artifact= version= classifier= extension= versionSource=UNKNOWN resolvedVersion= catalogAlias=jackson.databind projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=27 endLine=27 +configuration=testImplementation notation=VARIABLE_REFERENCE group= artifact= version= classifier= extension= versionSource=VARIABLE resolvedVersion= catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=26 endLine=26 diff --git a/parser/src/test-data/gradle/_golden/kotlin-dsl/parse-gaps.txt b/parser/src/test-data/gradle/_golden/kotlin-dsl/parse-gaps.txt new file mode 100644 index 000000000..9a006cc11 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/kotlin-dsl/parse-gaps.txt @@ -0,0 +1,5 @@ +reason=DROPPED_CLASS_REFERENCE nodeType=preprocessor originalText=::class startLine=17 endLine=17 startColumn=41 endColumn=48 +reason=DROPPED_TYPE_ARGUMENTS nodeType=preprocessor originalText=named( startLine=30 endLine=30 startColumn=6 endColumn=18 +reason=DROPPED_TYPE_ARGUMENTS nodeType=preprocessor originalText=register( startLine=34 endLine=34 startColumn=6 endColumn=21 +reason=DROPPED_TYPE_CAST nodeType=preprocessor originalText= as String? startLine=38 endLine=38 startColumn=46 endColumn=57 +reason=REWRITTEN_ELVIS nodeType=preprocessor originalText=?: startLine=40 endLine=40 startColumn=48 endColumn=50 diff --git a/parser/src/test-data/gradle/_golden/kotlin-dsl/scripts.txt b/parser/src/test-data/gradle/_golden/kotlin-dsl/scripts.txt new file mode 100644 index 000000000..8d6ac042a --- /dev/null +++ b/parser/src/test-data/gradle/_golden/kotlin-dsl/scripts.txt @@ -0,0 +1,2 @@ +scriptKind=ROOT_BUILD dslDialect=KOTLIN gradleProjectPath=: relativePath=build.gradle.kts fileName=build.gradle.kts parseStatus=PARTIAL lineCount=39 blockCount=7 declarationCount=20 valueReferenceCount=3 coordinateCount=4 commentCount=0 parseGapCount=5 +scriptKind=SETTINGS dslDialect=KOTLIN gradleProjectPath=: relativePath=settings.gradle.kts fileName=settings.gradle.kts parseStatus=OK lineCount=4 blockCount=0 declarationCount=2 valueReferenceCount=0 coordinateCount=0 commentCount=0 parseGapCount=0 diff --git a/parser/src/test-data/gradle/_golden/kotlin-dsl/value-references.txt b/parser/src/test-data/gradle/_golden/kotlin-dsl/value-references.txt new file mode 100644 index 000000000..26fb1bc0d --- /dev/null +++ b/parser/src/test-data/gradle/_golden/kotlin-dsl/value-references.txt @@ -0,0 +1,3 @@ +referenceExpression=buildNumber referenceType=FIND_PROPERTY rawFragment="findProperty(""buildNumber"")" defaultValue= resolutionKind=LOCAL_PROPERTY resolvedContext=GRADLE_DECLARATION_d8c0676ea2199688c0f492773d102147 startLine=40 endLine=40 startColumn=19 endColumn=46 +referenceExpression=jackson.databind referenceType=VERSION_CATALOG_ACCESSOR rawFragment=libs.jackson.databind defaultValue= resolutionKind=EXTERNAL resolvedContext= startLine=27 endLine=27 startColumn=4 endColumn=41 +referenceExpression=springVersion referenceType=GSTRING_SIMPLE rawFragment=$springVersion defaultValue= resolutionKind=LOCAL_PROPERTY resolvedContext=GRADLE_DECLARATION_c84021e5b5091c63702f32e26be9d465 startLine=24 endLine=24 startColumn=52 endColumn=66 diff --git a/parser/src/test-data/gradle/_golden/multi-project/blocks.txt b/parser/src/test-data/gradle/_golden/multi-project/blocks.txt new file mode 100644 index 000000000..2086ff980 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/multi-project/blocks.txt @@ -0,0 +1,16 @@ +blockType=ALLPROJECTS blockName=allprojects expression= depth=0 childBlockCount=1 declarationCount=0 dslDialect=GROOVY caughtExceptionTypes= startLine=16 endLine=22 startColumn=0 endColumn=1 +blockType=CONFIGURATIONS blockName=configurations expression= depth=0 childBlockCount=0 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes= startLine=40 endLine=42 startColumn=0 endColumn=1 +blockType=DEPENDENCIES blockName=dependencies expression= depth=0 childBlockCount=0 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes= startLine=1 endLine=1 startColumn=0 endColumn=72 +blockType=DEPENDENCIES blockName=dependencies expression= depth=0 childBlockCount=0 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes= startLine=2 endLine=4 startColumn=0 endColumn=1 +blockType=DEPENDENCIES blockName=dependencies expression= depth=0 childBlockCount=0 declarationCount=2 dslDialect=GROOVY caughtExceptionTypes= startLine=11 endLine=14 startColumn=0 endColumn=1 +blockType=DEPENDENCIES blockName=dependencies expression= depth=0 childBlockCount=0 declarationCount=4 dslDialect=GROOVY caughtExceptionTypes= startLine=1 endLine=6 startColumn=0 endColumn=1 +blockType=DEPENDENCIES blockName=dependencies expression= depth=0 childBlockCount=0 declarationCount=4 dslDialect=GROOVY caughtExceptionTypes= startLine=24 endLine=29 startColumn=0 endColumn=1 +blockType=DEPENDENCIES blockName=dependencies expression= depth=0 childBlockCount=1 declarationCount=6 dslDialect=GROOVY caughtExceptionTypes= startLine=2 endLine=16 startColumn=0 endColumn=1 +blockType=DSL_BLOCK blockName=doLast expression= depth=0 childBlockCount=0 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes= startLine=32 endLine=32 startColumn=4 endColumn=27 +blockType=DSL_BLOCK blockName=flatDir expression= depth=2 childBlockCount=0 declarationCount=2 dslDialect=GROOVY caughtExceptionTypes= startLine=20 endLine=20 startColumn=8 endColumn=31 +blockType=DSL_BLOCK blockName=implementation expression= depth=1 childBlockCount=0 declarationCount=2 dslDialect=GROOVY caughtExceptionTypes= startLine=11 endLine=13 startColumn=4 endColumn=5 +blockType=DSL_BLOCK blockName=maven expression= depth=2 childBlockCount=0 declarationCount=2 dslDialect=GROOVY caughtExceptionTypes= startLine=19 endLine=19 startColumn=8 endColumn=56 +blockType=DSL_BLOCK blockName=tasks expression= depth=0 childBlockCount=0 declarationCount=2 dslDialect=GROOVY caughtExceptionTypes= startLine=35 endLine=38 startColumn=0 endColumn=1 +blockType=EXT blockName=ext expression= depth=0 childBlockCount=0 declarationCount=4 dslDialect=GROOVY caughtExceptionTypes= startLine=6 endLine=9 startColumn=0 endColumn=1 +blockType=PLUGINS blockName=plugins expression= depth=0 childBlockCount=0 declarationCount=3 dslDialect=GROOVY caughtExceptionTypes= startLine=1 endLine=4 startColumn=0 endColumn=1 +blockType=REPOSITORIES blockName=repositories expression= depth=1 childBlockCount=2 declarationCount=1 dslDialect=GROOVY caughtExceptionTypes= startLine=17 endLine=21 startColumn=4 endColumn=5 diff --git a/parser/src/test-data/gradle/_golden/multi-project/catalog-entries.txt b/parser/src/test-data/gradle/_golden/multi-project/catalog-entries.txt new file mode 100644 index 000000000..f2c7cb6c6 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/multi-project/catalog-entries.txt @@ -0,0 +1,11 @@ +entryKind=BUNDLE alias=persistence accessorPath=persistence notation=BUNDLE_LIST group= artifact= pluginId= version= versionRef= resolvedVersion= richVersionConstraint= bundleMembers=jackson-databind,commons-lang3 catalogName=libs startLine=14 endLine=14 +entryKind=LIBRARY alias=bom-managed accessorPath=bom.managed notation=MODULE_NO_VERSION group=org.springframework artifact=spring-core pluginId= version= versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=10 endLine=10 +entryKind=LIBRARY alias=commons-lang3 accessorPath=commons.lang3 notation=SHORTHAND_STRING group=org.apache.commons artifact=commons-lang3 pluginId= version=3.14.0 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=9 endLine=9 +entryKind=LIBRARY alias=dangling-ref accessorPath=dangling.ref notation=MODULE_VERSION_REF group=com.example artifact=dangling pluginId= version= versionRef=does-not-exist resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=11 endLine=11 +entryKind=LIBRARY alias=jackson-databind accessorPath=jackson.databind notation=GROUP_NAME_VERSION_REF group=com.fasterxml.jackson.core artifact=jackson-databind pluginId= version= versionRef=jackson resolvedVersion=2.15.3 richVersionConstraint= bundleMembers= catalogName=libs startLine=8 endLine=8 +entryKind=LIBRARY alias=spring-boot-starter-web accessorPath=spring.boot.starter.web notation=MODULE_VERSION_REF group=org.springframework.boot artifact=spring-boot-starter-web pluginId= version= versionRef=spring-boot resolvedVersion=3.2.2 richVersionConstraint= bundleMembers= catalogName=libs startLine=7 endLine=7 +entryKind=PLUGIN alias=boot accessorPath=boot notation=PLUGIN_ID_VERSION_REF group= artifact= pluginId=org.springframework.boot version= versionRef=spring-boot resolvedVersion=3.2.2 richVersionConstraint= bundleMembers= catalogName=libs startLine=17 endLine=17 +entryKind=PLUGIN alias=kotlin-jvm accessorPath=kotlin.jvm notation=PLUGIN_SHORTHAND group= artifact= pluginId=org.jetbrains.kotlin.jvm version=1.9.22 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=18 endLine=18 +entryKind=VERSION alias=jackson accessorPath=jackson notation=VERSION_LITERAL group= artifact= pluginId= version=2.15.3 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=3 endLine=3 +entryKind=VERSION alias=spring-boot accessorPath=spring.boot notation=VERSION_LITERAL group= artifact= pluginId= version=3.2.2 versionRef= resolvedVersion= richVersionConstraint= bundleMembers= catalogName=libs startLine=2 endLine=2 +entryKind=VERSION alias=strict-guava accessorPath=strict.guava notation=VERSION_RICH group= artifact= pluginId= version=32.1.3-jre versionRef= resolvedVersion= richVersionConstraint=strictly=[32.0, 33.0[;prefer=32.1.3-jre bundleMembers= catalogName=libs startLine=4 endLine=4 diff --git a/parser/src/test-data/gradle/_golden/multi-project/comments.txt b/parser/src/test-data/gradle/_golden/multi-project/comments.txt new file mode 100644 index 000000000..60fabd19a --- /dev/null +++ b/parser/src/test-data/gradle/_golden/multi-project/comments.txt @@ -0,0 +1,4 @@ +commentKind=LINE text=buildSrc builds the build, not the product isCommentedOutCode=false startLine=1 endLine=1 startColumn=0 endColumn=45 +commentKind=LINE text=core is the shared library module isCommentedOutCode=false startLine=1 endLine=1 startColumn=0 endColumn=36 +commentKind=LINE text=implementation 'org.removed:removed:1.0' isCommentedOutCode=true startLine=15 endLine=15 startColumn=4 endColumn=47 +commentKind=LINE text=pinned deliberately: see ticket BUILD-91 isCommentedOutCode=false startLine=3 endLine=3 startColumn=4 endColumn=47 diff --git a/parser/src/test-data/gradle/_golden/multi-project/declarations.txt b/parser/src/test-data/gradle/_golden/multi-project/declarations.txt new file mode 100644 index 000000000..d23ffe23e --- /dev/null +++ b/parser/src/test-data/gradle/_golden/multi-project/declarations.txt @@ -0,0 +1,46 @@ +declarationType=DEPENDENCY name="""com.fasterxml.jackson.core:jackson-databind:${versions.jackson}""" value="""com.fasterxml.jackson.core:jackson-databind:${versions.jackson}""" notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=26 endLine=26 startColumn=4 endColumn=75 +declarationType=DEPENDENCY name="""com.google.guava:guava:${guavaVersion}""" value="""com.google.guava:guava:${guavaVersion}""" notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=25 endLine=25 startColumn=4 endColumn=54 +declarationType=DEPENDENCY name="group: ""org.slf4j"", name: ""slf4j-api"", version: ""2.0.9""" value="group: ""org.slf4j"", name: ""slf4j-api"", version: ""2.0.9""" notation=MAP_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=8 endLine=8 startColumn=4 endColumn=74 +declarationType=DEPENDENCY name='com.example:noisy:1.0' value='com.example:noisy:1.0' notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=11 endLine=13 startColumn=4 endColumn=5 +declarationType=DEPENDENCY name='com.google.code.gson:gson:2.10.1' value='com.google.code.gson:gson:2.10.1' notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=3 endLine=3 startColumn=4 endColumn=53 +declarationType=DEPENDENCY name='com.h2database:h2:2.2.224:tests@jar' value='com.h2database:h2:2.2.224:tests@jar' notation=STRING_WITH_EXTENSION qualifier=runtimeOnly hasConfigBlock=false reason= dslDialect=GROOVY startLine=9 endLine=9 startColumn=4 endColumn=53 +declarationType=DEPENDENCY name='com.squareup.okhttp3:okhttp:4.12.0' value='com.squareup.okhttp3:okhttp:4.12.0' notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=4 endLine=4 startColumn=4 endColumn=55 +declarationType=DEPENDENCY name='org.apache.commons:commons-lang3:3.14.0' value='org.apache.commons:commons-lang3:3.14.0' notation=STRING_NOTATION qualifier=api hasConfigBlock=false reason= dslDialect=GROOVY startLine=27 endLine=27 startColumn=4 endColumn=49 +declarationType=DEPENDENCY name='org.junit.jupiter:junit-jupiter:5.10.1' value='org.junit.jupiter:junit-jupiter:5.10.1' notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=1 endLine=1 startColumn=15 endColumn=70 +declarationType=DEPENDENCY name='org.junit.jupiter:junit-jupiter:5.10.1' value='org.junit.jupiter:junit-jupiter:5.10.1' notation=STRING_NOTATION qualifier=testImplementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=28 endLine=28 startColumn=4 endColumn=63 +declarationType=DEPENDENCY name='org.springframework:spring-core' value='org.springframework:spring-core' notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=6 endLine=6 startColumn=4 endColumn=52 +declarationType=DEPENDENCY name=files('libs/legacy.jar', 'libs/extra.jar') value=files('libs/legacy.jar', 'libs/extra.jar') notation=FILES qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=7 endLine=7 startColumn=4 endColumn=62 +declarationType=DEPENDENCY name=libs.bundles.persistence value=libs.bundles.persistence notation=VERSION_CATALOG_ACCESSOR qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=4 endLine=4 startColumn=4 endColumn=44 +declarationType=DEPENDENCY name=libs.spring.boot.starter.web value=libs.spring.boot.starter.web notation=VERSION_CATALOG_ACCESSOR qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=3 endLine=3 startColumn=4 endColumn=48 +declarationType=DEPENDENCY name=platform('org.springframework.boot:spring-boot-dependencies:3.2.2') value=platform('org.springframework.boot:spring-boot-dependencies:3.2.2') notation=PLATFORM qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=5 endLine=5 startColumn=4 endColumn=87 +declarationType=DEPENDENCY name=project(':core') value=project(':core') notation=PROJECT qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=2 endLine=2 startColumn=4 endColumn=36 +declarationType=DEPENDENCY name=projects.core value=projects.core notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=12 endLine=12 startColumn=4 endColumn=33 +declarationType=DEPENDENCY name=projects.core.dataTest value=projects.core.dataTest notation=STRING_NOTATION qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=13 endLine=13 startColumn=4 endColumn=42 +declarationType=DEPENDENCY name=testFixtures(project(':core')) value=testFixtures(project(':core')) notation=TEST_FIXTURES qualifier=testImplementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=5 endLine=5 startColumn=4 endColumn=54 +declarationType=EXCLUDE name=org.unwanted:bad-transitive value="group: ""org.unwanted"", module: ""bad-transitive""" notation= qualifier=implementation hasConfigBlock=false reason= dslDialect=GROOVY startLine=12 endLine=12 startColumn=8 endColumn=63 +declarationType=INCLUDE name=:app value=:app notation= qualifier=include hasConfigBlock=false reason= dslDialect=GROOVY startLine=4 endLine=4 startColumn=0 endColumn=14 +declarationType=INCLUDE name=:core value=:core notation= qualifier=include hasConfigBlock=false reason= dslDialect=GROOVY startLine=3 endLine=3 startColumn=0 endColumn=14 +declarationType=INCLUDE name=:core:data-test value=:core:data-test notation= qualifier=include hasConfigBlock=false reason= dslDialect=GROOVY startLine=5 endLine=5 startColumn=0 endColumn=25 +declarationType=PLUGIN name=java value= notation=PLUGINS_BLOCK_ID qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=2 endLine=2 startColumn=4 endColumn=13 +declarationType=PLUGIN name=org.springframework.boot value= notation=PLUGINS_BLOCK_ID qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=3 endLine=3 startColumn=4 endColumn=33 +declarationType=PROPERTY name=ciToken value=System.getenv('CI_TOKEN') notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=8 endLine=9 startColumn=0 endColumn=0 +declarationType=PROPERTY name=group value='com.example' notation= qualifier=PROJECT hasConfigBlock=false reason= dslDialect=GROOVY startLine=11 endLine=12 startColumn=0 endColumn=0 +declarationType=PROPERTY name=guavaVersion value='32.1.3-jre' notation= qualifier=EXT_BLOCK hasConfigBlock=false reason= dslDialect=GROOVY startLine=7 endLine=8 startColumn=4 endColumn=0 +declarationType=PROPERTY name=localOnly value='not-visible-elsewhere' notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=14 endLine=16 startColumn=0 endColumn=0 +declarationType=PROPERTY name=nexusUser value=project.findProperty('nexusUser') || 'anonymous' notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=9 endLine=11 startColumn=0 endColumn=0 +declarationType=PROPERTY name=rootProject.name value='fixture-build' notation= qualifier=PROJECT hasConfigBlock=false reason= dslDialect=GROOVY startLine=1 endLine=3 startColumn=0 endColumn=0 +declarationType=PROPERTY name=testImplementation value= notation= qualifier=LOCAL_VARIABLE hasConfigBlock=false reason= dslDialect=GROOVY startLine=41 endLine=42 startColumn=4 endColumn=0 +declarationType=PROPERTY name=version value='1.0.0' notation= qualifier=PROJECT hasConfigBlock=false reason= dslDialect=GROOVY startLine=12 endLine=14 startColumn=0 endColumn=0 +declarationType=PROPERTY name=versions value="[jackson: ""2.15.3"", caffeine: ""3.1.8""]" notation= qualifier=EXT_BLOCK hasConfigBlock=false reason= dslDialect=GROOVY startLine=8 endLine=9 startColumn=4 endColumn=0 +declarationType=PROPERTY name=versions.caffeine value="""3.1.8""" notation= qualifier=EXT_MAP_ENTRY hasConfigBlock=false reason= dslDialect=GROOVY startLine=8 endLine=8 startColumn=19 endColumn=36 +declarationType=PROPERTY name=versions.jackson value="""2.15.3""" notation= qualifier=EXT_MAP_ENTRY hasConfigBlock=false reason= dslDialect=GROOVY startLine=8 endLine=8 startColumn=0 endColumn=17 +declarationType=REPOSITORY name=flatDir value= notation=FLAT_DIR qualifier=flatDir hasConfigBlock=true reason= dslDialect=GROOVY startLine=20 endLine=20 startColumn=8 endColumn=31 +declarationType=REPOSITORY name=https://repo.spring.io/milestone value=https://repo.spring.io/milestone notation=MAVEN_CUSTOM qualifier=maven hasConfigBlock=true reason= dslDialect=GROOVY startLine=19 endLine=19 startColumn=8 endColumn=56 +declarationType=REPOSITORY name=mavenCentral value= notation=MAVEN_CENTRAL qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=18 endLine=18 startColumn=8 endColumn=22 +declarationType=STATEMENT name=dirs value='libs' notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=20 endLine=20 startColumn=18 endColumn=29 +declarationType=STATEMENT name=from value='docs' notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=37 endLine=37 startColumn=4 endColumn=15 +declarationType=STATEMENT name=println value='hi' notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=32 endLine=32 startColumn=13 endColumn=25 +declarationType=STATEMENT name=url value='https://repo.spring.io/milestone' notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=19 endLine=19 startColumn=16 endColumn=54 +declarationType=STATEMENT name=version value='3.2.2' notation= qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=3 endLine=3 startColumn=34 endColumn=49 +declarationType=TASK name=copyDocs value='copyDocs', Copy notation=TASKS_REGISTER_TYPED qualifier=Copy hasConfigBlock=true reason= dslDialect=GROOVY startLine=35 endLine=38 startColumn=0 endColumn=1 +declarationType=TASK name=hello value=hello notation=TASK_KEYWORD qualifier= hasConfigBlock=false reason= dslDialect=GROOVY startLine=31 endLine=31 startColumn=0 endColumn=10 diff --git a/parser/src/test-data/gradle/_golden/multi-project/dependency-coordinates.txt b/parser/src/test-data/gradle/_golden/multi-project/dependency-coordinates.txt new file mode 100644 index 000000000..3aef5444d --- /dev/null +++ b/parser/src/test-data/gradle/_golden/multi-project/dependency-coordinates.txt @@ -0,0 +1,20 @@ +configuration=api notation=STRING_NOTATION group=org.apache.commons artifact=commons-lang3 version=3.14.0 classifier= extension= versionSource=LITERAL resolvedVersion=3.14.0 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=27 endLine=27 +configuration=implementation notation=FILES group= artifact= version= classifier= extension= versionSource=ABSENT resolvedVersion= catalogAlias= projectPath= fileSpec=libs/extra.jar isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=7 endLine=7 +configuration=implementation notation=FILES group= artifact= version= classifier= extension= versionSource=ABSENT resolvedVersion= catalogAlias= projectPath= fileSpec=libs/legacy.jar isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=7 endLine=7 +configuration=implementation notation=INTERPOLATED_STRING group=com.fasterxml.jackson.core artifact=jackson-databind version=${versions.jackson} classifier= extension= versionSource=INTERPOLATED resolvedVersion= catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=26 endLine=26 +configuration=implementation notation=INTERPOLATED_STRING group=com.google.guava artifact=guava version=${guavaVersion} classifier= extension= versionSource=INTERPOLATED resolvedVersion= catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=25 endLine=25 +configuration=implementation notation=MAP_NOTATION group=org.slf4j artifact=slf4j-api version=2.0.9 classifier= extension= versionSource=LITERAL resolvedVersion=2.0.9 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=8 endLine=8 +configuration=implementation notation=PLATFORM group=org.springframework.boot artifact=spring-boot-dependencies version=3.2.2 classifier= extension= versionSource=LITERAL resolvedVersion=3.2.2 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=5 endLine=5 +configuration=implementation notation=PROJECT group= artifact= version= classifier= extension= versionSource=ABSENT resolvedVersion= catalogAlias= projectPath=:core fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=12 endLine=12 +configuration=implementation notation=PROJECT group= artifact= version= classifier= extension= versionSource=ABSENT resolvedVersion= catalogAlias= projectPath=:core fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=2 endLine=2 +configuration=implementation notation=PROJECT group= artifact= version= classifier= extension= versionSource=ABSENT resolvedVersion= catalogAlias= projectPath=:core:data-test fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=13 endLine=13 +configuration=implementation notation=STRING_NOTATION group=com.example artifact=noisy version=1.0 classifier= extension= versionSource=LITERAL resolvedVersion=1.0 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=11 endLine=13 +configuration=implementation notation=STRING_NOTATION group=com.google.code.gson artifact=gson version=2.10.1 classifier= extension= versionSource=LITERAL resolvedVersion=2.10.1 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=3 endLine=3 +configuration=implementation notation=STRING_NOTATION group=com.squareup.okhttp3 artifact=okhttp version=4.12.0 classifier= extension= versionSource=LITERAL resolvedVersion=4.12.0 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=4 endLine=4 +configuration=implementation notation=STRING_NOTATION group=org.junit.jupiter artifact=junit-jupiter version=5.10.1 classifier= extension= versionSource=LITERAL resolvedVersion=5.10.1 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=1 endLine=1 +configuration=implementation notation=STRING_NOTATION group=org.springframework artifact=spring-core version= classifier= extension= versionSource=ABSENT resolvedVersion= catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=6 endLine=6 +configuration=implementation notation=VERSION_CATALOG_ACCESSOR group=org.springframework.boot artifact=spring-boot-starter-web version= classifier= extension= versionSource=CATALOG resolvedVersion=3.2.2 catalogAlias=spring.boot.starter.web projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=3 endLine=3 +configuration=implementation notation=VERSION_CATALOG_BUNDLE group= artifact= version= classifier= extension= versionSource=ABSENT resolvedVersion= catalogAlias=persistence projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=4 endLine=4 +configuration=runtimeOnly notation=STRING_WITH_EXTENSION group=com.h2database artifact=h2 version=2.2.224 classifier=tests extension=jar versionSource=LITERAL resolvedVersion=2.2.224 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=9 endLine=9 +configuration=testImplementation notation=STRING_NOTATION group=org.junit.jupiter artifact=junit-jupiter version=5.10.1 classifier= extension= versionSource=LITERAL resolvedVersion=5.10.1 catalogAlias= projectPath= fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=28 endLine=28 +configuration=testImplementation notation=TEST_FIXTURES group= artifact= version= classifier= extension= versionSource=ABSENT resolvedVersion= catalogAlias= projectPath=:core fileSpec= isTransitive= isChanging=false isForced=false hasConfigBlock=false startLine=5 endLine=5 diff --git a/parser/src/test-data/gradle/_golden/multi-project/parse-gaps.txt b/parser/src/test-data/gradle/_golden/multi-project/parse-gaps.txt new file mode 100644 index 000000000..996ccb156 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/multi-project/parse-gaps.txt @@ -0,0 +1 @@ +reason=REWRITTEN_ELVIS nodeType=preprocessor originalText=?: startLine=9 endLine=9 startColumn=50 endColumn=52 diff --git a/parser/src/test-data/gradle/_golden/multi-project/scripts.txt b/parser/src/test-data/gradle/_golden/multi-project/scripts.txt new file mode 100644 index 000000000..d33a935c2 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/multi-project/scripts.txt @@ -0,0 +1,7 @@ +scriptKind=BUILD_SRC_BUILD dslDialect=GROOVY gradleProjectPath= relativePath=buildSrc/build.gradle fileName=build.gradle parseStatus=OK lineCount=5 blockCount=1 declarationCount=1 valueReferenceCount=0 coordinateCount=1 commentCount=1 parseGapCount=0 +scriptKind=PROJECT_BUILD dslDialect=GROOVY gradleProjectPath=:app relativePath=app/build.gradle fileName=build.gradle parseStatus=PARTIAL lineCount=15 blockCount=2 declarationCount=8 valueReferenceCount=4 coordinateCount=6 commentCount=0 parseGapCount=1 +scriptKind=PROJECT_BUILD dslDialect=GROOVY gradleProjectPath=:core relativePath=core/build.gradle fileName=build.gradle parseStatus=OK lineCount=17 blockCount=2 declarationCount=8 valueReferenceCount=0 coordinateCount=8 commentCount=3 parseGapCount=0 +scriptKind=PROJECT_BUILD dslDialect=GROOVY gradleProjectPath=:core:data-test relativePath=core/data-test/build.gradle fileName=build.gradle parseStatus=OK lineCount=2 blockCount=1 declarationCount=1 valueReferenceCount=0 coordinateCount=1 commentCount=0 parseGapCount=0 +scriptKind=ROOT_BUILD dslDialect=GROOVY gradleProjectPath=: relativePath=build.gradle fileName=build.gradle parseStatus=OK lineCount=42 blockCount=10 declarationCount=24 valueReferenceCount=2 coordinateCount=4 commentCount=0 parseGapCount=0 +scriptKind=SETTINGS dslDialect=GROOVY gradleProjectPath=: relativePath=settings.gradle fileName=settings.gradle parseStatus=OK lineCount=8 blockCount=0 declarationCount=4 valueReferenceCount=0 coordinateCount=0 commentCount=0 parseGapCount=0 +scriptKind=VERSION_CATALOG dslDialect=TOML gradleProjectPath= relativePath=gradle/libs.versions.toml fileName=libs.versions.toml parseStatus=OK lineCount=19 blockCount=0 declarationCount=0 valueReferenceCount=0 coordinateCount=11 commentCount=0 parseGapCount=0 diff --git a/parser/src/test-data/gradle/_golden/multi-project/value-references.txt b/parser/src/test-data/gradle/_golden/multi-project/value-references.txt new file mode 100644 index 000000000..2a491a411 --- /dev/null +++ b/parser/src/test-data/gradle/_golden/multi-project/value-references.txt @@ -0,0 +1,6 @@ +referenceExpression=CI_TOKEN referenceType=ENV_VARIABLE rawFragment=System.getenv('CI_TOKEN') defaultValue= resolutionKind=EXTERNAL resolvedContext= startLine=8 endLine=8 startColumn=14 endColumn=39 +referenceExpression=guavaVersion referenceType=GSTRING_INTERPOLATION rawFragment=${guavaVersion} defaultValue= resolutionKind=EXT_PROPERTY resolvedContext=GRADLE_DECLARATION_bb070c4a565b5dbb3ae5d5e8d1058d7a startLine=25 endLine=25 startColumn=4 endColumn=54 +referenceExpression=nexusUser referenceType=FIND_PROPERTY rawFragment=project.findProperty('nexusUser') defaultValue= resolutionKind=LOCAL_PROPERTY resolvedContext=GRADLE_DECLARATION_f74afffb0b3b11b5a774bd75a2bab34a startLine=9 endLine=9 startColumn=16 endColumn=49 +referenceExpression=persistence referenceType=VERSION_CATALOG_BUNDLE rawFragment=libs.bundles.persistence defaultValue= resolutionKind=CATALOG_BUNDLE resolvedContext=GRADLE_CATALOG_ENTRY_6b5ad3433514c5cd6abc4801d1e22ebd startLine=4 endLine=4 startColumn=4 endColumn=44 +referenceExpression=spring.boot.starter.web referenceType=VERSION_CATALOG_ACCESSOR rawFragment=libs.spring.boot.starter.web defaultValue= resolutionKind=CATALOG_LIBRARY resolvedContext=GRADLE_CATALOG_ENTRY_5e01730d71b82e6d51d4a48c302b418d startLine=3 endLine=3 startColumn=4 endColumn=48 +referenceExpression=versions.jackson referenceType=EXT_PROPERTY_ACCESS rawFragment=${versions.jackson} defaultValue= resolutionKind=EXT_PROPERTY resolvedContext=GRADLE_DECLARATION_ee10eece5d63fa1286c69f26fa8b8fe1 startLine=26 endLine=26 startColumn=4 endColumn=75 diff --git a/parser/src/test-data/gradle/catalogs/libs.versions.toml b/parser/src/test-data/gradle/catalogs/libs.versions.toml new file mode 100644 index 000000000..f86ad89b2 --- /dev/null +++ b/parser/src/test-data/gradle/catalogs/libs.versions.toml @@ -0,0 +1,28 @@ +# Every catalog notation, plus two lines that are not valid entries. + +[versions] +plain = "1.0.0" +rich = { strictly = "[1.0, 2.0[", reject = "1.5" } +with-equals = "1.0=beta" + +[libraries] +shorthand = "com.example:shorthand:1.0" +module-literal = { module = "com.example:module-literal", version = "2.0" } +module-ref = { module = "com.example:module-ref", version.ref = "plain" } +module-no-version = { module = "com.example:bom-managed" } +group-name-literal = { group = "com.example", name = "gn-literal", version = "3.0" } +group-name-ref = { group = "com.example", name = "gn-ref", version.ref = "plain" } +wrapped = { module = "com.example:wrapped", + version.ref = "plain" } +this line is not an entry + +[bundles] +everything = ["shorthand", "module-literal"] + +[plugins] +id-literal = { id = "com.example.plugin", version = "1.2.3" } +id-ref = { id = "com.example.refplugin", version.ref = "plain" } +shorthand-plugin = "com.example.short:4.5.6" + +[unknown-table] +ignored = "should not appear" diff --git a/parser/src/test-data/gradle/edge-cases/ci.gradle b/parser/src/test-data/gradle/edge-cases/ci.gradle new file mode 100644 index 000000000..d1c1a6f40 --- /dev/null +++ b/parser/src/test-data/gradle/edge-cases/ci.gradle @@ -0,0 +1 @@ +ext.ciBuild = true diff --git a/parser/src/test-data/gradle/edge-cases/optional.gradle b/parser/src/test-data/gradle/edge-cases/optional.gradle new file mode 100644 index 000000000..30bcac88c --- /dev/null +++ b/parser/src/test-data/gradle/edge-cases/optional.gradle @@ -0,0 +1 @@ +ext.optionalApplied = true diff --git a/parser/src/test-data/gradle/edge-cases/preprocessor.gradle b/parser/src/test-data/gradle/edge-cases/preprocessor.gradle new file mode 100644 index 000000000..b2951d8ea --- /dev/null +++ b/parser/src/test-data/gradle/edge-cases/preprocessor.gradle @@ -0,0 +1,40 @@ +// Every construct here is one tree-sitter-groovy cannot parse unaided. +// Each must either round-trip intact or leave a parse gap saying what was lost. + +def emptyDefault = project.findProperty('missing') ?: '' + +def envToken = System.getenv('BUILD_TOKEN') +def sysProp = System.getProperty('java.version') +def viaProvider = providers.gradleProperty('nexusUser') + +// ─── box drawing above is deliberately non-ASCII ─── +def unicodeNote = 'héllo wörld' + +configurations.all { config -> + config.resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + +subprojects { proj -> + proj.tasks.withType(Test) { t -> + t.useJUnitPlatform() + } +} + +dependencies { + implementation "com.example:lib:${-> project.version}" + implementation 'com.example:multi:1.0:linux-x86@zip' +} + +if (project.hasProperty('ci')) { + apply from: 'ci.gradle' +} else { + apply plugin: 'maven-publish' +} + +try { + apply from: 'optional.gradle' +} catch (Exception e) { + logger.warn("optional.gradle missing") +} finally { + logger.info('done') +} diff --git a/parser/src/test-data/gradle/entry-point/notAProject/helper.gradle b/parser/src/test-data/gradle/entry-point/notAProject/helper.gradle new file mode 100644 index 000000000..8fdf8b587 --- /dev/null +++ b/parser/src/test-data/gradle/entry-point/notAProject/helper.gradle @@ -0,0 +1 @@ +ext.helperApplied = true diff --git a/parser/src/test-data/gradle/entry-point/serviceA/build.gradle b/parser/src/test-data/gradle/entry-point/serviceA/build.gradle new file mode 100644 index 000000000..49eee7402 --- /dev/null +++ b/parser/src/test-data/gradle/entry-point/serviceA/build.gradle @@ -0,0 +1,2 @@ +plugins { id 'java' } +dependencies { implementation 'com.google.guava:guava:32.1.3-jre' } diff --git a/parser/src/test-data/gradle/entry-point/serviceA/core/build.gradle b/parser/src/test-data/gradle/entry-point/serviceA/core/build.gradle new file mode 100644 index 000000000..2fcd2c385 --- /dev/null +++ b/parser/src/test-data/gradle/entry-point/serviceA/core/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation 'org.slf4j:slf4j-api:2.0.9' +} diff --git a/parser/src/test-data/gradle/entry-point/serviceA/settings.gradle b/parser/src/test-data/gradle/entry-point/serviceA/settings.gradle new file mode 100644 index 000000000..5d021e1f7 --- /dev/null +++ b/parser/src/test-data/gradle/entry-point/serviceA/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'serviceA' +include 'core' diff --git a/parser/src/test-data/gradle/entry-point/serviceA/src/main/java/com/A.java b/parser/src/test-data/gradle/entry-point/serviceA/src/main/java/com/A.java new file mode 100644 index 000000000..cec8e55c3 --- /dev/null +++ b/parser/src/test-data/gradle/entry-point/serviceA/src/main/java/com/A.java @@ -0,0 +1 @@ +package com; class A {} diff --git a/parser/src/test-data/gradle/entry-point/serviceB/build.gradle.kts b/parser/src/test-data/gradle/entry-point/serviceB/build.gradle.kts new file mode 100644 index 000000000..c65d789c7 --- /dev/null +++ b/parser/src/test-data/gradle/entry-point/serviceB/build.gradle.kts @@ -0,0 +1,2 @@ +plugins { java } +dependencies { implementation("org.apache.commons:commons-lang3:3.14.0") } diff --git a/parser/src/test-data/gradle/entry-point/serviceB/src/main/java/com/B.java b/parser/src/test-data/gradle/entry-point/serviceB/src/main/java/com/B.java new file mode 100644 index 000000000..5bf84bbf7 --- /dev/null +++ b/parser/src/test-data/gradle/entry-point/serviceB/src/main/java/com/B.java @@ -0,0 +1 @@ +package com; class B {} diff --git a/parser/src/test-data/gradle/kotlin-dsl/build.gradle.kts b/parser/src/test-data/gradle/kotlin-dsl/build.gradle.kts new file mode 100644 index 000000000..523d4dccd --- /dev/null +++ b/parser/src/test-data/gradle/kotlin-dsl/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + java + id("org.springframework.boot") version "3.2.2" + alias(libs.plugins.boot) +} + +val springVersion: String by extra("6.1.3") +val nexusUrl: String by project + +group = "com.example" +version = "2.0.0" + +repositories { + mavenCentral() + maven { + url = uri("https://repo.example.com/releases") + credentials(HttpHeaderCredentials::class) { + name = "Authorization" + } + } +} + +dependencies { + implementation("org.springframework:spring-core:$springVersion") + implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.2")) + testImplementation(kotlin("test")) + implementation(libs.jackson.databind) +} + +tasks.named("test") { + useJUnitPlatform() +} + +tasks.register("copyReports") { + from("build/reports") +} + +val buildNumber = (findProperty("buildNumber") as String?) ?: "0" diff --git a/parser/src/test-data/gradle/kotlin-dsl/settings.gradle.kts b/parser/src/test-data/gradle/kotlin-dsl/settings.gradle.kts new file mode 100644 index 000000000..2c925cf7d --- /dev/null +++ b/parser/src/test-data/gradle/kotlin-dsl/settings.gradle.kts @@ -0,0 +1,3 @@ +rootProject.name = "kotlin-fixture" + +include(":service") diff --git a/parser/src/test-data/gradle/multi-project/app/build.gradle b/parser/src/test-data/gradle/multi-project/app/build.gradle new file mode 100644 index 000000000..093fb7965 --- /dev/null +++ b/parser/src/test-data/gradle/multi-project/app/build.gradle @@ -0,0 +1,14 @@ +dependencies { + implementation project(':core') + implementation libs.spring.boot.starter.web + implementation libs.bundles.persistence + testImplementation testFixtures(project(':core')) +} + +def ciToken = System.getenv('CI_TOKEN') +def nexusUser = project.findProperty('nexusUser') ?: 'anonymous' + +dependencies { + implementation projects.core + implementation projects.core.dataTest +} diff --git a/parser/src/test-data/gradle/multi-project/build.gradle b/parser/src/test-data/gradle/multi-project/build.gradle new file mode 100644 index 000000000..c464043a1 --- /dev/null +++ b/parser/src/test-data/gradle/multi-project/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.2' apply false +} + +ext { + guavaVersion = '32.1.3-jre' + versions = [jackson: '2.15.3', caffeine: '3.1.8'] +} + +group = 'com.example' +version = '1.0.0' + +def localOnly = 'not-visible-elsewhere' + +allprojects { + repositories { + mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + flatDir { dirs 'libs' } + } +} + +dependencies { + implementation "com.google.guava:guava:${guavaVersion}" + implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + api 'org.apache.commons:commons-lang3:3.14.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' +} + +task hello { + doLast { println 'hi' } +} + +tasks.register('copyDocs', Copy) { + from 'docs' +} + +configurations { + smokeTest.extendsFrom testImplementation +} diff --git a/parser/src/test-data/gradle/multi-project/buildSrc/build.gradle b/parser/src/test-data/gradle/multi-project/buildSrc/build.gradle new file mode 100644 index 000000000..993770a23 --- /dev/null +++ b/parser/src/test-data/gradle/multi-project/buildSrc/build.gradle @@ -0,0 +1,4 @@ +// buildSrc builds the build, not the product +dependencies { + implementation 'com.google.code.gson:gson:2.10.1' +} diff --git a/parser/src/test-data/gradle/multi-project/core/build.gradle b/parser/src/test-data/gradle/multi-project/core/build.gradle new file mode 100644 index 000000000..a2b438466 --- /dev/null +++ b/parser/src/test-data/gradle/multi-project/core/build.gradle @@ -0,0 +1,16 @@ +// core is the shared library module +dependencies { + // pinned deliberately: see ticket BUILD-91 + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation platform('org.springframework.boot:spring-boot-dependencies:3.2.2') + implementation 'org.springframework:spring-core' + implementation files('libs/legacy.jar', 'libs/extra.jar') + implementation group: 'org.slf4j', name: 'slf4j-api', version: '2.0.9' + runtimeOnly 'com.h2database:h2:2.2.224:tests@jar' + + implementation('com.example:noisy:1.0') { + exclude group: 'org.unwanted', module: 'bad-transitive' + } + + // implementation 'org.removed:removed:1.0' +} diff --git a/parser/src/test-data/gradle/multi-project/core/data-test/build.gradle b/parser/src/test-data/gradle/multi-project/core/data-test/build.gradle new file mode 100644 index 000000000..651ab6718 --- /dev/null +++ b/parser/src/test-data/gradle/multi-project/core/data-test/build.gradle @@ -0,0 +1 @@ +dependencies { implementation 'org.junit.jupiter:junit-jupiter:5.10.1' } diff --git a/parser/src/test-data/gradle/multi-project/gradle/libs.versions.toml b/parser/src/test-data/gradle/multi-project/gradle/libs.versions.toml new file mode 100644 index 000000000..4beb12653 --- /dev/null +++ b/parser/src/test-data/gradle/multi-project/gradle/libs.versions.toml @@ -0,0 +1,18 @@ +[versions] +spring-boot = "3.2.2" +jackson = "2.15.3" +strict-guava = { strictly = "[32.0, 33.0[", prefer = "32.1.3-jre" } + +[libraries] +spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } +jackson-databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson" } +commons-lang3 = "org.apache.commons:commons-lang3:3.14.0" +bom-managed = { module = "org.springframework:spring-core" } +dangling-ref = { module = "com.example:dangling", version.ref = "does-not-exist" } + +[bundles] +persistence = ["jackson-databind", "commons-lang3"] + +[plugins] +boot = { id = "org.springframework.boot", version.ref = "spring-boot" } +kotlin-jvm = "org.jetbrains.kotlin.jvm:1.9.22" diff --git a/parser/src/test-data/gradle/multi-project/settings.gradle b/parser/src/test-data/gradle/multi-project/settings.gradle new file mode 100644 index 000000000..efd706c71 --- /dev/null +++ b/parser/src/test-data/gradle/multi-project/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'fixture-build' + +include 'core' +include ':app' +include ':core:data-test' + +includeBuild '../shared-build' diff --git a/parser/src/test-data/java/annotations/MarkerAnnotationTest.java b/parser/src/test-data/java/annotations/MarkerAnnotationTest.java new file mode 100644 index 000000000..35253b228 --- /dev/null +++ b/parser/src/test-data/java/annotations/MarkerAnnotationTest.java @@ -0,0 +1,21 @@ +package com.test.annotations; + +@Deprecated +public class MarkerAnnotationTest { + + @SuppressWarnings + private String value; + + @Deprecated + public void deprecatedMethod() {} + + @Override + public String toString() { + return "MarkerAnnotationTest"; + } +} + +@FunctionalInterface +interface MarkerInterface { + void execute(); +} diff --git a/parser/src/test-data/java/annotations/NamedArgAnnotationTest.java b/parser/src/test-data/java/annotations/NamedArgAnnotationTest.java new file mode 100644 index 000000000..e7d237bcd --- /dev/null +++ b/parser/src/test-data/java/annotations/NamedArgAnnotationTest.java @@ -0,0 +1,21 @@ +package com.test.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@interface Config { + String name(); + int priority() default 0; + String description() default ""; +} + +@Config(name = "MainService", priority = 10, description = "Primary service") +public class NamedArgAnnotationTest { + + @Config(name = "field1", priority = 5) + private String data; + + @Config(name = "processor", description = "Processes data") + public void process() {} +} diff --git a/parser/src/test-data/java/annotations/OldClass.java b/parser/src/test-data/java/annotations/OldClass.java new file mode 100644 index 000000000..9f922a7eb --- /dev/null +++ b/parser/src/test-data/java/annotations/OldClass.java @@ -0,0 +1,20 @@ +package com.test.annotations; + +@Deprecated +public class OldClass { } + +@interface Deprecated { } + +@interface Nullable { } + +@interface Override { } + +@Nullable +class NullableClass { } + +@Override +class OverrideClass { } + +@Deprecated +@Nullable +class MultipleMarkers { } diff --git a/parser/src/test-data/java/annotations/ParameterAnnotationTest.java b/parser/src/test-data/java/annotations/ParameterAnnotationTest.java new file mode 100644 index 000000000..3075f2c49 --- /dev/null +++ b/parser/src/test-data/java/annotations/ParameterAnnotationTest.java @@ -0,0 +1,25 @@ +package com.inventory.auth.examples; + +import java.util.List; + +public class ParameterAnnotationTest { + + // Simple parameter annotation + public void methodWithAnnotatedParam(@Deprecated String name) { + System.out.println(name); + } + + // Multiple parameter annotations + public void methodWithMultipleAnnotations( + @Deprecated String first, + @SuppressWarnings("unchecked") List items + ) { + System.out.println(first + items); + } + + // Varargs with annotation + @SafeVarargs + public final void methodWithAnnotatedVarargs(T... values) { + System.out.println(values); + } +} diff --git a/parser/src/test-data/java/annotations/SingleValueAnnotationTest.java b/parser/src/test-data/java/annotations/SingleValueAnnotationTest.java new file mode 100644 index 000000000..b8ec91ffe --- /dev/null +++ b/parser/src/test-data/java/annotations/SingleValueAnnotationTest.java @@ -0,0 +1,28 @@ +package com.test.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@interface Named { + String value(); +} + +@Retention(RetentionPolicy.RUNTIME) +@interface Priority { + int value(); +} + +@Named("MainClass") +@Priority(100) +public class SingleValueAnnotationTest { + + @Named("importantField") + private String data; + + @Priority(50) + public void process() {} + + @SuppressWarnings("unchecked") + public void suppressedMethod() {} +} diff --git a/parser/src/test-data/java/annotations/TimedClass.java b/parser/src/test-data/java/annotations/TimedClass.java new file mode 100644 index 000000000..42675f855 --- /dev/null +++ b/parser/src/test-data/java/annotations/TimedClass.java @@ -0,0 +1,19 @@ +package com.test.annotations; + +@interface Timeout { + int value(); +} + +@interface Priority { + int value(); +} + +@Timeout(5000) +public class TimedClass { } + +@Priority(10) +class PriorityClass { } + +@Timeout(3000) +@Priority(5) +class MultipleSingleValue { } diff --git a/parser/src/test-data/java/annotations/TypeUseAnnotationPatterns.java b/parser/src/test-data/java/annotations/TypeUseAnnotationPatterns.java new file mode 100644 index 000000000..e9c92c458 --- /dev/null +++ b/parser/src/test-data/java/annotations/TypeUseAnnotationPatterns.java @@ -0,0 +1,308 @@ +package com.inventory.auth.examples4; + +import java.io.IOException; +import java.io.Serializable; +import java.sql.SQLException; +import java.lang.annotation.*; +import java.util.List; +import java.util.Map; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; + +/** + * Comprehensive examples of TYPE_USE annotations in method declarations. + * + * Java 8+ allows annotations on type uses, not just declarations. + * These annotations can appear on: + * - Return types + * - Parameter types + * - Throws clause types + * - Generic type arguments + * - Array component types + * - Wildcards + */ +public class TypeUseAnnotationPatterns { + + // ============================================================ + // Custom TYPE_USE annotations for testing + // ============================================================ + + @Target(ElementType.TYPE_USE) + @Retention(RetentionPolicy.RUNTIME) + public @interface NonNull {} + + @Target(ElementType.TYPE_USE) + @Retention(RetentionPolicy.RUNTIME) + public @interface Nullable {} + + @Target(ElementType.TYPE_USE) + @Retention(RetentionPolicy.RUNTIME) + public @interface Valid {} + + @Target(ElementType.TYPE_USE) + @Retention(RetentionPolicy.RUNTIME) + public @interface Size { + int min() default 0; + int max() default Integer.MAX_VALUE; + } + + @Target(ElementType.TYPE_USE) + @Retention(RetentionPolicy.RUNTIME) + public @interface Critical {} + + @Target(ElementType.TYPE_USE) + @Retention(RetentionPolicy.RUNTIME) + public @interface Immutable {} + + @Target(ElementType.TYPE_USE) + @Retention(RetentionPolicy.RUNTIME) + public @interface ReadOnly {} + + @Target(ElementType.TYPE_USE) + @Retention(RetentionPolicy.RUNTIME) + public @interface Validated { + Class validator() default Object.class; + } + + @Target({ElementType.TYPE_USE, ElementType.PARAMETER}) + @Retention(RetentionPolicy.RUNTIME) + public @interface NotEmpty {} + + // ============================================================ + // RETURN TYPE ANNOTATIONS + // ============================================================ + + // Simple annotation on return type + public @NonNull String getRequiredName() { + return "name"; + } + + // Annotation on parameterized return type + public @NonNull List getRequiredList() { + return List.of(); + } + + // Annotation on generic type argument of return type + public List<@NonNull String> getListOfNonNullStrings() { + return List.of(); + } + + // Multiple annotations on return type + public @NonNull @Immutable String getImmutableName() { + return "immutable"; + } + + // Annotation with arguments on return type + public @Size(min = 1, max = 100) String getSizedString() { + return "sized"; + } + + // Nested generic return type with annotations + public Map<@NonNull String, @Valid List<@Size(max = 50) String>> getComplexMap() { + return Map.of(); + } + + // Array return type with annotation + public @NonNull String[] getRequiredArray() { + return new String[0]; + } + + // Array component type annotation + public String @NonNull [] getArrayWithNonNullElements() { + return new String[0]; + } + + // Wildcard in return type with annotation + public List<@NonNull ? extends Number> getWildcardList() { + return List.of(); + } + + // CompletableFuture with annotated type argument + public CompletableFuture<@NonNull String> getAsyncResult() { + return CompletableFuture.completedFuture("result"); + } + + // ============================================================ + // PARAMETER TYPE ANNOTATIONS + // ============================================================ + + // Simple annotation on parameter type + public void processName(@NonNull String name) { + // process + } + + // Annotation on generic type argument + public void processList(List<@NonNull String> items) { + // process + } + + // Multiple annotations on parameter type + public void processValidated(@NonNull @Valid String data) { + // process + } + + // Annotation with arguments on parameter type + public void processSized(@Size(min = 5, max = 255) String text) { + // process + } + + // Complex nested annotations in parameter + public void processComplexParam( + Map<@NonNull String, List<@Valid @Size(max = 100) String>> complexMap + ) { + // process + } + + // Multiple parameters with various annotations + public void processMultiple( + @NonNull String required, + @Nullable String optional, + List<@Valid String> validItems, + @Size(max = 10) String limited + ) { + // process + } + + // Array parameter with annotations + public void processArray(@NonNull String @Size(min = 1) [] items) { + // process + } + + // Varargs with annotation + public void processVarargs(@NonNull String... messages) { + // process + } + + // Wildcard parameter with annotation + public void processWildcard(List<@NonNull ? extends Comparable> items) { + // process + } + + // ============================================================ + // THROWS CLAUSE ANNOTATIONS + // ============================================================ + + // Single annotated exception + public void mayThrowCritical() throws @Critical IOException { + throw new IOException("critical"); + } + + // Multiple annotated exceptions + public void mayThrowMultiple() throws @Critical IOException, @NonNull SQLException { + throw new IOException("error"); + } + + // Mix of annotated and non-annotated exceptions + public void mayThrowMixed() throws @Critical IOException, RuntimeException { + throw new IOException("mixed"); + } + + // Annotation with arguments on exception + public void mayThrowValidated() throws @Validated(validator = Exception.class) Exception { + throw new Exception("validated"); + } + + // ============================================================ + // COMBINED PATTERNS + // ============================================================ + + // Return type + parameter annotations + public @NonNull String transformName(@NonNull String input) { + return input.toUpperCase(); + } + + // Return type + parameter + throws annotations + public @NonNull String parseData(@NonNull @Valid String data) + throws @Critical IOException { + return data; + } + + // Full method with all annotation types + public @NonNull @Immutable Map<@NonNull String, @Valid List<@Size(max = 50) String>> + processFullyAnnotated( + @NonNull String key, + List<@Valid @NotEmpty String> values, + @Nullable Map options + ) throws @Critical IOException, @NonNull SQLException { + return Map.of(); + } + + // ============================================================ + // GENERIC METHOD TYPE PARAMETERS WITH TYPE_USE ANNOTATIONS + // ============================================================ + + // Method type parameter bound with TYPE_USE annotation + public T processNumber(T value) { + return value; + } + + // Multiple bounds with annotations + public & @Immutable Serializable> T sortableItem(T item) { + return item; + } + + // Return type using annotated method type parameter + public @NonNull T ensureNonNull(T value) { + return value; + } + + // Complex generic method with annotations + public > + Map createAnnotatedMap(K key, V value) { + return Map.of(key, value); + } + + // ============================================================ + // INNER CLASS WITH TYPE_USE ANNOTATIONS + // ============================================================ + + public class AnnotatedProcessor { + + // Field with TYPE_USE annotation (for completeness) + private @NonNull T data; + + public AnnotatedProcessor(@NonNull T initialData) { + this.data = initialData; + } + + // Method in inner class with TYPE_USE annotations + public @NonNull T getData() { + return data; + } + + public void setData(@NonNull T newData) { + this.data = newData; + } + + // Method with annotated wildcard + public void processItems(List<@NonNull ? super T> items) { + // process + } + } + + // ============================================================ + // STATIC METHODS WITH TYPE_USE ANNOTATIONS + // ============================================================ + + public static @NonNull String staticNonNullMethod() { + return "static"; + } + + public static @NonNull List<@Valid T> staticGenericMethod(@NonNull T item) { + return List.of(item); + } + + // ============================================================ + // INTERFACE WITH TYPE_USE ANNOTATIONS + // ============================================================ + + public interface AnnotatedService { + @NonNull T process(@NonNull T input) throws @Critical Exception; + + default @Nullable T processOptional(@Nullable T input) { + return input; + } + + List<@NonNull T> processAll(List<@NonNull T> items); + } +} diff --git a/parser/src/test-data/java/annotations/User.java b/parser/src/test-data/java/annotations/User.java new file mode 100644 index 000000000..3469c6452 --- /dev/null +++ b/parser/src/test-data/java/annotations/User.java @@ -0,0 +1,21 @@ +package com.test.annotations; + +@interface Table { + String name(); + String schema() default ""; +} + +@interface Column { + String name(); + boolean nullable() default true; + int length() default 255; +} + +@Table(name = "users", schema = "public") +public class User { } + +@Column(name = "id", nullable = false, length = 36) +class IdColumn { } + +@Table(name = "orders") +class OrderWithDefault { } diff --git a/parser/src/test-data/java/annotations/ValueTypes.java b/parser/src/test-data/java/annotations/ValueTypes.java new file mode 100644 index 000000000..9552becfd --- /dev/null +++ b/parser/src/test-data/java/annotations/ValueTypes.java @@ -0,0 +1,34 @@ +package com.test.annotations; + +@interface AllTypes { + String stringVal(); + int intVal(); + long longVal(); + float floatVal(); + boolean boolVal(); + char charVal(); + Class classVal(); + RetentionPolicy enumVal(); + int exprVal(); + Nested nestedVal(); +} + +@interface Nested { } + +enum RetentionPolicy { + SOURCE, CLASS, RUNTIME +} + +@AllTypes( + stringVal = "test", + intVal = 42, + longVal = 999L, + floatVal = 3.14f, + boolVal = true, + charVal = 'x', + classVal = String.class, + enumVal = RetentionPolicy.RUNTIME, + exprVal = 60 * 1000, + nestedVal = @Nested +) +public class ValueTypes { } diff --git a/parser/src/test-data/java/annotations/test-all-value-types.java b/parser/src/test-data/java/annotations/test-all-value-types.java new file mode 100644 index 000000000..e69de29bb diff --git a/parser/src/test-data/java/annotations/test-array-annotations.java b/parser/src/test-data/java/annotations/test-array-annotations.java new file mode 100644 index 000000000..1b78399a7 --- /dev/null +++ b/parser/src/test-data/java/annotations/test-array-annotations.java @@ -0,0 +1,21 @@ +package com.test.annotations; + +@interface Target { + ElementType[] value(); +} + +enum ElementType { + TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR +} + +@Target(ElementType.TYPE) +public @interface SingleElementArray { } + +@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.FIELD }) +public @interface MultiElementArray { } + +@Target({ ElementType.TYPE }) +class ExplicitSingleArray { } + +@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.CONSTRUCTOR }) +class AllElements { } diff --git a/parser/src/test-data/java/annotations/test-meta-annotations.java b/parser/src/test-data/java/annotations/test-meta-annotations.java new file mode 100644 index 000000000..65d0ee250 --- /dev/null +++ b/parser/src/test-data/java/annotations/test-meta-annotations.java @@ -0,0 +1,31 @@ +package com.test.annotations; + +@interface Retention { + RetentionPolicy value(); +} + +@interface Target { + ElementType[] value(); +} + +enum RetentionPolicy { + SOURCE, CLASS, RUNTIME +} + +enum ElementType { + TYPE, FIELD, METHOD, PARAMETER +} + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface MyAnnotation { } + +@Retention(RetentionPolicy.SOURCE) +public @interface SourceAnnotation { } + +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface MultiTargetAnnotation { } + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD }) +public @interface CompleteAnnotation { } diff --git a/parser/src/test-data/java/annotations/test-nested-annotations.java b/parser/src/test-data/java/annotations/test-nested-annotations.java new file mode 100644 index 000000000..50e93d7b1 --- /dev/null +++ b/parser/src/test-data/java/annotations/test-nested-annotations.java @@ -0,0 +1,38 @@ +package com.test.annotations; + +@interface Something { + Other meta(); +} + +@interface Other { } + +@interface SofaService { + Class interfaceType(); + SofaServiceBinding bindings(); +} + +@interface SofaServiceBinding { + String bindingType(); +} + +class UserService { } + +@Something(meta = @Other) +public class SimpleNested { } + +@SofaService( + interfaceType = UserService.class, + bindings = @SofaServiceBinding(bindingType = "bolt") +) +public class ServiceImpl { } + +@interface Complex { + Nested nested(); +} + +@interface Nested { + String value(); +} + +@Complex(nested = @Nested("test")) +class ComplexNested { } diff --git a/parser/src/test-data/java/annotations/test-type-param-annotations.java b/parser/src/test-data/java/annotations/test-type-param-annotations.java new file mode 100644 index 000000000..36aab29eb --- /dev/null +++ b/parser/src/test-data/java/annotations/test-type-param-annotations.java @@ -0,0 +1,25 @@ +package com.test.annotations; + +@interface NonNull { } + +@interface Validated { + Class validator() default Object.class; +} + +class SizeValidator { } + +class SimpleTypeParamAnnotation<@NonNull T> { } + +class ParameterizedTypeParamAnnotation<@Validated(validator = SizeValidator.class) U> { } + +class MultipleTypeParamsWithAnnotations< + @NonNull T, + @Validated(validator = SizeValidator.class) U, + V +> { } + +class MixedAnnotatedAndUnannotated< + @NonNull T extends Number, + U, + @Validated(validator = SizeValidator.class) V extends Comparable +> { } diff --git a/parser/src/test-data/java/blocks/AdvancedExceptionHandling.java b/parser/src/test-data/java/blocks/AdvancedExceptionHandling.java new file mode 100644 index 000000000..ad9498482 --- /dev/null +++ b/parser/src/test-data/java/blocks/AdvancedExceptionHandling.java @@ -0,0 +1,668 @@ +package com.inventory.auth.examples24; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.*; +import java.util.stream.*; + +import com.inventory.auth.examples24.CustomTypes.*; + +/** + * Advanced exception handling examples with ExecutorService, custom types, + * and realistic business logic patterns. + */ +public class AdvancedExceptionHandling { + + private final ExecutorService executorService; + private final DatabaseConnection databaseConnection; + private final AtomicInteger processedCount = new AtomicInteger(0); + private final AtomicInteger errorCount = new AtomicInteger(0); + + public AdvancedExceptionHandling(String dbConnectionString) throws ConnectionFailedException { + this.executorService = Executors.newFixedThreadPool(4); + this.databaseConnection = new DatabaseConnection(dbConnectionString); + this.databaseConnection.connect(); + } + + // ========== EXECUTOR SERVICE WITH TRY-CATCH ========== + + /** + * Process orders concurrently with ExecutorService, handling exceptions inside tasks + * and wrapping the entire operation in try-catch. + */ + public BatchResult processOrdersConcurrently(List orderIds) { + List> results = new ArrayList<>(); + List>> futures = new ArrayList<>(); + final String orderId = orderIds.get(0); + + try { + // Submit all tasks + Future> future = executorService.submit(() -> { + try { + Order order = databaseConnection.findOrderById(orderId); + Result validationResult = validateOrder(order); + + validationResult.failure(null); + validationResult.success(null); + + // if (validationResult.isFailure()) { + // return validationResult; + // } + + try (TransactionContext txn = new TransactionContext("TXN-" + orderId)) { + final int tempInt = 1000; + processOrderItems(order); + order.setStatus(OrderStatus.PROCESSING); + databaseConnection.saveOrder(order); + txn.commit(); + processedCount.incrementAndGet(); + return Result.success(order.toString() + " " + tempInt); + } catch (DataProcessingException e) { + String errorDetails = "Failed to process order " + orderId + ": " + e.getErrorCode(); + errorCount.incrementAndGet(); + return Result.failure(e); + } + } catch (ResourceNotFoundException e) { + String notFoundMsg = "Order not found: " + e.getResourceId(); + return Result.failure(e); + } catch (DataProcessingException e) { + String dataError = "Data error for order " + orderId; + return Result.failure(e); + } catch (Exception e) { + String unexpectedError = "Unexpected error processing " + orderId; + return Result.failure(e); + } + }); + futures.add(future); + + + // Collect results + // for (Future> future : futures) { + // try { + // Result result = future.get(30, TimeUnit.SECONDS); + // results.add(result); + // } catch (TimeoutException e) { + // String timeoutMsg = "Task timed out"; + // future.cancel(true); + // results.add(Result.failure(e)); + // } catch (InterruptedException e) { + // String interruptedMsg = "Task interrupted"; + // Thread.currentThread().interrupt(); + // results.add(Result.failure(e)); + // } catch (ExecutionException e) { + // String executionError = "Task execution failed: " + e.getCause().getMessage(); + // results.add(Result.failure((Exception) e.getCause())); + // } + // } + } catch (RejectedExecutionException e) { + String rejectedMsg = "Executor rejected task submission"; + throw new RuntimeException("Failed to process orders", e); + } finally { + int totalProcessed = processedCount.get(); + int totalErrors = errorCount.get(); + String summary = String.format("Processed: %d, Errors: %d", totalProcessed, totalErrors); + } + + return new BatchResult<>(results); + } + + /** + * Scheduled task execution with exception handling + */ + public void scheduleOrderCleanup(ScheduledExecutorService scheduler) { + try { + ScheduledFuture cleanupFuture = scheduler.scheduleAtFixedRate(() -> { + try { + List staleOrders = findStaleOrders(); + Order order = staleOrders.get(0); + try (TransactionContext txn = new TransactionContext("CLEANUP-" + order.getOrderId())) { + order.setStatus(OrderStatus.CANCELLED); + databaseConnection.saveOrder(order); + txn.commit(); + } catch (DataProcessingException e) { + String cleanupError = "Failed to cleanup order: " + order.getOrderId(); + } + } catch (Exception e) { + String schedulerError = "Cleanup task failed: " + e.getMessage(); + } + }, 0, 1, TimeUnit.HOURS); + + String scheduledInfo = "Cleanup scheduled: " + cleanupFuture.getDelay(TimeUnit.MINUTES) + " min"; + } catch (RejectedExecutionException e) { + String rejectionError = "Failed to schedule cleanup task"; + throw e; + } + } + + /** + * CompletableFuture chain with exception handling + */ + public CompletableFuture> processOrderAsync(String orderId) { + return CompletableFuture.supplyAsync(() -> { + try { + Order order = databaseConnection.findOrderById(orderId); + order.setStatus(OrderStatus.PROCESSING); + return order; + } catch (DataProcessingException | ResourceNotFoundException e) { + String fetchError = "Failed to fetch order: " + orderId; + throw new CompletionException(e); + } + }, executorService) + .thenApplyAsync(order -> { + try { + Result validation = validateOrder(order); + if (validation.isFailure()) { + throw new CompletionException(validation.getError()); + } + return order; + } catch (Exception e) { + String validationError = "Validation failed for: " + order.getOrderId(); + throw new CompletionException(e); + } + }, executorService) + .thenApplyAsync(order -> { + try (TransactionContext txn = new TransactionContext("ASYNC-" + order.getOrderId())) { + processOrderItems(order); + order.setStatus(OrderStatus.CONFIRMED); + databaseConnection.saveOrder(order); + txn.commit(); + return Result.success(order); + } catch (DataProcessingException e) { + String processError = "Processing failed: " + e.getErrorCode(); + return Result.failure(e); + } + }, executorService) + .exceptionally(throwable -> { + Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable; + String asyncError = "Async processing failed: " + cause.getMessage(); + return Result.failure(cause instanceof Exception ? (Exception) cause : new Exception(cause)); + }); + } + + // ========== COMPLEX NESTED EXCEPTION HANDLING ========== + + /** + * Multi-level retry with different exception types + */ + public T executeWithRetry( + Supplier operation, + int maxRetries, + Class... retryableExceptions + ) throws Exception { + Exception lastException = null; + Set> retryableSet = new HashSet<>(Arrays.asList(retryableExceptions)); + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + T result = operation.get(); + String successMsg = "Operation succeeded on attempt " + attempt; + return result; + } catch (Exception e) { + lastException = e; + boolean isRetryable = retryableSet.stream().anyMatch(c -> c.isInstance(e)); + + if (!isRetryable) { + String nonRetryableError = "Non-retryable exception: " + e.getClass().getName(); + someMetod(); + anyOtherMethod.forEach((item) -> { + final String result = item.toString(); + return result; + }); + throw e; + } + + if (attempt < maxRetries) { + try { + long backoffMs = (long) Math.pow(2, attempt) * 100; + String retryInfo = String.format("Retry %d/%d after %dms", attempt, maxRetries, backoffMs); + Thread.sleep(backoffMs); + } catch (InterruptedException ie) { + String interruptedDuringBackoff = "Interrupted during backoff"; + Thread.currentThread().interrupt(); + throw ie; + } + } + } + } + + String exhaustedRetries = "Exhausted all " + maxRetries + " retries"; + throw lastException; + } + + /** + * Transaction with savepoint and partial rollback + */ + public void processOrderWithSavepoints(Order order) throws DataProcessingException { + try (TransactionContext mainTxn = new TransactionContext("MAIN-" + order.getOrderId())) { + try { + // Phase 1: Validate inventory + // for (OrderItem item : order.getItems()) { + // try { + // validateInventory(item); + // } catch (ValidationException e) { + // String inventoryError = "Inventory validation failed for: " + item.getProductId(); + // List errors = e.getValidationErrors(); + // throw new DataProcessingException("Inventory check failed", "INV_001", item, e); + // } + // } + + // Phase 2: Reserve inventory (can partially fail) + List reservedItems = new ArrayList<>(); + try { + for (OrderItem item : order.getItems()) { + try { + reserveInventory(item); + reservedItems.add(item); + } catch (DataProcessingException e) { + String reserveError = "Failed to reserve: " + item.getProductId(); + // Rollback reserved items + for (OrderItem reserved : reservedItems) { + try { + releaseInventory(reserved); + } catch (Exception releaseEx) { + String releaseError = "Failed to release: " + reserved.getProductId(); + } + } + throw e; + } + } + } catch (DataProcessingException e) { + String phaseError = "Phase 2 failed, rolled back reservations"; + throw e; + } + + // Phase 3: Charge payment + try { + chargePayment(order); + } catch (DataProcessingException e) { + String paymentError = "Payment failed, releasing inventory"; + for (OrderItem reserved : reservedItems) { + try { + releaseInventory(reserved); + } catch (Exception releaseEx) { + String releaseError = "Compensation failed for: " + reserved.getProductId(); + } + } + throw e; + } + + order.setStatus(OrderStatus.CONFIRMED); + databaseConnection.saveOrder(order); + mainTxn.commit(); + + } catch (DataProcessingException e) { + String txnError = "Transaction failed: " + e.getErrorCode(); + throw e; + } + } + } + + /** + * Parallel stream processing with exception aggregation + */ + public BatchResult processUsersInParallel(List userIds) { + List> results = Collections.synchronizedList(new ArrayList<>()); + AtomicReference firstException = new AtomicReference<>(); + + try { + userIds.parallelStream().forEach(userId -> { + try { + User user = databaseConnection.findUserById(userId); + + try { + validateUser(user); + results.add(Result.success(user)); + } catch (ValidationException e) { + String userValidationError = "User validation failed: " + userId; + results.add(Result.failure(e)); + } + } catch (DataProcessingException e) { + String userFetchError = "Failed to fetch user: " + userId; + results.add(Result.failure(e)); + firstException.compareAndSet(null, e); + } + }); + } catch (Exception e) { + String parallelError = "Parallel processing failed: " + e.getMessage(); + throw e; + } + + return new BatchResult<>(results); + } + + class Temop implements AutoCloseable { + @Override + public void close() { + + } + } + + // ========== RESOURCE MANAGEMENT PATTERNS ========== + + /** + * Multiple resources with interdependencies + */ + public void processFileWithDatabase(String inputFile, String outputFile) + throws DataProcessingException, ConnectionFailedException { + + try (final com.inventory.auth.examples24.CustomTypes.FileProcessor + inputProcessor = new FileProcessor(inputFile); + final FileProcessor outputProcessor = new FileProcessor(outputFile); + final DatabaseConnection localDb = new DatabaseConnection("local-db")) { + + inputProcessor.openForReading(); + outputProcessor.openForWriting(); + localDb.connect(); + + try { + List lines = inputProcessor.readAllLines(); + + final String line = lines.get(0); + + try { + String[] parts = line.split(","); + String userId = parts[0]; + + try { + User user = localDb.findUserById(userId); + String outputLine = String.format("%s,%s,%s", + user.getId(), line, user.getName()); + outputProcessor.writeLine(outputLine); + } catch (DataProcessingException e) { + String lookupError = "Failed to lookup user: " + userId; + outputProcessor.writeLine("ERROR:" + userId); + } + } catch (ArrayIndexOutOfBoundsException | IllegalAccessError e) { + if(e instanceof ArrayIndexOutOfBoundsException) { + throw e; + } else { + throw e; + } + String parseError = "Invalid line format: " + line; + } + + // for (String line : lines) { + + // } + } catch (java.io.IOException e) { + String ioError = "I/O error during processing: " + e.getMessage(); + throw new DataProcessingException("File processing failed", "FILE_001", inputFile, e); + } + } catch (java.io.IOException e) { + String resourceError = "Failed to manage resources: " + e.getMessage(); + throw new DataProcessingException("Resource error", "RES_001", null, e); + } + } + + /** + * Fork-join pool with exception handling + * Tests deep lambda nesting: Wave 1 (method) -> Wave 2 (submit) -> Wave 3 (map) -> Wave 4 (transform) -> Wave 5 (validate) + */ + public List> processOrdersWithForkJoin(List orderIds) { + ForkJoinPool customPool = new ForkJoinPool(4); + + int order = 20; + + try { + return customPool.submit(() -> { // Wave 2: outer lambda + return orderIds.parallelStream() + .map(orderId -> { // Wave 3: map lambda + try { + Order order = databaseConnection.findOrderById(orderId); + + try (TransactionContext txn = new TransactionContext("FJ-" + orderId)) { + processOrderItems(order); + order.setStatus(OrderStatus.PROCESSING); + + Supplier> transformer = () -> { // Wave 4: transformer lambda + try { + databaseConnection.saveOrder(order); + txn.commit(); + + Supplier supplierCheckingOut = () -> { + var result = switch (value) { + case String s -> s.toUpperCase(); // pattern binding 's' tracked + case Integer i -> String.valueOf(i); + default -> "unknown"; + }; + return result; + }; + + Supplier> validator = () -> { // Wave 5: validator lambda + try { + String validationMsg = "Validating order: " + orderId; + return Result.success(order); + } catch (Exception e) { + String validationError = "Validation failed: " + orderId; + return Result.failure(e); + } + }; + return validator.get(); + + } catch (DataProcessingException e) { + String transformError = "Transform failed: " + orderId; + return Result.failure(e); + } + }; + return transformer.get(); + + } catch (DataProcessingException e) { + String txnError = "Transaction failed in fork-join: " + orderId; + return Result.failure(e); + } + } catch (DataProcessingException | ResourceNotFoundException e) { + String forkJoinError = "Fork-join task failed: " + orderId; + return Result.failure(e); + } + }) + .collect(Collectors.toList()); + }).get(60, TimeUnit.SECONDS); + } catch (InterruptedException e) { + String interruptError = "Fork-join interrupted"; + Thread.currentThread().interrupt(); + throw new RuntimeException("Processing interrupted", e); + } catch (ExecutionException e) { + String execError = "Fork-join execution failed"; + throw new RuntimeException("Processing failed", e.getCause()); + } catch (TimeoutException e) { + String timeoutError = "Fork-join timed out"; + throw new RuntimeException("Processing timed out", e); + } finally { + customPool.shutdown(); + try { + if (!customPool.awaitTermination(10, TimeUnit.SECONDS)) { + customPool.shutdownNow(); + String forcedShutdown = "Forced pool shutdown"; + } + } catch (InterruptedException e) { + customPool.shutdownNow(); + String shutdownInterrupted = "Shutdown interrupted"; + Thread.currentThread().interrupt(); + } + } + } + + // ========== CLEANUP ========== + + public void shutdown() { + try { + executorService.shutdown(); + if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + String forcedShutdown = "Forced executor shutdown"; + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + String shutdownError = "Shutdown interrupted"; + Thread.currentThread().interrupt(); + } finally { + try { + databaseConnection.close(); + } catch (Exception e) { + String closeError = "Failed to close database connection"; + } + } + } + + // ========== HELPER METHODS ========== + + private Result validateOrder(Order order) { + List errors = new ArrayList<>(); + + for(int i = 0; i < order.getItems().size(); i++) { + if(order.getItems().get(i).getQuantity() <= 0) { + errors.add("Item " + i + " has invalid quantity"); + } + } + if (order.getItems().isEmpty()) + errors.add("Order has no items"); + + if (order.getTotalAmount() <= 0) { + errors.add("Order total must be positive"); + } + + if (!errors.isEmpty()) { + return Result.failure(new ValidationException("Order validation failed", errors)); + } + return Result.success(order); + } + + private void validateUser(User user) throws ValidationException { + List errors = new ArrayList<>(); + if (user.getEmail() == null || !user.getEmail().contains("@")) { + errors.add("Invalid email"); + if(user.getEmail().contains("Hello")) { + System.out.print("Some expression"); + user.forEach(u1 -> { + int nullCount = 0; + if(u1 == null) { + nullCount++; + } + System.out.println(u1.getFirstName()); + }); + } + } + if (!errors.isEmpty()) { + throw new ValidationException("User validation failed", errors); + } + } + + private void validateInventory(OrderItem item) throws ValidationException { + // Simulate validation + } + + private void reserveInventory(OrderItem item) throws DataProcessingException { + // Simulate reservation + } + + private void releaseInventory(OrderItem item) throws DataProcessingException { + // Simulate release + } + + private void chargePayment(Order order) throws DataProcessingException { + // Simulate payment + } + + private void processOrderItems(Order order) throws DataProcessingException { + // Simulate processing + } + + private List findStaleOrders() { + return new ArrayList<>(300); + } + + // ========== LAMBDA HASH LINKING TEST CASES ========== + + /** + * Test case: Throw statement directly inside lambda (no surrounding block) + * This tests that throws inside lambdas are correctly linked to lambda hash + */ + private void testThrowInLambdaNoBlock(List items) { + items.forEach(item -> { + // This throw is directly in lambda body, no surrounding if/try/etc + throw new RuntimeException("Direct throw in lambda: " + item); + }); + } + + /** + * Test case: Expression statement directly inside lambda (no surrounding block) + * This tests that expressions inside lambdas are correctly linked to lambda hash + */ + private void testExpressionInLambdaNoBlock(List items) { + List results = new ArrayList<>(); + items.forEach(item -> { + // This expression is directly in lambda body, no surrounding block + results.add("Processed: " + item); + }); + } + + /** + * Test case: Mixed - some statements in blocks, some not + */ + private void testMixedLambdaStatements(List items) { + List errors = new ArrayList<>(); + items.forEach(item -> { + // Direct expression in lambda (no block) + System.out.println("Processing: " + item); + + // Expression inside if block + if (item == null) { + errors.add("Null item found"); + } + + // Another direct expression in lambda (no block) + System.out.println("Done: " + item); + }); + } + + /** + * Test case: Nested lambdas with throws + */ + private void testNestedLambdasWithThrow(List> nestedItems) { + nestedItems.forEach(innerList -> { + // Direct expression in outer lambda + System.out.println("Processing inner list: " + innerList); + + int temo = 30; + + innerList.forEach(item -> { + // Direct throw in inner lambda + throw new IllegalArgumentException("Error in nested lambda: " + item); + }); + + for(int i = 0; i < innerList.size(); i++) { + int res = temo == innerList.get(i).hashCode() ? 1 : 0; + System.out.println(res); + } + + try { + innerList.forEach(item -> { + int res = temo == item.hashCode() ? 1 : 0; + System.out.println(res); + }); + } catch (Exception e) { + + } + }); + } + + /** + * Test case: Switch expression inside lambda + */ + private void testSwitchInLambda(List items) { + items.forEach(item -> { + String result = switch (item) { + case "A" -> "Option A"; + case "B" -> "Option B"; + default -> throw new IllegalArgumentException("Unknown: " + item); + }; + System.out.println(result); + }); + } + + + public boolean supports(ProviderEvent providerEvent) { + return providerEvent instanceof ClientModel.ClientCreationEvent; + } +} diff --git a/parser/src/test-data/java/blocks/ComprehensiveExceptionPatterns.java b/parser/src/test-data/java/blocks/ComprehensiveExceptionPatterns.java new file mode 100644 index 000000000..31b31095e --- /dev/null +++ b/parser/src/test-data/java/blocks/ComprehensiveExceptionPatterns.java @@ -0,0 +1,929 @@ +package com.inventory.auth.examples4; + +import java.io.BufferedReader; +import java.io.EOFException; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Serializable; +import java.io.UncheckedIOException; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.Callable; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import javax.validation.constraints.NotBlank; + +import com.inventory.auth.domain.Permission; +import com.inventory.auth.domain.Role; +import com.inventory.auth.domain.Session; +import com.inventory.auth.domain.User; +import com.inventory.auth.examples4.TypeUseAnnotationPatterns.NotEmpty; + +/** + * Comprehensive Exception and Throws Clause Patterns + * + * This file demonstrates ALL locations where throws clauses and exception handling + * can appear in Java, for thorough extraction testing of THROWS_CLAUSE contexts. + * + * Coverage: + * 1. Method declaration throws + * 2. Constructor declaration throws + * 3. Generic methods with exception type parameters + * 4. Interface methods (regular, default, static) + * 5. Abstract methods + * 6. Record constructors (canonical with throws, compact without) + * 7. Try-catch blocks (single, multi-catch) + * 8. Try-with-resources + * 9. Lambda expressions (implicit exception handling) + * 10. Anonymous class methods + * 11. Local class methods + * 12. Sealed types with exception inheritance + * 13. Enum constructors (no throws allowed) + * 14. Initializer blocks (no throws allowed) + */ +@SuppressWarnings({"unused", "RedundantThrows"}) +public class ComprehensiveExceptionPatterns { + + // ========================================================================= + // SECTION 1: METHOD DECLARATION THROWS CLAUSE + // ========================================================================= + + // Mixed import styles - wildcard (*) and concrete from domain package + // Session and Role come from wildcard import com.inventory.auth.domain.* + // User comes from concrete import com.inventory.auth.domain.User + public void mixedImportStylesWithThrows( + Session session, + Role role, + User user, + Permission permission) throws IOException, ValidationException { + if (session == null || user == null) { + throw new IOException("Session or User cannot be null"); + } + if (role == null) { + throw new ValidationException("Role required"); + } + } + + // Generic method with domain types and multiple exceptions + public T processSessionWithException( + T session, + User owner, + List roles) throws IOException, SQLException, ProcessingException { + if (session == null) { + throw new ProcessingException("Session null"); + } + return session; + } + + // Single exception + public void singleException() throws IOException { + throw new IOException("Single exception"); + } + + // Multiple exceptions + public void multipleExceptions() throws IOException, SQLException, InterruptedException { + throw new IOException("Multiple exceptions"); + } + + // Generic return with exceptions + public T genericReturnWithException(Class clazz) throws InstantiationException, IllegalAccessException { + return clazz.newInstance(); + } + + // Checked and unchecked mixed + public void mixedExceptions() throws IOException, RuntimeException, Error { + throw new IOException("Mixed"); + } + + // Overloaded methods with different throws + public void overloadedMethod(String s) throws IOException { + throw new IOException("String version"); + } + + public void overloadedMethod(int i) throws SQLException { + throw new SQLException("Int version"); + } + + public void overloadedMethod(String s, int i) throws IOException, SQLException { + throw new IOException("Both version"); + } + + // ========================================================================= + // SECTION 2: CONSTRUCTOR DECLARATION THROWS CLAUSE + // ========================================================================= + + public static class ConstructorExceptions { + private final String value; + private final Connection conn; + + // Constructor with single exception + public ConstructorExceptions(String value) throws IllegalArgumentException { + if (value == null) { + throw new IllegalArgumentException("Value cannot be null"); + } + this.value = value; + this.conn = null; + } + + // Constructor with multiple exceptions + public ConstructorExceptions(String value, String url) throws IOException, SQLException { + if (value == null) { + throw new IOException("Value null"); + } + this.value = value; + this.conn = DriverManager.getConnection(url); + } + + // Constructor with generic exception type parameter + public ConstructorExceptions(String value, Class exceptionType) throws E { + this.value = value; + this.conn = null; + } + + // Overloaded constructors with different throws + public ConstructorExceptions() throws FileNotFoundException { + this.value = "default"; + this.conn = null; + throw new FileNotFoundException("Default constructor"); + } + } + + // ========================================================================= + // SECTION 3: GENERIC METHOD WITH EXCEPTION TYPE PARAMETER + // ========================================================================= + + // Single generic exception type + public void singleGenericException(Class type) throws E { + throw type.getDeclaredConstructor().newInstance(); + } + + // Multiple generic exception types + public void multipleGenericExceptions() throws E1, E2 { + // Can potentially throw both + } + + // Generic exception with bounds + public void boundedGenericException(E exception) throws E { + throw exception; + } + + // Generic method with generic return and exception + public T genericReturnAndException(Supplier supplier, Class exType) throws E { + try { + return supplier.get(); + } catch (Exception e) { + throw exType.getDeclaredConstructor().newInstance(); + } + } + + // Mixing regular and generic exceptions + public void mixedRegularAndGeneric() throws IOException, E, SQLException { + throw new IOException("Mixed"); + } + + // Generic exception with intersection bounds + public void genericWithIntersection(E exception) throws E { + throw exception; + } + + // ========================================================================= + // SECTION 4: TRY-CATCH BLOCKS + // ========================================================================= + + public void singleCatch() { + try { + throw new IOException("Test"); + } catch (IOException e) { + System.err.println("Caught: " + e.getMessage()); + } + } + + public void multipleCatch() { + try { + throw new SQLException("Test"); + } catch (IOException e) { + System.err.println("IO: " + e); + } catch (SQLException e) { + System.err.println("SQL: " + e); + } catch (Exception e) { + System.err.println("General: " + e); + } + } + + // Multi-catch (Java 7+) + public void multiCatch() { + try { + throw new IOException("Test"); + } catch (IOException | SQLException e) { + System.err.println("Multi-catch: " + e); + } + } + + // Try-catch-finally + public void tryCatchFinally() { + try { + throw new IOException("Test"); + } catch (IOException e) { + System.err.println("Caught"); + } finally { + System.out.println("Finally"); + } + } + + // Nested try-catch + public void nestedTryCatch() { + try { + try { + throw new IOException("Inner"); + } catch (IOException inner) { + throw new SQLException("Outer", inner); + } + } catch (SQLException outer) { + System.err.println("Outer caught: " + outer); + } + } + + // Try with multiple catch types + public void complexMultiCatch() { + try { + throw new FileNotFoundException("Test"); + } catch (FileNotFoundException | EOFException e) { + System.err.println("File exception: " + e); + } catch (IOException e) { + System.err.println("IO exception: " + e); + } catch (Exception e) { + System.err.println("General exception: " + e); + } + } + + // ========================================================================= + // SECTION 5: TRY-WITH-RESOURCES + // ========================================================================= + + // Single resource + public void trySingleResource() throws IOException { + try (FileInputStream fis = new FileInputStream("file.txt")) { + int data = fis.read(); + } + } + + // Multiple resources + public void tryMultipleResources() throws IOException { + try (FileInputStream fis = new FileInputStream("file.txt"); + BufferedReader br = new BufferedReader(new InputStreamReader(fis)); + FileOutputStream fos = new FileOutputStream("output.txt")) { + String line = br.readLine(); + fos.write(line.getBytes()); + } + } + + // Try-with-resources with catch + public void tryResourcesWithCatch() { + try (FileInputStream fis = new FileInputStream("file.txt")) { + int data = fis.read(); + } catch (IOException e) { + System.err.println("Caught: " + e); + } + } + + // Try-with-resources with finally + public void tryResourcesWithFinally() throws IOException { + try (FileInputStream fis = new FileInputStream("file.txt")) { + int data = fis.read(); + } finally { + System.out.println("Finally"); + } + } + + // Try-with-resources with catch and finally + public void tryResourcesComplete() { + try (FileInputStream fis = new FileInputStream("file.txt"); + BufferedReader br = new BufferedReader(new InputStreamReader(fis))) { + String line = br.readLine(); + } catch (IOException e) { + System.err.println("IO error: " + e); + } finally { + System.out.println("Cleanup"); + } + } + + // Nested try-with-resources + public void nestedTryResources() throws IOException { + try (FileInputStream outer = new FileInputStream("outer.txt")) { + try (FileInputStream inner = new FileInputStream("inner.txt")) { + outer.read(); + inner.read(); + } + } + } + + // ========================================================================= + // SECTION 6: INTERFACE METHOD DECLARATIONS + // ========================================================================= + + public interface DataProcessor { + // Regular interface method with throws + void process(String data) throws ProcessingException; + + // Multiple exceptions + void processMultiple(String data) throws IOException, SQLException, ProcessingException; + + // Generic exception + void processGeneric(String data, Class exType) throws E; + + // Default method with throws (Java 8+) + default void processAll(List items) throws ProcessingException { + for (String item : items) { + process(item); + } + } + + // Default method with multiple exceptions + default void processAllSafe(List items) throws IOException, ProcessingException { + for (String item : items) { + process(item); + } + } + + // Static method with throws (Java 8+) + static DataProcessor create(String type) throws ConfigException { + if (type == null) { + throw new ConfigException("Type cannot be null"); + } + return new DataProcessorImpl(); + } + + // Static method with multiple exceptions + static DataProcessor createFromFile(String path) throws IOException, ConfigException { + if (path == null) { + throw new IOException("Path null"); + } + throw new ConfigException("Config error"); + } + + // Generic static method with exception + static T load(Class clazz) throws E, IOException { + throw new IOException("Cannot load"); + } + } + + // Implementation + static class DataProcessorImpl implements DataProcessor { + @Override + public void process(String data) throws ProcessingException { + if (data == null) { + throw new ProcessingException("Null data"); + } + } + + @Override + public void processMultiple(String data) throws IOException, SQLException, ProcessingException { + throw new ProcessingException("Error"); + } + + @Override + public void processGeneric(String data, Class exType) throws E { + throw exType.getDeclaredConstructor().newInstance(); + } + } + + // ========================================================================= + // SECTION 7: ABSTRACT METHOD DECLARATIONS + // ========================================================================= + + public abstract static class AbstractService { + // Abstract method with single exception + public abstract void execute() throws ServiceException; + + // Abstract method with multiple exceptions + public abstract void executeMultiple() throws IOException, SQLException, ServiceException; + + // Abstract generic method with exception + public abstract void executeGeneric(Class exType) throws E; + + // Abstract method with generic return and exception + public abstract T fetchData(Class type) throws E; + + // Concrete method with throws in abstract class + public void concreteMethod() throws IOException { + throw new IOException("Concrete in abstract class"); + } + } + + static class ConcreteService extends AbstractService { + @Override + public void execute() throws ServiceException { + throw new ServiceException("Execute"); + } + + @Override + public void executeMultiple() throws IOException, SQLException, ServiceException { + throw new ServiceException("Multiple"); + } + + @Override + public void executeGeneric(Class exType) throws E { + throw exType.getDeclaredConstructor().newInstance(); + } + + @Override + public T fetchData(Class type) throws E { + return null; + } + } + + // ========================================================================= + // SECTION 8: LAMBDA EXPRESSIONS (Implicit Exception Handling) + // ========================================================================= + + public void lambdaExpressions() throws Exception { + // Lambda that can throw - functional interface declares it + Callable callable = () -> { + throw new Exception("Lambda exception"); + }; + + // Lambda with try-catch internally + Runnable runnable = () -> { + try { + throw new IOException("Handled internally"); + } catch (IOException e) { + System.err.println("Caught in lambda: " + e); + } + }; + + // Lambda with checked exception wrapped as unchecked + Consumer consumer = (s) -> { + try { + new FileInputStream(s); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + }; + + // Callable with generic exception + Callable callableInt = () -> { + if (Math.random() > 0.5) { + throw new Exception("Random exception"); + } + return 42; + }; + } + + // ========================================================================= + // SECTION 9: ANONYMOUS CLASS METHODS + // ========================================================================= + + public void anonymousClasses() { + // Anonymous Runnable - cannot add throws + Runnable r1 = new Runnable() { + @Override + public void run() { + // Cannot add throws - must match interface + try { + throw new IOException("Must catch"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }; + + // Anonymous Callable - CAN throw because interface declares it + Callable c1 = new Callable() { + @Override + public String call() throws Exception { + throw new Exception("Callable can throw"); + } + }; + + // Anonymous class with custom exception + DataProcessor processor = new DataProcessor() { + @Override + public void process(String data) throws ProcessingException { + throw new ProcessingException("Anonymous processor"); + } + + @Override + public void processMultiple(String data) throws IOException, SQLException, ProcessingException { + throw new SQLException("Anonymous multi"); + } + + @Override + public void processGeneric(String data, Class exType) throws E { + throw exType.getDeclaredConstructor().newInstance(); + } + }; + } + + // ========================================================================= + // SECTION 10: LOCAL CLASS METHODS + // ========================================================================= + + public void localClassMethods() { + // Local class with throws on methods + class LocalProcessor { + void process() throws IOException { + throw new IOException("Local class method"); + } + + void processMultiple() throws IOException, SQLException { + throw new SQLException("Local multi"); + } + + void processGeneric(Class exType) throws E { + throw exType.getDeclaredConstructor().newInstance(); + } + + // Local class constructor with throws + LocalProcessor(String config) throws ConfigException { + if (config == null) { + throw new ConfigException("Config null"); + } + } + } + + try { + LocalProcessor lp = new LocalProcessor("config"); + lp.process(); + } catch (Exception e) { + System.err.println("Caught from local class: " + e); + } + } + + // ========================================================================= + // SECTION 11: RECORD CONSTRUCTORS (Java 16+) + // ========================================================================= + + // Record with compact constructor (CANNOT declare throws) + public record CompactConstructorRecord(@NotBlank String value, String... random) { + public CompactConstructorRecord { + // Compact constructor cannot declare throws + // Can only throw unchecked exceptions + if (value == null) { + throw new IllegalArgumentException("Value cannot be null"); + } + } + } + + // Record demonstrating that canonical/compact constructor CANNOT declare throws + // Note: Canonical constructor (matching all record params) same restriction as compact + public record CanonicalConstructorExample(@NotEmpty String value, int code) { + // Compact form - cannot declare throws + public CanonicalConstructorExample { + if (value == null) { + throw new IllegalArgumentException("Only unchecked exceptions allowed"); + } + } + // If we wrote explicit canonical constructor, still cannot add throws: + // public CanonicalConstructorExample(String value, int code) throws Exception { } // INVALID! + } + + // Record with additional constructor (CAN declare throws) + public record AdditionalConstructorRecord(String value, int code) { + public AdditionalConstructorRecord(String value) throws IOException { + this(value, 0); + if (value == null) { + throw new IOException("Additional constructor"); + } + } + } + + // Record with method throwing exception + public record RecordWithMethod(Role[] role, Session... data) { + public void process() throws ProcessingException { + if (data == null) { + throw new ProcessingException("Record method exception"); + } + } + + public void processGeneric(Class exType) throws E { + throw exType.getDeclaredConstructor().newInstance(); + } + } + + // ========================================================================= + // SECTION 12: SEALED TYPES WITH EXCEPTION INHERITANCE + // ========================================================================= + + public sealed interface Processor permits SafeProcessor, RiskyProcessor, GenericProcessor { + void process() throws ProcessingException; + + default void processAll(List items) throws ProcessingException, IOException { + for (String item : items) { + process(); + } + } + } + + public final class SafeProcessor implements Processor { + @Override + public void process() { + // Can narrow - remove throws completely + System.out.println("Safe processing - no exceptions"); + } + } + + public final class RiskyProcessor implements Processor { + @Override + public void process() throws ProcessingException { + // Must declare or narrow + throw new ProcessingException("Risky processing"); + } + + public void processWithIO() throws ProcessingException, IOException { + throw new IOException("Risky with IO"); + } + } + + public final class GenericProcessor implements Processor { + @Override + public void process() throws ProcessingException { + throw new ProcessingException("Generic processing"); + } + + public void processWithGeneric(Class exType) throws E, ProcessingException { + if (Math.random() > 0.5) { + throw new ProcessingException("Processing error"); + } + throw exType.getDeclaredConstructor().newInstance(); + } + } + + // Sealed class hierarchy with exceptions + public sealed abstract class Result permits Success, Failure { + public abstract T getValue() throws ResultException; + + public abstract boolean isSuccess() throws ValidationException; + } + + public final class Success extends Result { + private final T value; + + public Success(T value) throws ValidationException { + if (value == null) { + throw new ValidationException("Success value cannot be null"); + } + this.value = value; + } + + @Override + public T getValue() { + // Narrowing - removing throws + return value; + } + + @Override + public boolean isSuccess() { + return true; + } + } + + public final class Failure extends Result { + private final Exception error; + + public Failure(Exception error) { + this.error = error; + } + + @Override + public T getValue() throws ResultException { + throw new ResultException("Failed result", error); + } + + @Override + public boolean isSuccess() throws ValidationException { + if (error == null) { + throw new ValidationException("Failure must have error"); + } + return false; + } + } + + // ========================================================================= + // SECTION 13: ENUM (Constructor CANNOT declare throws) + // ========================================================================= + + public enum Status { + ACTIVE("active"), + INACTIVE("inactive"), + PENDING("pending"); + + private final String code; + + // Enum constructor CANNOT declare throws + // Can only throw unchecked exceptions + Status(String code) { + if (code == null) { + throw new IllegalArgumentException("Code cannot be null"); + } + this.code = code; + } + + public String getCode() { + return code; + } + + // Enum methods CAN declare throws + public void validate() throws ValidationException { + if (code.isEmpty()) { + throw new ValidationException("Empty code"); + } + } + + public static Status fromCode(String code) throws ConfigException { + for (Status s : values()) { + if (s.code.equals(code)) { + return s; + } + } + throw new ConfigException("Invalid code: " + code); + } + } + + // ========================================================================= + // SECTION 14: INITIALIZER BLOCKS (Cannot declare throws) + // ========================================================================= + + public static class InitializerBlockExamples { + private final String value; + private static final Properties props; + + // Instance initializer - CANNOT declare throws + // Checked exceptions only if ALL constructors declare them + { + try { + value = loadValue(); + } catch (IOException e) { + // Must wrap in unchecked + throw new RuntimeException("Instance initializer error", e); + } + } + + // Static initializer - CANNOT declare throws + // Can only throw unchecked (ExceptionInInitializerError) + static { + try { + props = new Properties(); + props.load(new FileInputStream("config.properties")); + } catch (IOException e) { + throw new ExceptionInInitializerError(e); + } + } + + private static String loadValue() throws IOException { + throw new IOException("Load error"); + } + + public InitializerBlockExamples() { + // Constructor doesn't need to declare throws if instance initializer wraps + } + } + + // ========================================================================= + // SECTION 15: FIELD INITIALIZERS (Cannot declare throws) + // ========================================================================= + + public static class FieldInitializerExamples { + // Field initializer CANNOT directly use checked exceptions + // This would fail: + // private final FileInputStream fis = new FileInputStream("file.txt"); + + // Must wrap in method that handles exception + private final FileInputStream fis = createStream(); + + private static FileInputStream createStream() { + try { + return new FileInputStream("file.txt"); + } catch (FileNotFoundException e) { + throw new RuntimeException("Field init error", e); + } + } + + // Or use supplier with exception handling + private final String config = loadConfig(); + + private String loadConfig() { + try { + return new String(new FileInputStream("config.txt").readAllBytes()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + + // ========================================================================= + // SECTION 16: METHOD REFERENCE WITH EXCEPTIONS + // ========================================================================= + + public void methodReferences() throws Exception { + // Method reference that throws + Callable c1 = this::methodThatThrows; + + // Method reference in stream with exception handling + List files = Arrays.asList("a.txt", "b.txt"); + + // This doesn't work directly: + // files.stream().map(FileInputStream::new) + + // Must wrap: + files.stream().map(f -> { + try { + return new FileInputStream(f); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + }); + } + + private String methodThatThrows() throws Exception { + throw new Exception("Method reference exception"); + } + + // ========================================================================= + // SECTION 17: VARARGS WITH EXCEPTIONS + // ========================================================================= + + @SafeVarargs + public final void varargsWithException(T... items) throws ProcessingException { + for (T item : items) { + if (item == null) { + throw new ProcessingException("Null item in varargs"); + } + } + } + + public void multipleExceptionsVarargs(String... paths) throws IOException, SQLException { + for (String path : paths) { + new FileInputStream(path); + } + } + + // ========================================================================= + // SECTION 18: NESTED EXCEPTIONS IN GENERICS + // ========================================================================= + + public T complexGenericExceptions( + Supplier supplier, + Class ex1, + Class ex2) throws E1, E2, IOException { + try { + return supplier.get(); + } catch (Exception e) { + if (Math.random() > 0.66) { + throw ex1.getDeclaredConstructor().newInstance(); + } else if (Math.random() > 0.33) { + throw ex2.getDeclaredConstructor().newInstance(); + } else { + throw new IOException("Complex exception"); + } + } + } +} + +// ============================================================================= +// CUSTOM EXCEPTION TYPES +// ============================================================================= + +class ProcessingException extends Exception { + public ProcessingException(String message) { + super(message); + } +} + +class ConfigException extends Exception { + public ConfigException(String message) { + super(message); + } +} + +class ServiceException extends Exception { + public ServiceException(String message) { + super(message); + } +} + +class ValidationException extends Exception { + public ValidationException(String message) { + super(message); + } +} + +class ResultException extends Exception { + public ResultException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/parser/src/test-data/java/blocks/ControlFlowExamples.java b/parser/src/test-data/java/blocks/ControlFlowExamples.java new file mode 100644 index 000000000..709b33807 --- /dev/null +++ b/parser/src/test-data/java/blocks/ControlFlowExamples.java @@ -0,0 +1,503 @@ +package com.inventory.auth.examples25; + +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.function.Supplier; +import java.util.HashMap; + +/** + * Comprehensive examples for testing control flow variable and expression linkage. + */ +public class ControlFlowExamples { + + + private final Supplier random = (k) -> { + for (int n = 0; n < k; n++) { + int forBodyVar = n; + if (forBodyVar < 25) { + int lowVar = forBodyVar * 2; + System.out.println("Low: " + lowVar); + } else if (forBodyVar < 75) { + int midVar = forBodyVar + 50; + System.out.println("Mid: " + midVar); + } else { + int highVar = forBodyVar - 25; + System.out.println("High: " + highVar); + } + } + + int temp = 100; + + while(temp > 0) { + temp--; + } + }; + + // ==================== WHILE LOOP EXAMPLES ==================== + + /** + * Basic while loop with variables inside + */ + public void basicWhileLoop() { + int counter = 0; + while (counter < 10) { + int insideWhile = counter * 2; + String message = "Count: " + insideWhile; + System.out.println(message); + counter++; + } + } + + /** + * Nested while loops + */ + public void nestedWhileLoops() { + int outer = 0; + while (outer < 5) { + int outerVar = outer * 10; + int inner = 0; + while (inner < 3) { + int innerVar = outerVar + inner; + System.out.println("Value: " + innerVar); + inner++; + while(innerVar > 4) { + int innerInnerVar = innerVar * 2; + System.out.println("Inner Inner Var: " + innerInnerVar); + innerVar--; + } + } + outer++; + } + } + + /** + * While loop inside lambda + */ + public void whileInsideLambda(List numbers) { + numbers.forEach(num -> { + int lambdaVar = num; + while (lambdaVar > 0) { + int whileInLambda = lambdaVar * 2; + System.out.println(whileInLambda); + lambdaVar--; + } + }); + } + + /** + * While with if inside + */ + public void whileWithIfInside() { + int i = 0; + while (i < 20) { + int whileVar = i; + if (whileVar % 2 == 0) { + int evenVar = whileVar / 2; + System.out.println("Even: " + evenVar); + } else if(whileVar %5 ==0) { + int res = whileVar / 5; + System.out.println("Multiple of 5: " + res); + } else if(whileVar %7 ==0) { + try { + int res = whileVar /7; + System.out.println("Some res: " + res); + } catch(Exception e) { + throw e; + } + } else if(whileVar %9 ==0) { + int res = whileVar / 9; + System.out.println("Multiple of 9: " + res); + } else if(whileVar %30 == 1) { + int res = whileVar / 30; + System.out.println("Multiple of 30: " + res); + } else { + int something = 123; + try { + int oddVar = whileVar * 3 + 1; + System.out.println("Odd: " + oddVar); + } catch(Exception e) { + String less = "Less than 123"; + throw e; + } + } + i++; + } + } + + // ==================== DO-WHILE LOOP EXAMPLES ==================== + + /** + * Basic do-while loop + */ + public void basicDoWhileLoop() { + int count = 0; + do { + int insideDoWhile = count + 100; + System.out.println(insideDoWhile); + count++; + } while (count < 5); + } + + /** + * Nested do-while loops + */ + public void nestedDoWhileLoops() { + int x = 0; + do { + int outerDoWhile = x * 5; + int y = 0; + do { + int innerDoWhile = outerDoWhile + y; + System.out.println(innerDoWhile); + y++; + } while (y < 2); + x++; + } while (x < 3); + } + + // ==================== FOR LOOP EXAMPLES ==================== + + /** + * Basic for loop with multiple variables in init + */ + public void basicForLoop() { + for (int i = 0, j = 10; i < j; i++, j--) { + int forVar = i + j; + System.out.println(forVar); + } + } + + /** + * For loop with external variable (variable declared outside) + */ + public void forWithExternalVariable() { + int external = 0; + for (; external < 10; external++) { + int insideFor = external * 2; + System.out.println(insideFor); + } + } + + /** + * For loop with assignment expression in update + */ + public void forWithAssignmentUpdate() { + for (int i = 0; i < 100; i = i + 5) { + System.out.println(i); + } + } + + /** + * For loop with compound assignment and multiple updates + */ + public void forWithCompoundAssignment() { + for (int i = 0, j = 100; i < j; i += 10, j -= 5) { + int diff = j - i; + System.out.println(diff); + } + } + + /** + * For loop with method call in update + */ + public void forWithMethodCallUpdate(List list) { + for (int i = 0; i < 10; list.add(i++)) { + System.out.println("Added: " + i); + } + } + + /** + * For loop with negation/boolean toggle in update + */ + public void forWithBooleanToggle() { + boolean flag = true; + for (int i = 0; i < 10; i++, flag = !flag) { + String state = flag ? "ON" : "OFF"; + System.out.println(i + ": " + state); + } + } + + /** + * Nested for loops + */ + public void nestedForLoops() { + for (int row = 0; row < 5; row++) { + int rowVar = row * 10; + for (int col = 0; col < 5; col++) { + int colVar = col + rowVar; + System.out.println("Cell: " + colVar); + } + } + } + + /** + * For loop inside lambda + */ + public void forInsideLambda(List items) { + items.forEach(item -> { + int lambdaLocalVar = item.length(); + for (int idx = 0; idx < lambdaLocalVar; idx++) { + char c = item.charAt(idx); + int charCode = (int) c; + System.out.println("Char " + idx + ": " + charCode); + } + }); + } + + /** + * For loop with if/else inside + */ + public void forWithConditionals() { + for (int n = 0; n < 100; n++) { + int forBodyVar = n; + if (forBodyVar < 25) { + int lowVar = forBodyVar * 2; + System.out.println("Low: " + lowVar); + } else if (forBodyVar < 75) { + int midVar = forBodyVar + 50; + System.out.println("Mid: " + midVar); + } else { + int highVar = forBodyVar - 25; + System.out.println("High: " + highVar); + } + } + } + + // ==================== ENHANCED FOR LOOP EXAMPLES ==================== + + /** + * Enhanced for with nested regular for + */ + public void enhancedForWithNestedFor(List strings) { + for (String str : strings) { + int strLen = str.length(); + for (int i = 0; i < strLen; i++) { + char ch = str.charAt(i); + System.out.println(ch); + } + } + } + + /** + * Enhanced for with while inside + */ + public void enhancedForWithWhile(List nums) { + for (Integer num : nums) { + int current = num; + while (current > 0) { + int whileInEnhancedFor = current % 10; + System.out.println(whileInEnhancedFor); + current = current / 10; + } + } + } + + // ==================== SWITCH STATEMENT EXAMPLES ==================== + + /** + * Traditional switch statement with variables in cases + */ + public void traditionalSwitch(int value) { + switch (value) { + case 1: + int caseOneVar = value * 10; + System.out.println(caseOneVar); + break; + case 2: + int caseTwoVar = value * 20; + System.out.println(caseTwoVar); + break; + case 3: + case 4: + int caseThreeFourVar = value * 30; + System.out.println(caseThreeFourVar); + break; + default: + int defaultVar = value * 100; + System.out.println(defaultVar); + } + } + + /** + * Switch statement with loops inside cases + */ + public void switchWithLoopsInCases(int mode, List items) { + switch (mode) { + case 1: + for (String item : items) { + int itemLen = item.length(); + System.out.println(itemLen); + } + break; + case 2: + int idx = 0; + while (idx < items.size()) { + String current = items.get(idx); + System.out.println(current); + idx++; + } + break; + default: + for (int i = 0; i < items.size(); i++) { + int position = i + 1; + System.out.println("Item " + position); + } + } + } + + // ==================== SWITCH EXPRESSION EXAMPLES (Java 14+) ==================== + + /** + * Switch expression with arrow syntax + */ + public String switchExpression(int day) { + String dayType = switch (day) { + case 1, 2, 3, 4, 5 -> { + int workDay = day; + String result = "Weekday " + workDay; + yield result; + } + case 6, 7 -> { + int weekend = day - 5; + String result = "Weekend day " + weekend; + yield result; + } + default -> { + int unknown = day; + yield "Unknown day: " + unknown; + } + }; + return dayType; + } + + /** + * Switch expression with pattern matching (Java 21+) + */ + public String switchWithPatternMatching(Object obj) { + return switch (obj) { + case Integer o -> { + int doubled = i * 2; + yield "Integer: " + doubled; + } + case String s -> { + int len = s.length(); + yield "String of length: " + len; + } + case List list -> { + int size = list.size(); + yield "List of size: " + size; + } + case null -> { + String nullMsg = "null value"; + yield nullMsg; + } + default -> { + String className = obj.getClass().getName(); + yield "Unknown type: " + className; + } + }; + } + + /** + * Switch expression inside a loop + */ + public void switchExpressionInLoop(List values) { + for (Integer val : values) { + int loopVar = val; + String category = switch (loopVar % 3) { + case 0 -> { + int divisible = loopVar / 3; + something.forEach((insideHere) -> { + int insideLambda = insideHere * 2; + System.out.println(insideLambda); + }); + yield "Divisible by 3: " + divisible; + } + case 1 -> { + int remainder1 = loopVar - 1; + yield "Remainder 1: " + remainder1; + } + case 2 -> { + int remainder2 = loopVar - 2; + yield "Remainder 2: " + remainder2; + } + default -> "Unexpected"; + }; + System.out.println(category); + } + } + + /** + * Switch expression inside lambda + */ + public void switchExpressionInLambda(List items) { + items.forEach(item -> { + int itemLength = item.length(); + String size = switch (itemLength) { + case 0, 1, 2 -> { + int tiny = itemLength; + yield "Tiny: " + tiny; + } + case 3, 4, 5 -> { + int small = itemLength; + yield "Small: " + small; + } + default -> { + int large = itemLength; + yield "Large: " + large; + } + }; + System.out.println(size); + }); + } + + // ==================== COMPLEX NESTED EXAMPLES ==================== + + /** + * Deeply nested control flow + */ + public void deeplyNested(List> matrix) { + for (List row : matrix) { + int rowSum = 0; + for (Integer cell : row) { + int cellValue = cell; + if (cellValue > 0) { + int positiveVal = cellValue; + while (positiveVal > 10) { + int whileVar = positiveVal / 2; + positiveVal = whileVar; + } + rowSum += positiveVal; + } else if (cellValue < 0) { + int negativeVal = -cellValue; + do { + int doWhileVar = negativeVal * 2; + negativeVal = doWhileVar; + } while (negativeVal < 10); + rowSum -= negativeVal; + } + } + System.out.println("Row sum: " + rowSum); + } + } + + /** + * Mixed control flow with synchronized + */ + public void mixedWithSynchronized(List shared) { + for (Integer val : shared) { + int localVal = val; + synchronized (shared) { + int syncVar = localVal * 2; + if (syncVar > 100) { + int ifInSync = syncVar - 100; + System.out.println("Over 100: " + ifInSync); + } else { + int elseInSync = 100 - syncVar; + System.out.println("Under 100: " + elseInSync); + } + } + } + } +} diff --git a/parser/src/test-data/java/blocks/CustomTypes.java b/parser/src/test-data/java/blocks/CustomTypes.java new file mode 100644 index 000000000..b795d603c --- /dev/null +++ b/parser/src/test-data/java/blocks/CustomTypes.java @@ -0,0 +1,386 @@ +package com.inventory.auth.examples24; + +import java.io.*; +import java.util.*; + +import com.google.common.base.Supplier; + +/** + * Custom types used in exception handling examples to test cross-type references. + */ +public class CustomTypes { + + // ========== CUSTOM EXCEPTIONS ========== + + final Supplier> validatorFieldChecking = () -> { + int someNumber = 1; + { + int someTesting = 1000; + } + // Wave 5: validator lambda + try { + String validationMsg = "Validating order: " + 1; + final List validationErrors = new ArrayList<>(); + validationErrors.add("Validation failed: " + 1); + validationErrors.add("Validation failed: " + 2 + validationMsg); + return Result.success(validationErrors); + } catch (Exception e) { + String validationError = "Validation failed: " + 1; + return Result.failure(e); + } + }; + + + final Supplier validatorFieldCheckings = (a) -> { + try { + final int myRes = switch(a) { + case 1 -> 1; + case 2 -> 2; + default -> 3; + }; + return "Hello"; + } catch(Exception e) { + int myMessage = "this is another message"; + return "Hello" + e.getMessage() + myMessage; + } + }; + + final Supplier validatorFieldCheckingss = (String a) -> "hello "; + public static class DataProcessingException extends Exception { + private final String errorCode; + private final Object failedData; + + public DataProcessingException(String message, String errorCode, Object failedData) { + super(message); + this.errorCode = errorCode; + this.failedData = failedData; + } + + public DataProcessingException(String message, String errorCode, Object failedData, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + this.failedData = failedData; + } + + public String getErrorCode() { return errorCode; } + public Object getFailedData() { return failedData; } + } + + public static class ValidationException extends Exception { + private final List validationErrors; + + public ValidationException(String message, List errors) { + super(message); + this.validationErrors = new ArrayList<>(errors); + } + + public List getValidationErrors() { return validationErrors; } + } + + public static class ResourceNotFoundException extends RuntimeException { + private final String resourceType; + private final String resourceId; + + public ResourceNotFoundException(String resourceType, String resourceId) { + super(String.format("%s with id '%s' not found", resourceType, resourceId)); + this.resourceType = resourceType; + this.resourceId = resourceId; + } + + public String getResourceType() { return resourceType; } + public String getResourceId() { return resourceId; } + } + + public static class ConnectionFailedException extends IOException { + private final String host; + private final int port; + private final int retryCount; + + public ConnectionFailedException(String host, int port, int retryCount, Throwable cause) { + super(String.format("Failed to connect to %s:%d after %d retries", host, port, retryCount), cause); + this.host = host; + this.port = port; + this.retryCount = retryCount; + } + + public String getHost() { return host; } + public int getPort() { return port; } + public int getRetryCount() { return retryCount; } + } + + // ========== CUSTOM RESULT TYPES ========== + + public static class Result { + private final T value; + private final Exception error; + private final boolean success; + + private Result(T value, Exception error, boolean success) { + this.value = value; + this.error = error; + this.success = success; + } + + public static Result success(T value) { + return new Result<>(value, null, true); + } + + public static Result failure(Exception error) { + return new Result<>(null, error, false); + } + + public T getValue() { return value; } + public Exception getError() { return error; } + public boolean isSuccess() { return success; } + public boolean isFailure() { return !success; } + + public T getOrThrow() throws Exception { + if (!success) throw error; + return value; + } + + public T getOrDefault(T defaultValue) { + return success ? value : defaultValue; + } + } + + public static class BatchResult { + private final List> results; + private final int successCount; + private final int failureCount; + + public BatchResult(List> results) { + this.results = new ArrayList<>(results); + this.successCount = (int) results.stream().filter(Result::isSuccess).count(); + this.failureCount = results.size() - successCount; + } + + public List> getResults() { return results; } + public int getSuccessCount() { return successCount; } + public int getFailureCount() { return failureCount; } + public boolean hasFailures() { return failureCount > 0; } + + public List getSuccessfulValues() { + List values = new ArrayList<>(); + for (Result r : results) { + if (r.isSuccess()) values.add(r.getValue()); + } + return values; + } + + public List getErrors() { + List errors = new ArrayList<>(); + for (Result r : results) { + int anotherCheck = 500; + if (r.isFailure()) { + int anotherInt = 20; + errors.add(r.getError()); + } + } + return errors; + } + } + + // ========== DOMAIN MODELS ========== + + public static class User { + private final String id; + private final String email; + private final String name; + + public User(String id, String email, String name) { + this.id = id; + this.email = email; + this.name = name; + } + + public String getId() { return id; } + public String getEmail() { return email; } + public String getName() { return name; } + } + + public static class Order { + private final String orderId; + private final String userId; + private final List items; + private OrderStatus status; + + public Order(String orderId, String userId, List items) { + this.orderId = orderId; + this.userId = userId; + this.items = new ArrayList<>(items); + this.status = OrderStatus.PENDING; + } + + public String getOrderId() { return orderId; } + public String getUserId() { return userId; } + public List getItems() { return items; } + public OrderStatus getStatus() { return status; } + public void setStatus(OrderStatus status) { this.status = status; } + + public double getTotalAmount() { + return items.stream().mapToDouble(i -> i.getPrice() * i.getQuantity()).sum(); + } + } + + public static class OrderItem { + private final String productId; + private final String productName; + private final int quantity; + private final double price; + + public OrderItem(String productId, String productName, int quantity, double price) { + this.productId = productId; + this.productName = productName; + this.quantity = quantity; + this.price = price; + } + + public String getProductId() { return productId; } + public String getProductName() { return productName; } + public int getQuantity() { return quantity; } + public double getPrice() { return price; } + } + + public enum OrderStatus { + PENDING, CONFIRMED, PROCESSING, SHIPPED, DELIVERED, CANCELLED, FAILED + } + + // ========== CLOSEABLE RESOURCES ========== + + public static class DatabaseConnection implements AutoCloseable { + private final String connectionString; + private boolean connected; + private boolean closed; + + public DatabaseConnection(String connectionString) throws ConnectionFailedException { + this.connectionString = connectionString; + this.connected = false; + this.closed = false; + } + + public void connect() throws ConnectionFailedException { + if (closed) throw new IllegalStateException("Connection is closed"); + // Simulate connection + this.connected = true; + } + + public User findUserById(String userId) throws DataProcessingException { + if (!connected) throw new IllegalStateException("Not connected"); + // Simulate database lookup + return new User(userId, userId + "@example.com", "User " + userId); + } + + public Order findOrderById(String orderId) throws DataProcessingException, ResourceNotFoundException { + if (!connected) throw new IllegalStateException("Not connected"); + // Simulate database lookup + if (orderId.startsWith("INVALID")) { + throw new ResourceNotFoundException("Order", orderId); + } + return new Order(orderId, "user123", new ArrayList<>()); + } + + public void saveOrder(Order order) throws DataProcessingException { + if (!connected) throw new IllegalStateException("Not connected"); + // Simulate database save + } + + public String getConnectionString() { return connectionString; } + public boolean isConnected() { return connected; } + + @Override + public void close() { + this.connected = false; + this.closed = true; + } + } + + public static class FileProcessor implements AutoCloseable { + private final String filePath; + private BufferedReader reader; + private BufferedWriter writer; + + public FileProcessor(String filePath) { + this.filePath = filePath; + } + + public void openForReading() throws IOException { + this.reader = new BufferedReader(new FileReader(filePath)); + } + + public void openForWriting() throws IOException { + this.writer = new BufferedWriter(new FileWriter(filePath)); + } + + public synchronized void increment() { + if(2%2 == 0) { + + } else if (3%2 == 0) { + + } + } + + public String readLine() throws IOException { + if (reader == null) throw new IllegalStateException("Not opened for reading"); + return reader.readLine(); + } + + public void writeLine(String line) throws IOException { + if (writer == null) throw new IllegalStateException("Not opened for writing"); + writer.write(line); + writer.newLine(); + } + + public List readAllLines() throws IOException { + List lines = new ArrayList<>(); + String line; + while ((line = readLine()) != null) { + lines.add(line); + } + return lines; + } + + @Override + public void close() throws IOException { + if (reader != null) reader.close(); + if (writer != null) writer.close(); + } + } + + public static class TransactionContext implements AutoCloseable { + private final String transactionId; + private boolean active; + private boolean committed; + private boolean rolledBack; + + public TransactionContext(String transactionId) { + this.transactionId = transactionId; + this.active = true; + this.committed = false; + this.rolledBack = false; + } + + public void commit() throws DataProcessingException { + if (!active) throw new IllegalStateException("Transaction not active"); + // Simulate commit + this.committed = true; + this.active = false; + } + + public void rollback() { + if (!active) return; + this.rolledBack = true; + this.active = false; + } + + public String getTransactionId() { return transactionId; } + public boolean isActive() { return active; } + public boolean isCommitted() { return committed; } + public boolean isRolledBack() { return rolledBack; } + + @Override + public void close() { + if (active) rollback(); + } + } +} diff --git a/parser/src/test-data/java/blocks/NestedBlockLinking.java b/parser/src/test-data/java/blocks/NestedBlockLinking.java new file mode 100644 index 000000000..b6e2e2b9f --- /dev/null +++ b/parser/src/test-data/java/blocks/NestedBlockLinking.java @@ -0,0 +1,61 @@ +package com.axiom.test.blocks; + +/** + * Nested-block linking test. + * + * `nested` builds a 4-deep control-flow chain (IF > FOR > WHILE > IF) so the + * parser must link each inner block's parentContainerHash to its enclosing + * block, producing strictly increasing nesting depth. + * + * `exceptions` has one try with two catches and a finally: every CATCH and the + * FINALLY must share the SAME tryStatementHash, and that hash must resolve to a + * TRY block. `tryWithResources` adds a TRY_WITH_RESOURCES variant. + */ +public class NestedBlockLinking { + + void nested(int n) { + if (n > 0) { + for (int i = 0; i < n; i++) { + while (i < 5) { + if (i == 2) { + break; + } + } + } + } else { + System.out.println("neg"); + } + } + + void exceptions() { + try { + risky(); + } catch (IllegalArgumentException e) { + log(e); + } catch (RuntimeException e) { + log(e); + } finally { + cleanup(); + } + } + + void tryWithResources() { + try (AutoCloseable a = open()) { + use(a); + } catch (Exception e) { + log(e); + } + } + + void risky() { } + + void log(Object e) { } + + void cleanup() { } + + void use(Object a) { } + + AutoCloseable open() { + return null; + } +} diff --git a/parser/src/test-data/java/enums/EnumImplicitMembers.java b/parser/src/test-data/java/enums/EnumImplicitMembers.java new file mode 100644 index 000000000..b790b5a5e --- /dev/null +++ b/parser/src/test-data/java/enums/EnumImplicitMembers.java @@ -0,0 +1,75 @@ +package com.axiomcode.test.enums; + +/** + * Acceptance fixture for the members JLS 8.9 declares implicitly on an enum. + * + * The oracle is javac: compile this file and run `javap -p`. Note what that + * reports and what it does NOT, because both matter: + * + * public static E[] values(); <- JLS 8.9.3, always implicit + * public static E valueOf(String); <- JLS 8.9.3, always implicit + * private E(); <- JLS 8.9.2, only when none declared + * + * private static final E[] $VALUES; <- class-file artifact, NOT extracted + * private static E[] $values(); <- class-file artifact, NOT extracted + * static {}; <- constant initialiser, NOT extracted + * + * values() and valueOf(String) differ from a record's implicit members in that + * they can never be written by hand - declaring either is a compile error - so + * they are present on every enum below without exception. + */ +public class EnumImplicitMembers { + + /** + * No declared constructor, so javac also declares a private no-arg one. + */ + public enum Simple { + RED, GREEN, BLUE + } + + /** + * Declares a constructor, so javac declares NO default one. This enum has + * exactly one constructor, the private WithCtor(int) below. + */ + public enum WithCtor { + ONE(1), TWO(2); + + private final int value; + + WithCtor(int value) { + this.value = value; + } + + public int value() { + return value; + } + } + + /** + * A constant with a body still leaves the enum itself with the same three + * implicit members; the anonymous body's method belongs to the constant. + */ + public enum WithBody { + ACTIVE { + @Override + public String describe() { + return "active"; + } + }, + IDLE { + @Override + public String describe() { + return "idle"; + } + }; + + public abstract String describe(); + } + + /** + * An empty enum still gets values(), valueOf(String) and a private + * constructor - there is nothing about a constant that induces them. + */ + public enum Empty { + } +} diff --git a/parser/src/test-data/java/enums/SimpleStatus.java b/parser/src/test-data/java/enums/SimpleStatus.java new file mode 100644 index 000000000..697c78a3b --- /dev/null +++ b/parser/src/test-data/java/enums/SimpleStatus.java @@ -0,0 +1,278 @@ +package com.example.enums; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import java.time.Duration; +import java.math.BigDecimal; +import org.example.custom.MyCustomClass; +import org.example.custom.AnotherType; + +/** + * Comprehensive test file for enum constant extraction + * Tests various enum patterns including: + * - Simple enum constants (no arguments) + * - Enum constants with arguments + * - Enum constants with annotations + * - Enum constants with anonymous class bodies + * - Enum methods (both on the enum and constant overrides) + */ + +// Simple enum with no arguments +public enum SimpleStatus { + ACTIVE, + INACTIVE, + PENDING, + DELETED +} + +// Enum with constructor arguments +enum StatusWithArgs { + ACTIVE("Active", 1), + INACTIVE("Inactive", 0), + PENDING("Pending", 2), + UNKNOWN("Unknown", -1); + + private final String label; + private final int code; + + StatusWithArgs(String label, int code) { + this.label = label; + this.code = code; + } + + public String getLabel() { + return label; + } + + public int getCode() { + return code; + } +} + +// Enum with annotations on constants +enum AnnotatedStatus { + @Deprecated + LEGACY, + + @SuppressWarnings("unused") + CURRENT, + + @Deprecated + @SuppressWarnings("all") + OLD_FORMAT +} + +// Enum with anonymous class bodies (method overrides) +enum StatusWithBody { + ACTIVE("Active") { + @Override + public boolean isTransient() { + return false; + } + + @Override + public String getDisplayName() { + return "Currently Active"; + } + }, + INACTIVE("Inactive") { + @Override + public boolean isTransient() { + return false; + } + }, + PENDING("Pending") { + @Override + public boolean isTransient() { + return true; + } + + public void customMethod() { + // Custom method only in PENDING + } + }; + + private final String label; + + StatusWithBody(String label) { + this.label = label; + } + + public abstract boolean isTransient(); + + public String getDisplayName() { + return label; + } +} + +// Enum implementing interface +enum HttpMethod implements Serializable { + GET("GET", true), + POST("POST", false), + PUT("PUT", false), + DELETE("DELETE", false), + PATCH("PATCH", false); + + private final String method; + private final boolean idempotent; + + HttpMethod(String method, boolean idempotent) { + this.method = method; + this.idempotent = idempotent; + } + + public String getMethod() { + return method; + } + + public boolean isIdempotent() { + return idempotent; + } +} + +// Complex enum with multiple argument types including class literals +enum ComplexEnum { + @Deprecated + ITEM_A("A", 1, 1.5, true, new String[]{"tag1", "tag2"}, String.class), + + ITEM_B("B", 2, 2.5, false, new String[]{"tag3"}, Integer.class), + + @SuppressWarnings("unchecked") + ITEM_C("C", 3, 3.5, true, null, BigDecimal.class) { + @Override + public String getInfo() { + return "Special Item C"; + } + }, + + // Test with custom class literal + ITEM_D("D", 4, 4.5, true, null, MyCustomClass.class), + + // Test with field access (enum constant reference) + ITEM_E("E", 5, 5.5, false, null, Serializable.class); + + private final String name; + private final int id; + private final double value; + private final boolean active; + private final String[] tags; + private final Class type; + + ComplexEnum(String name, int id, double value, boolean active, String[] tags, Class type) { + this.name = name; + this.id = id; + this.value = value; + this.active = active; + this.tags = tags; + this.type = type; + } + + public String getInfo() { + return name + ":" + id; + } + + public Class getType() { + return type; + } +} + +// Enum with static methods and fields +enum EnumWithStatics { + VALUE_1, + VALUE_2, + VALUE_3; + + private static final String PREFIX = "ENUM_"; + + public static EnumWithStatics fromString(String value) { + return valueOf(value); + } + + static { + // Static initializer + System.out.println("EnumWithStatics loaded"); + } +} + +// Nested enum inside a class +class OuterClass { + public enum NestedEnum { + NESTED_A, + NESTED_B("B", 2) { + @Override + public String getDescription() { + return "Nested B with body"; + } + }; + + private final String label; + private final int code; + + NestedEnum() { + this.label = name(); + this.code = ordinal(); + } + + NestedEnum(String label, int code) { + this.label = label; + this.code = code; + } + + public String getDescription() { + return label; + } + } +} + +// Enum with abstract method implemented in each constant +enum Operation { + PLUS { + int apply(int a, int b) { return a + b; } + }, + MINUS { + int apply(int a, int b) { return a - b; } + }, + MULTIPLY { + int apply(int a, int b) { return a * b; } + }; + + abstract int apply(int a, int b); +} + +// Enum with custom imported types to test potentialQualifiedName resolution +enum ConfigType { + // Tests java.util.List import + LIST_CONFIG(new List[]{}), + + // Tests java.math.BigDecimal import + DECIMAL_CONFIG(new BigDecimal("100.50")), + + // Tests java.time.Duration import + DURATION_CONFIG(Duration.ofSeconds(30)), + + // Tests custom import org.example.custom.MyCustomClass + CUSTOM_CONFIG(new MyCustomClass()), + + // Tests custom import org.example.custom.AnotherType + ANOTHER_CONFIG(new AnotherType()), + + // Tests same-package type (no import needed) + SAME_PACKAGE_CONFIG(new LocalType()), + + // Tests unimported type (should resolve to same package with ambiguity) + UNIMPORTED_CONFIG(new UnknownType()); + + private final Object value; + + ConfigType(Object value) { + this.value = value; + } + + public Object getValue() { + return value; + } +} + +// Local type in same package for testing +class LocalType {} diff --git a/parser/src/test-data/java/expressions/ArrayAccessExamples.java b/parser/src/test-data/java/expressions/ArrayAccessExamples.java new file mode 100644 index 000000000..7b4f42f07 --- /dev/null +++ b/parser/src/test-data/java/expressions/ArrayAccessExamples.java @@ -0,0 +1,282 @@ +package com.inventory.auth.examples13; + +/** + * Test cases for ARRAY_ACCESS expression extraction. + * + * Array access: array[index] + * - array: qualifier expression (QUALIFIER edge role) + * - index: index expression (ARRAY_INDEX edge role) + */ +public class ArrayAccessExamples { + + // ==================== BASIC ARRAY ACCESS ==================== + + // Simple array with literal index + int[] numbers = {10, 20, 30}; + int firstNumber = numbers[0]; + int secondNumber = numbers[1]; + + // String array access + String[] names = {"Alice", "Bob", "Charlie"}; + String firstName = names[0]; + + // Object array access + Object[] objects = {new Object(), "string", 42}; + Object firstObject = objects[0]; + + // ==================== VARIABLE INDEX ==================== + + int index = 1; + int valueAtIndex = numbers[index]; + + // Expression as index + int valueAtExpr = numbers[index + 1]; + + // Method call as index + int valueAtMethod = numbers[getIndex()]; + + private static int getIndex() { return 0; } + + // ==================== MULTI-DIMENSIONAL ARRAY ACCESS ==================== + + // 2D array + int[][] matrix = {{1, 2}, {3, 4}}; + int topLeft = matrix[0][0]; + int bottomRight = matrix[1][1]; + + // 3D array + int[][][] cube = {{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}}; + int cubeElement = cube[0][1][0]; + + // ==================== CHAINED ARRAY ACCESS ==================== + + // Array of arrays (jagged) + int[][] jagged = new int[3][]; + // Access would be: jagged[0][0] after initialization + + // ==================== ARRAY ACCESS ON EXPRESSION RESULTS ==================== + + // Array access on method return + int[] getNumbers() { return new int[] {1, 2, 3}; } + int fromMethod = getNumbers()[0]; + + // Array access on field access + static class Container { + int[] values = {100, 200, 300}; + } + Container container = new Container(); + int fromField = container.values[0]; + + // Array access on new expression + int fromNew = new int[] {5, 10, 15}[1]; + + // ==================== ARRAY ACCESS WITH COMPLEX INDICES ==================== + + class Method { + public int getValue() { + return 0; + } + } + + // Ternary as index + boolean condition = true; + int ternaryIndex = numbers[condition ? 0 : 1]; + + // Binary expression as index + int offset = 1; + int binaryIndex = numbers[offset * 2]; + + // Nested array access as index + int[] indices = {0, 1, 2}; + int nestedIndex = numbers[indices[0]]; + + // ==================== ARRAY ACCESS ON VARIOUS TYPES ==================== + + // Primitive arrays + byte[] bytes = {1, 2, 3}; + byte firstByte = bytes[0]; + + short[] shorts = {100, 200}; + short firstShort = shorts[0]; + + long[] longs = {1000L, 2000L}; + long firstLong = longs[0]; + + float[] floats = {1.0f, 2.0f}; + float firstFloat = floats[0]; + + double[] doubles = {1.0, 2.0}; + double firstDouble = doubles[0]; + + char[] chars = {'a', 'b', 'c'}; + char firstChar = chars[0]; + + boolean[] booleans = {true, false}; + boolean firstBoolean = booleans[0]; + + // ==================== ARRAY ACCESS IN EXPRESSIONS ==================== + + // Array access as operand + int sum = numbers[0] + numbers[1]; + boolean isEqual = numbers[0] == numbers[1]; + + double val = doubles[new Method().getValue()]; + + // Array access in method argument + String formatted = String.valueOf(numbers[0]); + + // Array access in array initializer + int[] derived = {numbers[0], numbers[1], numbers[2]}; + + // ==================== ARRAY ACCESS WITH METHOD REFERENCE ==================== + + // Method reference on array element (array[i]::method) + String[] strings = {"hello", "world"}; + java.util.function.Supplier upperSupplier = strings[0]::toUpperCase; + java.util.function.Supplier upperSupplier2 = strings[new Method().getValue()]::toUpperCase; + + // Method reference on multi-dimensional array element + Object[][] objMatrix = {{new Object()}, {new Object()}}; + java.util.function.Supplier toStringSupplier = objMatrix[0][0]::toString; + + // ==================== ARRAY ACCESS ON CAST EXPRESSION ==================== + + Object arrayObj = new int[] {1, 2, 3}; + int fromCast = ((int[]) arrayObj)[0]; + + // Cast with generics + Object listArrayObj = new java.util.ArrayList[2]; + @SuppressWarnings("unchecked") + java.util.ArrayList fromGenericCast = ((java.util.ArrayList[]) listArrayObj)[0]; + + // ==================== ARRAY ACCESS ON PARENTHESIZED ==================== + + int fromParen = (numbers)[0]; // Parenthesized array reference + int fromParenExpr = (getNumbers())[0]; + + // ==================== INCREMENT/DECREMENT AS INDEX ==================== + + int preIncIdx = 0; + int withPreInc = numbers[++preIncIdx]; // Pre-increment index + int withPostInc = numbers[preIncIdx++]; // Post-increment index + int withPreDec = numbers[--preIncIdx]; // Pre-decrement index + + // ==================== ASSIGNMENT EXPRESSION AS INDEX (rare) ==================== + + int assignIdx; + int withAssignIndex = numbers[assignIdx = 1]; // Assignment as index + + // ==================== ARRAY ACCESS AS METHOD RECEIVER ==================== + + // Method call on array access result + int strLength = strings[0].length(); + char charAtZero = strings[0].charAt(0); + + // Chained method calls on array element + String upperFirst = strings[0].toUpperCase().trim(); + + // ==================== ARRAY ACCESS ON THIS ==================== + + int fromThis = this.numbers[0]; // Explicit this + + // ==================== GENERIC ARRAY ACCESS ==================== + + @SuppressWarnings("unchecked") + java.util.List[] genericArray = new java.util.ArrayList[2]; + java.util.List fromGenericArray = genericArray[0]; + + // ==================== CAST AS INDEX ==================== + + long longIndex = 1L; + int withCastIndex = numbers[(int) longIndex]; + + // ==================== ARRAY ACCESS IN TERNARY BRANCHES ==================== + + int ternaryAccess = condition ? numbers[0] : numbers[1]; + int[] arr1 = {1}, arr2 = {2}; + int ternaryArray = (condition ? arr1 : arr2)[0]; // Ternary result as array + + // ==================== MULTI-DIMENSIONAL ARRAY ACCESS ==================== + // Tree structure for matrix[i][j]: + // ARRAY_ACCESS (ROOT) <- matrix[i][j] + // ├── ARRAY_ACCESS (QUALIFIER) <- matrix[i] + // │ ├── IDENTIFIER_REFERENCE 'matrix' (QUALIFIER) + // │ └── LITERAL 'i' (ARRAY_INDEX) + // └── LITERAL 'j' (ARRAY_INDEX) + String[][] stringMatrix = {{"a", "b"}, {"c", "d"}}; + String matrixElement = stringMatrix[0][1]; + + // ==================== ARRAY ACCESS AS LVALUE (write contexts) ==================== + + int[] mutable = {1, 2, 3}; + int mutIdx = 1; + int lvalueAssign = mutable[0] = 99; + + // Compound assignment on array element + int lvaluePlusEq = mutable[mutIdx] += 5; + + // Post/pre increment on array element + int lvaluePostInc = mutable[mutIdx]++; // value before increment + int lvaluePreInc = ++mutable[mutIdx]; // value after increment + int lvaluePostDec = mutable[mutIdx]--; + int lvaluePreDec = --mutable[mutIdx]; + + // ==================== MORE QUALIFIER VARIANTS ==================== + + // Assignment expression as qualifier + int[] q1 = {1, 2}; + int[] q2 = {3, 4}; + int assignmentQualifierAccess = (q1 = q2)[1]; + + // New array with dimensions as qualifier (needs parentheses) + int newDimQualifierAccess = (new int[3])[0]; + int newDimExprQualifierAccess = (new int[index + 2])[1]; + + // Jagged: initialize inner array + access (assignment as qualifier) + int jaggedInitAndAccess = (jagged[0] = new int[] {11, 22})[1]; + + // ==================== MORE INDEX VARIANTS ==================== + + int iField = 0; + + // Compound assignment as index + int compoundIndexAccess = numbers[iField += 1]; + + // Boxed Integer as index (unboxing) + Integer boxedIndex = 0; + int unboxedIndexAccess = numbers[boxedIndex]; + + // Char as index (char -> int widening) + char charIndex = 1; + int charIndexAccess = numbers[charIndex]; + + // Unary plus/minus in index + int unaryPlusIndexAccess = numbers[+index]; + int unaryMinusIndexAccess = numbers[-0]; + + // ==================== STATIC FIELD QUALIFIER ==================== + + static int[] STATIC_ARR = {9, 8, 7}; + int staticQualifierAccess = ArrayAccessExamples.STATIC_ARR[0]; + + // ==================== SUPER QUALIFIER ==================== + + static class Parent { + int[] parentArr = {5, 6}; + } + + static class Child extends Parent { + // super.parentArr[0] as field initializer + int superQualifierAccess = super.parentArr[0]; + } + + // ==================== FIELD ACCESS ON ARRAY ELEMENT ==================== + + // .length field on array element (matrix[0] is an array) + int firstRowLength = matrix[0].length; + + // ==================== CLONE + CAST ==================== + + // Method invocation returning Object + cast + array access + int cloneCastAccess = ((int[]) numbers.clone())[0]; +} diff --git a/parser/src/test-data/java/expressions/AssertStatements.java b/parser/src/test-data/java/expressions/AssertStatements.java new file mode 100644 index 000000000..9b0b1874d --- /dev/null +++ b/parser/src/test-data/java/expressions/AssertStatements.java @@ -0,0 +1,92 @@ +package com.axiomcode.test.expressions; + +import java.util.List; + +/** + * Acceptance fixture for `assert` (JLS 14.10). + * + * Both halves of an assert are ordinary expressions reaching the same code the + * rest of the graph reaches, and they were extracted into nothing: not the + * condition, not the detail message, and not the calls inside either. An absent + * row is worse than a weak one, because nothing downstream can distinguish + * "no call here" from "a call that was never recorded". + * + * The vocabulary already existed for this: RootContext.ASSERT_CONDITION and + * ASSERT_MESSAGE, and ExpressionOwnerKind.ASSERT_STATEMENT, each documented with + * a worked example naming this construct. + * + * `assert_statement` carries NO grammar fields, so the two halves have to be found in the child + * list rather than asked for by name -- and `line_comment` / `block_comment` are NAMED nodes, so + * a read that takes the first two named children takes a comment as an operand. The commented + * shapes at the end of this file are what discriminate that: with the halves split on the `:` + * token instead, a comment in any position is inert. A BLOCK comment before the condition is + * the worst of them -- it reported the condition call as the MESSAGE and dropped the real + * message, a wrong context rather than a missing row. + */ +public class AssertStatements { + + boolean check() { return true; } + boolean check(int v) { return v > 0; } + String msg() { return "m"; } + + /** Condition only: no detail message, so no ASSERT_MESSAGE row. */ + void conditionOnly() { + assert check(); + } + + /** Both halves are calls, and both must be recorded as call sites. */ + void conditionAndMessage() { + assert check() : msg(); + } + + /** A condition that is not a call still carries its operands. */ + void operands(int x) { + assert x > 0 : "x must be positive"; + } + + /** Nested inside another statement's body: the walk must reach it. */ + void nested(List xs) { + if (!xs.isEmpty()) { + assert check(xs.size()) : msg(); + } + while (xs.size() > 100) { + assert check(1); + break; + } + } + + /** An assert is an expression position like any other. */ + void richExpressions() { + assert new Runnable() { @Override public void run() { } } != null : "anon"; + assert switch (1) { default -> true; }; + } + + // ── Comments. Each of these holds the same two calls as conditionAndMessage above, so the + // expected row set is identical: check() as the condition, msg() as the message. ── + + /** A line comment between the condition and the `:`. The message produced no rows. */ + void commentBeforeColon() { + assert check() // why + : msg(); + } + + /** A block comment before the condition: reported check() as the MESSAGE, dropped msg(). */ + void commentBeforeCondition() { + assert /* invariant */ check() : msg(); + } + + /** A comment after the `:`, before the message. */ + void commentAfterColon() { + assert check() : /* detail */ msg(); + } + + /** A comment in a condition-only assert, where there is no `:` to split on. */ + void commentInConditionOnly() { + assert /* invariant */ check(); + } + + /** A comment in every position at once. */ + void commentEverywhere() { + assert /* a */ check() /* b */ : /* c */ msg(); // d + } +} diff --git a/parser/src/test-data/java/expressions/AssignmentExpressionExamples.java b/parser/src/test-data/java/expressions/AssignmentExpressionExamples.java new file mode 100644 index 000000000..02ec5c51a --- /dev/null +++ b/parser/src/test-data/java/expressions/AssignmentExpressionExamples.java @@ -0,0 +1,557 @@ +package com.inventory.auth.examples11; + +import java.util.*; +import java.util.function.*; +import java.util.stream.*; +import java.io.*; +import java.nio.file.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +/** + * Comprehensive examples of ASSIGNMENT_EXPRESSION patterns. + * + * ASSIGNMENT_EXPRESSION is used when assignment (=) is used as an expression, + * meaning the assignment itself produces a value that is used elsewhere. + * + * Key patterns: + * - Chained assignments: x = y = z = value + * - Assignment in expressions: result = (temp = getValue()) + * - Assignment with complex RHS values + */ +public class AssignmentExpressionExamples { + + class Temp { + public static int TEMP = 10; + } + + // ========================================================================= + // SECTION 1: Basic Chained Assignments + // ========================================================================= + + // Simple chained primitive assignments + int a = 1; + int b = a; + int c = b; + + // Triple chain - classic ASSIGNMENT_EXPRESSION + int x, y, z; + int chainResult = x = y = z = 100; + + int anotherCheck = (x = y = z += Temp.TEMP + 10); + + // Quadruple chain + int p, q, r, s; + int quadChain = p = q = r = s = 50; + + // Chain with different compatible types + long longVal; + int intVal; + long chainedLong = longVal = intVal = 42; + + // Double chain + double d1, d2, d3; + double doubleChain = d1 = d2 = d3 = 3.14159; + + // Float chain + float f1, f2; + float floatChain = f1 = f2 = 2.5f; + + // ========================================================================= + // SECTION 2: Chained Assignments with Object Types + // ========================================================================= + + // String chain + String str1, str2, str3; + String stringChain = str1 = str2 = str3 = "chained"; + + // Object chain + Object obj1, obj2; + Object objectChain = obj1 = obj2 = new Object(); + + // StringBuilder chain + StringBuilder sb1, sb2; + StringBuilder sbChain = sb1 = sb2 = new StringBuilder("builder"); + + // ========================================================================= + // SECTION 3: Chained Assignments with Generic Types + // ========================================================================= + + // List chain with generics + List list1, list2; + List listChain = list1 = list2 = new ArrayList<>(); + + // Map chain with generics + Map map1, map2; + Map mapChain = map1 = map2 = new HashMap<>(); + + // Set chain + Set set1, set2; + Set setChain = set1 = set2 = new HashSet<>(); + + // Nested generic chain + Map> nestedMap1, nestedMap2; + Map> nestedMapChain = nestedMap1 = nestedMap2 = new HashMap<>(); + + // Wildcard generic chain + List wildList1, wildList2; + List wildListChain = wildList1 = wildList2 = new ArrayList(); + + // Bounded wildcard chain + List boundedList1, boundedList2; + List boundedListChain = boundedList1 = boundedList2 = new ArrayList(); + + // ========================================================================= + // SECTION 4: Chained Assignments with Array Types + // ========================================================================= + + // Primitive array chain + int[] arr1, arr2; + int[] arrayChain = arr1 = arr2 = new int[10]; + + // Object array chain + String[] strArr1, strArr2; + String[] strArrayChain = strArr1 = strArr2 = new String[]{"a", "b", "c"}; + + // Multi-dimensional array chain + int[][] matrix1, matrix2; + int[][] matrixChain = matrix1 = matrix2 = new int[3][3]; + + // 3D array chain + double[][][] cube1, cube2; + double[][][] cubeChain = cube1 = cube2 = new double[2][2][2]; + + // ========================================================================= + // SECTION 5: Chained Assignments with Functional Types + // ========================================================================= + + // Supplier chain + Supplier supplier1, supplier2; + Supplier supplierChain = supplier1 = supplier2 = () -> 42; + + // Function chain + Function func1, func2; + Function funcChain = func1 = func2 = String::length; + + // Consumer chain + Consumer consumer1, consumer2; + Consumer consumerChain = consumer1 = consumer2 = System.out::println; + + // Predicate chain + Predicate pred1, pred2; + Predicate predChain = pred1 = pred2 = n -> n > 0; + + // BiFunction chain + BiFunction biFunc1, biFunc2; + BiFunction biFuncChain = biFunc1 = biFunc2 = (a1, b1) -> a1 + b1; + + // ========================================================================= + // SECTION 6: Assignment in Parenthesized Expressions + // ========================================================================= + + int tempA; + int parenAssign = (tempA = 10) + 5; + + int tempB; + int parenMultiply = (tempB = 20) * 2; + + int tempC, tempD; + int nestedParen = ((tempC = 5) + (tempD = 10)); + + String tempStr; + int lengthFromAssign = (tempStr = "hello").length(); + + // ========================================================================= + // SECTION 7: Assignment with Object Creation Expressions + // ========================================================================= + + // Chain with ArrayList creation and method arguments + List createdList; + int listSize = (createdList = new ArrayList<>(Arrays.asList("a", "b", "c"))).size(); + + // Chain with HashMap and initial capacity + Map createdMap; + boolean mapEmpty = (createdMap = new HashMap<>(16, 0.75f)).isEmpty(); + + // Chain with StringBuilder and initial content + StringBuilder createdSb; + int sbLength = (createdSb = new StringBuilder("initial")).length(); + + // ========================================================================= + // SECTION 8: Assignment with Generic Method Calls + // ========================================================================= + + List genericResult; + List fromGenericMethod = genericResult = Collections.emptyList(); + + Set genericSetResult; + Set fromGenericSet = genericSetResult = Collections.emptySet(); + + Map genericMapResult; + Map fromGenericMap = genericMapResult = Collections.emptyMap(); + + // ========================================================================= + // SECTION 9: Assignment with Complex Type Creations + // ========================================================================= + + // Anonymous class in chain + Runnable runnableTemp; + Runnable runnableChain = runnableTemp = new Runnable() { + @Override + public void run() { + System.out.println("Running"); + } + }; + + // Comparator anonymous class chain + Comparator compTemp; + Comparator compChain = compTemp = new Comparator() { + @Override + public int compare(String o1, String o2) { + return o1.compareTo(o2); + } + }; + + // ========================================================================= + // SECTION 10: Assignment with Ternary Expressions + // ========================================================================= + + int ternaryTemp; + int ternaryChain = ternaryTemp = (10 > 5) ? 100 : 200; + + String strTernaryTemp; + String strTernaryChain = strTernaryTemp = (true) ? "yes" : "no"; + + // Nested ternary with assignment + int nestedTernaryTemp; + int nestedTernaryChain = nestedTernaryTemp = (5 > 3) ? ((2 > 1) ? 10 : 20) : 30; + + // ========================================================================= + // SECTION 11: Assignment with Cast Expressions + // ========================================================================= + + Object objTemp; + String castChain = (String)(objTemp = "casted"); + + Number numTemp; + int castIntChain = (int)(double)(numTemp = 42.5).doubleValue(); + + // ========================================================================= + // SECTION 12: Assignment with Binary Expressions + // ========================================================================= + + int binTemp1, binTemp2; + int binarySum = (binTemp1 = 10) + (binTemp2 = 20); + + int binTemp3, binTemp4; + int binaryProduct = (binTemp3 = 5) * (binTemp4 = 6); + + boolean boolTemp1, boolTemp2; + boolean logicalAnd = (boolTemp1 = true) && (boolTemp2 = false); + + int bitTemp1, bitTemp2; + int bitwiseOr = (bitTemp1 = 0xFF) | (bitTemp2 = 0x0F); + + // ========================================================================= + // SECTION 13: Assignment with Unary Expressions + // ========================================================================= + + int unaryTemp; + int negatedAssign = -(unaryTemp = 42); + + boolean boolUnaryTemp; + boolean notAssign = !(boolUnaryTemp = true); + + // ========================================================================= + // SECTION 14: Assignment with Array Access + // ========================================================================= + + int[] arrayForAccess = new int[10]; + int arrayIndexTemp; + int arrayAccessResult = arrayForAccess[arrayIndexTemp = 5]; + + int[][] matrix = new int[5][5]; + int rowTemp, colTemp; + int matrixAccess = matrix[rowTemp = 2][colTemp = 3]; + + // ========================================================================= + // SECTION 15: Assignment with Method Reference Targets + // ========================================================================= + + Function methodRefTemp; + Function methodRefChain = methodRefTemp = String::toUpperCase; + + Supplier> constructorRefTemp; + Supplier> constructorRefChain = constructorRefTemp = ArrayList::new; + + BiFunction instanceMethodRefTemp; + BiFunction instanceMethodRefChain = instanceMethodRefTemp = String::concat; + + // ========================================================================= + // SECTION 16: Assignment with Stream Operations + // ========================================================================= + + Stream streamTemp; + List streamResult = (streamTemp = Stream.of(1, 2, 3, 4, 5)).collect(Collectors.toList()); + + IntStream intStreamTemp; + int streamSum = (intStreamTemp = IntStream.range(1, 10)).sum(); + + // ========================================================================= + // SECTION 17: Assignment with Optional + // ========================================================================= + + Optional optTemp; + String optResult = (optTemp = Optional.of("value")).orElse("default"); + + Optional optIntTemp; + int optIntResult = (optIntTemp = Optional.of(42)).orElseThrow(); + + // ========================================================================= + // SECTION 18: Assignment with Concurrent Types + // ========================================================================= + + AtomicInteger atomicTemp; + int atomicValue = (atomicTemp = new AtomicInteger(100)).get(); + + AtomicReference atomicRefTemp; + String atomicRefValue = (atomicRefTemp = new AtomicReference<>("atomic")).get(); + + ConcurrentHashMap concurrentMapTemp; + boolean concurrentEmpty = (concurrentMapTemp = new ConcurrentHashMap<>()).isEmpty(); + + // ========================================================================= + // SECTION 19: Assignment with File/Path Types + // ========================================================================= + + Path pathTemp; + String pathString = (pathTemp = Paths.get("/tmp/test")).toString(); + + File fileTemp; + String fileName = (fileTemp = new File("/tmp/test.txt")).getName(); + + // ========================================================================= + // SECTION 20: Complex Nested Assignment Chains + // ========================================================================= + + // Triple object creation chain + List listA, listB, listC; + int tripleListSize = (listA = listB = listC = new ArrayList<>()).size(); + + // Chain with method call result + String strA, strB, strC; + int tripleStrLen = (strA = strB = strC = String.valueOf(12345)).length(); + + // Deep nesting with multiple assignments + int deepA, deepB, deepC, deepD; + int deepResult = ((deepA = (deepB = (deepC = (deepD = 1) + 1) + 1) + 1)); + + // Mixed type chain through common supertype + Number numA, numB; + Number numChain = numA = numB = Integer.valueOf(42); + + // ========================================================================= + // SECTION 21: Assignment with Varargs Method Calls + // ========================================================================= + + List varargListTemp; + List varargResult = varargListTemp = Arrays.asList("one", "two", "three", "four", "five"); + + String joinedTemp; + String joinedResult = joinedTemp = String.join(", ", "a", "b", "c", "d"); + + // ========================================================================= + // SECTION 22: Assignment with Builder Pattern + // ========================================================================= + + StringBuilder builderTemp; + String builderResult = (builderTemp = new StringBuilder()) + .append("Hello") + .append(" ") + .append("World") + .toString(); + + StringJoiner joinerTemp; + String joinerResult = (joinerTemp = new StringJoiner(", ", "[", "]")) + .add("item1") + .add("item2") + .toString(); +} + +// ============================================================================= +// SECTION 23: Nested Class with Assignment Expressions +// ============================================================================= + +class InnerAssignmentExamples { + + int innerA, innerB; + int innerChain = innerA = innerB = 999; + + static int staticA, staticB; + static int staticChain = staticA = staticB = 888; + + // Generic inner class + static class GenericHolder { + T value1, value2; + T valueChain; + + void initChain(T val) { + valueChain = value1 = value2 = val; + } + } + + GenericHolder holder1, holder2; + GenericHolder holderChain = holder1 = holder2 = new GenericHolder<>(); +} + +// ============================================================================= +// SECTION 24: Interface with Assignment Expressions in Default Fields +// ============================================================================= + +interface AssignmentInterface { + // Interface constants with complex initialization + int CONST_A = 10; + int CONST_B = CONST_A; + int CONST_C = CONST_B * 2; + + List EMPTY_LIST = Collections.emptyList(); + Map EMPTY_MAP = Collections.emptyMap(); + + Supplier DEFAULT_SUPPLIER = () -> "default"; + Function INT_TO_STRING = String::valueOf; +} + +// ============================================================================= +// SECTION 25: Enum with Assignment Expressions +// ============================================================================= + +enum AssignmentEnum { + FIRST(1), + SECOND(2), + THIRD(3); + + private final int value; + private static int staticTemp; + private static int staticInitialized = staticTemp = 100; + + AssignmentEnum(int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} + +// ============================================================================= +// SECTION 26: Abstract Class with Assignment Expressions +// ============================================================================= + +abstract class AbstractAssignmentExamples { + + protected int protectedA, protectedB; + protected int protectedChain = protectedA = protectedB = 777; + + abstract void process(); + + // Non-abstract method with assignment + int getChainedValue() { + int local1, local2; + return local1 = local2 = 42; + } +} + +// ============================================================================= +// SECTION 27: Record with Assignment Expressions (Java 16+) +// ============================================================================= + +// Note: Records have restrictions but can have static fields with assignments +// Uncomment if using Java 16+ +/* +record AssignmentRecord(int x, int y) { + static int staticA, staticB; + static int staticChain = staticA = staticB = 123; + + static List recordList; + static List recordListChain = recordList = new ArrayList<>(); +} +*/ + +// ============================================================================= +// SECTION 28: Multiple Type Parameters with Assignment +// ============================================================================= + +class MultiTypeAssignment { + + T tValue1, tValue2; + U uValue1, uValue2; + V vValue1, vValue2; + + Map mapTU1, mapTU2; + Map mapTUChain; + + BiFunction biFunc1, biFunc2; + BiFunction biFuncChain; + + void initializeAll(T t, U u, V v, Map map, BiFunction func) { + T localT = tValue1 = tValue2 = t; + U localU = uValue1 = uValue2 = u; + V localV = vValue1 = vValue2 = v; + mapTUChain = mapTU1 = mapTU2 = map; + biFuncChain = biFunc1 = biFunc2 = func; + } +} + +// ============================================================================= +// SECTION 29: Bounded Type Parameters with Assignment +// ============================================================================= + +class BoundedTypeAssignment> { + + T value1, value2, value3; + T valueChain; + + List list1, list2; + List listChain; + + Comparator comp1, comp2; + Comparator compChain; + + void initialize(T val, List list, Comparator comp) { + valueChain = value1 = value2 = value3 = val; + listChain = list1 = list2 = list; + compChain = comp1 = comp2 = comp; + } +} + +// ============================================================================= +// SECTION 30: Static Initializer Block Assignments +// ============================================================================= + +class StaticInitializerAssignments { + + static int staticA, staticB, staticC; + static List staticList1, staticList2; + static Map staticMap1, staticMap2; + + static { + int localResult = staticA = staticB = staticC = 500; + List localList = staticList1 = staticList2 = new ArrayList<>(); + Map localMap = staticMap1 = staticMap2 = new HashMap<>(); + } +} + +// ============================================================================= +// SECTION 31: Instance Initializer Block Assignments +// ============================================================================= + +class InstanceInitializerAssignments { + + int instanceA, instanceB, instanceC; + List instanceList1, instanceList2; + + { + int localResult = instanceA = instanceB = instanceC = 300; + List localList = instanceList1 = instanceList2 = new ArrayList<>(); + } +} diff --git a/parser/src/test-data/java/expressions/CastExpressionExamples.java b/parser/src/test-data/java/expressions/CastExpressionExamples.java new file mode 100644 index 000000000..0212f10b8 --- /dev/null +++ b/parser/src/test-data/java/expressions/CastExpressionExamples.java @@ -0,0 +1,248 @@ +package com.inventory.auth.examples12; + +import java.util.List; +import java.util.Map; +import java.util.ArrayList; +import java.io.Serializable; + +/** + * Test cases for CAST_EXPRESSION extraction. + */ +public class CastExpressionExamples { + + // Simple primitive cast + Object numObj = 42; + int simpleInt = (int) numObj; + + // Simple reference cast + Object strObj = "hello"; + String simpleStr = (String) strObj; + + // Cast with chained operations + Object obj1 = "world"; + int length = ((String) obj1).length(); + + // Nested casts + Object nested = 100; + long nestedCast = (long) (int) nested; + + // Cast in binary expression + Object leftObj = 10; + Object rightObj = 20; + int sum = (int) leftObj + (int) rightObj; + + // Generic type cast + Object listObj = new ArrayList(); + List genericCast = (List) listObj; + + // Complex generic cast + Object mapObj = null; + Map> complexCast = (Map>) mapObj; + + // Array type cast + Object arrObj = new int[]{1, 2, 3}; + int[] arrayCast = (int[]) arrObj; + + // 2D array cast + Object matrix = new int[][]{{1, 2}, {3, 4}}; + int[][] matrixCast = (int[][]) matrix; + + // Cast in ternary expression + Object condObj = "test"; + String ternaryCast = condObj != null ? (String) condObj : "default"; + + // Cast in assignment chain + Object chainObj = 50; + int a, b; + int chainCast = a = b = (int) chainObj; + + // Cast with parenthesized expression + Object parenObj = 100; + int parenCast = (int) ((Object) parenObj); + + public void someFunctions() { + try { + String ternaryCast = condObj != null ? (String) condObj : "default"; + List upperBoundedWildcard = (List) wildcardObj2; + } catch (Exception e) { + + } + } + + // ==================== ADVANCED GENERIC CASTS ==================== + + // Wildcard - unbounded + Object wildcardObj1 = new ArrayList(); + List unboundedWildcard = (List) wildcardObj1; + + // Wildcard - upper bounded (extends) + Object wildcardObj2 = new ArrayList(); + List upperBoundedWildcard = (List) wildcardObj2; + + // Wildcard - lower bounded (super) + Object wildcardObj3 = new ArrayList(); + List lowerBoundedWildcard = (List) wildcardObj3; + + // Deeply nested generics + Object deepObj = null; + Map>> deeplyNested = + (Map>>) deepObj; + + // Multiple wildcards + Object multiWildcard = null; + Map multiWildcardCast = + (Map) multiWildcard; + + // ==================== INNER CLASS CASTS ==================== + + // Inner class cast (simulated with Map.Entry) + Object entryObj = null; + Map.Entry innerClassCast = (Map.Entry) entryObj; + + // ==================== CAST OF EXPRESSIONS ==================== + + // Cast of method invocation result + Object methodResult = getObject(); + String castMethodResult = (String) getObject(); + + // Cast of field access + Object fieldVal = this.numObj; + Integer castFieldAccess = (Integer) this.numObj; + + // Cast in method argument (inline) + int hashOfCast = ((String) strObj).hashCode(); + + // ==================== OBJECT ARRAY CASTS ==================== + + // Object array to specific array + Object[] objArray = new String[]{"a", "b"}; + String[] stringArrayCast = (String[]) objArray; + + // Generic array (with warning) + Object genericArrObj = new ArrayList[3]; + ArrayList[] genericArrayCast = (ArrayList[]) genericArrObj; + + // ==================== CAST WITH OTHER EXPRESSIONS ==================== + + // Cast in unary expression + Object boolObj = true; + boolean notCast = !((Boolean) boolObj); + + // Cast result used in instanceof (rare but valid) + Object checkObj = "test"; + boolean instanceCheck = ((Object) checkObj) instanceof String; + + // Multiple casts in one expression + Object multiObj = 42; + String multiCastExpr = String.valueOf((int) (long) (Long) (Object) 42L); + + // Helper method for testing + private static Object getObject() { + return "result"; + } + + // ==================== INTERSECTION TYPE CASTS (Java 8+) ==================== + // This is the most significant missing case! + + Object intersectObj = "hello"; + Comparable intersectionCast = (Serializable & Comparable) intersectObj; + + // Multiple intersection bounds + Object multiIntersect = "test"; + Object tripleIntersect = (Serializable & Comparable & CharSequence) multiIntersect; + + // ==================== CONTEXTUAL POSITIONS ==================== + + // Cast in array initializer + Object e1 = "a", e2 = "b"; + String[] castInArrayInit = {(String) e1, (String) e2}; + + // Cast of array access result + Object[] objects = {"test"}; + String castArrayAccess = (String) objects[0]; + + // ==================== TYPE REPRESENTATION VARIANTS ==================== + + // Fully qualified type name in cast + Object fqnObj = new ArrayList(); + java.util.List fullyQualifiedCast = (java.util.List) fqnObj; + + // Raw type cast (erased generics) + List typedList = new ArrayList<>(); + List rawTypeCast = (List) typedList; + + // ==================== EDGE CASES ==================== + + // Cast of 'new' expression (unusual but valid) + Object castOfNew = (Object) new String("direct"); + + // Cast of literal (unusual but valid) + Object castOfLiteral = (Object) "literal"; + Number castNumLiteral = (Number) (Object) 42; + + // ==================== LAMBDA + METHOD REFERENCE CASTS (Java 8+) ==================== + + // Cast of lambda expression to functional interface + Object lambdaAsRunnable = (Runnable) () -> {}; + + // Cast of generic functional interface target + java.util.concurrent.Callable lambdaAsCallable = + (java.util.concurrent.Callable) () -> "ok"; + + // Cast of method reference to functional interface + java.util.function.Supplier methodRefAsSupplier = + (java.util.function.Supplier) String::new; + + // Intersection type cast with lambda (common for serializable lambdas) + Runnable serializableLambda = + (java.io.Serializable & Runnable) () -> {}; + + // Intersection type cast with method reference + Runnable serializableMethodRef = + (java.io.Serializable & Runnable) CastExpressionExamples::staticRun; + + private static void staticRun() {} + + // ==================== TYPE-USE ANNOTATIONS IN CASTS (Java 8+) ==================== + + @java.lang.annotation.Target({ + java.lang.annotation.ElementType.TYPE_USE, + java.lang.annotation.ElementType.TYPE_PARAMETER + }) + @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME) + @interface TA {} + + Object annotatedObj = "annotated"; + String annotatedCast = (@TA String) annotatedObj; + + Object annotatedListObj = new java.util.ArrayList(); + java.util.List<@TA String> annotatedTypeArgCast = + (java.util.List<@TA String>) annotatedListObj; + + Object annotatedArrayObj = new String[] {"a"}; + String @TA [] annotatedArrayDimCast = + (String[]) annotatedArrayObj; + + // ==================== TYPE VARIABLE CASTS ==================== + + static class Holder { + Object value; + + @SuppressWarnings("unchecked") + T asT() { return (T) value; } + } + + // ==================== QUALIFIED / PARAMETERIZED INNER TYPE CAST ==================== + + static class Outer { + class Inner {} + } + + Object innerObj = null; + Outer.Inner qualifiedInnerCast = + (Outer.Inner) innerObj; + + // ==================== NULL LITERAL CAST ==================== + + String castNullLiteral = (String) null; +} diff --git a/parser/src/test-data/java/expressions/CommentedTernary.java b/parser/src/test-data/java/expressions/CommentedTernary.java new file mode 100644 index 000000000..bcdb05f0b --- /dev/null +++ b/parser/src/test-data/java/expressions/CommentedTernary.java @@ -0,0 +1,60 @@ +package expressions; + +/** + * A comment inside a ternary must not move its operands. + * + * `line_comment` and `block_comment` are NAMED nodes in tree-sitter-java, so reading + * `namedChildren[0..2]` positionally hands back the comment as an operand. Under that read the + * `commentBeforeQuestion` shape emitted `whenTrue()` with edgeRole TERNARY_FALSE and dropped + * `whenFalse()` altogether: not a weaker answer but a wrong one, since the engine unions the two + * branch types to type a ternary receiver. + * + * Each shape below carries the same two calls, so the expected row set is identical for all of + * them and any positional read fails on at least one. + */ +public class CommentedTernary { + + String whenTrue() { return "t"; } + String whenFalse() { return "f"; } + boolean flag; + + /** The control: no comment anywhere, correct under either read. */ + String noComment() { + return flag ? whenTrue() : whenFalse(); + } + + /** A comment between the condition and the `?` — the shape that produced a WRONG role. */ + String commentBeforeQuestion() { + return flag // + ? whenTrue() + : whenFalse(); + } + + /** A comment between the `?` arm and the `:` — dropped the false branch. */ + String commentAfterQuestion() { + return flag + ? whenTrue() // + : whenFalse(); + } + + /** A block comment, which is a named node just as a line comment is. */ + String blockComment() { + return flag /* why */ ? whenTrue() : whenFalse(); + } + + /** A comment in every position at once. */ + String commentEverywhere() { + return flag // cond + ? whenTrue() // yes + : whenFalse(); // no + } + + /** Nested ternaries, each with a comment, so a shift in the outer cannot hide in the inner. */ + String nested() { + return flag // + ? whenTrue() + : flag // + ? whenTrue() + : whenFalse(); + } +} diff --git a/parser/src/test-data/java/expressions/CommentsAreInvisible.java b/parser/src/test-data/java/expressions/CommentsAreInvisible.java new file mode 100644 index 000000000..36c85d540 --- /dev/null +++ b/parser/src/test-data/java/expressions/CommentsAreInvisible.java @@ -0,0 +1,42 @@ +package com.axiomcode.test.expressions; + +/** + * Acceptance fixture for the invariant that a comment does not change extraction. + * + * tree-sitter models a comment as a NAMED child, so any read that indexes into + * namedChildren shifts when a comment appears. That is not a corner case: a + * comment in an ordinary position moved the operand out of the slot being read + * and the expression was dropped entirely, giving a call site with no row, which + * nothing downstream can distinguish from code that makes no call. + * + * Measured before the fix, on the constructs below: 6 of 22 expression rows + * disappeared and one changed kind, taking an instanceof pattern binding with it. + * + * This file is the twin of CommentsAreInvisibleControl.java. The two are + * identical apart from the comments, and the test asserts that they extract to + * the same multiset of expression rows. Asserting the equivalence rather than a + * fixed list is what makes the fixture cover constructs added later: any new + * positional read that a comment can shift will break the pair. + * + * Comments are placed in the leading position wherever one is legal, because a + * leading comment shifts index 0, which is the index nearly every read uses. + */ +public class CommentsAreInvisible { + + int f() { return 1; } + boolean c; + Object o; + + int returnValue() { return /* a */ f(); } + void throwValue() { throw /* a */ new RuntimeException(); } + void expressionStmt() { /* a */ f(); } + void ifCondition() { if (/* a */ c) { /* b */ f(); } } + void whileCondition() { while (/* a */ c) { /* b */ f(); } } + void instanceOf() { if (o /* a */ instanceof /* b */ String s) { f(); } } + int ternary() { return c /* a */ ? /* b */ f() /* c */ : /* d */ 0; } + void assertStatement() { assert /* a */ c /* b */ : /* c */ "m"; } + void parenthesized() { int x = (/* a */ f()); } + void castExpression() { Object x = (Object) /* a */ f(); } + void forClauses() { for (int i = /* a */ 0; /* b */ c; /* c */ i++) { } } + int switchArm() { return switch (1) { default -> /* a */ f(); }; } +} diff --git a/parser/src/test-data/java/expressions/CommentsAreInvisibleControl.java b/parser/src/test-data/java/expressions/CommentsAreInvisibleControl.java new file mode 100644 index 000000000..d30a7754f --- /dev/null +++ b/parser/src/test-data/java/expressions/CommentsAreInvisibleControl.java @@ -0,0 +1,28 @@ +package com.axiomcode.test.expressions; + +/** + * The uncommented twin of CommentsAreInvisible.java. + * + * The two files hold identical code apart from the comments, and the test + * asserts they extract to the same multiset of expression rows. This one is the + * reference: whatever it produces is what the commented file must produce. + */ +public class CommentsAreInvisibleControl { + + int f() { return 1; } + boolean c; + Object o; + + int returnValue() { return f(); } + void throwValue() { throw new RuntimeException(); } + void expressionStmt() { f(); } + void ifCondition() { if (c) { f(); } } + void whileCondition() { while (c) { f(); } } + void instanceOf() { if (o instanceof String s) { f(); } } + int ternary() { return c ? f() : 0; } + void assertStatement() { assert c : "m"; } + void parenthesized() { int x = (f()); } + void castExpression() { Object x = (Object) f(); } + void forClauses() { for (int i = 0; c; i++) { } } + int switchArm() { return switch (1) { default -> f(); }; } +} diff --git a/parser/src/test-data/java/expressions/ExpressionStatementTests.java b/parser/src/test-data/java/expressions/ExpressionStatementTests.java new file mode 100644 index 000000000..c13d17505 --- /dev/null +++ b/parser/src/test-data/java/expressions/ExpressionStatementTests.java @@ -0,0 +1,352 @@ +package com.inventory.auth.examples22; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Comprehensive test file for expression statement extraction. + * Tests all types of expression statements that can appear in method bodies. + * + * Expression statements are standalone expressions used as statements: + * - Assignment expressions (simple and compound) + * - Method invocations + * - Object creation + * - Pre/post increment/decrement + */ +public class ExpressionStatementTests { + + // Fields for testing + private int count = 0; + private String name = "default"; + private List items = new ArrayList<>(); + private int[] numbers = new int[10]; + private int x = 0; + private static int staticCount = 0; + + // ======================================== + // STATIC INITIALIZER BLOCK + // ======================================== + static { + staticCount = 100; // ASSIGNMENT_EXPRESSION in static initializer + staticCount++; // UNARY_EXPRESSION in static initializer + System.out.println("Static initializer"); // METHOD_INVOCATION in static initializer + + // Lambda expression in static initializer + java.util.function.Consumer printer = s -> System.out.println(s); + + // Anonymous class in static initializer + new Runnable() { + @Override + public void run() { + System.out.println("Anonymous in static init"); + } + }; + } + + // ======================================== + // INSTANCE INITIALIZER BLOCK + // ======================================== + { + count = 50; // ASSIGNMENT_EXPRESSION in instance initializer + count++; // UNARY_EXPRESSION in instance initializer + doSomething(); // METHOD_INVOCATION in instance initializer + + // Object creation in instance initializer + new ArrayList(); + + // Ternary/conditional in instance initializer + count = count > 0 ? count : 1; + } + + // ======================================== + // ASSIGNMENT EXPRESSIONS (=) + // ======================================== + + /** Simple field assignment: this.field = value */ + public void testSimpleFieldAssignment(String newName) { + this.name = newName; // ASSIGNMENT_EXPRESSION with FIELD_ACCESS target + this.x = (int) (Math.random() * 10); + } + + /** Field assignment without this qualifier */ + public void testFieldAssignmentNoThis(int value) { + count = value; // ASSIGNMENT_EXPRESSION with IDENTIFIER_REFERENCE target + } + + /** Chain assignment: a = b = c */ + public void testChainAssignment(int value) { + int a = 0; + int b = 0; + a = b = value; // Nested ASSIGNMENT_EXPRESSION + } + + /** Assignment with expression on right side */ + public void testAssignmentWithExpression(int a, int b) { + this.count = a + b; // ASSIGNMENT_EXPRESSION with BINARY_EXPRESSION value + } + + /** Assignment with method call on right side */ + public void testAssignmentWithMethodCall() { + this.name = getName(); // ASSIGNMENT_EXPRESSION with METHOD_INVOCATION value + } + + /** Assignment with ternary on right side */ + public void testAssignmentWithTernary(boolean flag, String a, String b) { + this.name = flag ? a : b; // ASSIGNMENT_EXPRESSION with TERNARY_EXPRESSION value + } + + /** Assignment to array element */ + public void testArrayAssignment(int index, int value) { + numbers[index] = value; // ASSIGNMENT_EXPRESSION with ARRAY_ACCESS target + } + + /** Assignment with cast */ + public void testAssignmentWithCast(Object obj) { + this.name = (String) obj; // ASSIGNMENT_EXPRESSION with CAST_EXPRESSION value + } + + /** Assignment with object creation */ + public void testAssignmentWithObjectCreation() { + this.items = new ArrayList<>(); // ASSIGNMENT_EXPRESSION with OBJECT_CREATION value + } + + // ======================================== + // COMPOUND ASSIGNMENT EXPRESSIONS (+=, -=, etc.) + // ======================================== + + /** Compound addition assignment */ + public void testCompoundAddition(int value) { + count += value; // COMPOUND_ASSIGNMENT (+=) + } + + /** Compound subtraction assignment */ + public void testCompoundSubtraction(int value) { + count -= value; // COMPOUND_ASSIGNMENT (-=) + } + + /** Compound multiplication assignment */ + public void testCompoundMultiplication(int value) { + count *= value; // COMPOUND_ASSIGNMENT (*=) + } + + /** Compound division assignment */ + public void testCompoundDivision(int value) { + count /= value; // COMPOUND_ASSIGNMENT (/=) + } + + /** Compound modulo assignment */ + public void testCompoundModulo(int value) { + count %= value; // COMPOUND_ASSIGNMENT (%=) + } + + /** Compound bitwise AND assignment */ + public void testCompoundBitwiseAnd(int value) { + count &= value; // COMPOUND_ASSIGNMENT (&=) + } + + /** Compound bitwise OR assignment */ + public void testCompoundBitwiseOr(int value) { + count |= value; // COMPOUND_ASSIGNMENT (|=) + } + + /** Compound bitwise XOR assignment */ + public void testCompoundBitwiseXor(int value) { + count ^= value; // COMPOUND_ASSIGNMENT (^=) + } + + /** Compound left shift assignment */ + public void testCompoundLeftShift(int value) { + count <<= value; // COMPOUND_ASSIGNMENT (<<=) + } + + /** Compound right shift assignment */ + public void testCompoundRightShift(int value) { + count >>= value; // COMPOUND_ASSIGNMENT (>>=) + } + + /** Compound unsigned right shift assignment */ + public void testCompoundUnsignedRightShift(int value) { + count >>>= value; // COMPOUND_ASSIGNMENT (>>>=) + } + + /** String concatenation assignment */ + public void testStringConcatAssignment(String suffix) { + name += suffix; // COMPOUND_ASSIGNMENT (+=) on String + } + + // ======================================== + // METHOD INVOCATION STATEMENTS + // ======================================== + + /** Simple method call statement */ + public void testSimpleMethodCall() { + doSomething(); // METHOD_INVOCATION (no receiver) + } + + /** Method call on this */ + public void testMethodCallOnThis() { + this.doSomething(); // METHOD_INVOCATION with THIS_REFERENCE receiver + } + + /** Static method call */ + public void testStaticMethodCall() { + System.out.println("test"); // METHOD_INVOCATION with FIELD_ACCESS receiver + } + + /** Chained method calls */ + public void testChainedMethodCalls() { + items.clear(); // METHOD_INVOCATION on field + items.add("item"); // METHOD_INVOCATION with argument + } + + /** Method call with multiple arguments */ + public void testMethodWithMultipleArgs(String a, String b, String c) { + processItems(a, b, c); // METHOD_INVOCATION with 3 IDENTIFIER_REFERENCE args + } + + /** Method call with expression arguments */ + public void testMethodWithExpressionArgs(int a, int b) { + processNumber(a + b); // METHOD_INVOCATION with BINARY_EXPRESSION arg + } + + /** Method call with lambda argument */ + public void testMethodWithLambdaArg() { + items.forEach(item -> System.out.println(item)); // METHOD_INVOCATION with LAMBDA_EXPRESSION arg + } + + /** Method call with method reference argument */ + public void testMethodWithMethodRefArg() { + items.forEach(System.out::println); // METHOD_INVOCATION with METHOD_REFERENCE arg + + } + + // ======================================== + // INCREMENT/DECREMENT STATEMENTS + // ======================================== + + /** Pre-increment statement */ + public void testPreIncrement() { + ++count; // UNARY_EXPRESSION (PREFIX ++) + } + + /** Post-increment statement */ + public void testPostIncrement() { + count++; // UNARY_EXPRESSION (POSTFIX ++) + } + + /** Pre-decrement statement */ + public void testPreDecrement() { + --count; // UNARY_EXPRESSION (PREFIX --) + } + + /** Post-decrement statement */ + public void testPostDecrement() { + count--; // UNARY_EXPRESSION (POSTFIX --) + } + + /** Increment on array element */ + public void testArrayIncrement(int index) { + numbers[index]++; // UNARY_EXPRESSION on ARRAY_ACCESS + } + + /** Increment on field access */ + public void testFieldAccessIncrement() { + this.count++; // UNARY_EXPRESSION on FIELD_ACCESS + } + + // ======================================== + // OBJECT CREATION STATEMENTS + // ======================================== + + /** Object creation as statement (result discarded) */ + public void testObjectCreationStatement() { + new ArrayList(); // OBJECT_CREATION as statement + } + + /** Object creation with arguments */ + public void testObjectCreationWithArgs() { + new StringBuilder("initial"); // OBJECT_CREATION with LITERAL arg + } + + /** Anonymous class creation as statement */ + public void testAnonymousClassStatement() { + new Runnable() { // ANONYMOUS_CLASS_CREATION as statement + @Override + public void run() { + System.out.println("running"); + } + }; + } + + // ======================================== + // CONSTRUCTOR INVOCATION STATEMENTS + // ======================================== + + /** Constructor with this() call */ + public ExpressionStatementTests() { + this(0); // CONSTRUCTOR_INVOCATION (this) + } + + /** Constructor with super() and expression */ + public ExpressionStatementTests(int initialCount) { + super(); // CONSTRUCTOR_INVOCATION (super) + this.count = initialCount; + this.count++; + new Object() { + @Override + public String toString() { + return "anonymous"; + } + }; + } + + // ======================================== + // MIXED/COMPLEX EXPRESSION STATEMENTS + // ======================================== + + /** Multiple expression statements in sequence */ + public void testMultipleStatements(int a, int b) { + count = a; + count += b; + count++; + doSomething(); + } + + /** Field access chain assignment */ + public void testFieldAccessChain() { + this.x = this.count; // Both sides are FIELD_ACCESS + } + + /** Expression statement with instanceof and cast */ + public void testInstanceofAndCast(Object obj) { + if (obj instanceof String) { + name = (String) obj; + } + } + + // ======================================== + // HELPER METHODS + // ======================================== + + private void doSomething() { + // Empty helper + } + + private String getName() { + return this.name; + } + + private void processItems(String... items) { + // Varargs helper + } + + private void processNumber(int value) { + // Helper for number processing + } + + private void doingSomething() { + this.x = ((java.util.function.IntUnaryOperator) n -> n * n).applyAsInt(this.x); + } +} diff --git a/parser/src/test-data/java/expressions/ForClauseContexts.java b/parser/src/test-data/java/expressions/ForClauseContexts.java new file mode 100644 index 000000000..f1c9d8b91 --- /dev/null +++ b/parser/src/test-data/java/expressions/ForClauseContexts.java @@ -0,0 +1,58 @@ +package com.axiomcode.test.expressions; + +/** + * Acceptance fixture for the three clauses of a basic for statement. + * + * The update pass matched children of the for_statement by NODE TYPE, so a + * condition that happened to be a method_invocation, a unary or an assignment + * matched the update set too and was emitted a second time with FOR_UPDATE. An + * expression init clause matched as well, so `for (init(); cond(); step())` + * produced three FOR_UPDATE rows for one update clause, and RootContext.FOR_INIT + * was declared and emitted by nothing. + * + * Two consequences in opposite directions: a duplicated site inflates any + * per-site denominator, and a call written in the init clause, which runs once, + * was reported in the clause that runs every iteration - which is the whole + * reason the three contexts are separate. + * + * The grammar labels these clauses `init`, `condition` and `update`, so the + * fixture below covers each shape that previously collided with the type match. + */ +public class ForClauseContexts { + + boolean cond() { return false; } + void init() { } + void step() { } + boolean flag; + + /** Every clause is a call: one row each, in its own context. */ + void allCalls() { + for (init(); cond(); step()) { } + } + + /** A call condition must not also appear as an update. */ + void callCondition() { + for (int i = 0; cond(); i++) { } + } + + /** A unary condition must not also appear as an update. */ + void unaryCondition() { + for (int i = 0; !flag; i++) { } + } + + /** Control: a binary condition never matched the type set, and is unchanged. */ + void binaryCondition() { + for (int i = 0; i < 3; i++) { } + } + + /** A clause may repeat, and each repetition is its own row. */ + void multipleClauses() { + int i, j; + for (i = 0, j = 10; i < j; i++, j--) { } + } + + /** No clause at all: nothing to extract, and nothing to duplicate. */ + void empty() { + for (;;) { break; } + } +} diff --git a/parser/src/test-data/java/expressions/InitializerBlockExpressions.java b/parser/src/test-data/java/expressions/InitializerBlockExpressions.java new file mode 100644 index 000000000..f368b99ce --- /dev/null +++ b/parser/src/test-data/java/expressions/InitializerBlockExpressions.java @@ -0,0 +1,55 @@ +package com.axiomcode.test.expressions; + +import java.util.List; + +/** + * Acceptance fixture for expressions inside a static or instance initializer. + * + * Only expression statements were extracted from an initializer body, so every + * expression in a control-flow POSITION was dropped: an if or while condition, + * an enhanced-for iterable, a throw value. The bodies of those statements + * survived, because their contents are expression statements in their own right, + * which is why an initializer reached the fact set looking like a straight-line + * block rather than an empty one. A call site that produces no row is neither an + * edge nor a declared unknown. + * + * The three members below hold the SAME seven statements. The method is the + * oracle: whatever it produces, both initializers must produce too, because an + * initializer body is an ordinary block. Asserting the equivalence rather than a + * fixed list means the fixture keeps discriminating if the extractor's context + * vocabulary changes later. + */ +public class InitializerBlockExpressions { + + static boolean cond() { return true; } + static List items() { return null; } + static String pick() { return "x"; } + static void act() { } + + static { + act(); + if (cond()) { act(); } + for (String s : items()) { act(); } + while (cond()) { act(); } + String v = pick(); + try { act(); } catch (Exception e) { throw new RuntimeException(pick()); } + } + + { + act(); + if (cond()) { act(); } + for (String s : items()) { act(); } + while (cond()) { act(); } + String v = pick(); + try { act(); } catch (Exception e) { throw new RuntimeException(pick()); } + } + + void method() { + act(); + if (cond()) { act(); } + for (String s : items()) { act(); } + while (cond()) { act(); } + String v = pick(); + try { act(); } catch (Exception e) { throw new RuntimeException(pick()); } + } +} diff --git a/parser/src/test-data/java/expressions/InitializerLambdaBodies.java b/parser/src/test-data/java/expressions/InitializerLambdaBodies.java new file mode 100644 index 000000000..d2f2e5d77 --- /dev/null +++ b/parser/src/test-data/java/expressions/InitializerLambdaBodies.java @@ -0,0 +1,113 @@ +package expressions; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Two statement shapes inside a lambda that initializes a local variable or a field produced no + * rows at all. + * + * (a) An UNBRACED control-flow body. `findExpressionStatements` deliberately skips expression + * statements inside a lambda that sits in a local-variable declaration and hands them to + * this extractor, which owns the local-variable linking — the split is intentional. The gap + * was on the receiving side: the walker recognised a statement only where it appeared as a + * CHILD of a `block`, and a single-statement control-flow body is not a `block`, it is a + * bare statement child of the `if_statement` / `for_statement` / `while_statement`. A + * braced body, a `try` and a `switch` in the same lambda were fine, which is what made this + * easy to miss. One corpus project loses 37 call sites to this shape alone. + * + * (b) A `throw` inside a lambda that initializes a FIELD. TypeMethodExtractor's throw walk + * covers method bodies, including lambdas in a local-variable declaration, but never + * reaches a field initializer. A `throw`-only lambda is the standard "disabled + * implementation" constant. + * + * Every defective shape has its working control in this file: the same lambda passed as an + * argument, the braced form, and the local-variable twin of the field lambda. + */ +public class InitializerLambdaBodies { + + void x() {} + void y() {} + boolean c; + static String reason() { return "r"; } + + /** (b) the defect: a throw in a FIELD-initializer lambda. */ + Supplier disabled = () -> { throw new IllegalStateException(reason()); }; + + /** (b) the control: the identical lambda in a local variable, extracted all along. */ + void throwInLocalVariableLambda() { + Supplier t = () -> { throw new IllegalStateException(reason()); }; + } + + /** The control for (a): the same unbraced if/else as ordinary statements. */ + void unbracedInMethod() { + if (c) x(); else y(); + } + + /** (a) the defect: an unbraced if/else in a local-variable-initializer lambda. */ + void unbracedIfInLocalVariableLambda() { + Consumer h = s -> { + if (c) + x(); + else + y(); + }; + h.accept("a"); + } + + /** (a) the control: braced, in the same position — always worked. */ + void bracedIfInLocalVariableLambda() { + Consumer h = s -> { + if (c) { x(); } else { y(); } + }; + h.accept("a"); + } + + /** (a) the control: the same unbraced lambda passed as an ARGUMENT — always worked. */ + void unbracedIfInArgumentLambda() { + run(s -> { + if (c) + x(); + else + y(); + }); + } + + /** (a) an unbraced `for` body. */ + void unbracedForInLocalVariableLambda() { + Consumer h = s -> { + for (int i = 0; i < 2; i++) + x(); + }; + h.accept("a"); + } + + /** (a) an unbraced `while` body. */ + void unbracedWhileInLocalVariableLambda() { + Consumer h = s -> { + while (c) + x(); + }; + h.accept("a"); + } + + /** (a) an unbraced `do` body. */ + void unbracedDoInLocalVariableLambda() { + Consumer h = s -> { + do + x(); + while (c); + }; + h.accept("a"); + } + + /** (a) an unbraced body in a FIELD-initializer lambda, the other owner of this walker. */ + Consumer fieldHandler = s -> { + if (c) + x(); + else + y(); + }; + + void run(Consumer f) {} +} diff --git a/parser/src/test-data/java/expressions/InstanceofPatternExamples.java b/parser/src/test-data/java/expressions/InstanceofPatternExamples.java new file mode 100644 index 000000000..1645f5e45 --- /dev/null +++ b/parser/src/test-data/java/expressions/InstanceofPatternExamples.java @@ -0,0 +1,102 @@ +package com.inventory.auth.examples14; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Test cases for INSTANCEOF_EXPRESSION and INSTANCEOF_PATTERN extraction. + * + * INSTANCEOF_EXPRESSION: obj instanceof Type (basic check) + * INSTANCEOF_PATTERN: obj instanceof Type varName (Java 16+ pattern matching) + */ +public class InstanceofPatternExamples { + + Object obj = "hello"; + Object numObj = 42; + + // ==================== BASIC INSTANCEOF (INSTANCEOF_EXPRESSION) ==================== + + // Simple instanceof check + boolean isString = obj instanceof String; + boolean isInteger = numObj instanceof Integer; + + // instanceof with qualified type + boolean isList = obj instanceof java.util.List; + + // instanceof with generic type (raw check) + boolean isArrayList = obj instanceof java.util.ArrayList; + + // ==================== INSTANCEOF WITH PATTERN (INSTANCEOF_PATTERN) ==================== + + // Basic pattern variable (Java 16+) + // obj instanceof String s - binds 's' to the casted value + String patternResult = (obj instanceof String s) ? s.toUpperCase() : "not a string"; + + // Pattern with different types + Integer intPattern = numObj instanceof Integer i ? i * 2 : 0; + + // Pattern with qualified type + java.util.List listPattern = obj instanceof java.util.List list ? list : null; + + // ==================== INSTANCEOF IN EXPRESSIONS ==================== + + // instanceof in ternary condition + int length = obj instanceof String ? ((String) obj).length() : 0; + int length2 = obj instanceof String ? ((String) new Object()).length() : 0; + + // instanceof with pattern in ternary - cleaner Java 16+ style + int lengthPattern = obj instanceof String str ? str.length() : 0; + + // instanceof in binary expression + boolean bothStrings = obj instanceof String && numObj instanceof String; + + // instanceof with pattern in method argument + void processIfString(Object o) { + // Pattern matching in method body - uses pattern variable in scope + } + + // ==================== COMPLEX INSTANCEOF PATTERNS ==================== + + // Nested instanceof checks + boolean nestedCheck = obj instanceof Object && obj instanceof String; + + // Pattern with final modifier (if supported) + // Note: final in pattern is optional and typically inferred + + // ==================== INSTANCEOF WITH GENERICS ==================== + + // Cannot use instanceof with parameterized type directly (compile error) + // boolean isStringList = obj instanceof List; // ERROR + // But can use wildcard + boolean isWildcardList = obj instanceof java.util.List; + + // ==================== EDGE CASES ==================== + + // instanceof on method return + boolean methodResultCheck = getString() instanceof String; + + // instanceof on array access + Object[] objects = {obj, numObj}; + boolean arrayElementCheck = objects[0] instanceof String; + + // instanceof on field access + static class Container { + Object value = "test"; + } + Container container = new Container(); + boolean fieldCheck = container.value instanceof String; + + // Pattern on field access + String fieldPattern = container.value instanceof String s ? s : null; + + private Object getString() { + String patternResultStr = (obj instanceof String s) ? s.toUpperCase() : "not a string"; + + List> listOfMaps = new ArrayList<>(); + listOfMaps.add(Map.of("one", 1, "two", 2)); + listOfMaps.add(Map.of("three", 3, "four", 4)); + + return "test"; + } +} diff --git a/parser/src/test-data/java/expressions/LambdaExpressionExamples1.java b/parser/src/test-data/java/expressions/LambdaExpressionExamples1.java new file mode 100644 index 000000000..f67345995 --- /dev/null +++ b/parser/src/test-data/java/expressions/LambdaExpressionExamples1.java @@ -0,0 +1,795 @@ +package com.inventory.auth.examples19; + +import java.util.function.*; +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.Comparator; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import javafx.scene.effect.Light.Point; + +/** + * Comprehensive Lambda Expression Test Cases + * Tests various lambda forms and their expression body extraction. + */ +public class LambdaExpressionExamples { + + // ========================================================================= + // BASIC LAMBDA FORMS + // ========================================================================= + + // Single parameter without parentheses - expression body + Function doubler = x -> x * 2; + + // Single parameter with parentheses - expression body + Function lengthFn = (s) -> s.length(); + + // Multiple parameters - expression body + BiFunction adder = (a, b) -> a + b; + + // No parameters - expression body + Supplier greeter = () -> "Hello, World!"; + + // Single parameter with explicit type + Function upperCase = (String s) -> s.toUpperCase(); + + // Multiple parameters with explicit types + BiFunction repeater = (String s, Integer n) -> s.repeat(n); + + // ========================================================================= + // COMPLEX EXPRESSION BODIES + // ========================================================================= + + // Ternary expression in lambda body + Function signChecker = x -> x >= 0 ? "positive" : "negative"; + + // Method invocation chain in lambda body + Function trimAndUpper = s -> s.trim().toUpperCase(); + + // Binary expression with multiple operators + BiFunction complexMath = (a, b) -> a * b + a - b; + + // Instanceof in lambda body + Function isString = obj -> obj instanceof String; + + // Cast expression in lambda body + Function castToString = obj -> (String) obj; + + // Array access in lambda body + Function firstElement = arr -> arr[0]; + + // Field access in lambda body + Function getName = p -> p.name; + + // Object creation in lambda body + Supplier> listCreator = () -> new ArrayList<>(); + + // Generic object creation + Supplier> mapCreator = () -> new java.util.HashMap<>(); + + // ========================================================================= + // NESTED LAMBDAS (Lambda returning Lambda) + // ========================================================================= + + // Curried function - lambda returning lambda + Function> curriedAdd = x -> y -> x + y; + + // Triple nested lambda + Function>> tripleNested = + x -> y -> z -> x + y + z; + + // Lambda returning lambda with method invocation + Function> concatCurried = + prefix -> suffix -> prefix.concat(suffix); + + // ========================================================================= + // LAMBDAS WITH COMPLEX TYPES + // ========================================================================= + + // Comparator lambda + Comparator lengthComparator = (s1, s2) -> s1.length() - s2.length(); + + // BiPredicate + BiPredicate containsCheck = (str, sub) -> str.contains(sub); + + // Consumer + Consumer printer = s -> System.out.println(s); + + // BiConsumer + BiConsumer repeatedPrint = (s, n) -> System.out.println(s.repeat(n)); + + // Predicate with complex condition + Predicate complexPredicate = s -> s != null && s.length() > 5 && s.startsWith("A"); + + // ========================================================================= + // LAMBDAS WITH GENERICS + // ========================================================================= + + // Generic identity function + Function identity = x -> x; + + // Lambda with wildcard types + Function, Integer> listSize = list -> list.size(); + + // Lambda with bounded wildcard + Function, Double> sumNumbers = + list -> list.stream().mapToDouble(Number::doubleValue).sum(); + + // ========================================================================= + // LAMBDAS IN COMPLEX EXPRESSIONS + // ========================================================================= + + // Lambda as method argument (simulated with field) + List names = List.of("Alice", "Bob", "Charlie"); + + // Lambda in ternary expression + Function conditional = true ? x -> x * 2 : x -> x * 3; + + // Lambda with string template in body (Java 21+) + Function withTemplate = name -> STR."Hello, \{name}!"; + + // ========================================================================= + // LAMBDAS WITH RECORD PATTERNS (Java 21+) + // ========================================================================= + + record Point(int x, int y) {} + record Person(String name, int age) {} + + // Lambda with instanceof pattern in body + Function patternLambda = + obj -> obj instanceof Point(int x, int y) ? STR."Point(\{x}, \{y})" : "not a point"; + + // Lambda with switch expression in body + Function switchLambda = obj -> switch (obj) { + case Integer i -> STR."Integer: \{i}"; + case String s -> STR."String: \{s}"; + case Point(int x, int y) -> STR."Point: \{x}, \{y}"; + default -> "unknown"; + }; + + // ========================================================================= + // LAMBDAS WITH METHOD REFERENCES COMPARISON + // ========================================================================= + + // Lambda equivalent of method reference + Function lambdaLength = s -> s.length(); + Function methodRefLength = String::length; + + // Lambda equivalent of constructor reference + Supplier> lambdaConstructor = () -> new ArrayList<>(); + Supplier> constructorRef = ArrayList::new; + + // ========================================================================= + // VARARGS AND SPECIAL PARAMETERS + // ========================================================================= + + // Lambda with array parameter + Function joinArray = arr -> String.join(",", arr); + + // Lambda with varargs (through interface) + @FunctionalInterface + interface VarargFunction { + String apply(String... args); + } + VarargFunction varargLambda = args -> String.join("-", args); + + // ========================================================================= + // LAMBDAS WITH EXCEPTION HANDLING (expression form) + // ========================================================================= + + // Lambda with ternary for null-safe parsing (expression body) + Function parseIntSafe = s -> s != null ? Integer.parseInt(s) : 0; + + // Note: Block lambdas (with { }) won't have body extracted yet (future: method body extraction) + // Example of block lambda (body not extracted): + // Function blockLambda = s -> { return s.length(); }; + + // ========================================================================= + // BINARY/UNARY OPERATIONS IN LAMBDA BODIES + // ========================================================================= + + // Unary operations + Function negate = x -> -x; + Function notFn = b -> !b; + Function increment = x -> ++x; + + // Logical operations + BiPredicate andFn = (a, b) -> a && b; + BiPredicate orFn = (a, b) -> a || b; + + // Bitwise operations + BiFunction bitwiseAnd = (a, b) -> a & b; + BiFunction bitwiseOr = (a, b) -> a | b; + BiFunction bitwiseXor = (a, b) -> a ^ b; + Function bitwiseNot = x -> ~x; + BiFunction leftShift = (a, b) -> a << b; + BiFunction rightShift = (a, b) -> a >> b; + + // ========================================================================= + // OPTIONAL AND STREAM LAMBDAS + // ========================================================================= + + // Lambda for Optional operations + Function, String> orElseLambda = opt -> opt.orElse("default"); + + // Lambda for stream operations (as field values) + Function, List> filterPositive = + list -> list.stream().filter(x -> x > 0).collect(Collectors.toList()); + + // Nested lambda in stream + Function, List> transformList = + list -> list.stream().map(s -> s.toUpperCase()).collect(Collectors.toList()); + + // ========================================================================= + // LAMBDA CAPTURING OUTER SCOPE (effectively final) + // ========================================================================= + + private final int multiplier = 10; + private final String prefix = "Result: "; + + // Lambda capturing field + Function captureField = x -> x * multiplier; + + // Lambda capturing multiple fields + Function captureMultiple = x -> prefix + (x * multiplier); + + // ========================================================================= + // PARENTHESIZED LAMBDA + // ========================================================================= + + // Lambda in parentheses + Function parenthesized = ((Function) (x -> x * 2)); + + // Chained lambda application + Integer result = ((Function) (x -> x + 1)).apply(5); + + // ========================================================================= + // STREAMING API LAMBDAS - Field Initializers with Stream Operations + // ========================================================================= + + // Stream.map with lambda + List lengths = List.of("a", "bb", "ccc").stream() + .map(s -> s.length()) + .collect(Collectors.toList()); + + // Stream.filter with lambda + List filtered = List.of("apple", "banana", "cherry").stream() + .filter(s -> s.startsWith("a")) + .collect(Collectors.toList()); + + // Stream.forEach (returns void, assigned to Object for field) + Runnable forEachRunner = () -> List.of(1, 2, 3).forEach(n -> System.out.println(n)); + + // Stream.reduce with lambda + Integer sum = List.of(1, 2, 3, 4, 5).stream() + .reduce(0, (a, b) -> a + b); + + // Stream.sorted with Comparator lambda + List sorted = List.of("banana", "apple", "cherry").stream() + .sorted((a, b) -> a.compareTo(b)) + .collect(Collectors.toList()); + + // Stream.flatMap with lambda + List flatMapped = List.of(List.of(1, 2), List.of(3, 4)).stream() + .flatMap(list + -> list.stream()) + .collect(Collectors.toList()); + + // Stream.anyMatch/allMatch/noneMatch with lambda + Boolean hasLong = List.of("a", "bb", "ccc").stream() + .anyMatch(s -> s.length() > 2); + + Boolean allShort = List.of("a", "bb", "ccc").stream() + .allMatch(s -> s.length() < 10); + + Boolean noneEmpty = List.of("a", "bb", "ccc").stream() + .noneMatch(s -> s.isEmpty()); + + // Stream.findFirst with filter lambda + Optional firstLong = List.of("a", "bb", "ccc").stream() + .filter(s + -> s.length() > 1) + .findFirst(); + + // Stream.count after filter + Long countLong = List.of("a", "bb", "ccc").stream() + .filter(s -> s.length() > 1) + .count(); + + // Stream.mapToInt/mapToDouble with lambda + int sumLengths = List.of("a", "bb", "ccc").stream() + .mapToInt(s -> s.length()) + .sum(); + + // Stream.max/min with Comparator lambda + Optional longest = List.of("a", "bb", "ccc").stream() + .max((a, b) -> a.length() - b.length()); + + // Chained stream operations with multiple lambdas + List chainedStream = List.of(" apple ", " BANANA ", " cherry ").stream() + .map(s -> s.trim()) + .filter(s -> s.length() > 4) + .map(s -> s.toLowerCase()) + .sorted((a, b) -> a.compareTo(b)) + .collect(Collectors.toList()); + + // Stream.peek with lambda (for debugging) + List peeked = List.of("a", "b", "c").stream() + .peek(s -> System.out.println(s)) + .map(s -> s.toUpperCase()) + .collect(Collectors.toList()); + + List peeked2 = List.of("a", "b", "c").stream() + .peek(System.out::println) + .map(s -> s.toUpperCase()) + .collect(Collectors.toList()); + // Stream.distinct then map + List distinctLengths = List.of("a", "bb", "a", "ccc", "bb").stream() + .distinct() + .map(s -> s.length()) + .collect(Collectors.toList()); + + // Stream.limit/skip with lambdas + List limited = List.of("a", "b", "c", "d", "e").stream() + .filter(s -> !s.equals("c")) + .limit(3) + .collect(Collectors.toList()); + + // Stream.takeWhile/dropWhile (Java 9+) + List takenWhile = List.of(1, 2, 3, 4, 5).stream() + .takeWhile(n -> n < 4) + .collect(Collectors.toList()); + + // Collectors.groupingBy with lambda + Map> groupedByLength = List.of("a", "bb", "ccc", "dd").stream() + .collect(Collectors.groupingBy(s -> s.length())); + + // Collectors.partitioningBy with lambda + Map> partitioned = List.of("a", "bb", "ccc").stream() + .collect(Collectors.partitioningBy(s -> s.length() > 1)); + + // Collectors.toMap with lambdas + Map toMap = List.of("a", "bb", "ccc").stream() + .collect(Collectors.toMap(s -> s, s -> s.length())); + + // Collectors.joining with map lambda + String joined = List.of("a", "b", "c").stream() + .map(s -> s.toUpperCase()) + .collect(Collectors.joining(", ")); + + // ========================================================================= + // ANONYMOUS CLASS WITH LAMBDAS IN FIELD INITIALIZERS + // ========================================================================= + + // Anonymous Runnable with lambda in field + Runnable anonRunnable = new Runnable() { + Function innerLambda = x -> x * 2; + @Override + public void run() {} + }; + + // Anonymous Comparator with lambda field + Comparator anonComparator = new Comparator() { + Function lengthFn = s -> s.length(); + @Override + public int compare(String a, String b) { return 0; } + }; + + // Anonymous class with multiple lambda fields + Object anonMultiLambda = new Object() { + Function double_ = x -> x * 2; + Function triple = x -> x * 3; + BiFunction add = (a, b) -> a + b; + Predicate notEmpty = s -> !s.isEmpty(); + }; + + // Anonymous class extending abstract class with lambda + abstract class Processor { + abstract void process(); + } + Processor anonProcessor = new Processor() { + Consumer handler = s -> System.out.println(s); + @Override + void process() {} + }; + + // Anonymous class with nested lambda (lambda in lambda field) + Object nestedLambdaAnon = new Object() { + Function> curried = x -> y -> x + y; + }; + + // Anonymous class with stream lambda in field + Object streamAnonClass = new Object() { + List processed = List.of(1, 2, 3).stream() + .map(n -> n * 2) + .filter(n -> n > 2) + .collect(Collectors.toList()); + }; + + // ========================================================================= + // LAMBDA IN COMPLEX FIELD EXPRESSIONS + // ========================================================================= + + // Lambda as argument to method in field initializer + List sortedWithLambda = new ArrayList<>(List.of("b", "a", "c")) {{ + sort((a, b) -> a.compareTo(b)); + }}; + + // Optional.map with lambda + Optional optMapped = Optional.of("hello").map(s -> s.length()); + + // Optional.filter with lambda + Optional optFiltered = Optional.of("hello").filter(s -> s.length() > 3); + + // Optional.flatMap with lambda + Optional optFlatMapped = Optional.of("hello") + .flatMap(s -> Optional.of(s.length())); + + // Optional.orElseGet with lambda supplier + String orElseResult = Optional.empty().orElseGet(() -> "default"); + + // Optional.ifPresent captured (won't return value, but shows lambda usage) + Runnable ifPresentRunner = () -> Optional.of("hello").ifPresent(s -> System.out.println(s)); + + // ========================================================================= + // LAMBDA WITH GENERIC BOUNDS + // ========================================================================= + + // Lambda with extends bound + Function, Double> sumExtends = + list -> list.stream().mapToDouble(Number::doubleValue).sum(); + + // Lambda with super bound + Consumer superConsumer = s -> System.out.println(s); + + // ========================================================================= + // LAMBDA IN ARRAY INITIALIZATION + // ========================================================================= + + // Array of lambdas + @SuppressWarnings("unchecked") + Function[] lambdaArray = new Function[] { + x -> x * 1, + x -> x * 2, + x -> x * 3 + }; + + // Array with mixed lambdas + Runnable[] runnables = new Runnable[] { + () -> System.out.println("first"), + () -> System.out.println("second"), + () -> System.out.println("third") + }; + + // ========================================================================= + // LAMBDA WITH THIS/SUPER REFERENCES + // ========================================================================= + + private String instanceField = "instance"; + + // Lambda referencing this + Supplier thisReference = () -> this.instanceField; + + // Lambda referencing this.method (simulated) + Supplier thisMethod = () -> this.hashCode(); + + // ========================================================================= + // COMPLEX NESTED STREAM LAMBDAS + // ========================================================================= + + // Nested stream with multiple lambdas + List> nestedList = List.of(List.of(1, 2), List.of(3, 4)); + List flattenedDoubled = nestedList.stream() + .flatMap(inner -> inner.stream().map(n -> n * 2)) + .filter(n -> n > 2) + .collect(Collectors.toList()); + + // Stream with ternary in lambda + List ternaryStream = List.of(1, 2, 3, 4, 5).stream() + .map(n -> n % 2 == 0 ? "even" : "odd") + .collect(Collectors.toList()); + + // Stream with method chain in lambda + List methodChainStream = List.of(" hello ", " world ").stream() + .map(s -> s.trim().toUpperCase().concat("!")) + .collect(Collectors.toList()); + + // Stream with instanceof in lambda + List mixedList = List.of("string", 123, "another", 456); + List stringsOnly = mixedList.stream() + .filter(obj -> obj instanceof String) + .map(obj -> (String) obj) + .collect(Collectors.toList()); + + // Stream with record pattern in lambda (Java 21+) + List points = List.of(new Point(1, 2), new Point(3, 4), "not a point"); + List pointStrings = points.stream() + .filter(obj -> obj instanceof Point) + .map(obj -> obj instanceof Point(int x, int y) ? x + "," + y : "") + .collect(Collectors.toList()); + + // ==================== ASSIGNMENT IN LAMBDA BODY ==================== + + int[] holder = {0}; + + // Assignment expression as lambda body (side effect) + Consumer assignInBody = x -> holder[0] = x; + + // Compound assignment in lambda body + Consumer compoundAssign = x -> holder[0] += x; + Consumer compoundMul = x -> holder[0] *= x; + + // Chained assignment + Consumer chainedAssign = x -> holder[0] = holder[1] = x; + + // ==================== VAR PARAMETER TYPE (Java 11+) ==================== + + // Lambda with var and annotations + BiFunction varParams = (var a, var b) -> a + b; + + // Lambda with nested generic types - single param with List + Function, Integer> nestedGeneric1 = (List items) -> items.size(); + + // Lambda with nested generic types - Map + Function, Integer> nestedGeneric2 = (Map map) -> map.size(); + + // Lambda with deeply nested generics - List>> + Function>>, Integer> deeplyNested = + (List>> data) -> data.size(); + + // Lambda with multiple nested generic params at different positions + BiFunction, Map, Integer> multiNestedParams = + (List first, Map second) -> first.size() + second.size(); + + // Lambda with triple-nested generic - Optional>> + Function>>, Boolean> tripleNestedLambdaParam = + (Optional>> opt) -> opt.isPresent(); + + // Lambda with wildcard bounds - List + Function, Double> wildcardExtends = + (List nums) -> nums.stream().mapToDouble(Number::doubleValue).sum(); + + // Lambda with wildcard super - List + Consumer> wildcardSuper = (List list) -> list.add(42); + + // Lambda with array of generics - List[] + Function[], Integer> arrayOfGenerics = (List[] arr) -> arr.length; + + // Lambda with BiFunction using nested generics in both params + BiFunction>, List>, Integer> complexBiFunc = + (Map> first, List> second) -> first.size() + second.size(); + + // var with annotation (if you have @Nullable etc.) + // BiFunction annotatedVar = (@Nullable var a, var b) -> a + b; + + // ==================== ANONYMOUS CLASS CREATION IN LAMBDA BODY ==================== + + // Lambda returning anonymous class + Supplier anonInLambda = () -> new Runnable() { + @Override + public void run() { + System.out.println("anonymous in lambda"); + } + }; + + // Lambda returning anonymous class with fields + Supplier> anonWithFields = () -> new Comparator() { + private int callCount = 0; + @Override + public int compare(String a, String b) { + callCount++; + return a.compareTo(b); + } + }; + + // ==================== ARRAY CREATION IN LAMBDA BODY ==================== + + // Primitive array creation + Supplier createIntArray = () -> new int[10]; + + // Array creation with initializer + Supplier createInitArray = () -> new int[]{1, 2, 3}; + + // Multi-dimensional array + Supplier createMatrix = () -> new int[3][3]; + + // Generic array (with warning) + @SuppressWarnings("unchecked") + Supplier[]> createGenericArray = () -> new ArrayList[5]; + + // ==================== CLASS LITERAL IN LAMBDA BODY ==================== + + Supplier> classLiteral = () -> String.class; + Supplier> primitiveClass = () -> int.class; + Supplier> arrayClass = () -> String[].class; + + // ==================== LAMBDA AS METHOD RECEIVER ==================== + + // Immediate invocation of lambda + String immediateResult = ((Supplier) () -> "immediate").get(); + + // Chained method on lambda result + int chainedOnLambda = ((Function) s -> s.toUpperCase()).apply("test").length(); + + // Lambda in method chain + String lambdaChain = Optional.of("test") + .map(((Function) s -> s.toUpperCase())) + .orElse(""); + + // ==================== LAMBDA IN BOTH TERNARY BRANCHES ==================== + + boolean flag = true; + Function ternaryLambdas = flag + ? (x -> x * 2) + : (x -> x * 3); + + // Complex ternary with different lambda forms + Function complexTernaryLambda = flag + ? s -> s.toUpperCase() + : s -> s.toLowerCase(); + + // ==================== QUALIFIED THIS/SUPER IN LAMBDA ==================== + + class OuterClass { + String outerField = "outer"; + + class InnerClass { + String innerField = "inner"; + + // Lambda with qualified this + Supplier qualifiedThis = () -> OuterClass.this.outerField; + + // Lambda accessing both + Supplier bothFields = () -> OuterClass.this.outerField + this.innerField; + } + } + + class ChildClass extends ParentClass { + // Lambda calling super method + Supplier superInLambda = () -> super.parentMethod(); + + // Lambda with super field access + Supplier superFieldLambda = () -> super.parentField; + } + + class ParentClass { + String parentField = "parent"; + String parentMethod() { return "from parent"; } + } + + // ==================== INTERSECTION TYPE CAST IN LAMBDA BODY ==================== + + Function intersectionLambda = + obj -> (java.io.Serializable & Comparable) obj; + + // ==================== LAMBDA WITH INSTANCEOF PATTERN + GUARD ==================== + + // Complex instanceof with && in lambda + Predicate complexInstanceof = obj -> + obj instanceof String s && s.length() > 5 && s.startsWith("A"); + + // Chained instanceof patterns + BiPredicate dualPattern = (a, b) -> + a instanceof String sa && b instanceof String sb && sa.equals(sb); + + // ==================== NULL-RELATED LAMBDAS ==================== + + // Null assignment to functional interface + Function nullLambda = null; + + // Lambda returning null + Supplier returnsNull = () -> null; + + // Lambda with null check in body + Function nullSafe = s -> s == null ? "" : s.toUpperCase(); + + // ==================== EMPTY/MINIMAL LAMBDAS ==================== + + // Lambda with just literal + Supplier justLiteral = () -> 42; + Supplier justString = () -> "constant"; + Supplier justBool = () -> true; + + // Lambda with just variable + int capturedValue = 10; + Supplier justVariable = () -> capturedValue; + + // Lambda with just null + Supplier justNull = () -> null; + + // ==================== LAMBDA WITH PRE/POST INCREMENT ON ARRAY ==================== + + int[] counter = {0}; + Supplier preIncArray = () -> ++counter[0]; + Supplier postIncArray = () -> counter[0]++; + + // ==================== LAMBDA WITH SWITCH EXPRESSION (FULL FORMS) ==================== + + // Switch with yield in lambda (block form in switch, expression lambda) + Function switchYield = n -> switch(n) { + case 1 -> "one"; + case 2 -> { + String result = "two"; + yield result; + } + default -> "other"; + }; + + // Switch with pattern matching in lambda + Function switchPattern = obj -> switch(obj) { + case Integer i when i > 0 -> "positive int"; + case Integer i -> "non-positive int"; + case String s when s.isEmpty() -> "empty string"; + case String s -> "string: " + s; + case null -> "null"; + default -> "unknown"; + }; + + // ==================== LAMBDA IN MAP.COMPUTEIFABSENT ETC ==================== + + Map> computeMap = new java.util.HashMap<>(); + List computed = computeMap.computeIfAbsent("key", k -> new ArrayList<>()); + + // computeIfPresent + Integer computedPresent = Map.of("a", 1).computeIfPresent("a", (k, v) -> v + 1); + + // merge with lambda + Map mergeMap = new java.util.HashMap<>(); + Integer merged = Map.of("a", 1).merge("a", 2, (Integer v1, Integer v2) -> v1 + v2); + + // replaceAll with lambda + // map.replaceAll((k, v) -> v.toUpperCase()); + + // ==================== LAMBDA WITH EXPLICIT ARRAY TYPE PARAMETER ==================== + + // Array as parameter type + Function arrayParam = (String[] arr) -> arr[0]; + + // Varargs-like with explicit array + Function intArrayParam = (int[] arr) -> arr.length; + + + static class GenericTypeVars> { + + Function id = t -> t; + + Function, Integer> sizeOfTList = list -> list.size(); + + Function, T> firstExtendsT = list -> list.get(0); + + Function, Integer> sizeSuperT = list -> list.size(); + + Function mapper = t -> (R) Integer.valueOf(0); // compiles but risks unsafe cast + } + + + // ==================== DEEPLY NESTED EXPRESSION IN LAMBDA ==================== + + // Very deep nesting + Function deepNest = s -> + Integer.parseInt(String.valueOf(Math.abs(Integer.parseInt(s.trim())))); + + // Multiple ternary nesting + Function multiTernary = n -> + n > 100 ? "large" : n > 50 ? "medium" : n > 10 ? "small" : "tiny"; + + // ==================== LAMBDA WITH BITWISE ON BOOLEAN ==================== + + BiPredicate boolBitwiseAnd = (a, b) -> a & b; // non-short-circuit + BiPredicate boolBitwiseOr = (a, b) -> a | b; // non-short-circuit + BiPredicate boolBitwiseXor = (a, b) -> a ^ b; + + // ==================== LAMBDA PARAMETER SHADOWING ==================== + + String shadowedField = "field"; + + // Lambda parameter shadows field (valid) + Function shadowingLambda = shadowedField -> shadowedField.toUpperCase(); + + // Nested lambda with shadowing + Function> nestedShadow = + x -> (x2 -> x + x2); // Note: can't reuse 'x' in nested lambda +} diff --git a/parser/src/test-data/java/expressions/LambdaExpressionExamples2.java b/parser/src/test-data/java/expressions/LambdaExpressionExamples2.java new file mode 100644 index 000000000..b0f1ee5cf --- /dev/null +++ b/parser/src/test-data/java/expressions/LambdaExpressionExamples2.java @@ -0,0 +1,135 @@ +package com.inventory.auth.examples20; + +import java.util.Collections; +import java.util.List; +import java.util.function.IntUnaryOperator; + +// Functional interfaces +@FunctionalInterface +interface Function { + R apply(T t); +} + +@FunctionalInterface +interface BiFunction { + R apply(T t, U u); +} + +@FunctionalInterface +interface Supplier { + T get(); +} + +public class LambdaExpressionExamples { + // ========================================================================= + // GENERIC TYPE VARIABLES (T, U) — REAL TYPE PARAMS + // ========================================================================= + + static class GenericBox { + // Explicit type-variable in lambda param + Function idT = (T t) -> t; + + // Type-variable inside nested generics + Function, Integer> sizeOfTList = (List list) -> list.size(); + + // Wildcard bound using T + Function, Integer> sizeExtendsT = list -> list.size(); + + // Array of T in lambda param type + Function tArrayLen = (T[] arr) -> arr.length; + + // Cast to T inside lambda body + @SuppressWarnings("unchecked") + Function castToT = obj -> (T) obj; + } + + // Generic method returning a lambda typed with T + static Function toDoubleFn() { + return (T n) -> n.doubleValue(); + } + + + // ========================================================================= + // GENERIC METHOD REFERENCES WITH EXPLICIT TYPE ARGS + // ========================================================================= + + Supplier> emptyViaGenericMethodRef = Collections::emptyList; + Supplier> emptyViaListOf = List::of; + + + // ========================================================================= + // STRING TEMPLATES: NESTED + "->" INSIDE TEMPLATE + LAMBDA INSIDE \{...\} + // ========================================================================= + + // Note: String templates (STR."...") are a preview feature in Java 21+ + // These may not compile without --enable-preview flag + // Commenting out for now as they require Java 21+ preview features + /* + // Nested template inside template + Function nestedTemplate = + name -> STR."Outer[\{STR."Inner(\{name})"}]"; + + // "->" appears in template *text* (should NOT confuse lambda-arrow extraction) + Function arrowInTemplateText = + s -> STR."literal arrow -> \{s}"; + + // A lambda appears inside the interpolation expression (introduces nested '->') + Function templateWithLambdaInInterpolation = + n -> STR."n+1=\{((IntUnaryOperator) x -> x + 1).applyAsInt(n)}"; + */ + + + // ========================================================================= + // LEXICAL TRAPS FOR SCANNERS (OPTIONAL BUT STRONGLY RECOMMENDED) + // ========================================================================= + + // "->" inside a normal string literal + Function arrowInStringLiteral = s -> "->" + s; + + static { + int x = 10; + int add = x++; + } + + static { + int y = 20; + int add = y++; + } + + + { + int testing = 10000; + int addingMan = testing++; + System.out.println(add + " " + testing); + } + + // "->" inside a text block (Java 15+) + Function arrowInTextBlock = s -> """ + here is an arrow: -> + value=%s + """.formatted(s); + + // Comment between tokens + Function commentAroundArrow = x /*param*/ -> /*body*/ x + 1; + + // Arrow on next line + Function arrowOnNextLine = x + -> x + 1; + + // Tight spacing around >> (generic close) and -> (arrow) + Function>, Integer> tightSpacing = + (List>list)->list.size(); + + // Generic-close '>>' plus shift '>>' in body in the same lambda + Function>, Integer> genericCloseAndShift = + (List> list) -> list.size() >> 1; + + // Unsigned shift (you currently only have >>) + BiFunction unsignedRightShift = (a, b) -> a >>> b; + + + @SafeVarargs + public int add(List list, int b) throws Exception { + return list.stream().mapToInt(Integer::intValue).sum() + b; + } +} diff --git a/parser/src/test-data/java/expressions/LambdaInitializerBodies.java b/parser/src/test-data/java/expressions/LambdaInitializerBodies.java new file mode 100644 index 000000000..fb194fda1 --- /dev/null +++ b/parser/src/test-data/java/expressions/LambdaInitializerBodies.java @@ -0,0 +1,83 @@ +package com.axiomcode.test.expressions; + +import java.util.function.Consumer; + +/** + * Acceptance fixture for statements inside a lambda that initializes a local or + * a field. + * + * Two shapes produced no rows at all, so those call sites were neither edges nor + * declared unknowns. + * + * (a) A BRACE-LESS control-flow body. Statements inside such a lambda are + * deliberately skipped by the method walk and handed to the local-variable + * walk instead, but that walk dispatches on a node's CHILDREN. A brace-less + * body is the statement itself rather than a block, so it was never dispatched + * and its calls vanished - while the identical code inside braces was extracted + * normally. It affected if, while and for alike. + * + * (b) A THROW in a field-initializer lambda. Neither side handled it: the method + * walk skips lambda bodies here, and the local-variable walk had no branch for + * a throw. A throw inside a lambda that initializes a LOCAL was already covered + * by the enclosing method's own throw pass, which is why the fix is scoped to + * field initializers - extracting both would report one written throw twice. + * + * The braced forms and the argument-lambda form are the controls: they were + * always correct, and they pin that the fix adds rows without duplicating any. + */ +public class LambdaInitializerBodies { + + void x() { } + void y() { } + boolean c; + + /** Control: the same statements outside any lambda. */ + void plain() { + if (c) x(); else y(); + } + + /** Control: braces, which were always extracted. */ + void bracedInLambda() { + Consumer h = s -> { if (c) { x(); } else { y(); } }; + h.accept("a"); + } + + /** (a) brace-less if/else inside a lambda initializing a local. */ + void unbracedIf() { + Consumer h = s -> { + if (c) + x(); + else + y(); + }; + h.accept("a"); + } + + /** (a) brace-less while body. */ + void unbracedWhile() { + Consumer h = s -> { while (c) x(); }; + h.accept("a"); + } + + /** (a) brace-less for body. */ + void unbracedFor() { + Consumer h = s -> { for (int i = 0; i < 1; i++) x(); }; + h.accept("a"); + } + + /** Control: the same lambda as an argument was always extracted. */ + void asArgument() { + run(s -> { if (c) x(); else y(); }); + } + + /** (b) a throw in a field-initializer lambda. */ + Consumer thrower = s -> { throw new RuntimeException("boom"); }; + + /** Control: a throw in a LOCAL-initializer lambda, already covered elsewhere. */ + void throwInLocalLambda() { + Consumer h = s -> { throw new IllegalStateException("local"); }; + h.accept("a"); + } + + void run(Consumer f) { } +} diff --git a/parser/src/test-data/java/expressions/LiteralTypeTestCases.java b/parser/src/test-data/java/expressions/LiteralTypeTestCases.java new file mode 100644 index 000000000..d3bc1a45a --- /dev/null +++ b/parser/src/test-data/java/expressions/LiteralTypeTestCases.java @@ -0,0 +1,196 @@ +package com.inventory.auth.examples5; + +import com.inventory.auth.domain.Session; +import com.inventory.auth.initialization.DataInitializer; + +import java.util.List; +/** + * Basic test cases for LiteralType extraction. + * Covers: INTEGER, LONG, FLOAT, DOUBLE, BOOLEAN, CHARACTER, STRING, TEXT_BLOCK, NULL + */ +public class LiteralTypeTestCases { + + // Generic inner class with literals + abstract class Box { + protected V value; + protected String tag = "base"; + protected int priority = 0; + } + + class StringBox extends Box { + public StringBox() { + this.value = "hello"; + this.tag = "string-box"; + } + private String localField = "local"; + } + + class IntegerBox extends Box { + public IntegerBox() { + this.value = 42; + this.tag = "integer-box"; + } + private int sum = 10 + 20; + // private int temporaryField = priority + this.priority + 5; + } + + // Using class type parameters + private T genericField; + private U numberField; + + public void useGenerics(T param, U num) { + String label = "processing: "; + int count = 5; + this.genericField = param; + this.numberField = num; + } + + // ==================== INTEGER LITERALS ==================== + private int basicInt = 42; + private int hexInt = 0x2A; + private int binaryInt = 0b101010; + private int octalInt = 052; // Octal (42 in decimal) + private int underscoreInt = 1_000_000; // Underscore separator + private int maxInt = 2147483647; // Integer.MAX_VALUE + private int minInt = -2147483648; // Integer.MIN_VALUE (unary) + private int zeroInt = 0; + private int hexUpperInt = 0XABC; // Uppercase X + private int binaryUpperInt = 0B1010; // Uppercase B + + // ==================== LONG LITERALS ==================== + private long basicLong = 42L; + private long hexLong = 0xFFFFFFFFFFL; + private long lowercaseLong = 42l; // Lowercase l suffix + private long binaryLong = 0b1010L; // Binary with L + private long octalLong = 0777L; // Octal with L + private long underscoreLong = 1_000_000_000L; // Underscore separator + private long maxLong = 9223372036854775807L; // Long.MAX_VALUE + + // ==================== FLOAT LITERALS ==================== + private float basicFloat = 3.14f; + private Number eFloat = 1e1; + private float scientificFloat = 1.5e-4f; + private float uppercaseFloat = 3.14F; // Uppercase F + private float hexFloat = 0x1.0p0f; // Hex float + private float leadingDotFloat = .5f; // Leading dot + private float trailingDotFloat = 5.f; // Trailing dot + private float underscoreFloat = 3.141_592f; // Underscore + private float zeroFloat = 0.0f; + private float scientificUpperFloat = 1.5E4F; // Uppercase E and F + + // ==================== DOUBLE LITERALS ==================== + private double basicDouble = 3.14159265359; + private double scientificDouble = 6.022E23; + private double explicitDouble = 3.14d; // Explicit d suffix + private double uppercaseDouble = 3.14D; // Uppercase D + private double hexDouble = 0x1.0p0; // Hex double + private double hexDoubleWithSuffix = 0x1.0p0d; // Hex with d + private double leadingDotDouble = .5; // Leading dot + private double trailingDotDouble = 5.; // Trailing dot + private double underscoreDouble = 3.141_592_653; // Underscore + private double zeroDouble = 0.0; + private double negativeExpDouble = 1.5e-10; // Negative exponent + + // ==================== BOOLEAN LITERALS ==================== + private boolean trueValue = true; + private boolean falseValue = false; + + // ==================== CHARACTER LITERALS ==================== + private char basicChar = 'a'; + private char escapeChar = '\n'; + private char unicodeChar = '\u0041'; + private char octalEscapeChar = '\101'; // Octal escape (A) + private char nullChar = '\0'; // Null character + private char tabChar = '\t'; + private char crChar = '\r'; + private char backslashChar = '\\'; + private char singleQuoteChar = '\''; + private char doubleQuoteChar = '"'; + private char backspaceChar = '\b'; + private char formFeedChar = '\f'; + + // ==================== STRING LITERALS ==================== + private String emptyString = ""; + private String simpleString = "Hello, World!"; + private String withEscapes = "line1\nline2"; + private String unicodeString = "Hello \u0041 World"; // Unicode in string + private String allEscapes = "\t\n\r\f\b\\\"\'"; // All escape chars + private String surrogatePair = "\uD83D\uDE00"; // Emoji (surrogate pair) + private String nullInString = "before\0after"; // Null char in string + private String singleChar = "x"; + private String numericString = "12345"; + private String mixedQuotes = "He said \"Hello\""; + + // ==================== TEXT_BLOCK LITERALS (Java 15+) ==================== + private String basicTextBlock = """ + Hello, + World! + """; + private String emptyTextBlock = """ + """; + private String escapedQuotesTextBlock = """ + She said \"""Hello\""" + """; + private String lineContinuationTextBlock = """ + This is a \ + single line + """; + private String indentedTextBlock = """ + { + "name": "test", + "value": 42 + } + """; + + // ==================== NULL LITERALS ==================== + private String nullString = null; + private Object nullObject = null; + private Integer nullInteger = null; + private int[] nullArray = null; + + // ==================== CLASS LITERALS ==================== + // Reference types + private Class stringClass = String.class; + private Class sessionClass = Session.class; + private Class objectClass = Object.class; + + // Primitive types + private Class intClass = int.class; + private Class longClass = long.class; + private Class doubleClass = double.class; + private Class floatClass = float.class; + private Class booleanClass = boolean.class; + private Class charClass = char.class; + private Class byteClass = byte.class; + private Class shortClass = short.class; + + // Void + private Class voidClass = void.class; + + // Array types + private Class intArrayClass = int[].class; + private Class stringArrayClass = String[].class; + private Class multiDimArrayClass = int[][].class; + private Class objectArrayClass = Object[].class; + + // Fully qualified + private Class listClass = List.class; + private Class mapClass = java.util.Map.class; + private Class tempClass = DataInitializer.TempClass.class; + + // Literals in expressions + private int sum = 10 + 20; + private String concat = "Hello" + " " + "World"; + private int ternary = true ? 1 : 0; + + // Array initializers + private int[] intArray = {1, 2, 3}; + private String[] stringArray = {"one", "two"}; + + // Method calls with literal arguments + private String formatted = String.format("Value: %d", 42); + + // Constants + public static final int CONSTANT_INT = 100; + public static final String CONSTANT_STRING = "CONSTANT"; +} diff --git a/parser/src/test-data/java/expressions/MethodReferenceExamples.java b/parser/src/test-data/java/expressions/MethodReferenceExamples.java new file mode 100644 index 000000000..cceda2812 --- /dev/null +++ b/parser/src/test-data/java/expressions/MethodReferenceExamples.java @@ -0,0 +1,568 @@ +package com.inventory.auth.examples10; + +import java.util.*; +import java.util.function.*; +import java.util.stream.*; + +import com.inventory.auth.domain.Session; + +/** + * Comprehensive examples of method references for expression extraction testing. + * + * Method Reference Forms: + * 1. Static method: Type::staticMethod + * 2. Instance (bound): instance::method + * 3. Instance (unbound): Type::instanceMethod + * 4. Constructor: Type::new + * 5. Array constructor: Type[]::new + * 6. this reference: this::method + * 7. super reference: super::method + * 8. Generic method: Type::method + */ +public class MethodReferenceExamples { + + // ======================================== + // 1. STATIC METHOD REFERENCES + // ======================================== + + // Basic static method reference: Type::staticMethod + private Function parseIntRef = Integer::parseInt; + private DoubleSupplier randomRef = Math::random; + private IntBinaryOperator maxRef = Integer::max; + private IntBinaryOperator minRef = Math::min; + + // Static method with multiple params + private BiFunction concatRef = String::concat; + + // ======================================== + // 2. BOUND INSTANCE METHOD REFERENCES + // ======================================== + + // Reference to instance method of a particular object + private String prefix = "Hello"; + private Function boundConcatRef = prefix::concat; + private Predicate boundStartsWithRef = prefix::startsWith; + private IntSupplier boundLengthRef = prefix::length; + + // With final field + private final StringBuilder sb = new StringBuilder("test"); + private Consumer appendRef = sb::append; + private IntSupplier sbLengthRef = sb::length; + + // ======================================== + // 3. UNBOUND INSTANCE METHOD REFERENCES + // ======================================== + + // Type::instanceMethod - first arg becomes receiver + private Function trimRef = String::trim; + private Function toLowerRef = String::toLowerCase; + private Function lengthRef = String::length; + private Predicate isEmptyRef = String::isEmpty; + + // With two params (receiver + arg) + private BiFunction containsRef = String::contains; + private BiPredicate startsWithRef = String::startsWith; + private Comparator compareToRef = String::compareTo; + private Comparator ignoreCaseRef = String::compareToIgnoreCase; + + // ======================================== + // 4. CONSTRUCTOR REFERENCES + // ======================================== + + // Basic constructor reference + private Supplier> arrayListRef = ArrayList::new; + private Supplier> hashMapRef = HashMap::new; + private Supplier sbCreatorRef = StringBuilder::new; + private Supplier objectCreatorRef = Object::new; + + // Constructor with parameters + private Function sbFromStringRef = StringBuilder::new; + private Function> arrayListWithCapacityRef = ArrayList::new; + private IntFunction sbWithCapacityRef = StringBuilder::new; + + // ======================================== + // 5. ARRAY CONSTRUCTOR REFERENCES + // ======================================== + + // Primitive array constructors + private IntFunction intArrayRef = int[]::new; + private IntFunction longArrayRef = long[]::new; + private IntFunction doubleArrayRef = double[]::new; + private IntFunction boolArrayRef = boolean[]::new; + private IntFunction byteArrayRef = byte[]::new; + private IntFunction charArrayRef = char[]::new; + private IntFunction shortArrayRef = short[]::new; + private IntFunction floatArrayRef = float[]::new; + private Session checkSession = ((Supplier) Session::new).get(); + private Supplier checkSession2 = Session.Inner::checkSession2; + private Object checkSession3 = new Session().new Inner()::checkSession2; + private static Supplier refSomething = new Session()::checkSession; + + // Object array constructors + private IntFunction stringArrayRef = String[]::new; + private IntFunction integerArrayRef = Integer[]::new; + private IntFunction objectArrayRef = Object[]::new; + + // Multi-dimensional array constructors + private IntFunction int2DArrayRef = int[][]::new; + private IntFunction string2DArrayRef = String[][]::new; + private IntFunction int3DArrayRef = int[][][]::new; + + // ======================================== + // 6. this:: REFERENCES + // ======================================== + + private Function thisProcessRef = this::processString; + private Consumer thisConsumeRef = this::consumeString; + private Supplier thisSupplyRef = this::supplyValue; + private Predicate thisCheckRef = this::checkString; + + private String processString(String s) { return s.toUpperCase(); } + private void consumeString(String s) { System.out.println(s); } + private int supplyValue() { return 42; } + private boolean checkString(String s) { return s != null && !s.isEmpty(); } + + // ======================================== + // 7. GENERIC METHOD REFERENCES + // ======================================== + + // Generic static method with explicit type arguments + private Supplier> emptyListRef = Collections::emptyList; + private Supplier> emptySetRef = Collections::emptySet; + private Supplier> emptyMapRef = Collections::emptyMap; + + // Generic method with type argument + private Function toStringRef = Objects::toString; + + // ======================================== + // 8. NESTED/QUALIFIED TYPE REFERENCES + // ======================================== + + // Inner class/interface references + private Function, String> entryKeyRef = Map.Entry::getKey; + private Function, Integer> entryValueRef = Map.Entry::getValue; + private Comparator> entryComparatorRef = Map.Entry::comparingByKey; + + // ======================================== + // 9. COMPLEX EXPRESSIONS WITH METHOD REFS + // ======================================== + + // Method reference as argument in method invocation + private List sortedList = List.of("b", "a", "c").stream() + .sorted(String::compareTo) + .collect(Collectors.toList()); + + // Multiple method references in one expression + private Map> groupedByLength = List.of("a", "bb", "ccc").stream() + .collect(Collectors.groupingBy(String::length)); + + // Method reference with toArray + private String[] strArray = List.of("a", "b").stream() + .toArray(String[]::new); + + + static class grandParent { + String greet() { return "P"; } + U id(U u) { return u; } + } + + static class Parent extends grandParent { + String greet() { return "P"; } + U id(U u) { return u; } + } + + static class Child extends Parent { + class Inner { + Supplier qSuperRef = Child.super::greet; + Function qSuperGenericRef = Child.super::id; + } + } + + static class Type1 { + static class Type2 { + // Static method → STATIC method reference + static String staticMethod() { return "static"; } + + // Instance method → UNBOUND method reference + String instanceMethod() { return "instance"; } + } + } + + // STATIC: Type1.Type2::staticMethod + Supplier staticRef = Type1.Type2::staticMethod; + + // UNBOUND: Type1.Type2::instanceMethod (first arg becomes receiver) + Function unboundRef = Type1.Type2::instanceMethod; + + // Example: Outer.Middle.super::method + static class Outer { + static class MiddleParent { + String method() { return "MiddleParent"; } + } + + class Middle extends MiddleParent { + class DeepInner { + // Qualified super reference: Outer.Middle.super::method + Supplier refTstr = Outer.Middle.super::method; + } + } + } + + // ======================================== + // 10. ENUM CONSTANT WITH METHOD REFERENCES + // ======================================== + + enum Operation { + ADD(Integer::sum), + SUBTRACT((a, b) -> a - b), // lambda for comparison + MULTIPLY(Math::multiplyExact), + MAX(Integer::max), + MIN(Integer::min); + + private final IntBinaryOperator operator; + Operation(IntBinaryOperator operator) { + this.operator = operator; + } + + int apply(int a, int b) { return operator.applyAsInt(a, b); } + } + + // Enum constant method references in field + private IntBinaryOperator addOpRef = Integer::sum; + private IntBinaryOperator multiplyOpRef = Math::multiplyExact; +} + +/** + * Tests super:: method references + */ +class MethodRefParent { + protected String greet() { return "Hello from parent"; } + protected String greetWith(String name) { return "Hello, " + name; } + protected int compute(int x, int y) { return x + y; } +} + +class MethodRefChild extends MethodRefParent { + // super:: method reference to parent's method + private Supplier superGreetRef = super::greet; + private Function superGreetWithRef = super::greetWith; + private IntBinaryOperator superComputeRef = super::compute; + + // Override and use super:: + @Override + protected String greet() { + return "Hello from child"; + } + + // this:: vs super:: + private Supplier thisGreetRef = this::greet; // calls child's greet + private Supplier parentGreetRef = super::greet; // calls parent's greet +} + +/** + * Generic class with method references + */ +class GenericMethodRefContainer { + private Function toStringRef = Object::toString; + private BiPredicate equalsRef = Object::equals; + private Function hashCodeRef = Object::hashCode; + + // Constructor reference for generic type + private Supplier> listSupplierRef = ArrayList::new; + private Supplier> setSupplierRef = HashSet::new; + + // Method reference using type parameter + private Function, Integer> sizeRef = List::size; + private Predicate> isEmptyRef = Collection::isEmpty; + + // Wildcard in method reference type argument + Function wildcardRef = SomeClass::method; + + // Bounded in method reference type argument + Function boundedRef = SomeClass::method; + + // Nested parameterized in method reference type argument + Supplier> nestedRef = Factory::>create; + } + +/** + * Bounded generic method references + */ +class BoundedGenericMethodRef> { + // Method reference with bounded type + private Comparator naturalOrderRef = Comparable::compareTo; + private BiPredicate equalsRef = Object::equals; +} + +/** + * Wildcard type method references + */ +class WildcardMethodRef { + // Unbounded wildcard + private Function, Integer> sizeRef = List::size; + private Predicate> isEmptyRef = Collection::isEmpty; + + // Upper bounded wildcard + private Function, Integer> numListSizeRef = List::size; + + // Lower bounded wildcard + private Consumer> clearRef = List::clear; +} + +/** + * Static inner class method references + */ +class OuterForMethodRef { + + static class StaticInner { + static String process(String s) { return s.toUpperCase(); } + String instanceProcess(String s) { return s.toLowerCase(); } + } + + // Reference to static method of static inner class + private Function staticInnerRef = OuterForMethodRef.StaticInner::process; + + // Constructor reference for static inner class + private Supplier staticInnerCreatorRef = StaticInner::new; + + class Inner { + String process(String s) { return s.trim(); } + } +} + +/** + * Interface default method references (Java 8+) + */ +interface MethodRefInterface { + default String process(String s) { return s.trim(); } + static String staticProcess(String s) { return s.toUpperCase(); } +} + +class MethodRefInterfaceImpl implements MethodRefInterface { + // Reference to static method of interface + private Function interfaceStaticRef = MethodRefInterface::staticProcess; +} + +/** + * Varargs method references + */ +class VarargsMethodRef { + private static String join(String... parts) { + return String.join(",", parts); + } + + // Note: Method reference to varargs method + private Function joinRef = VarargsMethodRef::join; + + // String.format is varargs + private BiFunction formatRef = String::format; +} + +/** + * Overloaded method references + */ +class OverloadedMethodRef { + private static void process(String s) { } + private static void process(Integer i) { } + private static void process(String s, Integer i) { } + + // Reference resolves based on functional interface type + private Consumer stringProcessRef = OverloadedMethodRef::process; + private Consumer intProcessRef = OverloadedMethodRef::process; + private BiConsumer biProcessRef = OverloadedMethodRef::process; +} + +/** + * Primitive specialized functional interfaces + */ +class PrimitiveMethodRef { + // IntFunction/LongFunction/DoubleFunction + private IntFunction intToStringRef = Integer::toString; + private LongFunction longToStringRef = Long::toString; + private DoubleFunction doubleToStringRef = Double::toString; + + // ToIntFunction/ToLongFunction/ToDoubleFunction + private ToIntFunction parseIntRef = Integer::parseInt; + private ToLongFunction parseLongRef = Long::parseLong; + private ToDoubleFunction parseDoubleRef = Double::parseDouble; + + // IntUnaryOperator/LongUnaryOperator/DoubleUnaryOperator + private IntUnaryOperator negateIntRef = Math::negateExact; + private LongUnaryOperator negateLongRef = Math::negateExact; + private DoubleUnaryOperator absDoubleRef = Math::abs; + + // IntBinaryOperator/LongBinaryOperator/DoubleBinaryOperator + private IntBinaryOperator addIntRef = Integer::sum; + private LongBinaryOperator addLongRef = Long::sum; + private DoubleBinaryOperator addDoubleRef = Double::sum; + + // IntPredicate/LongPredicate/DoublePredicate + private IntPredicate isPositiveIntRef = (i) -> i > 0; // lambda - no good method ref + private DoublePredicate isNaNRef = Double::isNaN; + private DoublePredicate isInfiniteRef = Double::isInfinite; +} + +/** + * Qualified Outer.this::method (from inner class) + */ +class OuterThisMethodRef { + String outerMethod() { return "outer"; } + + class Inner { + String innerMethod() { return "inner"; } + + // Qualified outer this method reference + private Supplier outerRef = OuterThisMethodRef.this::outerMethod; + + // Multi-level qualified + class Inner2 { + private Supplier outerRef = OuterThisMethodRef.this::outerMethod; + private Supplier inner1Ref = Inner.this::innerMethod; + } + } +} + +/** + * Method Reference on Field Access + */ +class FieldAccessMethodRef { + private final HelperClass helper = new HelperClass(); + + // Method reference on field + private Function fieldMethodRef = helper::process; + + // Method reference on this.field + private Function thisFieldMethodRef = this.helper::process; +} + +class HelperClass { + String process(String s) { return s; } +} + +/** + * Method Reference on Array Element (rare but valid) + */ +class ArrayElementMethodRef { + private final HelperClass[] helpers = { new HelperClass() }; + + // Method reference on array element + private Function arrayElemRef = helpers[0]::process; +} + +/** + * Method Reference on Method Result (rare but valid) + */ +class MethodResultMethodRef { + private HelperClass getHelper() { return new HelperClass(); } + + // Method reference on method result - captures result at field init time + private Function methodResultRef = getHelper()::process; +} + +/** + * Method Reference on Parenthesized Expression + */ +class ParenthesizedMethodRef { + private final HelperClass helper1 = new HelperClass(); + private final HelperClass helper2 = new HelperClass(); + + // Parenthesized expression + private Function parenRef = (helper1)::process; + + // Ternary as receiver (must be parenthesized) + private Function ternaryRef = (true ? helper1 : helper2)::process; +} + +/** + * Method Reference on Cast Expression + */ +class CastMethodRef { + private final Object obj = new HelperClass(); + + // Cast as receiver + private Function castRef = ((HelperClass) obj)::process; +} + +/** + * Method Reference on new Expression (rare) + */ +class NewExprMethodRef { + // Method reference on newly created object + // Note: Creates object at field init time, captures that instance + private Function newExprRef = new HelperClass()::process; + private Supplier newExprRef2 = new StringBuilder("test")::length; +} + +/** + * Fully Qualified Type Static Method Reference + */ +class FullyQualifiedMethodRef { + // Fully qualified package path + private Function fqParseInt = java.lang.Integer::parseInt; + private DoubleSupplier fqRandom = java.lang.Math::random; + private Supplier> fqEmptyList = java.util.Collections::emptyList; +} + +/** + * Generic Array Constructor Reference + */ +class GenericArrayMethodRef { + // Generic array constructor patterns + private IntFunction[]> listArrayRef = List[]::new; + private IntFunction[]> mapArrayRef = Map[]::new; +} + +/** + * Method Reference to getClass (special case) + */ +class GetClassMethodRef { + // Reference to Object::getClass + private Function> getClassRef = Object::getClass; +} + +/** + * Method Reference in Ternary Branches + */ +class TernaryBranchMethodRef { + private static final boolean FLAG = true; + + // Different method references in ternary branches + private Function ternaryMethodRef = + FLAG ? String::toUpperCase : String::toLowerCase; + + private IntBinaryOperator ternaryOpRef = + FLAG ? Integer::sum : Integer::max; +} + +/** + * Method Reference in Binary Expression context + */ +class BinaryExprMethodRef { + // Method refs as arguments in expression that's part of binary + private String result = List.of("a", "b").stream() + .map(String::toUpperCase) + .collect(Collectors.joining()) + "suffix"; +} + +/** + * Constructor Reference with explicit type parameter + */ +class GenericConstructorRef { + // Constructor reference where type is inferred + private Supplier> listRef = ArrayList::new; + private Supplier> mapRef = HashMap::new; + + // With explicit type parameter on constructor ref + private Supplier> explicitListRef = ArrayList::new; +} + +/** + * Inner Class Constructor Reference + */ +class OuterForInnerCtorRef { + class Inner { + Inner(String s) {} + } + + // Inner class constructor reference - requires enclosing instance + private Function innerCtorRef = Inner::new; +} diff --git a/parser/src/test-data/java/expressions/NestedRecordPatterns.java b/parser/src/test-data/java/expressions/NestedRecordPatterns.java new file mode 100644 index 000000000..8e94963b1 --- /dev/null +++ b/parser/src/test-data/java/expressions/NestedRecordPatterns.java @@ -0,0 +1,70 @@ +package com.axiomcode.test.expressions; + +/** + * Acceptance fixture for the components of a NESTED record pattern (JEP 440). + * + * A deconstruction's top-level components each got a local variable row with + * scopeKind RECORD_PATTERN carrying their written type. A component that is + * itself a record pattern did not: its own components produced no row, at any + * depth. + * + * The nested pattern is a direct child of the record_pattern_body, not wrapped + * in a record_pattern_component, so a loop that matched only components skipped + * it. A recursive branch existed already, but on the children of record_pattern + * rather than of its body, where a nested pattern never appears. + * + * The consequence was quiet rather than unsound: with no entity for the binding, + * a call on it is honestly a declared unknown and can never be anything else. + * Matching a shape more than one level deep is the point of the feature, and + * `Line(Point(var x1, var y1), Point p2)` is the JEP's own example. + * + * The single-level cases are the controls: they were always recorded, and they + * pin that the recursion adds rows without duplicating the ones already there. + */ +public class NestedRecordPatterns { + + /** Control: only top-level components, which always worked. */ + String topLevelOnly(Node n) { + if (n instanceof Pair(Leaf a, Node ignored)) { + return a.render(); + } + return ""; + } + + /** One level of nesting: x and i2 come from the inner pattern. */ + String oneLevelDeep(Node n) { + if (n instanceof Pair(Pair(Leaf x, Node i2), Node i3)) { + return x.render() + i2 + i3; + } + return ""; + } + + /** The JEP's canonical example, with `var` components. */ + void jepCanonical(Object o) { + if (o instanceof Line(Point(var x1, var y1), Point p2)) { + System.out.println(x1 + y1 + p2.x()); + } + } + + /** Three levels, so the recursion cannot stop after one. */ + void threeLevelsDeep(Object o) { + if (o instanceof Box(Line(Point(int a1, int b1), Point c1), Point d1)) { + System.out.println(a1 + b1 + c1.x() + d1.y()); + } + } + + /** A nested deconstruction in a switch pattern rather than an instanceof. */ + String inSwitch(Object o) { + return switch (o) { + case Line(Point(var p, var q), Point r) -> "" + p + q + r.x(); + default -> ""; + }; + } +} + +sealed interface Node permits Leaf, Pair { } +record Leaf(String value) implements Node { String render() { return "L"; } } +record Pair(Node left, Node right) implements Node { String render() { return "P"; } } +record Point(int x, int y) { } +record Line(Point from, Point to) { } +record Box(Line diag, Point origin) { } diff --git a/parser/src/test-data/java/expressions/ObjectCreationTestCases.java b/parser/src/test-data/java/expressions/ObjectCreationTestCases.java new file mode 100644 index 000000000..4dc55d7a3 --- /dev/null +++ b/parser/src/test-data/java/expressions/ObjectCreationTestCases.java @@ -0,0 +1,407 @@ +package com.inventory.auth.examples9; + +import java.util.*; +import java.util.concurrent.*; + +import javax.validation.constraints.NotNull; + +import com.inventory.auth.examples4.TypeUseAnnotationPatterns.NotEmpty; + +import java.io.*; +import java.lang.annotation.Target; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.ElementType; + +@Target(ElementType.TYPE_USE) +@Retention(RetentionPolicy.RUNTIME) +@interface TA {} + +/** + * Comprehensive test cases for OBJECT_CREATION expression kind. + */ +public class ObjectCreationTestCases { + + // ========================================================================= + // SIMPLE OBJECT CREATION + // ========================================================================= + + private Object simpleObject = new Object(); + private String simpleString = new String("hello"); + private StringBuilder builder = new StringBuilder(); + private StringBuilder builderWithArg = new StringBuilder("initial"); + private Integer boxedInt = new Integer(42); + + // ========================================================================= + // GENERIC OBJECT CREATION + // ========================================================================= + + private ArrayList explicitTypeArg = new ArrayList(); + private ArrayList diamond = new ArrayList<>(); + private HashMap multipleTypeArgs = new HashMap(); + private HashMap multipleWithDiamond = new HashMap<>(); + private ArrayList> nestedGeneric = new ArrayList>(); + private HashMap>> complexNested = + new HashMap>>(); + + // ========================================================================= + // FULLY QUALIFIED TYPE NAMES + // ========================================================================= + + private java.util.Date fullyQualifiedDate = new java.util.Date(); + private java.util.Map.Entry fullyQualifiedNested = + new java.util.AbstractMap.SimpleEntry<>("key", "value"); + + // ========================================================================= + // ANONYMOUS CLASS CREATION + // ========================================================================= + + // Anonymous implementing interface + private Runnable anonymousRunnable = new Runnable() { + VarargFunction varargLambda = args -> String.join("-", args); + + @Override + public void run() { + System.out.println("running"); + } + }; + + // Anonymous extending class + private Thread anonymousThread = new Thread() { + @Override + public void run() { + System.out.println("thread"); + } + }; + + // Anonymous with generic interface + private Comparator anonymousComparator = new Comparator() { + @Override + public int compare(String o1, String o2) { + return o1.compareTo(o2); + } + }; + + // Anonymous with fields and methods + private Object complexAnonymous = new Object() { + private int count = 0; + public void increment() { count++; } + }; + + // ========================================================================= + // QUALIFIED INNER CLASS INSTANTIATION (outer.new Inner()) + // ========================================================================= + + private OuterForInner outerInstance = new OuterForInner(); + private OuterForInner.Inner qualifiedInner = outerInstance.new Inner("test"); + private OuterForInner.Inner chainedQualified = new OuterForInner().new Inner("chained"); + + // ========================================================================= + // STATIC AND NON-STATIC INNER CLASS + // ========================================================================= + + private static class StaticInner { + StaticInner(int v) {} + } + + class NonStaticInner { + NonStaticInner(String n) {} + } + + private StaticInner staticInner = new StaticInner(10); + private NonStaticInner nonStaticInner = new NonStaticInner("test"); + + // ========================================================================= + // OBJECT CREATION IN EXPRESSIONS + // ========================================================================= + + // As method argument + private String inMethodArg = String.format("%s", new Object()); + + // In ternary + private List inTernary = true ? new ArrayList<>() : new LinkedList<>(); + + // Chained method on new + private String chainedOnNew = new String("hello").toUpperCase(); + + // In unary + private boolean inUnary = !new ArrayList<>().isEmpty(); + + // In binary + private String inBinaryConcat = new String("a") + new String("b"); + private boolean inBinaryInstanceof = new Object() instanceof Object; + + // ========================================================================= + // CONSTRUCTOR ARGUMENT VARIATIONS + // ========================================================================= + + // Array as argument + private String arrayArg = new String(new char[]{'a', 'b', 'c'}); + + // Collection as argument + private ArrayList collectionArg = new ArrayList<>(Arrays.asList("a", "b")); + + // Lambda as argument + private Thread lambdaArg = new Thread(() -> System.out.println("run")); + + // Method reference as argument + private Thread methodRefArg = new Thread(System.out::println); + + // Ternary as argument + private String ternaryArg = new String(true ? "yes" : "no"); + + // Binary expression as argument + private StringBuilder binaryExprArg = new StringBuilder(10 + 20); + + // Method call as argument + private String methodCallArg = new String("hello".toUpperCase()); + + // Nested object creation as argument + private ArrayList nestedNewArg = new ArrayList<>(new ArrayList<>()); + + // Null as argument (cast needed) + private RuntimeException nullArg = new RuntimeException((String) null); + + // ========================================================================= + // AS ARRAY ELEMENT + // ========================================================================= + + private Object[] inArrayInit = { new Object(), new String("test"), new Integer(42) }; + + // ========================================================================= + // EXCEPTION CREATION + // ========================================================================= + + private RuntimeException rtException = new RuntimeException("error"); + private IllegalArgumentException iaException = new IllegalArgumentException("bad"); + private NullPointerException npeException = new NullPointerException(); + + // ========================================================================= + // WILDCARD ASSIGNMENT + // ========================================================================= + + private List wildcardAssign = new ArrayList(); + private List upperBoundAssign = new ArrayList(); + private List lowerBoundAssign = new ArrayList(); + + // ========================================================================= + // CONCURRENT COLLECTIONS + // ========================================================================= + + private ConcurrentHashMap concurrentMap = new ConcurrentHashMap<>(); + private CopyOnWriteArrayList cowList = new CopyOnWriteArrayList<>(); + private LinkedBlockingQueue blockingQueue = new LinkedBlockingQueue<>(100); + + // ========================================================================= + // ARRAY CREATION EXPRESSIONS (arrays are objects too!) + // ========================================================================= + + // Primitive array creation + private int[] primitiveArray = new int[3]; + private double[] doubleArray = new double[10]; + private boolean[] boolArray = new boolean[5]; + + // Reference array creation + private String[] stringArray = new String[3]; + private Object[] objectArray = new Object[5]; + private Integer[] integerArray = new Integer[10]; + + // Array creation with initializer (explicit new) + private int[] arrayWithInit = new int[] { 1, 2, 3 }; + private String[] stringArrayInit = new String[] { "x", "y", "z" }; + private Object[] objectArrayInit = new Object[] { "str", 42, true }; + + // Array initializer WITHOUT explicit new (still creates new array) + private int[] implicitArrayInit = { 1, 2, 3 }; + private String[] implicitStringInit = { "a", "b", "c" }; + + // Multi-dimensional arrays + private int[][] twoDimArray = new int[2][3]; + private int[][] jaggedArray = new int[2][]; + private int[][] twoDimWithInit = new int[][] { {1, 2}, {3, 4, 5} }; + private String[][] stringMatrix = new String[3][4]; + + // 3D array + private int[][][] threeDimArray = new int[2][3][4]; + + // Generic array (can't do new T[], but can do this) + private List>[] genericArray = new ArrayList[5]; // unchecked but valid + + Object o = new @TA Object(); + java.util.List<@TA String> xs = new java.util.ArrayList<>(); + @TA int[] arr + = new @TA int[3]; + + // ========================================================================= + // ANONYMOUS CLASS + DIAMOND (Java 9+) + // ========================================================================= + + private List anonWithDiamond = new ArrayList<>() { + @Override + public boolean add(String s) { + System.out.println("Adding: " + s); + return super.add(s); + } + }; + + private Map anonMapDiamond = new HashMap<>() { + { put("default", 0); } // instance initializer + }; + + // ========================================================================= + // QUALIFIED INNER + ANONYMOUS COMBINED + // ========================================================================= + + private OuterForInner outerForAnon = new OuterForInner(); + private OuterForInner.Inner anonQualifiedInner = outerForAnon.new Inner("x") { + @Override + public String toString() { return "anonymous inner"; } + }; +} + +// Helper class for qualified inner class tests +class OuterForInner { + class Inner { + Inner(String s) {} + } +} + +// Parameterized outer with parameterized inner +class ParameterizedOuter { + class ParameterizedInner { + ParameterizedInner(T t, U u) {} + } +} + +class ParameterizedInnerCreationTests { + private ParameterizedOuter outer = new ParameterizedOuter(); + private ParameterizedOuter.ParameterizedInner inner = + outer.new ParameterizedInner("test", 42); +} + +// Generic constructor tests (rare) +class GenericConstructorClass { + GenericConstructorClass(T value) {} +} + +class GenericConstructorTests { + // Explicit type argument on constructor + private GenericConstructorClass explicitCtorTypeArg = new GenericConstructorClass("test"); +} + +// ========================================================================= +// EXPLICIT THIS.NEW +// ========================================================================= +class OuterExplicitThis { + class Inner { + Inner(String s) {} + } + + private Inner explicitThisNew = this.new Inner("test"); + private Object explicitTest = new Interface() { + @Override + public void myMethod() { + { + this.explicitThisNew = this.new Inner("LET US GO!!!!"); + } + this.explicitThisNew.toString(); + } + }; +} + +// ========================================================================= +// QUALIFIED THIS.NEW FROM SIBLING INNER +// ========================================================================= +class OuterQualifiedThis { + class Inner1 {} + + class Inner2 { + // Create sibling inner via qualified this + private Inner1 siblingNew = OuterQualifiedThis.this.new Inner1(); + private Inner1 testInner1 = Inner1.this; + } +} + +// ========================================================================= +// DEEPLY CHAINED QUALIFIED NEW +// ========================================================================= +class Level0 { + class Level1 { + class Level2 {} + } +} + +class DeepChainedNew { + private Level0.Level1.Level2 deepChained = new Level0().new Level1().new Level2(); +} + +// ========================================================================= +// PARENTHESIZED NEW +// ========================================================================= +class ParenthesizedNewTests { + private String parenNew = (new String("hello")).toUpperCase(); + private int parenNewLength = (new StringBuilder("test")).length(); +} + +// ========================================================================= +// MULTIPLE NEW IN EXPRESSION +// ========================================================================= +class MultipleNewTests { + private int multiNew = new String("a").length() + new String("bb").length(); + private boolean ternaryCondNew = new java.util.Random().nextBoolean() ? true : false; + private Object temp = new @NonNull String(); +} + +// ========================================================================= +// NEW AS ARRAY DIMENSION +// ========================================================================= +class NewAsDimensionTests { + private String[] newAsDim = new String[new Integer(5)]; +} + +// ========================================================================= +// RECORD CREATION (Java 16+) +// ========================================================================= +record TestPoint(int x, int y) {} + +class RecordCreationTests { + private TestPoint recordInstance = new TestPoint(10, 20); + private TestPoint recordWithExprs = new TestPoint(5 + 5, 10 * 2); +} + +// ========================================================================= +// CAST ON NEW EXPRESSION +// ========================================================================= +class CastOnNewTests { + // Cast the result of new expression + private Object castNew = (Object) new String("test"); + private CharSequence castToInterface = (CharSequence) new StringBuilder("test"); +} + +// ========================================================================= +// ANONYMOUS CLASS WITH CONSTRUCTOR ARGS +// ========================================================================= +class AnonymousWithCtorArgs { + // Anonymous extending class with constructor args + private Thread anonWithCtorArg = new Thread("thread-name") { + @Override + public void run() { } + }; +} + +// ========================================================================= +// MULTI-LEVEL QUALIFIED THIS.NEW +// ========================================================================= +class MultiLevelQualifiedThisNew { + class Level1 { + class Level2 { + class Level3 {} + } + } + + class SiblingInner { + // Access deeply nested inner from sibling + private Level1.Level2.Level3 deepQualified = + MultiLevelQualifiedThisNew.this.new Level1().new Level2().new Level3(); + } +} \ No newline at end of file diff --git a/parser/src/test-data/java/expressions/PatternBindingDestructuring.java b/parser/src/test-data/java/expressions/PatternBindingDestructuring.java new file mode 100644 index 000000000..ff01c07e6 --- /dev/null +++ b/parser/src/test-data/java/expressions/PatternBindingDestructuring.java @@ -0,0 +1,104 @@ +package expressions; + +/** + * The USE site of a pattern binding is a `PATTERN_BINDING_VARIABLE`, not a `FIELD`. + * + * `currentPatternBindingNames` is populated while the `instanceof` is extracted, but the + * binding is USED in a different statement, and therefore in a different extraction call: + * `if (o instanceof Target a)` is an `if` condition, `a.hit()` is an expression statement — and + * expression statements are extracted BEFORE control-flow conditions, so the use site was + * reached before the binding existed. `addIdentifierReferenceData` then fell through to + * `classifyByNamingConvention`, which returns `FIELD` for any lowercase name. + * + * Where the binding SHADOWS a field of another type — legal and unambiguous per JLS 6.3.1 and + * 14.30.2 — that is a wrong answer rather than a weak one: the engine answers the call site + * with the field's type as well, and since the field's type is not a subtype of the binding's, + * that extra target is not inside a sound envelope either. It also means an unresolved binding + * cannot be told apart from an unresolved field. + * + * The second half of this fixture is about the OPPOSITE error. The four statement entry points + * never reset the binding set, so names leaked from one extraction call into the next — and, + * because the extractor instance is reused, from one FILE into the next: ordinary field reads + * in files containing no pattern at all were tagged `PATTERN_BINDING_VARIABLE`. `NoPatterns` + * below declares fields with the same names as the bindings above and reads them, so a leak + * shows up as a field read tagged as a binding. + */ +public class PatternBindingDestructuring { + + /** A field of a DIFFERENT type from the binding that shadows it. This is the whole point. */ + Other shadowed = new Other(); + + /** The core case: the binding `shadowed` shadows the field `shadowed`. */ + void bindingShadowsAField(Object o) { + if (o instanceof Target shadowed) { + shadowed.hit(); + } + } + + /** A binding used in a following statement, with no field of that name at all. */ + void bindingInFollowingStatement(Object o) { + if (o instanceof Target bound) { + bound.hit(); + } + } + + /** A binding used in the SAME expression as the instanceof — this always worked. */ + boolean bindingInSameExpression(Object o) { + return o instanceof Target inline && inline.ready(); + } + + /** A record pattern's components are bindings too, and were tagged FIELD the same way. */ + String recordPatternComponents(Object o) { + if (o instanceof Pair(String left, String right)) { + return left + right; + } + return ""; + } + + /** A nested record pattern, so the recursion has to reach the inner components. */ + String nestedRecordPattern(Object o) { + if (o instanceof Outer(Pair(String inner, String other), String tail)) { + return inner + other + tail; + } + return ""; + } + + /** A switch type pattern binding, used in the arm. */ + int switchTypePattern(Object o) { + return switch (o) { + case Target hit -> hit.size(); + default -> 0; + }; + } + + record Pair(String left, String right) { } + record Outer(Pair pair, String tail) { } + + static class Other { void hit() {} } + static class Target { void hit() {} boolean ready() { return true; } int size() { return 1; } } +} + +/** + * No pattern of any kind. Every read below is a genuine field read, and each name is also a + * binding name in the class above — so if the binding set leaks, these are tagged + * PATTERN_BINDING_VARIABLE and the fixture says so. + */ +class NoPatterns { + + int bound; + int inline; + int left; + int right; + int inner; + int hit; + + int readsFields() { + return bound + inline + left + right + inner + hit; + } + + int alsoReadsFields() { + int total = bound; + total += left; + return total + hit; + } +} diff --git a/parser/src/test-data/java/expressions/PatternBindingUseSites.java b/parser/src/test-data/java/expressions/PatternBindingUseSites.java new file mode 100644 index 000000000..ad269c5e5 --- /dev/null +++ b/parser/src/test-data/java/expressions/PatternBindingUseSites.java @@ -0,0 +1,82 @@ +package com.axiomcode.test.expressions; + +/** + * Acceptance fixture for the USE site of a pattern binding (JLS 6.3.1, 14.30.2). + * + * The declaration site was already correct, tagged PATTERN_BINDING. The use site + * was not: it fell through to the naming-convention fallback and was tagged + * FIELD. + * + * The reason is scope, not classification. The classifier checks a set of names + * in scope, and that set was populated per extraction call. A binding is declared + * in one statement and used in another - `if (o instanceof Target a) { a.hit(); }` + * - and each statement is extracted by its own call, so the binding was already + * out of scope by the time its use was classified. + * + * The consequence is worst where a binding shadows a field, which Java permits: + * the use site then answers with the field's type as well as the binding's, and + * that extra target is outside the sound envelope rather than inside it. The + * mistagging itself is not limited to shadowing - it applied to every pattern + * binding use - so the non-shadowing cases below matter as much as `shadowsField`. + * + * The genuine field references at the top are the control: they must stay FIELD. + */ +public class PatternBindingUseSites { + + Other shadowed = new Other(); + String plainField; + + /** Control: real field references, which must keep FIELD. */ + void realFields() { + shadowed.hit(); + System.out.println(plainField); + } + + /** The shadowing case: `shadowed` here is the binding, not the field. */ + void shadowsField(Object o) { + if (o instanceof Target shadowed) { + shadowed.hit(); + } + } + + /** No shadowing, and it was mistagged just the same. */ + void noShadowing(Object o) { + if (o instanceof Target t) { + t.hit(); + } + } + + /** A switch type pattern binding. */ + String switchPattern(Object o) { + return switch (o) { + case Target t -> t.toString(); + default -> ""; + }; + } + + /** Record pattern components are bindings too. */ + void recordPattern(Object o) { + if (o instanceof Point(int x, int y)) { + System.out.println(x + y); + } + } + + /** + * A binding is scoped to the statement that declares it, so a field of the + * same name is still a field before and after that statement. + * + * Matching on the name alone would call all three uses the binding, which + * trades one wrong answer for another rather than fixing anything. + */ + void fieldOutsideBindingScope(Object o) { + shadowed.hit(); + if (o instanceof Target shadowed) { + shadowed.hit(); + } + shadowed.hit(); + } +} + +record Point(int x, int y) { } +class Other { public void hit() { } } +class Target { public void hit() { } } diff --git a/parser/src/test-data/java/expressions/QualifiedConstructorTest.java b/parser/src/test-data/java/expressions/QualifiedConstructorTest.java new file mode 100644 index 000000000..de33f9750 --- /dev/null +++ b/parser/src/test-data/java/expressions/QualifiedConstructorTest.java @@ -0,0 +1,11 @@ +package expressions; + +public class QualifiedConstructorTest { + // Fully qualified constructor calls - these should be broken down recursively + private java.util.Random random = new java.util.Random(); + private java.util.ArrayList list = new java.util.ArrayList(); + private java.util.HashMap map = new java.util.HashMap(); + + // Simple constructor call for comparison + private String simple = new String("test"); +} diff --git a/parser/src/test-data/java/expressions/RecordPatternAdvanced.java b/parser/src/test-data/java/expressions/RecordPatternAdvanced.java new file mode 100644 index 000000000..d0d65ee39 --- /dev/null +++ b/parser/src/test-data/java/expressions/RecordPatternAdvanced.java @@ -0,0 +1,213 @@ +package com.inventory.auth.examples18; + +import java.util.List; + +/** + * Advanced test cases for Java 21+ Record Pattern extraction. + * Tests: unary expressions, lambdas, OR patterns, wrapper types, + * inner class records, enum components, complex guards, and more. + * + * NOTE: Method body expressions are NOT extracted yet - only field initializers. + * Tests here focus on field-level expressions. + */ +public class RecordPatternAdvanced { + + // ======================================== + // Record Definitions (reused from examples17) + // ======================================== + + record Point(int x, int y) {} + record Person(String name, int age) {} + record Address(String street, String city, String zip) {} + record Contact(String email, String phone) {} + record Customer(String name, Address address, Contact contact) {} + + // ==================== RECORD PATTERN IN UNARY EXPRESSION ==================== + + Object unaryPatternObj = new Point(1, 1); + + // Negated pattern check + boolean notPoint = !(unaryPatternObj instanceof Point(int x, int y)); + + // Double negation + boolean doubleNot = !!(unaryPatternObj instanceof Point(int x, int y) && x > 0); + + // ==================== RECORD PATTERN IN LAMBDA (NOT IMPLEMENTED YET) ==================== + // Lambda extraction not implemented - skipping these tests + // java.util.function.Predicate patternPredicate = + // obj -> obj instanceof Point(int x, int y) && x > 0; + + // ==================== RECORD PATTERN WITH || (OR) ==================== + + Object orPatternObj = new Point(1, 2); + + // Pattern result combined with || + boolean orPattern = orPatternObj instanceof Point(int x, int y) && x > 0 + || orPatternObj instanceof Person(String name, int age); + + // Multiple OR conditions with different patterns + Object orPatternObj2 = new Person("Alice", 30); + boolean multiOr = orPatternObj instanceof Point(int px, int py) + || orPatternObj2 instanceof Person(String pname, int page) + || orPatternObj instanceof Customer(String cname, Address addr, Contact c); + + // ==================== INNER CLASS RECORD ==================== + + static class OuterContainer { + record InnerRecord(String value, int count) {} + + Object innerObj = new InnerRecord("test", 5); + String innerPattern = innerObj instanceof InnerRecord(String v, int c) + ? v + ":" + c + : "not inner"; + } + + // Instance of outer container to test + OuterContainer outerContainer = new OuterContainer(); + + // ==================== RECORD PATTERN WITH WRAPPER TYPES ==================== + + record Wrapper(Integer boxedInt, Double boxedDouble, Boolean boxedBool) {} + Object wrapperObj = new Wrapper(42, 3.14, true); + + String wrapperPattern = wrapperObj instanceof Wrapper(Integer i, Double d, Boolean b) + ? "boxed: " + i + ", " + d + ", " + b + : "not wrapper"; + + // ==================== RECORD WITH OBJECT/INTERFACE COMPONENT ==================== + + record GenericComponents(Object any, Comparable comp, java.io.Serializable ser) {} + Object genericCompObj = new GenericComponents("str", 42, "serializable"); + + // Pattern extracts as declared types + String genericCompPattern = genericCompObj instanceof GenericComponents(Object a, Comparable c, java.io.Serializable s) + ? "generic components" + : "not generic"; + + // ==================== RECORD PATTERN WITH ENUM COMPONENT ==================== + + enum Status { ACTIVE, INACTIVE } + record StatusHolder(Status status, String message) {} + Object enumComponentObj = new StatusHolder(Status.ACTIVE, "running"); + + String enumComponentPattern = enumComponentObj instanceof StatusHolder(Status s, String msg) + ? s.name() + ": " + msg + : "not status holder"; + + // ==================== GUARDED PATTERN WITH COMPLEX GUARD ==================== + + Object complexGuardObj = new Customer("Test", new Address("St", "Boston", "02101"), new Contact("e@x.com", "555")); + + // Guard with method calls on extracted variables + String complexGuard = complexGuardObj instanceof Customer(String name, Address(String street, String city, String zip), Contact c) + && city.toLowerCase().startsWith("b") + && zip.matches("\\d{5}") + ? "valid Boston customer" + : "invalid"; + + // ==================== PATTERN VARIABLE SCOPE EDGE CASES ==================== + + // Variable only in scope when pattern matches + Object scopeObj = new Point(1, 2); + String scopeTest = scopeObj instanceof Point(int x, int y) && x > 0 + ? "x=" + x + ", y=" + y + : "no match"; + + // Pattern in && - both sides must match for variables to be in scope + Object scopeObj2 = new Person("Test", 30); + boolean scopeAnd = scopeObj instanceof Point(int px, int py) + && scopeObj2 instanceof Person(String name, int age) + && px + age > 0; + + // ==================== DEEPLY NESTED WITH MULTIPLE LEVELS ==================== + + record Level1(Level2 l2, String name) {} + record Level2(Level3 l3, int count) {} + record Level3(String value) {} + + Object deepObj = new Level1(new Level2(new Level3("deep"), 10), "top"); + + String deepPattern = deepObj instanceof Level1(Level2(Level3(String innerVal), int cnt), String topName) + ? "deep: " + innerVal + ", " + cnt + ", " + topName + : "not deep"; + + // ==================== ARRAY TYPE IN RECORD PATTERN ==================== + + record WithArrays(int[] ints, String[] strings, Object[] objs) {} + Object arrayObj = new WithArrays(new int[]{1,2,3}, new String[]{"a","b"}, new Object[]{}); + + String arrayPattern = arrayObj instanceof WithArrays(int[] is, String[] ss, Object[] os) + ? "arrays: " + is.length + ", " + ss.length + ", " + os.length + : "not arrays"; + + // ==================== PARENTHESIZED PATTERN EXPRESSIONS ==================== + + Object parenObj = new Point(5, 10); + + // Extra parentheses around instanceof + boolean parenPattern1 = (parenObj instanceof Point(int x, int y)); + + // Parentheses in complex expression + boolean parenPattern2 = ((parenObj instanceof Point(int x, int y)) && (x > 0)); + + // Parenthesized ternary with pattern + String parenPattern3 = (parenObj instanceof Point(int x, int y) ? x + y : 0) + " total"; + + // ==================== CAST AND PATTERN COMBINATION ==================== + + Object castObj = new Point(3, 4); + + // Pattern check then cast + String castPattern = castObj instanceof Point(int x, int y) + ? "point sum: " + ((Point) castObj).x() + ((Point) castObj).y() + : "not point"; + + // ==================== RECORD WITH GENERIC TYPE PARAMETERS ==================== + + record Box(T value) {} + record Pair(A first, B second) {} + + Object boxObj = new Box<>("hello"); + Object pairObj = new Pair<>(1, "one"); + + // Note: Generic type is erased at runtime, but we extract the declared pattern types + String boxPattern = boxObj instanceof Box(Object v) ? "box: " + v : "not box"; + String pairPattern = pairObj instanceof Pair(Object f, Object s) ? f + ":" + s : "not pair"; + + // ==================== SWITCH WITH MIXED PATTERNS (field version) ==================== + + Object switchMixedObj = new Point(1, 2); + + String switchMixed = switch (switchMixedObj) { + case String s -> "string: " + s; + case Integer i -> "int: " + i; + case Point(int x, int y) -> "point: " + x + "," + y; + case Person(String n, int a) -> "person: " + n; + case null -> "null"; + default -> "other"; + }; + + // ==================== SWITCH WITH GUARDS ==================== + + Object guardedSwitchObj = new Point(0, 0); + + String guardedSwitch = switch (guardedSwitchObj) { + case Point(int x, int y) when x == 0 && y == 0 -> "origin"; + case Point(int x, int y) when x == y -> "diagonal"; + case Point(int x, int y) when x > y -> "above diagonal"; + case Point(int x, int y) -> "below diagonal"; + case null, default -> "other"; + }; + + // ==================== MULTIPLE PATTERNS SAME VARIABLE NAMES ==================== + + Object multi1 = new Point(1, 2); + Object multi2 = new Person("Test", 25); + + // Same variable names 'x' in different branches - no conflict + String multiVars = switch (multi1) { + case Point(int x, int y) -> "point x: " + x; + case Person(String x, int y) -> "person x: " + x; // x is String here + default -> "other"; + }; +} diff --git a/parser/src/test-data/java/expressions/RecordPatternExamples.java b/parser/src/test-data/java/expressions/RecordPatternExamples.java new file mode 100644 index 000000000..8c89f3d0f --- /dev/null +++ b/parser/src/test-data/java/expressions/RecordPatternExamples.java @@ -0,0 +1,366 @@ +package com.inventory.auth.examples17; + +import java.util.List; +import java.util.Optional; + +/** + * Comprehensive test cases for Java 21+ Record Pattern extraction. + * Tests: simple patterns, nested patterns, generic records, guarded patterns, + * switch expressions with patterns, and various edge cases. + */ +public class RecordPatternExamples { + + // ======================================== + // Record Definitions + // ======================================== + + // Simple records + record Point(int x, int y) {} + record Point3D(int x, int y, int z) {} + record Person(String name, int age) {} + record Employee(String name, int id, Department dept) {} + record Department(String name, String code) {} + + // Nested structure records + record Address(String street, String city, String zip) {} + record Contact(String email, String phone) {} + record Customer(String name, Address address, Contact contact) {} + + // Generic records + record Pair(T first, U second) {} + record Triple(A a, B b, C c) {} + record Box(T value) {} + record Result(T data, String status) {} + + // Deep nesting records + record Outer(Middle middle) {} + record Middle(Inner inner) {} + record Inner(String value) {} + + // Array component record + record Matrix(int[][] data, int rows, int cols) {} + + // ======================================== + // Test Fields - Simple Record Patterns + // ======================================== + + Object pointObj = new Point(10, 20); + Object personObj = new Person("Alice", 30); + + // Simple 2-component pattern + String simplePoint = pointObj instanceof Point(int x, int y) + ? "(" + x + ", " + y + ")" + : "not a point"; + + // Simple 2-component with String + String simplePerson = personObj instanceof Person(String name, int age) + ? name + " is " + age + " years old" + : "unknown"; + + // 3-component pattern + Object point3DObj = new Point3D(1, 2, 3); + String point3D = point3DObj instanceof Point3D(int x, int y, int z) + ? "3D: " + x + ", " + y + ", " + z + : "not 3D"; + + // ======================================== + // Test Fields - Nested Record Patterns (2 levels) + // ======================================== + + Object employeeObj = new Employee("Bob", 123, new Department("Engineering", "ENG")); + + // Nested pattern - extract department components + String nestedEmployee = employeeObj instanceof Employee(String name, int id, Department(String deptName, String code)) + ? name + " #" + id + " in " + deptName + " (" + code + ")" + : "not employee"; + + // ======================================== + // Test Fields - Nested Record Patterns (3 levels) + // ======================================== + + Object customerObj = new Customer( + "Charlie", + new Address("123 Main St", "Boston", "02101"), + new Contact("charlie@email.com", "555-1234") + ); + + // 3-level nesting - extract all components + String nestedCustomer = customerObj instanceof Customer( + String name, + Address(String street, String city, String zip), + Contact(String email, String phone)) + ? name + ": " + street + ", " + city + " " + zip + " | " + email + " | " + phone + : "not customer"; + + // ======================================== + // Test Fields - Deep Nesting (4+ levels) + // ======================================== + + Object outerObj = new Outer(new Middle(new Inner("deep value"))); + + // 4-level deep nesting + String deepNesting = outerObj instanceof Outer(Middle(Inner(String value))) + ? "Deep: " + value + : "not outer"; + + // ======================================== + // Test Fields - Generic Record Patterns + // ======================================== + + Object pairObj = new Pair<>("hello", 42); + Object tripleObj = new Triple<>("a", "b", "c"); + Object boxObj = new Box<>(new Point(5, 5)); + Object resultObj = new Result<>(new Person("Dave", 25), "success"); + + // Generic pair pattern + String genericPair = pairObj instanceof Pair(String s, Integer i) + ? s + " -> " + i + : "not pair"; + + // Generic triple pattern + String genericTriple = tripleObj instanceof Triple(String a, String b, String c) + ? a + b + c + : "not triple"; + + // Generic with nested record + String genericBoxNested = boxObj instanceof Box(Point(int x, int y)) + ? "Boxed point: " + x + ", " + y + : "not boxed point"; + + // Generic with nested record extraction + String genericResultNested = resultObj instanceof Result(Person(String name, int age), String status) + ? status + ": " + name + " age " + age + : "not result"; + + // ======================================== + // Test Fields - Guarded Patterns (when clause) + // ======================================== + + Object guardedPointObj = new Point(100, 200); + + // Pattern with guard - positive coordinates + String guardedPositive = guardedPointObj instanceof Point(int x, int y) && x > 0 && y > 0 + ? "positive point: " + x + ", " + y + : "not positive"; + + // Pattern with guard - age check + Object guardedPersonObj = new Person("Eve", 17); + String guardedAdult = guardedPersonObj instanceof Person(String name, int age) && age >= 18 + ? name + " is an adult" + : "not an adult"; + + // Complex guard with nested pattern + Object guardedCustomerObj = new Customer("Frank", new Address("456 Oak", "NYC", "10001"), new Contact("f@x.com", "555")); + String guardedNested = guardedCustomerObj instanceof Customer(String name, Address(String street, String city, String zip), Contact c) + && city.equals("NYC") + ? name + " is in NYC at " + street + : "not NYC customer"; + + // ======================================== + // Test Fields - Multiple instanceof in expression + // ======================================== + + Object obj1 = new Point(1, 1); + Object obj2 = new Person("Grace", 40); + + // Multiple patterns in ternary chain + String multiPattern = obj1 instanceof Point(int x, int y) + ? "point: " + x + "," + y + : obj1 instanceof Person(String name, int age) + ? "person: " + name + : "unknown"; + + // Combined patterns with && + String combinedPatterns = obj1 instanceof Point(int x, int y) && obj2 instanceof Person(String name, int age) + ? "Point(" + x + "," + y + ") and Person(" + name + "," + age + ")" + : "mismatch"; + + // ======================================== + // Test Fields - Var in patterns + // ======================================== + + Object varPatternObj = new Point(7, 8); + + // Using var instead of explicit type + String varPattern = varPatternObj instanceof Point(var x, var y) + ? "var point: " + x + ", " + y + : "not point"; + + // ======================================== + // Test Fields - Underscore patterns (Java 22+) + // Note: May not parse in all tree-sitter versions + // ======================================== + + // Object underscoreObj = new Point(9, 10); + // String underscorePattern = underscoreObj instanceof Point(int x, _) + // ? "x only: " + x + // : "not point"; + + // ======================================== + // Test Fields - Patterns in complex expressions + // ======================================== + + Object complexObj = new Employee("Hank", 999, new Department("Sales", "SLS")); + + // Pattern in method call argument + String inMethodCall = String.valueOf( + complexObj instanceof Employee(String name, int id, Department(String dept, String code)) + ? name + "#" + id + : "none" + ); + + // Pattern in array initializer + String[] patternInArray = { + complexObj instanceof Employee(String name, int id, Department d) + ? name : "n/a", + complexObj instanceof Employee(String n, int id, Department(String dept, String code)) + ? dept : "n/a" + }; + + // Pattern in binary expression + int patternInBinary = (complexObj instanceof Employee(String name, int id, Department d) ? id : 0) + 100; + + // ======================================== + // Test Fields - Null handling + // ======================================== + + Object nullObj = null; + + // Pattern on null - should not match + String nullPattern = nullObj instanceof Point(int x, int y) + ? "point: " + x + ", " + y + : "null or not point"; + + // ======================================== + // Test Methods with Record Patterns + // ======================================== + + public String methodWithPattern(Object obj) { + if (obj instanceof Point(int x, int y)) { + return "Point: " + x + ", " + y; + } + return "not a point"; + } + + public String methodWithNestedPattern(Object obj) { + if (obj instanceof Customer(String name, Address(String street, String city, String zip), Contact contact)) { + return name + " lives at " + street + ", " + city + " " + zip; + } + return "not a customer"; + } + + public String methodWithGuardedPattern(Object obj) { + if (obj instanceof Person(String name, int age) && age >= 21) { + return name + " can drink"; + } + return "cannot drink"; + } + + // ======================================== + // Test Methods - Switch with Record Patterns + // ======================================== + + Object randomTest = switch (obj) { + case Customer(String name, Address(String street, String city, String zip), Contact c) + -> name + " at " + city; + case Employee(String name, int id, Department(String dept, String code)) + -> name + " in " + dept; + case null, default -> "unknown"; + }; + + public String switchWithPatterns(Object obj) { + return switch (obj) { + case Point(int x, int y) -> "Point(" + x + ", " + y + ")"; + case Person(String name, int age) -> "Person: " + name + ", " + age; + case Employee(String name, int id, Department dept) -> "Employee: " + name; + case null -> "null"; + default -> "unknown"; + }; + } + + public String switchWithNestedPatterns(Object obj) { + return switch (obj) { + case Customer(String name, Address(String street, String city, String zip), Contact c) + -> name + " at " + city; + case Employee(String name, int id, Department(String dept, String code)) + -> name + " in " + dept; + case null, default -> "unknown"; + }; + } + + public String switchWithGuardedPatterns(Object obj) { + return switch (obj) { + case Point(int x, int y) when x > 0 && y > 0 -> "Q1"; + case Point(int x, int y) when x < 0 && y > 0 -> "Q2"; + case Point(int x, int y) when x < 0 && y < 0 -> "Q3"; + case Point(int x, int y) when x > 0 && y < 0 -> "Q4"; + case Point(int x, int y) -> "origin or axis"; + case null, default -> "not a point"; + }; + } + + // ======================================== + // Test Methods - Exhaustive switch + // ======================================== + + sealed interface Shape permits Circle, Rectangle, Triangle {} + record Circle(double radius) implements Shape {} + record Rectangle(double width, double height) implements Shape {} + record Triangle(double a, double b, double c) implements Shape {} + + public double area(Shape shape) { + return switch (shape) { + case Circle(double r) -> Math.PI * r * r; + case Rectangle(double w, double h) -> w * h; + case Triangle(double a, double b, double c) -> { + double s = (a + b + c) / 2; + yield Math.sqrt(s * (s - a) * (s - b) * (s - c)); + } + }; + } + + // ======================================== + // Test Fields - Edge cases + // ======================================== + + // Single component record + record Single(String only) {} + Object singleObj = new Single("alone"); + String singlePattern = singleObj instanceof Single(String only) + ? "single: " + only + : "not single"; + + // Many components record + record Many(int a, int b, int c, int d, int e) {} + Object manyObj = new Many(1, 2, 3, 4, 5); + String manyPattern = manyObj instanceof Many(int a, int b, int c, int d, int e) + ? "sum: " + (a + b + c + d + e) + : "not many"; + + // Record with array type component + record WithArray(int[] values, String label) {} + Object arrayRecordObj = new WithArray(new int[]{1, 2, 3}, "test"); + String arrayRecordPattern = arrayRecordObj instanceof WithArray(int[] vals, String label) + ? label + ": " + vals.length + " items" + : "not with array"; + + // ======================================== + // Test - Anonymous Class with Field Initializers using Record Patterns + // ======================================== + + // Anonymous class with field that uses record pattern in initializer + Object anonWithRecordPattern = new Object() { + Object testObj = new Person("Bob", 25); + + // Field initializer with record pattern - should be extracted + String anonFieldPattern = testObj instanceof Person(String name, int age) + ? "anon: " + name + " is " + age + : "not person"; + + // Nested record pattern in anonymous class field + Object nestedTestObj = new Customer("Jane", new Address("123 Main", "Boston", "02101"), new Contact("jane@email.com", "555-1234")); + String anonNestedPattern = nestedTestObj instanceof Customer(String cname, Address(String street, String city, String zip), Contact c) + ? "anon customer: " + cname + " in " + city + : "not customer"; + }; +} diff --git a/parser/src/test-data/java/expressions/ReturnExpressionExamples.java b/parser/src/test-data/java/expressions/ReturnExpressionExamples.java new file mode 100644 index 000000000..4ac00f135 --- /dev/null +++ b/parser/src/test-data/java/expressions/ReturnExpressionExamples.java @@ -0,0 +1,461 @@ +package com.inventory.auth.examples21; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Complex return expression examples for testing method body extraction. + * Tests various scenarios involving method parameters, fields, and expressions. + */ +public class ReturnExpressionExamples { + + // Fields for testing field vs parameter disambiguation + private int count = 0; + private String name = "default"; + private List items; + + // ========================================================================= + // SIMPLE RETURN - Parameter references + // ========================================================================= + + /** Direct parameter return */ + public int identity(int value) { + return value; // value is PARAMETER + } + + /** Multiple parameters in return */ + public int add(int a, int b) { + return a + b; // a is PARAMETER, b is PARAMETER + } + + /** Parameter with field - disambiguation test */ + public int addToCount(int count) { + return count + this.count; // first count is PARAMETER, this.count is FIELD + } + + // ========================================================================= + // METHOD CHAIN RETURNS - Parameters in chains + // ========================================================================= + + /** Stream chain with parameter */ + public int sumList(List numbers) { + return numbers.stream() + .mapToInt(Integer::intValue) + .sum() + count; // numbers is PARAMETER + } + + /** Complex stream with multiple operations */ + public List filterAndMap(List input, String prefix) { + return input.stream() + .filter(s -> s.startsWith(prefix)) // input is PARAMETER, prefix is PARAMETER + .map(String::toUpperCase) + .collect(Collectors.toList()); + } + + /** Nested method calls */ + public String processInput(String input, int limit) { + return input.substring(0, Math.min(input.length(), limit)).trim(); + // input is PARAMETER (3 times), limit is PARAMETER + } + + // ========================================================================= + // CONDITIONAL RETURNS - Ternary with parameters + // ========================================================================= + + /** Ternary operator with parameters */ + public int max(int x, int y) { + return x > y ? x : y; // x is PARAMETER (2 times), y is PARAMETER (2 times) + } + + /** Nested ternary */ + public int clamp(int value, int min, int max) { + return value < min ? min : (value > max ? max : value); + // value is PARAMETER (3 times), min is PARAMETER (2 times), max is PARAMETER (2 times) + } + + /** Ternary with method calls */ + public String formatOrDefault(String input, String defaultValue) { + return input != null ? input.toUpperCase() : defaultValue; + // input is PARAMETER (2 times), defaultValue is PARAMETER + } + + // ========================================================================= + // LAMBDA IN RETURN - Lambda expressions as return values + // ========================================================================= + + /** Return a lambda that captures parameter */ + public Function createAdder(int addend) { + return x -> x + addend; // addend is PARAMETER (captured) + } + + /** Return a supplier capturing parameter */ + public Supplier createGreeter(String greeting) { + return () -> greeting + "!"; // greeting is PARAMETER (captured) + } + + /** Return lambda with parameter in transformation */ + public Function compose(Function first, Function second) { + return t -> second.apply(first.apply(t)); + // first is PARAMETER, second is PARAMETER + } + + // ========================================================================= + // OBJECT CREATION IN RETURN + // ========================================================================= + + /** Return new object with parameters */ + public StringBuilder createBuilder(String initial, int capacity) { + return new StringBuilder(capacity).append(120); + // capacity is PARAMETER, initial is PARAMETER + } + + /** Return array with parameters */ + public int[] createArray(int a, int b, int c) { + return new int[] { a, b, c }; // a, b, c are PARAMETER + } + + /** Return Optional with parameter */ + public Optional wrapIfPresent(String value) { + return value != null ? Optional.of(value) : Optional.empty(); + // value is PARAMETER (2 times) + } + + // ========================================================================= + // CAST EXPRESSIONS IN RETURN + // ========================================================================= + private final int counters = (int) 120.90; + /** Cast parameter */ + public long toLong(int value) { + return (long) value; // value is PARAMETER + } + + /** Cast with computation */ + public double average(int sum, int count) { + return (double) sum / count; // sum is PARAMETER, count is PARAMETER + } + + // ========================================================================= + // SWITCH EXPRESSION IN RETURN (Java 14+) + // ========================================================================= + + /** Return switch expression with parameter */ + public String dayType(int day) { + return switch (day) { + case 1, 7 -> "weekend"; + case 2, 3, 4, 5, 6 -> "weekday"; + default -> "invalid"; + }; // day is PARAMETER + } + + /** Switch with parameter in cases */ + public int calculate(String op, int a, int b) { + return switch (op) { + case "add" -> a + b; + case "sub" -> a - b; + case "mul" -> a * b; + case "div" -> b != 0 ? a / b : 0; + default -> 0; + }; // op is PARAMETER, a is PARAMETER, b is PARAMETER + } + + // ========================================================================= + // NESTED RETURNS - Multiple return statements + // ========================================================================= + + /** Early return with parameter check */ + public String validate(String input) { + if (input == null) { + return "null input"; // no parameters here + } + if (input.isEmpty()) { + return "empty input"; // no parameters here + } + return input.trim(); // input is PARAMETER + } + + /** Return in try-catch */ + public int parseOrDefault(String text, int defaultValue) { + try { + return Integer.parseInt(text); // text is PARAMETER + } catch (NumberFormatException e) { + return defaultValue; // defaultValue is PARAMETER + } + } + + // ========================================================================= + // GENERIC METHOD RETURNS + // ========================================================================= + + /** Generic identity */ + public T genericIdentity(T value) { + return value; // value is PARAMETER + } + + /** Generic with bounds */ + public > T minOf(T a, T b) { + return a.compareTo(b) < 0 ? a : b; + // a is PARAMETER (2 times), b is PARAMETER (2 times) + } + + /** Generic with multiple type params */ + public Map.Entry createEntry(K key, V value) { + return Map.entry(key, value); // key is PARAMETER, value is PARAMETER + } + + // ========================================================================= + // VARARGS IN RETURN + // ========================================================================= + + /** Varargs sum */ + public int sumAll(int... values) { + int sum = 0; + for (int v : values) { + sum += v; + } + return sum; // sum is LOCAL_VARIABLE (not yet implemented) + } + + /** Return first vararg */ + public T firstOrNull(T... items) { + return items.length > 0 ? items[0] : null; // items is PARAMETER (2 times) + } + + // ========================================================================= + // METHOD REFERENCE RETURNS (not inside streams) + // ========================================================================= + + /** Method reference as direct return value */ + public Function getConverter() { + return String::valueOf; // METHOD_REFERENCE as return value itself + } + + /** Method reference with specific type */ + public Function getIntConverter() { + return Object::toString; // METHOD_REFERENCE - using Object to avoid ambiguity + } + + // ========================================================================= + // ANONYMOUS CLASS RETURNS + // ========================================================================= + + /** Return anonymous class implementing Comparator */ + public java.util.Comparator getComparator(boolean reverse) { + return new java.util.Comparator() { + @Override + public int compare(String a, String b) { + return reverse ? b.compareTo(a) : a.compareTo(b); + } + + public void fix() { + + } + }; + } + + /** Return anonymous Runnable */ + public Runnable getTask(String message) { + return new Runnable() { + @Override + public void run() { + System.out.println(message); + } + }; + } + + // ========================================================================= + // THIS RETURN (builder pattern) + // ========================================================================= + + /** Builder pattern - return this */ + public ReturnExpressionExamples withName(String name) { + this.name = name; + return this; // THIS reference + } + + /** Chained builder */ + public ReturnExpressionExamples withCount(int count) { + this.count = count; + return this; // THIS reference + } + + // ========================================================================= + // SUPER METHOD CALL RETURN + // ========================================================================= + + /** Override toString with super call */ + @Override + public String toString() { + int x = 5; + java.util.function.Supplier tempLocalVar = () -> { + int y = x * 2; // internal variable with operation + return y; + }; + return super.toString() + "[" + name + "]" + x; // super method call + field + } + + /** Override hashCode with super */ + @Override + public int hashCode() { + return super.hashCode() + count; // super method call + field + } + + // ========================================================================= + // UNARY OPERATORS + // ========================================================================= + + /** Unary minus */ + public int negate(int value) { + return -value; // UNARY_MINUS on PARAMETER + } + + /** Logical not */ + public boolean invert(boolean flag) { + return !flag; // LOGICAL_NOT on PARAMETER + } + + /** Bitwise complement */ + public int complement(int bits) { + return ~bits; // BITWISE_NOT on PARAMETER + } + + /** Unary plus (rare but valid) */ + public int positive(int value) { + return +value; // UNARY_PLUS on PARAMETER + } + + // ========================================================================= + // INSTANCEOF PATTERN MATCHING (Java 16+) + // ========================================================================= + + /** instanceof with pattern variable */ + public int getLength(Object obj) { + return obj instanceof String s ? s.length() : -1; + } + + /** instanceof pattern in complex expression */ + public String describeObject(Object obj) { + return obj instanceof Number n ? "Number: " + n.intValue() : "Not a number"; + } + + // ========================================================================= + // ARRAY ACCESS + // ========================================================================= + + /** Array access with parameter index */ + public String getAtIndex(String[] arr, int index) { + return arr[index]; // ARRAY_ACCESS with PARAMETER array and PARAMETER index + } + + /** Array access with literal index */ + public String getFirst(String[] arr) { + return arr[0]; // ARRAY_ACCESS with PARAMETER array and literal index + } + + /** Multidimensional array access */ + public int getElement(int[][] matrix, int row, int col) { + return matrix[row][col]; // Nested ARRAY_ACCESS + } + + // ========================================================================= + // STRING CONCATENATION + // ========================================================================= + + /** Binary + with mixed types */ + public String format(String prefix, int value, String suffix) { + return prefix + value + suffix; // Binary + with PARAMETER (string, int, string) + } + + /** Concatenation with field and parameter */ + public String describe(String adjective) { + return name + " is " + adjective; // FIELD + literal + PARAMETER + } + + // ========================================================================= + // PARENTHESIZED EXPRESSIONS + // ========================================================================= + + /** Parentheses affecting precedence */ + public int compute(int a, int b, int c) { + return (a + b) * c; // Parenthesized expression + } + + /** Complex parenthesized expression */ + public int complexCompute(int x, int y, int z) { + return ((x + y) * (y + z)) / (x + z); // Multiple parenthesized expressions + } + + // ========================================================================= + // CLASS LITERAL RETURN + // ========================================================================= + + /** Return class literal */ + public Class getStringType() { + return String.class; // CLASS_LITERAL + } + + /** Return parameterized class */ + public Class getIntegerType() { + return Integer.class; // CLASS_LITERAL + } + + /** Return array class type */ + public Class getArrayType() { + return int[].class; // CLASS_LITERAL for array + } + + // ========================================================================= + // NULL LITERAL + // ========================================================================= + + /** Direct null return */ + public String getNullString() { + return null; // NULL_LITERAL + } + + /** Null in ternary */ + public String getNullable(boolean flag) { + return flag ? name : null; // NULL_LITERAL in ternary false branch + } + + // ========================================================================= + // FIELD ACCESS ON PARAMETER OBJECTS + // ========================================================================= + + /** Access field on parameter object */ + public int getPointX(java.awt.Point point) { + return point.x; // Field access on PARAMETER + } + + /** Access field on parameter with computation */ + public int getPointSum(java.awt.Point point) { + return point.x + point.y; // Multiple field accesses on PARAMETER + } + + // ========================================================================= + // PRE/POST INCREMENT + // ========================================================================= + + /** Pre-increment on field */ + public int incrementAndReturn() { + return ++count; // PRE_INCREMENT on FIELD + } + + /** Pre-decrement on field */ + public int decrementAndReturn() { + return --count; // PRE_DECREMENT on FIELD + } + + /** Post-increment (value before increment) */ + public int returnThenIncrement() { + return count++; // POST_INCREMENT on FIELD + } + + /** Post-decrement (value before decrement) */ + public int returnThenDecrement() { + return count--; // POST_DECREMENT on FIELD + } +} diff --git a/parser/src/test-data/java/expressions/SwitchArmDuplication.java b/parser/src/test-data/java/expressions/SwitchArmDuplication.java new file mode 100644 index 000000000..dff85db9a --- /dev/null +++ b/parser/src/test-data/java/expressions/SwitchArmDuplication.java @@ -0,0 +1,67 @@ +package com.axiomcode.test.expressions; + +/** + * Acceptance fixture for arrow arms of a switch used as a VALUE. + * + * `case 1 -> t();` is written as an expression_statement, and + * `case 1 -> throw e;` as a throw_statement, whichever form the switch takes. + * Collecting every expression statement therefore picked the arm up a second + * time, once correctly as the arm's result and once again as a top-level + * statement of the enclosing method. One written call site became two rows, and + * the spurious one asserted a root context the source does not have: the arm's + * value is the switch's value, not a statement in the method. + * + * The two forms are told apart by what the switch is attached to. A switch used + * as a statement sits directly in a block; one used as a value sits under + * whatever consumes it. Every consuming position is covered below, because the + * distinction is made on the parent node and each parent is a different type. + * + * The statement form at the bottom is the control: there the arm really is a + * statement, and its single row is correct and must stay. + */ +public class SwitchArmDuplication { + + int t() { return 1; } + int u(int x) { return x; } + int f; + + /** Consumed by a return. */ + int inReturn(int k) { + return switch (k) { case 1 -> t(); default -> 0; }; + } + + /** Consumed by a variable declarator. */ + void inLocalVar(int k) { + int v = switch (k) { case 1 -> t(); default -> 0; }; + } + + /** Consumed by an argument list. */ + void inArgument(int k) { + u(switch (k) { case 1 -> t(); default -> 0; }); + } + + /** Consumed by an assignment. */ + void inField(int k) { + f = switch (k) { case 1 -> t(); default -> 0; }; + } + + /** A throw arm is a throw_statement, collected by a different pass. */ + int inThrowArm(int k) { + return switch (k) { case 1 -> throw new RuntimeException(); default -> 0; }; + } + + /** A block arm was always correct: its yield is not an arm-level statement. */ + int inYieldBlock(int k) { + return switch (k) { case 1 -> { yield t(); } default -> 0; }; + } + + /** The colon form was always correct. */ + int colonForm(int k) { + return switch (k) { case 1: yield t(); default: yield 0; }; + } + + /** Control: a switch used as a STATEMENT. Its arm is a statement, and stays one. */ + void asStatement(int k) { + switch (k) { case 1 -> t(); default -> { } } + } +} diff --git a/parser/src/test-data/java/expressions/SwitchExpressionExamples.java b/parser/src/test-data/java/expressions/SwitchExpressionExamples.java new file mode 100644 index 000000000..1b0e2e689 --- /dev/null +++ b/parser/src/test-data/java/expressions/SwitchExpressionExamples.java @@ -0,0 +1,161 @@ +package com.inventory.auth.examples15; + +import java.util.List; +import java.util.ArrayList; + +public class SwitchExpressionExamples { + Object obj = "hello"; + int num = 5; + String status = "ACTIVE"; + + // ==================== BASIC ARROW SYNTAX ==================== + + // Basic switch expression with multiple labels + String dayType = switch(num) { + case 1, 7 -> "Weekend"; + case 2, 3, 4, 5, 6 -> "Weekday"; + default -> "Unknown"; + }; + + // Switch with method invocation as selector + int length = switch(obj.toString()) { + case "hello" -> 5; + case "world" -> 5; + default -> 0; + }; + + // Switch with binary expression results + int computed = switch(num) { + case 1 -> num * 2; + case 2 -> num + 10; + default -> num - 1; + }; + + // ==================== YIELD IN BLOCKS ==================== + + // Block with yield + int yieldExample = switch(num) { + case 1 -> { + int temp = num * 2; + yield temp + 1; + } + case 2 -> { + if (num > 0) { + yield 100; + } + yield 0; + } + default -> 0; + }; + + // ==================== COLON SYNTAX WITH YIELD ==================== + + // Traditional colon syntax + int colonYield = switch(num) { + case 1: + yield 10; + case 2: + case 3: + yield 20; + default: + yield 0; + }; + + // ==================== PATTERN MATCHING WITH GUARDS ==================== + + // Pattern with guard (when clause) + String guardedPattern = switch(obj) { + case String s when s.length() > 5 -> "long string"; + case String s when s.isEmpty() -> "empty"; + case String s -> "short string"; + default -> "other"; + }; + + // ==================== THROW IN SWITCH ARM ==================== + + // Throw statement as result + String throwInSwitch = switch(num) { + case 1 -> "one"; + case 2 -> throw new IllegalArgumentException("error"); + default -> "other"; + }; + + // ==================== COMPLEX SELECTORS ==================== + + // Field access as selector + int fieldSelector = switch(this.num) { + case 1 -> 10; + default -> 0; + }; + + // Binary expression as selector + int binarySelector = switch(num + 1) { + case 2 -> 10; + default -> 0; + }; + + // Method invocation as selector + int methodSelector = switch(getStatus()) { + case 1 -> 10; + default -> 0; + }; + + // ==================== COMPLEX RESULTS ==================== + + // Object creation as result + List listResult = switch(num) { + case 1 -> new ArrayList(); + case 2 -> new ArrayList<>(10); + default -> List.of("default"); + }; + + // Cast expression as result + Object castResult = switch(num) { + case 1 -> (Object) "string"; + case 2 -> (Object) Integer.valueOf(42); + default -> null; + }; + + // Array access as result + String[] arr = {"a", "b", "c"}; + String arrayResult = switch(num) { + case 0 -> arr[0]; + case 1 -> arr[1]; + default -> arr[2]; + }; + + // Ternary as result + String ternaryResult = switch(num) { + case 1 -> num > 0 ? "positive" : "zero"; + default -> "other"; + }; + + // ==================== NESTED SWITCH ==================== + + // Nested switch expression + int nested = switch(num) { + case 1 -> switch(status) { + case "ACTIVE" -> 100; + default -> 50; + }; + case 2 -> 200; + default -> 0; + }; + + private int getStatus() { + return 1; + } + + java.util.function.Consumer> complexResult = switch(num) { + case 1 -> new java.util.function.Consumer>() { + @Override + public void accept(List list) { + list.forEach(System.out::println); // Method ref inside anonymous + } + }; + case 2 -> list -> list.stream() + .map(String::toUpperCase) // Method ref in lambda + .forEach(System.out::println); + default -> List::clear; // Direct method reference + }; +} diff --git a/parser/src/test-data/java/expressions/TernaryWithComments.java b/parser/src/test-data/java/expressions/TernaryWithComments.java new file mode 100644 index 000000000..59e39ab53 --- /dev/null +++ b/parser/src/test-data/java/expressions/TernaryWithComments.java @@ -0,0 +1,61 @@ +package com.axiomcode.test.expressions; + +/** + * Acceptance fixture for a comment inside a conditional expression (JLS 15.25). + * + * The three operands were read as namedChildren[0..2], which assumed they are + * the only named children of the ternary. A comment is a named child, and + * tree-sitter attaches it to the field it follows, so a comment before the `?` + * yielded [condition, comment, trueExpr, falseExpr]: the true branch was read as + * the comment, the true operand was emitted under TERNARY_FALSE, and the false + * operand was never read at all. + * + * Two defects at once, and in the worse order. A dropped branch is a call site + * with no row; the mislabelled one is a row asserting a role the source does not + * have, so a consumer following the false branch reaches the true expression. + * + * The grammar labels these `condition`, `consequence` and `alternative`. Every + * position a comment can occupy is covered below, because the shift depends on + * which field the comment attaches to and each position attaches differently. + * + * All eight ternaries must produce the same three roles. The control at the top + * is the reference: whatever it produces, each of the others must produce too. + */ +public class TernaryWithComments { + + String a() { return "a"; } + String b() { return "b"; } + boolean c; + + /** Control: no comment. */ + String control() { + return c ? a() : b(); + } + + /** Line comment before the `?` - the form that mislabelled a branch. */ + String lineBeforeQuestion() { + return c // + ? a() + : b(); + } + + /** Line comment after the `?`. */ + String lineAfterQuestion() { + return c + ? a() // + : b(); + } + + String blockBeforeQuestion() { return c /* x */ ? a() : b(); } + + String blockAfterQuestion() { return c ? /* x */ a() : b(); } + + String blockBeforeColon() { return c ? a() /* x */ : b(); } + + String blockAfterColon() { return c ? a() : /* x */ b(); } + + /** A comment in every position at once. */ + String everywhere() { + return c /* 1 */ ? /* 2 */ a() /* 3 */ : /* 4 */ b(); + } +} diff --git a/parser/src/test-data/java/imports/ImportStylePatterns.java b/parser/src/test-data/java/imports/ImportStylePatterns.java new file mode 100644 index 000000000..66ae2faa7 --- /dev/null +++ b/parser/src/test-data/java/imports/ImportStylePatterns.java @@ -0,0 +1,166 @@ +package com.inventory.auth.examples; + +// Explicit imports +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.HashMap; +import java.util.Set; +import java.util.HashSet; + +// Wildcard import +import java.io.*; + +// Static imports +import static java.util.Collections.sort; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static java.lang.Math.max; +import static java.lang.Math.min; + +// Fully qualified names (no import) +// java.util.concurrent.ConcurrentHashMap + +/** + * Test file demonstrating different import styles and their impact on + * type resolution in method signatures + */ +public class ImportStylePatterns { + + public List explicitImportMethod() { + return new ArrayList<>(); + } + + public Map explicitMapMethod() { + Map map = new HashMap<>(); + return map; + } + + public Set explicitSetMethod() { + return new HashSet<>(); + } + + public Serializable wildcardImportMethod(Serializable input) throws IOException { + if (input == null) throw new IOException("Null input"); + return input; + } + + public InputStream inputStreamMethod(InputStream stream) throws IOException { + return stream; + } + + public OutputStream outputStreamMethod() { + return new ByteArrayOutputStream(); + } + + public void staticImportMethod(List numbers) { + sort(numbers); + int maximum = max(numbers.get(0), numbers.get(1)); + int minimum = min(numbers.get(0), numbers.get(1)); + } + + public List staticImportReturn() { + return emptyList(); + } + + public List staticSingletonList(String value) { + return singletonList(value); + } + + public java.util.concurrent.ConcurrentHashMap fullyQualifiedMethod() { + return new java.util.concurrent.ConcurrentHashMap<>(); + } + + public java.util.concurrent.atomic.AtomicInteger fullyQualifiedAtomic() { + return new java.util.concurrent.atomic.AtomicInteger(0); + } + + public java.sql.Connection fullyQualifiedSql() throws java.sql.SQLException { + return null; + } + + public T genericWithWildcardImport(T value) throws IOException { + if (value == null) throw new IOException(); + return value; + } + + public List mixedImportStyle(T value) { + List list = new ArrayList<>(); + list.add(value); + sort((List) list); + return list; + } + + public Map>> nestedWithExplicitImports() { + Map>> result = new HashMap<>(); + Set set = new HashSet<>(); + set.add(1); + List> list = new ArrayList<>(); + list.add(set); + result.put("key", list); + return result; + } + + public java.util.stream.Stream fullyQualifiedStream(List input) { + return input.stream(); + } + + public java.util.function.Function fullyQualifiedFunction() { + return s -> s.length(); + } + + public java.util.Optional fullyQualifiedOptional(String value) { + return java.util.Optional.ofNullable(value); + } + + public static class NestedWithDifferentImports { + public List usesExplicitImport() { + return new ArrayList<>(); + } + + public Serializable usesWildcardImport(Serializable input) { + return input; + } + + public java.util.Queue usesFullyQualified() { + return new java.util.LinkedList<>(); + } + } + + public interface InterfaceWithImportVariations { + List explicitMethod(); + + java.util.Deque fullyQualifiedMethod(); + + Serializable wildcardMethod(Serializable input) throws IOException; + } + + public static class Implementation implements InterfaceWithImportVariations { + @Override + public List explicitMethod() { + return emptyList(); + } + + @Override + public java.util.Deque fullyQualifiedMethod() { + return new java.util.ArrayDeque<>(); + } + + @Override + public Serializable wildcardMethod(Serializable input) throws IOException { + return input; + } + } + + public enum EnumWithImports { + OPTION1, OPTION2; + + public List getExplicitList() { + return new ArrayList<>(); + } + + public java.util.TreeSet getFullyQualifiedSet() { + return new java.util.TreeSet<>(); + } + } +} diff --git a/parser/src/test-data/java/imports/ModuleImportDeclarations.java b/parser/src/test-data/java/imports/ModuleImportDeclarations.java new file mode 100644 index 000000000..ddc80066f --- /dev/null +++ b/parser/src/test-data/java/imports/ModuleImportDeclarations.java @@ -0,0 +1,42 @@ +/* + * Acceptance fixture for module import declarations (JEP 511, final in Java 25). + * + * No tree-sitter-java release parses this construct, checked through 0.23.5. + * What the grammar produces is a malformed import_declaration, with `module` + * swallowed into the qualified name and an ERROR node beside it: + * + * import_declaration + * import + * scoped_identifier <- text is "module java.base" + * identifier = "module" + * ERROR + * identifier = "java" + * . + * identifier = "base" + * + * Read naively that is a single-type import of a type named `base` in a package + * named `module java`, which is a row that describes something that does not + * exist. The extractor recognises the shape instead and classifies it as MODULE. + * + * The remaining imports are here to hold the ordinary classification steady: + * whatever recognises the module form must not reclassify any of them. + * + * This file compiles under `javac --release 24 --enable-preview`, and + * unconditionally on Java 25. + */ +import module java.base; +import module java.sql; + +import java.util.List; +import java.util.ArrayList; +import static java.lang.Math.PI; +import java.util.concurrent.*; +import static java.lang.Integer.*; + +public class ModuleImportDeclarations { + + void go() { + List values = new ArrayList<>(); + values.add(String.valueOf(PI)); + } +} diff --git a/parser/src/test-data/java/imports/WrappedQualifiedNames.java b/parser/src/test-data/java/imports/WrappedQualifiedNames.java new file mode 100644 index 000000000..b4445e250 --- /dev/null +++ b/parser/src/test-data/java/imports/WrappedQualifiedNames.java @@ -0,0 +1,37 @@ +package com.axiomcode.test.imports; + +/* + * Acceptance fixture for qualified names split across a line break. + * + * JLS 3.6 permits whitespace, including a line terminator, between the + * identifiers and dots of a qualified name, so every import below compiles and + * names exactly what its single-line form would name. + * + * Two defects met in one row here. The value kept the line break, so + * `importedPath` was "java.util.\nOptional" rather than "java.util.Optional"; + * and the writer did not escape it, so one logical row was written across two + * physical lines and was dropped downstream on the header field-count check. + * + * The trigger is a formatting accident or a generator that wraps long lines, + * but it is deterministic once present: the row is gone on every run. + */ +import java.util. +Optional; + +import java.util.function +.Function; + +import java.util + .concurrent + .Callable; + +import static java.lang.Math +.PI; + +public class WrappedQualifiedNames { + + Optional optional = Optional.empty(); + Function identity = s -> s; + Callable callable = () -> ""; + double pi = PI; +} diff --git a/parser/src/test-data/java/integration/CompleteExample.java b/parser/src/test-data/java/integration/CompleteExample.java new file mode 100644 index 000000000..c147147a1 --- /dev/null +++ b/parser/src/test-data/java/integration/CompleteExample.java @@ -0,0 +1,33 @@ +package com.example.test; + +import java.io.Serializable; + +@interface Deprecated { } + +@interface SuppressWarnings { + String[] value(); +} + +@interface NonNull { } + +@interface Validated { + Class[] groups() default {}; +} + +class ValidationGroup { } + +class BaseClass { } + +class SubClass1 extends CompleteExample { } + +class SubClass2 extends CompleteExample { } + +@Deprecated +@SuppressWarnings("unchecked") +public sealed class CompleteExample< + @NonNull T extends Number & Comparable, + @Validated(groups = ValidationGroup.class) U +> extends BaseClass + implements Serializable, Comparable> + permits SubClass1, SubClass2 { +} diff --git a/parser/src/test-data/java/integration/CompleteExampleTest.java b/parser/src/test-data/java/integration/CompleteExampleTest.java new file mode 100644 index 000000000..94d833391 --- /dev/null +++ b/parser/src/test-data/java/integration/CompleteExampleTest.java @@ -0,0 +1,39 @@ +package com.test.integration; + +import java.util.List; +import java.util.Map; +import java.io.Serializable; + +@Deprecated +public class CompleteExampleTest { + + @SuppressWarnings("unused") + private List items; + + private Map cache; + + public CompleteExampleTest() {} + + public CompleteExampleTest(List items) { + this.items = items; + } + + @Override + public String toString() { + return "CompleteExampleTest"; + } + + public U transform(T input, Class targetType) { + return null; + } + + public void processAll(String... args) {} +} + +interface Processor { + void process(E element); +} + +abstract class AbstractHandler { + public abstract void handle(); +} diff --git a/parser/src/test-data/java/integration/ComplexMethodsIntegration.java b/parser/src/test-data/java/integration/ComplexMethodsIntegration.java new file mode 100644 index 000000000..3e46e74fb --- /dev/null +++ b/parser/src/test-data/java/integration/ComplexMethodsIntegration.java @@ -0,0 +1,144 @@ +package com.test.integration; + +import java.io.Closeable; +import java.io.IOException; +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; + +@interface Cached { + int ttl() default 60; + String key() default ""; +} + +@interface Validated {} + +@interface NotNull {} + +@interface Size { + int min() default 0; + int max() default Integer.MAX_VALUE; +} + +public class ComplexMethodsIntegration> { + + // Complex generic method with multiple type params and bounds + @Cached(ttl = 300, key = "transform-cache") + public , V extends Collection> + Map> transformCollection( + @NotNull V input, + @Validated Function keyMapper, + Predicate filter + ) throws IOException, IllegalArgumentException { + return null; + } + + // Method with recursive bounds and complex wildcards + public > List sort( + List items, + BiFunction comparator + ) { + return null; + } + + // Method with deeply nested generics + public Map>>> getNestedData( + @Size(min = 1, max = 100) String key, + Map> source + ) { + return null; + } + + // Varargs with complex generic type + @SafeVarargs + public final List combine( + Function mapper, + @NotNull T... items + ) { + return null; + } + + // Method with intersection type bound and closeable + public void executeTask( + R task, + @Size(max = 1000) int timeout + ) throws Exception { + try { + task.run(); + } finally { + task.close(); + } + } + + // Complex constructor + public ComplexMethodsIntegration( + @NotNull T initialValue, + Map cache, + Predicate validator + ) {} + + // Static factory with complex bounds + public static > + ComplexMethodsIntegration create( + @Validated T seed, + Function transformer + ) { + return null; + } +} + +abstract class AbstractProcessor { + + // Abstract method with exception type parameter + public abstract R process( + T input, + Function handler + ) throws E; + + // Method with stream and complex lambda types + protected Stream filterAndMap( + Stream input, + Predicate filter, + Function mapper + ) { + return input.filter(filter).map(mapper); + } +} + +interface FluentBuilder, T> { + + // Recursive generic method + Self with(@NotNull String key, T value); + + // Build method with type parameter + R build(Class type); + + // Default method with complex signature + default > Self sorted( + Function keyExtractor + ) { + return null; + } +} + +record ComplexRecord( + @NotNull T value, + @Size(min = 0, max = 10) List items, + Map lookup +) { + // Compact constructor with validation + public ComplexRecord { + if (value == null) throw new IllegalArgumentException("value required"); + } + + // Record method with generics + public Optional transform(Function mapper) { + return Optional.ofNullable(value).map(mapper); + } +} diff --git a/parser/src/test-data/java/integration/test-multiple-types.java b/parser/src/test-data/java/integration/test-multiple-types.java new file mode 100644 index 000000000..fb792da77 --- /dev/null +++ b/parser/src/test-data/java/integration/test-multiple-types.java @@ -0,0 +1,32 @@ +package com.example.service; + +import java.io.Serializable; + +@interface Service { } + +interface ServiceFacade extends Serializable { + void execute(); +} + +interface AnotherFacade { + void process(); +} + +@Service +class ServiceImpl implements ServiceFacade { + public void execute() { } +} + +@Service +class AnotherService implements AnotherFacade { + public void process() { } +} + +@Service +class GenericService implements ServiceFacade { + public void execute() { } +} + +class HelperClass { } + +interface HelperInterface { } diff --git a/parser/src/test-data/java/local-variables/CrossFileLocalVariables.java b/parser/src/test-data/java/local-variables/CrossFileLocalVariables.java new file mode 100644 index 000000000..61ca7528b --- /dev/null +++ b/parser/src/test-data/java/local-variables/CrossFileLocalVariables.java @@ -0,0 +1,392 @@ +package com.inventory.auth.examples23; + +import java.util.*; +import java.util.function.*; +import java.util.stream.*; + +/** + * Test cases for local variables that reference types defined in other files. + * This tests cross-file type resolution for local variables. + */ +public class CrossFileLocalVariables { + + // ======================================================================== + // SECTION 1: USING HELPER TYPES FROM HelperTypes.java + // ======================================================================== + public static final HelperTypes.Result stringResult = HelperTypes.Result.success("hello"); + + public void usingResultType() { + // Generic Result type from HelperTypes + HelperTypes.Result stringResult = HelperTypes.Result.success("hello"); + HelperTypes.Result intResult = HelperTypes.Result.success(42); + HelperTypes.Result failureResult = HelperTypes.Result.failure("error"); + + // Extracting values + String value = stringResult.getValue(); + boolean isSuccess = stringResult.isSuccess(); + String message = stringResult.getMessage(); + + // Chained operations + HelperTypes.Result mappedResult = stringResult.map(s -> s.length()); + Integer mappedValue = mappedResult.getValue(); + + // Nested generic + HelperTypes.Result> listResult = HelperTypes.Result.success(List.of("a", "b")); + List listValue = listResult.getValue(); + } + + public void usingBuilderPattern() { + // Builder pattern from HelperTypes + HelperTypes.RequestBuilder builder = new HelperTypes.RequestBuilder(); + + // Fluent API with local variable reassignment + HelperTypes.RequestBuilder configuredBuilder = builder + .url("https://api.example.com") + .method("POST") + .header("Content-Type", "application/json") + .body("{\"key\": \"value\"}"); + + // Build the request + HelperTypes.Request request = configuredBuilder.build(); + + // Extract request properties + String url = request.getUrl(); + String method = request.getMethod(); + Map headers = request.getHeaders(); + String body = request.getBody(); + } + + // ======================================================================== + // SECTION 2: USING RECORDS FROM HelperTypes.java + // ======================================================================== + + public void usingRecords() { + // Simple records + Coordinate coord1 = new Coordinate(40.7128, -74.0060); + Coordinate coord2 = new Coordinate(34.0522, -118.2437); + + // Record method call result + double distance = coord1.distanceTo(coord2); + + // Nested records + Address address = new Address("123 Main St", "New York", "10001"); + Customer customer = new Customer("C001", "Alice", address); + + // Accessing nested record fields + String street = customer.address().street(); + String city = customer.address().city(); + String fullAddr = customer.fullAddress(); + + // Complex records with collections + OrderItem item1 = new OrderItem("P001", "Widget", 9.99, 2); + OrderItem item2 = new OrderItem("P002", "Gadget", 19.99, 1); + List items = List.of(item1, item2); + + Order order = new Order("O001", customer, items); + double total = order.total(); + + // Generic records + Wrapper stringWrapper = new Wrapper<>("content", System.currentTimeMillis()); + String content = stringWrapper.content(); + long timestamp = stringWrapper.timestamp(); + + Pair pair = new Pair<>("key", 100); + String first = pair.first(); + Integer second = pair.second(); + Pair swapped = pair.swap(); + + Triple triple = new Triple<>("a", 1, true); + } + + // ======================================================================== + // SECTION 3: PATTERN MATCHING WITH EXTERNAL RECORDS + // ======================================================================== + + public void patternMatchingWithRecords() { + Object obj = new Coordinate(1.0, 2.0); + + // instanceof pattern with external record + if (obj instanceof Coordinate coord) { + double lat = coord.latitude(); + double lon = coord.longitude(); + } + + // Record pattern with external record + if (obj instanceof Coordinate(double lat, double lon)) { + double sum = lat + lon; + } + + // Switch with external records + Object shape = new Address("street", "city", "zip"); + String description = switch (shape) { + case Coordinate(double lat, double lon) -> "Coord: " + lat + ", " + lon; + case Address(String s, String c, String z) -> "Address: " + s + ", " + c; + case Customer(String id, String name, Address addr) -> "Customer: " + name; + default -> "Unknown"; + }; + + // Nested record patterns + Object wrapped = new Wrapper<>(new Coordinate(1.0, 2.0), 12345L); + if (wrapped instanceof Wrapper(Coordinate(double x, double y), long ts)) { + double coordSum = x + y; + long time = ts; + } + } + + // ======================================================================== + // SECTION 4: USING ENUMS FROM HelperTypes.java + // ======================================================================== + + public void usingEnums() { + // Enum values + Priority priority = Priority.HIGH; + HttpStatus status = HttpStatus.OK; + + // Enum methods + int weight = priority.getWeight(); + String desc = priority.getDescription(); + Priority escalated = priority.escalate(); + + int code = status.getCode(); + String reason = status.getReason(); + boolean isSuccess = status.isSuccess(); + boolean isError = status.isError(); + + // Switch on external enum + String priorityText = switch (priority) { + case LOW -> "Not urgent"; + case MEDIUM -> "Normal"; + case HIGH -> "Important"; + case CRITICAL -> "Emergency"; + }; + + // Switch with guards on external enum + String statusCategory = switch (status) { + case HttpStatus s when s.isSuccess() -> "Success: " + s.getReason(); + case HttpStatus s when s.isError() -> "Error: " + s.getReason(); + default -> "Unknown"; + }; + } + + // ======================================================================== + // SECTION 5: USING FUNCTIONAL INTERFACES FROM HelperTypes.java + // ======================================================================== + + public void usingFunctionalInterfaces() { + // Validator interface + Validator notEmpty = s -> s != null && !s.isEmpty() + ? ValidationResult.valid() + : ValidationResult.invalid("String is empty"); + + Validator maxLength = s -> s.length() <= 100 + ? ValidationResult.valid() + : ValidationResult.invalid("String too long"); + + // Composed validators + Validator combined = notEmpty.and(maxLength); + ValidationResult result = combined.validate("test"); + + boolean isValid = result.isValid(); + List errors = result.errors(); + + // ThrowingSupplier + ThrowingSupplier supplier = () -> { + String value = "computed"; + return value; + }; + + // ThrowingFunction + ThrowingFunction parser = s -> { + int parsed = Integer.parseInt(s); + return parsed; + }; + } + + // ======================================================================== + // SECTION 6: USING GENERIC UTILITY CLASSES FROM HelperTypes.java + // ======================================================================== + + public void usingGenericUtilities() { + // Either type + Either rightEither = Either.right(42); + Either leftEither = Either.left("error"); + + boolean isRight = rightEither.isRight(); + boolean isLeft = leftEither.isLeft(); + Integer rightValue = rightEither.getRight(); + String leftValue = leftEither.getLeft(); + + // Fold operation + String folded = rightEither.fold( + error -> "Error: " + error, + value -> "Value: " + value + ); + + // Lazy type + Lazy lazyString = new Lazy<>(() -> { + String computed = "expensive computation"; + return computed; + }); + + String lazyValue = lazyString.get(); + + // Mapped lazy + Lazy mappedLazy = lazyString.map(s -> { + int length = s.length(); + return length; + }); + Integer mappedLazyValue = mappedLazy.get(); + } + + // ======================================================================== + // SECTION 7: USING SEALED CLASSES FROM HelperTypes.java + // ======================================================================== + + public void usingSealedClasses() { + // Create shapes + Shape circle = new Circle(5.0); + Shape rectangle = new Rectangle(4.0, 3.0); + Shape triangle = new Triangle(3.0, 4.0, 5.0); + + // Method calls + double circleArea = circle.area(); + double circlePerimeter = circle.perimeter(); + + // Pattern matching on sealed type + String shapeDesc = switch (circle) { + case Circle c -> "Circle with radius " + c.getRadius(); + case Rectangle r -> "Rectangle " + r.getWidth() + "x" + r.getHeight(); + case Triangle t -> "Triangle with sides " + t.getA() + "," + t.getB() + "," + t.getC(); + }; + + // Exhaustive switch (no default needed due to sealed) + double area = switch (rectangle) { + case Circle c -> c.area(); + case Rectangle r -> r.area(); + case Triangle t -> t.area(); + }; + } + + // ======================================================================== + // SECTION 8: USING REPOSITORY PATTERN FROM HelperTypes.java + // ======================================================================== + + public void usingRepositoryPattern() { + // Create repository with lambda + Repository customerRepo = new InMemoryRepository<>(c -> c.id()); + + // Save operations + Address addr = new Address("456 Oak Ave", "Boston", "02101"); + Customer newCustomer = new Customer("C002", "Bob", addr); + Customer saved = customerRepo.save(newCustomer); + + // Find operations + Optional found = customerRepo.findById("C002"); + List all = customerRepo.findAll(); + + // Optional handling + Customer customer = found.orElse(null); + String name = found.map(c -> c.name()).orElse("Unknown"); + + // Stream operations on repository results + List customerNames = all.stream() + .map(c -> { + String customerName = c.name(); + return customerName; + }) + .collect(Collectors.toList()); + } + + // ======================================================================== + // SECTION 9: USING EXCEPTION CLASSES FROM HelperTypes.java + // ======================================================================== + + public void usingExceptionClasses() { + try { + // Simulate some operation + boolean valid = false; + if (!valid) { + List errors = List.of("Field required", "Invalid format"); + ValidationException ve = new ValidationException("Validation failed", errors); + throw ve; + } + } catch (ValidationException e) { + String message = e.getMessage(); + String errorCode = e.getErrorCode(); + List validationErrors = e.getValidationErrors(); + + // Process errors + for (String error : validationErrors) { + String logged = "Error: " + error; + } + } catch (BusinessException e) { + String code = e.getErrorCode(); + } + + try { + String resourceId = "R001"; + NotFoundException nfe = new NotFoundException("Customer", resourceId); + throw nfe; + } catch (NotFoundException e) { + String resourceType = e.getResourceType(); + String id = e.getResourceId(); + } catch (BusinessException e) { + // Generic handling + } + } + + // ======================================================================== + // SECTION 10: COMPLEX SCENARIOS COMBINING MULTIPLE EXTERNAL TYPES + // ======================================================================== + + public void complexScenarios() { + // Nested generics with external types + HelperTypes.Result> complexResult = + HelperTypes.Result.success(Either.right(new Customer("C003", "Charlie", + new Address("789 Pine Rd", "Chicago", "60601")))); + + Either eitherValue = complexResult.getValue(); + + // Stream with external types + List orders = List.of( + new Order("O001", new Customer("C001", "Alice", + new Address("1", "A", "1")), List.of()), + new Order("O002", new Customer("C002", "Bob", + new Address("2", "B", "2")), List.of()) + ); + + Map orderCustomers = orders.stream() + .collect(Collectors.toMap( + o -> { + String orderId = o.orderId(); + return orderId; + }, + o -> { + Customer c = o.customer(); + return c; + } + )); + + // Lazy computation with external types + Lazy> lazyResult = new Lazy<>(() -> { + String computed = "lazy value"; + HelperTypes.Result result = HelperTypes.Result.success(computed); + return result; + }); + + // Validator chain with external types + Validator hasName = c -> c.name() != null && !c.name().isEmpty() + ? ValidationResult.valid() + : ValidationResult.invalid("Customer must have name"); + + Validator hasAddress = c -> c.address() != null + ? ValidationResult.valid() + : ValidationResult.invalid("Customer must have address"); + + Validator customerValidator = hasName.and(hasAddress); + + Customer testCustomer = new Customer("C004", "Diana", + new Address("100 Elm St", "Denver", "80201")); + ValidationResult validation = customerValidator.validate(testCustomer); + } +} diff --git a/parser/src/test-data/java/local-variables/HelperTypes.java b/parser/src/test-data/java/local-variables/HelperTypes.java new file mode 100644 index 000000000..aeb9a8ae1 --- /dev/null +++ b/parser/src/test-data/java/local-variables/HelperTypes.java @@ -0,0 +1,462 @@ +package com.inventory.auth.examples23; + +import java.io.Serializable; +import java.util.*; +import java.util.function.*; + +/** + * Helper types for testing cross-file type references in local variable extraction. + * These types are defined in a separate file to verify that type resolution works + * correctly across file boundaries. + */ + +// ============================================================================ +// BASIC HELPER CLASSES +// ============================================================================ + +/** + * Simple data class for testing object creation and field access. + */ +public class HelperTypes { + + // Inner class for testing nested type references + public static class Result { + private final T value; + private final boolean success; + private final String message; + + public Result(T value, boolean success, String message) { + this.value = value; + this.success = success; + this.message = message; + } + + public static Result success(T value) { + return new Result<>(value, true, "Success"); + } + + public static Result failure(String message) { + return new Result<>(null, false, message); + } + + public T getValue() { return value; } + public boolean isSuccess() { return success; } + public String getMessage() { return message; } + + public Result map(Function mapper) { + if (success) { + R mapped = mapper.apply(value); + return Result.success(mapped); + } + return Result.failure(message); + } + } + + // Builder pattern class + public static class RequestBuilder { + private String url; + private String method = "GET"; + private Map headers = new HashMap<>(); + private String body; + + public RequestBuilder url(String url) { + this.url = url; + return this; + } + + public RequestBuilder method(String method) { + this.method = method; + return this; + } + + public RequestBuilder header(String key, String value) { + this.headers.put(key, value); + return this; + } + + public RequestBuilder body(String body) { + this.body = body; + return this; + } + + public Request build() { + return new Request(url, method, headers, body); + } + } + + // Immutable request class + public static class Request { + private final String url; + private final String method; + private final Map headers; + private final String body; + + Request(String url, String method, Map headers, String body) { + this.url = url; + this.method = method; + this.headers = Collections.unmodifiableMap(new HashMap<>(headers)); + this.body = body; + } + + public String getUrl() { return url; } + public String getMethod() { return method; } + public Map getHeaders() { return headers; } + public String getBody() { return body; } + } +} + +// ============================================================================ +// RECORDS FOR PATTERN MATCHING TESTS +// ============================================================================ + +record Coordinate(double latitude, double longitude) { + public double distanceTo(Coordinate other) { + double dx = latitude - other.latitude; + double dy = longitude - other.longitude; + return Math.sqrt(dx * dx + dy * dy); + } +} + +record Address(String street, String city, String zipCode) {} + +record Customer(String id, String name, Address address) { + public String fullAddress() { + return address.street() + ", " + address.city() + " " + address.zipCode(); + } +} + +record Order(String orderId, Customer customer, List items) { + public double total() { + return items.stream() + .mapToDouble(item -> item.price() * item.quantity()) + .sum(); + } +} + +record OrderItem(String productId, String name, double price, int quantity) {} + +// Nested records for complex pattern matching +record Wrapper(T content, long timestamp) {} + +record Pair(A first, B second) { + public Pair swap() { + return new Pair<>(second, first); + } +} + +record Triple(A first, B second, C third) {} + +// ============================================================================ +// ENUMS FOR SWITCH EXPRESSION TESTS +// ============================================================================ + +enum Priority { + LOW(1, "Low Priority"), + MEDIUM(5, "Medium Priority"), + HIGH(10, "High Priority"), + CRITICAL(100, "Critical Priority"); + + private final int weight; + private final String description; + + Priority(int weight, String description) { + this.weight = weight; + this.description = description; + } + + public int getWeight() { return weight; } + public String getDescription() { return description; } + + public Priority escalate() { + return switch (this) { + case LOW -> MEDIUM; + case MEDIUM -> HIGH; + case HIGH, CRITICAL -> CRITICAL; + }; + } +} + +enum HttpStatus { + OK(200, "OK"), + CREATED(201, "Created"), + BAD_REQUEST(400, "Bad Request"), + UNAUTHORIZED(401, "Unauthorized"), + NOT_FOUND(404, "Not Found"), + INTERNAL_ERROR(500, "Internal Server Error"); + + private final int code; + private final String reason; + + HttpStatus(int code, String reason) { + this.code = code; + this.reason = reason; + } + + public int getCode() { return code; } + public String getReason() { return reason; } + + public boolean isSuccess() { + return code >= 200 && code < 300; + } + + public boolean isError() { + return code >= 400; + } +} + +// ============================================================================ +// INTERFACES FOR FUNCTIONAL PROGRAMMING TESTS +// ============================================================================ + +@FunctionalInterface +interface Validator { + ValidationResult validate(T input); + + default Validator and(Validator other) { + return input -> { + ValidationResult result = validate(input); + return result.isValid() ? other.validate(input) : result; + }; + } + + default Validator or(Validator other) { + return input -> { + ValidationResult result = validate(input); + return result.isValid() ? result : other.validate(input); + }; + } +} + +record ValidationResult(boolean isValid, List errors) { + public static ValidationResult valid() { + return new ValidationResult(true, List.of()); + } + + public static ValidationResult invalid(String... errors) { + return new ValidationResult(false, List.of(errors)); + } +} + +@FunctionalInterface +interface ThrowingSupplier { + T get() throws E; +} + +@FunctionalInterface +interface ThrowingFunction { + R apply(T input) throws E; +} + +// ============================================================================ +// GENERIC UTILITY CLASSES +// ============================================================================ + +class Either { + private final L left; + private final R right; + private final boolean isRight; + + private Either(L left, R right, boolean isRight) { + this.left = left; + this.right = right; + this.isRight = isRight; + } + + public static Either left(L value) { + return new Either<>(value, null, false); + } + + public static Either right(R value) { + return new Either<>(null, value, true); + } + + public boolean isLeft() { return !isRight; } + public boolean isRight() { return isRight; } + public L getLeft() { return left; } + public R getRight() { return right; } + + public T fold(Function leftMapper, Function rightMapper) { + return isRight ? rightMapper.apply(right) : leftMapper.apply(left); + } +} + +class Lazy { + private final Supplier supplier; + private T value; + private boolean computed = false; + + public Lazy(Supplier supplier) { + this.supplier = supplier; + } + + public synchronized T get() { + if (!computed) { + value = supplier.get(); + computed = true; + } + return value; + } + + public Lazy map(Function mapper) { + return new Lazy<>(() -> mapper.apply(get())); + } +} + +// ============================================================================ +// SEALED CLASSES FOR PATTERN MATCHING +// ============================================================================ + +sealed interface Shape permits Circle, Rectangle, Triangle { + double area(); + double perimeter(); +} + +final class Circle implements Shape { + private final double radius; + + public Circle(double radius) { + this.radius = radius; + } + + public double getRadius() { return radius; } + + @Override + public double area() { + return Math.PI * radius * radius; + } + + @Override + public double perimeter() { + return 2 * Math.PI * radius; + } +} + +final class Rectangle implements Shape { + private final double width; + private final double height; + + public Rectangle(double width, double height) { + this.width = width; + this.height = height; + } + + public double getWidth() { return width; } + public double getHeight() { return height; } + + @Override + public double area() { + return width * height; + } + + @Override + public double perimeter() { + return 2 * (width + height); + } +} + +final class Triangle implements Shape { + private final double a, b, c; + + public Triangle(double a, double b, double c) { + this.a = a; + this.b = b; + this.c = c; + } + + public double getA() { return a; } + public double getB() { return b; } + public double getC() { return c; } + + @Override + public double area() { + double s = (a + b + c) / 2; + return Math.sqrt(s * (s - a) * (s - b) * (s - c)); + } + + @Override + public double perimeter() { + return a + b + c; + } +} + +// ============================================================================ +// SERVICE CLASSES FOR DEPENDENCY INJECTION TESTS +// ============================================================================ + +interface Repository { + Optional findById(ID id); + List findAll(); + T save(T entity); + void delete(ID id); +} + +class InMemoryRepository implements Repository { + private final Map storage = new HashMap<>(); + private final Function idExtractor; + + public InMemoryRepository(Function idExtractor) { + this.idExtractor = idExtractor; + } + + @Override + public Optional findById(ID id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(storage.values()); + } + + @Override + public T save(T entity) { + ID id = idExtractor.apply(entity); + storage.put(id, entity); + return entity; + } + + @Override + public void delete(ID id) { + storage.remove(id); + } +} + +// ============================================================================ +// EXCEPTION CLASSES FOR TRY-CATCH TESTS +// ============================================================================ + +class BusinessException extends Exception { + private final String errorCode; + + public BusinessException(String message, String errorCode) { + super(message); + this.errorCode = errorCode; + } + + public String getErrorCode() { return errorCode; } +} + +class ValidationException extends BusinessException { + private final List validationErrors; + + public ValidationException(String message, List errors) { + super(message, "VALIDATION_ERROR"); + this.validationErrors = errors; + } + + public List getValidationErrors() { return validationErrors; } +} + +class NotFoundException extends BusinessException { + private final String resourceType; + private final String resourceId; + + public NotFoundException(String resourceType, String resourceId) { + super(resourceType + " not found: " + resourceId, "NOT_FOUND"); + this.resourceType = resourceType; + this.resourceId = resourceId; + } + + public String getResourceType() { return resourceType; } + public String getResourceId() { return resourceId; } +} diff --git a/parser/src/test-data/java/local-variables/LocalVariableExamples.java b/parser/src/test-data/java/local-variables/LocalVariableExamples.java new file mode 100644 index 000000000..38c74a985 --- /dev/null +++ b/parser/src/test-data/java/local-variables/LocalVariableExamples.java @@ -0,0 +1,1260 @@ +package com.inventory.auth.examples23; + +import java.io.*; +import java.util.*; +import java.util.Collections; +import java.util.function.*; +import java.util.stream.*; + +import org.checkerframework.checker.units.qual.h; + +/** + * Comprehensive test cases for local variable extraction. + * Covers all contexts where local variables can be declared and all assignment types. + */ +public class LocalVariableExamples { + + private final String someString = "hello"; + private final Function blockLambdaField = s -> { + String upper = s.toUpperCase(); + String trimmed = upper.trim(); + return new Object().toString(); + }; + + private final int statusCodeFinalCheck = switch (status) { + case ACTIVE: 1; + case INACTIVE: 0; + case PENDING: { + int code = -1; + yield code; + } + }; + + + private static final String instanceofResult = switch (mixedObj) { + case String s -> { + String upper = s.toUpperCase(); + int len = s.length(); + yield len + upper.length(); + } + case Integer i when i > 0 -> { + int doubled = i * 2; + yield "Positive integer: " + doubled; + } + case Integer i -> { + int negated = -i; + yield negated; + } + case List list -> { + int size = list.size(); + boolean empty = list.isEmpty(); + yield "List with " + size + " elements"; + } + case Map map -> { + int mapSize = map.size(); + boolean hasKeys = !map.isEmpty(); + yield "Map with " + mapSize + " entries"; + } + case Point p -> { + int px = p.x(); + int py = p.y(); + yield "Point at " + px + "," + py; + } + case Person person when person.age() > 18 -> { + String adultName = person.name(); + yield "Adult: " + adultName; + } + case Person person -> { + String minorName = person.name(); + int minorAge = person.age(); + yield "Minor: " + minorName + " age " + minorAge; + } + case T something -> { + yield "Something: " + something; + } + case int[] arr -> { + int arrLen = arr.length; + yield "Int array of length " + arrLen; + } + case List[] ls -> { + int lsLen = ls.length; + yield "List of arrays with length " + lsLen; + } + case null -> "null value"; + default -> { + String defaultType = mixedObj.getClass().getSimpleName(); + yield defaultType; + } + }; + // ======================================================================== + // CUSTOM TYPES FOR TESTING TYPE RESOLUTION + // ======================================================================== + + public record Point(int x, int y) {} + public record Person(String name, int age) {} + public record Box(T value) {} + + public static class CustomService { + public CustomService(String input) {} + public String process(String input) { return input.toUpperCase(); } + public static CustomService getInstance() { return new CustomService("Hello Testing"); } + } + + public interface Processor { + R process(T input); + } + + public enum Status { ACTIVE, INACTIVE, PENDING } + + // ======================================================================== + // SECTION 1: METHOD BODY - BASIC DECLARATIONS + // ======================================================================== + + public void basicDeclarations() { + // Primitive literals + int intVar = 42; + long longVar = 100L; + double doubleVar = 3.14; + float floatVar = 2.5f; + boolean boolVar = true; + char charVar = 'A'; + byte byteVar = 127; + short shortVar = 1000; + + // String and null + String stringVar = "hello"; + String nullVar = null; + + // Uninitialized (declaration only) + int uninitializedInt; + String uninitializedString; + + // Multiple declarations in one statement + int a = 1, b = 2, c = 3; + String s1 = "one", s2 = "two", s3; + uninitializedInt = 20; + // Final local variables + final int finalInt = 100; + final String finalString = "constant"; + + // Var inference (Java 10+) + var inferredInt = 42; + var inferredString = "inferred"; + var inferredList = new ArrayList(); + } + + public int someReturnNumber(int a, int... b) { + int x = 10; + return a + b.length + x; + } + + // ======================================================================== + // SECTION 2: METHOD BODY - OBJECT CREATION + // ======================================================================== + + public void objectCreationAssignments() { + // Simple object creation + Object obj = new Object(); + String str = new String("test"); + StringBuilder sb = new StringBuilder(); + + // Generic types + List list = new ArrayList<>(); + Map map = new HashMap<>(); + Set pointSet = new HashSet<>(); + + // Custom types + Point point = new Point(1, 2); + Person person = new Person("Alice", 30); + Box box = new Box<>("content"); + CustomService service = new CustomService(); + + // Diamond operator with complex generics + Map> complexMap = new HashMap<>(); + List> entryList = new ArrayList(); + + // Anonymous class creation + Runnable runnable = new Runnable() { + public static final int temp = 20; + @Override + public void run() { + int innerVar = 10; + } + + public int someNumber() { + int someNumber = 30; + return someNumber + temp; + } + }; + + Comparator comparator = new Comparator() { + @Override + public int compare(String o1, String o2) { + int result = o1.compareTo(o2); + return result; + } + }; + } + + // ======================================================================== + // SECTION 3: METHOD BODY - ARRAY CREATION + // ======================================================================== + + public void arrayCreationAssignments() { + // Array with size + int[] intArray = new int[10]; + String[] stringArray = new String[5]; + + // Array with initializer + int[] initializedArray = {1, 2, 3, 4, 5}; + String[] names = {"Alice", "Bob", "Charlie"}; + + // Multi-dimensional arrays + int[][] matrix = new int[3][3]; + int[][] initializedMatrix = {{1, 2}, {3, 4}}; + String[][][] cube = new String[2][2][2]; + + // Array of custom types + Point[] points = new Point[3]; + Point[] initializedPoints = {new Point(0, 0), new Point(1, 1)}; + + // Generic array (with cast) + @SuppressWarnings("unchecked") + List[] listArray = (List[]) new List[5]; + + // Complex annotation cases - multiple annotations + @SuppressWarnings({"unchecked", "rawtypes"}) + Map rawMap = new HashMap(); + + @SuppressWarnings({"unchecked", "rawtypes", "deprecation"}) + List rawList = new ArrayList(); + + // Multiple distinct annotations on same variable + @Deprecated + @SuppressWarnings("unused") + String deprecatedUnused = "old value"; + + @SuppressWarnings("all") + @Deprecated + Object legacyObject = new Object(); + + // Annotation with array value containing single element + @SuppressWarnings({"unused"}) + int singleElementArray = 0; + + // Multiple annotations with different value types + @SuppressWarnings(value = {"unchecked", "rawtypes"}) + Set rawSet = new HashSet(); + + // Nested generic with multiple suppression + @SuppressWarnings({"unchecked", "rawtypes"}) + Map mixedGenericMap = new HashMap<>(); + + // Array creation with multiple annotations + @SuppressWarnings({"unchecked"}) + @Deprecated + List[] deprecatedListArray = (List[]) new List[10]; + } + + // ======================================================================== + // SECTION 4: METHOD BODY - METHOD INVOCATION RESULTS + // ======================================================================== + + public void methodInvocationAssignments() { + // Static method calls + int maxValue = Integer.MAX_VALUE; + String formatted = String.format("Value: %d", 42); + List emptyList = Collections.emptyList(); + CustomService instance = CustomService.getInstance(); + + // Instance method calls + String upperCase = "hello".toUpperCase(); + int length = "test".length(); + String substring = "hello world".substring(0, 5); + + // Chained method calls + String chained = " hello ".trim().toUpperCase(); + List streamResult = List.of("a", "b", "c").stream() + .filter(s -> s.length() > 0) + .collect(Collectors.toList()); + + // Generic method calls + Optional optional = Optional.of("value"); + String orElse = optional.orElse("default"); + + // Custom service method + CustomService svc = new CustomService(); + String processed = svc.process("input"); + } + + // ======================================================================== + // SECTION 5: METHOD BODY - EXPRESSIONS AND OPERATORS + // ======================================================================== + + public void expressionAssignments() { + int a = 10, b = 20; + + // Arithmetic expressions + int sum = a + b; + int difference = a - b; + int product = a * b; + int quotient = a / b; + int remainder = a % b; + + // Compound expressions + int complex = (a + b) * (a - b) / 2; + + // Unary expressions + int negated = -a; + int incremented = ++a; + boolean notTrue = !true; + int bitwiseNot = ~a; + + // Comparison results + boolean isEqual = a == b; + boolean isGreater = a > b; + boolean isLessOrEqual = a <= b; + + // Logical expressions + boolean andResult = true && false; + boolean orResult = true || false; + + // Ternary expression + int ternaryResult = a > b ? a : b; + String ternaryString = a > 0 ? "positive" : "non-positive"; + ternaryString += "temp" + b; + + // Cast expression + double d = 3.14; + int castedInt = (int) d; + Object obj = "string"; + String castedString = (String) obj; + + // Instanceof with cast + Object maybeString = "test"; + boolean isString = maybeString instanceof String; + } + + // ======================================================================== + // SECTION 6: METHOD BODY - LAMBDAS AND METHOD REFERENCES + // ======================================================================== + + public void lambdaAndMethodReferenceAssignments() { + // Simple lambdas + Runnable runnable = () -> System.out.println("hello"); + Supplier supplier = () -> "value"; + Consumer consumer = s -> System.out.println(s); + Function function = s -> s.length(); + BiFunction biFunction = (x, y) -> x + y; + String someString = "hello"; + // Lambda with block body + Function blockLambda = s -> { + String upper = s.toUpperCase(); + String trimmed = upper.trim(); + return trimmed + someString.toUpperCase(); + }; + + // Custom functional interface + Processor processor = input -> input.length(); + + // Method references - static + Function parseInt = Integer::parseInt; + Supplier> listSupplier = ArrayList::new; + + // Method references - instance + String prefix = "Hello: "; + Function concat = prefix::concat; + + // Method references - arbitrary instance + Function toUpper = String::toUpperCase; + Comparator comparator = String::compareTo; + + // Method references - constructor + Supplier sbSupplier = StringBuilder::new; + Function pointCreator = s -> new Point(s.length(), 0); + } + + // ======================================================================== + // SECTION 7: METHOD BODY - SWITCH EXPRESSIONS (Java 14+) + // ======================================================================== + + class SomeClassHelper { + private final int x; + private final int y; + public SomeClassHelper(int x, int y) { + this.x = x; + this.y = y; + } + public int () { + return x + y; + } + }; + + public void switchExpressionAssignments(T t) { + Status status = Status.ACTIVE; + Object obj = "test"; + + // Traditional switch expression with arrow + String statusText = switch (status) { + case ACTIVE -> "Active"; break; + case INACTIVE -> "Inactive"; break; + case PENDING -> "Pending"; break; + default -> "Unknown"; break; + }; + + // Switch expression with yield + int statusCode = switch (status) { + case ACTIVE: yield 1; + case INACTIVE: yield 0; + case PENDING: { + int code = -1; + yield code; + } + default: yield -2; + }; + + // Switch with type patterns (Java 21+) + String typeResult = switch (obj) { + case String s -> { + var o = new Object() { + int x = 1; + }; + SomeClassHelper helper = new SomeClassHelper(1, 2); + + yield "String: " + s + " " + o.x + " " + helper.getSum(); + } + case Integer i -> "Integer: " + i; + case Point p -> "Point: " + p.x() + "," + p.y(); + case null -> "null"; + default -> "unknown"; + }; + + // Switch with record patterns (Java 21+) + Object shape = new Point(3, 4); + String recordResult = switch (shape) { + case Point(int x, int y) -> "Point(" + x + "," + y + ")"; + case Person(String name, int age) -> "Person: " + name; + default -> "other"; + }; + + // Switch with guards + String guardedResult = switch (shape) { + case Point(int x, int y) when x == 0 && y == 0 -> "origin"; + case Point(int x, int y) when x == y -> "diagonal"; + case Point(int x, int y) -> "point"; + default -> "other"; + }; + + // Switch with instanceof type patterns (testing various types) + Object mixedObj = getRandomObject(); + } + + private Object getRandomObject() { + return "test"; + } + + // ======================================================================== + // SECTION 8: METHOD BODY - PATTERN MATCHING + // ======================================================================== + + public void patternMatchingAssignments() { + Object obj = "hello"; + Object point = new Point(1, 2); + + // instanceof pattern (Java 16+) + if (obj instanceof String s) { + String upperS = s.toUpperCase(); + int len = s.length(); + } + + // instanceof with record pattern (Java 21+) + if (point instanceof Point(int x, int y)) { + int sum = x + y; + int product = x * y; + } + + // Nested record pattern + Object nested = new Box<>(new Point(1, 2)); + if (nested instanceof Box(Point(int x, int y))) { + int total = x + y; + } + } + + // ======================================================================== + // SECTION 9: FOR LOOPS + // ======================================================================== + + public void forLoopVariables() { + // Traditional for loop + for (int i = 0; i < 10; i++) { + int squared = i * i; + } + + // Multiple loop variables + for (int i = 0, j = 10; i < j; i++, j--) { + int sum = i + j; + } + + // Enhanced for loop (for-each) + List items = List.of("a", "b", "c"); + for (String item : items) { + String upper = item.toUpperCase(); + } + + // For-each with array + int[] numbers = {1, 2, 3, 4, 5}; + for (int num : numbers) { + int doubled = num * 2; + } + + // For-each with custom type + List points = List.of(new Point(0, 0), new Point(1, 1)); + for (Point p : points) { + int sum = p.x() + p.y(); + } + + // For-each with var + for (var entry : Map.of("a", 1, "b", 2).entrySet()) { + String key = entry.getKey(); + Integer value = entry.getValue(); + } + } + + // ======================================================================== + // SECTION 10: WHILE AND DO-WHILE LOOPS + // ======================================================================== + + public void whileLoopVariables() { + int count = 0; + + // While loop with internal variable + while (count < 10) { + int current = count; + int next = count + 1; + count++; + } + + // Do-while with internal variable + do { + int value = count * 2; + count--; + } while (count > 0); + } + + // ======================================================================== + // SECTION 11: TRY-CATCH-FINALLY AND TRY-WITH-RESOURCES + // ======================================================================== + + public void tryBlockVariables() throws Exception { + // Basic try-catch + try { + int result = Integer.parseInt("123"); + String processed = String.valueOf(result); + } catch (NumberFormatException e) { + String message = e.getMessage(); + int errorCode = -1; + } + + // Multiple catch blocks + try { + Object obj = null; + String str = obj.toString(); + } catch (NullPointerException e) { + String npeMessage = e.getMessage(); + } catch (Exception e) { + String exMessage = e.getMessage(); + } + + // Try-finally + try { + int value = 42; + } finally { + int cleanup = 0; + } + + // Try-with-resources (Java 7+) + try (BufferedReader reader = new BufferedReader(new StringReader("test"))) { + String line = reader.readLine(); + int length = line != null ? line.length() : 0; + } + + // Multiple resources + try (StringReader sr = new StringReader("data"); + BufferedReader br = new BufferedReader(sr)) { + String content = br.readLine(); + } + + // Var in try-with-resources (Java 11+) + try (var reader = new BufferedReader(new StringReader("test"))) { + var line = reader.readLine(); + } + } + + // ======================================================================== + // SECTION 12: SCOPED BLOCKS + // ======================================================================== + + public void scopedBlockVariables() { + int outerVar = 1; + + // Simple block + { + int blockVar = 2; + int sum = outerVar + blockVar; + } + + // Nested blocks + { + int level1 = 10; + { + int level2 = 20; + { + int level3 = 30; + int total = level1 + level2 + level3; + } + } + } + + // If-else blocks + boolean condition = true; + if (condition) { + int trueVar = 1; + String trueStr = "true"; + } else { + int falseVar = 0; + String falseStr = "false"; + } + + // If-else-if chain + int value = 5; + if (value < 0) { + String negative = "negative"; + } else if (value == 0) { + String zero = "zero"; + } else { + String positive = "positive"; + } + } + + // ======================================================================== + // SECTION 13: CONSTRUCTOR BODY + // ======================================================================== + + private String instanceField; + private final Local local; + + class Temp { + public final int n; + public Temp(int n) { + this.n = n; + } + + public int getN() { + return n; + } + + public int hashCode() { + return n; + } + } + + class Local { + public final Temp temp; + public final String name; + public Local(final Temp x, final String name) { + this.temp = x; + this.name = name; + } + } + + public LocalVariableExamples() { + // Local variables in constructor + int initValue = 42; + String computed = "prefix_" + initValue; + local = new Local(new Temp(initValue + 20), "test1"); + this.instanceField = computed + local.name + " " + local.temp.getN() + " " + local.temp.hashCode(); + + // Object creation in constructor + StringBuilder sb = new StringBuilder(); + sb.append(new Local(new Temp(initValue), "test")); + System.out.print(sb.capacity()); + + // Custom type in constructor + Point origin = new Point(0, 0); + } + + public LocalVariableExamples(String param) { + // Constructor with parameter using local var + String processed = param.toUpperCase(); + int length = processed.length(); + this.instanceField = processed; + } + + // ======================================================================== + // SECTION 14: STATIC INITIALIZER BLOCK + // ======================================================================== + + private static String staticField; + private static List staticList; + + static { + // Local variables in static block + int staticInit = 100; + String computed = "static_" + staticInit; + staticField = computed; + + // Object creation in static block + List tempList = new ArrayList<>(); + tempList.add("one"); + tempList.add("two"); + staticList = Collections.unmodifiableList(tempList); + + // Loop in static block + for (int i = 0; i < 5; i++) { + String item = "item_" + i; + } + } + + // ======================================================================== + // SECTION 15: INSTANCE INITIALIZER BLOCK + // ======================================================================== + + private List instanceList; + + { + // Local variables in instance initializer + int instanceInit = 200; + String prefix = "instance"; + + // Object creation in instance initializer + List tempList = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + int value = i * instanceInit; + tempList.add(value); + } + instanceList = tempList; + } + + // ======================================================================== + // SECTION 16: LAMBDA BODIES (Nested local variables) + // ======================================================================== + + public void lambdaBodyVariables() { + // Lambda with block body containing local variables + Function processor = s -> { + String trimmed = s.trim(); + String upper = trimmed.toUpperCase(); + int length = upper.length(); + return length; + }; + + // Lambda capturing outer variables (effectively final) + int multiplier = 2; + Function multiply = x -> { + int result = x * multiplier; + return result; + }; + + // Nested lambdas + Function> adder = x -> { + int captured = x; + return y -> { + int sum = captured + y; + return sum; + }; + }; + + // Lambda in stream + List items = List.of("apple", "banana", "cherry"); + List lengths = items.stream() + .map(s -> { + String processed = s.toLowerCase(); + int len = processed.length(); + return len; + }) + .collect(Collectors.toList()); + } + + // ======================================================================== + // SECTION 17: ANONYMOUS CLASS BODIES + // ======================================================================== + + public void anonymousClassVariables() { + // Anonymous class with local variables in method + Runnable r = new Runnable() { + @Override + public void run() { + int localInAnon = 10; + String message = "Running: " + localInAnon; + System.out.println(message); + } + }; + + // Anonymous class with multiple methods + Comparator pointComparator = new Comparator() { + @Override + public int compare(Point p1, Point p2) { + int dist1 = p1.x() * p1.x() + p1.y() * p1.y(); + int dist2 = p2.x() * p2.x() + p2.y() * p2.y(); + int result = Integer.compare(dist1, dist2); + return result; + } + + @Override + public boolean equals(Object obj) { + boolean isEqual = obj instanceof Comparator; + return isEqual; + } + }; + + // Anonymous class with instance initializer + Object withInit = new Object() { + private int field; + { + int initVar = 42; + field = initVar; + } + }; + } + + // ======================================================================== + // SECTION 18: SYNCHRONIZED BLOCKS + // ======================================================================== + + private final Object lock = new Object(); + + public void synchronizedBlockVariables() { + synchronized (lock) { + int syncVar = 100; + String syncStr = "synchronized: " + syncVar; + + // Nested operations + List list = new ArrayList<>(); + list.add(syncStr); + } + + // Synchronized on this + synchronized (this) { + int thisVar = 200; + } + } + + // ======================================================================== + // SECTION 19: COMPLEX/EDGE CASES + // ======================================================================== + + public void complexCases() { + // Variable shadowing (different scopes, same name) + int x = 1; + { + int x2 = 2; // Can't shadow, using different name + int y = x + x2; + { + int legendaryHello = x2 + y + x + 30; + } + } + + // Assignment in condition (not recommended but valid) + String s; + if ((s = getString()) != null) { + int len = s.length(); + } + + // Chained assignment + int a, b, c; + a = b = c = 10; + + // Compound assignment operators + int n = 10; + n += 5; + n -= 2; + n *= 3; + n /= 2; + n %= 4; + + // Array element assignment (not variable declaration, but related) + int[] arr = new int[3]; + arr[0] = 1; + + // Generic method return + List list = createList(); + Optional optPoint = Optional.of(new Point(1, 2)); + Point extracted = optPoint.orElse(new Point(0, 0)); + } + + private String getString() { return "test"; } + private List createList() { return new ArrayList<>(); } + + // ======================================================================== + // SECTION 20: RECORD CLASSES (Local variables in record methods) + // ======================================================================== + + public record Rectangle(int width, int height) { + public int area() { + int result = width * height; + return result; + } + + public Rectangle scaled(int factor) { + int newWidth = width * factor; + int newHeight = height * factor; + return new Rectangle(newWidth, newHeight); + } + } + + // ======================================================================== + // SECTION 21: ENUM WITH METHODS + // ======================================================================== + + public enum Operation { + ADD { + @Override + public int apply(int a, int b) { + int result = a + b; + return result; + } + }, + SUBTRACT { + @Override + public int apply(int a, int b) { + int result = a - b; + return result; + } + }; + + public abstract int apply(int a, int b); + + public String describe(int a, int b) { + int result = apply(a, b); + String desc = String.format("%d op %d = %d", a, b, result); + return desc; + } + } + + // ======================================================================== + // SECTION 22: SEALED CLASSES AND PERMITS + // ======================================================================== + + public sealed interface Shape permits Circle, Square { + default double area() { + double result = 0.0; + return result; + } + } + + public final class Circle implements Shape { + private final double radius; + + public Circle(double r) { + double validated = r > 0 ? r : 0; + this.radius = validated; + } + + @Override + public double area() { + double squared = radius * radius; + double result = Math.PI * squared; + return result; + } + } + + public final class Square implements Shape { + private final double side; + + public Square(double s) { + this.side = s; + } + + @Override + public double area() { + double result = side * side; + return result; + } + } + + // ======================================================================== + // SECTION 23: RECORD VARIABLES AND INSTANTIATION + // ======================================================================== + + public record Circle2(double radius) {} + + public record NestedRecord(T data, String label) {} + + public void recordVariables() { + // Simple record instantiation stored in variable + Rectangle rect = new Rectangle(10, 20); + Circle2 circle = new Circle2(5.0); + + // Record with var inference + var inferredRect = new Rectangle(15, 25); + var inferredCircle = new Circle2(7.5); + + // Generic record instantiation + NestedRecord stringRecord = (Object) new NestedRecord<>("data", "label"); + NestedRecord intRecord = new NestedRecord<>(42, "number"); + NestedRecord> complexRecord = new NestedRecord<>(List.of("a", "b"), "list"); + + // Record with var and generics + var inferredGenericRecord = new NestedRecord<>("inferred", "type"); + + // Accessing record components + int width = rect.width(); + int height = rect.height(); + int area = rect.area(); + + // Record in collection + List rectangles = new ArrayList<>(); + Rectangle first = rectangles.isEmpty() ? new Rectangle(0, 0) : rectangles.get(0); + + // Record pattern in local context + Object obj = new Rectangle(100, 200); + if (obj instanceof Rectangle(int w, int h)) { + int localArea = w * h; + String desc = "Area: " + localArea; + } + } + + // ======================================================================== + // SECTION 24: SUPPLIER AND FUNCTIONAL INTERFACE LAMBDAS + // ======================================================================== + + public void supplierLambdas() { + // Basic Supplier with lambda + Supplier stringSupplier = () -> { + String computed = "computed value"; + return computed; + }; + + // Supplier with block body and multiple statements + Supplier intSupplier = () -> { + int base = 10; + int multiplier = 5; + int result = base * multiplier; + return result; + }; + + // Supplier with var + var inferredSupplier = (Supplier) () -> { + double pi = Math.PI; + double squared = pi * pi; + return squared; + }; + + // Supplier returning custom type + Supplier rectSupplier = () -> { + int w = 100; + int h = 200; + Rectangle rect = new Rectangle(w, h); + return rect; + }; + + // Supplier returning collection + Supplier> listSupplier = () -> { + List items = new ArrayList<>(); + String item1 = "first"; + String item2 = "second"; + items.add(item1); + items.add(item2); + return items; + }; + + // Consumer with local variables + Consumer consumer = (String input) -> { + String processed = input.toUpperCase(); + int length = processed.length(); + String result = processed + " (" + length + ")"; + }; + + // Function with local variables + Function function = (String s) -> { + String trimmed = s.trim(); + int length = trimmed.length(); + return length; + }; + + // BiFunction with local variables + BiFunction biFunction = (Integer a, Integer b) -> { + int sum = a + b; + int product = a * b; + String result = "Sum: " + sum + ", Product: " + product; + return result; + }; + + // Predicate with local variables + Predicate predicate = (String s) -> { + String lower = s.toLowerCase(); + boolean startsWithA = lower.startsWith("a"); + boolean endsWithZ = lower.endsWith("z"); + boolean result = startsWithA && endsWithZ; + return result; + }; + + // Using the suppliers + String suppliedString = stringSupplier.get(); + Integer suppliedInt = intSupplier.get(); + Rectangle suppliedRect = rectSupplier.get(); + } + + // ======================================================================== + // SECTION 25: NESTED LAMBDAS WITH LOCAL VARIABLES AT EACH LAYER + // ======================================================================== + + public void someFunctions() { + (x) -> { + int y = 10; + return 100 + y; + } + } + native void nestedLambdasWithLocalVariables(int a,int b); + public void nestedLambdasWithLocalVariables() { + // Level 0: Method scope local variable + String outerMethodVar = "method level"; + int outerCounter = 0; + + // Level 1: First lambda layer + Supplier> level1Supplier = () -> { + // Level 1 local variables + String level1Var = "level 1"; + int level1Counter = 1; + StringBuilder level1Builder = new StringBuilder(); + + // Level 2: Second lambda layer (nested inside first) + Supplier level2Supplier = () -> { + // Level 2 local variables + String level2Var = "level 2"; + int level2Counter = 2; + List level2List = new ArrayList<>(); + + // Can reference outer variables (effectively final) + String combined = outerMethodVar + " -> " + level1Var + " -> " + level2Var; + int total = outerCounter + level1Counter + level2Counter; + + return combined + " (total: " + total + ")"; + }; + + return level2Supplier; + }; + + // Three levels deep with different functional interfaces + Function>> threeLevelNested = + (String param1) -> { + // Level 1 locals + String level1Processed = param1.toUpperCase(); + int level1Length = level1Processed.length(); + + return (Integer param2) -> { + // Level 2 locals + int level2Sum = level1Length + param2; + double level2Ratio = (double) param2 / level1Length; + + return () -> { + // Level 3 locals + boolean level3Result = level2Sum > 10; + String level3Message = "Result: " + level3Result; + int level3Final = level2Sum * 2; + + return level3Result; + }; + }; + }; + + // Nested lambdas in stream operations + List> nestedLists = List.of( + List.of(1, 2, 3), + List.of(4, 5, 6) + ); + + + List flattened = nestedLists.stream() + .flatMap(innerList -> { + // Level 1 in stream lambda + int innerSize = innerList.size(); + String innerDesc = "Processing list of size " + innerSize; + + return innerList.stream() + .map(num -> { + // Level 2 in nested stream lambda + int doubled = num * 2; + int withSize = doubled + innerSize; + return withSize; + }); + }) + .collect(Collectors.toList()); + + // Lambda inside lambda inside anonymous class + Runnable complexNested = new Runnable() { + // Anonymous class field + private int anonField = 100; + + @Override + public void run() { + // Method local in anonymous class + int methodLocal = 50; + + Supplier innerSupplier = () -> { + // Lambda level 1 local + int lambdaLocal1 = 25; + + Function innerFunction = (Integer x) -> { + // Lambda level 2 local + int lambdaLocal2 = 10; + int result = x + lambdaLocal1 + lambdaLocal2 + methodLocal + anonField; + return result; + }; + + int computed = innerFunction.apply(5); + return computed; + }; + + Integer finalResult = innerSupplier.get(); + } + }; + + // Consumer chain with nested lambdas + Consumer> processList = (List items) -> { + // Outer lambda locals + int totalLength = 0; + StringBuilder aggregator = new StringBuilder(); + + items.forEach(item -> { + // Inner lambda locals (note: can't modify totalLength directly) + String processed = item.trim(); + int itemLength = processed.length(); + String formatted = "[" + processed + "]"; + + // Use effectively final aggregator + aggregator.append(formatted); + }); + + String finalAggregate = aggregator.toString(); + }; + + // Deeply nested with type inference (var) + var deepNested = (Supplier>>) () -> { + var level1Data = "L1"; + var level1Num = 1; + + return () -> { + var level2Data = level1Data + "-L2"; + var level2Num = level1Num + 1; + + return () -> { + var level3Data = level2Data + "-L3"; + var level3Num = level2Num + 1; + var finalResult = level3Data + ":" + level3Num; + return finalResult; + }; + }; + }; + + // Execute and store results + Supplier> resultLevel1 = level1Supplier.get(); + Supplier resultLevel2 = resultLevel1.get(); + String finalString = resultLevel2.get(); + + // Execute three-level + Function> partial1 = threeLevelNested.apply("test"); + Supplier partial2 = partial1.apply(5); + Boolean finalBool = partial2.get(); + } +} diff --git a/parser/src/test-data/java/method-type-parameters/GenericMethodLinking.java b/parser/src/test-data/java/method-type-parameters/GenericMethodLinking.java new file mode 100644 index 000000000..7e8b4c0eb --- /dev/null +++ b/parser/src/test-data/java/method-type-parameters/GenericMethodLinking.java @@ -0,0 +1,38 @@ +package com.axiom.test.generics; + +import java.util.List; +import java.util.Map; + +/** + * Method-type-parameter linking test (populates the previously empty + * method-type-parameters category). + * + * Every method type parameter must link (methodRegistryLinkHash) to its OWN + * method, with contiguous positions 0..n-1. `consume` is overloaded with + * different type-parameter arities (1 vs 2); each overload's type parameters + * must link only to that overload. Bounded parameters must report hasBounds. + */ +public class GenericMethodLinking { + + public T identity(T x) { + return x; + } + + public Map pair(K k, V v) { + return Map.of(k, v); + } + + public > T max(List items) { + return items.get(0); + } + + // second type parameter bounded by the first + public B narrow(A a, B b) { + return b; + } + + // overloaded generic methods: 1 vs 2 type parameters + public void consume(T t) { } + + public void consume(T t, U u) { } +} diff --git a/parser/src/test-data/java/methods/AllMethodExamples.java b/parser/src/test-data/java/methods/AllMethodExamples.java new file mode 100644 index 000000000..9f1880da1 --- /dev/null +++ b/parser/src/test-data/java/methods/AllMethodExamples.java @@ -0,0 +1,391 @@ +package com.inventory.auth.examples; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.function.Function; +import java.util.function.Supplier; + +public class AllMethodExamples { + + private String instanceField = "Instance Field"; + private static String staticField = "Static Field"; + + static { + System.out.println("Static initializer - executed when class is loaded"); + } + + { + System.out.println("Instance initializer - executed before constructor"); + } + + public AllMethodExamples() { + } + + public AllMethodExamples(String value) { + this.instanceField = value; + } + + public void publicInstanceMethod() { + System.out.println("Public instance method"); + } + + protected void protectedMethod() { + System.out.println("Protected method"); + } + + void packagePrivateMethod() { + System.out.println("Package-private method"); + } + + private void privateMethod() { + System.out.println("Private method"); + } + + public static void staticMethod() { + System.out.println("Static method"); + } + + public final synchronized void finalSynchronizedMethod() { + System.out.println("Final synchronized method"); + } + + public strictfp double strictfpMethod() { + return 1.0 / 3.0; + } + + public int[] methodWithMultipleParams(String param1, int param2, double param3) { + System.out.println("Multiple params: " + param1 + ", " + param2 + ", " + param3); + return new int[]{1, 2, 3}; + } + + public void methodWithVarargs(String... args) { + System.out.println("Varargs: " + Arrays.toString(args)); + } + + public Map genericMethodMultipleTypes(T key, U value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + + public & Cloneable> void multipleBoundsMethod(T item) { + System.out.println("Multiple bounds: " + item); + } + + public void overloadedMethod() { + System.out.println("No parameters"); + } + + public void overloadedMethod(String param) { + System.out.println("String parameter"); + } + + public void overloadedMethod(String param1, int param2, Object... varargs) { + System.out.println("Multiple params + varargs"); + } + + public T methodThrowsMultipleExceptions(T input) throws IllegalArgumentException, NullPointerException, Exception { + try { + if (input == null) throw new NullPointerException(); + return input; + } catch (NullPointerException e) { + throw e; + } finally { + System.out.println("Finally block"); + } + } + + public native void nativeMethod(); + + public String[][] multiDimensionalArrayReturn() { + return new String[][]{{"a", "b"}, {"c", "d"}}; + } + + public void wildcardParamsAllTypes(List any, List upper, List lower) { + System.out.println("All wildcard types"); + } + + public void receiverParameter(AllMethodExamples this) { + System.out.println("Explicit receiver parameter"); + } + + public void deeplyNestedComplexParam( + List>> tripleNested, + Map>> complexMap, + List[] arrayOfLists) { + System.out.println("Deep nesting: 3-level list, map-list-set, array of lists"); + } + + public , U> Map> genericNestedWithBounds(T key, U value) { + Map> result = new HashMap<>(); + result.put(key, Arrays.asList(value)); + return result; + } + + public void nestedWildcardsComplex( + List> nestedWildcard, + Map mapWildcard) { + System.out.println("Nested wildcards with covariant and contravariant"); + } + + public void functionalInterfaceParams( + Function> nestedFunc, + Supplier> supplier, + Callable> callable) throws Exception { + System.out.println("Multiple functional interfaces: nested function, supplier, callable"); + } + + public Map>>> complexGenericNestedReturn(K key, V value) { + Map>>> result = new HashMap<>(); + Set set = new HashSet<>(); + set.add(value); + Map> innerMap = new HashMap<>(); + innerMap.put(1, set); + result.put(key, Arrays.asList(innerMap)); + return result; + } + + public Class classTypeParamWithBounds(Class clazz, T instance) { + System.out.println("Class: " + clazz.getName() + ", instance: " + instance); + return (Class) instance.getClass(); + } + + public void multipleComplexParams( + Map> param1, + List>> param2, + Function>> param3, + Class> param4) { + System.out.println("Method with multiple complex parameters"); + } + + public , U extends Comparable> Map> recursiveBoundsMultipleTypes( + List list1, + Collection input, + Collection output) { + System.out.println("Recursive bounds on multiple types with bounded wildcards"); + return new HashMap<>(); + } + + public > T[] genericArrayWithEnumAndVarargs( + T[] array, + Class componentType, + Class enumClass, + List... varargLists) { + System.out.println("Generic array, enum bound, varargs of lists"); + return array; + } + + public abstract static class InnerAbstractClass { + public abstract void abstractMethod(); + + public void concreteMethod() { + System.out.println("Concrete method in abstract class"); + } + } + + public static class ConcreteInnerClass extends InnerAbstractClass { + @Override + public void abstractMethod() { + System.out.println("Implementation of abstract method"); + } + } + + @FunctionalInterface + public interface SimpleFunctionalInterface { + void singleAbstractMethod(); + } + + public interface InterfaceWithMethods { + void abstractMethodInInterface(); + + default void defaultMethodWithImplementation() { + System.out.println("Default method with implementation"); + } + + static void staticInterfaceMethod() { + System.out.println("Static method in interface"); + } + } + + public String lambdaMethodExample() { + SimpleFunctionalInterface lambda = () -> System.out.println("Lambda implementation"); + lambda.singleAbstractMethod(); + return "Lambda executed"; + } + + public void methodReferenceExample() { + List list = Arrays.asList("a", "b", "c"); + list.forEach(System.out::println); + } + + public AllMethodExamples recursiveBuilderMethod(int count, String value) { + if (count > 0) { + this.instanceField = value + count; + return recursiveBuilderMethod(count - 1, value); + } + return this; + } + + @Deprecated + public void deprecatedMethod() { + System.out.println("This method is deprecated"); + } + + @SafeVarargs + public final void safeVarargsMethod(T... args) { + System.out.println("Safe varargs method"); + } + + @Override + public String toString() { + return "AllMethodExamples{instanceField='" + instanceField + "'}"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + AllMethodExamples that = (AllMethodExamples) obj; + return instanceField.equals(that.instanceField); + } + + @Override + public int hashCode() { + return instanceField.hashCode(); + } + + public static enum Status { + ACTIVE("A") { + @Override + public String getDescription() { + return "Active status"; + } + }, + INACTIVE("I") { + @Override + public String getDescription() { + return "Inactive status"; + } + }; + + private final String code; + + Status(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public abstract String getDescription(); + } + + public static enum Operation { + ADD { + @Override + public int apply(int a, int b) { + return a + b; + } + }, + SUBTRACT { + @Override + public int apply(int a, int b) { + return a - b; + } + }, + MULTIPLY { + @Override + public int apply(int a, int b) { + return a * b; + } + }; + + public abstract int apply(int a, int b); + } + + public static @interface MyAnnotation { + String value(); + int count() default 0; + Class type() default Object.class; + String[] tags() default {}; + } + + public static class Outer { + private String outerField = "Outer"; + + public class Inner { + public void methodAccessingOuter(String msg) { + System.out.println("Outer field: " + Outer.this.outerField + ", Message: " + msg); + } + } + + public void methodWithAnnotatedReceiver(AllMethodExamples.Outer this) { + System.out.println("Method with receiver parameter: " + this.outerField); + } + } + + public static void main(String[] args) { + AllMethodExamples example = new AllMethodExamples(); + + System.out.println("=== Basic Methods ==="); + example.publicInstanceMethod(); + staticMethod(); + int[] result = example.methodWithMultipleParams("test", 123, 45.6); + example.methodWithVarargs("arg1", "arg2", "arg3"); + + System.out.println("\n=== Generics & Bounds ==="); + Map genericResult = example.genericMethodMultipleTypes("key", 100); + System.out.println("Generic result: " + genericResult); + + System.out.println("\n=== Overloading ==="); + example.overloadedMethod(); + example.overloadedMethod("param"); + example.overloadedMethod("param", 1, "obj1", "obj2"); + + System.out.println("\n=== Builder Pattern & Recursion ==="); + AllMethodExamples chained = example.recursiveBuilderMethod(3, "val"); + System.out.println(chained); + + System.out.println("\n=== Enums ==="); + Status status = Status.ACTIVE; + System.out.println("Status: " + status.getDescription()); + Operation op = Operation.ADD; + System.out.println("Operation result: " + op.apply(5, 3)); + + System.out.println("\n=== Complex Nested Parameters ==="); + List>> tripleNested = Arrays.asList( + Arrays.asList(Arrays.asList("deeply", "nested")) + ); + Map>> complexMap = new HashMap<>(); + Set set = new HashSet<>(); + set.add(1); + complexMap.put("key", Arrays.asList(set)); + example.deeplyNestedComplexParam(tripleNested, complexMap, null); + + System.out.println("\n=== Generic Nested with Bounds ==="); + Map> boundedResult = example.genericNestedWithBounds("key", 42); + System.out.println("Bounded generic nested: " + boundedResult); + + System.out.println("\n=== Wildcards ==="); + example.wildcardParamsAllTypes(Arrays.asList(1, "a"), Arrays.asList(1, 2), Arrays.asList(1, 2, 3)); + + System.out.println("\n=== Complex Return Types ==="); + Map>>> complexReturn = example.complexGenericNestedReturn("k", "v"); + System.out.println("Complex nested return: " + complexReturn); + + System.out.println("\n=== Class Types & Reflection ==="); + Class classResult = example.classTypeParamWithBounds(Integer.class, 100); + System.out.println("Class type result: " + classResult); + + System.out.println("\n=== Recursive Type Bounds ==="); + example.recursiveBoundsMultipleTypes(Arrays.asList("a", "b"), Arrays.asList(1, 2, 3), Arrays.asList("x")); + } +} diff --git a/parser/src/test-data/java/methods/AllMethodExampless.java b/parser/src/test-data/java/methods/AllMethodExampless.java new file mode 100644 index 000000000..918856dc6 --- /dev/null +++ b/parser/src/test-data/java/methods/AllMethodExampless.java @@ -0,0 +1,566 @@ +package com.inventory.auth.examples2; + +import java.io.IOException; +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.function.Function; +import java.util.function.Supplier; + +import javax.validation.constraints.NotNull; + +import com.inventory.auth.domain.Session; + +public class AllMethodExampless { + + private String instanceField = "Instance Field"; + private static String staticField = "Static Field"; + + static { + System.out.println("Static initializer - executed when class is loaded"); + } + + { + System.out.println("Instance initializer - executed before constructor"); + } + + public AllMethodExampless() { + } + + public AllMethodExampless(String value) { + this.instanceField = value; + } + + public AllMethodExampless(T param, Class type) { + this.instanceField = param != null ? param.toString() : "null"; + } + + public AllMethodExampless(T baseParam, U derivedParam, Class derivedType) { + this.instanceField = derivedParam != null ? derivedParam.toString() : "null"; + } + + public void publicInstanceMethod() { + System.out.println("Public instance method"); + } + + protected void protectedMethod() { + System.out.println("Protected method"); + } + + void packagePrivateMethod() { + System.out.println("Package-private method"); + } + + private void privateMethod() { + System.out.println("Private method"); + } + + public static void staticMethod() { + System.out.println("Static method"); + } + + public final synchronized void finalSynchronizedMethod() { + System.out.println("Final synchronized method"); + } + + public strictfp double strictfpMethod() { + return 1.0 / 3.0; + } + + public int[] methodWithMultipleParams(String param1, int param2, double param3) { + System.out.println("Multiple params: " + param1 + ", " + param2 + ", " + param3); + return new int[]{1, 2, 3}; + } + + public void methodWithVarargs(String... args) { + System.out.println("Varargs: " + Arrays.toString(args)); + } + + public Map genericMethodMultipleTypes(T key, U value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + + public & Cloneable> void multipleBoundsMethod(T item) { + System.out.println("Multiple bounds: " + item); + } + + public void overloadedMethod() { + System.out.println("No parameters"); + } + + public void overloadedMethod(String param) { + System.out.println("String parameter"); + } + + public void overloadedMethod(String param1, int param2, Object... varargs) { + System.out.println("Multiple params + varargs"); + } + + public T methodThrowsMultipleExceptions(T input) throws IllegalArgumentException, NullPointerException, Exception { + try { + if (input == null) throw new NullPointerException(); + return input; + } catch (NullPointerException e) { + throw e; + } finally { + System.out.println("Finally block"); + } + } + + public native void nativeMethod(); + + public String[][] multiDimensionalArrayReturn() { + return new String[][]{{"a", "b"}, {"c", "d"}}; + } + + public void wildcardParamsAllTypes(List any, List upper, List lower) { + System.out.println("All wildcard types"); + } + + public void receiverParameter(AllMethodExampless this) { + System.out.println("Explicit receiver parameter"); + } + + public void deeplyNestedComplexParam( + List>> tripleNested, + Map>> complexMap, + List[] arrayOfLists) { + System.out.println("Deep nesting: 3-level list, map-list-set, array of lists"); + } + + public , U> Map> genericNestedWithBounds(T key, U value) { + Map> result = new HashMap<>(); + result.put(key, Arrays.asList(value)); + return result; + } + + public void nestedWildcardsComplex( + List> nestedWildcard, + Map mapWildcard) { + System.out.println("Nested wildcards with covariant and contravariant"); + } + + public void functionalInterfaceParams( + Function> nestedFunc, + Supplier> supplier, + Callable> callable) throws Exception { + System.out.println("Multiple functional interfaces: nested function, supplier, callable"); + } + + public Map>>> complexGenericNestedReturn(K key, V value) { + Map>>> result = new HashMap<>(); + Set set = new HashSet<>(); + set.add(value); + Map> innerMap = new HashMap<>(); + innerMap.put(1, set); + result.put(key, Arrays.asList(innerMap)); + return result; + } + + public Class classTypeParamWithBounds(Class clazz, T instance) { + System.out.println("Class: " + clazz.getName() + ", instance: " + instance); + return (Class) instance.getClass(); + } + + public void multipleComplexParams( + Map> param1, + List>> param2, + Function>> param3, + Class> param4) { + System.out.println("Method with multiple complex parameters"); + } + + public , U extends Comparable> Map> recursiveBoundsMultipleTypes( + List list1, + Collection input, + Collection output) { + System.out.println("Recursive bounds on multiple types with bounded wildcards"); + return new HashMap<>(); + } + + public > T[] genericArrayWithEnumAndVarargs( + T[] array, + Class componentType, + Class enumClass, + List... varargLists) { + System.out.println("Generic array, enum bound, varargs of lists"); + return array; + } + + public abstract static class InnerAbstractClass { + public abstract void abstractMethod(); + + public void concreteMethod() { + System.out.println("Concrete method in abstract class"); + } + } + + public static class ConcreteInnerClass extends InnerAbstractClass { + @Override + public void abstractMethod() { + System.out.println("Implementation of abstract method"); + } + } + + @FunctionalInterface + public interface SimpleFunctionalInterface { + void singleAbstractMethod(); + } + + public interface InterfaceWithMethods { + void abstractMethodInInterface(); + + default void defaultMethodWithImplementation() { + System.out.println("Default method with implementation"); + } + + static void staticInterfaceMethod() { + System.out.println("Static method in interface"); + } + } + + public String lambdaMethodExample() { + SimpleFunctionalInterface lambda = () -> System.out.println("Lambda implementation"); + lambda.singleAbstractMethod(); + return "Lambda executed"; + } + + public void methodReferenceExample() { + List list = Arrays.asList("a", "b", "c"); + list.forEach(System.out::println); + } + + public AllMethodExampless recursiveBuilderMethod(int count, String value) { + if (count > 0) { + this.instanceField = value + count; + return recursiveBuilderMethod(count - 1, value); + } + return this; + } + + @Deprecated + public void deprecatedMethod() { + System.out.println("This method is deprecated"); + } + + @SafeVarargs + public final void safeVarargsMethod(T... args) { + System.out.println("Safe varargs method"); + } + + @Override + public String toString() { + return "AllMethodExamples{instanceField='" + instanceField + "'}"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + AllMethodExampless that = (AllMethodExampless) obj; + return instanceField.equals(that.instanceField); + } + + @Override + public int hashCode() { + return instanceField.hashCode(); + } + + public static enum Status { + ACTIVE("A") { + @Override + public String getDescription() { + return "Active status"; + } + }, + INACTIVE("I") { + @Override + public String getDescription() { + return "Inactive status"; + } + }; + + private final String code; + + Status(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public abstract String getDescription(); + } + + public static enum Operation { + ADD { + @Override + public int apply(int a, int b) { + return a + b; + } + }, + SUBTRACT { + @Override + public int apply(int a, int b) { + return a - b; + } + }, + MULTIPLY { + @Override + public int apply(int a, int b) { + return a * b; + } + }; + + public abstract int apply(int a, int b); + } + + public static @interface MyAnnotation { + String value(); + int count() default 0; + Class type() default Object.class; + String[] tags() default {}; + } + + public > T intersectionTypeReturn(T input) { + return input; + } + + public & Cloneable & Serializable> List intersectionTypeInCollection(T value) { + return Arrays.asList(value); + } + + public List getRandomVoidList() { + return null; + } + + public List factoryMethodWithDiamond(Class clazz) { + return new java.util.ArrayList<>(); + } + + public void primitiveArraysInGenericContext(int[][] matrix, byte[]... chunks) { + System.out.println("Primitive arrays: matrix " + matrix.length + " x " + matrix[0].length + ", chunks " + chunks.length); + } + + public void mixedPrimitiveAndGenericArrays(int[] primitiveArray, List[] genericArrays, double[]... varargsPrimitive) { + System.out.println("Mixed primitive and generic arrays"); + } + + public void throwsGenericException(Supplier exceptionSupplier) throws E { + throw exceptionSupplier.get(); + } + + public void throwsBoundedException(Class exceptionClass) throws E { + throw null; + } + + public T methodWithValueAndException(T value, Supplier exceptionSupplier) throws E { + if (value == null) throw exceptionSupplier.get(); + return value; + } + + public > void explicitEnumBound(Class enumClass, T enumValue) { + System.out.println("Enum type: " + enumClass.getName() + ", value: " + enumValue); + } + + public > T[] enumArrayReturn(Class enumClass) { + return enumClass.getEnumConstants(); + } + + public > T selfReferencingBound(T a, T b) { + return a.compareTo(b) > 0 ? a : b; + } + + public T[] genericArrayReturn(Class type, int size) { + @SuppressWarnings("unchecked") + T[] array = (T[]) java.lang.reflect.Array.newInstance(type, size); + return array; + } + + public T[][] twoDimensionalGenericArrayReturn(Class type, int rows, int cols) { + @SuppressWarnings("unchecked") + T[][] array = (T[][]) java.lang.reflect.Array.newInstance(type, rows, cols); + return array; + } + + public T[] boundedGenericArrayReturn(Class type, T... elements) { + return elements; + } + + public void arrayDimensionAnnotations(@NotNull String @NotNull [] @NotNull [] arr) { + System.out.println("Array with type annotations on dimensions: " + arr.length); + } + + public void annotatedMultiDimensionalArrays(@NotNull Integer @NotNull [] arr, @NotNull List<@NotNull String> @NotNull [] listArray) { + System.out.println("Multiple annotated arrays"); + } + + public void annotatedWildcard(List<@NotNull ? extends @NotNull Number> list) { + System.out.println("List with annotated wildcard"); + } + + public void multipleAnnotatedWildcards( + List<@NotNull ? extends @NotNull Number> upper, + List<@NotNull ? super @NotNull Integer> lower, + Map<@NotNull ? extends String, @NotNull ? super Object> mapWildcards) { + System.out.println("Multiple annotated wildcards"); + } + + public Map<@NotNull String, @NotNull List<@NotNull Integer>> annotatedTypeArgs() { + Map<@NotNull String, @NotNull List<@NotNull Integer>> map = new HashMap<>(); + return map; + } + + public @NotNull List<@NotNull Map<@NotNull String, @NotNull Set<@NotNull Integer>>> deeplyAnnotatedTypeArgs() { + return new java.util.ArrayList<>(); + } + + public & Iterable> void deepRecursiveBound(T item) { + System.out.println("Deep recursive bound with Comparable and Iterable"); + } + + public & Serializable & Cloneable & Iterable> void veryDeepRecursiveBound(T item) { + System.out.println("Very deep recursive bound with multiple interfaces"); + } + + public void multiGenericThrows(Class ex1, Class ex2) throws E1, E2 { + throw null; + } + + public void tripleGenericThrows() throws E1, E2, E3 { + throw null; + } + + public T methodWithMultipleGenericExceptions(T value, Supplier ex1Supplier, Supplier ex2Supplier) throws E1, E2 { + if (value == null) throw ex1Supplier.get(); + return value; + } + + public static class OuterGeneric { + private O outerValue; + + public OuterGeneric(O value) { + this.outerValue = value; + } + + public Map combinedTypeParams(O outer, M method) { + Map map = new HashMap<>(); + map.put(outer, method); + return map; + } + + public M methodTypeBoundedByOuter(M methodParam) { + return methodParam; + } + + public List methodTypeIndependentOfOuter(M methodParam, O outerParam) { + List list = new java.util.ArrayList<>(); + list.add(methodParam); + return list; + } + + public class InnerGeneric { + private I innerValue; + + public Map> tripleTypeParamCombination(@NotNull O outer, I inner, M method) { + Map innerMap = new HashMap<>(); + innerMap.put(inner, method); + Map> outerMap = new HashMap<>(); + outerMap.put(outer, innerMap); + return outerMap; + } + + public List methodBoundedByInnerType(M methodParam) { + return Arrays.asList(methodParam); + } + } + } + + public static class Outer { + private String outerField = "Outer"; + + public class Inner { + public void methodAccessingOuter(String msg) { + System.out.println("Outer field: " + Outer.this.outerField + ", Message: " + msg); + } + } + + public void methodWithAnnotatedReceiver(AllMethodExampless.Outer this) { + System.out.println("Method with receiver parameter: " + this.outerField); + } + } + + public > T annotatedBoundedMethod(@NotNull T value) { + return value; + } + + public @NotNull List<@NotNull T> annotatedReturnAndTypeParam(@NotNull Class<@NotNull T> clazz) { + return new java.util.ArrayList<>(); + } + + public static void main(String[] args) { + AllMethodExampless example = new AllMethodExampless(); + + System.out.println("=== Basic Methods ==="); + example.publicInstanceMethod(); + staticMethod(); + int[] result = example.methodWithMultipleParams("test", 123, 45.6); + example.methodWithVarargs("arg1", "arg2", "arg3"); + + System.out.println("\n=== Generics & Bounds ==="); + Map genericResult = example.genericMethodMultipleTypes("key", 100); + System.out.println("Generic result: " + genericResult); + + System.out.println("\n=== Overloading ==="); + example.overloadedMethod(); + example.overloadedMethod("param"); + example.overloadedMethod("param", 1, "obj1", "obj2"); + + System.out.println("\n=== Builder Pattern & Recursion ==="); + AllMethodExampless chained = example.recursiveBuilderMethod(3, "val"); + System.out.println(chained); + + System.out.println("\n=== Enums ==="); + Status status = Status.ACTIVE; + System.out.println("Status: " + status.getDescription()); + Operation op = Operation.ADD; + System.out.println("Operation result: " + op.apply(5, 3)); + + System.out.println("\n=== Complex Nested Parameters ==="); + List>> tripleNested = Arrays.asList( + Arrays.asList(Arrays.asList("deeply", "nested")) + ); + Map>> complexMap = new HashMap<>(); + Set set = new HashSet<>(); + set.add(1); + complexMap.put("key", Arrays.asList(set)); + example.deeplyNestedComplexParam(tripleNested, complexMap, null); + + System.out.println("\n=== Generic Nested with Bounds ==="); + Map> boundedResult = example.genericNestedWithBounds("key", 42); + System.out.println("Bounded generic nested: " + boundedResult); + + System.out.println("\n=== Wildcards ==="); + example.wildcardParamsAllTypes(Arrays.asList(1, "a"), Arrays.asList(1, 2), Arrays.asList(1, 2, 3)); + + System.out.println("\n=== Complex Return Types ==="); + Map>>> complexReturn = example.complexGenericNestedReturn("k", "v"); + System.out.println("Complex nested return: " + complexReturn); + + System.out.println("\n=== Class Types & Reflection ==="); + Class classResult = example.classTypeParamWithBounds(Integer.class, 100); + System.out.println("Class type result: " + classResult); + + System.out.println("\n=== Recursive Type Bounds ==="); + example.recursiveBoundsMultipleTypes(Arrays.asList("a", "b"), Arrays.asList(1, 2, 3), Arrays.asList("x")); + } +} diff --git a/parser/src/test-data/java/methods/ComprehensiveMethodPatterns.java b/parser/src/test-data/java/methods/ComprehensiveMethodPatterns.java new file mode 100644 index 000000000..f0a3613b9 --- /dev/null +++ b/parser/src/test-data/java/methods/ComprehensiveMethodPatterns.java @@ -0,0 +1,378 @@ +package com.inventory.auth.examples; + +// Explicit imports +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.HashMap; +import java.util.Set; +import java.util.Comparator; + +// Wildcard import +import java.io.*; +import java.lang.reflect.Array; + +// Static imports +import static java.util.Collections.sort; + +// Cross-package imports +import com.inventory.auth.domain.Session; +import com.inventory.auth.domain.User; + +/** + * Comprehensive test file covering all Java method patterns in one place: + * - Generic bounds (single, multiple, recursive, chained) + * - Type parameter dependencies (T extends U, U extends T in methods) + * - Constructors with generics + * - Arrays with generics + * - Throws with type parameters + * - Nested classes with type interactions + * - Anonymous and local classes + * - Interface default/static methods + * - Inheritance patterns (covariant returns, bridge methods) + * - Cross-file type dependencies + * - Import style variations + */ +public class ComprehensiveMethodPatterns { + + private T value; + + // === CONSTRUCTORS === + + public ComprehensiveMethodPatterns(T value) { + this.value = value; + } + + public ComprehensiveMethodPatterns(T t, U u) { + this.value = t; + } + + public ComprehensiveMethodPatterns(T base, U derived, Class clazz) { + this.value = base; + } + + // === GENERIC METHOD PATTERNS === + + public U selfBoundedMethod(U input) { + return input; + } + + public > U recursiveBound(U a, U b) { + return a.compareTo(b) > 0 ? a : b; + } + + public & Serializable> U multipleBounds(U value) { + return value; + } + + public Map> chainedBounds(T t, U u, V v) { + Map inner = new HashMap<>(); + inner.put(u, v); + Map> outer = new HashMap<>(); + outer.put(t, inner); + return outer; + } + + public List multipleBoundsBySameBase(T base, U derived1, V derived2) { + List result = new ArrayList<>(); + result.add(derived1); + return result; + } + + public static > U staticGenericMethod(List list) { + return list.isEmpty() ? null : list.get(0); + } + + // === ARRAY + GENERIC PATTERNS === + + public U[] genericArrayCreation(Class clazz, int size) { + @SuppressWarnings("unchecked") + U[] array = (U[]) Array.newInstance(clazz, size); + return array; + } + + public U[][] twoDimensionalArray(U[][] input) { + return input; + } + + public U[] boundedArrayMethod(Class baseClass, Class derivedClass, U value) { + @SuppressWarnings("unchecked") + U[] array = (U[]) Array.newInstance(derivedClass, 5); + array[0] = value; + return array; + } + + public final List[] arrayOfGenericLists(@SuppressWarnings("unchecked") List... lists) { + return lists; + } + + // === THROWS PATTERNS === + + public void throwsGenericException(Class exClass) throws E { + throw null; + } + + public void throwsBounded(E exception) throws E { + throw exception; + } + + public U methodWithGenericAndException(U value) throws E, IOException { + if (value == null) throw new IOException(); + return value; + } + + // === WILDCARD PATTERNS === + + public void wildcardMethod(List unbounded, List upper, List lower) { + } + + public void wildcardWithTypeParam(List source, List dest) { + for (U item : source) { + dest.add(item); + } + } + + // === CROSS-FILE TYPE DEPENDENCIES === + + public void concreteTypeMethod(Session session, User user) { + // Uses types from different package + } + + public U boundByConcreteType(U session) { + return session; + } + + public Map chainedConcreteTypeBounds(U user, V derivedUser) { + Map map = new HashMap<>(); + map.put(user, derivedUser); + return map; + } + + // === NESTED CLASS PATTERNS === + + public class Inner { + public Map combineOuterInner(T t, U u) { + Map map = new HashMap<>(); + map.put(t, u); + return map; + } + + public V shadowingMethod(V input) { + return input; + } + + public class DeepNested { + public Map> tripleNesting(T t, U u, V v) { + Map inner = new HashMap<>(); + inner.put(u, v); + Map> outer = new HashMap<>(); + outer.put(t, inner); + return outer; + } + } + } + + public static class StaticNested { + public Map staticNestedMethod(U key, V value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + } + + // === ANONYMOUS AND LOCAL CLASS PATTERNS === + + public > void anonymousClass(U value) { + Comparator comp = new Comparator() { + @Override + public int compare(U o1, U o2) { + return o1.compareTo(o2); + } + + public V customMethod(V v) { + return v; + } + }; + comp.compare(value, value); + } + + public void localClass(U outerValue) { + class LocalProcessor { + private V value; + + public V process(V input) { + this.value = input; + return value; + } + + public Map localGenericMethod(V v, W w) { + Map map = new HashMap<>(); + map.put(v, w); + return map; + } + } + + LocalProcessor processor = new LocalProcessor<>(); + processor.process(outerValue); + } + + public void localClassWithConcreteTypeBound(U session) { + class SessionProcessor { + public V processSession(V s) { + return s; + } + } + + SessionProcessor processor = new SessionProcessor<>(); + processor.processSession(session); + } + + // === INTERFACE PATTERNS === + + public interface GenericInterface { + U getValue(); + + default Map defaultMethod(U key, V value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + + static List staticInterfaceMethod(V value) { + List list = new ArrayList<>(); + list.add(value); + return list; + } + } + + public interface BoundedInterface> { + default V boundedDefault(V value) { + return value; + } + } + + // === INHERITANCE PATTERNS === + + public static class Parent { + public U getValue() { + return null; + } + + public Number getNumber() { + return 0; + } + } + + public static class Child extends Parent { + @Override + public String getValue() { + return "child"; + } + + @Override + public Integer getNumber() { + return 42; + } + } + + public static abstract class AbstractGeneric> { + public abstract U abstractMethod(U value); + + public V concreteGenericMethod(V value) { + return value; + } + } + + public static class ConcreteImplementation extends AbstractGeneric { + @Override + public String abstractMethod(String value) { + return value.toUpperCase(); + } + } + + // === IMPORT STYLE VARIATIONS === + + public List explicitImportMethod() { + return new ArrayList<>(); + } + + public Serializable wildcardImportMethod(Serializable input) throws IOException { + if (input == null) throw new IOException(); + return input; + } + + public void staticImportMethod(List numbers) { + sort(numbers); + } + + public java.util.concurrent.ConcurrentHashMap fullyQualifiedMethod() { + return new java.util.concurrent.ConcurrentHashMap<>(); + } + + // === STATIC INITIALIZER === + static { + System.out.println("Static initializer"); + } + + // === INSTANCE INITIALIZER === + { + System.out.println("Instance initializer"); + } + + // === VARARGS PATTERNS === + + public void genericVarargs(U... elements) { + } + + public void boundedVarargs(U... elements) { + } + + // === RECEIVER PARAMETER === + + public void receiverParameterMethod(ComprehensiveMethodPatterns this) { + } + + // === OVERLOADING === + + public void overloadedMethod() { + } + + public void overloadedMethod(String s) { + } + + public void overloadedMethod(String s, Integer i) { + } + + public void overloadedGenericMethod(U bounded) { + } + + // === ENUM WITH METHODS === + + public enum Status { + ACTIVE { + @Override + public String getDescription() { + return "Active status"; + } + }, + INACTIVE { + @Override + public String getDescription() { + return "Inactive status"; + } + }; + + public abstract String getDescription(); + + public U genericEnumMethod(U value) { + return value; + } + } + + // === ANNOTATION TYPE === + + public @interface CustomAnnotation { + String value() default "default"; + int count() default 0; + } +} diff --git a/parser/src/test-data/java/methods/ConstructorPatterns.java b/parser/src/test-data/java/methods/ConstructorPatterns.java new file mode 100644 index 000000000..cdd8235c8 --- /dev/null +++ b/parser/src/test-data/java/methods/ConstructorPatterns.java @@ -0,0 +1,106 @@ +package com.inventory.auth.examples; + +import java.util.*; +import java.io.Serializable; + +/** + * Test file for constructor patterns including: + * - Generic constructors separate from class type parameters + * - Constructor with multiple type parameters + * - Constructor with bounded type parameters + */ +public class ConstructorPatterns { + + private T value; + private Object[] data; + + public ConstructorPatterns() { + } + + public ConstructorPatterns(T value) { + this.value = value; + } + + public ConstructorPatterns(T t, U u) { + this.value = t; + this.data = new Object[]{t, u}; + } + + public > ConstructorPatterns(T t, U u, Class clazz) { + this.value = t; + this.data = new Object[]{t, u, clazz}; + } + + public ConstructorPatterns(U u, int discriminator) { + this.value = u; + } + + public ConstructorPatterns(Map map, T defaultValue) { + this.value = defaultValue; + this.data = new Object[]{map}; + } + + public > ConstructorPatterns(List list, T value) { + this.value = value; + this.data = list.toArray(); + } + + public ConstructorPatterns(U[] numbers, T value) { + this.value = value; + this.data = numbers; + } + + @SafeVarargs + public ConstructorPatterns(T value, U... elements) { + this.value = value; + this.data = elements; + } + + public static class NonGenericClass { + + public NonGenericClass() { + } + + public NonGenericClass(T value) { + } + + public NonGenericClass(T t, U u) { + } + + public > NonGenericClass(List list) { + } + } + + public static class MultipleTypeParams { + + public MultipleTypeParams() { + } + + public MultipleTypeParams(K key, V value) { + } + + public MultipleTypeParams(K key, V value, U extra) { + } + + public MultipleTypeParams(U key, V value, boolean flag) { + } + + public > MultipleTypeParams(K key, U value, W comparable) { + } + } + + public static class BoundedClassParams> { + + public BoundedClassParams() { + } + + public BoundedClassParams(T value) { + } + + public BoundedClassParams(U value, int discriminator) { + } + + public > BoundedClassParams(T t, U u) { + } + } +} diff --git a/parser/src/test-data/java/methods/DefaultConstructors.java b/parser/src/test-data/java/methods/DefaultConstructors.java new file mode 100644 index 000000000..2786a2a8d --- /dev/null +++ b/parser/src/test-data/java/methods/DefaultConstructors.java @@ -0,0 +1,46 @@ +package com.axiomcode.test.methods; + +/** + * Acceptance fixture for the default constructor of JLS 8.8.9. + * + * A class that declares no constructor gets one implicitly, and it takes the + * access of the class itself - a package-private class does not get a public + * constructor. Interfaces and annotation types get none at all, which is the + * half a "synthesise a constructor for every type" rule would get wrong. + * + * Per `javap -p`: Plain() is public, PackagePrivate() is package-private, + * Abstract() is public, Declared has only Declared(int), and neither Contract + * nor Marker has any constructor. + */ +public class DefaultConstructors { + + /** Public class, no constructor: implicit public no-arg constructor. */ + public static class Plain { + private int count; + } + + /** Package-private class: the implicit constructor is package-private too. */ + static class PackagePrivate { + } + + /** An abstract class still gets one, despite never being instantiated directly. */ + public abstract static class Abstract { + public abstract void go(); + } + + /** Declares a constructor, so nothing is implicit. There is exactly one. */ + public static class Declared { + public Declared(int value) { + } + } + + /** An interface has no constructor at all. */ + public interface Contract { + void go(); + } + + /** Nor does an annotation type. */ + public @interface Marker { + String value() default ""; + } +} diff --git a/parser/src/test-data/java/methods/GenericMethodPatterns.java b/parser/src/test-data/java/methods/GenericMethodPatterns.java new file mode 100644 index 000000000..e728efdb6 --- /dev/null +++ b/parser/src/test-data/java/methods/GenericMethodPatterns.java @@ -0,0 +1,113 @@ +package com.inventory.auth.examples; + +import java.io.Serializable; +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.Collections; + +/** + * Test file for generic method patterns including: + * - Self-referential return types + * - Recursive generics + * - Intersection types + * - Static generic methods + */ +public class GenericMethodPatterns { + + public > T selfBoundedReturn(T input) { + return input; + } + + public > T superBoundedReturn(T input) { + return input; + } + + public > T recursiveListReturn() { + return null; + } + + public & Serializable> T intersectionReturn(T a, T b) { + return a.compareTo(b) > 0 ? a : b; + } + + public & Cloneable> T tripleIntersectionReturn(T value) { + return value; + } + + public > T numberComparableIntersection(T a, T b) { + return a.compareTo(b) >= 0 ? a : b; + } + + public static > T staticGenericMethod(List list) { + return Collections.max(list); + } + + public static > Map.Entry staticMapEntry(K key, V value) { + return new Map.Entry() { + public K getKey() { return key; } + public V getValue() { return value; } + public V setValue(V value) { throw new UnsupportedOperationException(); } + }; + } + + public static List staticListCreator(T... elements) { + List list = new ArrayList<>(); + for (T elem : elements) { + list.add(elem); + } + return list; + } + + public T genericWithDependency(T t, U u) { + return t; + } + + public U reverseDependency(T t, U u) { + return u; + } + + public , U extends T> U boundedDependency(T t, U u) { + return u; + } + + public T unboundedGeneric(T input) { + return input; + } + + public Map> tripleGenericNested(T key1, U key2, V value) { + Map innerMap = new java.util.HashMap<>(); + innerMap.put(key2, value); + Map> outerMap = new java.util.HashMap<>(); + outerMap.put(key1, innerMap); + return outerMap; + } + + public > List selfBoundedList(T... elements) { + List list = new ArrayList<>(); + for (T elem : elements) { + list.add(elem); + } + return list; + } + + public static > T staticEnumMethod(Class enumType, String name) { + return Enum.valueOf(enumType, name); + } + + public T[] genericNumberArray(T... numbers) { + return numbers; + } + + public T genericReturnWithWildcardParam(List list) { + return list.isEmpty() ? null : list.get(0); + } + + public void genericWithMultipleWildcards( + List source, + List destination) { + for (T item : source) { + destination.add(item); + } + } +} diff --git a/parser/src/test-data/java/methods/MethodAnnotationsTest.java b/parser/src/test-data/java/methods/MethodAnnotationsTest.java new file mode 100644 index 000000000..9f9772a86 --- /dev/null +++ b/parser/src/test-data/java/methods/MethodAnnotationsTest.java @@ -0,0 +1,98 @@ +package com.test.methods; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.annotation.ElementType; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE_PARAMETER}) +@interface Validated {} + +@Retention(RetentionPolicy.RUNTIME) +@interface Cached { + int ttl() default 60; + String key() default ""; +} + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +@interface NotNull {} + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +@interface Size { + int min() default 0; + int max() default Integer.MAX_VALUE; +} + +public class MethodAnnotationsTest { + + // Method-level annotations + @Deprecated + public void deprecatedMethod() {} + + @Override + public String toString() { + return "MethodAnnotationsTest"; + } + + @SuppressWarnings("unchecked") + public void suppressedMethod() {} + + // Multiple method annotations + @Deprecated + @SuppressWarnings("deprecation") + public void multiAnnotatedMethod() {} + + // Annotation with named arguments + @Cached(ttl = 300, key = "user-cache") + public String getCachedData() { + return null; + } + + // Parameter annotations + public void paramAnnotations(@NotNull String name, @Size(min = 1, max = 100) String value) {} + + // Multiple parameter annotations + public void multiParamAnnotations( + @NotNull @Size(min = 1) String required, + @Validated Object data + ) {} + + // Method type parameter annotations + public <@Validated T> T validatedGeneric(T input) { + return input; + } + + // Combined method, parameter, and type parameter annotations + @Cached(ttl = 60) + public <@Validated T> void fullAnnotated( + @NotNull T input, + @Size(min = 0, max = 10) String limit + ) {} + + // SafeVarargs annotation + @SafeVarargs + public final void safeVarargs(T... items) {} +} + +class ConstructorAnnotationsTest { + + // Annotated constructor + @Deprecated + public ConstructorAnnotationsTest() {} + + // Constructor with annotated parameters + public ConstructorAnnotationsTest(@NotNull String name, @Size(max = 50) String description) {} +} + +interface InterfaceAnnotationsTest { + + // Annotated default method + @Deprecated + default void deprecatedDefault() {} + + // Abstract method with parameter annotations + void process(@NotNull String data); +} diff --git a/parser/src/test-data/java/methods/MethodKindsTest.java b/parser/src/test-data/java/methods/MethodKindsTest.java new file mode 100644 index 000000000..3e2593277 --- /dev/null +++ b/parser/src/test-data/java/methods/MethodKindsTest.java @@ -0,0 +1,77 @@ +package com.test.methods; + +import java.io.IOException; + +public class MethodKindsTest { + + // Instance method + public void instanceMethod() {} + + // Static method + public static void staticMethod() {} + + // Final method + public final void finalMethod() {} + + // Synchronized method + public synchronized void synchronizedMethod() {} + + // Native method + public native int nativeMethod(); + + // Constructor + public MethodKindsTest() {} + + // Overloaded constructor + public MethodKindsTest(String name) {} + + // Method with throws + public void throwingMethod() throws IOException, IllegalArgumentException {} + + // Private method + private void privateMethod() {} + + // Protected method + protected void protectedMethod() {} + + // Package-private method + void packageMethod() {} + + // Static initializer + static { + System.out.println("Static init"); + } + + // Instance initializer + { + System.out.println("Instance init"); + } +} + +abstract class AbstractMethodTest { + // Abstract method + public abstract void abstractMethod(); + + // Concrete method in abstract class + public void concreteMethod() {} +} + +interface InterfaceMethodTest { + // Abstract interface method + void interfaceMethod(); + + // Default method + default void defaultMethod() { + System.out.println("default"); + } + + // Static interface method + static void staticInterfaceMethod() {} +} + +@interface AnnotationMethodTest { + // Annotation elements + String name(); + int priority() default 0; + String[] tags() default {}; +} diff --git a/parser/src/test-data/java/methods/MethodOverloadPatterns.java b/parser/src/test-data/java/methods/MethodOverloadPatterns.java new file mode 100644 index 000000000..be43281de --- /dev/null +++ b/parser/src/test-data/java/methods/MethodOverloadPatterns.java @@ -0,0 +1,59 @@ +package com.axiom.test.overloads; + +import java.util.List; + +/** + * Overload-linking test. + * + * `process` is overloaded 4 ways (arity 0, 1-int, 1-String, 2). The two arity-1 + * overloads share an arity but differ in parameter type, so they MUST still be + * assigned distinct method hashes. `combine` and `pick` are overloaded 2 ways each. + * + * The parser must: + * - give every overload a distinct method hash, + * - link every parameter back to its OWN overload (no cross-linking between + * same-named overloads), + * - report parameterCount matching the number of linked parameters. + */ +public class MethodOverloadPatterns { + + // process/0 + public void process() { } + + // process/1 (int) + public void process(int value) { } + + // process/1 (String) — same arity, different type: distinct hash required + public void process(String value) { } + + // process/2 + public void process(int value, String label) { } + + // combine/2 (int, int) + public int combine(int a, int b) { + return a + b; + } + + // combine/2 (String, String) — same arity, distinct signature + public String combine(String a, String b) { + return a + b; + } + + // varargs overload — a distinct name, single overload + public int sum(int... nums) { + int total = 0; + for (int n : nums) { + total += n; + } + return total; + } + + // generic overloads: scalar vs collection + public T pick(T only) { + return only; + } + + public T pick(List many) { + return many.get(0); + } +} diff --git a/parser/src/test-data/java/methods/MethodParamsTest.java b/parser/src/test-data/java/methods/MethodParamsTest.java new file mode 100644 index 000000000..1c508b99d --- /dev/null +++ b/parser/src/test-data/java/methods/MethodParamsTest.java @@ -0,0 +1,81 @@ +package com.test.methods; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public class MethodParamsTest { + + // Simple parameters + public void simpleParams(String name, int age) {} + + // Final parameters + public void finalParams(final String id, final int count) {} + + // Generic parameters + public void genericParams(List items, Map scores) {} + + // Wildcard parameters + public void wildcardParams(List numbers, List integers) {} + + // Array parameters + public void arrayParams(String[] names, int[][] matrix) {} + + // Varargs parameters + public void varargParams(String format, Object... args) {} + + // Mixed complex parameters + public void complexParams( + final List> data, + Function transformer, + String... tags + ) {} + + // Primitive parameters + public void primitiveParams(byte b, short s, int i, long l, float f, double d, boolean bool, char c) {} + + // Nested generic parameters + public void nestedGenerics(Map>> deepNested) {} + + // Multiple varargs methods (overloaded) + public void process(int... numbers) {} + public void process(String... strings) {} + + // Complex wildcard with nested bounds + public void complexWildcard( + Map, List> data + ) {} + + // Functional interface parameters with complex generics + public void functionalParams( + java.util.function.BiFunction> biFunc, + java.util.function.Predicate> predicate, + java.util.function.Consumer>> consumer + ) {} + + // Parameter with bounded type from class type param + public void boundedParam( + List items, + java.util.function.Function> mapper + ) {} + + // Complex array with generic element type + public void genericArrayParam(List[] arrayOfLists, Map[][] nestedArrays) {} + + // Stream and Optional parameters + public void streamParams( + java.util.stream.Stream numbers, + java.util.Optional>> optionalData + ) {} +} + +class RecordParamsTest { + // Record with parameters + record Person(String name, int age) {} + + // Record with varargs + record TaggedItem(String id, String... tags) {} + + // Record with generics + record Container(T value, List items) {} +} diff --git a/parser/src/test-data/java/methods/MethodTypeParamsTest.java b/parser/src/test-data/java/methods/MethodTypeParamsTest.java new file mode 100644 index 000000000..0fc4537fa --- /dev/null +++ b/parser/src/test-data/java/methods/MethodTypeParamsTest.java @@ -0,0 +1,91 @@ +package com.test.methods; + +import java.io.Serializable; +import java.io.Closeable; +import java.util.List; +import java.util.Collection; + +public class MethodTypeParamsTest { + + // Simple unbounded type parameter + public T identity(T input) { + return input; + } + + // Multiple type parameters + public void process(K key, V value) {} + + // Single bounded type parameter + public double sum(List numbers) { + return numbers.stream().mapToDouble(Number::doubleValue).sum(); + } + + // Multiple bounds (intersection type) + public void executeAndClose(T task) throws Exception { + try { + task.run(); + } finally { + task.close(); + } + } + + // Recursive bound (F-bounded polymorphism) + public > T max(T a, T b) { + return a.compareTo(b) >= 0 ? a : b; + } + + // Type parameter with parameterized bound + public > void processCollection(T collection) {} + + // Multiple type parameters with different bounds + public , V> void multipleWithBounds(T num, U comp, V any) {} + + // Type parameter used in return type + public E wrapException(String message, Class exceptionType) { + return null; + } + + // Static method with type parameter + public static List asList(T... items) { + return List.of(items); + } + + // Type parameter with Serializable bound + public void serialize(T object) {} + + // Complex nested bounds + public > void sortable(List items) {} + + // F-bounded polymorphism with complex bounds + public , T extends Serializable> T build(B builder) { + return null; + } + + // Type parameter with parameterized intersection bounds + public & Serializable & Comparable> void complexIntersection(T list) {} + + // Multiple type params with cross-references + public , V extends Collection> + java.util.Map createSortedMap(V values) { + return null; + } + + // Exception type parameter with bounds + public void throwTyped(Class exType) throws E {} +} + +interface Builder, T> { + Self with(String key, Object value); + T build(); +} + +class GenericConstructorTest { + // Generic constructor + public GenericConstructorTest(T value) {} + + // Bounded generic constructor + public GenericConstructorTest(T value, int count) {} + + // Constructor with multiple bounds + public > GenericConstructorTest(List items, Class type) {} +} diff --git a/parser/src/test-data/java/methods/RecordCanonicalConstructor.java b/parser/src/test-data/java/methods/RecordCanonicalConstructor.java new file mode 100644 index 000000000..d09491f4d --- /dev/null +++ b/parser/src/test-data/java/methods/RecordCanonicalConstructor.java @@ -0,0 +1,53 @@ +package com.axiomcode.test.methods; + +/** + * Acceptance fixture for JLS 8.10.4: a record has EXACTLY ONE canonical + * constructor, and it is implicitly declared only when the record declares + * neither an explicit canonical constructor nor a compact one. + * + * Every record below has exactly one constructor per `javap -p`, except + * Delegating, which has two. + */ +public class RecordCanonicalConstructor { + + /** Declares nothing: the canonical constructor is implicit. */ + public record Implicit(int x, int y) { + } + + /** + * A compact constructor IS the canonical constructor - not a second one. + * Its parameters are the record's components, so its signature is + * Compact(int,int), never Compact(). + */ + public record Compact(int x, int y) { + public Compact { + if (x < 0) { + throw new IllegalArgumentException("x"); + } + } + } + + /** + * An explicit canonical constructor. What makes it canonical is that its + * parameter TYPES match the component types in order, which is what the + * extractor tests. javac additionally requires the parameter NAMES to match + * the component names in this non-compact form, so they do here. + */ + public record Explicit(String name, int code) { + public Explicit(String name, int code) { + this.name = name; + this.code = code; + } + } + + /** + * A canonical constructor plus a genuine second constructor that delegates + * to it. The delegating one is NOT canonical - its parameter types differ - + * so this record really does have two constructors, and both are emitted. + */ + public record Delegating(int x, int y) { + public Delegating(int both) { + this(both, both); + } + } +} diff --git a/parser/src/test-data/java/methods/RecordImplicitMembers.java b/parser/src/test-data/java/methods/RecordImplicitMembers.java new file mode 100644 index 000000000..88ddbc3fe --- /dev/null +++ b/parser/src/test-data/java/methods/RecordImplicitMembers.java @@ -0,0 +1,65 @@ +package com.axiomcode.test.methods; + +import java.io.Serializable; + +/** + * Acceptance fixture for the members JLS 8.10 declares implicitly on a record. + * + * The oracle for every assertion here is javac: compile this file and run + * `javap -p`, and the member set below is what it reports (minus ACC_SYNTHETIC + * and ACC_BRIDGE entries, which are class-file artifacts rather than declared + * members and are deliberately NOT extracted). + */ +public class RecordImplicitMembers { + + /** + * The plain case. javac declares, and the extractor must emit: + * private final int x; private final int y; + * public Point(int, int); <- canonical constructor + * public int x(); public int y(); <- accessors + * public String toString(); public int hashCode(); + * public boolean equals(Object); + * Nothing here has a declaration node in the source. + */ + public record Point(int x, int y) { + } + + /** + * Type variables reach the accessor return type: first() returns A, not Object. + */ + public record Pair(A first, B second) { + } + + /** + * A varargs component. The component's type - and so the field type and the + * accessor return type - is the array type int[], while the canonical + * constructor keeps the varargs parameter. + */ + public record Args(String name, int... values) { + } + + /** + * A record may declare any implicit member itself, and javac then declares + * nothing. x() and toString() below are declared, so only y(), equals and + * hashCode are implicit - and x()/toString() keep INSTANCE_METHOD, because + * a person wrote them. + */ + public record Custom(int x, int y) { + @Override + public int x() { + return x < 0 ? 0 : x; + } + + @Override + public String toString() { + return "Custom[" + x + "," + y + "]"; + } + } + + /** + * A record with no components has no accessors and no component fields, but + * still has equals/hashCode/toString and a no-argument canonical constructor. + */ + public record Empty() { + } +} diff --git a/parser/src/test-data/java/methods/ThrowsPatterns.java b/parser/src/test-data/java/methods/ThrowsPatterns.java new file mode 100644 index 000000000..f9d6146bf --- /dev/null +++ b/parser/src/test-data/java/methods/ThrowsPatterns.java @@ -0,0 +1,105 @@ +package com.inventory.auth.examples; + +import java.io.*; +import java.sql.SQLException; +import java.util.concurrent.TimeoutException; + +/** + * Test file for throws clause patterns including: + * - Generic exception types + * - Multiple throws with generics + * - Type parameters in throws clause + * - Bounded exception types + */ +public class ThrowsPatterns { + + public void throwsGenericException(Class exClass) throws E { + throw null; + } + + public void multipleGenericThrows() throws E, F { + throw null; + } + + public void boundedExceptionThrows(E exception) throws E { + throw exception; + } + + public T methodWithGenericAndException(T value, Class exClass) throws E { + return value; + } + + public void intersectionExceptionType() throws E { + throw null; + } + + public void multipleCheckedExceptions() throws IOException, SQLException, TimeoutException { + throw new IOException(); + } + + public T throwsMultipleMixed(T value) throws IOException, IllegalArgumentException, Exception { + if (value == null) throw new IllegalArgumentException(); + return value; + } + + public void throwsThrowable() throws E { + throw null; + } + + public void throwsErrorType() throws E { + throw null; + } + + public static void staticThrowsGeneric() throws E { + throw null; + } + + public T genericReturnAndThrows(T value) throws E, IOException { + return value; + } + + public void twoGenericExceptions() throws E1, E2 { + throw null; + } + + public void runtimeExceptionGeneric() throws E { + throw null; + } + + public void checkedAndUncheckedMixed() throws IOException, NullPointerException, SQLException { + throw new IOException(); + } + + public void nestedTryCatch() throws T { + try { + throw new IOException(); + } catch (IOException e) { + throw null; + } + } + + public void throwsWithFinally() throws E { + try { + throw null; + } finally { + System.out.println("Finally"); + } + } + + public static class CustomException extends Exception { + public CustomException() { super(); } + public CustomException(String msg) { super(msg); } + } + + public void throwsCustomBounded() throws E { + throw null; + } + + public void throwsWithTypeParameter(T value) throws CustomException { + if (value == null) throw new CustomException("null value"); + } + + public void throwsBoundedWithInterface() throws E { + throw null; + } +} diff --git a/parser/src/test-data/java/modules/module-info.java b/parser/src/test-data/java/modules/module-info.java new file mode 100644 index 000000000..d665a8491 --- /dev/null +++ b/parser/src/test-data/java/modules/module-info.java @@ -0,0 +1,27 @@ +/** + * Acceptance fixture for module declarations (JLS 7.7). + * + * The oracle for every assertion is javac's own module descriptor: compile an + * equivalent module and read `javap -verbose module-info.class`, which reports + * three `requires` (one ACC_TRANSITIVE, one ACC_STATIC_PHASE), two `exports` + * (one qualified `to`), one `opens`, one `uses` and one `provides ... with`. + * + * The multi-target directives below flatten to one row per target: `exports + * com.example.multi to a, b` is two rows differing only in targetName and + * position, so a consumer joins on a column instead of splitting a string. + */ +module com.example.app { + requires java.base; + requires transitive java.sql; + requires static java.compiler; + + exports com.example.api; + exports com.example.internal to com.example.client; + exports com.example.multi to com.example.one, com.example.two; + + opens com.example.model; + + uses com.example.spi.Service; + provides com.example.spi.Service with com.example.impl.ServiceImpl; + provides com.example.spi.Codec with com.example.impl.FastCodec, com.example.impl.SafeCodec; +} diff --git a/parser/src/test-data/java/type-parameters/test-annotated-type-params.java b/parser/src/test-data/java/type-parameters/test-annotated-type-params.java new file mode 100644 index 000000000..f36d1ffd0 --- /dev/null +++ b/parser/src/test-data/java/type-parameters/test-annotated-type-params.java @@ -0,0 +1,30 @@ +package com.test.typeparameters; + +import java.lang.annotation.*; + +@interface NonNull { } + +@interface Validated { + Class validator() default Object.class; + Class[] groups() default {}; +} + +class ValidationGroup { } + +class SizeValidator { } + +class MarkerAnnotation<@NonNull T> { } + +class SingleArgAnnotation<@Validated(validator = SizeValidator.class) U> { } + +class MultiArgAnnotation<@Validated(validator = SizeValidator.class, groups = ValidationGroup.class) V> { } + +class MultipleAnnotations< + @NonNull @Validated(validator = SizeValidator.class) T +> { } + +class MixedAnnotatedParams< + @NonNull T extends Number, + @Validated(groups = ValidationGroup.class) U, + V extends Comparable +> { } diff --git a/parser/src/test-data/java/type-parameters/test-bounded-type-params.java b/parser/src/test-data/java/type-parameters/test-bounded-type-params.java new file mode 100644 index 000000000..287a59467 --- /dev/null +++ b/parser/src/test-data/java/type-parameters/test-bounded-type-params.java @@ -0,0 +1,15 @@ +package com.test.typeparameters; + +import java.io.Serializable; + +class SingleBound { } + +class MultipleBounds> { } + +class ComplexBounds & Serializable & Cloneable> { } + +class MixedBounds< + T extends Number, + U extends Comparable, + V extends Serializable & Cloneable +> { } diff --git a/parser/src/test-data/java/type-parameters/test-generic-linking.java b/parser/src/test-data/java/type-parameters/test-generic-linking.java new file mode 100644 index 000000000..9ee2d73a9 --- /dev/null +++ b/parser/src/test-data/java/type-parameters/test-generic-linking.java @@ -0,0 +1,23 @@ +package com.axiom.test.generics; + +/** + * Type-parameter linking test. + * + * `GenericLinking` declares three type parameters K, V, T. The bound of V + * (`V extends K`) references the SIBLING type parameter K, so its bound + * TypeReference must carry kind TYPE_VARIABLE and link (typeParameterLinkHash) + * to a declared TypeParameter. Positions must be contiguous 0..n-1 per owner. + */ +public class GenericLinking, V extends K, T> { + + private K key; + private V value; + private T tag; +} + +/** A second generic type in the same file, to verify positions are per-owner. */ +class Pair { + + private A first; + private B second; +} diff --git a/parser/src/test-data/java/type-parameters/test-simple-type-params.java b/parser/src/test-data/java/type-parameters/test-simple-type-params.java new file mode 100644 index 000000000..d14eb44d6 --- /dev/null +++ b/parser/src/test-data/java/type-parameters/test-simple-type-params.java @@ -0,0 +1,11 @@ +package com.test.typeparameters; + +class SingleParam { } + +class TwoParams { } + +class ThreeParams { } + +class DescriptiveNames { } + +interface GenericInterface { } diff --git a/parser/src/test-data/java/type-references/test-array-types.java b/parser/src/test-data/java/type-references/test-array-types.java new file mode 100644 index 000000000..ad50e0eda --- /dev/null +++ b/parser/src/test-data/java/type-references/test-array-types.java @@ -0,0 +1,19 @@ +package com.test.typereferences; + +import java.util.List; + +class ArrayBound> { } + +class PrimitiveArrayBound> { } + +class MultiDimensionalArray> { } + +class MixedArrays< + T extends List, + U extends List, + V extends List +> { } + +class ArraySuperclass extends ArrayList { } + +class ArrayList { } diff --git a/parser/src/test-data/java/type-references/test-interface-refs.java b/parser/src/test-data/java/type-references/test-interface-refs.java new file mode 100644 index 000000000..28ec91697 --- /dev/null +++ b/parser/src/test-data/java/type-references/test-interface-refs.java @@ -0,0 +1,16 @@ +package com.test.typereferences; + +import java.io.Serializable; +import java.util.List; + +class SingleInterface implements Runnable { } + +class MultipleInterfaces implements Serializable, Cloneable { } + +class GenericInterfaces implements Comparable, Serializable { } + +class ComplexGenericInterfaces implements Comparable>, Cloneable { } + +interface ExtendingInterface extends Serializable, Cloneable { } + +interface GenericExtendingInterface extends Comparable { } diff --git a/parser/src/test-data/java/type-references/test-nested-generics.java b/parser/src/test-data/java/type-references/test-nested-generics.java new file mode 100644 index 000000000..e32d11ce9 --- /dev/null +++ b/parser/src/test-data/java/type-references/test-nested-generics.java @@ -0,0 +1,19 @@ +package com.test.typereferences; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +class SimpleGeneric> { } + +class DoubleNested>> { } + +class TripleNested>>> { } + +class ComplexNested>>> { } + +class MultiParamNested< + T extends Map, + U extends List>, + V extends Set> +> { } diff --git a/parser/src/test-data/java/type-references/test-permits-refs.java b/parser/src/test-data/java/type-references/test-permits-refs.java new file mode 100644 index 000000000..f0f5bb9a5 --- /dev/null +++ b/parser/src/test-data/java/type-references/test-permits-refs.java @@ -0,0 +1,15 @@ +package com.test.typereferences; + +sealed interface Shape permits Circle, Rectangle, Triangle { } + +final class Circle implements Shape { } + +final class Rectangle implements Shape { } + +final class Triangle implements Shape { } + +sealed class Vehicle permits Car, Truck { } + +final class Car extends Vehicle { } + +final class Truck extends Vehicle { } diff --git a/parser/src/test-data/java/type-references/test-superclass-refs.java b/parser/src/test-data/java/type-references/test-superclass-refs.java new file mode 100644 index 000000000..202811fa4 --- /dev/null +++ b/parser/src/test-data/java/type-references/test-superclass-refs.java @@ -0,0 +1,16 @@ +package com.test.typereferences; + +import java.util.ArrayList; +import java.util.HashMap; + +class Parent { } + +class SimpleChild extends Parent { } + +class GenericChild extends ArrayList { } + +class MultiGenericChild extends HashMap { } + +class DeepGenericChild extends ArrayList> { } + +class ParameterizedChild extends ArrayList { } diff --git a/parser/src/test-data/java/type-references/test-type-param-bounds.java b/parser/src/test-data/java/type-references/test-type-param-bounds.java new file mode 100644 index 000000000..e60bc945d --- /dev/null +++ b/parser/src/test-data/java/type-references/test-type-param-bounds.java @@ -0,0 +1,16 @@ +package com.test.typereferences; + +import java.io.Serializable; +import java.util.List; + +class SimpleBox { } + +class MultipleBounds & Serializable> { } + +class ComplexBounds< + T extends List, + U extends Comparable & Cloneable, + V extends Number & Serializable +> { } + +class RecursiveBound> { } diff --git a/parser/src/test-data/java/type-references/test-wildcards.java b/parser/src/test-data/java/type-references/test-wildcards.java new file mode 100644 index 000000000..2e9ec2128 --- /dev/null +++ b/parser/src/test-data/java/type-references/test-wildcards.java @@ -0,0 +1,17 @@ +package com.test.typereferences; + +import java.util.List; + +class UnboundedWildcard> { } + +class UpperBoundedWildcard> { } + +class LowerBoundedWildcard> { } + +class ComplexWildcard>> { } + +class MultipleWildcards< + T extends List, + U extends List, + V extends List +> { } diff --git a/parser/src/test-data/java/type-registry/AnonymousClassNames.java b/parser/src/test-data/java/type-registry/AnonymousClassNames.java new file mode 100644 index 000000000..ddeb76d6c --- /dev/null +++ b/parser/src/test-data/java/type-registry/AnonymousClassNames.java @@ -0,0 +1,61 @@ +package com.axiomcode.test.typeregistry; + +import java.util.Comparator; + +/** + * Acceptance fixture for anonymous class naming. + * + * An anonymous class is keyed by the type it extends or implements, as + * `Outer$anon:Runnable`. The previous form numbered them `Outer$N` from the + * running count of every type row emitted for the file, which had two problems: + * the count included the enclosing type, so the first anonymous class was + * `Outer$2`, and adding an unrelated NAMED nested type above renumbered every + * anonymous type below it. + * + * The named nested types below are positioned deliberately: one before the + * anonymous classes and one between them. Under the old scheme they shifted the + * numbering of everything after them, so their presence here is what makes the + * fixture discriminate on stability rather than only on the starting value. + * + * Identity is unaffected by any of this. The row's hash is position-derived, so + * the two anonymous Runnables below are distinct rows that share a name, in the + * same way two nested types can share a flattened qualified name. + */ +public class AnonymousClassNames { + + /** Named nested type declared BEFORE any anonymous class. */ + static class DeclaredFirst { } + + Runnable fieldAnon = new Runnable() { + @Override public void run() { } + }; + + /** Named nested type declared BETWEEN two anonymous classes. */ + static class DeclaredBetween { } + + void methodAnons() { + Runnable a = new Runnable() { + @Override public void run() { } + }; + + // A second anonymous class with the same supertype: same name, distinct row. + Runnable b = new Runnable() { + @Override public void run() { } + }; + + // A qualified, generic supertype keys on the simple name with no type + // arguments, so the key survives an import being rewritten or a type + // argument being added. + Comparator c = new java.util.Comparator() { + @Override public int compare(String x, String y) { return 0; } + }; + + // extends Object rather than implementing an interface. + Object d = new Object() { }; + + a.run(); + b.run(); + c.compare("", ""); + d.toString(); + } +} diff --git a/parser/src/test-data/java/type-registry/AnonymousLocalPatterns.java b/parser/src/test-data/java/type-registry/AnonymousLocalPatterns.java new file mode 100644 index 000000000..0bbc1435a --- /dev/null +++ b/parser/src/test-data/java/type-registry/AnonymousLocalPatterns.java @@ -0,0 +1,302 @@ +package com.inventory.auth.examples; + +import java.util.*; + +import com.inventory.auth.domain.Session; +import com.inventory.auth.domain.User; + +import java.io.Serializable; +/** + * Test file for anonymous and local class patterns including: + * - Anonymous classes with method overrides + * - Local classes with generics + * - Anonymous generic interfaces + */ +public class AnonymousLocalPatterns { + + public void anonymousClassMethods() { + Comparable comp = new Comparable() { + @Override + public int compareTo(String o) { + return 0; + } + }; + comp.compareTo("test"); + } + + public void tempFunc(final Session session) { + } + + public void methodExec(final User user) { + } + + public > T anonymousWithGeneric(T a, T b) { + Comparator comparator = new Comparator() { + @Override + public int compare(T o1, T o2) { + return o1.compareTo(o2); + } + }; + return comparator.compare(a, b) >= 0 ? a : b; + } + + public void anonymousListImplementation() { + List list = new ArrayList() { + @Override + public boolean add(String e) { + System.out.println("Adding: " + e); + return super.add(e); + } + + @Override + public String get(int index) { + System.out.println("Getting: " + index); + return super.get(index); + } + }; + list.add("test"); + } + + public void methodWithLocalClass(T value) { + class LocalClass { + private T outerValue; + private U innerValue; + + public LocalClass(T outer, U inner) { + this.outerValue = outer; + this.innerValue = inner; + } + + public T getOuterValue() { + return outerValue; + } + + public U getInnerValue() { + return innerValue; + } + + public Map localMethod(U u, V v) { + Map map = new HashMap<>(); + map.put(u, v); + return map; + } + } + + LocalClass local = new LocalClass<>(value, "test"); + local.getOuterValue(); + } + + public void localClassWithBounds() { + class BoundedLocal> { + public T max(T a, T b) { + return a.compareTo(b) >= 0 ? a : b; + } + + public U boundedMethod(U value) { + return value; + } + } + + BoundedLocal local = new BoundedLocal<>(); + local.max(1, 2); + } + + public void nestedLocalClasses(T value) { + class OuterLocal { + class InnerLocal { + public Map> tripleNesting(T t, U u, V v) { + Map inner = new HashMap<>(); + inner.put(u, v); + Map> outer = new HashMap<>(); + outer.put(t, inner); + return outer; + } + } + } + } + + public void anonymousMapImplementation() { + Map map = new HashMap() { + @Override + public Integer put(String key, Integer value) { + System.out.println("Putting: " + key + " = " + value); + return super.put(key, value); + } + + @Override + public Integer get(Object key) { + Integer val = super.get(key); + System.out.println("Getting: " + key + " = " + val); + return val; + } + }; + map.put("key", 1); + } + + public void localClassCapturingGeneric(T value) { + class CapturingLocal { + public T getValue() { + return value; + } + + public Map createMap(U u) { + Map map = new HashMap<>(); + map.put(value, u); + return map; + } + } + + CapturingLocal local = new CapturingLocal(); + local.getValue(); + } + + public void anonymousAbstractClass() { + abstract class AbstractLocal { + public abstract T process(T input); + } + + AbstractLocal impl = new AbstractLocal() { + @Override + public String process(String input) { + return input.toUpperCase(); + } + }; + impl.process("test"); + } + + public Comparator anonymousComparatorReturn() { + return new Comparator() { + @Override + public int compare(T o1, T o2) { + return 0; + } + }; + } + + public void localClassInLoop() { + for (int i = 0; i < 3; i++) { + final int index = i; + class LoopLocal { + public int getIndex() { + return index; + } + + public Map createIndexedMap(U value) { + Map map = new HashMap<>(); + map.put(index, value); + return map; + } + } + + LoopLocal local = new LoopLocal<>(); + local.getIndex(); + } + } + + public > void anonymousWithMultipleBounds() { + Comparator comp = new Comparator() { + @Override + public int compare(T o1, T o2) { + return o1.compareTo(o2); + } + + public & Serializable> U customMethod(U value) { + return value; + } + }; + comp.compare(null, null); + } + + public T concreteTypeBound(T example, String value) { + example.publicInstanceMethod(); + return example; + } + + public void anonymousWithConcreteType(AllMethodExamples example) { + Comparator comparator = new Comparator() { + @Override + public int compare(AllMethodExamples o1, AllMethodExamples o2) { + return o1.toString().compareTo(o2.toString()); + } + }; + comparator.compare(example, example); + } + + public void localClassWithConcreteTypeBound(T example) { + class LocalProcessor { + private U value; + + public LocalProcessor(U value) { + this.value = value; + } + + public U process() { + value.publicInstanceMethod(); + return value; + } + + public Map createMapping(V derived) { + Map map = new HashMap<>(); + map.put(value, derived); + return map; + } + } + + LocalProcessor processor = new LocalProcessor<>(example); + processor.process(); + } + + public List anonymousListOfConcreteType() { + return new ArrayList() { + @Override + public boolean add(AllMethodExamples e) { + e.publicInstanceMethod(); + return super.add(e); + } + }; + } + + public Map genericWithConcreteTypeBound(T key, U value) { + class MapBuilder { + public Map build(K k, V v) { + k.publicInstanceMethod(); + Map result = new HashMap<>(); + result.put(k, v); + return result; + } + } + + MapBuilder builder = new MapBuilder<>(); + return builder.build(key, value); + } + + public Comparator createComparatorForConcreteType() { + return new Comparator() { + @Override + public int compare(T o1, T o2) { + o1.publicInstanceMethod(); + o2.publicInstanceMethod(); + return 0; + } + }; + } + + public void nestedAnonymousWithConcreteType(AllMethodExamples outer) { + List list = new ArrayList() { + @Override + public AllMethodExamples get(int index) { + AllMethodExamples result = super.get(index); + result.publicInstanceMethod(); + return result; + } + }; + + Map map = new HashMap() { + @Override + public AllMethodExamples put(String key, AllMethodExamples value) { + value.publicInstanceMethod(); + return super.put(key, value); + } + }; + } +} diff --git a/parser/src/test-data/java/type-registry/ArrayGenericPatterns.java b/parser/src/test-data/java/type-registry/ArrayGenericPatterns.java new file mode 100644 index 000000000..7efdb7cb1 --- /dev/null +++ b/parser/src/test-data/java/type-registry/ArrayGenericPatterns.java @@ -0,0 +1,120 @@ +package com.inventory.auth.examples; + +import java.lang.reflect.Array; +import java.util.List; +import java.util.ArrayList; + +/** + * Test file for array and generic combination patterns including: + * - Generic array creation + * - Multi-dimensional generic arrays + * - Array of wildcards + * - Generic varargs with arrays + */ +public class ArrayGenericPatterns { + + @SuppressWarnings("unchecked") + public T[] genericArrayCreation(Class clazz, int size) { + return (T[]) Array.newInstance(clazz, size); + } + + public T[][] twoDimensionalGeneric(T[][] input) { + return input; + } + + @SuppressWarnings("unchecked") + public T[][] create2DArray(Class clazz, int rows, int cols) { + return (T[][]) Array.newInstance(clazz, rows, cols); + } + + public T[][][] threeDimensionalGeneric(T[][][] input) { + return input; + } + + public List[] arrayOfWildcardLists() { + @SuppressWarnings("unchecked") + List[] array = (List[]) new List[10]; + return array; + } + + public List[] arrayOfBoundedWildcards(int size) { + @SuppressWarnings("unchecked") + List[] array = (List[]) new List[size]; + return array; + } + + public List[] arrayOfLowerBoundedWildcards(int size) { + @SuppressWarnings("unchecked") + List[] array = (List[]) new List[size]; + return array; + } + + @SafeVarargs + public final List[] arrayOfGenericLists(List... lists) { + return lists; + } + + @SafeVarargs + public final T[][] varargsMultiDimensional(T[]... arrays) { + return arrays; + } + + public > T[] sortedArrayCreation(Class clazz, int size) { + @SuppressWarnings("unchecked") + T[] array = (T[]) Array.newInstance(clazz, size); + return array; + } + + @SafeVarargs + public final T[] numberArrayFromVarargs(T... numbers) { + return numbers; + } + + public T[] concatenateArrays(T[] first, T[] second) { + @SuppressWarnings("unchecked") + T[] result = (T[]) Array.newInstance( + first.getClass().getComponentType(), + first.length + second.length + ); + System.arraycopy(first, 0, result, 0, first.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } + + public List listOfArrays(int size) { + return new ArrayList<>(); + } + + public T[][] transposeMatrix(T[][] matrix, Class clazz) { + if (matrix.length == 0) return matrix; + int rows = matrix.length; + int cols = matrix[0].length; + + @SuppressWarnings("unchecked") + T[][] transposed = (T[][]) Array.newInstance(clazz, cols, rows); + + for (int i = 0; i < rows; i++) { + for (int j = 0; j < cols; j++) { + transposed[j][i] = matrix[i][j]; + } + } + return transposed; + } + + @SafeVarargs + public static T[] staticGenericVarargs(T... elements) { + return elements; + } + + public T[] filterArray(T[] array, Class clazz) { + List filtered = new ArrayList<>(); + for (T elem : array) { + if (elem != null) { + filtered.add(elem); + } + } + @SuppressWarnings("unchecked") + T[] result = (T[]) Array.newInstance(clazz, filtered.size()); + return filtered.toArray(result); + } +} diff --git a/parser/src/test-data/java/type-registry/InheritancePatterns.java b/parser/src/test-data/java/type-registry/InheritancePatterns.java new file mode 100644 index 000000000..1833043af --- /dev/null +++ b/parser/src/test-data/java/type-registry/InheritancePatterns.java @@ -0,0 +1,245 @@ +package com.inventory.auth.examples; + +import java.util.*; + +/** + * Test file for inheritance patterns including: + * - Bridge methods (compiler generated) + * - Covariant return types + * - Generic inheritance hierarchies + * - Method overriding with generics + */ +public class InheritancePatterns { + + public static class Parent { + public Number getValue() { + return 1; + } + + public List getList() { + return new ArrayList<>(); + } + + public Object process(Object input) { + return input; + } + } + + public static class Child extends Parent { + @Override + public Integer getValue() { + return 42; + } + + @Override + public ArrayList getList() { + return new ArrayList<>(); + } + + @Override + public String process(Object input) { + return input.toString(); + } + } + + public static class GrandChild extends Child { + @Override + public Integer getValue() { + return 100; + } + } + + public static abstract class GenericParent { + public abstract T getValue(); + + public abstract List getList(); + + public T process(T input) { + return input; + } + + public Map createMap(T key, U value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + } + + public static class GenericChild extends GenericParent { + @Override + public String getValue() { + return "value"; + } + + @Override + public List getList() { + return new ArrayList<>(); + } + + @Override + public String process(String input) { + return input.toUpperCase(); + } + } + + public static class GenericChild2 extends GenericParent { + private T value; + + @Override + public T getValue() { + return value; + } + + @Override + public List getList() { + List list = new ArrayList<>(); + list.add(value); + return list; + } + } + + public static class BoundedGenericChild extends GenericParent { + private T value; + + @Override + public T getValue() { + return value; + } + + @Override + public List getList() { + return Collections.singletonList(value); + } + + @Override + public T process(T input) { + return input; + } + } + + public static class CustomList extends ArrayList { + @Override + public E get(int index) { + System.out.println("Getting index: " + index); + return super.get(index); + } + + @Override + public boolean add(E e) { + System.out.println("Adding: " + e); + return super.add(e); + } + } + + public static class StringList extends CustomList { + @Override + public String get(int index) { + return super.get(index); + } + + @Override + public boolean add(String e) { + System.out.println("StringList adding: " + e); + return super.add(e); + } + } + + public interface GenericInterface { + T process(T input); + + List processList(List input); + } + + public static class ConcreteImplementation implements GenericInterface { + @Override + public String process(String input) { + return input.toLowerCase(); + } + + @Override + public List processList(List input) { + List result = new ArrayList<>(); + for (String s : input) { + result.add(s.toLowerCase()); + } + return result; + } + } + + public static class GenericImplementation implements GenericInterface { + @Override + public T process(T input) { + return input; + } + + @Override + public List processList(List input) { + return new ArrayList<>(input); + } + } + + public static abstract class MultiLevelParent { + public abstract Map getMap(); + + public abstract T getKey(); + + public abstract U getValue(); + } + + public static abstract class MultiLevelMiddle extends MultiLevelParent { + @Override + public String getValue() { + return "middle"; + } + } + + public static class MultiLevelChild extends MultiLevelMiddle { + @Override + public Map getMap() { + return new HashMap<>(); + } + + @Override + public Integer getKey() { + return 42; + } + } + + public static class CovariantArrays { + public Number[] getNumbers() { + return new Number[]{1, 2, 3}; + } + } + + public static class CovariantArraysChild extends CovariantArrays { + @Override + public Integer[] getNumbers() { + return new Integer[]{1, 2, 3}; + } + } + + public static class GenericHierarchy> { + public T max(T a, T b) { + return a.compareTo(b) >= 0 ? a : b; + } + + public List sort(List list) { + List sorted = new ArrayList<>(list); + Collections.sort(sorted); + return sorted; + } + } + + public static class StringHierarchy extends GenericHierarchy { + @Override + public String max(String a, String b) { + return super.max(a, b); + } + + @Override + public List sort(List list) { + List sorted = super.sort(list); + return sorted; + } + } +} diff --git a/parser/src/test-data/java/type-registry/InterfacePatterns.java b/parser/src/test-data/java/type-registry/InterfacePatterns.java new file mode 100644 index 000000000..31a2937ff --- /dev/null +++ b/parser/src/test-data/java/type-registry/InterfacePatterns.java @@ -0,0 +1,184 @@ +package com.inventory.auth.examples; + +import java.util.*; +import java.io.Serializable; + +/** + * Test file for interface patterns including: + * - Default methods with generics + * - Static interface methods + * - Multiple interface inheritance + */ +public class InterfacePatterns { + + public interface GenericInterface { + T getValue(); + + void setValue(T value); + + default Map defaultGenericMethod(T t, U u) { + Map map = new HashMap<>(); + map.put(t, u); + return map; + } + + default T getOrDefault(T defaultValue) { + T current = getValue(); + return current != null ? current : defaultValue; + } + + static GenericInterface create(T initialValue) { + return new GenericInterface() { + private T value = initialValue; + + @Override + public T getValue() { + return value; + } + + @Override + public void setValue(T value) { + this.value = value; + } + }; + } + + static Map staticHelper(T key, U value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + } + + public interface BoundedGenericInterface> { + T compare(T a, T b); + + default T max(T a, T b) { + return a.compareTo(b) >= 0 ? a : b; + } + + default U boundedDefault(U value) { + return value; + } + + static > T staticMax(T a, T b) { + return a.compareTo(b) >= 0 ? a : b; + } + } + + public interface MultipleTypeParamsInterface { + Map getMap(); + + default Map> nestedDefault(K key, V value, U extra) { + Map inner = new HashMap<>(); + inner.put(value, extra); + Map> outer = new HashMap<>(); + outer.put(key, inner); + return outer; + } + + static Map staticCreate() { + return new HashMap<>(); + } + } + + public interface ExtendingInterface extends GenericInterface { + void additionalMethod(T value); + + default List defaultListMethod(T... elements) { + List list = new ArrayList<>(); + for (T elem : elements) { + list.add(elem); + } + return list; + } + + @Override + default T getOrDefault(T defaultValue) { + return GenericInterface.super.getOrDefault(defaultValue); + } + } + + public interface IntersectionInterface> { + T process(T input); + + default T processWithDefault(T input, T defaultValue) { + return input != null ? input : defaultValue; + } + + default U genericProcess(U value) { + return value; + } + } + + public static class ImplementingClass implements GenericInterface { + private T value; + + @Override + public T getValue() { + return value; + } + + @Override + public void setValue(T value) { + this.value = value; + } + + public Map> customMethod(T key, U... values) { + Map> map = new HashMap<>(); + List list = new ArrayList<>(); + for (U val : values) { + list.add(val); + } + map.put(key, list); + return map; + } + } + + public static class MultipleInterfaces + implements GenericInterface, MultipleTypeParamsInterface { + private T value; + private Map map = new HashMap<>(); + + @Override + public T getValue() { + return value; + } + + @Override + public void setValue(T value) { + this.value = value; + } + + @Override + public Map getMap() { + return map; + } + } + + public interface FunctionalWithGenerics { + R apply(T input); + + default FunctionalWithGenerics andThen(FunctionalWithGenerics after) { + return (T t) -> after.apply(apply(t)); + } + + static FunctionalWithGenerics identity() { + return t -> t; + } + } + + public interface WildcardInterface { + List getWildcardList(); + + Map getWildcardMap(); + + default List defaultWildcard(T... elements) { + List list = new ArrayList<>(); + for (T elem : elements) { + list.add(elem); + } + return list; + } + } +} diff --git a/parser/src/test-data/java/type-registry/Java17PlusTypes.java b/parser/src/test-data/java/type-registry/Java17PlusTypes.java new file mode 100644 index 000000000..12572efe6 --- /dev/null +++ b/parser/src/test-data/java/type-registry/Java17PlusTypes.java @@ -0,0 +1,625 @@ +package com.inventory.auth.examples3; + +import java.io.IOException; +import java.io.Serializable; +import java.lang.annotation.*; +import java.util.*; +import java.util.concurrent.Callable; +import java.util.function.*; + +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +/** + * Spring REST Controller - Generics Torture Test for Method Parameters + * + * This controller demonstrates EXTREME generic complexity on METHOD PARAMETERS: + * - Type parameter annotations (@TypeAnno, @Validated, @MultiValidator) + * - Recursive bounds (T extends Comparable) + * - Intersection types (T extends A & B & C) + * - Annotated bounds (@TypeAnno on bounds) + * - Wildcard parameters (?, extends, super) + * - Method-level type parameter shadowing + * - Nested generics in parameters + * - Java 17+ records and sealed types + * - Spring REST endpoint patterns with complex generics + */ +@RestController +@RequestMapping("/api/v1/generics-torture") +@SuppressWarnings({"unchecked", "rawtypes"}) +public class Java17PlusTypes<@TypeAnno("ControllerLevel") T extends Comparable> { + + // =================================================================== + // ENDPOINT 1: Simple Annotated Type Parameter on Method + // =================================================================== + @GetMapping("/simple/{id}") + public <@TypeAnno("SimpleMethod") E> ResponseEntity simpleAnnotated( + @PathVariable String id, + @RequestBody E element) { + return ResponseEntity.ok(element); + } + + // =================================================================== + // ENDPOINT 2: Recursive Type Bound - T extends Comparable + // Classic recursive generic pattern on parameter + // =================================================================== + @PostMapping("/recursive-bound") + public <@TypeAnno("RecursiveBound") R extends Comparable> + ResponseEntity recursiveBound( + @RequestBody R value, + @RequestParam(required = false) R compareWith) { + if (compareWith != null && value.compareTo(compareWith) > 0) { + return ResponseEntity.ok(value); + } + return ResponseEntity.ok(compareWith != null ? compareWith : value); + } + + // =================================================================== + // ENDPOINT 3: Intersection Type Bounds (Multiple Bounds with &) + // T extends Number & Comparable & Serializable + // =================================================================== + @PostMapping("/intersection-bounds") + public <@TypeAnno("Intersection") I extends Number & Comparable & Serializable> + ResponseEntity> intersectionBounds( + @RequestBody List numbers, + @RequestParam boolean sort) throws IOException { + if (sort) { + Collections.sort(numbers); + } + return ResponseEntity.ok(numbers); + } + + // =================================================================== + // ENDPOINT 4: Annotated Bounds - Annotation ON the bound type + // @TypeAnno appears on the BOUND, not just the parameter + // =================================================================== + @PostMapping("/annotated-bounds") + public & Serializable> + ResponseEntity annotatedBounds( + @RequestBody R value, + @RequestHeader(value = "X-Multiplier", defaultValue = "1") int multiplier) { + return ResponseEntity.ok(value); + } + + // =================================================================== + // ENDPOINT 5: Distinction - Annotation ON parameter vs ON bound + // Shows @TypeAnno on BOTH the parameter AND the bound + // =================================================================== + @GetMapping("/annotation-distinction/{type}") + public < + @TypeAnno("OnParameter") P extends @TypeAnno("OnBound") Number + > ResponseEntity> annotationDistinction( + @PathVariable String type, + @RequestBody P param, + @RequestParam(defaultValue = "default") String key) { + return ResponseEntity.ok(Map.of(key, param)); + } + + // =================================================================== + // ENDPOINT 6: Class Reference in Annotation Argument + // @Validated contains Class references in annotation args + // =================================================================== + @PostMapping("/validated-container") + public < + @Validated(validator = StringValidator.class) S, + @Validated(validator = NumberValidator.class, groups = ValidationGroup.class) N + > ResponseEntity> validatedContainer( + @RequestBody S stringValue, + @RequestParam N numberValue) { + return ResponseEntity.ok(new Container<>(stringValue, numberValue)); + } + + // =================================================================== + // ENDPOINT 7: Array of Class References in Annotation + // @MultiValidator uses Class[] array + // =================================================================== + @PostMapping("/multi-validator") + public < + @MultiValidator(validators = {QuickValidator.class, FullValidator.class, DetailedValidator.class}) E + > ResponseEntity> multiValidator( + @RequestBody List elements, + @RequestParam(defaultValue = "quick") String validationType) { + return ResponseEntity.ok(elements); + } + + // =================================================================== + // ENDPOINT 8: Nested Annotations on Type Parameters + // @Wrapper contains inner @TypeAnno + // =================================================================== + @PostMapping("/nested-annotations") + public < + @Wrapper(inner = @TypeAnno("NestedValue")) K + > ResponseEntity> nestedAnnotations( + @RequestBody K key, + @RequestParam Object value) { + return ResponseEntity.ok(Map.of(key, value)); + } + + // =================================================================== + // ENDPOINT 9: Enum Constant in Annotation Argument + // @RetentionTest uses enum value + // =================================================================== + @GetMapping("/enum-test") + public < + @RetentionTest(policy = RetentionPolicy.RUNTIME) R + > ResponseEntity enumTest( + @RequestParam R value) { + return ResponseEntity.ok(value); + } + + // =================================================================== + // ENDPOINT 10: Mixed Annotations - Simple + Complex + // =================================================================== + @PostMapping("/mixed-annotations") + public < + @TypeAnno("Simple") A, + @Validated(validator = CustomValidator.class, + groups = ValidationGroup.class, + message = "Invalid") C + > ResponseEntity> mixedAnnotations( + @RequestBody A first, + @RequestParam C second) { + return ResponseEntity.ok(new Pair<>(first, second)); + } + + // =================================================================== + // ENDPOINT 11: Multiple Method-Level Type Parameters with Annotations + // =================================================================== + @PostMapping("/process-validation") + public < + @TypeAnno("MethodLevel") M, + @Validated(validator = MethodValidator.class) V + > ResponseEntity processWithValidation( + @RequestBody V value, + @RequestParam M metadata) { + return ResponseEntity.ok(metadata); + } + + // =================================================================== + // ENDPOINT 12: Wildcard Parameters - Unbounded + // =================================================================== + @PostMapping("/wildcard-unbounded") + public ResponseEntity wildcardUnbounded( + @RequestBody List items) { + return ResponseEntity.ok(items.size()); + } + + // =================================================================== + // ENDPOINT 13: Wildcard Parameters - Upper Bounded + // =================================================================== + @PostMapping("/wildcard-upper") + public ResponseEntity wildcardUpper( + @RequestBody List numbers) { + double sum = numbers.stream() + .mapToDouble(Number::doubleValue) + .sum(); + return ResponseEntity.ok(sum); + } + + // =================================================================== + // ENDPOINT 14: Wildcard Parameters - Lower Bounded + // =================================================================== + @PostMapping("/wildcard-lower") + public ResponseEntity wildcardLower( + @RequestBody List container, + @RequestParam int value) { + container.add(value); + return ResponseEntity.ok().build(); + } + + // =================================================================== + // ENDPOINT 15: Nested Wildcard in Parameters + // =================================================================== + @PostMapping("/wildcard-nested") + public ResponseEntity> wildcardNested( + @RequestBody Map> nestedMap) { + return ResponseEntity.ok(Collections.unmodifiableMap(nestedMap)); + } + + // =================================================================== + // ENDPOINT 16: Generic Method with Wildcard AND Type Parameter + // Combines both patterns + // =================================================================== + @PostMapping("/combined-wildcard-generic") + public <@TypeAnno("CombinedWildcard") T> ResponseEntity> + combinedWildcardGeneric( + @RequestBody T seed, + @RequestParam int count) { + List result = new ArrayList<>(); + for (int i = 0; i < count; i++) { + result.add(seed); + } + return ResponseEntity.ok(result); + } + + // =================================================================== + // ENDPOINT 17: Shadowing - Method Type Parameter Shadows Class Type + // The controller has , this method ALSO has (different T!) + // =================================================================== + @PostMapping("/shadow-test") + public <@TypeAnno("ShadowedT") T> ResponseEntity shadowTest( + @RequestBody T localT) { + // This 'T' is NOT the same as the class-level 'T' + return ResponseEntity.ok(localT); + } + + // =================================================================== + // ENDPOINT 18: Multiple Shadowed Parameters + // =================================================================== + @PostMapping("/multi-shadow") + public < + @TypeAnno("Shadow1") T, + @TypeAnno("Shadow2") U, + @TypeAnno("Shadow3") V + > ResponseEntity> multiShadow( + @RequestBody T first, + @RequestParam U second, + @RequestHeader("X-Third") V third) { + return ResponseEntity.ok(new Triple<>(first, second, third)); + } + + // =================================================================== + // ENDPOINT 19: Varargs with Generics + // =================================================================== + @SafeVarargs + @PostMapping("/varargs-generic") + public final <@TypeAnno("Varargs") E> ResponseEntity> varargsGeneric( + @RequestBody E first, + @RequestParam E... rest) { + List result = new ArrayList<>(); + result.add(first); + result.addAll(Arrays.asList(rest)); + return ResponseEntity.ok(result); + } + + // =================================================================== + // ENDPOINT 20: Complex Nested Generics in Parameters + // =================================================================== + @PostMapping("/complex-nested") + public < + @TypeAnno("OuterKey") K, + @TypeAnno("InnerValue") V + > ResponseEntity>>> complexNested( + @RequestBody Map>> complexData) { + return ResponseEntity.ok(complexData); + } + + // =================================================================== + // ENDPOINT 21: Functional Interface Parameters with Generics + // =================================================================== + @PostMapping("/functional-params") + public < + @TypeAnno("Input") I, + @TypeAnno("Output") O + > ResponseEntity> functionalParams( + @RequestBody List inputs, + @RequestParam Function transformer) { + List outputs = new ArrayList<>(); + for (I input : inputs) { + outputs.add(transformer.apply(input)); + } + return ResponseEntity.ok(outputs); + } + + // =================================================================== + // ENDPOINT 22: Callable/Supplier with Generic Return + // =================================================================== + @PostMapping("/callable-supplier") + public <@TypeAnno("Result") R> ResponseEntity callableSupplier( + @RequestBody Supplier supplier, + @RequestParam(defaultValue = "false") boolean useCallable) throws Exception { + R result = supplier.get(); + return ResponseEntity.ok(result); + } + + // =================================================================== + // ENDPOINT 23: BiFunction with Three Type Parameters + // =================================================================== + @PostMapping("/bi-function") + public < + @TypeAnno("First") F, + @TypeAnno("Second") S, + @TypeAnno("Result") R + > ResponseEntity biFunction( + @RequestBody F first, + @RequestParam S second, + @RequestHeader("X-Combiner") BiFunction combiner) { + return ResponseEntity.ok(combiner.apply(first, second)); + } + + // =================================================================== + // ENDPOINT 24: Predicate with Generic Test + // =================================================================== + @PostMapping("/predicate-filter") + public <@TypeAnno("Element") E> ResponseEntity> predicateFilter( + @RequestBody List elements, + @RequestParam Predicate filter) { + List filtered = new ArrayList<>(); + for (E element : elements) { + if (filter.test(element)) { + filtered.add(element); + } + } + return ResponseEntity.ok(filtered); + } + + // =================================================================== + // ENDPOINT 25: Consumer with Side Effects + // =================================================================== + @PostMapping("/consumer-action") + public <@TypeAnno("Item") I> ResponseEntity consumerAction( + @RequestBody List items, + @RequestParam Consumer action) { + items.forEach(action); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + // =================================================================== + // ENDPOINT 26: Recursive with Wildcard - Ultra Complex + // =================================================================== + @PostMapping("/recursive-wildcard") + public <@TypeAnno("RecursiveWild") RW extends Comparable> + ResponseEntity recursiveWildcard( + @RequestBody RW value, + @RequestParam List candidates) { + RW max = value; + for (RW candidate : candidates) { + if (candidate.compareTo(max) > 0) { + max = candidate; + } + } + return ResponseEntity.ok(max); + } + + // =================================================================== + // ENDPOINT 27: Self-Referential with Builder Pattern + // =================================================================== + @PostMapping("/self-referential-builder") + public <@TypeAnno("Builder") B extends Builder> ResponseEntity selfReferentialBuilder( + @RequestBody B builder, + @RequestParam String configValue) { + builder.configure(configValue); + return ResponseEntity.ok(builder); + } + + // =================================================================== + // ENDPOINT 28: Enum Type Parameter + // =================================================================== + @PostMapping("/enum-type") + public <@TypeAnno("EnumType") E extends Enum> ResponseEntity enumType( + @RequestBody E enumValue) { + return ResponseEntity.ok(enumValue); + } + + // =================================================================== + // ENDPOINT 29: Array Parameter with Generics + // =================================================================== + @PostMapping("/array-generic") + public <@TypeAnno("ArrayElement") A> ResponseEntity> arrayGeneric( + @RequestBody A[] array) { + return ResponseEntity.ok(Arrays.asList(array)); + } + + // =================================================================== + // ENDPOINT 30: Multiple Complex Bounds Combined + // =================================================================== + @PostMapping("/mega-complex") + public < + @TypeAnno("MegaComplex") + MC extends @TypeAnno("BoundLevel1") Number + & Comparable + & @TypeAnno("BoundLevel2") Serializable + & Cloneable + > ResponseEntity megaComplex( + @RequestBody MC value, + @RequestParam(defaultValue = "process") String action, + @RequestHeader(value = "X-Metadata", required = false) String metadata) { + return ResponseEntity.ok(value); + } + + // =================================================================== + // JAVA 17+ FEATURES: Records with Generics + // =================================================================== + + public record Container(S string, N number) { + public <@TypeAnno("RecordMethod") R> R transform(Function mapper) { + return mapper.apply(string); + } + } + + public record Pair(A first, B second) { + public static Pair of(X x, Y y) { + return new Pair<>(x, y); + } + } + + public record Triple(T first, U second, V third) { + public <@TypeAnno("TripleMap") R> Triple mapFirst(Function mapper) { + return new Triple<>(mapper.apply(first), second, third); + } + } + + // =================================================================== + // JAVA 17+ FEATURES: Sealed Interfaces with Generics + // =================================================================== + + public sealed interface Result<@TypeAnno("ResultType") T> + permits Success, Failure { + T getValue(); + boolean isSuccess(); + } + + public record Success(T value) implements Result { + @Override + public T getValue() { return value; } + + @Override + public boolean isSuccess() { return true; } + } + + public record Failure(T defaultValue, String error) implements Result { + @Override + public T getValue() { return defaultValue; } + + @Override + public boolean isSuccess() { return false; } + } + + // =================================================================== + // ENDPOINT 31: Sealed Type Result Pattern + // =================================================================== + @PostMapping("/sealed-result") + public <@TypeAnno("SealedResult") SR> ResponseEntity> sealedResult( + @RequestBody SR value, + @RequestParam(defaultValue = "false") boolean fail) { + if (fail) { + return ResponseEntity.ok(new Failure<>(value, "Simulated failure")); + } + return ResponseEntity.ok(new Success<>(value)); + } + + // =================================================================== + // Nested Class with Shadowing + // =================================================================== + + public class InnerShadow<@TypeAnno("InnerOuter") T> { + + @PostMapping("/inner-shadow") + public <@TypeAnno("InnerMethod") T> ResponseEntity innerMethod( + @RequestBody T item) { + // This T shadows both the outer class T AND the InnerShadow T + return ResponseEntity.ok(item); + } + + @PostMapping("/inner-mixed") + public ResponseEntity innerMixed( + @RequestBody U specific, + @RequestParam T general) { + // U extends the InnerShadow's T (not the outer class T) + return ResponseEntity.ok(specific); + } + } + + // =================================================================== + // Static Method - CANNOT use class-level T + // Must define its own type parameters + // =================================================================== + + @GetMapping("/static-generic") + public static <@TypeAnno("StaticMethod") ST> ResponseEntity staticGeneric( + @RequestParam ST input) { + // This ST is completely independent from any class-level generics + return ResponseEntity.ok(input); + } + + // =================================================================== + // Interface for Builder Pattern Testing + // =================================================================== + + public interface Builder> { + B configure(String value); + } + + // =================================================================== + // ENDPOINT 32: Class Type Parameter with Reflection + // =================================================================== + @PostMapping("/class-type") + public <@TypeAnno("ClassType") CT> ResponseEntity classType( + @RequestBody Class clazz, + @RequestParam Map params) throws Exception { + CT instance = clazz.getDeclaredConstructor().newInstance(); + return ResponseEntity.ok(instance); + } + + // =================================================================== + // ENDPOINT 33: Optional with Generics + // =================================================================== + @PostMapping("/optional-generic") + public <@TypeAnno("Optional") OT> ResponseEntity optionalGeneric( + @RequestBody Optional optionalValue, + @RequestParam OT defaultValue) { + return ResponseEntity.ok(optionalValue.orElse(defaultValue)); + } + + // =================================================================== + // ENDPOINT 34: Stream Operations with Generics + // =================================================================== + @PostMapping("/stream-operations") + public < + @TypeAnno("StreamInput") SI, + @TypeAnno("StreamOutput") SO + > ResponseEntity> streamOperations( + @RequestBody List inputs, + @RequestParam Function mapper, + @RequestHeader(value = "X-Filter", required = false) Predicate filter) { + var stream = inputs.stream().map(mapper); + if (filter != null) { + stream = stream.filter(filter); + } + return ResponseEntity.ok(stream.toList()); + } + + // =================================================================== + // ENDPOINT 35: Collector with Complex Generics + // =================================================================== + @PostMapping("/collector-complex") + public < + @TypeAnno("Source") S, + @TypeAnno("Target") TG + > ResponseEntity>> collectorComplex( + @RequestBody List sources, + @RequestParam Function classifier) { + Map> grouped = new HashMap<>(); + for (S source : sources) { + TG key = classifier.apply(source); + grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(source); + } + return ResponseEntity.ok(grouped); + } +} + +// ============================================================================= +// ANNOTATION DEFINITIONS +// Identical to GenericsTortureTest for consistency +// ============================================================================= + +@Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) +@interface TypeAnno { + String value(); +} + +@Target(ElementType.TYPE_PARAMETER) +@interface Validated { + Class validator(); + Class groups() default Object.class; + String message() default ""; +} + +@Target(ElementType.TYPE_PARAMETER) +@interface MultiValidator { + Class[] validators(); +} + +@Target(ElementType.TYPE_PARAMETER) +@interface Wrapper { + TypeAnno inner(); +} + +@Target(ElementType.TYPE_PARAMETER) +@interface RetentionTest { + RetentionPolicy policy(); +} + +// ============================================================================= +// VALIDATOR CLASSES (Dummy implementations for CLASS_REFERENCE testing) +// ============================================================================= + +class StringValidator {} +class NumberValidator {} +class ValidationGroup {} +class QuickValidator {} +class FullValidator {} +class DetailedValidator {} +class CustomValidator {} +class MethodValidator {} diff --git a/parser/src/test-data/java/type-registry/LocalTypePlacement.java b/parser/src/test-data/java/type-registry/LocalTypePlacement.java new file mode 100644 index 000000000..2bea7d666 --- /dev/null +++ b/parser/src/test-data/java/type-registry/LocalTypePlacement.java @@ -0,0 +1,85 @@ +package com.axiomcode.test.typeregistry; + +/** + * Acceptance fixture for TypePlacement.LOCAL_PLACEMENT (JLS 14.3). + * + * A local class is not a member of the enclosing type. INNER_PLACEMENT is not a + * vaguer answer for one, it is a different and false one: the vocabulary defines + * INNER_PLACEMENT as a non-static inner class WITH access to an outer instance. + * A local class in a static method or a static initializer has no outer + * instance, and a local record can never have one, so reporting INNER_PLACEMENT + * for those states something the language forbids. + * + * What decides the answer is which scope is reached first walking outward: an + * executable body makes the type local, an enclosing type declaration makes it a + * member. The nested cases at the bottom are here to hold that ordering, because + * a rule that simply asked "is there a method anywhere above me" would relabel + * every member type of a local class as local too. + */ +public class LocalTypePlacement { + + // ── LOCAL_PLACEMENT: every executable scope a local type can sit in ── + + void inInstanceMethod() { + class InMethod { } + } + + static void inStaticMethod() { + // No enclosing instance exists here at all. + class InStaticMethod { } + record InStaticMethodRecord(int x) { } + } + + LocalTypePlacement() { + class InConstructor { } + } + + { + class InInstanceInitializer { } + } + + static { + class InStaticInitializer { } + } + + void inLambda() { + Runnable r = () -> { + class InLambdaBody { } + }; + r.run(); + } + + /** A local record is implicitly static, and still local rather than nested. */ + void localRecord() { + record LocalRecord(int x) { } + } + + /** A local interface and a local enum, both permitted since Java 16. */ + void localInterfaceAndEnum() { + interface LocalContract { } + enum LocalKind { ONE } + } + + // ── Unchanged: types whose nearest enclosing scope really is a class body ── + + /** Non-static member class: genuinely has an outer instance. */ + class RealInner { } + + static class RealStaticNested { } + + interface ImplicitlyStaticContract { } + + record ImplicitlyStaticRecord(int x) { } + + // ── Ordering: a member of a local class is a member, not a local ── + + void nestingInsideALocalClass() { + class Outer { + class MemberOfLocal { } // INNER_PLACEMENT + static class StaticMemberOfLocal { } // STATIC_NESTED_PLACEMENT + void deeper() { + class LocalInsideLocal { } // LOCAL_PLACEMENT again + } + } + } +} diff --git a/parser/src/test-data/java/type-registry/NestedTypePatterns.java b/parser/src/test-data/java/type-registry/NestedTypePatterns.java new file mode 100644 index 000000000..3283f436c --- /dev/null +++ b/parser/src/test-data/java/type-registry/NestedTypePatterns.java @@ -0,0 +1,147 @@ +package com.inventory.auth.examples; + +import java.util.Map; +import java.util.HashMap; +import java.util.List; + +/** + * Test file for nested type patterns including: + * - Outer/inner class type parameter interactions + * - Type parameter shadowing + * - Static inner classes with generics + */ +public class NestedTypePatterns { + + private T outerValue; + + public T getOuterValue() { + return outerValue; + } + + public Map outerMethod(T t, U u) { + Map map = new HashMap<>(); + map.put(t, u); + return map; + } + + public class Inner { + private U innerValue; + + public Map combineTypes(T t, U u) { + Map map = new HashMap<>(); + map.put(t, u); + return map; + } + + public T getOuterType() { + return outerValue; + } + + public U getInnerType() { + return innerValue; + } + + public Map innerGenericMethod(U u, V v) { + Map map = new HashMap<>(); + map.put(u, v); + return map; + } + + public T shadowingMethod(T input) { + return input; + } + + public Map doubleShadowing(T t, U u) { + Map map = new HashMap<>(); + map.put(t, u); + return map; + } + } + + public static class StaticInner { + private V value; + + public V getValue() { + return value; + } + + public Map staticInnerMethod(V v, U u) { + Map map = new HashMap<>(); + map.put(v, u); + return map; + } + + public V shadowingInStatic(V input) { + return input; + } + + public static W staticMethodInStaticClass(W input) { + return input; + } + } + + public class DeepNested { + public class MoreNested { + public Map> tripleTypeParams(T t, U u, V v) { + Map inner = new HashMap<>(); + inner.put(u, v); + Map> outer = new HashMap<>(); + outer.put(t, inner); + return outer; + } + + public Map>> quadrupleNesting(T t, U u, V v, W w) { + Map level3 = new HashMap<>(); + level3.put(v, w); + Map> level2 = new HashMap<>(); + level2.put(u, level3); + Map>> level1 = new HashMap<>(); + level1.put(t, level2); + return level1; + } + } + } + + public static class GenericStaticOuter { + public static class GenericStaticInner { + public Map method(T t, U u) { + Map map = new HashMap<>(); + map.put(t, u); + return map; + } + } + + public class NonStaticInner { + public Map> combineAll(K k, V v, W w) { + Map inner = new HashMap<>(); + inner.put(v, w); + Map> outer = new HashMap<>(); + outer.put(k, inner); + return outer; + } + } + } + + public static abstract class AbstractNested { + public abstract U abstractMethodWithBound(U input); + + public Map concreteMethod(T t, V v) { + Map map = new HashMap<>(); + map.put(t, v); + return map; + } + } + + public static class ConcreteNested extends AbstractNested { + @Override + public U abstractMethodWithBound(U input) { + return input; + } + + public Map extendedMethod(U u, V v) { + Map map = new HashMap<>(); + map.put(u, v); + return map; + } + } +} diff --git a/parser/src/test-data/java/type-registry/test-type-access.java b/parser/src/test-data/java/type-registry/test-type-access.java new file mode 100644 index 000000000..24b485891 --- /dev/null +++ b/parser/src/test-data/java/type-registry/test-type-access.java @@ -0,0 +1,15 @@ +package com.test.typeregistry; + +public class PublicClass { } + +class PackagePrivateClass { } + +public class AccessLevels { + public class PublicInner { } + + protected class ProtectedInner { } + + class PackagePrivateInner { } + + private class PrivateInner { } +} diff --git a/parser/src/test-data/java/type-registry/test-type-categories.java b/parser/src/test-data/java/type-registry/test-type-categories.java new file mode 100644 index 000000000..dceb42268 --- /dev/null +++ b/parser/src/test-data/java/type-registry/test-type-categories.java @@ -0,0 +1,14 @@ +package com.test.typeregistry; + +public class MyClass { } + +interface MyInterface { } + +enum MyEnum { + A, + B +} + +record MyRecord(int x, String name) { } + +@interface MyAnnotation { } diff --git a/parser/src/test-data/java/type-registry/test-type-modifiers.java b/parser/src/test-data/java/type-registry/test-type-modifiers.java new file mode 100644 index 000000000..90bacd5c7 --- /dev/null +++ b/parser/src/test-data/java/type-registry/test-type-modifiers.java @@ -0,0 +1,17 @@ +package com.test.typeregistry; + +public abstract class AbstractBase { } + +public final class FinalClass { } + +public sealed class SealedClass permits SealedSubclass { } + +final class SealedSubclass extends SealedClass { } + +public class Outer { + public static class StaticNested { } + + public static abstract class AbstractStaticNested { } + + public static final class FinalStaticNested { } +} diff --git a/parser/src/test-data/java/type-registry/test-type-placement.java b/parser/src/test-data/java/type-registry/test-type-placement.java new file mode 100644 index 000000000..2e98f89bf --- /dev/null +++ b/parser/src/test-data/java/type-registry/test-type-placement.java @@ -0,0 +1,21 @@ +package com.test.typeregistry; + +public class TopLevel { } + +class PackageLevel { } + +public class OuterClass { + public static class StaticNestedClass { } + + public class InnerClass { } + + public static interface StaticNestedInterface { } + + public interface InnerInterface { } + + public static class DeeplyNested { + public static class DeepStaticNested { } + + public class DeepInner { } + } +} diff --git a/parser/src/test-data/javascript/CORPUS-MANIFEST.json b/parser/src/test-data/javascript/CORPUS-MANIFEST.json new file mode 100644 index 000000000..1fb460592 --- /dev/null +++ b/parser/src/test-data/javascript/CORPUS-MANIFEST.json @@ -0,0 +1,442 @@ +{ + "generatedBy": "js-corpus, phase 3 stratified sweep", + "measuredAgainst": { + "parser": "js-impl@30f3076 (pushed; origin/js == origin/js-impl, fetched 2026-09-13T09:06:12Z)", + "reVerifiedAgainst": "js-impl@30f3076", + "fixtures": "js-fixtures@5ec101a", + "compiler": "typescript@6.0.3", + "priorSweeps": [ + "js-impl@527770c", + "js-impl@24774d7", + "js-impl@a7bf1c3", + "js-impl@1b99d0d", + "js-impl@b9e2676", + "js-impl@ba8846c", + "js-impl@a4e1e24", + "js-impl@e3d45cd", + "js-impl@5095da4" + ] + }, + "heldBack": { + "note": "NAMED AND UNSEEN. Not cloned, not parsed, not counted, not vendored. Identities in the private manifest; the discipline is what publishes.", + "attestation": { + "askedAt": "2026-09-12, before sweep 3, because this corpus carries 248 @flow files and the holdout is Flow throughout — so the one column it was chosen to falsify is the one that had to be cleared", + "checksRun": [ + { + "check": "git remote of every clone", + "result": "31 distinct remotes over 33 clones (two members cloned twice at different tags); 0 match either holdout, case-insensitive", + "canFail": "a synthetic URL naming a holdout matches" + }, + { + "check": "any path component matching either holdout name, case-insensitive", + "result": "0 of 4,549 files" + }, + { + "check": "vendored copy by CONTENT — either holdout's banner comment, repository slug, organisation name or scoped-package prefix", + "result": "0 files", + "canFail": "a synthetic banner comment naming a holdout matches" + }, + { + "check": "every package.json name or dependency naming either holdout", + "result": "0" + }, + { + "check": "attribution of all 248 @flow files", + "result": "two UI-framework-stratum members: 239 and 9. Nothing else." + }, + { + "check": "attribution of all 3,793 SYNTACTIC_FLOW parameters", + "result": "three UI-framework-stratum members: 3,750, 42, 1" + }, + { + "check": "files the ORACLE's ts.Program loaded that the parser never claimed", + "result": "6, all a charting library's dist/ inside an npm tarball; bucketed, never scored. FILE_PARSER_ONLY 0 both directions." + }, + { + "check": "census holdout guard, re-run at sweep 2", + "result": "0 matches; self-test on a synthetic path naming a holdout returns true, so the guard can report a hit" + } + ], + "conclusion": "The Flow population came entirely from two members of the UI-framework stratum. Neither holdout nor any vendored copy of either was cloned, parsed, counted or reachable.", + "summary": "two named holdouts, identities in the private manifest, verified absent by eight checks, five of which have demonstrated synthetic positives" + }, + "count": 2, + "roles": [ + "a Flow-throughout UI framework, v2 source tree — chosen because it can falsify SYNTACTIC_FLOW, which currently rests on one vendor's dialect", + "a CommonJS application server's core — chosen because the development corpus contained no application code" + ], + "identities": [ + {}, + {} + ], + "commitments": { + "$comment": "salted sha256(salt NUL name) over each holdout's repository slug, via manifest-privacy.mjs commit; the salt is beside the private manifest and never published. PRE-REGISTRATION: when a holdout is opened, its slug re-committed with the same salt must equal one of these, which is what proves it is the one named before anyone believed they were finished.", + "algorithm": "sha256(salt + 0x00 + slug), salt >= 16 chars, per manifest-privacy.mjs commitTo()", + "values": [ + "768e3217f4d4f6bd8789e453b9dcdb66caab814a64fa12691c70e74f4d023c77", + "31aece5c3eddbe1d0d6ca6e71b39ad89f4ae2278135689bbc9d8daf5262942d0" + ] + }, + "opened": { + "at": "2026-09-13T08:32Z", + "measuredRef": "origin/js @ 5095da450eccd8077c696641ea401dbcdf00a5e3 (== origin/js-impl), fetched 2026-09-13T08:30:23Z", + "sequence": "materialise -> salted digest -> record (this block, and the private manifest) -> measure -> verify", + "commitmentsMatched": "both slugs re-committed with the private salt equal the two published values; a swapped slug does not", + "digest": { + "salted": true, + "files": 1122, + "sha256": "d3dc03dcab8211185c64e990b8d47efb33f0b47b85967209c5fb4e77a1c0f300", + "byMember": { + "flow-detector holdout": { + "files": 194, + "sha256": "22b3f32e0950f9f97a57e2d3e49af6af4d5483f2a2b91ecae28102fcb42c50b1" + }, + "javascript holdout": { + "files": 928, + "sha256": "1380db4364857302f5296325fb77a0b54041d8fc5fd10b61cd670c6f22c7ef71" + } + } + }, + "verdict": "NOT DONE — one NEW CLASS. The Flow-detector holdout: 161 of 161 pragma files rejected, 0 missed, 0 of 33 pragma-less files rejected, 0 SYNTACTIC_FLOW rows survived, 2 parse gaps both NON_LITERAL_SPECIFIER (known); the vendor's dialect turned out to be one form, `/* @flow */` on line 1 of every file, and the project checks nothing without a pragma, so 'Flow-ness known from the project' never had to be inferred. The JavaScript holdout: 928 files, 13,476 call sites, 0 recall / 0 require-edge / 0 kind / 0 optionality misses; FK 0 dangling over 743,526; call-site<->expression 1:1; type-only isolated; every intra-file link means what its column claims; IR sufficiency 100% on method, field, alias and type flow, member flow short only of computed access (known) and the parameter hop (ruled). Residues, each an INSTANCE OF A KNOWN CLASS: 9 comments (trailing comment inside a bracketed construct — after an opening brace), 21 JSDoc type references (dotted @param chains; @callback declarations), 8 JSDoc import types (three host shapes the comment walker does not visit, appended to the open finding). THE NEW CLASS: 4 duplicate js_import primary keys at three sites — one @type {import()} comment over a variable whose initializer contains nested function DECLARATIONS is minted once per declaration. Zero duplicates existed in 3,589,235 development rows; no question had been asked about a comment re-hosted downward. Fails gate 1. Reproduced in isolation and promoted to verified/jsdoc-import-type-rehosted/. Fix plus one more full sweep follows.", + "verifiedAfterMeasurement": "2026-09-13T08:41Z — all three salted digests recomputed after every instrument had run and equal the values above; the parser worktree was at 5095da4 with a clean status before and after", + "spent": "both holdouts, 2026-09-13. A held-back corpus is held back once; from here the JavaScript holdout is a corpus that already found something and is re-run as one, and the Flow-detector holdout is not re-run at all — see the exposure.", + "openExposure": "PRAGMA-LESS FLOW IS STILL UNTESTED. The Flow-detector holdout was chosen on the premise that its Flow-ness is known from the project rather than the file and that its dialect differs from the two Flow-annotated development members every current Flow number comes from. Measured, the premise did not hold: every one of its 161 Flow files carries `/* @flow */` on line 1 — one spelling — and its .flowconfig checks nothing without a pragma, so the 33 pragma-less files are plain JavaScript by the project's own rule, not pragma-less Flow. Consequently the detector's 161/161 answers the question the development corpus had already answered (a pragma on line 1 is caught), and SYNTACTIC_FLOW's detection-miss column has NEVER met a real file that is Flow without saying so — the direction this manifest's strata note already called invisible. That holdout is spent and cannot be re-chosen. Closing this needs a corpus whose .flowconfig sets all=true, or a tree annotated without pragmas, pre-registered the same way; until then the Flow detector's clean result is a statement about pragma-bearing files only. Recorded as an exposure, not absorbed into a clean result.", + "rerunAtFixedSha": "2026-09-13 09:10Z, js-impl@30f3076, the JavaScript holdout re-run AS A CORPUS THAT ALREADY FOUND SOMETHING (no longer a holdout; its digest recomputed first and equal to the value above): PK 0 duplicates over 182,005 rows (8 fewer than at 5095da4 — the 4 import copies and the 4 type-reference copies beneath them); every other number identical to the opening run; oracle adjudication byte-identical; recall residues unchanged and all instances of known classes. Verdict in class terms: only known classes. The new class found at opening is closed by 3676a12 and covered by verified/jsdoc-import-type-rehosted/." + } + }, + "totals": { + "discoveredFiles": 4549, + "walkedByParser": 4529, + "bytes": 40300000, + "nonProjectProvenance": 34, + "extractionErrors": 0, + "rows": 3570441, + "shippedSourceFiles": 4048, + "testSpecFiles": 447, + "projectFiles": 4252, + "flowExcluded": 248, + "bundledExcluded": 17, + "generatedMonolith": 12, + "callSitesAdjudicated": 195844, + "recallMisses": 0, + "requireEdgeMisses": 0, + "kindMismatches": 0, + "optionalityMismatches": 0, + "duplicatePrimaryKeys": 0 + }, + "members": [ + { + "files": 124, + "callSites": 10268, + "strata": { + "jsdoc-typed": 14, + "cjs-prototype": 110 + } + }, + { + "files": 538, + "callSites": 20882, + "strata": { + "cjs-prototype": 537, + "jsdoc-typed": 1 + } + }, + { + "files": 170, + "callSites": 4734, + "strata": { + "cjs-prototype": 165, + "jsdoc-typed": 5 + } + }, + { + "files": 0, + "callSites": 0, + "strata": {} + }, + { + "files": 0, + "callSites": 0, + "strata": {} + }, + { + "files": 12, + "callSites": 579, + "strata": { + "jsdoc-typed": 8, + "cjs-prototype": 4 + } + }, + { + "files": 629, + "callSites": 3786, + "strata": { + "cjs-prototype": 603, + "jsdoc-typed": 26 + } + }, + { + "files": 58, + "callSites": 2207, + "strata": { + "cjs-prototype": 27, + "jsdoc-typed": 31 + } + }, + { + "files": 247, + "callSites": 6547, + "strata": { + "cjs-prototype": 197, + "jsdoc-typed": 50 + } + }, + { + "files": 314, + "callSites": 22638, + "strata": { + "cjs-prototype": 259, + "jsdoc-typed": 55 + } + }, + { + "files": 13, + "callSites": 648, + "strata": { + "cjs-prototype": 13 + } + }, + { + "files": 539, + "callSites": 24500, + "strata": { + "cjs-prototype": 238, + "jsdoc-typed": 301 + } + }, + { + "files": 15, + "callSites": 511, + "strata": { + "cjs-prototype": 15 + } + }, + { + "files": 57, + "callSites": 929, + "strata": { + "esm": 55, + "jsdoc-typed": 2 + } + }, + { + "files": 3, + "callSites": 104, + "strata": { + "cjs-prototype": 3 + } + }, + { + "files": 4, + "callSites": 91, + "strata": { + "jsdoc-typed": 1, + "cjs-prototype": 3 + } + }, + { + "files": 8, + "callSites": 380, + "strata": { + "esm": 5, + "jsdoc-typed": 3 + } + }, + { + "files": 5, + "callSites": 128, + "strata": { + "esm": 5 + } + }, + { + "files": 1, + "callSites": 0, + "strata": { + "esm": 1 + } + }, + { + "files": 9, + "callSites": 219, + "strata": { + "esm": 9 + } + }, + { + "files": 4, + "callSites": 182, + "strata": { + "esm": 4 + } + }, + { + "files": 13, + "callSites": 439, + "strata": { + "esm": 9, + "jsdoc-typed": 4 + } + }, + { + "files": 3, + "callSites": 417, + "strata": { + "esm": 3 + } + }, + { + "files": 4, + "callSites": 405, + "strata": { + "esm": 4 + } + }, + { + "files": 212, + "callSites": 6682, + "strata": { + "jsdoc-typed": 73, + "esm": 139 + } + }, + { + "files": 374, + "callSites": 8010, + "strata": { + "esm": 374 + } + }, + { + "files": 57, + "callSites": 2922, + "strata": { + "jsdoc-typed": 27, + "esm": 30 + } + }, + { + "files": 25, + "callSites": 352, + "strata": { + "esm": 17, + "jsdoc-typed": 8 + } + }, + { + "files": 116, + "callSites": 3946, + "strata": { + "esm": 46, + "jsdoc-typed": 69, + "cjs-prototype": 1 + } + }, + { + "files": 144, + "callSites": 2088, + "strata": { + "cjs-prototype": 62, + "esm": 6, + "jsx-in-js": 76 + } + }, + { + "files": 190, + "callSites": 11620, + "strata": { + "cjs-prototype": 105, + "esm": 74, + "jsdoc-typed": 3, + "jsx-in-js": 8 + } + }, + { + "files": 495, + "callSites": 59398, + "strata": { + "esm": 265, + "jsdoc-typed": 7, + "jsx-in-js": 207, + "cjs-prototype": 16 + } + }, + { + "files": 38, + "callSites": 288, + "strata": { + "esm": 13, + "jsx-in-js": 25 + } + } + ], + "strata": { + "note": "Priority-ordered partition, one stratum per file. FLOW IS NO LONGER A RESOLUTION STRATUM: under the out-of-scope ruling a Flow file emits one js_module row with sourceProvenance = FLOW_REJECTED (FLOW_EXCLUDED until the 2026-09-13 rename) and nothing else, so it has no calls to resolve. Flow is now a DETECTION measure — 248 declined, 10 leaked, 25 surviving SYNTACTIC_FLOW rows, each naming a file whose Flow-ness was not caught. Overlaps among the remaining strata are cross-tabbed rather than resolved away.", + "order": [ + "bundled and flow (classified, never counted)", + "jsx-in-js", + "jsdoc-typed", + "esm", + "cjs-prototype" + ], + "alsoSplit": [ + "test/spec files, reported apart from shipped source" + ] + }, + "scopeOfTheRecallNumber": "CALL SITES ONLY. The sweep adjudicates CallExpression, NewExpression and TaggedTemplateExpression against ts.createProgram. It is blind to js_expression, js_block, js_variable and js_type_heritage — demonstrated, not theoretical: it missed 4,944 nested JSX elements that emitted no row and 43 heritage edges with an unusable superTypeName, both of which js-impl's positional AST-recall found.", + "convergence": { + "adjudicationSweep": "clean for six consecutive runs (@24774d7, @527770c, @ba8846c, @a4e1e24, @e3d45cd, @30f3076); the corpus was constant through the first five and gained two files at @30f3076 when the umd and long-literal rulings returned them to PROJECT", + "caveat": "Each of the last two sweeps still produced a finding, and neither came from the sweep. @527770c's came from auditing the residue of js-impl's AST-recall; @ba8846c's came from a new IR-sufficiency measure. The sweep converged; the set of questions being asked did not. Read the clean result as: the call-graph relation is converged, and the other relations have each been measured once, by a measure built after the fact.", + "latestSweep": "2026-09-13 @30f3076, the sweep after the holdout fix: 195,844 call sites 0/0/0/0; PK 0 duplicates over 3,589,235; FK 0 / 14,736,986; link meaning 2 / 6.06M and both are the filed parameter-scope instance; enum invariant holds over corpus + categories; ten AST relations unchanged (jsdoc types 94.38%, jsdoc imports 99.43%, comments 97.84%, the rest 100%). Two files moved BUNDLED -> PROJECT, the exact two the provenance findings named. Zero new disagreements, zero new classes." + }, + "identityFrom": { + "env": "JS_CORPUS_IDENTITY", + "onMissing": "fail loudly; never measure a partial corpus silently" + }, + "digest": { + "$comment": "SALTED SHA-256 over each stratum directory's sorted (relative path, file sha256) list — sha256(salt NUL joined rows), as manifest-privacy.mjs digestOf(root, salt) computes it. Salted because a public tree digest over a small stratum is enumerable: anyone could clone a few dozen candidate repositories and hash them. The salt is beside the private manifest and never published; a holder can prove a materialised tree byte-identical to the one every number was measured on with `verify --salt-file`, and `verify` refuses without it rather than reporting DIFFER on a matching tree.", + "salted": true, + "files": 4532, + "sha256": "ea5a30949f8bec5c518cf85ce78441f46a3b1ea88ff26b125129d5c41ed0b3da", + "byStratumDirectory": { + "app": { + "files": 832, + "sha256": "dabc2e2997bd77f87413f4a2fd03c73dc35fb368695341bbc15f0c39d60e0e26" + }, + "bundled": { + "files": 97, + "sha256": "d15d7a0c17b3a20b4d6d26600dfd7b6dcc88cfb955de7c16c181e91de975fbbe" + }, + "cjs": { + "files": 1834, + "sha256": "f53123c6dc5d9b69cb61a42ebd8374db9ef328143c39cabf6284c7b8e1cb5d46" + }, + "dual": { + "files": 72, + "sha256": "a795ad51668e4aae6078ab7afdcf8d09be17b1e17d3e513ef161c17b281fa85e" + }, + "esm": { + "files": 625, + "sha256": "3f97a0f9fa3790eaf74c47f4f7a1968cf3a7a7e5f2ff8525cb1c33950ef63e73" + }, + "jsdoc": { + "files": 198, + "sha256": "c50dc5be0002bd5158bd6731b386eb7fd8ef91ab1684e8bfd47c0a942b8a9fe2" + }, + "jsx": { + "files": 874, + "sha256": "418f71954f1f7561fcbbcf666fe832d2edf1e852f10591b57e0e1c1ac5fb15d1" + } + } + } +} diff --git a/parser/src/test-data/javascript/categories/PROVENANCE.json b/parser/src/test-data/javascript/categories/PROVENANCE.json new file mode 100644 index 000000000..fb01542d9 --- /dev/null +++ b/parser/src/test-data/javascript/categories/PROVENANCE.json @@ -0,0 +1,667 @@ +{ + "generatedBy": "src/test/javascript-gates/promote-fixtures.mjs", + "stagingFiles": 121, + "categoryFiles": 137, + "mapping": [ + { + "from": "staging/cjs/blocks/control-flow.js", + "to": "categories/blocks/control-flow.js", + "sha256_16": "4d372fa770c3723f" + }, + { + "from": "staging/cjs/blocks/exception-handling.js", + "to": "categories/blocks/exception-handling.js", + "sha256_16": "e993064a4adddb37" + }, + { + "from": "staging/cjs/call-forms/accessor-invocation.js", + "to": "categories/call-forms/accessor-invocation.js", + "sha256_16": "044c842a80d11034" + }, + { + "from": "staging/cjs/call-forms/call-apply-bind.js", + "to": "categories/call-forms/call-apply-bind.js", + "sha256_16": "584d131e5e5bf111" + }, + { + "from": "staging/cjs/call-forms/computed-and-optional-calls.js", + "to": "categories/call-forms/computed-and-optional-calls.js", + "sha256_16": "b7186ae25daad197" + }, + { + "from": "staging/cjs/call-forms/dynamic-code.js", + "to": "categories/call-forms/dynamic-code.js", + "sha256_16": "a44438972fae6868" + }, + { + "from": "staging/cjs/call-forms/generators-and-iterators.js", + "to": "categories/call-forms/generators-and-iterators.js", + "sha256_16": "9020b7cee897cb20" + }, + { + "from": "staging/cjs/call-forms/proxy-traps.js", + "to": "categories/call-forms/proxy-traps.js", + "sha256_16": "f523d637ab084423" + }, + { + "from": "staging/cjs/call-forms/tagged-templates.js", + "to": "categories/call-forms/tagged-templates.js", + "sha256_16": "e281d279ffaf729a" + }, + { + "from": "staging/cjs/commonjs/circular-a.js", + "to": "categories/imports/circular-a.js", + "sha256_16": "5841689cef6c8a6b" + }, + { + "from": "staging/cjs/commonjs/circular-b.js", + "to": "categories/imports/circular-b.js", + "sha256_16": "3e40278d04c3a9c4" + }, + { + "from": "staging/cjs/commonjs/conditional-require.js", + "to": "categories/imports/conditional-require.js", + "sha256_16": "f431713fb83e8f6e" + }, + { + "from": "staging/cjs/commonjs/destructured-require.js", + "to": "categories/imports/destructured-require.js", + "sha256_16": "faa57f5c8496bf04" + }, + { + "from": "staging/cjs/commonjs/export-overwrite-conditional.js", + "to": "categories/exports/export-overwrite-conditional.js", + "sha256_16": "751d9a6fae5895be" + }, + { + "from": "staging/cjs/commonjs/export-overwrite-unconditional.js", + "to": "categories/exports/export-overwrite-unconditional.js", + "sha256_16": "39b9477187c59112" + }, + { + "from": "staging/cjs/commonjs/exports-array.js", + "to": "categories/exports/exports-array.js", + "sha256_16": "9ba664208e7874c9" + }, + { + "from": "staging/cjs/commonjs/exports-shorthand.js", + "to": "categories/exports/exports-shorthand.js", + "sha256_16": "c1121165e90ad1ad" + }, + { + "from": "staging/cjs/commonjs/module-exports-assignment.js", + "to": "categories/exports/module-exports-assignment.js", + "sha256_16": "c1397ed1bfb98a4e" + }, + { + "from": "staging/cjs/commonjs/module-exports-members.js", + "to": "categories/exports/module-exports-members.js", + "sha256_16": "9f005dc1567a1e29" + }, + { + "from": "staging/cjs/commonjs/reexport-require.js", + "to": "categories/imports/reexport-require.js", + "sha256_16": "485bff6468dc2df5" + }, + { + "from": "staging/cjs/commonjs/require-forms.js", + "to": "categories/imports/require-forms.js", + "sha256_16": "f38157874fbeb955" + }, + { + "from": "staging/cjs/commonjs/require-non-literal.js", + "to": "categories/imports/require-non-literal.js", + "sha256_16": "f63c36ada39b1c3c" + }, + { + "from": "staging/cjs/directives/cli-entry.js", + "to": "categories/directives/cli-entry.js", + "sha256_16": "6522cda85f0cf8bf" + }, + { + "from": "staging/cjs/directives/directive-comments.js", + "to": "categories/directives/directive-comments.js", + "sha256_16": "e8e693dbc155e0f8" + }, + { + "from": "staging/cjs/directives/no-check.js", + "to": "categories/directives/no-check.js", + "sha256_16": "0930310313d57a00" + }, + { + "from": "staging/cjs/directives/source-map-footer-long.js", + "to": "categories/directives/source-map-footer-long.js", + "sha256_16": "f6503a85c48c385f" + }, + { + "from": "staging/cjs/directives/source-map-footer-short.js", + "to": "categories/directives/source-map-footer-short.js", + "sha256_16": "1c0f8c79d4049686" + }, + { + "from": "staging/cjs/enums/enum-idioms.js", + "to": "categories/enums/enum-idioms.js", + "sha256_16": "d0b909a244c7d9c4" + }, + { + "from": "staging/cjs/expressions/calls-and-member-access.js", + "to": "categories/expressions/calls-and-member-access.js", + "sha256_16": "a18a5177ef3faf7f" + }, + { + "from": "staging/cjs/expressions/deep-nesting.js", + "to": "categories/expressions/deep-nesting.js", + "sha256_16": "05d36aa6e93e9698" + }, + { + "from": "staging/cjs/expressions/literals.js", + "to": "categories/expressions/literals.js", + "sha256_16": "6d609f738be0f90c" + }, + { + "from": "staging/cjs/expressions/operators.js", + "to": "categories/expressions/operators.js", + "sha256_16": "15d22f227341cc1b" + }, + { + "from": "staging/cjs/expressions/trailing-comments.js", + "to": "categories/expressions/trailing-comments.js", + "sha256_16": "b3839fee1760ccea" + }, + { + "from": "staging/cjs/hoisting/arrow-lexical-this.js", + "to": "categories/hoisting/arrow-lexical-this.js", + "sha256_16": "acb84baa74bd019e" + }, + { + "from": "staging/cjs/hoisting/closures-over-loops.js", + "to": "categories/hoisting/closures-over-loops.js", + "sha256_16": "579eac994c5f8db8" + }, + { + "from": "staging/cjs/hoisting/function-decl-vs-expression.js", + "to": "categories/hoisting/function-decl-vs-expression.js", + "sha256_16": "49e18ca2009df3f6" + }, + { + "from": "staging/cjs/hoisting/sloppy-implicit-global.js", + "to": "categories/hoisting/sloppy-implicit-global.js", + "sha256_16": "72c991499736cdfb" + }, + { + "from": "staging/cjs/hoisting/tdz.js", + "to": "categories/hoisting/tdz.js", + "sha256_16": "4c8487fd17453952" + }, + { + "from": "staging/cjs/hoisting/this-by-call-form.js", + "to": "categories/hoisting/this-by-call-form.js", + "sha256_16": "ba9e5e831b2b5460" + }, + { + "from": "staging/cjs/hoisting/var-hoisting.js", + "to": "categories/hoisting/var-hoisting.js", + "sha256_16": "3b36507f7e82648c" + }, + { + "from": "staging/cjs/hoisting/with-statement.js", + "to": "categories/hoisting/with-statement.js", + "sha256_16": "7277e41216a8e26b" + }, + { + "from": "staging/cjs/integration/contracts.js", + "to": "categories/integration/contracts.js", + "sha256_16": "522448ec17400b99" + }, + { + "from": "staging/cjs/integration/errors.js", + "to": "categories/integration/errors.js", + "sha256_16": "9ab24fe94ee86196" + }, + { + "from": "staging/cjs/integration/repository.js", + "to": "categories/integration/repository.js", + "sha256_16": "59230208562b0dfa" + }, + { + "from": "staging/cjs/integration/service-layer.js", + "to": "categories/integration/service-layer.js", + "sha256_16": "c13f6939c5b612c4" + }, + { + "from": "staging/cjs/jsdoc/callback-and-template.js", + "to": "categories/jsdoc/callback-and-template.js", + "sha256_16": "99636806597ba9f8" + }, + { + "from": "staging/cjs/jsdoc/casts-and-throws.js", + "to": "categories/jsdoc/casts-and-throws.js", + "sha256_16": "b2e159f7152e6ba9" + }, + { + "from": "staging/cjs/jsdoc/contradicting-jsdoc.js", + "to": "categories/jsdoc/contradicting-jsdoc.js", + "sha256_16": "2c13ceaf0f0f1d66" + }, + { + "from": "staging/cjs/jsdoc/extends-implements.js", + "to": "categories/jsdoc/extends-implements.js", + "sha256_16": "e1446d9d81319321" + }, + { + "from": "staging/cjs/jsdoc/nested-params.js", + "to": "categories/jsdoc/nested-params.js", + "sha256_16": "c2042eb3ffa53b46" + }, + { + "from": "staging/cjs/jsdoc/param-returns.js", + "to": "categories/jsdoc/param-returns.js", + "sha256_16": "3233ade79640d4e7" + }, + { + "from": "staging/cjs/jsdoc/satisfies-and-unknown-syntax.js", + "to": "categories/jsdoc/satisfies-and-unknown-syntax.js", + "sha256_16": "4cbeff477b8d88a0" + }, + { + "from": "staging/cjs/jsdoc/typedef-only.js", + "to": "categories/jsdoc/typedef-only.js", + "sha256_16": "e25faa9b69a32cb5" + }, + { + "from": "staging/cjs/local-variables/cross-file-locals.js", + "to": "categories/local-variables/cross-file-locals.js", + "sha256_16": "9cab755f1792eef7" + }, + { + "from": "staging/cjs/local-variables/helper-values.js", + "to": "categories/local-variables/helper-values.js", + "sha256_16": "9d9db367b77a058c" + }, + { + "from": "staging/cjs/local-variables/local-variable-forms.js", + "to": "categories/local-variables/local-variable-forms.js", + "sha256_16": "ee65d8c0db96d976" + }, + { + "from": "staging/cjs/methods/arity-dispatch.js", + "to": "categories/methods/arity-dispatch.js", + "sha256_16": "5b27295c93d7dcc5" + }, + { + "from": "staging/cjs/methods/constructor-patterns.js", + "to": "categories/methods/constructor-patterns.js", + "sha256_16": "3f96dbad2cc20e90" + }, + { + "from": "staging/cjs/methods/method-kinds.js", + "to": "categories/methods/method-kinds.js", + "sha256_16": "6764778153d90b31" + }, + { + "from": "staging/cjs/methods/parameter-forms.js", + "to": "categories/methods/parameter-forms.js", + "sha256_16": "3b90d192bd06224e" + }, + { + "from": "staging/cjs/methods/parameter-references.js", + "to": "categories/methods/parameter-references.js", + "sha256_16": "7b7a8ce9b8d3111c" + }, + { + "from": "staging/cjs/methods/pattern-binding-paths.js", + "to": "categories/methods/pattern-binding-paths.js", + "sha256_16": "3b4ba1f481c99f4c" + }, + { + "from": "staging/cjs/prototypes/bound-members.js", + "to": "categories/prototypes/bound-members.js", + "sha256_16": "926677327ada65cb" + }, + { + "from": "staging/cjs/prototypes/constructor-function.js", + "to": "categories/prototypes/constructor-function.js", + "sha256_16": "c557c227f7e12c65" + }, + { + "from": "staging/cjs/prototypes/define-property-accessors.js", + "to": "categories/prototypes/define-property-accessors.js", + "sha256_16": "d8a4f76ebee0bef5" + }, + { + "from": "staging/cjs/prototypes/iife-module.js", + "to": "categories/prototypes/iife-module.js", + "sha256_16": "c63e90b04562b39c" + }, + { + "from": "staging/cjs/prototypes/object-assign-prototype.js", + "to": "categories/prototypes/object-assign-prototype.js", + "sha256_16": "d07195dba72e55e9" + }, + { + "from": "staging/cjs/prototypes/object-create-chain.js", + "to": "categories/prototypes/object-create-chain.js", + "sha256_16": "21093d15d2aac96c" + }, + { + "from": "staging/cjs/prototypes/prototype-assignment.js", + "to": "categories/prototypes/prototype-assignment.js", + "sha256_16": "70a2053333fd52a5" + }, + { + "from": "staging/cjs/prototypes/util-inherits.js", + "to": "categories/prototypes/util-inherits.js", + "sha256_16": "f076e1db8954393a" + }, + { + "from": "staging/cjs/provenance/readable.bundle.js", + "to": "categories/modules/provenance/readable.bundle.js", + "sha256_16": "60d880362b053eb5" + }, + { + "from": "staging/cjs/provenance/readable.esm.js", + "to": "categories/modules/provenance/readable.esm.js", + "sha256_16": "267f3e163c24c933" + }, + { + "from": "staging/cjs/provenance/readable.min.js", + "to": "categories/modules/provenance/readable.min.js", + "sha256_16": "b37eaf2439db6b1a" + }, + { + "from": "staging/cjs/provenance/readable.umd.js", + "to": "categories/modules/provenance/readable.umd.js", + "sha256_16": "2dc8471352fe82ae" + }, + { + "from": "staging/cjs/provenance/single-long-literal.js", + "to": "categories/modules/provenance/single-long-literal.js", + "sha256_16": "33181aa6643857d4" + }, + { + "from": "staging/cjs/type-registry/cross-file-heritage.js", + "to": "categories/type-registry/cross-file-heritage.js", + "sha256_16": "886e856916e58e41" + }, + { + "from": "staging/cjs/type-registry/type-categories.js", + "to": "categories/type-registry/type-categories.js", + "sha256_16": "07152af429984b5e" + }, + { + "from": "staging/cjs/type-registry/type-placement.js", + "to": "categories/type-registry/type-placement.js", + "sha256_16": "4ed2c859474ba636" + }, + { + "from": "staging/esm/create-require.js", + "to": "categories/modules/esm/create-require.js", + "sha256_16": "20ebc75d825ce80c" + }, + { + "from": "staging/esm/exports/export-forms.js", + "to": "categories/exports/esm/export-forms.js", + "sha256_16": "13536649763ebc7b" + }, + { + "from": "staging/esm/import-meta.js", + "to": "categories/modules/esm/import-meta.js", + "sha256_16": "b4b0947f5c74b234" + }, + { + "from": "staging/esm/imports/import-forms.js", + "to": "categories/imports/esm/import-forms.js", + "sha256_16": "de2613d3ec234414" + }, + { + "from": "staging/esm/imports/pkg/index.js", + "to": "categories/imports/esm/pkg/index.js", + "sha256_16": "51beb019f0d8d151" + }, + { + "from": "staging/esm/imports/pkg/side-effects.js", + "to": "categories/imports/esm/pkg/side-effects.js", + "sha256_16": "fd6bd1ae7d992fd7" + }, + { + "from": "staging/esm/imports/pkg/util.js", + "to": "categories/imports/esm/pkg/util.js", + "sha256_16": "ed60c14cca55ef87" + }, + { + "from": "staging/esm/require-under-esm.js", + "to": "categories/modules/esm/require-under-esm.js", + "sha256_16": "cdb97c7577634171" + }, + { + "from": "staging/esm/top-level-await.js", + "to": "categories/modules/esm/top-level-await.js", + "sha256_16": "ee08f00978226f2a" + }, + { + "from": "staging/exports-map/consumer/consumer.js", + "to": "categories/modules/exports-map/consumer/consumer.js", + "sha256_16": "3cdcdc09abf546d6" + }, + { + "from": "staging/exports-map/internal/log.dev.js", + "to": "categories/modules/exports-map/internal/log.dev.js", + "sha256_16": "f207ccf1f3959124" + }, + { + "from": "staging/exports-map/internal/log.js", + "to": "categories/modules/exports-map/internal/log.js", + "sha256_16": "99de4cfecf4aa2bb" + }, + { + "from": "staging/exports-map/lib/index.cjs", + "to": "categories/modules/exports-map/lib/index.cjs", + "sha256_16": "f5d945fbc6250493" + }, + { + "from": "staging/exports-map/lib/index.js", + "to": "categories/modules/exports-map/lib/index.js", + "sha256_16": "c18fe2b236f0beb6" + }, + { + "from": "staging/exports-map/lib/stream.browser.js", + "to": "categories/modules/exports-map/lib/stream.browser.js", + "sha256_16": "accf78ab24e771ae" + }, + { + "from": "staging/exports-map/lib/stream.node.cjs", + "to": "categories/modules/exports-map/lib/stream.node.cjs", + "sha256_16": "15140404be078664" + }, + { + "from": "staging/exports-map/lib/stream.node.js", + "to": "categories/modules/exports-map/lib/stream.node.js", + "sha256_16": "65aefa0280ebec30" + }, + { + "from": "staging/ext/cjs-package/override.mjs", + "to": "categories/modules/ext/cjs-package/override.mjs", + "sha256_16": "11df4a571e4dbc60" + }, + { + "from": "staging/ext/cjs-package/plain.js", + "to": "categories/modules/ext/cjs-package/plain.js", + "sha256_16": "7ecbddb129f24525" + }, + { + "from": "staging/ext/esm-package/override.cjs", + "to": "categories/modules/ext/esm-package/override.cjs", + "sha256_16": "92b3932009cc2579" + }, + { + "from": "staging/ext/esm-package/plain.js", + "to": "categories/modules/ext/esm-package/plain.js", + "sha256_16": "ddf507503fbb98f6" + }, + { + "from": "staging/flow/casts.js", + "to": "categories/flow/casts.js", + "sha256_16": "7a4afa9b1b19035d" + }, + { + "from": "staging/flow/declare-statements.js", + "to": "categories/flow/declare-statements.js", + "sha256_16": "e0f7415c2edffc31" + }, + { + "from": "staging/flow/detection-miss/after-long-licence.js", + "to": "categories/flow/detection-miss/after-long-licence.js", + "sha256_16": "7844964878f4c85e" + }, + { + "from": "staging/flow/detection-miss/no-pragma.js", + "to": "categories/flow/detection-miss/no-pragma.js", + "sha256_16": "ac27f6363f6eeeaa" + }, + { + "from": "staging/flow/detection/after-shebang.js", + "to": "categories/flow/detection/after-shebang.js", + "sha256_16": "900a52cfd41033bf" + }, + { + "from": "staging/flow/detection/block-pragma.js", + "to": "categories/flow/detection/block-pragma.js", + "sha256_16": "6874c80ae6d549a2" + }, + { + "from": "staging/flow/detection/jsdoc-pragma.js", + "to": "categories/flow/detection/jsdoc-pragma.js", + "sha256_16": "200ab982f4306b72" + }, + { + "from": "staging/flow/detection/line-pragma.js", + "to": "categories/flow/detection/line-pragma.js", + "sha256_16": "0a0f69bba173d926" + }, + { + "from": "staging/flow/detection/strict-pragma.js", + "to": "categories/flow/detection/strict-pragma.js", + "sha256_16": "19bca9d88f528889" + }, + { + "from": "staging/flow/false-positive/flow-scoped-package.js", + "to": "categories/flow/false-positive/flow-scoped-package.js", + "sha256_16": "e8de5788129e803a" + }, + { + "from": "staging/flow/false-positive/mentions-flow-in-prose.js", + "to": "categories/flow/false-positive/mentions-flow-in-prose.js", + "sha256_16": "1f784171962e713f" + }, + { + "from": "staging/flow/flow-annotations.js", + "to": "categories/flow/flow-annotations.js", + "sha256_16": "07e546db2800c4d0" + }, + { + "from": "staging/flow/flow-pragma.js", + "to": "categories/flow/flow-pragma.js", + "sha256_16": "dee2a7afdda117a4" + }, + { + "from": "staging/flow/recovery-mangling.js", + "to": "categories/flow/recovery-mangling.js", + "sha256_16": "3dd4d3f5aa89f82c" + }, + { + "from": "staging/flow/silently-typed.js", + "to": "categories/flow/silently-typed.js", + "sha256_16": "8e3cf0e6ecad69fb" + }, + { + "from": "staging/jsx/component-in-js.js", + "to": "categories/jsx/component-in-js.js", + "sha256_16": "a23f113c2031c984" + }, + { + "from": "staging/jsx/component.jsx", + "to": "categories/jsx/component.jsx", + "sha256_16": "08bd93e37ade95aa" + }, + { + "from": "staging/jsx/tag-forms.jsx", + "to": "categories/jsx/tag-forms.jsx", + "sha256_16": "08494fc02a55dd05" + }, + { + "from": "staging/jsx/unterminated-element.jsx", + "to": "categories/jsx/unterminated-element.jsx", + "sha256_16": "c6b58b9e5ea3d1a7" + }, + { + "from": "staging/mismatch/esm-under-commonjs/esm-syntax.js", + "to": "categories/modules/mismatch/esm-under-commonjs/esm-syntax.js", + "sha256_16": "d365f4bc2b3c7bab" + }, + { + "from": "staging/mismatch/esm-under-commonjs/mixed-both-systems.js", + "to": "categories/modules/mismatch/esm-under-commonjs/mixed-both-systems.js", + "sha256_16": "d934afca22fd391c" + }, + { + "from": "staging/pkg-absent-type/defaulted.js", + "to": "categories/modules/pkg-absent-type/defaulted.js", + "sha256_16": "32e3ff31a726b674" + }, + { + "from": "ADDED by js-corpus (enum coverage gap)", + "to": "categories/blocks/class-static-block.js", + "sha256_16": "c1102b401c202304" + }, + { + "from": "ADDED by js-corpus (enum coverage gap)", + "to": "categories/imports/esm/import-binding-alias.js", + "sha256_16": "b88a1173d5613562" + }, + { + "from": "ADDED by js-corpus (enum coverage gap)", + "to": "categories/directives/module-attached-comments.js", + "sha256_16": "3c9dc797d10751fb" + }, + { + "from": "ADDED by js-corpus (enum coverage gap)", + "to": "categories/jsdoc/this-annotation.js", + "sha256_16": "1f19330fef58defb" + }, + { + "from": "ADDED by js-corpus (enum coverage gap)", + "to": "categories/modules/import-equals.js", + "sha256_16": "5cb5384191589e17" + }, + { + "from": "MINTED by the promotion (category layout)", + "to": "categories/package.json", + "sha256_16": "54bdb5bf0a9615c3" + }, + { + "from": "MINTED by the promotion (category layout)", + "to": "categories/imports/esm/package.json", + "sha256_16": "c18774aca8224b15" + }, + { + "from": "MINTED by the promotion (category layout)", + "to": "categories/exports/esm/package.json", + "sha256_16": "cff04da486a6d22a" + }, + { + "from": "MINTED by the promotion (category layout)", + "to": "categories/modules/esm/package.json", + "sha256_16": "d5d03aa526254246" + }, + { + "from": "MINTED by the promotion (category layout)", + "to": "categories/flow/package.json", + "sha256_16": "6b9e5d9685976a77" + }, + { + "from": "MINTED by the promotion (category layout)", + "to": "categories/jsx/package.json", + "sha256_16": "8c6ec40118d4f6bc" + } + ] +} diff --git a/parser/src/test-data/javascript/categories/blocks/class-static-block.js b/parser/src/test-data/javascript/categories/blocks/class-static-block.js new file mode 100644 index 000000000..59580a5f9 --- /dev/null +++ b/parser/src/test-data/javascript/categories/blocks/class-static-block.js @@ -0,0 +1,39 @@ +// fixture: categories/blocks/class-static-block.js +// nature: runtime-bearing +// JsBlockKind.CLASS_STATIC_BLOCK — declared, documented `static { ... }`, zero rows. +// +// The column-scoped enum audit found this and the whole-cell audit could not: +// the string CLASS_STATIC_BLOCK IS emitted corpus-wide, in js_scope.scopeKind, +// so exact-cell matching marks the value covered on another enum's evidence. +// js_block carries 17 kinds and 148,000 rows and none of them is this one. +// +// It is the TypeScript NAMESPACE_BODY / MODULE_BODY defect exactly: a body that +// produces a scope and no block row. The control is in the same file — the class +// body, the method bodies and the module body all produce blocks. +// +// module system: CommonJS, governed by categories/package.json. +'use strict'; + +class Registry { + static entries = []; + + static { + // Runs at class-definition time, in its own scope, with its own `this`. + Registry.entries.push('bootstrap'); + } + + static { + // Two of them, because one wrapper that survives a single occurrence and + // collapses on the second is the failure this file also has to catch. + Registry.entries.push('second'); + } + + add(name) { + { // a plain nested block, the control + Registry.entries.push(name); + } + return Registry.entries.length; + } +} + +module.exports = { Registry }; diff --git a/parser/src/test-data/javascript/categories/blocks/control-flow.js b/parser/src/test-data/javascript/categories/blocks/control-flow.js new file mode 100644 index 000000000..9505a6850 --- /dev/null +++ b/parser/src/test-data/javascript/categories/blocks/control-flow.js @@ -0,0 +1,195 @@ +// fixture: cjs/blocks/control-flow.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2018 (for await) for everything except the final class, which +// needs ES2022 for its static field and static initialisation block. Those +// two lines are the only above-baseline syntax in the file and can be excised +// without touching anything else — but CLASS_STATIC_BLOCK is a declared +// js_block kind AND a declared js_scope kind, so excising them leaves two +// enum values with no fixture. See MANIFEST.md's syntax-floor table. +// +// Port of java/blocks/ControlFlowExamples.java and NestedBlockLinking.java. +// Every js_block kind that is not an exception form: FUNCTION_BODY, BLOCK, IF, +// ELSE, FOR, FOR_IN, FOR_OF, WHILE, DO, SWITCH, SWITCH_CASE, LABELED, +// CLASS_BODY, CLASS_STATIC_BLOCK, MODULE_BODY. +// +// `label` is a column because TypeScript's enum audit found `outer: for (...)` +// emitting the loop and DROPPING the label — a correctly positioned row with a +// missing sibling, invisible to every count-based check. Labels appear in both +// their forms here: on a loop and on a bare block. +// +// The block/scope distinction the schema draws is visible throughout: a block is +// SYNTAX, a scope is BINDING. A bare `{}` containing only `var` opens no scope, +// and js_block.opensScope says so. + +'use strict'; + +const items = [1, 2, 3]; +const obj = { a: 1, b: 2 }; + +function everyLoopForm(n) { + const out = []; + + for (let i = 0; i < n; i += 1) { out.push(i); } + + // A for with an empty head in every slot, and one with no body block at all. + let j = 0; + for (;;) { if (j++ > 2) { break; } } + for (let k = 0; k < 2; k += 1) out.push(k); // no braces: the body is a statement + + // Comma operator in both the init and the update. + for (let a = 0, b = 10; a < b; a += 1, b -= 1) { out.push(a + b); } + + for (const key in obj) { out.push(key); } + for (const value of items) { out.push(value); } + + // Destructuring in a for-of head. + for (const [index, value] of items.entries()) { out.push(index + value); } + + while (out.length < 20) { out.push(0); } + + do { out.pop(); } while (out.length > 15); + + return out; +} + +async function asyncLoops(stream) { + const out = []; + for await (const chunk of stream) { out.push(chunk); } + return out; +} + +function branching(value) { + if (value > 10) { + return 'big'; + } else if (value > 5) { + return 'medium'; + } else { + return 'small'; + } +} + +// An if with no braces, an else with no braces, and a dangling else — the shape +// §6 of BUILDING-A-PARSER.md warns about in extractor code, present here as +// input rather than as implementation. +function unbraced(value) { + if (value) return 'yes'; + else return 'no'; +} + +function nestedDangling(a, b) { + if (a) + if (b) return 'both'; + else return 'a only'; // binds to the INNER if + return 'neither'; +} + +function switching(kind) { + switch (kind) { + case 'a': + case 'b': + // Two case labels, one body. Fall-through between them is intentional + // and invisible. + return 'ab'; + case 'c': { + // A braced case body opens a real block AND a scope. + const local = 'c'; + return local; + } + case 'd': + // Deliberate fall-through with no break. + kind = 'e'; + case 'e': + return 'de'; + default: + return 'other'; + } +} + +// A switch with the default in the MIDDLE, which is legal and changes nothing +// about matching order. +function defaultInMiddle(kind) { + switch (kind) { + case 1: return 'one'; + default: return 'other'; + case 2: return 'two'; + } +} + +// Labels: on a loop, on a nested loop, and on a bare block. `break label` from +// a block is the closest JavaScript has to a goto. +function labelled(matrix) { + const found = []; + outer: + for (const row of matrix) { + inner: + for (const cell of row) { + if (cell === 0) { continue outer; } + if (cell < 0) { break outer; } + if (cell === 99) { break inner; } + found.push(cell); + } + } + + block: { + if (found.length === 0) { break block; } + found.push(-1); + } + + return found; +} + +// A bare block containing only `var`. It is a BLOCK with opensScope = false — +// the block exists in the syntax and creates no binding scope, which is the +// distinction js_block and js_scope are two relations for. +function bareBlock() { + { + var notScoped = 1; + } + { + let scoped = 2; + return notScoped + scoped; + } +} + +// Four-deep nesting, with a function boundary in the middle so the block tree +// and the scope tree diverge. +function deeplyNested(input) { + if (input) { + for (const x of input) { + while (x > 0) { + try { + const inner = () => { + switch (x) { + case 1: { + return 'one'; + } + default: + return 'many'; + } + }; + return inner(); + } finally { + break; + } + } + } + } + return null; +} + +class WithStaticBlock { + static registry = null; // [ES2022] static class field + static { // [ES2022] static initialisation block + WithStaticBlock.registry = new Map(); + } + method() { + // A class method body is a FUNCTION_BODY block whose parent is a CLASS_BODY. + { return WithStaticBlock.registry; } + } +} + +module.exports = { + everyLoopForm, asyncLoops, branching, unbraced, nestedDangling, + switching, defaultInMiddle, labelled, bareBlock, deeplyNested, WithStaticBlock +}; diff --git a/parser/src/test-data/javascript/categories/blocks/exception-handling.js b/parser/src/test-data/javascript/categories/blocks/exception-handling.js new file mode 100644 index 000000000..141d042c2 --- /dev/null +++ b/parser/src/test-data/javascript/categories/blocks/exception-handling.js @@ -0,0 +1,190 @@ +// fixture: cjs/blocks/exception-handling.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2019 (optional catch binding); ES2022 for `cause` +// +// Port of java/blocks/AdvancedExceptionHandling.java and +// ComprehensiveExceptionPatterns.java. Block kinds TRY, CATCH, FINALLY. +// +// NO ANALOGUE — Java's `throws` clause. JavaScript has no checked exceptions and +// no throws clause, exactly as TypeScript does not. Java's entire ThrowsPatterns.java +// and the THROWS_CLAUSE type-reference context have no port and none is invented. +// The nearest thing JavaScript has is the JSDoc @throws tag, which is a comment +// with no enforcement, and it is covered in jsdoc/param-returns.js. +// +// ALSO NO ANALOGUE — multi-catch `catch (A | B e)` and any typed catch +// parameter. A catch binding has no type and no annotation channel; the type is +// recovered by `instanceof` narrowing, which is what is covered instead. +// +// What JavaScript has that Java does not: throwing a non-Error (any value at +// all), an optional catch binding that declares nothing, and `finally` that can +// swallow a throw by returning. + +'use strict'; + +// The Error subclass hierarchy that replaces Java's checked-exception types. +class AppError extends Error { + constructor(message, options) { + super(message, options); // `options.cause` — [ES2022] + this.name = 'AppError'; + // Error subclasses need this to get a useful stack in V8, and it is a + // static method call whose receiver is a builtin. + Error.captureStackTrace(this, AppError); + } +} + +class NotFoundError extends AppError { + constructor(resource) { + super('not found: ' + resource); + this.name = 'NotFoundError'; + this.resource = resource; + } +} + +class ValidationError extends AppError { + constructor(field, cause) { + super('invalid: ' + field, { cause }); + this.name = 'ValidationError'; + this.field = field; + } +} + +function basic(fn) { + try { + return fn(); + } catch (err) { + return err.message; + } finally { + // Runs on every path, including the return above. + } +} + +// try/catch with no finally, try/finally with no catch, and a bare try/finally +// whose finally RETURNS — which discards the in-flight exception entirely. +function tryFinallyOnly(fn) { + try { + return fn(); + } finally { + return 'finally wins'; + } +} + +// Optional catch binding: no parameter at all, so nothing is declared. +function swallow(fn) { + try { + return fn(); + } catch { + return null; + } +} + +// A destructured catch binding. The parameter is a pattern, not a name, so +// bindingForm is OBJECT_PATTERN and it binds two names. +function destructuredCatch(fn) { + try { + return fn(); + } catch ({ message, code = 'UNKNOWN' }) { + return code + ':' + message; + } +} + +// The multi-catch replacement: one catch, `instanceof` narrowing inside. +function narrowing(fn) { + try { + return fn(); + } catch (err) { + if (err instanceof NotFoundError) { return 404; } + if (err instanceof ValidationError) { return 422; } + if (err instanceof AppError) { return 500; } + if (err instanceof TypeError || err instanceof RangeError) { return 400; } + // A non-Error throw lands here, and `err.message` would be undefined. + if (typeof err === 'string') { return err; } + throw err; // rethrow: the same value, a new throw site + } +} + +// Throwing values that are not Errors. Legal, and it means a catch parameter's +// shape is unknown even by convention. +function throwsAnything(kind) { + switch (kind) { + case 'string': throw 'a string'; + case 'number': throw 42; + case 'object': throw { code: 'E_PLAIN' }; + case 'null': throw null; + case 'error': throw new NotFoundError('user'); + default: throw new Error('unknown', { cause: new Error('root') }); + } +} + +// Nested try, rethrow with a cause chain, and a catch that throws a DIFFERENT +// error — three throw sites for one failure. +function wrapping(fn) { + try { + try { + return fn(); + } catch (inner) { + throw new ValidationError('field', inner); + } + } catch (outer) { + throw new AppError('wrapped', { cause: outer }); + } finally { + // A `continue`/`break`/`return` here would discard the throw; a bare + // statement does not. + } +} + +// try inside a loop, and a catch that continues. +function resilientLoop(tasks) { + const results = []; + for (const task of tasks) { + try { + results.push(task()); + } catch (err) { + results.push(null); + continue; + } finally { + results.push('done'); + } + } + return results; +} + +// async/await: try around an await, and the promise-chain equivalent, which is +// the same control flow with no try block at all. +async function asyncTry(promise) { + try { + return await promise; + } catch (err) { + return null; + } finally { + await Promise.resolve(); + } +} + +function promiseChain(promise) { + return promise + .then((value) => value) + .catch((err) => null) + .finally(() => undefined); +} + +// A generator with a try/finally: the finally runs when the iterator's return() +// is called, which is a resumption from outside the function. +function* generatorTry(items) { + try { + for (const item of items) { yield item; } + } finally { + items.length = 0; + } +} + +// An unhandled rejection and a process-level handler — the two places a throw +// goes when nothing catches it. +process.on('uncaughtException', (err) => { return err; }); +process.on('unhandledRejection', (reason) => { return reason; }); + +module.exports = { + AppError, NotFoundError, ValidationError, + basic, tryFinallyOnly, swallow, destructuredCatch, narrowing, + throwsAnything, wrapping, resilientLoop, asyncTry, promiseChain, generatorTry +}; diff --git a/parser/src/test-data/javascript/categories/call-forms/accessor-invocation.js b/parser/src/test-data/javascript/categories/call-forms/accessor-invocation.js new file mode 100644 index 000000000..891ac087e --- /dev/null +++ b/parser/src/test-data/javascript/categories/call-forms/accessor-invocation.js @@ -0,0 +1,127 @@ +// fixture: cjs/call-forms/accessor-invocation.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// A function invoked by READING a property. Every line marked below runs a +// method body and none of them is syntactically a call. +// +// GETTER_INVOCATION and SETTER_INVOCATION are RESERVED with a zero-row +// assertion, and this fixture is the argument for that: whether `obj.x` invokes +// anything depends on whether `x` is an accessor ON THAT OBJECT OR ITS +// PROTOTYPE CHAIN at that moment. It is a fact about the object at runtime, not +// about the expression. 1,225 getters are DECLARED in the schema's corpus and 0 +// invocations are emittable from syntax. +// +// The declarations here are real and must be emitted: accessorPairKind on +// js_field (NONE | GETTER_ONLY | SETTER_ONLY | GETTER_SETTER) plus GETTER and +// SETTER methodKinds. It is only the invocation that cannot be read. + +'use strict'; + +// --- declarations: every way to declare an accessor ------------------------------ + +class Temperature { + constructor(celsius) { this._celsius = celsius; } + + get celsius() { return this._celsius; } + set celsius(value) { this._celsius = Number(value); } + + get fahrenheit() { return this._celsius * 9 / 5 + 32; } // GETTER_ONLY + set kelvin(value) { this._celsius = value - 273.15; } // SETTER_ONLY + + static get zero() { return new Temperature(0); } // static getter + + get ['computed' + 'Name']() { return 'computed accessor'; } +} + +// In an object literal. +const gauge = { + _reading: 0, + get reading() { return this._reading; }, + set reading(v) { this._reading = v; }, + get ['dyn' + 'amic']() { return 'dynamic'; } +}; + +// Via defineProperty — the third spelling, covered structurally in +// prototypes/define-property-accessors.js and repeated here so every invocation +// below has a declaration in the same file. +const lazy = {}; +let lazyComputations = 0; +Object.defineProperty(lazy, 'value', { + enumerable: true, + configurable: true, + get() { lazyComputations += 1; return lazyComputations; } +}); + +// --- invocations: none of these looks like a call ----------------------------------- + +const t = new Temperature(20); + +const readGetter = t.celsius; // invokes get celsius +const readDerived = t.fahrenheit; // invokes get fahrenheit +t.celsius = 25; // invokes set celsius +t.kelvin = 300; // invokes set kelvin +const readStatic = Temperature.zero; // invokes the static getter +const readComputed = t.computedName; + +// Destructuring invokes the getter for each name it pulls out. +const { celsius, fahrenheit } = t; + +// Spread invokes EVERY enumerable getter on the source. One token, N invocations, +// and the set of names is not in this file. +const snapshot = { ...gauge }; + +// Object.assign does the same, by reading. +const copied = Object.assign({}, gauge); + +// JSON.stringify invokes every enumerable getter, transitively. +const serialised = JSON.stringify(gauge); + +// A compound assignment invokes the getter AND the setter, in that order. +gauge.reading += 5; + +// An increment likewise. +gauge.reading++; + +// Optional chaining still invokes: `?.` guards nullishness of the receiver, not +// the accessor. +const optionalRead = gauge?.reading; + +// A computed read invokes it too, and the name is not fixed by syntax — the two +// unresolvable things compound. +const key = 'reading'; +const computedRead = gauge[key]; + +// Reading a property in a template substitution, a condition, an argument and a +// return position — the invocation is at each of these edgeRoles. +const inTemplate = `now ${gauge.reading}`; +const inCondition = gauge.reading ? 'set' : 'unset'; +const inArgument = String(gauge.reading); +function inReturn() { return gauge.reading; } + +// The proof that this is not decidable from syntax: `plain.value` and +// `lazy.value` are the same expression shape, and only one of them runs code. +const plain = { value: 1 }; +const plainRead = plain.value; // no invocation +const lazyRead = lazy.value; // invocation, and it MUTATES + +// Same object, same property name, different behaviour after a runtime edit. +// Any static answer about `plain.value` is wrong on one side of this line. +Object.defineProperty(plain, 'value', { get() { return 42; } }); +const plainReadAfter = plain.value; // now an invocation + +// Reflect.get and the descriptor API: reading a getter WITHOUT invoking it. +const descriptor = Object.getOwnPropertyDescriptor(gauge, 'reading'); +const getterFn = descriptor.get; // the function itself, uninvoked +const invokedExplicitly = getterFn.call(gauge); +const viaReflect = Reflect.get(gauge, 'reading'); // invokes + +module.exports = { + Temperature, gauge, lazy, t, + readGetter, readDerived, readStatic, readComputed, celsius, fahrenheit, + snapshot, copied, serialised, optionalRead, computedRead, + inTemplate, inCondition, inArgument, inReturn, + plainRead, lazyRead, plainReadAfter, lazyComputations, + descriptor, getterFn, invokedExplicitly, viaReflect +}; diff --git a/parser/src/test-data/javascript/categories/call-forms/call-apply-bind.js b/parser/src/test-data/javascript/categories/call-forms/call-apply-bind.js new file mode 100644 index 000000000..87f4138ce --- /dev/null +++ b/parser/src/test-data/javascript/categories/call-forms/call-apply-bind.js @@ -0,0 +1,125 @@ +// fixture: cjs/call-forms/call-apply-bind.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 (spread) +// +// The 1,048 call sites where THE RECEIVER IS AN ARGUMENT. callKind = +// FUNCTION_CALL_CALL / FUNCTION_CALL_APPLY / FUNCTION_CALL_BIND, and +// receiverPosition = FIRST_ARGUMENT. Without that column an engine reads +// `Function.prototype.call` as the target of every one of them and never sees +// the real receiver at all. +// +// .bind is the odd one: it does NOT invoke. It returns a new function, so the +// call site is a call to bind and the invocation happens somewhere else, maybe +// never, maybe many times. +// +// Grounded in a runtime's internal utilities, a utility library's `apply` helper, and the +// borrowed-method idiom (`Array.prototype.slice.call(arguments)`) that is in +// every file written before 2015. + +'use strict'; + +function describe(prefix, suffix) { + return prefix + (this && this.name) + suffix; +} + +const target = { name: 'target' }; +const other = { name: 'other' }; + +// --- .call: receiver first, arguments spread out -------------------------------- + +const viaCall = describe.call(target, '[', ']'); +const viaCallNoArgs = describe.call(target); +const viaCallNull = describe.call(null, '<', '>'); // sloppy: global; strict: null + +// --- .apply: receiver first, arguments as an ARRAY -------------------------------- +// +// argumentCount at the call site is 2, and the callee's arity is whatever the +// array's length turns out to be. The count is not the arity. + +const viaApply = describe.apply(target, ['(', ')']); +const args = ['{', '}']; +const viaApplyVariable = describe.apply(target, args); +const viaApplyArguments = (function () { + return describe.apply(target, arguments); +})('«', '»'); + +// --- .bind: no invocation here ------------------------------------------------------ + +const boundToTarget = describe.bind(target); +const boundWithPrefix = describe.bind(target, '#'); // partial application +const laterA = boundToTarget('a', 'b'); +const laterB = boundWithPrefix('!'); + +// Binding an already-bound function does not rebind the receiver; it only adds +// more leading arguments. +const doubleBound = boundWithPrefix.bind(other, '?'); +const doubleResult = doubleBound(); + +// A bound function used as a constructor: `new` OVERRIDES the bound receiver, +// which is the one case where bind's guarantee does not hold. +function Point(x, y) { this.x = x; this.y = y; } +const BoundPoint = Point.bind(null, 1); +const point = new BoundPoint(2); + +// --- borrowed methods --------------------------------------------------------------- +// +// The receiver is not an instance of the method's own type. The method comes +// from one prototype and is applied to something else entirely, which is +// structural typing enforced at runtime and invisible to any declared-type model. + +function toArray() { + return Array.prototype.slice.call(arguments); +} +const arrayLike = { 0: 'a', 1: 'b', length: 2 }; +const sliced = Array.prototype.slice.call(arrayLike); +const joined = Array.prototype.join.call(arrayLike, '-'); +const hasOwn = Object.prototype.hasOwnProperty.call(arrayLike, 'length'); +const typeTag = Object.prototype.toString.call(arrayLike); +const maxOf = Math.max.apply(null, [1, 5, 3]); + +// The uncurried form: .call itself borrowed via .bind. `uncurryThis` is in +// a runtime's internal utilities and in every polyfill library, and the callee here +// is three levels of indirection away from the function that eventually runs. +const uncurryThis = Function.prototype.call.bind(Function.prototype.call); +const uncurried = uncurryThis(describe, target, '<<', '>>'); + +const hasOwnFast = Function.prototype.call.bind(Object.prototype.hasOwnProperty); +const fastCheck = hasOwnFast(arrayLike, '0'); + +// --- Reflect.apply: the same operation as a plain function call --------------------- +// +// No `.call` or `.apply` token anywhere; a matcher keyed on the member name +// misses it, and the receiver is still an argument. + +const viaReflect = Reflect.apply(describe, target, ['R', 'R']); + +// --- spread: the modern replacement for .apply --------------------------------------- +// +// `f(...args)` does what `f.apply(null, args)` did. hasSpreadArgument = true and +// the receiver is back in syntactic position, so the two spellings of one intent +// produce different call kinds. + +const viaSpread = describe(...args); +const viaSpreadMethod = target.describe ? target.describe(...args) : null; + +// --- super-method call, prototype era ------------------------------------------------- +// +// Before `super`, calling a superclass method meant naming its prototype and +// passing the receiver. This is the same construct as the borrowed methods above +// and it means something completely different. + +function Base() {} +Base.prototype.render = function () { return 'base'; }; +function Derived() { Base.call(this); } +Derived.prototype = Object.create(Base.prototype); +Derived.prototype.render = function () { + return Base.prototype.render.call(this) + '+derived'; +}; + +module.exports = { + describe, viaCall, viaCallNoArgs, viaCallNull, viaApply, viaApplyVariable, + viaApplyArguments, boundToTarget, boundWithPrefix, laterA, laterB, + doubleResult, point, toArray, sliced, joined, hasOwn, typeTag, maxOf, + uncurried, fastCheck, viaReflect, viaSpread, viaSpreadMethod, Derived +}; diff --git a/parser/src/test-data/javascript/categories/call-forms/computed-and-optional-calls.js b/parser/src/test-data/javascript/categories/call-forms/computed-and-optional-calls.js new file mode 100644 index 000000000..803442db8 --- /dev/null +++ b/parser/src/test-data/javascript/categories/call-forms/computed-and-optional-calls.js @@ -0,0 +1,114 @@ +// fixture: cjs/call-forms/computed-and-optional-calls.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2020 (optional chaining, nullish coalescing) +// +// Two call kinds syntax cannot fully decide. +// +// COMPUTED_CALL (663 sites measured): `obj[expr]()`. calleeName is "" because +// there is no name in the syntax to put there, and inventing one — say, by +// constant-folding the expression when it happens to be a literal — is a +// resolution decision, not a parse. isComputedName says the name is not fixed. +// +// OPTIONAL_CALL (28 sites): `a?.b()`. Differs in REACHABILITY, not in target. +// The target is exactly the target of `a.b()`; whether the call happens is a +// runtime fact about `a`. +// +// INDEX_CALL is the reserved value nearby, and it is reserved because whether a +// computed access goes THROUGH AN INDEX SIGNATURE is a fact about the receiver's +// type. Nothing in this file can tell a parser that, which is the point. +// +// Grounded in a web framework's `methods.forEach(function(method){ app[method] = ... })`, +// a utility library's `_[name]` dispatch, and every options-object handler table. + +'use strict'; + +const handlers = { + get(path) { return 'GET ' + path; }, + post(path) { return 'POST ' + path; }, + 'delete'(path) { return 'DELETE ' + path; } +}; + +// --- computed calls ------------------------------------------------------------- + +// A string literal in brackets. Syntax DOES fix this name — it is the control +// that stops a parser from calling everything in brackets unnameable. +const literalKey = handlers['get']('/a'); + +// A variable. The name is not knowable. +const method = process.env.FIXTURE_METHOD || 'post'; +const viaVariable = handlers[method]('/b'); + +// An expression. Two operands and a concatenation. +const viaExpression = handlers['de' + 'lete']('/c'); + +// A member expression as the key. +const config = { verb: 'get' }; +const viaMember = handlers[config.verb]('/d'); + +// The dispatch-table loop. One call site in the source, N targets at runtime, +// and the set of targets is the object's keys. +const results = []; +for (const name of Object.keys(handlers)) { + results.push(handlers[name]('/loop')); +} + +// A numeric index into an array of functions. Same construct; the "name" is an +// integer and the receiver is an Array. +const pipeline = [(x) => x + 1, (x) => x * 2]; +const piped = pipeline[0](pipeline[1](3)); + +// A symbol key. Not a string at all, and not printable as a name. +const TAG = Symbol('tag'); +const symbolKeyed = { [TAG]: () => 'symbol call' }; +const viaSymbol = symbolKeyed[TAG](); + +// A computed call whose receiver is itself computed. +const registry = { handlers }; +const nestedComputed = registry['handlers'][method]('/nested'); + +// A computed access that is READ, not called. The control for the call kind. +const readOnly = handlers[method]; + +// --- optional calls ----------------------------------------------------------------- + +const maybe = process.env.FIXTURE_NULL ? null : handlers; + +// Optional member, then a call. The `?.` guards the MEMBER ACCESS; if `maybe` +// is null the whole chain short-circuits and `get` is never looked up. +const optionalMember = maybe?.get('/e'); + +// Optional CALL: guards the invocation. `maybe.get` is looked up; if it is +// nullish it is not called. +const optionalCall = maybe?.get?.('/f'); + +// Optional computed call — both forms at once. +const optionalComputed = maybe?.[method]?.('/g'); + +// A long chain where the short-circuit skips everything downstream, including +// argument evaluation. The argument's side effect does not happen. +let sideEffectRan = false; +const deep = maybe?.missing?.deeper?.(sideEffectRan = true); + +// Optional call on a function-valued variable. +const callback = null; +const optionalCallback = callback?.(); + +// `?.` followed by a NON-optional link. Only the first link is guarded; the +// rest throw if the chain is broken past it. This is the common misuse. +const partiallyGuarded = maybe?.get('/h'); + +// Nullish coalescing beside it, which is a different operator with a different +// short circuit: `??` tests the VALUE, `?.` tests the RECEIVER. +const withDefault = maybe?.get?.('/i') ?? 'default'; + +// Optional call in a tagged-template position is a SYNTAX ERROR, and optional +// `new` is too. Recorded rather than written: `new a?.b()` and ``a?.b`x` `` do +// not parse, so no fixture can contain them. + +module.exports = { + handlers, literalKey, viaVariable, viaExpression, viaMember, results, + piped, viaSymbol, nestedComputed, readOnly, + optionalMember, optionalCall, optionalComputed, deep, sideEffectRan, + optionalCallback, partiallyGuarded, withDefault +}; diff --git a/parser/src/test-data/javascript/categories/call-forms/dynamic-code.js b/parser/src/test-data/javascript/categories/call-forms/dynamic-code.js new file mode 100644 index 000000000..049d0eb33 --- /dev/null +++ b/parser/src/test-data/javascript/categories/call-forms/dynamic-code.js @@ -0,0 +1,117 @@ +// fixture: cjs/call-forms/dynamic-code.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// callKind = DYNAMIC_CODE_CALL, isDynamicCode = true. The target is UNKNOWABLE +// and the schema's rule is that it is emitted and never guessed — the call site +// is complete precisely BECAUSE it says the target cannot be named +// (resolutionOutcome = DYNAMIC_CODE in the IR-completeness gate's fourth bucket). +// +// Two sites in the whole schema corpus, which is exactly why a fixture is +// needed: an enum value that no corpus exercises cannot be told apart from an +// unimplemented one. +// +// The distinction that matters and is easy to miss: DIRECT eval sees the calling +// scope and can create bindings in it; INDIRECT eval (any call to eval that is +// not the bare identifier `eval`) runs in global scope and cannot. Two +// expressions that differ only in punctuation, with different scope effects. +// +// Grounded in the JSON-parsing fallbacks of old libraries, the template +// compilers that build functions from strings (every JS template engine), and +// the `new Function('return this')()` globalThis shim. + +'use strict'; + +const outerBinding = 'visible to direct eval'; + +// --- direct eval: sees and can WRITE the local scope -------------------------------- + +function directEval() { + const local = 1; + // Reads a local. The string is data; the binding it reads is real. + const read = eval('local + 1'); + // Creates a binding in THIS scope. In sloppy mode `var injected` would be + // visible after this line; in strict mode eval gets its own scope, so it is + // not. Same call, two scope structures, decided by the directive. + eval('var injected = 42;'); + return [read, typeof injected]; +} + +// A direct eval whose argument is not a literal. Nothing about the code it runs +// is in this file. +function evalVariable(source) { + return eval(source); +} + +// --- indirect eval: global scope only ------------------------------------------------- +// +// Each of these is the SAME function and a different semantics. + +const geval = eval; +function indirectEval() { + const local = 'not visible'; + const viaAlias = geval('typeof local'); // 'undefined' + const viaComma = (0, eval)('typeof local'); // 'undefined' + const viaMember = globalThis.eval('typeof local'); // 'undefined' + const viaOptional = eval?.('typeof local'); // 'undefined' — indirect + return [viaAlias, viaComma, viaMember, viaOptional]; +} + +// --- new Function: always global scope, never the caller's ----------------------------- +// +// The parameters and body are STRINGS, so the function's arity and its entire +// contents are runtime values. A js_method row for the produced function cannot +// exist: there is no declaration node. + +const add = new Function('a', 'b', 'return a + b;'); +const sum = add(1, 2); + +// Called without `new` — identical behaviour, and the call kind differs. +const mul = Function('a', 'b', 'return a * b;'); + +// The globalThis shim, which is why `new Function` survives in libraries that +// would otherwise never use it. +const getGlobal = new Function('return this')(); + +// A body assembled from data. Every JS template engine +// compile to exactly this. +function compile(template) { + const body = 'return `' + template.replace(/`/g, '\\`') + '`;'; + return new Function('data', 'with (data) { ' + body + ' }'); +} +const rendered = compile('hello ${name}')({ name: 'world' }); + +// --- the timer forms that take a string ------------------------------------------------- +// +// setTimeout with a STRING argument evaluates it as code, in global scope. The +// callee is setTimeout; the code that runs is in the argument. + +const timer = setTimeout('globalThis.__timerRan = true', 0); +clearTimeout(timer); + +// The control: the same function with a FUNCTION argument. Not dynamic code. +const properTimer = setTimeout(() => { globalThis.__timerRan = true; }, 0); +clearTimeout(properTimer); + +// --- eval-adjacent things that are NOT dynamic code --------------------------------------- +// +// The controls. Each parses data rather than code, and a matcher keyed on +// "builds a value from a string" would sweep all of them in. + +const parsed = JSON.parse('{"a":1}'); +const asNumber = Number('42'); +const asRegExp = new RegExp('^a' + 'b$', 'i'); // a pattern, not code +const tagged = String.raw`no\escape`; + +// A local variable named `eval` — legal in sloppy mode, and it is not eval. +function shadowedEval() { + const evalLike = { call: () => 'not eval' }; + return evalLike.call(); +} + +module.exports = { + outerBinding, directEval, evalVariable, indirectEval, + add, sum, mul, getGlobal, compile, rendered, + parsed, asNumber, asRegExp, tagged, shadowedEval +}; diff --git a/parser/src/test-data/javascript/categories/call-forms/generators-and-iterators.js b/parser/src/test-data/javascript/categories/call-forms/generators-and-iterators.js new file mode 100644 index 000000000..ec0c1254d --- /dev/null +++ b/parser/src/test-data/javascript/categories/call-forms/generators-and-iterators.js @@ -0,0 +1,131 @@ +// fixture: cjs/call-forms/generators-and-iterators.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2018 (async iteration) +// +// GENERATOR_RESUME, reserved with a zero-row assertion. `it.next()` is +// syntactically an ordinary method call and semantically a RESUMPTION of a +// suspended frame: control re-enters the generator body in the middle, at the +// `yield` it last stopped at. Which yield that is depends on how many times +// `next` has been called before, so no static answer exists. +// +// The declarations are ordinary and must be emitted — isGenerator on js_method, +// 248 yield sites in the schema's corpus. It is only the resumption edge that is +// unreadable. +// +// `for...of` is the same thing with the calls implicit: it invokes +// [Symbol.iterator](), then next() repeatedly, then possibly return(), and none +// of those appears in the source. + +'use strict'; + +// --- declarations -------------------------------------------------------------- + +function* counter(from, to) { + for (let i = from; i <= to; i += 1) { + // The value SENT IN by next(v) is the result of this expression. Data flows + // backwards through a yield, which no call-graph edge models. + const sent = yield i; + if (sent === 'stop') { return 'stopped'; } + } + return 'done'; +} + +// yield* delegates: the inner generator's yields pass through, and its return +// value becomes the value of the yield* expression. One call site, N resumptions +// of a DIFFERENT function. +function* delegating() { + const inner = yield* counter(1, 3); + yield inner; +} + +// A generator method, a static generator, and a generator on a prototype. +class Tree { + constructor(value, children) { this.value = value; this.children = children || []; } + *walk() { + yield this.value; + for (const child of this.children) { yield* child.walk(); } + } + static *range(n) { for (let i = 0; i < n; i += 1) { yield i; } } + [Symbol.iterator]() { return this.walk(); } +} + +function Legacy() {} +Legacy.prototype.items = function* () { yield 1; }; + +// An async generator, and its async iterator protocol. +async function* streamed(items) { + for (const item of items) { + yield await Promise.resolve(item); + } +} + +// --- explicit resumption --------------------------------------------------------- +// +// Each of these re-enters a suspended body. `next`, `return` and `throw` are the +// three entry points, and `throw` injects an exception AT the yield. + +const it = counter(1, 3); +const a = it.next(); // runs to the first yield +const b = it.next(); // resumes AFTER that yield +const c = it.next('stop'); // resumes and delivers a value into the body +const d = it.return('early'); // resumes as if a return statement were at the yield +const e = counter(1, 3); +e.next(); +let thrown; +try { e.throw(new Error('injected')); } catch (err) { thrown = err.message; } + +// --- implicit resumption ----------------------------------------------------------- +// +// No `.next()` anywhere. for-of does all of it. + +const collected = []; +for (const n of counter(1, 3)) { collected.push(n); } + +// Destructuring from an iterable: calls next() exactly as many times as there +// are targets, then return(). +const [firstItem, secondItem] = counter(10, 20); + +// Spread: calls next() until done. +const spread = [...counter(1, 4)]; + +// Array.from, and a Map/Set built from an iterable. +const fromIterable = Array.from(counter(1, 3)); +const asSet = new Set(counter(1, 3)); + +// yield inside a for-of over another generator, two levels deep. +const tree = new Tree('root', [new Tree('a'), new Tree('b', [new Tree('c')])]); +const walked = [...tree]; + +// --- a hand-written iterator: the protocol without a generator ----------------------- +// +// Same for-of, same next() calls, and no generator function anywhere. The +// resumption is now an ordinary call to an ordinary method, which is why the +// distinction cannot be drawn from the call site. + +const manual = { + [Symbol.iterator]() { + let i = 0; + return { + next() { return i < 3 ? { value: i++, done: false } : { value: undefined, done: true }; }, + return() { return { done: true }; } + }; + } +}; +const manualCollected = [...manual]; + +// --- async iteration ------------------------------------------------------------------- + +async function consume() { + const out = []; + for await (const item of streamed([1, 2, 3])) { out.push(item); } + const ai = streamed([1]); + const next = await ai.next(); + return { out, next }; +} + +module.exports = { + counter, delegating, Tree, Legacy, streamed, + a, b, c, d, thrown, collected, firstItem, secondItem, spread, + fromIterable, asSet, walked, manual, manualCollected, consume +}; diff --git a/parser/src/test-data/javascript/categories/call-forms/proxy-traps.js b/parser/src/test-data/javascript/categories/call-forms/proxy-traps.js new file mode 100644 index 000000000..1835a1027 --- /dev/null +++ b/parser/src/test-data/javascript/categories/call-forms/proxy-traps.js @@ -0,0 +1,136 @@ +// fixture: cjs/call-forms/proxy-traps.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// PROXY_TRAP_CALL, reserved with a zero-row assertion. A Proxy makes EVERY +// fundamental operation on an object a potential function call: property read, +// property write, `in`, `delete`, enumeration, invocation, construction. None of +// them looks like a call, and whether a given expression hits a trap depends on +// whether the value flowing into it is a Proxy — which is a runtime fact. +// +// The reason to have the fixture at all, given the value is reserved: it is the +// evidence for the reservation. A corpus with no Proxy in it makes +// PROXY_TRAP_CALL indistinguishable from an unimplemented enum value, and §4 of +// BUILDING-A-PARSER.md says that distinction has to be checkable. +// +// Grounded in the `require('module')._resolveFilename` interception used by +// test tooling, immutable-draft libraries, and a reactivity core. + +'use strict'; + +const target = { + name: 'target', + greet(who) { return 'hello ' + who; } +}; + +const log = []; + +const handler = { + get(obj, prop, receiver) { + log.push('get:' + String(prop)); + return Reflect.get(obj, prop, receiver); + }, + set(obj, prop, value, receiver) { + log.push('set:' + String(prop)); + return Reflect.set(obj, prop, value, receiver); + }, + has(obj, prop) { + log.push('has:' + String(prop)); + return Reflect.has(obj, prop); + }, + deleteProperty(obj, prop) { + log.push('delete:' + String(prop)); + return Reflect.deleteProperty(obj, prop); + }, + ownKeys(obj) { + log.push('ownKeys'); + return Reflect.ownKeys(obj); + }, + getOwnPropertyDescriptor(obj, prop) { + return Reflect.getOwnPropertyDescriptor(obj, prop); + }, + defineProperty(obj, prop, desc) { + log.push('defineProperty:' + String(prop)); + return Reflect.defineProperty(obj, prop, desc); + }, + getPrototypeOf(obj) { + log.push('getPrototypeOf'); + return Reflect.getPrototypeOf(obj); + } +}; + +const proxied = new Proxy(target, handler); + +// Every line below invokes a handler method. Not one is a CallExpression whose +// callee names the function that runs. +const read = proxied.name; // get trap +proxied.name = 'renamed'; // set trap +const present = 'name' in proxied; // has trap +delete proxied.missing; // deleteProperty trap +const keys = Object.keys(proxied); // ownKeys + getOwnPropertyDescriptor +const spread = { ...proxied }; // ownKeys + get, once per key +const proto = Object.getPrototypeOf(proxied); + +// A method call through a proxy is TWO operations: a get trap that returns the +// function, then an ordinary invocation of it. One source expression, two +// distinct things happening, and only the second is a call site. +const greeted = proxied.greet('world'); + +// --- apply and construct traps ------------------------------------------------------ +// +// A proxy around a FUNCTION intercepts calling and `new`. The callee of the +// call site is `callable`; the function that runs is `apply`. + +function bare(x) { return x * 2; } + +const callable = new Proxy(bare, { + apply(fn, thisArg, argList) { + log.push('apply'); + return Reflect.apply(fn, thisArg, argList) + 1; + }, + construct(fn, argList, newTarget) { + log.push('construct'); + return Reflect.construct(fn, argList, newTarget); + } +}); + +const applied = callable(21); // apply trap: 43, not 42 +const constructed = new callable(1); // construct trap + +// --- the trap that makes a MISSING property look present ------------------------------ +// +// An autovivifying proxy. `anything.at.all.works` resolves, so no static claim +// about which properties exist can be correct. + +const magic = new Proxy({}, { + get(_obj, prop) { + if (prop === Symbol.toPrimitive || typeof prop === 'symbol') { return undefined; } + return magic; + } +}); +const chained = magic.a.b.c.d; + +// --- a revocable proxy ------------------------------------------------------------------ +// +// After revoke() every operation throws. The same expression is valid and then +// is not, with no source change between them. + +const { proxy: revocable, revoke } = Proxy.revocable(target, handler); +const beforeRevoke = revocable.name; +revoke(); + +// --- the control --------------------------------------------------------------------- +// +// Identical expressions against the raw target. No traps, no calls. + +const plainRead = target.name; +target.name = 'target'; +const plainIn = 'name' in target; + +module.exports = { + target, proxied, handler, log, + read, present, keys, spread, proto, greeted, + callable, applied, constructed, chained, beforeRevoke, + plainRead, plainIn +}; diff --git a/parser/src/test-data/javascript/categories/call-forms/tagged-templates.js b/parser/src/test-data/javascript/categories/call-forms/tagged-templates.js new file mode 100644 index 000000000..a045dd7da --- /dev/null +++ b/parser/src/test-data/javascript/categories/call-forms/tagged-templates.js @@ -0,0 +1,100 @@ +// fixture: cjs/call-forms/tagged-templates.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// callKind = TAGGED_TEMPLATE_CALL, 20 sites measured. A call with NO PARENTHESES +// and no comma-separated argument list: the tag function receives the string +// parts as an array and each substitution as a further argument, so +// argumentCount is 1 + the number of substitutions and none of them is written +// as an argument. +// +// The template's raw strings are also a `strings.raw` array, and the SAME array +// object is passed on every evaluation of that call site — which is what makes +// the WeakMap-keyed caching in CSS-in-JS and query-tag libraries work. +// +// Grounded in a query library's `gql`, a CSS-in-JS library's `styled.div`, and +// String.raw / a terminal-colour library's template literal API. + +'use strict'; + +function tag(strings, ...values) { + return strings.raw.join('|') + '::' + values.join(','); +} + +const name = 'world'; +const count = 2; + +// --- the shapes ------------------------------------------------------------------- + +// No substitutions at all. One argument, an array of length 1. +const noSubs = tag`plain`; + +// One substitution: two arguments. +const oneSub = tag`hello ${name}`; + +// Several, including adjacent ones with an empty string between them. +const manySubs = tag`${name}${count} items for ${name}`; + +// A substitution containing a CALL. The nested call is a call site of its own, +// nested inside a tagged template's argument list that has no parentheses. +const nested = tag`upper: ${name.toUpperCase()}`; + +// A multiline template. +const multiline = tag` + line one + line two ${count} +`; + +// --- the tag is not always an identifier --------------------------------------------- + +const tags = { sql: tag, html: tag }; + +// A member expression as the tag. The receiver is `tags`, and the whole +// receiver/callee analysis of a method call applies with no parentheses in sight. +const memberTag = tags.sql`SELECT 1`; + +// A computed member as the tag. +const key = 'html'; +const computedTag = tags[key]`${name}`; + +// A CALL as the tag — the CSS-in-JS signature. `styled('div')` returns +// the tag, so there are two call sites: one ordinary, one tagged. +function styled(element) { + return function (strings, ...values) { return element + ':' + strings.join('') + values.join(''); }; +} +const styledTag = styled('div')`color: ${'red'};`; + +// A chain: call, member, tagged. +const chained = styled('a').call(null, ['x'], []); + +// String.raw, the builtin tag. Its whole purpose is the `.raw` array. What is +// load-bearing in the literal is the backslash sequences that a normal string +// would interpret — `\U`, `\f`, `\n` — not the path they spell. The segment was +// renamed away from a home-directory shape because the OSS scrub gate flags +// `:\Users\` as a home-path disclosure, and the gate cannot know a +// fixture's path is synthetic. That is the gate behaving correctly. +const rawPath = String.raw`D:\Uploads\fixture\n`; + +// A tag stored in a variable that is reassigned. Which function runs is not +// fixed by the call site. +let current = tag; +const first = current`before`; +current = (strings) => strings.join('!'); +const second = current`after`; + +// --- the control: an UNTAGGED template ------------------------------------------------ +// +// Same literal, no tag, no call. A parser that mints a call site per +// TemplateExpression is wrong here, and the substitution is still an expression +// that must be walked — TEMPLATE_SUBSTITUTION is its edgeRole. + +const untagged = `hello ${name}, ${count} times, ${name.length + count}`; +const noSubstitutions = `just a string`; +const nestedTemplate = `outer ${`inner ${name}`} end`; + +module.exports = { + tag, noSubs, oneSub, manySubs, nested, multiline, + memberTag, computedTag, styledTag, chained, rawPath, + first, second, untagged, noSubstitutions, nestedTemplate +}; diff --git a/parser/src/test-data/javascript/categories/directives/cli-entry.js b/parser/src/test-data/javascript/categories/directives/cli-entry.js new file mode 100644 index 000000000..dbf0e2843 --- /dev/null +++ b/parser/src/test-data/javascript/categories/directives/cli-entry.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// fixture: cjs/directives/cli-entry.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// A shebang. commentKind = SHEBANG, and it is the one comment form whose +// POSITION is load-bearing: it is legal only as the first two bytes of a file, +// it is not a comment to any other tool, and `ts.createSourceFile` gives it its +// own trivia kind rather than folding it into a line comment. +// +// Every CLI entry point published to npm has one — +// and it is the reason `bin` scripts cannot begin with a blank line. Until now +// no fixture had one, so SHEBANG carried zero rows and was indistinguishable +// from an unimplemented value. + +'use strict'; + +const path = require('path'); + +function main(argv) { + const [, , command = 'help'] = argv; + switch (command) { + case 'run': return 0; + case 'help': return 1; + default: return 2; + } +} + +if (require.main === module) { + process.exitCode = main(process.argv); +} + +module.exports = { main, binName: path.basename(__filename) }; diff --git a/parser/src/test-data/javascript/categories/directives/directive-comments.js b/parser/src/test-data/javascript/categories/directives/directive-comments.js new file mode 100644 index 000000000..7eb3d62a9 --- /dev/null +++ b/parser/src/test-data/javascript/categories/directives/directive-comments.js @@ -0,0 +1,62 @@ +// fixture: cjs/directives/directive-comments.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// Comments that are INSTRUCTIONS rather than prose. js_comment.directiveKind +// enumerates six, and a directive comment changes how a tool treats the file — +// so reading it as an ordinary comment loses a fact about the analysis itself, +// not about the program. +// +// The source-map footer that used to live at the bottom of this file has moved +// to source-map-footer-short.js and source-map-footer-long.js, because it was +// making this file read as GENERATED_MONOLITH — and a file with that provenance +// contributes zero rows to any coverage denominator (gate 7.3.5), so the +// @ts-check coverage below was being silently dropped. Keeping the two concerns +// in one file made this fixture's own coverage depend on an unrelated ruling. +// +// The one that matters most here is @ts-check / @ts-nocheck. The schema's oracle +// runs with `checkJs: true` project-wide, and these two comments OVERRIDE that +// PER FILE — §9 of BUILDING-A-PARSER.md, read the governing config per file, in +// its smallest form. A file with @ts-nocheck yields no diagnostics at all, so an +// oracle that ignores the comment reports a clean file as evidence. +// +// Grounded in the @ts-check adoption pattern used across Node's own tools and in +// the sourceMappingURL footer every build step appends. + +// @ts-check + +'use strict'; + +/* eslint-disable no-console */ +// eslint-disable-next-line no-unused-vars +const unusedOnPurpose = 1; + +/** + * @param {string} name + * @returns {string} + */ +function greet(name) { + // @ts-expect-error - the next line is wrong on purpose and the comment says so + return name.notAMethod(); +} + +// @ts-ignore +const alsoWrong = greet(42); + +/* istanbul ignore next */ +function uncovered() { return null; } + +// A directive-LOOKING comment that is not one. `@ts-check` only counts at the +// top of a file, and prose mentioning @ts-nocheck is prose. +// This line talks about @ts-nocheck without being it. + +// And the same trap for the linter. MEASURED: the directive detector is +// `/\beslint(-disable|-enable|\s)/`, so the word followed by a SPACE in prose +// is classified as an ESLINT directive. The next line is prose about how an +// eslint config resolver loads plugins, and it must NOT be a DIRECTIVE row. A +// real directive names a rule or an action; a sentence does not. +// Found because a scrub of a different fixture's prose made a DIRECTIVE row +// disappear from the fact base, and the fingerprint diff noticed. + +module.exports = { greet, alsoWrong, uncovered, unusedOnPurpose }; diff --git a/parser/src/test-data/javascript/categories/directives/module-attached-comments.js b/parser/src/test-data/javascript/categories/directives/module-attached-comments.js new file mode 100644 index 000000000..bb249d47c --- /dev/null +++ b/parser/src/test-data/javascript/categories/directives/module-attached-comments.js @@ -0,0 +1,30 @@ +// fixture: categories/directives/module-attached-comments.js +// nature: runtime-bearing +#!/usr/bin/env node +/*! + * JsCommentAttachmentKind.MODULE — declared, zero rows. + * + * The enum's own docstring names exactly three things: "a file-level comment: a + * licence header, a @flow pragma, a shebang". This file has the licence header + * and the shebang; flow/flow-pragma.js has the third. All of them are emitted + * with attachedToKind = NONE. + * + * NONE is the value for "not attached to anything", and a file-level comment IS + * attached to something — the module. Collapsing the two loses the distinction + * between a licence header and a stray comment in the middle of a function, which + * is the distinction the value exists to make. + * + * MODULE was invisible to whole-cell matching because the string MODULE is also a + * value of JsScopeKind and of JsBindingResolution, and both of those emit. + * + * module system: CommonJS, governed by categories/package.json. + */ +'use strict'; + +// A comment attached to a variable — the control. +const attachedToAVariable = 1; + +/** A JSDoc attached to a method — the second control. */ +function attachedToAMethod() { return attachedToAVariable; } + +module.exports = { attachedToAMethod }; diff --git a/parser/src/test-data/javascript/categories/directives/no-check.js b/parser/src/test-data/javascript/categories/directives/no-check.js new file mode 100644 index 000000000..96a51aab9 --- /dev/null +++ b/parser/src/test-data/javascript/categories/directives/no-check.js @@ -0,0 +1,28 @@ +// @ts-nocheck +// fixture: cjs/directives/no-check.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The other half of the pair. @ts-nocheck must be the FIRST comment in the file +// to take effect, which is why it precedes this header rather than following it — +// moving it three lines down silently disables it, and nothing reports that. +// +// Under the oracle's `checkJs: true` this file yields zero diagnostics no matter +// what it contains. The JSDoc below contradicts the code exactly as +// jsdoc/contradicting-jsdoc.js does, and the difference is that here the +// contradiction is DECLARED UNCHECKED. A fact base that cannot tell the two +// files apart is asserting that one of them was verified. + +'use strict'; + +/** + * @param {number} a + * @param {number} b + * @returns {number} + */ +function add(a, b) { + return a.concat(b); +} + +module.exports = { add }; diff --git a/parser/src/test-data/javascript/categories/directives/source-map-footer-long.js b/parser/src/test-data/javascript/categories/directives/source-map-footer-long.js new file mode 100644 index 000000000..7fa9c07c6 --- /dev/null +++ b/parser/src/test-data/javascript/categories/directives/source-map-footer-long.js @@ -0,0 +1,467 @@ +// fixture: cjs/directives/source-map-footer-long.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// HALF TWO OF A PAIRED REPRO. See source-map-footer-short.js for the argument. +// This file carries the identical trailing a source-map footer comment line and is +// deliberately larger than the 4,096-byte head window that BUNDLER_PREAMBLES is +// searched within, so the footer is invisible to the classifier and this file is +// PROJECT while its shorter twin is GENERATED_MONOLITH. +// +// The padding below is a real dispatch table rather than filler, so the file is +// a plausible module: a length-sensitive check must not be satisfiable by +// something that only looks like code. + +'use strict'; + +const handlers = Object.create(null); + +/** + * Handler 1. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step01 = function step01(input) { + const weighted = input.weight * 1; + return input.id + ':' + weighted; +}; + +/** + * Handler 2. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step02 = function step02(input) { + const weighted = input.weight * 2; + return input.id + ':' + weighted; +}; + +/** + * Handler 3. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step03 = function step03(input) { + const weighted = input.weight * 3; + return input.id + ':' + weighted; +}; + +/** + * Handler 4. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step04 = function step04(input) { + const weighted = input.weight * 4; + return input.id + ':' + weighted; +}; + +/** + * Handler 5. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step05 = function step05(input) { + const weighted = input.weight * 5; + return input.id + ':' + weighted; +}; + +/** + * Handler 6. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step06 = function step06(input) { + const weighted = input.weight * 6; + return input.id + ':' + weighted; +}; + +/** + * Handler 7. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step07 = function step07(input) { + const weighted = input.weight * 7; + return input.id + ':' + weighted; +}; + +/** + * Handler 8. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step08 = function step08(input) { + const weighted = input.weight * 8; + return input.id + ':' + weighted; +}; + +/** + * Handler 9. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step09 = function step09(input) { + const weighted = input.weight * 9; + return input.id + ':' + weighted; +}; + +/** + * Handler 10. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step10 = function step10(input) { + const weighted = input.weight * 10; + return input.id + ':' + weighted; +}; + +/** + * Handler 11. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step11 = function step11(input) { + const weighted = input.weight * 11; + return input.id + ':' + weighted; +}; + +/** + * Handler 12. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step12 = function step12(input) { + const weighted = input.weight * 12; + return input.id + ':' + weighted; +}; + +/** + * Handler 13. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step13 = function step13(input) { + const weighted = input.weight * 13; + return input.id + ':' + weighted; +}; + +/** + * Handler 14. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step14 = function step14(input) { + const weighted = input.weight * 14; + return input.id + ':' + weighted; +}; + +/** + * Handler 15. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step15 = function step15(input) { + const weighted = input.weight * 15; + return input.id + ':' + weighted; +}; + +/** + * Handler 16. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step16 = function step16(input) { + const weighted = input.weight * 16; + return input.id + ':' + weighted; +}; + +/** + * Handler 17. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step17 = function step17(input) { + const weighted = input.weight * 17; + return input.id + ':' + weighted; +}; + +/** + * Handler 18. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step18 = function step18(input) { + const weighted = input.weight * 18; + return input.id + ':' + weighted; +}; + +/** + * Handler 19. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step19 = function step19(input) { + const weighted = input.weight * 19; + return input.id + ':' + weighted; +}; + +/** + * Handler 20. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step20 = function step20(input) { + const weighted = input.weight * 20; + return input.id + ':' + weighted; +}; + +/** + * Handler 21. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step21 = function step21(input) { + const weighted = input.weight * 21; + return input.id + ':' + weighted; +}; + +/** + * Handler 22. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step22 = function step22(input) { + const weighted = input.weight * 22; + return input.id + ':' + weighted; +}; + +/** + * Handler 23. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step23 = function step23(input) { + const weighted = input.weight * 23; + return input.id + ':' + weighted; +}; + +/** + * Handler 24. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step24 = function step24(input) { + const weighted = input.weight * 24; + return input.id + ':' + weighted; +}; + +/** + * Handler 25. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step25 = function step25(input) { + const weighted = input.weight * 25; + return input.id + ':' + weighted; +}; + +/** + * Handler 26. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step26 = function step26(input) { + const weighted = input.weight * 26; + return input.id + ':' + weighted; +}; + +/** + * Handler 27. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step27 = function step27(input) { + const weighted = input.weight * 27; + return input.id + ':' + weighted; +}; + +/** + * Handler 28. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step28 = function step28(input) { + const weighted = input.weight * 28; + return input.id + ':' + weighted; +}; + +/** + * Handler 29. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step29 = function step29(input) { + const weighted = input.weight * 29; + return input.id + ':' + weighted; +}; + +/** + * Handler 30. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step30 = function step30(input) { + const weighted = input.weight * 30; + return input.id + ':' + weighted; +}; + +/** + * Handler 31. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step31 = function step31(input) { + const weighted = input.weight * 31; + return input.id + ':' + weighted; +}; + +/** + * Handler 32. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step32 = function step32(input) { + const weighted = input.weight * 32; + return input.id + ':' + weighted; +}; + +/** + * Handler 33. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step33 = function step33(input) { + const weighted = input.weight * 33; + return input.id + ':' + weighted; +}; + +/** + * Handler 34. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step34 = function step34(input) { + const weighted = input.weight * 34; + return input.id + ':' + weighted; +}; + +/** + * Handler 35. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step35 = function step35(input) { + const weighted = input.weight * 35; + return input.id + ':' + weighted; +}; + +/** + * Handler 36. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step36 = function step36(input) { + const weighted = input.weight * 36; + return input.id + ':' + weighted; +}; + +/** + * Handler 37. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step37 = function step37(input) { + const weighted = input.weight * 37; + return input.id + ':' + weighted; +}; + +/** + * Handler 38. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step38 = function step38(input) { + const weighted = input.weight * 38; + return input.id + ':' + weighted; +}; + +/** + * Handler 39. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step39 = function step39(input) { + const weighted = input.weight * 39; + return input.id + ':' + weighted; +}; + +/** + * Handler 40. Registered by name so the table is reachable by a computed call. + * + * @param {{ id: string, weight: number }} input + * @returns {string} + */ +handlers.step40 = function step40(input) { + const weighted = input.weight * 40; + return input.id + ':' + weighted; +}; + +function dispatch(name, input) { + const handler = handlers[name]; + return handler ? handler(input) : null; +} + +module.exports = { handlers, dispatch }; + +//# sourceMappingURL=source-map-footer-long.js.map diff --git a/parser/src/test-data/javascript/categories/directives/source-map-footer-short.js b/parser/src/test-data/javascript/categories/directives/source-map-footer-short.js new file mode 100644 index 000000000..0803fba87 --- /dev/null +++ b/parser/src/test-data/javascript/categories/directives/source-map-footer-short.js @@ -0,0 +1,43 @@ +// fixture: cjs/directives/source-map-footer-short.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// HALF ONE OF A PAIRED REPRO. This file and source-map-footer-long.js carry the +// SAME trailing a source-map footer comment line and differ only in length. Under +// js-module-extractor.ts @ a00dc10 they receive OPPOSITE provenance verdicts: +// this one is GENERATED_MONOLITH, the long one is PROJECT. +// +// Neither is generated. Both are hand-written fixtures. +// +// The cause is that `sourceMappingURL` sits in a list called BUNDLER_PREAMBLES +// which is searched only in `sourceText.slice(0, 4096)` — a bound whose stated +// purpose is that a bundler token "appearing in a comment halfway down a +// hand-written file is not evidence the file is generated". For any file under +// 4 KB the head window IS the whole file, so the bound does nothing here, while +// for the long file it excludes the only evidence there is. A source map URL is +// a FOOTER by convention; the other four patterns in that list are genuine +// headers and are correctly bounded. +// +// Measured on this checkout's node_modules: of 42 files carrying a source-map +// footer and no other preamble pattern, 21 are flagged and 21 are not, split +// purely by length. Two files from ONE build in this checkout's dependencies +// (2,230 B and 4,701 B) get opposite verdicts — same build, same directory. +// +// Why this matters beyond tidiness: gate 7.3.5 says a file whose sourceProvenance +// is not PROJECT contributes zero rows to any coverage denominator. So the error +// is silent in both directions — a fixture drops out of coverage while reading +// green, and a genuinely generated file is counted as project source. +// +// Owner of the fix: js-impl. This pair exists so the fix has a repro that does +// not depend on anyone's node_modules. + +'use strict'; + +function short() { + return 'under 4 KB, so the head window is the whole file'; +} + +module.exports = { short }; + +//# sourceMappingURL=source-map-footer-short.js.map diff --git a/parser/src/test-data/javascript/categories/enums/enum-idioms.js b/parser/src/test-data/javascript/categories/enums/enum-idioms.js new file mode 100644 index 000000000..a6e2b2c68 --- /dev/null +++ b/parser/src/test-data/javascript/categories/enums/enum-idioms.js @@ -0,0 +1,136 @@ +// fixture: cjs/enums/enum-idioms.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// NO ANALOGUE. JavaScript has no `enum` — not as a keyword, not as a reserved +// declaration form, not at all. ts_enum_member has no js_* counterpart and the +// schema says so explicitly. Java's enum constructors, per-constant fields, +// per-constant class bodies and `implements` on an enum have no port either. +// +// What real JavaScript does INSTEAD is what this file covers, because a fixture +// that invented an enum would be testing a construct the language does not have. +// Four idioms appear in practice, and none of them is a declaration form — each +// is an object or a set of constants, so a parser must NOT mint a type for them. +// That is the property worth pinning: these are VALUES. +// +// Grounded in the platform's `errno` tables and `http.STATUS_CODES` object, a charting library's +// frozen constant maps, and the Symbol-based sentinels in View's source. + +'use strict'; + +// --- idiom 1: a frozen object literal ------------------------------------------ +// +// The commonest form. Object.freeze is the only enforcement, it is shallow, and +// it happens at runtime. + +const Color = Object.freeze({ + RED: 'red', + GREEN: 'green', + BLUE: 'blue' +}); + +// Numeric values, and a computed one — the JavaScript equivalent of a +// constant-expression enum member. +const Level = Object.freeze({ + DEBUG: 10, + INFO: 20, + WARN: 30, + ERROR: 40, + MAX: 40 + 10 +}); + +// Bit flags, which are what an enum is usually being used for. +const Permission = Object.freeze({ + NONE: 0, + READ: 1 << 0, + WRITE: 1 << 1, + EXECUTE: 1 << 2, + ALL: (1 << 0) | (1 << 1) | (1 << 2) +}); + +// NOT frozen. Same shape, mutable, and nothing in the syntax distinguishes the +// two — which is why treating "an object of constants" as an enum declaration +// is a guess. +const Mutable = { A: 1, B: 2 }; +Mutable.C = 3; + +// --- idiom 2: Symbol constants --------------------------------------------------- +// +// Unique by construction, so two enums cannot collide and a value cannot be +// forged from a string. The description is not the identity. + +const Direction = Object.freeze({ + UP: Symbol('UP'), + DOWN: Symbol('DOWN') +}); + +// Symbol.for uses a global registry, so these ARE forgeable and equal across +// realms — the opposite property, same syntax. +const Shared = Object.freeze({ + PING: Symbol.for('app.ping') +}); + +// --- idiom 3: the reverse map ----------------------------------------------------- +// +// TypeScript's numeric enums generate this. Written by hand it is two entries +// per member, built by a loop, so the member names are not literals in the +// source at all. + +const Status = {}; +[['OK', 200], ['NOT_FOUND', 404], ['ERROR', 500]].forEach(([name, code]) => { + Status[name] = code; + Status[code] = name; +}); +Object.freeze(Status); + +// --- idiom 4: a class of static constants ------------------------------------------- +// +// The only idiom that produces a real js_type. Its members are static fields, +// and the instances are created by the class itself — which is the closest +// JavaScript gets to a Java enum with a constructor and per-constant state. + +class Suit { + constructor(name, symbol) { + this.name = name; + this.symbol = symbol; + Object.freeze(this); + } + toString() { return this.symbol; } + isRed() { return this === Suit.HEARTS || this === Suit.DIAMONDS; } + static values() { return [Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS, Suit.SPADES]; } + static valueOf(name) { return Suit.values().find((s) => s.name === name); } +} +Suit.HEARTS = new Suit('HEARTS', '♥'); +Suit.DIAMONDS = new Suit('DIAMONDS', '♦'); +Suit.CLUBS = new Suit('CLUBS', '♣'); +Suit.SPADES = new Suit('SPADES', '♠'); +Object.freeze(Suit); + +// --- consumption --------------------------------------------------------------------- +// +// The switch over a "enum" — a switch over string values, with no exhaustiveness +// check of any kind, which is the concrete cost of not having the construct. + +function describe(color) { + switch (color) { + case Color.RED: return 'warm'; + case Color.GREEN: return 'cool'; + case Color.BLUE: return 'cold'; + default: return 'unknown'; // reachable, unlike a TypeScript never-check + } +} + +function hasPermission(mask, permission) { + return (mask & permission) === permission; +} + +const allColors = Object.values(Color); +const colorNames = Object.keys(Color); +const isColor = (v) => allColors.includes(v); +const reverse = Status[404]; + +module.exports = { + Color, Level, Permission, Mutable, Direction, Shared, Status, Suit, + describe, hasPermission, allColors, colorNames, isColor, reverse +}; diff --git a/parser/src/test-data/javascript/categories/exports/esm/export-forms.js b/parser/src/test-data/javascript/categories/exports/esm/export-forms.js new file mode 100644 index 000000000..2db816dc8 --- /dev/null +++ b/parser/src/test-data/javascript/categories/exports/esm/export-forms.js @@ -0,0 +1,48 @@ +// fixture: esm/exports/export-forms.js +// module system: ESM (governing: staging/esm/package.json, "type": "module") +// nature: runtime-bearing +// syntax floor: ES2015, except the string-named export at the bottom (ES2022) +// +// Every declaration-borne export form, as the counterpart to imports/import-forms.js. +// exportForm = EXPORT_DECLARATION / EXPORT_DEFAULT / EXPORT_ALL; edgeBearer = +// DECLARATION throughout, which is the partition that CommonJS inverts. +// +// The two properties CommonJS does not have, and that a ported CJS model cannot +// express, are both here: ESM exports are LIVE BINDINGS (mutating the local +// updates the importer's view, which `module.exports.x = 1` does not), and they +// are STATIC (the set of names is fixed at parse time, which is what makes +// `export *` analysable and `Object.assign(exports, ...)` not). + +// Exported declarations. +export const NAME = 'export-forms'; +export let counter = 0; +export var legacy = null; +export function bump() { counter += 1; return counter; } +export async function bumpLater() { return bump(); } +export function* bumps() { while (true) { yield bump(); } } +export class Counter { + constructor() { this._value = 0; } + increment() { return ++this._value; } + get value() { return this._value; } + set value(v) { this._value = v; } +} + +// Export lists, with and without renaming. A name may be exported twice under +// two names, which means exportedName is not a key on its own. +const internal = Symbol('internal'); +function helper() { return internal; } +export { helper, helper as alsoHelper, internal as internalSymbol }; + +// Default export of an expression rather than a declaration. There is no local +// binding named `default`, and exportedName is `default` all the same. +export default { + NAME, + bump +}; + +// A string-named export. Legal since ES2022; the name is not an identifier, so +// it is only reachable by `import { "not-an-identifier" as x }`. Kept here +// rather than quarantined because it is export SYNTAX at the ES2022 line, not a +// class-body feature — see MANIFEST.md's syntax-floor table. +const weird = 1; +export { weird as 'not-an-identifier' }; diff --git a/parser/src/test-data/javascript/categories/exports/esm/package.json b/parser/src/test-data/javascript/categories/exports/esm/package.json new file mode 100644 index 000000000..a85146f83 --- /dev/null +++ b/parser/src/test-data/javascript/categories/exports/esm/package.json @@ -0,0 +1,4 @@ +{ + "name": "javascript-categories-exports-esm", + "type": "module" +} diff --git a/parser/src/test-data/javascript/categories/exports/export-overwrite-conditional.js b/parser/src/test-data/javascript/categories/exports/export-overwrite-conditional.js new file mode 100644 index 000000000..e77d85722 --- /dev/null +++ b/parser/src/test-data/javascript/categories/exports/export-overwrite-conditional.js @@ -0,0 +1,71 @@ +// fixture: cjs/commonjs/export-overwrite-conditional.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The same construct as export-overwrite-unconditional.js, guarded. The schema +// claims an overwrite only when it is UNCONDITIONAL, so every +// `module.exports = ...` in this file must be recorded with +// overwritesPreviousExport = false and isConditional = true: whether the earlier +// edges survive is decided at runtime, and the fact base must not assert either +// outcome. +// +// A parser that treats a guarded overwrite as an overwrite deletes exports that +// exist on the other branch. A parser that treats an unguarded one as +// conditional keeps exports that do not exist. The two fixtures exist as a pair +// because either error alone looks correct in isolation. +// +// Grounded in the browser/node dual-build idiom several HTTP and encoding +// libraries use, and in the `if (process.env.NODE_ENV === 'production')` swap a +// view library's index.js has shipped for years. + +'use strict'; + +// --- edges written before any guard ---------------------------------------- + +exports.shared = function shared() { return 'shared'; }; +module.exports.version = '1.0.0'; + +// 1. Guarded by an if with no else. On the false branch, `shared` and `version` +// are the module's exports. +if (process.env.NODE_ENV === 'production') { + module.exports = require('./module-exports-assignment'); +} + +// 2. Guarded by if/else. BOTH branches overwrite, so at runtime the earlier +// edges never survive — but that is a fact about the pair of branches, not +// about either assignment, and no single row can carry it. +if (typeof window === 'undefined') { + module.exports = { platform: 'node', shared: exports.shared }; +} else { + module.exports = { platform: 'browser' }; +} + +// 3. Inside a try. The overwrite may be skipped by a throw in the expression +// on its right-hand side. +try { + module.exports = require('fast-serialize'); +} catch (err) { + module.exports.fallback = true; +} + +// 4. Inside a function body. Runs only if something calls it, which may be +// never — and if it is called twice, it runs twice. +function installTestDouble(double) { + module.exports = double; +} + +// 5. Inside a loop body. Executes zero or more times. +for (const key of Object.keys(process.env.FIXTURE_KEYS || {})) { + module.exports = { key }; +} + +// 6. Short-circuit and ternary forms. The assignment is an expression here, not +// a statement, and it is still an overwrite when it runs. +process.env.FIXTURE_SWAP && (module.exports = { swapped: true }); +const chosen = process.env.FIXTURE_ALT + ? (module.exports = { alt: true }) + : module.exports; + +module.exports.installTestDouble = installTestDouble; +module.exports.chosen = chosen; diff --git a/parser/src/test-data/javascript/categories/exports/export-overwrite-unconditional.js b/parser/src/test-data/javascript/categories/exports/export-overwrite-unconditional.js new file mode 100644 index 000000000..005375928 --- /dev/null +++ b/parser/src/test-data/javascript/categories/exports/export-overwrite-unconditional.js @@ -0,0 +1,54 @@ +// fixture: cjs/commonjs/export-overwrite-unconditional.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The overwrite the schema claims: `module.exports.foo = 1` followed by an +// UNCONDITIONAL `module.exports = {}`. At runtime this module exports `b` and +// `c` and nothing else. Every edge written before line 30 is discarded. +// +// overwritesPreviousExport = true is what stops the fact base asserting exports +// that do not exist. Suppressing the earlier rows instead would lose the fact +// that the assignment executed, which is why the schema keeps both rows and +// flags them. +// +// This file is the paired control for export-overwrite-conditional.js. The two +// must be DISTINGUISHABLE: same construct, different reachability, and only +// this one is an unconditional overwrite. Grounded in the common refactor +// accident where a file grows `exports.x =` members and later acquires a +// `module.exports = class` at the bottom. + +'use strict'; + +// --- edges that will be discarded ------------------------------------------ + +exports.a = function a() { return 'a'; }; +module.exports.alsoA = 1; +Object.defineProperty(exports, 'hiddenA', { get() { return true; } }); + +// --- the unconditional overwrite ------------------------------------------- +// +// Top level, no guard, no branch above it that could skip it. Everything above +// is now unreachable through this module's public surface. + +module.exports = { + b: function b() { return 'b'; }, + c: 3 +}; + +// --- edges added AFTER the overwrite, which do survive ---------------------- + +module.exports.d = 4; +exports.e = 5; // does NOT survive: `exports` still aliases the OLD object. + +// --- a second unconditional overwrite -------------------------------------- +// +// Two overwrites in one file. Only the last one decides the module's exports, +// so `d` is gone too. A parser that flags "an overwrite happened" without +// ordering cannot say which edges survived. + +module.exports = function finalExport() { + return 'final'; +}; + +module.exports.tail = true; diff --git a/parser/src/test-data/javascript/categories/exports/exports-array.js b/parser/src/test-data/javascript/categories/exports/exports-array.js new file mode 100644 index 000000000..a0d884de8 --- /dev/null +++ b/parser/src/test-data/javascript/categories/exports/exports-array.js @@ -0,0 +1,15 @@ +// fixture: cjs/commonjs/exports-array.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES5 +// +// Support module. Exists so destructured-require.js can array-destructure a +// require against something real. exportedValueKind is neither OBJECT_LITERAL +// nor FUNCTION here — it is an array literal, which is the OTHER case. + +'use strict'; + +module.exports = [ + function first() { return 1; }, + function second() { return 2; } +]; diff --git a/parser/src/test-data/javascript/categories/exports/exports-shorthand.js b/parser/src/test-data/javascript/categories/exports/exports-shorthand.js new file mode 100644 index 000000000..fba3d2e20 --- /dev/null +++ b/parser/src/test-data/javascript/categories/exports/exports-shorthand.js @@ -0,0 +1,51 @@ +// fixture: cjs/commonjs/exports-shorthand.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// `exports.foo = ...` — the EXPORTS_MEMBER form, 118 sites in the schema's +// corpus. `exports` is a free variable that starts out === module.exports, and +// the whole point of this fixture is that the aliasing is fragile in ways the +// syntax does not show. +// +// Grounded in a runtime's querystring module and a web framework's utils. + +'use strict'; + +// The plain form. +exports.stringify = function stringify(obj) { + return JSON.stringify(obj); +}; + +exports.parse = function parse(text) { + return JSON.parse(text); +}; + +// A value, not a function. +exports.escape = encodeURIComponent; + +// Rebinding a local alias of `exports` and assigning through it. Same effect, +// different callee text — a matcher keyed on the identifier `exports` misses it. +const ex = exports; +ex.unescape = decodeURIComponent; + +// Object.assign onto `exports`. N export edges from one call, and the names are +// the object literal's keys, not anything in the assignment target. +Object.assign(exports, { + encode: exports.escape, + decode: exports.unescape, + sep: '&', + eq: '=' +}); + +// `exports` used as a value rather than an assignment target. Not an export +// edge; a read of the object. +const exportedNames = Object.keys(exports); +exports.count = exportedNames.length; + +// The trap: reassigning the local `exports` binding does NOT change what the +// module exports, because module.exports is what is returned. Everything +// assigned after this line is invisible to an importer. A fact base that +// records `lost` as an export asserts something that is false at runtime. +exports = { lost: true }; +exports.lost = true; diff --git a/parser/src/test-data/javascript/categories/exports/module-exports-assignment.js b/parser/src/test-data/javascript/categories/exports/module-exports-assignment.js new file mode 100644 index 000000000..080fd171d --- /dev/null +++ b/parser/src/test-data/javascript/categories/exports/module-exports-assignment.js @@ -0,0 +1,66 @@ +// fixture: cjs/commonjs/module-exports-assignment.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES5 +// +// `module.exports = `, plus properties hung off that function after +// the fact. This is a web framework's entry module almost exactly: the module's export +// is a factory function, and the constructors it exposes are attached to it as +// static properties afterwards. +// +// exportedValueKind = FUNCTION here, and the later `createService.Plugin = ` +// assignments are NOT exports in their own right — they mutate the already +// exported value. Whether they mint js_export rows is the ruling this fixture +// is written to be adjudicated against; either answer must be consistent, and +// the file's shape must not force a guess. + +'use strict'; + +const EventEmitter = require('events').EventEmitter; + +/** + * Create an application. + * + * @returns {Function} the application, callable as a request handler + */ +function createService() { + function app(req, res, next) { + app.dispatch(req, res, next); + } + + Object.assign(app, EventEmitter.prototype); + app.inbound = Object.create(null); + app.outbound = Object.create(null); + app.setup(); + return app; +} + +// The single export edge for this module. exportedName is `default`. +module.exports = createService; + +// Statics hung off the exported function AFTER the export assignment. An +// importer sees them, but they are property writes on a function value, not +// separate module.exports assignments. +module.exports.application = createService; +createService.Plugin = require('./reexport-require'); +createService.encode = function encode(options) { return options; }; +createService.assets = require('./exports-shorthand'); + +// A getter installed on the exported object. Reading `service.parser` runs a +// function — the GETTER_INVOCATION case, which is reserved precisely because +// this fact lives on the object and not in the reading expression. +Object.defineProperty(createService, 'parser', { + configurable: true, + enumerable: true, + get: function () { + return require('./module-exports-members'); + } +}); + +createService.prototype = Object.create(EventEmitter.prototype); +createService.prototype.setup = function setup() { + this.settings = {}; +}; +createService.prototype.handle = function dispatch(req, res, next) { + return next && next(); +}; diff --git a/parser/src/test-data/javascript/categories/exports/module-exports-members.js b/parser/src/test-data/javascript/categories/exports/module-exports-members.js new file mode 100644 index 000000000..402985712 --- /dev/null +++ b/parser/src/test-data/javascript/categories/exports/module-exports-members.js @@ -0,0 +1,72 @@ +// fixture: cjs/commonjs/module-exports-members.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 (const, arrow, computed property name) +// +// `module.exports.foo = ...` — the MODULE_EXPORTS_MEMBER form, 380 sites in the +// schema's corpus. Never reassigns module.exports itself, so every edge here +// survives to runtime and overwritesPreviousExport is false throughout. The +// overwrite cases live in their own two fixtures, deliberately, because the +// difference between them is the thing that has to be distinguishable. +// +// Grounded in a runtime's path module and a web framework's utils, which both build their +// export object one member at a time. + +'use strict'; + +const crypto = require('crypto'); + +// A function, named on the left and anonymous on the right. The export's name +// comes from the assignment target, not from the value. +module.exports.digest = function (body) { + return crypto.createHash('sha1').update(body).digest('base64'); +}; + +// A function, named on both sides, and the names DISAGREE. exportedName is +// `weakDigest`; the function's own name is `weakDigestImpl`, and that is the name that +// appears in a stack trace. +module.exports.weakDigest = function weakDigestImpl(body) { + return 'W/' + body.length; +}; + +// An arrow. No `this`, no `arguments`, no name of its own except by inference. +module.exports.isAbsolute = (p) => p.charCodeAt(0) === 47; + +// A plain value, not a callable. exportedValueKind is not FUNCTION. +module.exports.defaultCharset = 'utf-8'; + +// An object literal. +module.exports.contentTypes = { html: 'text/html', json: 'application/json' }; + +// An identifier — the exported thing is declared elsewhere in the file. +function normalizeType(type) { + return { value: type, quality: 1 }; +} +module.exports.normalizeType = normalizeType; + +// A class. Assignment-declared, so the type's evidence is an expression. +module.exports.Segment = class Segment { + constructor(pattern) { + this.pattern = pattern; + } + match(pathname) { + return this.pattern === pathname; + } +}; + +// A computed export name. Syntax does not fix the exported name, and a row that +// invents one is wrong. isComputedName on the expression says so. +const dynamicKey = 'compileDigest'; +module.exports[dynamicKey] = function (val) { return val; }; + +// Nested: a property of an already-exported object. This is NOT a module edge — +// it mutates a value that happens to be reachable from module.exports. +module.exports.contentTypes.xml = 'application/xml'; + +// Object.defineProperty on module.exports. Still an export, with a different +// exportForm, and it can be non-enumerable — which an importer using +// Object.keys will not see even though `require('...').deprecated` works. +Object.defineProperty(module.exports, 'deprecated', { + enumerable: false, + get() { return true; } +}); diff --git a/parser/src/test-data/javascript/categories/expressions/calls-and-member-access.js b/parser/src/test-data/javascript/categories/expressions/calls-and-member-access.js new file mode 100644 index 000000000..01f3a00f0 --- /dev/null +++ b/parser/src/test-data/javascript/categories/expressions/calls-and-member-access.js @@ -0,0 +1,157 @@ +// fixture: cjs/expressions/calls-and-member-access.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2022 (private `#x in obj` brand check is the only line above +// ES2020; see MANIFEST.md's syntax-floor table) +// +// Port of java/expressions/ObjectCreationTestCases.java, MethodReferenceExamples.java +// and QualifiedConstructorTest.java. The base call vocabulary: METHOD_CALL, +// FUNCTION_CALL, CONSTRUCTOR_CALL, SUPER_CALL, IIFE_CALL. The forms syntax +// cannot decide live in cjs/call-forms/, one fixture each. +// +// NO ANALOGUE — Java method references (`String::length`). There is no `::` and +// js_method.methodReferenceKind is a parity slot that stays "". The real +// JavaScript analogue is passing the function VALUE, which is also where +// receiver binding goes wrong in practice, so that is what is covered. +// +// NO ANALOGUE — Java's qualified constructor invocation (`outer.new Inner()`) +// and `this(...)` constructor delegation. JavaScript classes do not nest as +// types and there is no `this()` form; `super()` is the only delegation. + +'use strict'; + +const util = require('util'); + +function free(a, b) { return a + b; } + +const receiver = { + name: 'receiver', + method(x) { return this.name + x; }, + nested: { deep(x) { return x; } }, + fns: [free] +}; + +class Base { + constructor(v) { this.v = v; } + render() { return 'base:' + this.v; } + static create(v) { return new Base(v); } +} + +class Derived extends Base { + #secret = 1; // [ES2022] private field + constructor(v) { + super(v); // SUPER_CALL + this.extra = true; + } + render() { + return super.render() + '+derived'; // super.method(): a method call whose + // receiver is `this` and whose lookup + // starts at the SUPERCLASS prototype + } + reveal() { return this.#secret; } + static has(obj) { return #secret in obj; } // [ES2022] brand check +} + +// --- plain calls --------------------------------------------------------------------- + +const functionCall = free(1, 2); +const methodCall = receiver.method('!'); +const staticCall = Base.create(1); +const chained = receiver.nested.deep('x'); +const throughIndex = receiver.fns[0](1, 2); +const parenthesisedCallee = (free)(1, 2); +const commaCallee = (0, receiver.method)('!'); // detaches the receiver +const spreadArgs = free(...[1, 2]); +const trailingComma = free(1, 2,); +const noArgs = Base.create(); + +// A call whose callee is a call. +function makeAdder(n) { return (x) => x + n; } +const curriedCall = makeAdder(1)(2); + +// A call inside every edgeRole: argument, condition, element, property value, +// template substitution, return, spread operand. +const inArgument = free(free(1, 2), 3); +const inCondition = free(1, 2) > 0 ? 'y' : 'n'; +const inElement = [free(1, 2)]; +const inPropertyValue = { v: free(1, 2) }; +const inTemplate = `${free(1, 2)}`; +const inSpread = [...[free(1, 2)]]; +function inReturn() { return free(1, 2); } + +// A call nested inside PARENTHESES and inside a logical operator — the exact +// shape that vanished in TypeScript when a subtree rooted at a non-emitting node +// died before its children were enqueued (55,683 -> 57,491 expressions when +// fixed). +const inParens = ( receiver.name && receiver.method('!') ); +const doubleParens = ((free(1, 2))); + +// --- construction ---------------------------------------------------------------------- + +const constructed = new Base(1); +const noParens = new Base; // no argument list at all +const derived = new Derived(2); +const viaVariable = new (Base)(3); +const ofClassExpression = new (class { constructor() { this.anon = true; } })(); +const nsCtor = new util.TextEncoder(); // qualified constructor +const nested = new Base(new Base(1).v); +const spreadNew = new Base(...[1]); +const builtin = new Map([[1, 'a']]); +const reflected = Reflect.construct(Base, [4]); + +// --- member access --------------------------------------------------------------------- + +const dotted = receiver.name; +const bracketed = receiver['name']; +const computedMember = receiver['na' + 'me']; +const numericMember = receiver.fns[0]; +const deepChain = receiver.nested.deep; +const optionalMember = receiver?.nested?.deep; +const optionalComputed = receiver?.['nested']?.['deep']; +const optionalCall = receiver.nested?.deep?.('x'); + +// Assignment to a member — the target side of the same syntax. +receiver.added = 1; +receiver['computed added'] = 2; +receiver.nested.deeper = 3; + +// A member access on a call result, on a literal, and on a template. +const onCall = Base.create(1).render(); +const onLiteral = (5).toFixed(2); +const onArrayLiteral = [1, 2, 3].map(String); +const onTemplate = `abc`.toUpperCase(); +const onRegex = /a/.test('a'); + +// --- passing functions as values: the method-reference analogue --------------------------- +// +// Java's `String::length` has no syntax here. These are the four forms real code +// uses, and the receiver-binding difference between them is the reason the Java +// construct does not port cleanly. + +const asValue = free; // a free function: safe +const boundMethod = receiver.method.bind(receiver); // explicitly bound: safe +const detachedMethod = receiver.method; // loses `this`: the bug +const wrapped = (x) => receiver.method(x); // preserves it lexically +const mapped = [1, 2].map(free); +const mappedDetached = ['a'].map(receiver.method); // `this` is undefined +const mappedWithThisArg = ['a'].map(receiver.method, receiver); + +// A static method passed as a value, and a constructor passed as a value — +// the latter cannot be called without `new`, so passing it is usually a bug. +const staticAsValue = Base.create; +const ctorAsValue = Base; + +module.exports = { + Base, Derived, + functionCall, methodCall, staticCall, chained, throughIndex, + parenthesisedCallee, commaCallee, spreadArgs, trailingComma, noArgs, + curriedCall, inArgument, inCondition, inElement, inPropertyValue, inTemplate, + inSpread, inReturn, inParens, doubleParens, + constructed, noParens, derived, viaVariable, ofClassExpression, nsCtor, + nested, spreadNew, builtin, reflected, + dotted, bracketed, computedMember, numericMember, deepChain, + optionalMember, optionalComputed, optionalCall, + onCall, onLiteral, onArrayLiteral, onTemplate, onRegex, + asValue, boundMethod, detachedMethod, wrapped, mapped, mappedDetached, + mappedWithThisArg, staticAsValue, ctorAsValue +}; diff --git a/parser/src/test-data/javascript/categories/expressions/deep-nesting.js b/parser/src/test-data/javascript/categories/expressions/deep-nesting.js new file mode 100644 index 000000000..2c940fedf --- /dev/null +++ b/parser/src/test-data/javascript/categories/expressions/deep-nesting.js @@ -0,0 +1,159 @@ +// fixture: cjs/expressions/deep-nesting.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// GENERATED, and the only generated file in this corpus. Everything else here is +// derived from real code; this one cannot be, because its whole content is a +// depth no human writes deliberately — which is exactly why it is needed. +// +// The schema caps js_expression and js_type_reference traversal at depth 32 and +// records isTruncated beyond it, with js_parse_gap.gapKind = DEPTH_CAP_REACHED. +// TypeScript capped at 20 and the memo measured a real maximum of 67, so the cap +// is the difference between the two. Until this file existed the whole staging +// corpus stayed under the cap, so isTruncated was never true, DEPTH_CAP_REACHED +// carried zero rows, and the cap had never once been exercised. +// +// A cap that is never reached is a check that cannot fail. Five shapes reach it +// by different routes, because a walker may cap one and not another: +// 1. left-associative binary chain, 45 deep +// 2. nested conditional (ternary) ladder, 39 deep +// 3. member-access chain, 39 deep +// 4. nested call arguments, 35 deep +// 5. nested array literals, 37 deep +// +// Real provenance, so this is not purely synthetic: minified and bundled output +// reaches these depths constantly, generated parsers emit +// ternary ladders of exactly this shape, and a long `a && b && c && ...` guard +// chain in hand-written code is the same tree. + +'use strict'; + +const a = 0; +const b1 = 1; +const b2 = 2; +const b3 = 3; +const b4 = 4; +const b5 = 5; +const b6 = 6; +const b7 = 7; +const b8 = 8; +const b9 = 9; +const b10 = 10; +const b11 = 11; +const b12 = 12; +const b13 = 13; +const b14 = 14; +const b15 = 15; +const b16 = 16; +const b17 = 17; +const b18 = 18; +const b19 = 19; +const b20 = 20; +const b21 = 21; +const b22 = 22; +const b23 = 23; +const b24 = 24; +const b25 = 25; +const b26 = 26; +const b27 = 27; +const b28 = 28; +const b29 = 29; +const b30 = 30; +const b31 = 31; +const b32 = 32; +const b33 = 33; +const b34 = 34; +const b35 = 35; +const b36 = 36; +const b37 = 37; +const b38 = 38; +const b39 = 39; +const b40 = 40; +const b41 = 41; +const b42 = 42; +const b43 = 43; +const b44 = 44; +const b45 = 45; +const c1 = 1 % 2 === 0, d1 = 1; +const c2 = 2 % 2 === 0, d2 = 2; +const c3 = 3 % 2 === 0, d3 = 3; +const c4 = 4 % 2 === 0, d4 = 4; +const c5 = 5 % 2 === 0, d5 = 5; +const c6 = 6 % 2 === 0, d6 = 6; +const c7 = 7 % 2 === 0, d7 = 7; +const c8 = 8 % 2 === 0, d8 = 8; +const c9 = 9 % 2 === 0, d9 = 9; +const c10 = 10 % 2 === 0, d10 = 10; +const c11 = 11 % 2 === 0, d11 = 11; +const c12 = 12 % 2 === 0, d12 = 12; +const c13 = 13 % 2 === 0, d13 = 13; +const c14 = 14 % 2 === 0, d14 = 14; +const c15 = 15 % 2 === 0, d15 = 15; +const c16 = 16 % 2 === 0, d16 = 16; +const c17 = 17 % 2 === 0, d17 = 17; +const c18 = 18 % 2 === 0, d18 = 18; +const c19 = 19 % 2 === 0, d19 = 19; +const c20 = 20 % 2 === 0, d20 = 20; +const c21 = 21 % 2 === 0, d21 = 21; +const c22 = 22 % 2 === 0, d22 = 22; +const c23 = 23 % 2 === 0, d23 = 23; +const c24 = 24 % 2 === 0, d24 = 24; +const c25 = 25 % 2 === 0, d25 = 25; +const c26 = 26 % 2 === 0, d26 = 26; +const c27 = 27 % 2 === 0, d27 = 27; +const c28 = 28 % 2 === 0, d28 = 28; +const c29 = 29 % 2 === 0, d29 = 29; +const c30 = 30 % 2 === 0, d30 = 30; +const c31 = 31 % 2 === 0, d31 = 31; +const c32 = 32 % 2 === 0, d32 = 32; +const c33 = 33 % 2 === 0, d33 = 33; +const c34 = 34 % 2 === 0, d34 = 34; +const c35 = 35 % 2 === 0, d35 = 35; +const c36 = 36 % 2 === 0, d36 = 36; +const c37 = 37 % 2 === 0, d37 = 37; +const c38 = 38 % 2 === 0, d38 = 38; +const c39 = 39 % 2 === 0, d39 = 39; +const wrap0 = (v) => v; +const wrap1 = (v) => v; +const wrap2 = (v) => v; +const wrap3 = (v) => v; +const root = { p1: { } }; +const x = 1; +function seed() { return 0; } + +// 1. binary chain +const binaryChain = (((((((((((((((((((((((((((((((((((((((((((((a + b1) + b2) + b3) + b4) + b5) + b6) + b7) + b8) + b9) + b10) + b11) + b12) + b13) + b14) + b15) + b16) + b17) + b18) + b19) + b20) + b21) + b22) + b23) + b24) + b25) + b26) + b27) + b28) + b29) + b30) + b31) + b32) + b33) + b34) + b35) + b36) + b37) + b38) + b39) + b40) + b41) + b42) + b43) + b44) + b45); + +// 2. ternary ladder +const ternaryLadder = (c39 ? (c38 ? (c37 ? (c36 ? (c35 ? (c34 ? (c33 ? (c32 ? (c31 ? (c30 ? (c29 ? (c28 ? (c27 ? (c26 ? (c25 ? (c24 ? (c23 ? (c22 ? (c21 ? (c20 ? (c19 ? (c18 ? (c17 ? (c16 ? (c15 ? (c14 ? (c13 ? (c12 ? (c11 ? (c10 ? (c9 ? (c8 ? (c7 ? (c6 ? (c5 ? (c4 ? (c3 ? (c2 ? (c1 ? x : d1) : d2) : d3) : d4) : d5) : d6) : d7) : d8) : d9) : d10) : d11) : d12) : d13) : d14) : d15) : d16) : d17) : d18) : d19) : d20) : d21) : d22) : d23) : d24) : d25) : d26) : d27) : d28) : d29) : d30) : d31) : d32) : d33) : d34) : d35) : d36) : d37) : d38) : d39); + +// 3. member chain — reads a property 39 levels down and throws at runtime; +// the SHAPE is the point, and nothing calls this. +function memberChain() { + return root.p1.p2.p3.p4.p5.p6.p7.p8.p9.p10.p11.p12.p13.p14.p15.p16.p17.p18.p19.p20.p21.p22.p23.p24.p25.p26.p27.p28.p29.p30.p31.p32.p33.p34.p35.p36.p37.p38.p39; +} + +// 4. nested calls +const nestedCalls = wrap3(wrap2(wrap1(wrap0(wrap3(wrap2(wrap1(wrap0(wrap3(wrap2(wrap1(wrap0(wrap3(wrap2(wrap1(wrap0(wrap3(wrap2(wrap1(wrap0(wrap3(wrap2(wrap1(wrap0(wrap3(wrap2(wrap1(wrap0(wrap3(wrap2(wrap1(wrap0(wrap3(wrap2(wrap1(seed()))))))))))))))))))))))))))))))))))); + +// 6. THE OTHER DEPTH CAP: a JSDoc type expression. +// +// js_type_reference is capped at 32 exactly as js_expression is, and the audit +// for columns nothing discriminates found `type-references.isTruncated` FALSE in +// every row — the expression cap was exercised and the type cap never was. A +// type expression is a TREE, so `Array>` 40 deep is 40 rows and the +// cap must bite at the same place. + +/** + * @param {Array>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>} deepType + * @returns {void} + */ +function deepJsdocType(deepType) { return undefined; } + +// 5. nested array literals +const nestedArrays = [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[0]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]; + +module.exports = { + binaryChain, ternaryLadder, memberChain, nestedCalls, nestedArrays, deepJsdocType +}; diff --git a/parser/src/test-data/javascript/categories/expressions/literals.js b/parser/src/test-data/javascript/categories/expressions/literals.js new file mode 100644 index 000000000..765f8db2d --- /dev/null +++ b/parser/src/test-data/javascript/categories/expressions/literals.js @@ -0,0 +1,148 @@ +// fixture: cjs/expressions/literals.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2020 (BigInt); ES2021 for numeric separators +// +// Port of java/expressions/LiteralTypeTestCases.java. js_expression.literalKind +// covers STRING | NUMBER | TEMPLATE | REGEX | NULL | UNDEFINED | BIGINT | NONE. +// +// NO ANALOGUE — Java's char literals ('a') and numeric type suffixes (1.5f, +// 999L, 0.1d). JavaScript has one number type plus BigInt, and a single-quoted +// 'a' is a string of length one, not a different kind. +// +// `undefined` is the interesting one: it is not a literal at all, it is a global +// IDENTIFIER that happens to hold undefined. `null` is a keyword and a literal. +// A parser that classifies them identically is wrong about which one can be +// shadowed. + +'use strict'; + +// --- numbers --------------------------------------------------------------------- + +const decimal = 42; +const negative = -17; // a unary operator applied to a literal +const float = 3.14159; +const leadingDot = .5; +const trailingDot = 5.; +const exponent = 1.5e10; +const negExponent = 2E-8; +const hex = 0xFF; +const hexUpper = 0XDEADBEEF; +const octal = 0o755; +const binary = 0b1010_1010; +const separators = 1_000_000; +const floatSeparators = 1_234.567_8; +const zero = 0; +const negativeZero = -0; +const infinity = Infinity; +const notANumber = NaN; +const maxSafe = 9007199254740991; +const beyondSafe = 9007199254740993; // silently not representable + +// BigInt: a different type, and the `n` suffix is the only one JavaScript has. +const big = 9007199254740993n; +const bigHex = 0xFFn; +const bigZero = 0n; + +// --- strings ----------------------------------------------------------------------- + +const single = 'single quoted'; +const double = "double quoted"; +const withApostrophe = "it's fine"; +const escaped = 'tab:\t newline:\n cr:\r backslash:\\ quote:\' null:\0 bell:\b'; +const hexEscape = '\x41\x42'; +const unicodeEscape = '\u0041\u00e9'; +const codePointEscape = '\u{1F600}'; +const surrogatePair = '\uD83D\uDE00'; +const lineContinuation = 'a \ +continued line'; +const empty = ''; +const unicodeIdentifierValue = 'café — naïve — 日本語'; + +// --- templates ------------------------------------------------------------------------ + +const name = 'world'; +const noSubstitution = `just text`; +const oneSubstitution = `hello ${name}`; +const expressionSubstitution = `sum ${1 + 2 * 3}`; +const nestedTemplate = `outer ${`inner ${name}`}`; +const multiline = `line one +line two`; +const escapeInTemplate = `backtick: \` dollar: \${not a substitution}`; +const callInSubstitution = `upper ${name.toUpperCase()}`; + +// --- regular expressions ------------------------------------------------------------- +// +// A regex literal is ambiguous with division and the parser must already know +// which it is from context. Both appear below, adjacent. + +const simple = /abc/; +const withFlags = /abc/gimsuy; +const escapedSlash = /a\/b/; +const characterClass = /[a-z0-9_\-]+/i; +const namedGroups = /(?\d{4})-(?\d{2})/u; +const lookahead = /foo(?=bar)/; +const lookbehind = /(?<=\$)\d+/; +const backreference = /(\w)\1/; +const unicodeProperty = /\p{Letter}+/u; +const divisionNotRegex = maxSafe / decimal / 2; + +// --- the keyword and identifier literals ------------------------------------------------- + +const t = true; +const f = false; +const nul = null; +const undef = undefined; // an IDENTIFIER, not a literal +const voidUndefined = void 0; // the un-shadowable spelling + +// --- object literals ----------------------------------------------------------------------- + +const key = 'computed'; +const shorthandValue = 1; +const objectLiteral = { + plain: 1, + 'quoted key': 2, + "double quoted key": 3, + 42: 'numeric key', + 0.5: 'float key', + [key]: 'computed key', + [`${key}-template`]: 'computed from a template', + shorthandValue, + method() { return this.plain; }, + *generator() { yield 1; }, + async asyncMethod() { return 1; }, + get accessor() { return this.plain; }, + set accessor(v) { this.plain = v; }, + ['computed' + 'Method']() { return 'computed method'; }, + [Symbol.iterator]() { return [][Symbol.iterator](); }, + nested: { deep: { deeper: true } }, + __proto__: null, // the one key that is not a property + trailing: 'comma follows', +}; + +const spreadInObject = { ...objectLiteral, extra: true }; +const emptyObject = {}; + +// --- array literals ---------------------------------------------------------------------- + +const arrayLiteral = [1, 'two', null, undefined, true, { a: 1 }, [2]]; +const holes = [1, , 3]; // a HOLE, not undefined: `1 in holes` is false +const trailingComma = [1, 2, 3, ]; +const spreadInArray = [...arrayLiteral, ...'abc']; +const emptyArray = []; +const nestedArrays = [[1, [2, [3]]]]; + +module.exports = { + decimal, negative, float, leadingDot, trailingDot, exponent, negExponent, + hex, hexUpper, octal, binary, separators, floatSeparators, zero, negativeZero, + infinity, notANumber, maxSafe, beyondSafe, big, bigHex, bigZero, + single, double, withApostrophe, escaped, hexEscape, unicodeEscape, + codePointEscape, surrogatePair, lineContinuation, empty, unicodeIdentifierValue, + noSubstitution, oneSubstitution, expressionSubstitution, nestedTemplate, + multiline, escapeInTemplate, callInSubstitution, + simple, withFlags, escapedSlash, characterClass, namedGroups, lookahead, + lookbehind, backreference, unicodeProperty, divisionNotRegex, + t, f, nul, undef, voidUndefined, + objectLiteral, spreadInObject, emptyObject, + arrayLiteral, holes, trailingComma, spreadInArray, emptyArray, nestedArrays +}; diff --git a/parser/src/test-data/javascript/categories/expressions/operators.js b/parser/src/test-data/javascript/categories/expressions/operators.js new file mode 100644 index 000000000..a09de7577 --- /dev/null +++ b/parser/src/test-data/javascript/categories/expressions/operators.js @@ -0,0 +1,150 @@ +// fixture: cjs/expressions/operators.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2021 (logical assignment: &&= ||= ??=) +// +// Port of java/expressions/AssignmentExpressionExamples.java. +// +// THE RULE THIS FIXTURE EXISTS FOR: one ASSIGNMENT kind covers every compound +// form, with the operator in `operatorString` as a COLUMN. Java established it, +// TypeScript follows it, and Python's failure to establish it left 1,276 +// statements unpairable — target and value both depth-0 roots with no wrapper. +// +// The two tests that catch the flat form are both here: +// - `a += 1; b += 2;` on ONE LINE. Flat emission gives four depth-0 rows and +// the engine-side workaround pairs them on (scope, line, rootContext), +// yielding four pairs of which two INVENT value flow: `a` paired with `2`. +// - a compound assignment NESTED in something that emits no row of its own. + +'use strict'; + +let a = 1, b = 2, c = 3; +const arr = [1, 2, 3]; +const obj = { x: 1, y: { z: 2 } }; + +// --- arithmetic, comparison, logical, bitwise --------------------------------------- + +const arithmetic = [a + b, a - b, a * b, a / b, a % b, a ** b]; +const comparison = [a < b, a > b, a <= b, a >= b]; +const equality = [a === b, a !== b, a == b, a != b]; +const logical = [a && b, a || b, a ?? b]; +const bitwise = [a & b, a | b, a ^ b, ~a]; +const shifts = [a << 2, a >> 2, a >>> 2]; +const stringConcat = 'a' + 1 + true + null + undefined + [] + {}; + +// Right-associative exponentiation, and the parenthesisation it forces on the +// left operand — `-a ** b` is a syntax error. +const power = 2 ** 3 ** 2; +const negatedPower = (-a) ** 2; + +// --- every compound assignment ------------------------------------------------------- +// +// One kind, thirteen operators, thirteen values of operatorString. + +a += 1; a -= 1; a *= 2; a /= 2; a %= 3; a **= 2; +a <<= 1; a >>= 1; a >>>= 1; +a &= 7; a |= 8; a ^= 3; +let n = null; +n ??= 'nullish assigned'; +let truthy = 1; +truthy &&= 2; +let falsy = 0; +falsy ||= 3; + +// TWO COMPOUND ASSIGNMENTS ON ONE LINE. This is the case flat emission gets +// wrong by inventing `a` paired with `2`. +a += 1; b += 2; + +// Three on one line, with a member target and a computed target among them. +obj.x += 1; arr[0] += 2; obj.y.z += 3; + +// A compound assignment nested inside a parenthesised expression, a ternary and +// a call argument — each a position where a subtree rooted at a non-emitting +// node has died in a real parser. +const nestedInParens = ((a += 1)); +const nestedInTernary = b > 0 ? (a += 1) : (a -= 1); +const nestedInArgument = Math.max((a += 1), (b += 2)); +const nestedInTemplate = `${a += 1}`; +const nestedInArray = [(a += 1), (b += 2)]; + +// Chained plain assignment: right-associative, three targets, one value. +let p, q, r; +p = q = r = 5; + +// Assignment as an expression, used for its value. +const usedAsValue = (a = 10) + (b = 20); + +// --- destructuring assignment (not declaration) ------------------------------------------ +// +// The target is a PATTERN and there is no `const`. The parenthesised object form +// is required, because a statement cannot begin with `{`. + +let x, y, rest; +[x, y] = [1, 2]; +[x, y] = [y, x]; // swap +({ x, y } = { x: 3, y: 4 }); +[x, ...rest] = [1, 2, 3]; +({ x = 9, ...rest } = { y: 1 }); +[obj.x, arr[1]] = [10, 20]; // member expressions as targets +[[x], { y }] = [[1], { y: 2 }]; // nested patterns + +// --- unary and update --------------------------------------------------------------------- + +const unary = [+a, -a, !a, ~a, typeof a, void a, delete obj.x]; +let counter = 0; +const postIncrement = counter++; +const preIncrement = ++counter; +const postDecrement = counter--; +const preDecrement = --counter; + +// --- relational and type operators ---------------------------------------------------------- + +const inOperator = 'x' in obj; +const instanceOf = arr instanceof Array; +const typeofUndeclared = typeof neverDeclared; // the only safe undeclared read + +// --- conditional, comma, sequence ------------------------------------------------------------- + +const ternary = a > b ? 'a' : 'b'; +const nestedTernary = a > b ? (a > c ? 'a' : 'c') : (b > c ? 'b' : 'c'); +const comma = (a++, b++, c); +const inForHead = (function () { for (let i = 0, j = 10; i < j; i++, j--) { } return 'done'; })(); + +// --- optional chaining and nullish, as OPERATORS -------------------------------------------- + +const maybe = process.env.NOTHING ? obj : null; +const optionalMember = maybe?.x; +const optionalComputed = maybe?.['x']; +const optionalDeep = maybe?.y?.z; +const nullishDefault = maybe?.x ?? 'default'; +const mixedPrecedence = (maybe?.x ?? 0) + 1; + +// --- await and yield as operators -------------------------------------------------------------- + +async function operatorsInAsync(promise) { + const awaited = await promise; + const awaitedExpression = (await promise) + 1; + return awaited + awaitedExpression; +} + +function* operatorsInGenerator(inner) { + const received = yield 1; + const delegated = yield* inner; + return received + delegated; +} + +// --- new.target, a meta-property that is neither a member access nor an identifier ------------ + +function metaProperty() { + return new.target; +} + +module.exports = { + arithmetic, comparison, equality, logical, bitwise, shifts, stringConcat, + power, negatedPower, n, truthy, falsy, nestedInParens, nestedInTernary, + nestedInArgument, nestedInTemplate, nestedInArray, p, q, r, usedAsValue, + x, y, rest, unary, postIncrement, preIncrement, postDecrement, preDecrement, + inOperator, instanceOf, typeofUndeclared, ternary, nestedTernary, comma, + inForHead, optionalMember, optionalComputed, optionalDeep, nullishDefault, + mixedPrecedence, operatorsInAsync, operatorsInGenerator, metaProperty +}; diff --git a/parser/src/test-data/javascript/categories/expressions/trailing-comments.js b/parser/src/test-data/javascript/categories/expressions/trailing-comments.js new file mode 100644 index 000000000..0e8046032 --- /dev/null +++ b/parser/src/test-data/javascript/categories/expressions/trailing-comments.js @@ -0,0 +1,97 @@ +// fixture: cjs/expressions/trailing-comments.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// A TRAILING COMMENT INSIDE A BRACKETED CONSTRUCT versus the same comment after +// a statement. js-corpus reports the statement-position comment emits a +// js_comment row and the bracketed one does not. +// +// MEASURED at a5f6aab, with every comment carrying a unique TRAIL-nn marker: +// 25 in source, 15 emitted, **10 missing — and every missing one follows a +// COMMA.** TRAIL-02/03 (array), 06/07 (object), 10/11 (parameter), 14/15 +// (argument), 17 (pattern), 23 (block-comment form). Every last-element comment +// with NO comma after it — TRAIL-04, 08, 12, 16, 18, 24 — survives. So the +// defect is not "inside a bracketed construct"; it is "trailing a comma", which +// is a single token and a single place to look. +// +// The same comment text is used in every position, so a missing row is +// attributable to the POSITION and to nothing else. Each is paired: the +// bracketed form, then the statement form it must match. Every comment carries a +// unique marker so a row can be matched by name and an absence named. + +'use strict'; + +// --- the control: after a statement, which emits --------------------------------------- + +const afterStatement = 1; // TRAIL-01 after a statement + +// --- after an ARRAY ELEMENT ------------------------------------------------------------- + +const array = [ + 1, // TRAIL-02 after an array element, more elements follow + 2, // TRAIL-03 after an array element, trailing comma follows + 3 // TRAIL-04 after the last element, no comma +]; +const arrayControl = 4; // TRAIL-05 the statement form of the above + +// --- after an OBJECT PROPERTY ------------------------------------------------------------- + +const object = { + a: 1, // TRAIL-06 after a property, more follow + b: 2, // TRAIL-07 after a property, trailing comma follows + c: 3 // TRAIL-08 after the last property, no comma +}; +const objectControl = 5; // TRAIL-09 the statement form of the above + +// --- after a PARAMETER ------------------------------------------------------------------------ + +function parameters( + first, // TRAIL-10 after a parameter + second, // TRAIL-11 after a parameter, trailing comma follows + third // TRAIL-12 after the last parameter +) { + return first + second + third; // TRAIL-13 after a return statement — the statement form +} + +// --- after an ARGUMENT -------------------------------------------------------------------------- + +const called = parameters( + 1, // TRAIL-14 after an argument + 2, // TRAIL-15 after an argument + 3 // TRAIL-16 after the last argument +); + +// --- after a DESTRUCTURING ELEMENT and a class member ------------------------------------------------ + +const { + a, // TRAIL-17 after an object-pattern element + b // TRAIL-18 after the last pattern element +} = object; + +class Members { + method() { return 1; } // TRAIL-19 after a class method + other() { return 2; } // TRAIL-20 after the last class method +} + +// --- inside a bracketed construct but on its OWN line, not trailing anything -------------------------- + +const ownLine = [ + // TRAIL-21 a leading comment inside the brackets + 1, + // TRAIL-22 between elements + 2 +]; + +// --- block-comment form in the same positions, so the kind is not the variable ----------------------------- + +const blockForm = [ + 1, /* TRAIL-23 block comment after an element */ + 2 /* TRAIL-24 block comment after the last element */ +]; +const blockControl = 6; /* TRAIL-25 block comment after a statement */ + +module.exports = { + afterStatement, array, arrayControl, object, objectControl, parameters, + called, a, b, Members, ownLine, blockForm, blockControl +}; diff --git a/parser/src/test-data/javascript/categories/flow/casts.js b/parser/src/test-data/javascript/categories/flow/casts.js new file mode 100644 index 000000000..cadc32b45 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/casts.js @@ -0,0 +1,136 @@ +/* @flow */ +// fixture: flow/casts.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing — a Flow cast is erased, and the expression inside it +// runs. `(x: number)` evaluates `x`. +// expected provenance: FLOW_REJECTED — and if it is ever PROJECT again, four js_parse_gap rows sharing one primary key come back +// syntax floor: Flow cast expressions, from flow.org/en/docs/types/casting +// +// THE LOUD HALF, and the one that stresses ERROR RECOVERY rather than +// classification. A Flow cast `(expr: Type)` is a parenthesised expression with +// a type annotation, which TypeScript's expression grammar has no production +// for — so every one below sends the parser into recovery, and recovery is +// where duplicate rows come from. +// +// ## Why this fixture is about primary keys — MEASURED, through the extractor +// +// `js_parse_gap`'s PK is `(ownerModule, gapKind, startLine, startColumn)`. Every +// parse diagnostic is minted with `gapKind = PARSE_ERROR`, so **the diagnostic +// CODE is not in the key**: any two diagnostics at one line and column produce +// two rows with one primary key. Duplicates DOUBLE, they do not collide, and +// nothing about the output looks wrong. +// +// Run against this file, the parser emits **four `js_parse_gap` rows sharing one +// primary key**, all at column 1 of the line ONE PAST THE LAST — that is, +// `start = the file's length` — each a zero-length `1005: '` in +// a position the type grammar cannot accept is read as an **unterminated +// JSX element**, and the recovery runs to end of file. +// 2. The parse-gap PK omits the diagnostic code. +// +// Neither is wrong by itself. Together, Flow generics become unterminated JSX, +// the diagnostics pile up at one offset, and the rows collapse to one key. +// +// ## What I could NOT reproduce, recorded so nobody re-derives it +// +// A minimal synthetic version does **not** reproduce this. N casts of the form +// `(raw: Array)` in an otherwise empty module produce N diagnostics and +// **zero** at EOF, for N in 0,1,2,3,5,8. I expected the count to be linear in +// the number of generic casts; it is not. Bisecting this file shows the EOF +// count moving up AND down as later lines are added — it rose to five and fell +// back to four twice while I bisected — because an unterminated JSX element +// swallows whatever follows it. +// +// So the collision is an emergent property of a realistic file, not of any one +// construct — which is the argument for this fixture existing rather than a +// three-line regression test. Do not "simplify" it: the simplification does not +// reproduce. +// +// ## It discriminates under all three rulings +// +// distinct scriptKind — a Flow parser reads these as casts: no gap, no recovery +// explicit rejection — zero rows, and the collision cannot occur +// emit-with-residual — recovery runs, and whether the residual is deduped by +// primary key is exactly what this file asks + + +const raw: mixed = JSON.parse('{}'); +declare function f(x: number): string; +declare function g(): number; +class C { constructor(x: number) { this.x = x; } } +const p: Promise = Promise.resolve('a'); +const obj = { a: 1 }; + +// --- one cast per line: the attributable case --------------------------------- + +const simple = (raw: string); +const ofCall = (g(): number); +const ofNew = (new C(1): C); +const ofObject = ({ a: 1 }: { a: number }); +const ofArray = ([1, 2]: Array); +const ofMember = (obj.a: number); +const ofTemplate = (`t`: string); + +// The double cast — Flow's documented escape hatch for an unsafe conversion, +// `(value: any: Target)`. Two annotations, one parenthesised expression. +const doubleCast = ((raw: any): string); + +// A cast whose result is immediately used, so the recovery has to rejoin the +// expression grammar rather than just skip to the next statement. +const thenMember = (raw: string).length; +const thenCall = (raw: Object).toString(); +const thenIndex = (raw: Array)[0]; +const inBinary = (raw: number) + 1; +const inTernary = (raw: boolean) ? 1 : 2; +const inSpread = [...(raw: Array)]; + +// --- casts in argument, return and arrow-body position --------------------------- + +function consume(x: string): number { return x.length; } +const asArgument = consume((raw: string)); +function returnsCast(): string { return (raw: string); } +const arrowBody = () => (raw: string); +const arrowBlock = () => { return (raw: string); }; + +// --- casts inside an await and a template substitution ---------------------------- + +export async function awaited(): Promise { + const v = (await p: string); + return v.length; +} +const inTemplateSub = `${(raw: string)}`; + +// --- TWO casts on ONE line: the stacked case -------------------------------------- +// +// If recovery reports more than one diagnostic at the same column, or emits more +// than one node over the same span, these lines are where it shows. + +const twoOnALine = (raw: string), alsoOnIt = (raw: number); +const nested = ((raw: any): (string)); +const adjacent = [(raw: string), (raw: number), (raw: boolean)]; +const chained = ((raw: A): B); + +// --- a cast in a position that is ALSO valid JavaScript ------------------------------ +// +// The control. `(a, b)` is a comma expression and `(x)` is a parenthesised +// identifier; neither is a cast, and neither may produce a gap. A recogniser +// that treats every parenthesised expression as a possible cast trips here. + +const commaExpression = (raw, obj); +const justParens = (raw); +const arrowParams = (a, b) => a + b; +const iife = (function () { return 1; })(); + +export { simple, ofCall, ofNew, ofObject, ofArray, ofMember, ofTemplate, + doubleCast, thenMember, thenCall, thenIndex, inBinary, inTernary, inSpread, + asArgument, returnsCast, arrowBody, arrowBlock, inTemplateSub, + twoOnALine, alsoOnIt, nested, adjacent, chained, + commaExpression, justParens, arrowParams, iife }; diff --git a/parser/src/test-data/javascript/categories/flow/declare-statements.js b/parser/src/test-data/javascript/categories/flow/declare-statements.js new file mode 100644 index 000000000..1e6ba6d98 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/declare-statements.js @@ -0,0 +1,141 @@ +/* @flow */ +// fixture: flow/declare-statements.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing — by the gate's definition (it has 15 statements). +// RELABELLED 2026-09-12: this file was labelled type-only on purpose, to +// falsify the `statements.length === 0` predicate, and it did. The Flow +// out-of-scope ruling then made the falsification moot: the file is now +// FLOW_EXCLUDED and emits one module row, so whether its statements are +// "type-only" is not a question the parser ever asks. The argument is kept +// below as history; the label is now what the gate can check. +// expected provenance: FLOW_REJECTED — and if it is ever PROJECT again, 13 +// NO_BODY method rows come back into the call graph +// syntax floor: not JavaScript at all — Flow's `declare` family, from +// flow.org/en/docs/libdefs and flow.org/en/docs/types/modules +// +// MEASURED, and it is the reason this fixture exists: under `ScriptKind.JS` +// every declaration below parses with **ZERO diagnostics** — verified, and the +// three Flow `declare` forms that are NOT silent (`declare opaque type`, +// `declare module.exports`, and a variance sigil on a declared property) were +// moved out to recovery-mangling.js so that claim stays true of this file. `declare function` +// becomes a `FunctionDeclaration`, `declare class` a `ClassDeclaration`, +// `declare module` a `ModuleDeclaration`, and `interface` an +// `InterfaceDeclaration` — in a `.js` file, where JavaScript has none of those +// concepts. +// +// So this is the DANGEROUS half of Flow, not the loud half. A file of exact +// object types announces itself with a parse gap per line; this one announces +// nothing, and every row it mints is a confident claim about a program that has +// no runtime existence whatsoever. +// +// ## The specific defect this discriminates +// +// `declare function` has no body, so it mints a method row with +// `bodyPresence = NO_BODY` — and Gate 4 says type-only constructs never reach +// the call graph. Every declaration here is a call TARGET that can never be +// called, because none of it survives to runtime. +// +// I recorded `JsBodyPresence.NO_BODY` in MANIFEST.md Findings 7 as "not +// expressible in JavaScript — there is no `declare`, no ambient signature, no +// bodyless callable". That was **wrong**, and this file is the counter-example: +// it is not expressible in JavaScript and it is reachable from Flow, which the +// corpus contains. Findings 7 is corrected. +// +// ## It discriminates under all three of js-oracle's open rulings +// +// distinct scriptKind — parsed as Flow: these are ambient declarations, and +// whatever relation holds them, none is a call target +// explicit rejection — the file is skipped: ZERO rows, and the absence is +// checkable because the names below appear nowhere else +// emit-with-residual — rows are minted with no diagnostic to mark them, so +// `isTypeOnly` / `bodyPresence` are the ONLY columns +// that can carry the fact. If they do not, the call +// graph gains **seven** unreachable targets: this file +// has 15 statements, 0 diagnostics and 7 bodyless +// function declarations (`parse` x3, `stringify`, +// `walk`, `load`, `configure`), all measured. +// +// ## The predicate this file breaks +// +// MANIFEST.md Findings 5 proposed, and `js-impl` implemented, a mechanical +// nature gate: `type-only` iff `ts.createSourceFile(...).statements.length === 0`. +// I argued that was EXACT for JavaScript because "there is no erasable +// declaration form here". Flow has one. This file has fifteen statements and +// zero runtime, so the predicate now returns the wrong answer. +// +// The refinement, proposed rather than applied because the gate is not mine. +// Measured on this file: 15 top-level statements, of which **14 carry +// `ts.ModifierFlags.Ambient`** and the fifteenth is the `interface`, which is +// type-only by KIND rather than by modifier. So: +// +// type-only iff every top-level statement either carries ModifierFlags.Ambient +// or is an InterfaceDeclaration / TypeAliasDeclaration +// +// The `statements.length === 0` form is still correct for every non-Flow file in +// this corpus, and both type-only fixtures in `cjs/` still satisfy it. This is a +// widening, not a replacement — and if js-oracle rules Flow out of scope it is +// not needed at all, which is the fourth way this file discriminates. + + +// --- declare function, including the overload set ------------------------------ +// +// Flow spells overloads as repeated `declare function` with one name. There is +// no implementation anywhere, in this file or any other — an importer gets the +// runtime export, and these describe it. + +declare function parse(input: string): Object; +declare function parse(input: string, reviver: Function): Object; +declare function parse(input: Buffer, encoding: string): Object; + +declare function stringify(value: mixed): string; + +// A declared generator and a declared async function. Both bodyless. +declare function walk(root: T): Iterator; +declare function load(path: string): Promise; + +// --- declare class --------------------------------------------------------------- +// +// Every member is a signature. `m()` has no body; neither does the constructor. + +declare class Emitter { + constructor(name: string): void; + emit(event: string, ...args: Array): boolean; + static create(name: string): Emitter; + listeners: Array; +} + +declare class Subclass extends Emitter { + extra(): void; +} + +// --- declare var / let / const ------------------------------------------------------- + +declare var __DEV__: boolean; +declare var process: { env: { [string]: string } }; + +// --- declare type and declare opaque type ------------------------------------------ + +declare type NodeId = string; + +// --- interface, which JavaScript does not have at all --------------------------------- + +interface Serializable { + serialize(): string; +} + +// --- declare export, the libdef form ------------------------------------------------- + +declare export function configure(options: Object): void; +declare export default class Registry { + register(id: NodeId): void; +} + +// --- declare module, which has no JavaScript analogue in any dialect -------------------- +// +// A whole module's shape declared from outside it. There is no runtime object, +// no file at './missing-at-runtime', and nothing to import. + +declare module 'missing-at-runtime' { + declare export function helper(): void; + declare export var version: string; +} diff --git a/parser/src/test-data/javascript/categories/flow/detection-miss/after-long-licence.js b/parser/src/test-data/javascript/categories/flow/detection-miss/after-long-licence.js new file mode 100644 index 000000000..aa4024d34 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/detection-miss/after-long-licence.js @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2019-present, the fixture authors. + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @flow + */ +// fixture: flow/detection-miss/after-long-licence.js +// nature: runtime-bearing — it has statements; what the parser DOES with them is the next line +// expected provenance: FLOW_REJECTED — and the detector MISSES IT — the pragma sits past the 2,048-byte window, so the parser emits it as PROJECT +// +// MEASURED: `hasFlowPragma` is `/@flow\b/.test(sourceText.slice(0, 2048))`. +// The pragma above sits past that bound, so the detector returns false and this +// file is emitted as ordinary JavaScript. Under schema 2.6 that makes it a +// DETECTION MISS, and `declaredTypeSource = SYNTACTIC_FLOW` on the parameter +// below is the named residual that is supposed to say so. +// +// This is not a contrived length. A widely-copied house style puts the pragma at +// the END of a copyright block, and an Apache-2.0 header with a contributor +// notice reaches this size in real repositories. The bound's stated purpose is +// to stop the word matching "in any prose comment" -- but a licence is prose, +// and it is exactly what sits above a pragma. +// +// The fix is not simply a bigger number: any bound can be exceeded. Bounding to +// the leading COMMENT RUN rather than to a byte count is the shape that cannot +// be outgrown, and it is js-impl's call. + +export function flowAfterLongLicenceMarker(x: number): string { + return String(x); +} diff --git a/parser/src/test-data/javascript/categories/flow/detection-miss/no-pragma.js b/parser/src/test-data/javascript/categories/flow/detection-miss/no-pragma.js new file mode 100644 index 000000000..412c634f3 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/detection-miss/no-pragma.js @@ -0,0 +1,55 @@ +// fixture: flow/detection-miss/no-pragma.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing — it has statements; what the parser DOES with them is the next line +// expected provenance: PROJECT — because there is nothing to detect — this is the tripwire, and SYNTACTIC_FLOW on its parameters is what says the file is Flow +// ordinary `.js` extension. There is nothing for the detector to detect. +// +// THIS IS THE FIXTURE THE TRIPWIRE EXISTS FOR. Schema §2.6 changed +// `declaredTypeSource = SYNTACTIC_FLOW` from "a declared-type channel" to "a +// detection miss": a syntactic type annotation that survived into an emitted +// file, which can only happen if the file is Flow and carried no pragma. +// +// Its expected count in emitted files is 0. Deliberately NOT a zero-row +// assertion — §7.3c — because the population it measures is the failure of the +// detector, which is a corpus property. This file is the only thing in the +// corpus that can make it fire, so without it the column is untestable and a +// gate asserting zero would pass vacuously forever. +// +// Real provenance: the pragma is removed during a refactor, or a file is copied +// out of a Flow project into one that does not use it, or a codemod rewrites +// the header. The annotations stay. Every one below parses under `ScriptKind.JS` +// with ZERO diagnostics, so nothing else in the fact base can flag this file. +// +// Deliberately using only the SILENT half of Flow — measured, these produce no +// parse diagnostic at all. A loud construct would be caught by a parse gap and +// would make the fixture prove less than it claims. + +export function flowNoPragmaMarker(value: mixed): string { + return typeof value === 'string' ? value : ''; +} + +export function maybeMarker(input: ?string): number { + return input == null ? 0 : input.length; +} + +export function utilityMarker(config: $ReadOnly<{ host: string }>): string { + return config.host; +} + +export function indexerMarker(counts: { [string]: number }): number { + return Object.keys(counts).length; +} + +export class NoPragmaStore { + entries: { [string]: mixed }; + size: number; + + constructor(size: number) { + this.entries = {}; + this.size = size; + } + + get(key: string): mixed { + return this.entries[key]; + } +} diff --git a/parser/src/test-data/javascript/categories/flow/detection/after-shebang.js b/parser/src/test-data/javascript/categories/flow/detection/after-shebang.js new file mode 100644 index 000000000..caeb0bafd --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/detection/after-shebang.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +// @flow +// fixture: flow/detection/after-shebang.js +// nature: runtime-bearing — it has statements; what the parser DOES with them is the next line +// expected provenance: FLOW_REJECTED — the detector must fire on line 2, behind a shebang +// detector: the pragma is not the first line, because a shebang must be. Any +// detector that requires the pragma at offset 0 misses every Flow CLI entry +// point, and `bin` scripts are exactly where Flow-typed tools put them. + +export function flowAfterShebangMarker(argv: Array): number { + return argv.length; +} diff --git a/parser/src/test-data/javascript/categories/flow/detection/block-pragma.js b/parser/src/test-data/javascript/categories/flow/detection/block-pragma.js new file mode 100644 index 000000000..5134ff174 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/detection/block-pragma.js @@ -0,0 +1,9 @@ +/* @flow */ +// fixture: flow/detection/block-pragma.js +// nature: runtime-bearing — it has statements; what the parser DOES with them is the next line +// expected provenance: FLOW_REJECTED — the detector must fire on `/* @flow */` +// detector: `/* @flow */` block comment. The form Flow's own upstream source uses. + +export function flowBlockPragmaMarker(x: ?string): string { + return x == null ? '' : x; +} diff --git a/parser/src/test-data/javascript/categories/flow/detection/jsdoc-pragma.js b/parser/src/test-data/javascript/categories/flow/detection/jsdoc-pragma.js new file mode 100644 index 000000000..18fd3c858 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/detection/jsdoc-pragma.js @@ -0,0 +1,18 @@ +/** + * fixture: flow/detection/jsdoc-pragma.js + * nature: runtime-bearing — it has statements; what the parser does with them is the next line + * expected provenance: FLOW_EXCLUDED — the detector must fire on a pragma inside a JSDoc block among other tags + * + * detector: the pragma inside a JSDoc block, surrounded by other tags. This is + * the shape a file acquires when someone adds Flow to a documented module, and + * it is the one where a detector that looks only at the FIRST comment's first + * line would miss it. + * + * @module jsdoc-pragma + * @flow + * @author fixture + */ + +export function flowJsdocPragmaMarker(items: Array): number { + return items.length; +} diff --git a/parser/src/test-data/javascript/categories/flow/detection/line-pragma.js b/parser/src/test-data/javascript/categories/flow/detection/line-pragma.js new file mode 100644 index 000000000..31c320ddf --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/detection/line-pragma.js @@ -0,0 +1,13 @@ +// @flow +// fixture: flow/detection/line-pragma.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing — it has statements; what the parser DOES with them is the next line +// expected provenance: FLOW_REJECTED — the detector must fire on `// @flow` line 1 +// detector: `// @flow` line comment, first line. The canonical form. +// +// Payload is uniquely named so a leak is greppable: nothing else in the corpus +// declares `flowLinePragmaMarker`. + +export function flowLinePragmaMarker(x: number): string { + return String(x); +} diff --git a/parser/src/test-data/javascript/categories/flow/detection/strict-pragma.js b/parser/src/test-data/javascript/categories/flow/detection/strict-pragma.js new file mode 100644 index 000000000..845297850 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/detection/strict-pragma.js @@ -0,0 +1,11 @@ +// @flow strict-local +// fixture: flow/detection/strict-pragma.js +// nature: runtime-bearing — it has statements; what the parser DOES with them is the next line +// expected provenance: FLOW_REJECTED — the detector must fire on a mode suffix +// detector: the pragma carries a MODE suffix. Flow has `@flow`, `@flow strict`, +// `@flow strict-local` and `@flow weak`, and a detector anchored on the exact +// string `@flow` followed by end-of-line matches none of the last three. + +export function flowStrictPragmaMarker(value: mixed): boolean { + return typeof value === 'string'; +} diff --git a/parser/src/test-data/javascript/categories/flow/false-positive/flow-scoped-package.js b/parser/src/test-data/javascript/categories/flow/false-positive/flow-scoped-package.js new file mode 100644 index 000000000..d48a68c4a --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/false-positive/flow-scoped-package.js @@ -0,0 +1,26 @@ +// fixture: flow/false-positive/flow-scoped-package.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing — it has statements; what the parser DOES with them is the next line +// expected provenance: PROJECT — plain JavaScript, must be emitted in full — the detector fires on a scoped package specifier +// +// A second false-positive vector, and a subtler one: the detector matches the +// pragma token as a bare WORD, and `/` is a word boundary — so a scoped package +// specifier under that npm scope matches. The token appears nowhere in this +// header; it is in the import on the first code line, in a string, and in a +// template, and those three are the whole trigger. +// +// MEASURED: fires. Flow's toolchain publishes real packages under that scope +// and beside it, any of which a plain JavaScript file may depend on +// — a file that merely CONSUMES Flow tooling is not itself Flow. +// +// Also covered: the word inside a string literal and inside a template, neither +// of which is a comment and neither of which any pragma convention honours. + +import parser from '@flow/parser'; + +const toolName = '@flow/config'; +const message = `run @flow check before committing`; + +export function scopedPackageFalsePositiveMarker(source) { + return parser.parse(source, { name: toolName, message }); +} diff --git a/parser/src/test-data/javascript/categories/flow/false-positive/mentions-flow-in-prose.js b/parser/src/test-data/javascript/categories/flow/false-positive/mentions-flow-in-prose.js new file mode 100644 index 000000000..55e3d0be8 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/false-positive/mentions-flow-in-prose.js @@ -0,0 +1,35 @@ +// fixture: flow/false-positive/mentions-flow-in-prose.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing — it has statements; what the parser DOES with them is the next line +// expected provenance: PROJECT — this is plain JavaScript and must be emitted in full — the detector fires on prose, and that is the false positive +// +// The detector matches the pragma token as a bare word over the first 2,048 +// bytes, and this file contains that token in ordinary prose, ONCE, in the +// migration note near the bottom. MEASURED: it fires, and the header above is +// deliberately written without the token so the trigger is attributable to that +// one comment and to nothing else. +// +// Under schema §2.6 a false positive is not a cosmetic problem. Exclusion means +// the file contributes ONE module row and nothing else, so a plain JavaScript +// file that trips the detector is silently deleted from the fact base — its +// functions, its call sites and its imports all vanish, and `FLOW_EXCLUDED` +// makes the deletion look deliberate. That is the mirror image of a detection +// miss and it is equally invisible to a count. +// +// A pragma is a DIRECTIVE: it must be in a leading comment, and Flow itself +// only honours it there. "The word appears in the first 2 KB" is a weaker test +// than that, and this file is the gap between the two. +// +// This one is not hypothetical. A migration note at the top of a file is the +// single most likely place for the word to appear, because that is where +// someone writes down why the file is NOT typed yet. + +// TODO(build): this module is not @flow typed yet — the annotations were +// stripped when it moved out of the typed package. See the migration notes +// before adding @flow back; the indexer types do not survive the codemod. + +export function proseFalsePositiveMarker(a, b) { + return a + b; +} + +export const config = { retries: 3 }; diff --git a/parser/src/test-data/javascript/categories/flow/flow-annotations.js b/parser/src/test-data/javascript/categories/flow/flow-annotations.js new file mode 100644 index 000000000..06060800f --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/flow-annotations.js @@ -0,0 +1,90 @@ +/* @flow */ +// fixture: flow/flow-annotations.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing — and it CANNOT RUN unmodified. Flow's inline +// annotation syntax is stripped by a build step; Node rejects it. +// expected provenance: FLOW_REJECTED — the pragma is on line 1 and the detector must fire +// syntax floor: ES2015 + Flow INLINE annotation syntax +// +// declaredTypeSource = SYNTACTIC_FLOW, the third value beside NONE and JSDOC. +// The schema measured 64 syntactically annotated parameters in its whole corpus +// and ALL 64 WERE FLOW — so this value does not rest on a hypothesis, it rests +// on the only syntactic annotations that were actually found. Until now it rested +// on no fixture, which is a different problem: an enum value with no fixture +// cannot be told apart from an unimplemented one. +// +// The behaviour the schema names is why the value exists: ts.createSourceFile +// "parses these into real .type nodes where it overlaps TypeScript and +// MIS-PARSES SILENTLY where it does not". Both halves are below. The overlapping +// half looks exactly like TypeScript and must not be recorded as TypeScript; the +// non-overlapping half is Flow-only and is where a TypeScript-shaped reader goes +// wrong without saying so. +// +// Grounded in a Flow-throughout application framework, which is written this way throughout. + + +// --- the half that OVERLAPS TypeScript --------------------------------------- +// +// Identical spelling, different language. Recording these as TypeScript +// annotations is the mistake declaredTypeSource exists to prevent. + +export function resolveAsset(options: Object, type: string, id: string): mixed { + return options[type] && options[type][id]; +} + +export const isReserved = (str: string): boolean => str.charCodeAt(0) === 0x24; + +export class Dep { + id: number; + subs: Array; + static target: ?Dep; + + constructor(id: number) { + this.id = id; + this.subs = []; + } + + addSub(sub: Object): void { + this.subs.push(sub); + } +} + +// --- the half that is FLOW ONLY ---------------------------------------------- +// +// None of this is TypeScript. `?T` is Flow's maybe type and means +// `T | null | void`, which TypeScript spells differently; `{| |}` is an exact +// object; `$Shape`, `mixed` and the variance sigils have no TypeScript form. + +export function maybeName(name: ?string): string { + return name == null ? 'anonymous' : name; +} + +export function exact(config: {| host: string, port: number |}): string { + return `${config.host}:${config.port}`; +} + +export function shaped(partial: $Shape<{ a: number, b: string }>): mixed { + return partial; +} + +// Variance sigils on properties: `+` covariant (read-only), `-` contravariant. +export type ReadOnlyPoint = { +x: number, +y: number }; +export type WriteOnlySink = { -value: string }; + +// An opaque type alias — Flow's nominal-typing escape hatch, with no +// TypeScript equivalent at all. +export opaque type UserId: string = string; + +// A generic with a bound, and Flow's `*` existential type. +export function first(items: Array): ?T { + return items[0]; +} + +// Type-only import and export, Flow's spelling. +import type { ComponentOptions } from './flow-pragma.js'; +export type { ComponentOptions }; + +// A function type as a parameter annotation, and a predicate function. +export function apply(fn: (value: number) => string, n: number): string { + return fn(n); +} diff --git a/parser/src/test-data/javascript/categories/flow/flow-pragma.js b/parser/src/test-data/javascript/categories/flow/flow-pragma.js new file mode 100644 index 000000000..0608503c7 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/flow-pragma.js @@ -0,0 +1,44 @@ +/* @flow */ +// fixture: flow/flow-pragma.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing +// expected provenance: FLOW_REJECTED — comment-only Flow, and the pragma still makes it a Flow file +// syntax floor: ES2015 + Flow comment syntax (which is not syntax at all — it is +// entirely inside comments, and this file runs unmodified in any engine) +// +// The @flow pragma with NO annotation syntax. js_module.hasFlowPragma is a +// column and js_comment.directiveKind = FLOW_PRAGMA is a value, and both are +// about the DIRECTIVE rather than about any annotation: a file can declare +// itself Flow-checked and carry all its types in comment form. +// +// This is the "comment types" Flow dialect — `/*: T */` and `/*:: ... */` — which +// exists precisely so Flow-typed code can ship without a build step. It is a +// THIRD type-comment dialect beside JSDoc and TypeScript's, and a parser that +// assumes every type comment is JSDoc reads none of it. +// +// Grounded in a Flow-throughout application framework, which opens nearly every file with `/* @flow */`. + + +import { EventEmitter } from 'node:events'; + +/*:: +type ComponentOptions = { + name: string, + data: () => Object, + props?: Array, +}; +export type { ComponentOptions }; +*/ + +export function createComponent(options /*: ComponentOptions */) /*: Object */ { + return { ...options, _isComponent: true }; +} + +export const noop = (/*:: ...args: Array */) /*: void */ => {}; + +export class Watcher extends EventEmitter { + constructor(expression /*: string */) { + super(); + this.expression = expression /*: string */; + } +} diff --git a/parser/src/test-data/javascript/categories/flow/package.json b/parser/src/test-data/javascript/categories/flow/package.json new file mode 100644 index 000000000..8a78a2af3 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/package.json @@ -0,0 +1,4 @@ +{ + "name": "javascript-categories-flow", + "type": "module" +} diff --git a/parser/src/test-data/javascript/categories/flow/recovery-mangling.js b/parser/src/test-data/javascript/categories/flow/recovery-mangling.js new file mode 100644 index 000000000..1afe9165e --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/recovery-mangling.js @@ -0,0 +1,126 @@ +/* @flow */ +// fixture: flow/recovery-mangling.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing in intent — but see below, because what the parser +// produces from this file is not what the file says. +// expected provenance: FLOW_REJECTED — and if it is ever PROJECT again, 63 statements from ~25 written come back, four of them detached Blocks +// syntax floor: Flow syntax with no TypeScript equivalent, from +// flow.org/en/docs/types/objects, /variance, /opaque-types and /libdefs +// +// THE LOUD HALF, and the finding is not the diagnostics — it is what recovery +// leaves behind. Measured per construct under `ScriptKind.JS`: +// +// | one source statement | diags | statements produced | +// |------------------------------------------|-------|---------------------| +// | `type T = { +x: number };` | 3 | TypeAlias, Expression, Expression, Empty | +// | `opaque type Id = string;` | 1 | Expression, TypeAlias | +// | `declare opaque type Token: string;` | 2 | Expression, Expression, TypeAlias, Expression | +// | `declare module.exports: {...};` | 1 | Expression, Expression, **Block**, Empty | +// | `declare class E { +p: T; }` | 4 | Class, Expression, Expression | +// | `function f(x): boolean %checks {...}` | 2 | Function, Expression, **Block** | +// | `import typeof T from "./m";` | 1 | Import, Expression, Expression | +// +// **One statement in, four out.** Recovery does not skip the construct it cannot +// parse — it re-enters the statement grammar mid-expression and manufactures +// statements that are in no source line, including detached `Block`s that will +// be walked as if they were real blocks and `ExpressionStatement`s whose +// identifiers will be resolved as if they were real reads. +// +// That is a different failure from a missing row. A parse gap says something is +// absent; this produces rows that are PRESENT and describe nothing. They are +// correctly positioned, so they are invisible to recall, to completeness and to +// every count-based check — §4's defect class, arriving through the parser's +// error path rather than through a classification bug. +// +// ## It discriminates under all three rulings +// +// distinct scriptKind — a Flow parser produces one statement per statement and +// none of the phantoms below exist +// explicit rejection — zero rows, and the phantoms cannot be minted +// emit-with-residual — the phantom statements ARE the residual, and the +// question this file asks is whether anything marks them. +// A gap at the diagnostic's position does not cover the +// invented `Block` three tokens later. + + +// --- variance sigils: read-only and write-only properties ------------------------- +// +// `+` covariant, `-` contravariant. Flow's spelling for readonly-ness, and there +// is no TypeScript form, so the object type is torn in half. + +type Point = { +x: number, +y: number }; +type Sink = { -value: string }; +type Both = { +ro: number, -wo: string, rw: boolean }; + +// --- variance on class properties ------------------------------------------------- + +declare class Frozen { + +readOnlyProp: number; + -writeOnlyProp: string; +} + +// --- exact object types ------------------------------------------------------------ +// +// `{| |}` means no extra properties. TypeScript has no exact-object syntax at +// all, so both delimiters are unparseable and the recovery is the widest here. + +type ExactPoint = {| x: number, y: number |}; +type ExactNested = {| inner: {| deep: string |} |}; + +export function takesExact(p: {| a: number |}): number { + return p.a; +} + +// --- opaque types --------------------------------------------------------------------- +// +// Flow's nominal escape hatch: outside this file `UserId` is not a string. +// `opaque` is not a TypeScript keyword, so it is parsed as an identifier and +// becomes an expression statement of its own. + +opaque type UserId = string; +opaque type Meters: number = number; + +declare opaque type Token: string; + +// --- type-parameter bounds --------------------------------------------------------------- +// +// Flow writes ``; TypeScript writes ``. The colon is +// the whole difference and it is a syntax error. + +export function firstOf(items: Array): T { + return items[0]; +} + +declare class Container { + get(): T; +} + +// --- predicate functions ------------------------------------------------------------------- +// +// `%checks` marks a function as a type refinement. The `%` sends recovery into +// the statement grammar and the function's real body becomes a DETACHED BLOCK. + +export function isString(x: mixed): boolean %checks { + return typeof x === 'string'; +} + +// --- object type spread and the module.exports declaration ------------------------------------ + +type Base = { a: number }; +type Spread = { ...Base, b: number }; +type ExactSpread = {| ...Base, +c: string |}; + +declare module.exports: { takesExact: typeof takesExact }; + +// --- import typeof, which has no TypeScript spelling -------------------------------------------- + +import typeof StoreClass from './silently-typed.js'; + +// --- the control: valid JavaScript that recovery must NOT touch ----------------------------------- +// +// Nothing below is Flow. If any of it acquires a diagnostic or a phantom +// statement, the recovery from the constructs above has run past its construct. + +export const plainObject = { x: 1, y: 2 }; +export function plainFunction(a, b) { return a + b; } +export class PlainClass { constructor() { this.ok = true; } } diff --git a/parser/src/test-data/javascript/categories/flow/silently-typed.js b/parser/src/test-data/javascript/categories/flow/silently-typed.js new file mode 100644 index 000000000..a4183dea1 --- /dev/null +++ b/parser/src/test-data/javascript/categories/flow/silently-typed.js @@ -0,0 +1,176 @@ +/* @flow */ +// fixture: flow/silently-typed.js +// module system: ESM (governing: staging/flow/package.json, "type": "module") +// nature: runtime-bearing — every function below has a body and runs, once the +// annotations are stripped by Flow's build step. +// expected provenance: FLOW_REJECTED — and if it is ever PROJECT again, 28 SYNTACTIC_FLOW parameters come back with zero diagnostics to find them by +// syntax floor: Flow type annotations, from flow.org/en/docs/types +// +// THE SILENT POPULATION. Every annotation in this file parses under +// `ScriptKind.JS` with **ZERO diagnostics**, and every one of them is Flow. +// There is no TypeScript here and there cannot be: this is a `.js` file. +// +// This is the 3,793-parameter case, and it is the one that cannot be found by +// counting parse gaps — because it produces none. A corpus sweep that measures +// Flow by its diagnostics sees the loud half (exact objects, variance sigils, +// casts) and is structurally incapable of seeing this half at all. +// +// ## A correction to how the population was described to me +// +// The brief grouped "exact object types, variance sigils, opaque types, +// `$ReadOnly` and friends" together as silently accepted. **Measured, they +// split**, and the split is the whole point: +// +// | construct | diagnostics under ScriptKind.JS | +// |--------------------------------|---------------------------------| +// | `$ReadOnly` and friends | 0 — silent, this file | +// | `mixed`, `empty` | 0 — silent, this file | +// | `?T` maybe types | 0 — silent, this file | +// | `{ [string]: number }` indexer | 0 — silent, this file | +// | `Array<*>` existential | 0 — silent, this file | +// | `interface` / `implements` | 0 — silent, declare-statements.js | +// | exact object `{| |}` | 4 — LOUD, recovery-mangling.js | +// | variance sigil `+x` | 3 — LOUD, recovery-mangling.js | +// | `opaque type` | 1 — LOUD, recovery-mangling.js | +// | type-param bound `` | 1 — LOUD, recovery-mangling.js | +// +// So the silent set is BIGGER and DIFFERENT from the one named, and it includes +// the whole ambient-declaration family. Recording that split is worth more than +// either fixture on its own. +// +// ## What makes these Flow and not TypeScript +// +// Every type named below is either Flow-only (`mixed`, `empty`, `$Keys`, +// `$ObjMap`, the existential `*`) or means something DIFFERENT in Flow than the +// identically-spelled TypeScript. `mixed` is Flow's `unknown`; TypeScript has no +// `mixed`, so a TypeScript-shaped reader records a reference to a nominal type +// named "mixed" that resolves to nothing, in a language that has no nominal +// types. The row is syntactically well-formed and semantically empty. +// +// ## It discriminates under all three rulings +// +// distinct scriptKind — parsed as Flow, `declaredTypeSource = SYNTACTIC_FLOW` +// explicit rejection — zero rows; the parameter names below appear nowhere else +// emit-with-residual — rows minted with NO diagnostic anywhere, so +// `declaredTypeSource` is the only column that can say +// these are Flow. If it says anything else, a JavaScript +// fact base is asserting TypeScript annotations. + + +// --- Flow's top and bottom types -------------------------------------------------- +// +// `mixed` is the supertype of everything and requires refinement before use. +// `empty` is the bottom type, inhabited by nothing. Neither exists in TypeScript. + +export function describe(value: mixed): string { + if (typeof value === 'string') { return value; } + if (typeof value === 'number') { return String(value); } + return 'unknown'; +} + +export function unreachable(x: empty): empty { + throw new Error('unreachable'); +} + +// --- maybe types --------------------------------------------------------------------- +// +// `?T` is `T | null | void`. TypeScript spells that `T | null | undefined` and +// has no prefix form, yet `?T` draws no diagnostic here. + +export function trim(input: ?string): string { + return input == null ? '' : input.trim(); +} + +export function pick(items: ?Array): number { + return (items && items[0]) || 0; +} + +// --- the $-prefixed utility types --------------------------------------------------- +// +// Every one of these is a Flow builtin. They parse as ordinary generic type +// references because `$Foo` is a legal identifier, so nothing marks them. + +export function keysOf(source: $Keys<{ a: number, b: string }>): string { + return String(source); +} + +export function readOnly(config: $ReadOnly<{ host: string }>): string { + return config.host; +} + +export function partial(patch: $Shape<{ a: number, b: string }>): Object { + return { ...patch }; +} + +export function exactly(value: $Exact<{ a: number }>): Object { + return value; +} + +export function valuesOf(v: $Values<{ a: number }>): mixed { return v; } +export function nonMaybe(v: $NonMaybeType): string { return v; } +export function elementOf(v: $ElementType, number>): number { return v; } +export function propertyOf(v: $PropertyType<{ a: number }, 'a'>): number { return v; } +export function difference(v: $Diff<{ a: number, b: string }, { b: string }>): Object { return v; } +export function called(v: $Call<() => number>): number { return v; } +export function mapped(v: $ObjMap<{ a: number }, (V) => V>): Object { return v; } +export function tupleMapped(v: $TupleMap<[number], (V) => V>): Object { return v; } +export function rest(v: $Rest<{ a: number }, { }>): Object { return v; } + +// --- the existential type ----------------------------------------------------------- +// +// `*` tells Flow to infer. It is a legal TypeScript token only inside a JSDoc +// type expression, and here it is in a syntactic annotation, silently. + +export function anyElement(items: Array<*>): number { + return items.length; +} + +// --- indexer properties ---------------------------------------------------------------- +// +// `{ [string]: number }` has an UNNAMED index key. TypeScript requires +// `{ [k: string]: number }` and rejects the unnamed form in a type literal — +// but not here. + +export function total(counts: { [string]: number }): number { + return Object.keys(counts).reduce((sum, k) => sum + counts[k], 0); +} + +export function lookup(table: { [key: string]: ?Array }, k: string): mixed { + return table[k]; +} + +// --- Flow's unsafe escape hatches ---------------------------------------------------- +// +// `any` and `Object` and `Function` all exist in TypeScript with different +// meanings and different strictness. Identical spelling, different language. + +export function unsafe(value: any): Object { + return value; +} + +export function callAnything(fn: Function, arg: mixed): mixed { + return fn(arg); +} + +// --- a class whose every member is silently annotated --------------------------------- + +export class Store { + cache: { [string]: mixed }; + size: number; + fallback: ?Store; + + constructor(size: number) { + this.cache = {}; + this.size = size; + this.fallback = null; + } + + get(key: string): mixed { + return this.cache[key]; + } + + set(key: string, value: mixed): Store { + this.cache[key] = value; + return this; + } +} diff --git a/parser/src/test-data/javascript/categories/hoisting/arrow-lexical-this.js b/parser/src/test-data/javascript/categories/hoisting/arrow-lexical-this.js new file mode 100644 index 000000000..4e75861eb --- /dev/null +++ b/parser/src/test-data/javascript/categories/hoisting/arrow-lexical-this.js @@ -0,0 +1,111 @@ +// fixture: cjs/hoisting/arrow-lexical-this.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The other half of this-by-call-form.js. An arrow does not BIND `this` at all — +// it has no `this` of its own, so the name resolves up the scope chain exactly +// as any other free variable would. js_scope.bindsThis is false for ARROW, and +// that one boolean is the whole mechanism: lexical `this` is not a special rule, +// it is the absence of a binding. +// +// The same is true of `arguments`, `super` and `new.target`. bindsArguments is +// false for ARROW for the same reason. +// +// 9,391 arrows in the schema's corpus, many sharing a line, which is why +// startColumn is in js_method's primary key. + +'use strict'; + +class Timer { + constructor(name) { + this.name = name; + this.ticks = 0; + } + + // An arrow inside a method: `this` is the method's `this`, which is the + // instance. This is the form that made the `var self = this` idiom obsolete. + startWorking() { + return [1, 2, 3].map(() => { + this.ticks += 1; + return this.name; + }); + } + + // A plain function in the same position: `this` is undefined (strict), and + // the two lines are otherwise identical. + startBroken() { + return [1, 2, 3].map(function () { + return this && this.name; + }); + } + + // An arrow stored as an instance property in the constructor is bound per + // INSTANCE and survives detachment — the View event-handler idiom. It is + // also a member declared by assignment, not a class method. + install() { + this.handler = () => this.name; + return this.handler; + } + + // Nested arrows: three levels, one `this`, resolved at the outermost + // non-arrow function boundary. + deep() { + return () => () => () => this.name; + } +} + +// An arrow at MODULE level. `this` is module.exports in CommonJS and undefined +// in an ES module, so the arrow's meaning depends on a file it does not contain. +const moduleArrow = () => this; + +// An arrow as an object-literal property. The enclosing OBJECT is not a scope, +// so `this` is the module's, NOT the object's — the single most common arrow +// mistake, and the shorthand method beside it does the expected thing. +const config = { + name: 'config', + arrow: () => this, + method() { return this; }, + nestedArrowInMethod() { return (() => this)(); } +}; + +// .call / .apply / .bind CANNOT change an arrow's `this`. The receiver argument +// is accepted and ignored, which means a bind-based fix silently does nothing. +const arrowThis = () => this; +const unchanged = arrowThis.call({ name: 'ignored' }); +const stillUnchanged = arrowThis.bind({ name: 'ignored' })(); + +// An arrow has no `arguments` of its own — it sees the enclosing function's. +function outerWithArguments() { + const inner = () => arguments.length; + return inner(1, 2, 3); // the OUTER call's argument count +} + +// An arrow cannot be called with `new`: it has no [[Construct]] and no +// .prototype. `new arrowThis()` is a TypeError, which is a difference between +// the two callable kinds with no syntactic marker. +const arrowHasPrototype = 'prototype' in arrowThis; // false + +// Concise body vs block body. bodyPresence = EXPRESSION_BODY vs HAS_BODY, and +// the concise form has an implicit return that the block form does not. +const concise = (x) => x * 2; +const block = (x) => { return x * 2; }; +const conciseObject = (x) => ({ value: x }); // parens, or the brace is a block + +// Every parameter form on an arrow, including the one with no parens. +const noParens = x => x; +const noParams = () => 'nothing'; +const defaulted = (x = 1, y = x + 1) => x + y; +const rest = (...args) => args.length; +const destructured = ({ a, b: renamed = 2 }, [first]) => a + renamed + first; +const asyncArrow = async (x) => x; + +// An arrow returning an arrow, on one line — two js_method rows with the same +// startLine and different startColumn. +const curried = (a) => (b) => a + b; + +module.exports = { + Timer, moduleArrow, config, arrowThis, unchanged, stillUnchanged, + outerWithArguments, arrowHasPrototype, concise, block, conciseObject, + noParens, noParams, defaulted, rest, destructured, asyncArrow, curried +}; diff --git a/parser/src/test-data/javascript/categories/hoisting/closures-over-loops.js b/parser/src/test-data/javascript/categories/hoisting/closures-over-loops.js new file mode 100644 index 000000000..4a13a4b70 --- /dev/null +++ b/parser/src/test-data/javascript/categories/hoisting/closures-over-loops.js @@ -0,0 +1,135 @@ +// fixture: cjs/hoisting/closures-over-loops.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// One loop, two keywords, two different programs. With `var` there is ONE binding +// and every closure sees its final value; with `let` there is one binding PER +// ITERATION and each closure sees its own. Nothing distinguishes the two loops +// except the keyword in the head, and the difference is not local to that head — +// it changes what every closure in the body captures. +// +// This is the case that makes js_scope a real relation rather than a flag. A +// binder that records "i is declared in the for statement" is right about both +// loops and useless for either. +// +// Grounded in the setTimeout-in-a-loop bug that is the most-asked question about +// JavaScript closures, and in the IIFE workaround every pre-ES2015 codebase used. + +'use strict'; + +// --- var: one binding, shared ------------------------------------------------- + +function varLoop() { + const fns = []; + for (var i = 0; i < 3; i += 1) { + fns.push(function () { return i; }); + } + // i is 3 here — the binding outlived the loop — and every closure returns 3. + return { after: i, values: fns.map((f) => f()) }; +} + +// --- let: one binding per iteration -------------------------------------------- + +function letLoop() { + const fns = []; + for (let i = 0; i < 3; i += 1) { + fns.push(function () { return i; }); + } + // i is not in scope here at all, and the closures return 0, 1, 2. + return { values: fns.map((f) => f()) }; +} + +// --- the pre-ES2015 workaround -------------------------------------------------- +// +// An IIFE per iteration, so the parameter is a fresh binding. Same effect as +// `let`, achieved with a function boundary — three extra scopes the binder has +// to build, and 3 extra call sites. + +function iifeWorkaround() { + const fns = []; + for (var i = 0; i < 3; i += 1) { + (function (captured) { + fns.push(function () { return captured; }); + })(i); + } + return fns.map((f) => f()); +} + +// --- .bind as the other workaround ------------------------------------------------ + +function bindWorkaround() { + const fns = []; + for (var i = 0; i < 3; i += 1) { + fns.push(function (captured) { return captured; }.bind(null, i)); + } + return fns.map((f) => f()); +} + +// --- for-of and for-in -------------------------------------------------------------- +// +// `const` in a for-of head is legal and gives a fresh binding each iteration. +// `var` in the same position gives one, exactly as above. + +function forOfConst(items) { + const fns = []; + for (const item of items) { fns.push(() => item); } + return fns.map((f) => f()); +} + +function forInVar(obj) { + const fns = []; + for (var key in obj) { fns.push(() => key); } + return fns.map((f) => f()); +} + +// --- an async loop ----------------------------------------------------------------- +// +// The captures resolve after the loop has finished, which is what makes the +// var/let difference observable rather than theoretical. + +async function asyncCaptures() { + const pending = []; + for (var v = 0; v < 3; v += 1) { pending.push(Promise.resolve().then(() => v)); } + for (let l = 0; l < 3; l += 1) { pending.push(Promise.resolve().then(() => l)); } + return Promise.all(pending); // [3,3,3, 0,1,2] +} + +// --- capture in a nested loop, and a closure over BOTH counters ----------------------- + +function nestedCapture() { + const fns = []; + for (let outer = 0; outer < 2; outer += 1) { + for (var inner = 0; inner < 2; inner += 1) { + fns.push(() => [outer, inner]); + } + } + return fns.map((f) => f()); +} + +// --- capture of a mutable let, mutated after the closure is made ------------------- +// +// A closure captures the BINDING, not the value. `let` here is one binding for +// the whole function, so the mutation is visible. + +function capturesBinding() { + let value = 1; + const read = () => value; + value = 2; + return read(); // 2 +} + +// --- a closure that WRITES to the captured binding ----------------------------------- + +function counterFactory() { + let count = 0; + return { + increment() { count += 1; return count; }, + read() { return count; } + }; +} + +module.exports = { + varLoop, letLoop, iifeWorkaround, bindWorkaround, forOfConst, forInVar, + asyncCaptures, nestedCapture, capturesBinding, counterFactory +}; diff --git a/parser/src/test-data/javascript/categories/hoisting/function-decl-vs-expression.js b/parser/src/test-data/javascript/categories/hoisting/function-decl-vs-expression.js new file mode 100644 index 000000000..1ca0b621b --- /dev/null +++ b/parser/src/test-data/javascript/categories/hoisting/function-decl-vs-expression.js @@ -0,0 +1,127 @@ +// fixture: cjs/hoisting/function-decl-vs-expression.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The same syntax category behaving differently by POSITION. js_method.hoisting +// is a column for this reason: 5,271 function declarations (HOISTED_FULLY) and +// 4,325 function expressions (NOT_HOISTED) in the schema's corpus, and telling +// them apart is a question about the node's parent, not about the node. +// +// A function declaration's name AND body are both available from the top of the +// enclosing scope. A function expression's binding follows its declaration's +// own rules — var (undefined until assigned) or let/const (TDZ). + +'use strict'; + +// --- hoisted completely: callable before its own text ------------------------- + +const early = hoistedDeclaration(); + +function hoistedDeclaration() { + return 'callable from line 18'; +} + +// --- not hoisted: the binding is, the value is not ----------------------------- + +// var: the name exists and holds undefined, so this is a TypeError ("not a +// function"), NOT a ReferenceError. The distinction says which half hoisted. +function varExpressionTooEarly() { + const probe = typeof notYet; // 'undefined' + return notYet(); // TypeError + var notYet = function () { return 1; }; // eslint-disable-line no-unreachable +} + +// const: the name is in a TDZ, so this is a ReferenceError. Same construct, +// different error, decided by one keyword. +function constExpressionTooEarly() { + return alsoNotYet(); // ReferenceError + const alsoNotYet = () => 1; // eslint-disable-line no-unreachable +} + +// --- the named function expression: two names, one of them private ------------- +// +// `factorial` is bound INSIDE the function body only. Outside, the name is +// `fact`. Recursion through the inner name survives reassignment of the outer +// one, which is the reason the form exists. + +const fact = function factorial(n) { + return n <= 1 ? 1 : n * factorial(n - 1); +}; +const factNameVisible = typeof factorial === 'undefined'; + +// --- a declaration inside a BLOCK ---------------------------------------------- +// +// In strict mode (this file) a block-level function declaration is block-scoped +// and hoisted only within that block. In sloppy mode Annex B also creates a +// function-scoped `var` of the same name, so the SAME SOURCE binds differently +// depending on a directive — see sloppy-implicit-global.js for the other half. + +function blockLevel() { + if (true) { + function inner() { return 'block scoped in strict mode'; } + return inner(); + } + return typeof inner; // 'undefined' in strict mode +} + +// --- a declaration inside a nested function, hoisted within it ------------------ + +function nestedHoisting() { + const result = helper(); + function helper() { return 'hoisted inside nestedHoisting'; } + return result; +} + +// --- two declarations of the same name ------------------------------------------- +// +// The LAST one wins, and both are hoisted, so the earlier one is unreachable +// from the moment the scope is entered. Two js_method rows describe one callable +// name, and only one of them can ever be the target of a call to `duplicated`. + +function duplicated() { return 'first'; } +function duplicated() { return 'second'; } + +// --- a declaration and a var of the same name -------------------------------------- +// +// One binding. The function hoists into it, then the `var` declaration (which +// has no initialiser) leaves it alone — so `collides` is still the function. + +function collides() { return 'function wins'; } +var collides; + +// --- generators, async, and the class-body forms ----------------------------------- +// +// A generator declaration hoists like any other declaration. A class does not: +// class declarations are in a TDZ. Same file, so the two are directly comparable. + +const genEarly = typeof generatorDeclaration === 'function'; +function* generatorDeclaration() { yield 1; } +async function asyncDeclaration() { return 1; } +async function* asyncGenDeclaration() { yield 1; } + +// --- the arrow: never hoisted, never named by itself --------------------------------- + +const arrow = (x) => x * 2; +const asyncArrow = async (x) => x * 2; + +// --- function expressions in every non-declaration position --------------------------- +// +// Argument, property value, array element, return value, operand of a ternary, +// right side of a default. None hoists, all are NOT_HOISTED, and each sits under +// a different edgeRole in the expression tree. + +const asArgument = [1, 2, 3].map(function double(n) { return n * 2; }); +const asProperty = { handler: function () { return 'prop'; } }; +const asElement = [function () { return 'elem'; }]; +function returnsFunction() { return function returned() { return 'ret'; }; } +const asTernary = process.env.X ? function () { return 'a'; } : function () { return 'b'; }; +function withDefault(fn = function () { return 'default fn'; }) { return fn(); } + +module.exports = { + early, hoistedDeclaration, varExpressionTooEarly, constExpressionTooEarly, + fact, factNameVisible, blockLevel, nestedHoisting, duplicated, collides, + genEarly, generatorDeclaration, asyncDeclaration, asyncGenDeclaration, + arrow, asyncArrow, asArgument, asProperty, asElement, returnsFunction, + asTernary, withDefault +}; diff --git a/parser/src/test-data/javascript/categories/hoisting/sloppy-implicit-global.js b/parser/src/test-data/javascript/categories/hoisting/sloppy-implicit-global.js new file mode 100644 index 000000000..3bdd2ff47 --- /dev/null +++ b/parser/src/test-data/javascript/categories/hoisting/sloppy-implicit-global.js @@ -0,0 +1,126 @@ +// fixture: cjs/hoisting/sloppy-implicit-global.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// NOTE: this file has NO 'use strict' directive, on purpose. CommonJS is sloppy +// unless a directive says otherwise, and js_scope.isStrictMode / +// strictModeSource = SLOPPY are what record that. Adding the directive would +// turn every assignment below into a TypeError, so the absence is load-bearing +// and must not be "fixed". +// +// bindingRegime = GLOBAL_IMPLICIT is the binding with NO DECLARATION. An +// assignment to an undeclared name in sloppy mode creates a property on the +// global object; in strict mode it throws ReferenceError. The same source line +// is a binding in one file and an error in another, which is exactly why the +// scope tree cannot be derived from declarations alone. + +// --- the implicit global ------------------------------------------------------ + +// No var, no let, no const. This creates globalThis.implicitCounter, visible to +// every other module in the process. +implicitCounter = 0; + +function bump() { + // Assigned inside a function, still global. The binding's scope is not the + // function it appears in, and nothing at this line says so. + implicitCounter = implicitCounter + 1; + alsoGlobal = 'created on first call'; + return implicitCounter; +} + +// The classic typo: a chained declaration where only the first name is declared. +// `second` and `third` are implicit globals. +var first = 1, second = 2; +function chainedAssignment() { + var a = b = c = 5; // only `a` is local; `b` and `c` are globals + return [a, b, c]; +} + +// Reading an undeclared name is a ReferenceError in BOTH modes — only writing +// differs. typeof is the exception that does not throw. +const safeProbe = typeof neverAssigned; + +// --- Annex B block-level function declarations ---------------------------------- +// +// In sloppy mode a function declared in a block ALSO creates a function-scoped +// var of the same name, hoisted and initialised when the block runs. In strict +// mode it is block-scoped only. Same source, two binding structures, decided by +// a directive that is not on this line. + +function annexB() { + const before = typeof blockFn; // 'undefined' — the var exists, unassigned + { + function blockFn() { return 'annex B'; } + } + const after = typeof blockFn; // 'function' — visible outside the block + return [before, after]; +} + +// --- arguments aliasing -------------------------------------------------------- +// +// In sloppy mode a non-simple-free parameter list makes `arguments` a LIVE +// ALIAS of the named parameters: writing arguments[0] writes the parameter, and +// vice versa. Strict mode severs the link. usesArguments records that the +// second parameter channel is in play; the aliasing is why it matters. + +function aliased(x) { + arguments[0] = 'changed'; + return x; // 'changed' in sloppy mode, original in strict +} + +// --- octal literals and other sloppy-only syntax --------------------------------- +// +// A legacy octal literal is a syntax error under 'use strict'. Parsing this file +// as strict fails outright, which makes the module system and directive a +// PARSE-time input, not only a semantic one. + +const legacyOctal = 0755; +const octalEscape = '\101'; + +// --- delete on an unqualified name ------------------------------------------------ +// +// Legal in sloppy mode, a syntax error in strict mode. Deleting an implicit +// global works; deleting a declared var does not. + +implicitCounter = 1; +const deletedImplicit = delete implicitCounter; // true +const deletedDeclared = delete first; // false + +// --- a function whose body is strict while the file is not ------------------------- +// +// The directive is per-scope. This function and everything nested in it is +// strict; the rest of the file is not. strictModeSource = USE_STRICT_DIRECTIVE +// on this scope, SLOPPY on the module's. + +function strictIsland() { + 'use strict'; + try { + undeclaredInStrict = 1; // ReferenceError here, an implicit global two lines up + } catch (e) { + return e.constructor.name; + } + return null; +} + +// --- a class body is strict even here ------------------------------------------------ +// +// strictModeSource = CLASS_BODY_IMPLICIT. No directive, no module system, +// strict anyway. + +class AlwaysStrict { + attempt() { + try { + undeclaredInClass = 1; + return null; + } catch (e) { + return e.constructor.name; + } + } +} + +module.exports = { + bump, chainedAssignment, safeProbe, annexB, aliased, + legacyOctal, octalEscape, deletedImplicit, deletedDeclared, + strictIsland, AlwaysStrict +}; diff --git a/parser/src/test-data/javascript/categories/hoisting/tdz.js b/parser/src/test-data/javascript/categories/hoisting/tdz.js new file mode 100644 index 000000000..1b3f75c6e --- /dev/null +++ b/parser/src/test-data/javascript/categories/hoisting/tdz.js @@ -0,0 +1,114 @@ +// fixture: cjs/hoisting/tdz.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The temporal dead zone. `let`, `const` and `class` bindings are created when +// their scope is entered and are UNINITIALISED until their declaration is +// evaluated; touching one in between throws ReferenceError. So the binding +// exists, is in scope, shadows an outer name of the same name — and cannot be +// read. hasTemporalDeadZone is the column, and bindingRegime distinguishes +// LET_BLOCK_TDZ / CONST_BLOCK_TDZ / CLASS_TDZ. +// +// The reason this is not bookkeeping: a name resolver that binds an identifier +// to the nearest declaration and stops there will happily resolve every +// reference in this file, including the ones that throw. Whether a read is legal +// is a property of the ORDER of evaluation, not of the scope chain. +// +// Every throwing read below is inside a function that is not called at load, so +// this file evaluates cleanly. + +'use strict'; + +const outerName = 'module scope'; + +function shadowedButUnreadable() { + // `outerName` here refers to the LOCAL const on the next line, not to the + // module-scope one — the binding shadows from the top of the function body. + // This line therefore throws, even though a name at module scope exists. + const read = outerName; + const outerName = 'function scope'; + return [read, outerName]; +} + +function letTdz() { + const probe = typeof value; // throws: typeof does NOT protect a TDZ binding, + // which is the difference from an undeclared name + let value = 1; + return [probe, value]; +} + +function constTdz() { + return () => frozen; // safe: the closure is not called until later + const frozen = 1; // eslint-disable-line no-unreachable +} + +// A class binding is in a TDZ exactly as a `let` is. `new Early()` before the +// declaration throws; the same code with a constructor FUNCTION would work, +// because a function declaration hoists completely. That contrast is the whole +// content of function-decl-vs-expression.js. +function classTdz() { + const made = new Early(); + class Early {} + return made; +} + +// A class EXPRESSION assigned to a const: the const is in a TDZ, and the class +// has no binding of its own outside the expression. +const Late = class Named { + self() { return Named; } +}; + +// The loop TDZ. `let` in a for head creates a FRESH binding per iteration, and +// each iteration's binding is in a TDZ until that iteration's initialisation. +function perIterationBindings() { + const fns = []; + for (let i = 0; i < 3; i += 1) { + fns.push(() => i); // captures THIS iteration's `i` + } + return fns.map((f) => f()); // [0, 1, 2] +} + +// Mutual TDZ: two consts referring to each other. The first is unreadable when +// the second is evaluated only if the order is wrong; here the functions defer +// both reads, so both work. +const a = () => b(); +const b = () => 'b'; + +// const is not deep. The BINDING cannot be reassigned; the value can be mutated, +// and `isReassigned` on the variable is about the binding, not the object. +const mutable = { count: 0 }; +mutable.count += 1; + +// A `let` with no initialiser. It is initialised to undefined at its +// declaration, so the TDZ ends there even though nothing was assigned. +function uninitialisedLet() { + let declaredNotAssigned; + return typeof declaredNotAssigned; // 'undefined', and no throw +} + +// A catch parameter is its own binding regime — CATCH_PARAMETER — scoped to the +// catch block, and it shadows an outer name of the same name. +const err = 'module-level err'; +function catchScope() { + try { + throw new Error('boom'); + } catch (err) { + return err.message; // the parameter, not the module const + } +} + +// An optional catch binding declares nothing at all. +function catchNoBinding() { + try { + throw new Error('boom'); + } catch { + return 'swallowed'; + } +} + +module.exports = { + shadowedButUnreadable, letTdz, constTdz, classTdz, Late, + perIterationBindings, a, b, mutable, uninitialisedLet, err, + catchScope, catchNoBinding +}; diff --git a/parser/src/test-data/javascript/categories/hoisting/this-by-call-form.js b/parser/src/test-data/javascript/categories/hoisting/this-by-call-form.js new file mode 100644 index 000000000..04ac725e2 --- /dev/null +++ b/parser/src/test-data/javascript/categories/hoisting/this-by-call-form.js @@ -0,0 +1,132 @@ +// fixture: cjs/hoisting/this-by-call-form.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// One function, six values of `this`, decided entirely by HOW IT IS CALLED. The +// function's own text is identical in every case. 33,189 `this` references in +// the schema's corpus, and js_method.thisBinding (LEXICAL | DYNAMIC | BOUND | +// NONE) is the column that says which regime a callable is in — but the VALUE is +// a property of the call site, not of the callable, which is why the two are +// separate facts. +// +// This is the clearest example in the language of a fact that syntax cannot +// decide. An engine that models `this` as "the receiver of the declaration" is +// wrong for five of the six forms below. + +'use strict'; + +function whoAmI() { + return this; +} + +const obj = { + name: 'obj', + whoAmI, // the SAME function object + nested: { name: 'nested', whoAmI } +}; + +// 1. Bare call. In strict mode `this` is undefined; in sloppy mode it is +// globalThis. The same line, two answers, decided by a directive. +const bare = whoAmI(); + +// 2. Method call. `this` is the receiver — and the receiver is whatever is to +// the left of the dot at the CALL, not where the function was defined. +const asMethod = obj.whoAmI(); +const asNested = obj.nested.whoAmI(); + +// 3. Detached. Taking the same function out of the object and calling it loses +// the receiver entirely. This is the single most common `this` bug, and no +// syntax at the assignment says anything is lost. +const detached = obj.whoAmI; +const detachedResult = detached(); + +// 4. .call / .apply — the receiver is an ARGUMENT. receiverPosition = +// FIRST_ARGUMENT, and an engine reading the syntactic receiver gets +// Function.prototype.call as the target and the real receiver not at all. +const viaCall = whoAmI.call(obj); +const viaApply = whoAmI.apply(obj, []); + +// 5. .bind — produces a NEW function whose `this` is fixed. thisBinding = BOUND +// on the result, and binding twice does NOT rebind: the first bind wins. +const bound = whoAmI.bind(obj); +const boundResult = bound(); +const reboundResult = bound.call({ name: 'ignored' }); // still obj +const doubleBound = bound.bind({ name: 'also ignored' })(); + +// 6. As a constructor. `this` is a brand-new object whose prototype is +// whoAmI.prototype, and the return value is discarded unless it is an object. +const constructed = new whoAmI(); + +// --- through a call form that hides the receiver ------------------------------- + +// Computed member call. The receiver is still `obj`; the method name is not +// fixed by syntax. callKind = COMPUTED_CALL. +const key = 'whoAmI'; +const computed = obj[key](); + +// Optional call. Same receiver rules; differs in reachability, not in target. +const optional = obj?.whoAmI?.(); + +// Through a call in an argument position — the receiver is lost the same way +// `detached` loses it, and this is why `arr.map(obj.method)` misbehaves. +const mapped = [1].map(obj.whoAmI); + +// Tagged template. The tag is called with the receiver to its left, so `this` +// is obj here and undefined for a bare tag. +function tag(strings) { return this; } +const taggedBare = tag`x`; +const taggedMethod = { tag }.tag`x`; + +// --- inside a constructor and a prototype method ---------------------------------- + +function Counter() { + this.count = 0; + + // A nested plain function does NOT inherit the constructor's `this`. This is + // the bug the `var self = this` line below exists to work around, and it is + // the reason arrow functions were added. + this.brokenIncrement = function () { + return function () { return this; }(); + }; + + const self = this; + this.workingIncrement = function () { + return function () { return self; }(); + }; +} + +Counter.prototype.method = function () { return this; }; +Counter.prototype.callback = function () { + return [1].map(function () { return this; }); // undefined per element +}; +Counter.prototype.boundCallback = function () { + return [1].map(function () { return this; }, this); // thisArg parameter +}; + +// --- class bodies are always strict, even in a sloppy file -------------------------- + +class Modern { + constructor() { this.kind = 'modern'; } + method() { return this; } + static staticMethod() { return this; } // the CLASS, not an instance +} + +const modern = new Modern(); +const modernDetached = modern.method; + +// --- module-level `this` in CommonJS -------------------------------------------------- +// +// At the top level of a CommonJS module `this` is module.exports — not +// globalThis and not undefined. In an ES module it is undefined. Same token, +// three meanings, and only the module system decides. + +const moduleThis = this; +const isExports = this === module.exports; + +module.exports = { + whoAmI, obj, bare, asMethod, asNested, detachedResult, + viaCall, viaApply, boundResult, reboundResult, doubleBound, constructed, + computed, optional, mapped, taggedBare, taggedMethod, + Counter, Modern, modern, modernDetached, moduleThis, isExports +}; diff --git a/parser/src/test-data/javascript/categories/hoisting/var-hoisting.js b/parser/src/test-data/javascript/categories/hoisting/var-hoisting.js new file mode 100644 index 000000000..887d4aa65 --- /dev/null +++ b/parser/src/test-data/javascript/categories/hoisting/var-hoisting.js @@ -0,0 +1,112 @@ +// fixture: cjs/hoisting/var-hoisting.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 (let/const appear only as contrast) +// +// The two scope columns. js_variable carries BOTH declarationScopeLinkHash (where +// the name is visible from) and syntacticScopeLinkHash (the block the declaration +// is written in), and for `var` they differ whenever the declaration sits inside +// a block. That difference IS hoisting, and it is not recoverable from anything +// else in the fact base — an engine would have to re-implement JavaScript's +// scoping rules to derive one from the other. +// +// The scope-coherence gate reads this file: every VAR_* binding's declaration +// scope must have isFunctionScope = true. +// +// 3,705 `var` bindings in the schema's corpus. Not a legacy curiosity. + +'use strict'; + +// Declared inside a block, visible for the whole module scope. +{ + var blockDeclared = 'visible outside the block'; +} +const stillVisible = blockDeclared; + +// Read BEFORE the declaration line. Not an error: the binding exists from the +// start of the scope and holds undefined until the assignment runs. This is the +// difference from a TDZ, and the two must not be modelled with one flag. +const beforeDeclaration = typeof laterVar; // 'undefined', not a ReferenceError +var laterVar = 'assigned on line 30'; + +function functionScoped() { + // Same name declared twice in one function. Legal, one binding, and the + // second `var` is not a redeclaration error the way a second `let` would be. + var dup = 1; + var dup = 2; + + if (true) { + var inIf = 'hoists to functionScoped'; + } + for (var i = 0; i < 3; i += 1) { + var inLoop = i; + } + try { + var inTry = 'hoists too'; + } catch (e) { + var inCatch = e; + } + switch (dup) { + case 2: { + var inCase = 'and here'; + break; + } + } + // Every one of these is visible here, and none of them was declared here. + return [dup, inIf, i, inLoop, inTry, inCatch, inCase]; +} + +// A `var` in a nested function does NOT escape it. The nearest function scope is +// the boundary, and `outer` cannot see `inner`. +function outer() { + var outerVar = 'outer'; + function inner() { + var innerVar = 'inner'; + return outerVar + innerVar; + } + // typeof is the only safe probe: `innerVar` is not in scope at all here. + return [inner(), typeof innerVar]; +} + +// A `var` shadowing a parameter of the same name. One binding, not two. +function shadowsParameter(value) { + var value = value || 'default'; + return value; +} + +// A `var` whose declaration is unreachable. The BINDING still exists, hoisted, +// holding undefined — the declaration hoists even though the assignment never +// runs. This is the clearest case where syntactic position and binding effect +// come apart. +function unreachableDeclaration() { + return 'early'; + var neverAssigned = 'never runs'; +} + +// `var` in a for-in / for-of head. The binding is function-scoped, so it +// survives the loop and holds the LAST value. +function loopHeads(obj) { + for (var key in obj) { /* body */ } + for (var item of Object.values(obj)) { /* body */ } + return [key, item]; +} + +// The contrast, in the same file so the pair is comparable: `let` in a block is +// invisible outside it, and its two scope columns are equal. +{ + let letInBlock = 'invisible outside'; + var varBesideIt = letInBlock; +} +const onlyVarEscaped = typeof varBesideIt; + +// `var` at module top level in CommonJS is NOT a global. The module wrapper is a +// function, so the module scope IS a function scope — which is why +// js_scope.isFunctionScope is true for MODULE. In a script (or an ES module, +// for a different reason) the same line behaves differently. +var notAGlobal = true; + +module.exports = { + blockDeclared, stillVisible, beforeDeclaration, laterVar, + functionScoped, outer, shadowsParameter, unreachableDeclaration, + loopHeads, onlyVarEscaped, notAGlobal +}; diff --git a/parser/src/test-data/javascript/categories/hoisting/with-statement.js b/parser/src/test-data/javascript/categories/hoisting/with-statement.js new file mode 100644 index 000000000..5f714955a --- /dev/null +++ b/parser/src/test-data/javascript/categories/hoisting/with-statement.js @@ -0,0 +1,81 @@ +// fixture: cjs/hoisting/with-statement.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES3 — and it is a SYNTAX ERROR in strict mode and in every ES +// module, so this file deliberately has no 'use strict' and could not be one. +// +// `with` pushes an OBJECT onto the scope chain, so every free name inside its +// body may resolve to a property of that object — and which names those are is +// decided at runtime by the object's own properties, including inherited ones. +// Every identifier in a with-body is therefore STATICALLY UNRESOLVABLE. +// +// js_scope has a WITH scope kind and a hasWithStatement flag, and +// js_parse_gap.gapKind = WITH_STATEMENT_SCOPE, precisely so the honest answer is +// recorded rather than confident wrong bindings emitted. This fixture exists to +// prove the parser marks the scope instead of resolving through it. +// +// Rare, and not extinct: `with` survives in template engines (several compile +// user templates into a with-block), which is the +// realistic provenance. + +const context = { name: 'ctx', value: 1 }; +const outerName = 'module scope'; +let value = 99; + +function render(data) { + const out = []; + with (data) { + // `title` and `body` MIGHT be properties of `data`, or they might be free + // names resolving outward. Nothing in this file can say which. + out.push(title); + out.push(body); + // `out` might ALSO be a property of `data` — a name that is obviously local + // to a reader is not obviously local to the language. + out.push(outerName); + } + return out.join(''); +} + +function shadowing() { + with (context) { + // `value` here is context.value (1), not the module-level let (99) — unless + // `context` stops having a `value` property, in which case it is the let. + return value; + } +} + +// The inherited-property case. `toString` is not an own property of `context` +// and `with` still finds it, so even enumerating the object's own keys does not +// bound the set of names it captures. +function inherited() { + with (context) { + return toString(); + } +} + +// A `with` whose subject is computed. The object is not knowable at all. +function dynamicSubject(key) { + const table = { a: { x: 1 }, b: { x: 2 } }; + with (table[key]) { + return x; + } +} + +// A function DECLARED inside a with-body. Its closure includes the with-scope, +// so the unresolvability escapes the block. +function escapes(data) { + with (data) { + return function () { return leaked; }; + } +} + +// Nested with. Two objects on the chain, innermost first. +function nested(a, b) { + with (a) { + with (b) { + return both; + } + } +} + +module.exports = { render, shadowing, inherited, dynamicSubject, escapes, nested }; diff --git a/parser/src/test-data/javascript/categories/imports/circular-a.js b/parser/src/test-data/javascript/categories/imports/circular-a.js new file mode 100644 index 000000000..bf6890e70 --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/circular-a.js @@ -0,0 +1,26 @@ +// fixture: cjs/commonjs/circular-a.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// Half of a require cycle. a requires b at the top; b requires a lazily, inside +// a function, which is the standard way the cycle is made to work rather than +// to deadlock. At the moment b's top-level code runs, `require('./circular-a')` +// returns a PARTIALLY POPULATED module.exports — which is why the export +// assignment here comes before the require, and why moving it would break the +// program without changing any parser-visible structure. + +'use strict'; + +// Exported first, on purpose. See above. +module.exports.name = 'a'; + +const b = require('./circular-b'); + +module.exports.callB = function callB() { + return b.describe(); +}; + +module.exports.describe = function describe() { + return 'a knows ' + b.name; +}; diff --git a/parser/src/test-data/javascript/categories/imports/circular-b.js b/parser/src/test-data/javascript/categories/imports/circular-b.js new file mode 100644 index 000000000..1f12fcb45 --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/circular-b.js @@ -0,0 +1,17 @@ +// fixture: cjs/commonjs/circular-b.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The other half. The require of a is deferred into the function body precisely +// so it observes a fully populated module.exports. Same syntax as any other +// nested require; different reason, and the reason is not in the syntax. + +'use strict'; + +module.exports.name = 'b'; + +module.exports.describe = function describe() { + const a = require('./circular-a'); + return 'b knows ' + a.name; +}; diff --git a/parser/src/test-data/javascript/categories/imports/conditional-require.js b/parser/src/test-data/javascript/categories/imports/conditional-require.js new file mode 100644 index 000000000..038aac43c --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/conditional-require.js @@ -0,0 +1,131 @@ +// fixture: cjs/commonjs/conditional-require.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The module edge that is not at the top of the file. 13.6% of require() calls +// in the schema's corpus are not top-level statements — 1,048 inside a function +// body and 179 inside a block — and an extractor that walks the file's statement +// list, which is what every TypeScript module-edge extractor does because every +// TypeScript module edge IS a top-level declaration, misses one require in seven. +// +// Two columns carry the distinction: isTopLevel (false for all but the first +// require here) and isConditional (a module edge that may never execute). +// ownerScopeLinkHash and ownerMethodLinkHash matter too: a nested require binds +// in a function scope, not the module's. +// +// Grounded in a runtime's CommonJS loader (lazy requires to break +// cycles), a web framework's response object (a file sender required inside sendFile), and +// the near-universal `try { require('optional-dep') } catch {}` probe. + +'use strict'; + +// The control: top-level, unconditional. isTopLevel = true. +const path = require('path'); + +// 1. Inside a function body. Runs once per call, and the binding lives in the +// function scope. This is the single largest nested-require population. +function sendFile(res, file) { + const send = require('file-stream'); + return send(res, file).pipe(res); +} + +// 2. Guarded by an `if`. The edge may never execute at all. +let colors = null; +if (process.stdout.isTTY) { + colors = require('./exports-shorthand'); +} + +// 3. Inside a try/catch — the optional-dependency probe. The catch swallows a +// MODULE_NOT_FOUND, so an unresolvable specifier here is INTENDED, and a +// parser reporting it as a defect is reporting the program working. +let fastJson; +try { + fastJson = require('fast-serialize'); +} catch (err) { + fastJson = JSON.stringify; +} + +// 4. Lazy singleton — the cycle-breaking idiom. The require runs on first call, +// not at load, specifically so a circular dependency is resolved late. +let _loader = null; +function getLoader() { + if (_loader === null) { + _loader = require('./circular-a'); + } + return _loader; +} + +// 5. Inside a nested block that is not a function. `var` here hoists to the +// module's function scope; the require does not move with it. +{ + var blockScopedRequire = require('./exports-array'); +} + +// 6. Inside a loop. Same specifier every iteration, one module instance, N +// call sites for the cache lookup. +const plugins = []; +for (const name of ['./exports-shorthand', './module-exports-members']) { + plugins.push(require(name)); +} + +// 7. Inside an arrow, inside a callback, inside a method. Three function +// boundaries deep — the worklist has to descend explicitly to reach it. +const registry = { + install(app) { + return ['a', 'b'].map((tag) => { + const helper = require('./module-exports-assignment'); + return helper(tag, app); + }); + } +}; + +// 8. Short-circuit require. Evaluated only if the left side is falsy, so the +// edge is conditional without any statement saying so. +const optional = process.env.NO_DEBUG || require('debug'); + +// 9. Inside a ternary consequent. Both branches are edges. +const impl = process.platform === 'win32' + ? require('./module-exports-members') + : require('./exports-shorthand'); + +// 10. Inside a switch case, and inside a catch block. +function loadByKind(kind) { + switch (kind) { + case 'stream': { + return require('stream'); + } + case 'buffer': + return require('buffer'); + default: + try { + return require('./' + kind); + } catch (e) { + return require('./exports-array'); + } + } +} + +// 11. Inside a class method and a static block-free static method. +class Loader { + load(name) { + const zlib = require('zlib'); + return zlib.gzipSync(name); + } + static defaults() { + return require('./module-exports-members').contentTypes; + } +} + +// 12. Inside an IIFE at top level. Syntactically nested, semantically eager — +// it runs at load time exactly as a top-level require would. isTopLevel is +// false and isConditional is false, and those two columns are not the same +// question. +const eagerButNested = (function () { + return require('os'); +})(); + +module.exports = { + path, sendFile, colors, fastJson, getLoader, blockScopedRequire, + plugins, registry, optional, impl, loadByKind, Loader, eagerButNested +}; diff --git a/parser/src/test-data/javascript/categories/imports/destructured-require.js b/parser/src/test-data/javascript/categories/imports/destructured-require.js new file mode 100644 index 000000000..c323be83a --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/destructured-require.js @@ -0,0 +1,58 @@ +// fixture: cjs/commonjs/destructured-require.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2018 (object rest in a destructuring pattern) +// +// One require, N bound names, one line. This is the shape that made startColumn +// part of the js_import primary key: `const {a, b} = require('x')` is two import +// rows with the same ownerModule, the same specifier and the same startLine, and +// without a column they collide by DOUBLING rather than by erroring. +// +// Grounded in a runtime's internal modules, which open nearly every file with +// `const { ObjectKeys, StringPrototypeSlice } = primordials;` and with +// destructured requires of internal/errors. + +'use strict'; + +// The canonical two-name form. +const { readFile, writeFile } = require('fs'); + +// Renamed while destructured: importedName and localName differ, and neither is +// the specifier. Three rows on one line. +const { promisify: toPromise, inherits: extend, format } = require('util'); + +// Nested destructuring. `constants.errno.ENOENT` binds one name from two levels +// down; the path to it is not recoverable from the local name alone. +const { constants: { errno: { ENOENT } } } = require('os'); + +// Default value in the pattern. The name is bound whether or not the module +// exports it, which means the binding exists even when the import edge is +// unsatisfiable. +const { createHash, createHmac = null } = require('crypto'); + +// Rest element. `rest` binds every OTHER export, and syntax cannot say what +// those are — the names are a property of the required module, not of this file. +const { join, ...restOfPath } = require('path'); + +// Array destructuring of a require. Rare but legal, and it binds by position +// rather than by name, which is a different importedName story again. +const [first, second] = require('./exports-array'); + +// Destructuring a member of a require. The specifier is the module; the +// destructuring applies to one of its properties. +const { get, post } = require('./reexport-require').methods; + +// Two requires, two patterns, one statement. A per-statement extractor that +// assumes one specifier per VariableStatement loses the second. +const { EventEmitter } = require('events'), { Readable } = require('stream'); + +// let, not const. The binding is reassignable, so the module alias is not +// stable — isReassigned on the variable is the column that says so. +let { deepEqual } = require('assert'); +deepEqual = null; + +module.exports = { + readFile, writeFile, toPromise, extend, format, ENOENT, + createHash, createHmac, join, restOfPath, first, second, + get, post, EventEmitter, Readable, deepEqual +}; diff --git a/parser/src/test-data/javascript/categories/imports/esm/import-binding-alias.js b/parser/src/test-data/javascript/categories/imports/esm/import-binding-alias.js new file mode 100644 index 000000000..82b337dd7 --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/esm/import-binding-alias.js @@ -0,0 +1,30 @@ +// fixture: categories/imports/esm/import-binding-alias.js +// nature: runtime-bearing +// JsInitializerKind.IMPORT_BINDING — declared, and the CommonJS twin emits 12,345 times. +// +// `REQUIRE_CALL` is documented as "the name is a module alias" and is emitted for +// `const x = require('y')`. `IMPORT_BINDING` is documented as "a name bound by an +// import declaration. Also a module alias, by the other route" and is emitted +// never. Both spellings alias a module binding; an engine that can see one and +// not the other sees half the module graph's aliases. +// +// Two readings are covered here because the schema does not say which is meant, +// and both currently produce something other than IMPORT_BINDING: +// (a) the variable minted FOR the import binding itself -> initializerKind NONE +// (b) a local initialised FROM an import binding -> initializerKind OTHER +// +// module system: ESM, governed by imports/esm/package.json ("type": "module"). +import { readFile } from 'node:fs'; +import defaultExport from './pkg/util.js'; +import * as namespaceBinding from './pkg/util.js'; + +// (b) — a local whose initializer IS an import binding. +const aliasOfNamed = readFile; +const aliasOfDefault = defaultExport; +const aliasOfNamespace = namespaceBinding; + +// The control, in the same file: a local initialised from something that is not +// a module binding at all. +const notAnAlias = { readFile }; + +export { aliasOfNamed, aliasOfDefault, aliasOfNamespace, notAnAlias }; diff --git a/parser/src/test-data/javascript/categories/imports/esm/import-forms.js b/parser/src/test-data/javascript/categories/imports/esm/import-forms.js new file mode 100644 index 000000000..8080afce0 --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/esm/import-forms.js @@ -0,0 +1,65 @@ +// fixture: esm/imports/import-forms.js +// module system: ESM (governing: staging/esm/package.json, "type": "module") +// nature: runtime-bearing +// syntax floor: ES2020 (dynamic import); ES2015 for everything else +// +// Every declaration-borne import form. These are the 16.4% of module edges the +// schema calls DECLARATION-borne, and they are the half that ports cleanly from +// TypeScript. edgeBearer = DECLARATION, isTopLevel = true, sourceExpressionLinkHash = "" +// for all of them EXCEPT the dynamic imports at the bottom, which are +// expression-borne even in an ES module. +// +// Grounded in pure-ESM library source. + +import greet from './pkg/util.js'; +import { normalize, Formatter } from './pkg/util.js'; +import { normalize as clean, DEFAULT_LOCALE as LOCALE } from './pkg/util.js'; +import defaultAndNamed, { counter } from './pkg/util.js'; +import * as util from './pkg/util.js'; +import './pkg/side-effects.js'; + +// Builtins, both spellings. 'node:fs' and 'fs' resolve to the same module and +// are different specifiers as written — specifier is recorded AS WRITTEN. +import { readFile } from 'node:fs/promises'; +import process from 'process'; + +// A bare package specifier that is not installed in this checkout. +import ansiPaint from 'ansi-paint'; + +// A directory specifier resolved through the barrel's index.js. ESM does NOT do +// directory-index resolution the way CommonJS does, so this one needs the +// explicit file name — which is itself a difference between the two systems +// that the same-looking specifier hides. +import { clean as barrelClean } from './pkg/index.js'; + +// Dynamic import: expression-borne, and a call site as well as a module edge. +// callKind = DYNAMIC_IMPORT_CALL. Kept inside a function so this file's syntax +// floor stays at ES2020; top-level await has its own fixture and its own floor. +export async function lazyLoad() { + const lazy = await import('./pkg/util.js'); + const { Formatter: LazyFormatter } = await import('./pkg/util.js'); + return { lazy, LazyFormatter }; +} + +// Dynamic import with a non-literal specifier — unresolvable by construction, +// exactly as require(variable) is. +async function loadLocale(name) { + return import(`./locales/${name}.js`); +} + +// Dynamic import inside a conditional, not at top level. +async function maybeLoad(flag) { + if (flag) { + return import('./pkg/side-effects.js'); + } + return null; +} + +// Dynamic import used as a value without awaiting: a Promise, not a module. +const pending = import('./pkg/index.js'); + +export { + greet, normalize, Formatter, clean, LOCALE, defaultAndNamed, counter, + util, readFile, process, ansiPaint, barrelClean, + loadLocale, maybeLoad, pending +}; diff --git a/parser/src/test-data/javascript/categories/imports/esm/package.json b/parser/src/test-data/javascript/categories/imports/esm/package.json new file mode 100644 index 000000000..30eb7b27b --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/esm/package.json @@ -0,0 +1,4 @@ +{ + "name": "javascript-categories-imports-esm", + "type": "module" +} diff --git a/parser/src/test-data/javascript/categories/imports/esm/pkg/index.js b/parser/src/test-data/javascript/categories/imports/esm/pkg/index.js new file mode 100644 index 000000000..ea7e4c2fa --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/esm/pkg/index.js @@ -0,0 +1,29 @@ +// fixture: esm/imports/pkg/index.js +// module system: ESM (governing: staging/esm/package.json, "type": "module") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// A barrel. Every re-export form in one file: each line is an import edge and an +// export edge at once, and none of them binds a local name — which is what +// distinguishes `export { x } from './y'` from `import { x } from './y'; export { x }`. +// The second binds x locally; the first does not, and a fact base that models +// them identically claims a binding that is not in scope. +// +// MEASURED at a5f6aab: this file's three re-export forms come out as follows. +// `export { a as b } from` -> EXPORT_DECLARATION with the right names. +// `export * from` -> EXPORT_ALL. +// `export * as util from` -> NOTHING. No row at all. The construct has been in +// this file since it was written and the missing row was found only when the +// line was checked by name rather than the file counted. That is the whole +// argument for checking every line of a fixture, and it is the parser defect +// this file now exists to hold. + +export { normalize, Formatter } from './util.js'; +export { normalize as clean } from './util.js'; +export { default as greet } from './util.js'; +export { default } from './util.js'; +export * from './util.js'; +export * as util from './util.js'; + +// A re-export whose target does not exist. Structural, not environmental. +export { nothing } from './does-not-exist.js'; diff --git a/parser/src/test-data/javascript/categories/imports/esm/pkg/side-effects.js b/parser/src/test-data/javascript/categories/imports/esm/pkg/side-effects.js new file mode 100644 index 000000000..68425e0fe --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/esm/pkg/side-effects.js @@ -0,0 +1,13 @@ +// fixture: esm/imports/pkg/side-effects.js +// module system: ESM (governing: staging/esm/package.json, "type": "module") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// No exports at all. Imported purely for what its top-level code does, which is +// the one import form that binds no name — bindingForm = SIDE_EFFECT_ONLY, and +// importedName and localName are both "". A parser that mints an import row +// only when a binding appears drops this edge entirely. + +globalThis.__fixtureSideEffect = (globalThis.__fixtureSideEffect || 0) + 1; + +console.log('side effect ran'); diff --git a/parser/src/test-data/javascript/categories/imports/esm/pkg/util.js b/parser/src/test-data/javascript/categories/imports/esm/pkg/util.js new file mode 100644 index 000000000..906bbe372 --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/esm/pkg/util.js @@ -0,0 +1,42 @@ +// fixture: esm/imports/pkg/util.js +// module system: ESM (governing: staging/esm/package.json, "type": "module") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// Named exports of every declaration kind, plus a default. The target of most +// of import-forms.js. + +export function normalize(text) { + return String(text).trim(); +} + +export async function fetchJson(url) { + const res = await globalThis.fetch(url); + return res.json(); +} + +export function* counter(from) { + let i = from; + while (true) { yield i++; } +} + +export class Formatter { + constructor(locale) { this.locale = locale; } + format(value) { return String(value); } +} + +export const DEFAULT_LOCALE = 'en-US'; +export let mutableCounter = 0; +export var legacyFlag = false; + +const internalOnly = 'not exported'; + +function shorthandTarget() { return internalOnly; } + +// Export list, separate from the declarations — the two spellings bind the same +// names and are different syntax. +export { shorthandTarget, shorthandTarget as aliasedTarget }; + +export default function greet(name) { + return 'hello ' + name; +} diff --git a/parser/src/test-data/javascript/categories/imports/reexport-require.js b/parser/src/test-data/javascript/categories/imports/reexport-require.js new file mode 100644 index 000000000..cc6f9fa1c --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/reexport-require.js @@ -0,0 +1,40 @@ +// fixture: cjs/commonjs/reexport-require.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// `module.exports = require('./y')` — one statement that is simultaneously an +// import edge and an export edge, 81 sites in the schema's corpus. isReExport +// and reExportSpecifier exist for exactly this row, and the js_export must +// point at the js_import it re-exports rather than duplicating the specifier. +// +// Grounded in a web framework's router (`module.exports = Plugin`), a utility library's +// per-method files, and the countless `index.js` files whose entire content is +// a re-export. + +'use strict'; + +// A member of a re-export. The import edge is `./module-exports-members`; the +// export edge is that module's `Segment`, under a different name. +module.exports = require('./module-exports-members').Segment; + +// Named members re-exported one at a time. Each line is an import edge AND an +// export edge, and the two specifiers differ. +module.exports.methods = { + get: require('./module-exports-members').digest, + post: require('./exports-shorthand').stringify +}; + +// A re-export of a builtin. The re-exported module is not in this project, so +// the import's resolutionOutcome is RESOLVED_BUILTIN and following the edge +// leaves the project entirely. +module.exports.pathModule = require('path'); + +// Re-export spread into an object literal. Every enumerable own property of the +// required module becomes an export of this one, and syntax cannot name them — +// the names live in the other file. +module.exports.all = Object.assign({}, require('./exports-shorthand')); + +// The unresolvable re-export: specifier is not a literal, so this exports +// something the parser cannot name from something it cannot resolve. +module.exports.plugin = require(process.env.FIXTURE_PLUGIN || './exports-array'); diff --git a/parser/src/test-data/javascript/categories/imports/require-forms.js b/parser/src/test-data/javascript/categories/imports/require-forms.js new file mode 100644 index 000000000..57aa63235 --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/require-forms.js @@ -0,0 +1,104 @@ +// fixture: cjs/commonjs/require-forms.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 (const/let, destructuring, template literal) +// +// Every shape a resolvable `require` takes in the wild. Derived from the head of +// a web framework's entry and application modules and a runtime's HTTP server, +// which between them use all of: bare builtin, bare package, relative, relative +// with an explicit extension, directory-index, deep subpath, member-picked, and +// require used directly as an expression rather than bound to a name. +// +// Note what is NOT here: a non-literal specifier (require-non-literal.js), a +// require that is not top-level (conditional-require.js), and a require whose +// result is destructured (destructured-require.js). Each is its own fixture +// because each produces a different js_import row shape. + +'use strict'; + +// --- bare specifiers ------------------------------------------------------- + +// Node builtin. resolutionOutcome is the RESOLVED_BUILTIN case, and it is also +// the population that carries 24.4% of the oracle's declines when no ambient +// declarations are loaded. +const path = require('path'); +const EventEmitter = require('events').EventEmitter; +const { format } = require('util'); + +// Bare package specifier that this checkout does not install. Unresolvable for +// an environmental reason, which is not the same thing as a parser gap. +const mime = require('media-types'); + +// A bare package that IS installed in this checkout, transitively, with the +// require's RESULT called directly — `require(x)(…)`, the shape a namespaced +// logger factory made ubiquitous. resolutionOutcome = RESOLVED_EXTERNAL. The +// package name is an English word, excluded from the corpus-identity scrub on +// that basis; it was never a measured corpus. +const debug = require('debug')('fixture:require-forms'); + +// --- relative specifiers --------------------------------------------------- + +const Plugin = require('./reexport-require'); +const withExtension = require('./exports-shorthand.js'); +const upOneLevel = require('../methods/method-kinds'); + +// --- require as an expression, not a binding ------------------------------- + +// Side-effect-only: the module edge exists, nothing is bound. +require('./module-exports-members'); + +// Called immediately. The require is the receiver of the call, so the module +// edge is nested inside a call expression rather than inside a variable +// initializer. A web framework's entry module does exactly this with a descriptor-merge helper. +const createService = require('./module-exports-assignment'); +const appProto = require('./module-exports-assignment').prototype; + +// Member-picked at the require site. The local name and the imported name +// differ, and neither is the specifier. +const inherits = require('util').inherits; + +// Property access on a require, two hops deep. +const sep = require('path').posix.sep; + +// require inside an argument list. No binding at all, and the module edge is +// an argument of another call. +Object.assign(module.exports, require('./module-exports-members')); + +// --- require.resolve and the require object itself ------------------------- +// +// require.resolve is a path computation, not a module edge that loads code. +// Whether these mint js_import rows is the oracle's ruling; the fixture exists +// so the ruling has something to be made against. + +const resolvedPath = require.resolve('./require-non-literal'); +const cacheKeys = Object.keys(require.cache); +const mainIsMe = require.main === module; + +// --- the CommonJS free variables ------------------------------------------- +// +// __dirname, __filename, module, exports and require are bindings no source +// line declares. They are the CommonJS module wrapper's parameters. In an ESM +// file every one of them is a ReferenceError, which is what makes them evidence +// of the module system rather than incidental. + +const here = path.join(__dirname, 'fixtures'); +const me = __filename; + +function describe() { + return format('%s <- %s (%s) [%s] %d cached', here, me, resolvedPath, sep, cacheKeys.length); +} + +module.exports = { + path: path, + EventEmitter: EventEmitter, + mime: mime, + debug: debug, + Plugin: Plugin, + withExtension: withExtension, + upOneLevel: upOneLevel, + createService: createService, + appProto: appProto, + inherits: inherits, + mainIsMe: mainIsMe, + describe: describe +}; diff --git a/parser/src/test-data/javascript/categories/imports/require-non-literal.js b/parser/src/test-data/javascript/categories/imports/require-non-literal.js new file mode 100644 index 000000000..e815797de --- /dev/null +++ b/parser/src/test-data/javascript/categories/imports/require-non-literal.js @@ -0,0 +1,74 @@ +// fixture: cjs/commonjs/require-non-literal.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 (template literal) +// +// The fixture exists to prove the parser SAYS a specifier is unresolvable rather +// than guessing one. Every require below has a specifier that syntax does not +// fix, so the honest row is specifierKind = NON_LITERAL (or TEMPLATE), +// resolvedFilePath = "", resolutionOutcome = UNRESOLVED_NON_LITERAL — plus a +// js_parse_gap with gapKind = NON_LITERAL_SPECIFIER. +// +// A parser that resolves `require(name)` by picking the first candidate it can +// find is wrong in the direction that invents module edges, which is worse than +// emitting none. Grounded in the plugin-loader pattern every test runner, build +// tool and lint-config resolver uses. + +'use strict'; + +const path = require('path'); + +// 1. A plain identifier. The value is a parameter — not knowable at parse time. +function loadPlugin(name) { + return require(name); +} + +// 2. A template literal with a substitution. The prefix is fixed, the rest is not. +function loadReporter(reporter) { + return require(`./reporters/${reporter}`); +} + +// 3. String concatenation — the pre-template-literal spelling of the same thing. +function loadRule(ruleId) { + return require('./rules/' + ruleId); +} + +// 4. A computed path. Two calls deep and definitively not a literal. +function loadFromDir(dir, file) { + return require(path.join(dir, file)); +} + +// 5. A member expression as the specifier. +const config = { adapter: './adapters/memory' }; +const adapter = require(config.adapter); + +// 6. A conditional. Two literal specifiers, but the expression is not a literal. +// Both branches are real module edges; the row cannot name just one of them. +const impl = require(process.env.FIXTURE_FAST ? './fast-impl' : './slow-impl'); + +// 7. A template literal with NO substitution. This one IS a literal — it has a +// single fixed value — and it is here as the control. A parser that treats +// every TemplateExpression as unresolvable gets this wrong in the other +// direction, and without the control nothing would catch that. +const literalTemplate = require(`./module-exports-assignment`); + +// 8. require aliased to another name, then called. The callee is not the +// identifier `require`, so a syntactic matcher keyed on the callee name +// misses it entirely. +const req = require; +const aliased = req('./exports-shorthand'); + +// 9. Indirect through a member. `module.require` is a real API. +const viaModule = module.require('./module-exports-members'); + +module.exports = { + loadPlugin, + loadReporter, + loadRule, + loadFromDir, + adapter, + impl, + literalTemplate, + aliased, + viaModule +}; diff --git a/parser/src/test-data/javascript/categories/integration/contracts.js b/parser/src/test-data/javascript/categories/integration/contracts.js new file mode 100644 index 000000000..69d63af0a --- /dev/null +++ b/parser/src/test-data/javascript/categories/integration/contracts.js @@ -0,0 +1,55 @@ +// fixture: cjs/integration/contracts.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: TYPE-ONLY. No statement, no binding, no export. The whole file is +// comments, exactly as jsdoc/typedef-only.js is — repeated here because an +// integration corpus that has no type-only module cannot show a type-only +// module being consumed by a runtime-bearing one without leaking. +// syntax floor: none +// +// The contracts the rest of the integration set is written against. Every type +// here is COMMENT_ONLY, isTypeOnly = true, and no js_call_site anywhere in this +// directory may resolve to one. + +/** + * @typedef {Object} User + * @property {string} id + * @property {string} email + * @property {'admin'|'member'|'guest'} role + * @property {Date} createdAt + * @property {?Profile} profile + */ + +/** + * @typedef {Object} Profile + * @property {string} displayName + * @property {string} [avatarUrl] + */ + +/** + * @typedef {Object} Page + * @property {number} offset + * @property {number} limit + */ + +/** + * The repository contract. Implemented twice — once as an ES class and once as + * a constructor function — and declared by neither, because JavaScript has no + * `implements`. + * + * @typedef {Object} UserRepository + * @property {function(string): Promise} findById + * @property {function(Page): Promise>} list + * @property {function(User): Promise} save + */ + +/** + * @callback ErrorHandler + * @param {Error} error + * @param {string} context + * @returns {void} + */ + +/** + * @template T + * @typedef {{ ok: true, value: T } | { ok: false, error: Error }} Result + */ diff --git a/parser/src/test-data/javascript/categories/integration/errors.js b/parser/src/test-data/javascript/categories/integration/errors.js new file mode 100644 index 000000000..c108597f7 --- /dev/null +++ b/parser/src/test-data/javascript/categories/integration/errors.js @@ -0,0 +1,58 @@ +// fixture: cjs/integration/errors.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2022 (Error `cause`); ES2015 otherwise +// +// The error hierarchy the service throws. Three levels of `extends`, a builtin +// at the root, and one legacy error declared as a constructor function — so the +// same hierarchy carries both an EXTENDS_CLAUSE edge and an +// OBJECT_CREATE_PROTOTYPE one. + +'use strict'; + +class ServiceError extends Error { + /** + * @param {string} message + * @param {{ cause?: Error, status?: number }} [options] + */ + constructor(message, options = {}) { + super(message, { cause: options.cause }); + this.name = new.target.name; + this.status = options.status || 500; + Error.captureStackTrace(this, new.target); + } + + /** @returns {{ name: string, message: string, status: number }} */ + toJSON() { + return { name: this.name, message: this.message, status: this.status }; + } +} + +class NotFoundError extends ServiceError { + constructor(id) { + super('user not found: ' + id, { status: 404 }); + this.id = id; + } +} + +class ConflictError extends ServiceError { + constructor(field, cause) { + super('conflict on ' + field, { status: 409, cause }); + this.field = field; + } +} + +// The prototype-era error, in the same hierarchy. `Error.call(this, ...)` does +// NOT set the message — that is the reason the modern form exists — so the +// message is assigned by hand. +function LegacyError(message) { + Error.call(this, message); + this.name = 'LegacyError'; + this.message = message; + this.status = 500; +} +LegacyError.prototype = Object.create(Error.prototype); +LegacyError.prototype.constructor = LegacyError; +LegacyError.prototype.toJSON = ServiceError.prototype.toJSON; // a borrowed method + +module.exports = { ServiceError, NotFoundError, ConflictError, LegacyError }; diff --git a/parser/src/test-data/javascript/categories/integration/repository.js b/parser/src/test-data/javascript/categories/integration/repository.js new file mode 100644 index 000000000..603e29c7b --- /dev/null +++ b/parser/src/test-data/javascript/categories/integration/repository.js @@ -0,0 +1,101 @@ +// fixture: cjs/integration/repository.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2018 (async iteration) +// +// The prototype-era half of the integration set: a UserRepository implemented +// as a constructor function with prototype-assigned methods, inheriting from +// EventEmitter through util.inherits, satisfying a @typedef contract it does not +// declare, and exported by `module.exports = `. +// +// Everything the schema calls a declaration-by-assignment is here in one file +// with real call sites through it: PROTOTYPE_ASSIGNMENT, STATIC_ASSIGNMENT, +// OBJECT_DEFINE_PROPERTY, UTIL_INHERITS. + +'use strict'; + +const util = require('util'); +const EventEmitter = require('events').EventEmitter; +const { NotFoundError, ConflictError } = require('./errors'); + +/** + * An in-memory user store. + * + * @constructor + * @augments {EventEmitter} + * @implements {import('./contracts.js').UserRepository} + * @param {Map} [seed] + */ +function MemoryUserRepository(seed) { + EventEmitter.call(this); + /** @type {Map} */ + this.store = seed || new Map(); + this.reads = 0; +} + +util.inherits(MemoryUserRepository, EventEmitter); + +/** + * @param {string} id + * @returns {Promise} + */ +MemoryUserRepository.prototype.findById = async function findById(id) { + this.reads += 1; + const found = this.store.get(id) || null; + this.emit('read', id, found !== null); + return found; +}; + +/** + * @param {import('./contracts.js').Page} page + * @returns {Promise>} + */ +MemoryUserRepository.prototype.list = async function list({ offset = 0, limit = 10 } = {}) { + return Array.from(this.store.values()).slice(offset, offset + limit); +}; + +/** + * @param {import('./contracts.js').User} user + * @returns {Promise} + */ +MemoryUserRepository.prototype.save = async function save(user) { + if (this.store.has(user.id)) { + throw new ConflictError('id', new Error('duplicate ' + user.id)); + } + this.store.set(user.id, user); + this.emit('write', user.id); + return user; +}; + +MemoryUserRepository.prototype.mustFind = async function mustFind(id) { + const found = await this.findById(id); + if (found === null) { throw new NotFoundError(id); } + return found; +}; + +// An async generator by assignment. +MemoryUserRepository.prototype.stream = async function* stream() { + for (const user of this.store.values()) { yield user; } +}; + +// A prototype FIELD, shared by every instance. +MemoryUserRepository.prototype.defaultLimit = 10; + +// A getter installed by defineProperty. Reading `repo.size` runs a function. +Object.defineProperty(MemoryUserRepository.prototype, 'size', { + enumerable: true, + get: function () { return this.store.size; } +}); + +// Statics. +MemoryUserRepository.empty = function empty() { return new MemoryUserRepository(); }; +MemoryUserRepository.VERSION = '1.0.0'; + +// Mixed-in behaviour, arriving by call rather than by inheritance. +Object.assign(MemoryUserRepository.prototype, { + toJSON() { return { size: this.size, reads: this.reads }; }, + clear() { this.store.clear(); return this; } +}); + +module.exports = MemoryUserRepository; +module.exports.MemoryUserRepository = MemoryUserRepository; diff --git a/parser/src/test-data/javascript/categories/integration/service-layer.js b/parser/src/test-data/javascript/categories/integration/service-layer.js new file mode 100644 index 000000000..ca02092ba --- /dev/null +++ b/parser/src/test-data/javascript/categories/integration/service-layer.js @@ -0,0 +1,152 @@ +// fixture: cjs/integration/service-layer.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2022 (private members); ES2018 otherwise +// +// The modern half, and the composition root. Everything the categories cover, +// used together the way a real CommonJS service is written: destructured +// requires, a conditional require inside a function, a class implementing a +// @typedef contract with no `implements`, an Error hierarchy, closures returned +// from methods, an async generator, a getter, arity dispatch, and an export +// object built member by member. +// +// The call graph through this file is the thing worth checking end to end: +// every receiver is either a local class (LOCAL_CLASS), a require alias +// (IMPORT_ALIAS), a JSDoc-typed parameter (JSDOC) or a builtin +// (NODE_BUILTIN) — one call site of each receiverTypeSource, in one file. + +'use strict'; + +const path = require('node:path'); +const { NotFoundError, ServiceError } = require('./errors'); +const MemoryUserRepository = require('./repository'); + +/** + * @implements {import('./contracts.js').UserRepository} + */ +class CachingUserRepository { + #cache = new Map(); + + /** + * @param {import('./contracts.js').UserRepository} inner + */ + constructor(inner) { + this.inner = inner; + } + + /** + * @param {string} id + * @returns {Promise} + */ + async findById(id) { + if (this.#cache.has(id)) { return this.#cache.get(id); } + const found = await this.inner.findById(id); + this.#cache.set(id, found); + return found; + } + + /** @param {import('./contracts.js').Page} page */ + list(page) { return this.inner.list(page); } + + /** @param {import('./contracts.js').User} user */ + async save(user) { + this.#cache.delete(user.id); + return this.inner.save(user); + } + + get cached() { return this.#cache.size; } +} + +class UserService { + /** + * @param {import('./contracts.js').UserRepository} repository + * @param {import('./contracts.js').ErrorHandler} [onError] + */ + constructor(repository, onError = () => {}) { + this.repository = repository; + this.onError = onError; + this.auditLog = []; + } + + /** + * Arity dispatch: `get(id)` and `get(id, options)` are one method. + * + * @param {string} id + * @param {{ throwIfMissing?: boolean }|function(Error): void} [options] + * @returns {Promise} + */ + async get(id, options) { + if (typeof options === 'function') { options = { throwIfMissing: false }; } + const settings = Object.assign({ throwIfMissing: true }, options); + try { + const user = await this.repository.findById(id); + if (user === null && settings.throwIfMissing) { throw new NotFoundError(id); } + return user; + } catch (err) { + if (err instanceof ServiceError) { this.onError(err, 'get'); return null; } + throw err; + } finally { + this.auditLog.push('get:' + id); + } + } + + /** + * A closure returned from a method, capturing `this` lexically through an + * arrow — the pattern that makes the returned function usable as a callback. + * + * @param {string} prefix + * @returns {function(string): Promise} + */ + scoped(prefix) { + return (id) => this.get(prefix + ':' + id, { throwIfMissing: false }); + } + + /** @returns {AsyncGenerator} */ + async *all(pageSize = 2) { + for (let offset = 0; ; offset += pageSize) { + const page = await this.repository.list({ offset, limit: pageSize }); + if (page.length === 0) { return; } + for (const user of page) { yield user; } + if (page.length < pageSize) { return; } + } + } + + /** + * A conditional require inside a method body — a module edge that is not at + * the top of the file, and one that may never execute. + * + * @param {string} format + * @returns {string} + */ + render(format) { + if (format === 'yaml') { + const yaml = require('yaml'); + return yaml.stringify(this.auditLog); + } + return JSON.stringify(this.auditLog); + } + + get auditPath() { + return path.join(__dirname, 'audit.log'); + } +} + +/** + * The composition root, and the module's only export edge until the members + * below it. + * + * @param {{ cache?: boolean }} [options] + * @returns {UserService} + */ +function createService(options = {}) { + const base = MemoryUserRepository.empty(); + const repository = options.cache ? new CachingUserRepository(base) : base; + return new UserService(repository, (err, context) => { + process.emitWarning(context + ': ' + err.message); + }); +} + +module.exports = createService; +module.exports.UserService = UserService; +module.exports.CachingUserRepository = CachingUserRepository; +module.exports.createService = createService; diff --git a/parser/src/test-data/javascript/categories/jsdoc/callback-and-template.js b/parser/src/test-data/javascript/categories/jsdoc/callback-and-template.js new file mode 100644 index 000000000..389dcb87a --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/callback-and-template.js @@ -0,0 +1,183 @@ +// fixture: cjs/jsdoc/callback-and-template.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// @callback and @template. 109 @callback and 1,013 @template sites in the +// schema's corpus. +// +// @callback declares a FUNCTION TYPE with a name — typeCategory = +// JSDOC_CALLBACK, isTypeOnly = true. It looks exactly like a function +// declaration in a comment and it is not one: nothing is callable, and a +// js_call_site whose target is a @callback row is the type-only leak the gate +// forbids. +// +// @template is the whole of JavaScript's generics, and it deliberately has NO +// relation of its own — the schema puts it in js_type_reference with +// contextKind = TEMPLATE rather than porting ts_type_parameter for 1,013 +// comment-borne rows. +// +// Grounded in the platform's `util.callbackify` docs, a charting library's plugin +// typedefs, and the @template usage in Closure-annotated code. + +'use strict'; + +/** + * The Node error-first callback, declared once and referenced everywhere. + * + * @callback NodeCallback + * @param {Error|null} err + * @param {*} [result] + * @returns {void} + */ + +/** + * A callback with named parameters and a real return type. + * + * @callback Comparator + * @param {*} a + * @param {*} b + * @returns {number} negative, zero or positive + */ + +/** + * A generic @callback: @template on a callback declaration. + * + * @template T, R + * @callback Mapper + * @param {T} value + * @param {number} index + * @param {Array} array + * @returns {R} + */ + +/** + * A callback declared with `this`. + * + * @callback Handler + * @this {{ name: string }} + * @param {Event} event + * @returns {boolean|void} + */ + +/** + * Uses two of the callbacks above as parameter types. The @param types are + * references to comment-declared types, which is a normal FK to a js_type row + * whose only evidence is a comment. + * + * @param {Array<*>} items + * @param {Comparator} compare + * @param {NodeCallback} done + * @returns {void} + */ +function sortAsync(items, compare, done) { + try { + done(null, items.slice().sort(compare)); + } catch (err) { + done(err); + } +} + +// --- @template on functions --------------------------------------------------- + +/** + * One type parameter. + * + * @template T + * @param {Array} items + * @param {Mapper} fn + * @returns {Array} + */ +function mapToStrings(items, fn) { + return items.map(fn); +} + +/** + * Two parameters on one tag, and a CONSTRAINT — the `@template {Base} T` form, + * which is TypeScript's `T extends Base` written in JSDoc. + * + * @template K, V + * @template {object} TSource + * @param {TSource} source + * @param {K} key + * @param {V} value + * @returns {TSource & Record} + */ +function withProperty(source, key, value) { + return Object.assign({}, source, { [key]: value }); +} + +/** + * A DEFAULT for a type parameter — `@template [T=string]`. + * + * @template [T=string] + * @param {T} [value] + * @returns {Array} + */ +function boxed(value) { + return value === undefined ? [] : [value]; +} + +/** + * @template on a class, which is how a generic class is declared in JavaScript. + * The type parameter is in scope for every member's JSDoc and for nothing in the + * code. + * + * @template T + */ +class Box { + /** + * @param {T} value + */ + constructor(value) { + /** @type {T} */ + this.value = value; + } + + /** + * A method-level type parameter that SHADOWS the class's. Both are named T in + * real code often enough that the shadowing case has to be covered. + * + * @template T + * @param {function(T): T} fn + * @returns {Box} + */ + map(fn) { + return new Box(fn(/** @type {*} */ (this.value))); + } + + /** + * A type parameter constrained by a SIBLING one. + * + * @template U + * @template {keyof U} K + * @param {U} source + * @param {K} key + * @returns {U[K]} + */ + static pick(source, key) { + return source[key]; + } +} + +/** + * @template on a typedef whose body uses the parameter twice, and a recursive + * generic — the deepest type-expression tree in this fixture set. + * + * @template T + * @typedef {{ value: T, next: LinkedNode|null }} LinkedNode + */ + +/** + * Uses the recursive generic. + * + * @param {LinkedNode} head + * @returns {number} + */ +function sumList(head) { + let total = 0; + for (let node = head; node !== null; node = node.next) { total += node.value; } + return total; +} + +module.exports = { sortAsync, mapToStrings, withProperty, boxed, Box, sumList }; diff --git a/parser/src/test-data/javascript/categories/jsdoc/casts-and-throws.js b/parser/src/test-data/javascript/categories/jsdoc/casts-and-throws.js new file mode 100644 index 000000000..2b370cce9 --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/casts-and-throws.js @@ -0,0 +1,260 @@ +// fixture: cjs/jsdoc/casts-and-throws.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// DISCRIMINATORS FOR `JsTypeReferenceContextKind.CAST` AND `THROWS`, written +// ahead of the append. js-corpus found 1,942 inline JSDoc casts with no context +// kind to be emitted under. +// +// ## The distinction the CAST ruling has to make, and both halves are here +// +// TypeScript's JSDoc cast is the PARENTHESISED form only: +// +// /** @type {T} */ (expr) <- a type assertion on `expr` +// +// Without the parentheses the same comment is, to TypeScript, a comment that +// happens to precede an expression — it asserts nothing. But the unparenthesised +// form is what the 1,942 mostly ARE (`/** @type {NoticeFunction} */ data => …`), +// because authors write it and nothing tells them it does nothing. So the ruling +// has to say whether CAST records the parser's semantics (parentheses required) +// or the author's intent (any @type immediately before an expression). Both +// forms sit side by side below so that whichever is chosen, the other one is the +// control. +// +// ## Measured before the append, at a5f6aab +// +// The file produces nine js_type_reference rows and NONE of them is a cast: +// VARIABLE/type x2 (the two controls, correctly), FIELD/type x1, PARAM x2, +// RETURN x1, TYPEDEF x2. The nine cast sites produce nothing, the four @throws +// produce nothing, and `paramAnnotated`'s @type on a parameter produces nothing +// either. That is the before-state the append is measured against: the controls +// must stay VARIABLE, and the nine casts must become CAST or the ruling must say +// which of them do not. +// +// ## The trap, avoided on purpose +// +// This header is written WITHOUT any `/** */` block, because a JSDoc block in a +// header that mentions a tag in prose is read by the parser as that tag. The +// only `/** */` comments in this file are the ones under test. + +'use strict'; + +const raw = JSON.parse('{}'); +const items = [1, 2, 3]; + +/** + * @typedef {Object} Notice + * @property {string} text + */ + +/** + * @callback NoticeFunction + * @param {Notice} data + * @returns {string} + */ + +// --- 1. the cast proper: parenthesised ---------------------------------------------- + +const asBanner = /** @type {Notice} */ (raw); + +// A cast of a call result, and of a member access. +const fromCall = /** @type {Notice} */ (JSON.parse('{"text":"x"}')); +const fromMember = /** @type {string} */ (raw.text); + +// --- 2. a cast on an ARROW passed as an argument — the 1,942 case, both forms --------- + +// 2a. Parenthesised: a cast, unambiguously. +const parenthesisedArrow = items.map(/** @type {NoticeFunction} */ ((data) => data.text)); + +// 2b. NOT parenthesised: this is what the corpus actually contains. To TypeScript +// the @type asserts nothing; to the author it is a cast. One of these two +// lines is a CAST row and the other is the control — which is which is the +// ruling. +const bareArrow = items.map(/** @type {NoticeFunction} */ (data) => data.text); + +// 2c. The same, with the comment INSIDE the parameter list rather than before it — +// a third placement authors use, and it annotates the parameter, not the arrow. +const paramAnnotated = items.map((/** @type {Notice} */ data) => data.text); + +// --- 3. a cast on an OBJECT LITERAL ------------------------------------------------------ +// +// The parentheses are load-bearing twice here: they make it a cast, and they +// keep the `{` from being parsed as a block. Without them this line is a syntax +// error, which is why the object-literal cast is always parenthesised in the wild. + +const literalCast = /** @type {Notice} */ ({ text: 'literal' }); +const nestedLiteralCast = /** @type {{ inner: Notice }} */ ({ inner: { text: 'n' } }); + +// --- 4. a cast whose type DOES NOT EXIST ---------------------------------------------------- +// +// Nothing declares `NeverDeclaredType`. The cast still asserts it. The parser +// emits the name as written and resolves nothing, which is the only honest row; +// a parser that drops the cast because the type is unresolvable loses the +// author's claim. + +const toNothing = /** @type {NeverDeclaredType} */ (raw); + +// --- 5. the CONTROLS: ordinary @type that must NOT be recorded as a cast --------------------- + +// 5a. On a declaration. contextKind = VARIABLE, not CAST. The difference from §1 +// is that the comment precedes a `const`, not a parenthesised expression. +/** @type {Notice} */ +const declared = raw; + +// 5b. On a declaration whose initialiser is parenthesised. Still VARIABLE: the +// comment is attached to the declaration, and the parentheses are just +// parentheses. +/** @type {Notice} */ +const parenthesisedInit = (raw); + +// 5c. On a field. +class Holder { + constructor() { + /** @type {Notice} */ + this.banner = raw; + } +} + +// 5d. @type in a comment that precedes a STATEMENT, not an expression. Not a cast, +// and not attached to anything the parser can type. +/** @type {Notice} */ +items.push(4); + +// --- 5e–5k. THE 63% — @type on a STATEMENT, where the extractor must walk one node down ---- +// +// js-oracle's decomposition: only 29% of @type tags are a vocabulary gap. 63% +// attach to a VariableStatement or an ExpressionStatement and need the extractor +// to reach the declaration or expression one node beneath — vocabulary that +// already exists. So a fix that routes every unparenthesised @type to CAST +// passes the cast cases above and gets all of these wrong. Each must come out +// VARIABLE (or FIELD), never CAST, and never nothing. +// +// MEASURED at db339c1, after CAST entered the vocabulary and before any +// extractor emits it. Nine @type tags in this section; seven come out right +// today and two come out as NOTHING — and the two are the same shape: +// +// let ret = raw; VARIABLE (5e) +// let assignedLater; VARIABLE (5f, no initialiser) +// var legacy = raw; VARIABLE (5g) +// ret = JSON.parse(...); NOTHING (5h) <- identifier target +// Holder.prototype.shared = raw; FIELD (5i) <- member target, walks down +// let firstOfTwo = 1, secondOfTwo; VARIABLE x1(5j, one row for two bindings) +// let local = raw; VARIABLE (5k, in a body) +// local = JSON.parse('{}'); NOTHING (5k) <- identifier target again +// const text = local.text; VARIABLE (5k) +// +// So the walk-down already works for every declaration AND for an expression +// statement whose target is a member; it fails only for an expression statement +// whose target is a bare identifier. That is the narrowest possible statement +// of the gap, and it is the shape a route-everything-to-CAST fix would grab +// first, because it is the one currently producing nothing. +// +// WHAT 5k ACTUALLY FOUND, which is not what it was written for. js-impl traced +// the silence on 5k to the identifier resolving from the MODULE scope — the walk +// was inside a function and did not know it — and from there to `scopeOfNode` +// and `scopeByNode` swapped in four places (c1a52e1). Every js_method's +// bodyScopeLinkHash had been its ENCLOSING scope since the first commit. +// Measured across this corpus: before the fix, 908 of 908 non-initializer +// methods had bodyScope === ownerScope; after it, 0 of 908. A three-line control +// written to catch a wrong fix for a narrow gap found a defect in every method +// row the parser had ever emitted. +// +// After c1a52e1: 9 of 9 tags in this section produce a row — 8 VARIABLE, 1 +// FIELD. 5h and 5k now attach to the reassigned binding as VARIABLE rather than +// to the expression; whether that is the right owner is a ruling, not recorded +// here as an expectation. + +// 5e. `let` with an initialiser — the case asked for by name. VARIABLE. +/** @type {Notice} */ +let ret = raw; + +// 5f. `let` with NO initialiser, assigned later. The declaration is still the +// thing the comment is on. VARIABLE, and hasInitializer = false. +/** @type {Notice} */ +let assignedLater; +assignedLater = raw; + +// 5g. `var`, the third keyword. VARIABLE. +/** @type {Notice} */ +var legacy = raw; + +// 5h. An EXPRESSION STATEMENT: an assignment to a binding declared earlier. There +// is no declaration under the comment and no parenthesised expression. The +// node beneath is an assignment; the target is `ret`. This is the case a +// route-everything-to-CAST fix misclassifies first. +/** @type {Notice} */ +ret = JSON.parse('{"text":"reassigned"}'); + +// 5i. An expression statement whose target is a MEMBER. The node beneath is an +// assignment to `Holder.prototype.shared`, which is an assignment-declared +// field — so if anything, FIELD. Never CAST. +/** @type {Notice} */ +Holder.prototype.shared = raw; + +// 5j. A multi-declarator statement. One comment, two bindings. Which one carries +// the type — the first, both, or neither — is a ruling; CAST is not an option. +/** @type {number} */ +let firstOfTwo = 1, secondOfTwo = 2; + +// 5k. The same shapes INSIDE a function body, so the walk-down happens under a +// method owner rather than the module initializer. +function insideBody() { + /** @type {Notice} */ + let local = raw; + /** @type {Notice} */ + local = JSON.parse('{}'); + /** @type {string} */ + const text = local.text; + return text; +} + +// --- 6. @throws, getting a kind in the same append ----------------------------------------- +// +// The closest JavaScript comes to Java's throws clause, and it is a comment with +// no enforcement. Three shapes: a type, a type with a description, and a +// description with no type at all — the last has no type expression to make a +// js_type_reference row from, which is the discriminator. + +/** + * @param {string} id + * @throws {RangeError} + * @returns {string} + */ +function throwsTyped(id) { + if (!id) { throw new RangeError('id'); } + return id; +} + +/** + * @param {string} id + * @throws {TypeError} when id is not a string + * @throws {RangeError} when id is empty + */ +function throwsTwice(id) { + if (typeof id !== 'string') { throw new TypeError('id'); } + if (!id) { throw new RangeError('id'); } + return id; +} + +/** + * @throws when the input is bad + */ +function throwsUntyped(input) { + if (!input) { throw new Error('bad'); } + return input; +} + +/** + * @throws {NeverDeclaredError} + */ +function throwsUndeclared() { + throw new Error('x'); +} + +module.exports = { + asBanner, fromCall, fromMember, parenthesisedArrow, bareArrow, paramAnnotated, + literalCast, nestedLiteralCast, toNothing, declared, parenthesisedInit, Holder, + ret, assignedLater, legacy, firstOfTwo, secondOfTwo, insideBody, + throwsTyped, throwsTwice, throwsUntyped, throwsUndeclared +}; diff --git a/parser/src/test-data/javascript/categories/jsdoc/contradicting-jsdoc.js b/parser/src/test-data/javascript/categories/jsdoc/contradicting-jsdoc.js new file mode 100644 index 000000000..26b8308d4 --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/contradicting-jsdoc.js @@ -0,0 +1,187 @@ +// fixture: cjs/jsdoc/contradicting-jsdoc.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// JSDoc THAT IS WRONG. Every comment in this file disagrees with the code it +// annotates, and the parser's job is to emit what is written, not to adjudicate. +// +// The rule this fixture pins: declaredTypeName comes from the comment, +// parameterCount and the parameter names come from the code, and the fact base +// records both without reconciling them. A parser that drops the JSDoc when it +// conflicts loses the author's stated intent; one that trusts the JSDoc over the +// code produces a fact base describing a program that does not exist; one that +// silently picks per case is worst of all, because the rule is then unknowable. +// +// This is not a synthetic worry. JSDoc rots the moment a signature changes and +// nothing checks it — which is precisely why it is only 36.4% present and why +// checkJs finds errors in nearly every JSDoc-typed package. + +'use strict'; + +/** + * The comment says two parameters; the code takes three. The comment says the + * return is a string; the code returns a number. + * + * @param {string} a + * @param {string} b + * @returns {string} + */ +function arityMismatch(a, b, c) { + return a.length + b.length + (c || 0); +} + +/** + * The comment names parameters that do not exist and misses ones that do. + * + * @param {number} width + * @param {number} height + * @returns {number} + */ +function nameMismatch(rows, columns) { + return rows * columns; +} + +/** + * The comment says the parameter is optional; the code has no default and + * dereferences it unconditionally, so calling it without the argument throws. + * + * @param {Object} [options] + * @returns {string} + */ +function optionalityMismatch(options) { + return options.name; +} + +/** + * The comment says the parameter is a string; every use in the body treats it + * as an array. + * + * @param {string} items + * @returns {void} + */ +function typeMismatch(items) { + items.forEach((item) => item); +} + +/** + * The comment declares a rest parameter; the code uses `arguments` instead — + * the second, undeclared parameter channel. usesArguments is true, hasRestParameter + * is false, and @param says otherwise. + * + * @param {...number} values + * @returns {number} + */ +function restMismatch() { + return Array.prototype.reduce.call(arguments, (a, b) => a + b, 0); +} + +/** + * @returns on a function that returns nothing, and @yields on a function that + * is not a generator. + * + * @returns {Promise} + * @yields {number} + */ +function returnsNothing() { + // no return statement at all +} + +/** + * @async on a synchronous function, and @generator on one that does not yield. + * + * @async + * @generator + * @returns {number} + */ +function notAsync() { + return 1; +} + +/** + * The comment says this extends EventEmitter; the code extends Error. Both are + * in the file, they name different types, and only one of them runs. + * + * @extends {EventEmitter} + */ +class HeritageMismatch extends Error { + constructor(message) { + super(message); + this.name = 'HeritageMismatch'; + } +} + +/** + * @implements an interface none of whose members this class has. + * + * @implements {import('./typedef-only.js').Middleware} + */ +class ImplementsNothing { + unrelated() { return true; } +} + +/** + * @type declares a number; the initialiser is a string. tsc under checkJs flags + * this; the parser records both and flags nothing. + * + * @type {number} + */ +const wrongType = 'not a number'; + +/** + * @type on a const that is later mutated in a way the type forbids. + * + * @type {ReadonlyArray} + */ +const mutated = []; +mutated.push('mutation'); + +/** + * @private on an exported member, and @readonly on one that is assigned twice. + * Both tags are advisory: nothing enforces either. + * + * @private + * @readonly + * @type {number} + */ +let notReallyPrivate = 1; +notReallyPrivate = 2; + +/** + * @deprecated with a replacement that does not exist, and @see pointing at a + * missing symbol. Reference tags with dangling targets are the norm, not the + * exception. + * + * @deprecated use {@link doesNotExist} instead + * @see NeverDeclared + * @param {string} x + */ +function deprecated(x) { return x; } + +/** + * A comment attached to nothing. There is a blank line between it and the next + * statement, which by JSDoc's own rules detaches it — and different tools + * disagree about that. attachedToKind = NONE is the honest answer. + * + * @type {string} + */ + +const unattached = 1; + +/** + * Two JSDoc comments on one declaration. The LAST one wins by convention, and + * both are in the file. + * + * @param {string} first + */ +/** + * @param {number} second + * @returns {boolean} + */ +function twoComments(x) { return Boolean(x); } + +module.exports = { + arityMismatch, nameMismatch, optionalityMismatch, typeMismatch, restMismatch, + returnsNothing, notAsync, HeritageMismatch, ImplementsNothing, + wrongType, mutated, notReallyPrivate, deprecated, unattached, twoComments +}; diff --git a/parser/src/test-data/javascript/categories/jsdoc/extends-implements.js b/parser/src/test-data/javascript/categories/jsdoc/extends-implements.js new file mode 100644 index 000000000..54c91ddc0 --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/extends-implements.js @@ -0,0 +1,157 @@ +// fixture: cjs/jsdoc/extends-implements.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// Heritage declared in a comment. @extends (and its alias @augments), +// @implements, @interface, @constructor, @lends and @mixes. +// +// The load-bearing case is @extends on a class whose `extends` clause is a CALL +// or a variable: the code cannot name the superclass and the comment can. And +// @implements has NO code counterpart at all — JavaScript has no `implements`, +// so js_type_heritage.inheritsMembers is always true and the interface +// relationship exists only as a comment. +// +// Grounded in a charting library's element classes, a media player's component +// hierarchy, and Closure-annotated code, where @implements is the primary interface mechanism. + +'use strict'; + +const EventEmitter = require('events').EventEmitter; + +/** + * An interface with no runtime existence. @interface makes the class a TYPE and + * its methods SIGNATURES — the bodies are conventionally empty and are not + * meant to be called. A parser that treats these as ordinary methods produces + * call targets nobody can reach. + * + * @interface + */ +class Serializable { + /** + * @returns {string} + */ + serialize() { throw new Error('not implemented'); } +} + +/** + * A second interface, declared as a @typedef rather than as a class. Same + * concept, no code at all. + * + * @typedef {Object} Comparable + * @property {function(*): number} compareTo + */ + +/** + * A class whose heritage is BOTH declared in code and in the comment. They + * agree here; contradicting-jsdoc.js has the case where they do not. + * + * @extends {EventEmitter} + * @implements {Serializable} + */ +class Emitter extends EventEmitter { + constructor(name) { + super(); + this.name = name; + } + + /** + * @returns {string} + * @override + */ + serialize() { return JSON.stringify({ name: this.name }); } +} + +/** + * The case the code cannot express: the superclass is the result of a CALL, so + * `extends` names no type and isComputedSuperclass is true. The comment supplies + * what syntax cannot. + * + * @template T + * @extends {Emitter} + * @implements {Serializable} + * @implements {Comparable} + */ +class Mixed extends withLogging(Emitter) { + /** @returns {string} */ + serialize() { return super.serialize(); } + /** @param {*} other @returns {number} */ + compareTo(other) { return this.name < other.name ? -1 : 1; } +} + +/** + * The mixin factory the class above extends. @mixes records the mixin + * relationship for a target that gains members by assignment rather than by + * inheritance. + * + * @param {Function} Base + * @returns {Function} + */ +function withLogging(Base) { + return class extends Base { + log(msg) { return '[' + this.name + '] ' + msg; } + }; +} + +/** + * A CONSTRUCTOR FUNCTION with its heritage in the comment. There is no `class` + * and no `extends` token; @constructor is what makes this a type at all, and + * @augments (the @extends alias) is the edge. + * + * @constructor + * @augments {Emitter} + * @param {string} name + */ +function LegacyEmitter(name) { + Emitter.call(this, name); +} +LegacyEmitter.prototype = Object.create(Emitter.prototype); +LegacyEmitter.prototype.constructor = LegacyEmitter; + +/** + * @lends: the members of this object literal belong to LegacyEmitter's + * prototype. It is a comment that RETARGETS every declaration in the expression + * it annotates, which no other tag does. + */ +Object.assign(LegacyEmitter.prototype, /** @lends LegacyEmitter.prototype */ { + /** @returns {string} */ + serialize() { return 'legacy:' + this.name; }, + /** @type {number} */ + version: 1 +}); + +/** + * A class implementing an interface declared in ANOTHER FILE, reached by + * import-type syntax inside the tag. + * + * @implements {import('./typedef-only.js').Middleware} + */ +class ImportedInterfaceImpl { + /** @param {*} req @param {*} res @returns {void} */ + handle(req, res) { return undefined; } +} + +/** + * @implements pointing at a name NOTHING declares. The row names the interface + * and resolves nothing, which is the only honest answer available. + * + * @implements {NeverDeclaredInterface} + */ +class DanglingImplements {} + +/** + * @abstract on a class and on a method. There is no abstract in JavaScript, so + * js_type.isAbstract stays a parity slot at false; the tag is still written and + * is still evidence. + * + * @abstract + */ +class AbstractBase { + /** @abstract @returns {void} */ + render() { throw new Error('abstract'); } +} + +module.exports = { + Serializable, Emitter, Mixed, withLogging, LegacyEmitter, + ImportedInterfaceImpl, DanglingImplements, AbstractBase +}; diff --git a/parser/src/test-data/javascript/categories/jsdoc/nested-params.js b/parser/src/test-data/javascript/categories/jsdoc/nested-params.js new file mode 100644 index 000000000..dc0a6b453 --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/nested-params.js @@ -0,0 +1,235 @@ +// fixture: cjs/jsdoc/nested-params.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2018 +// +// THE DOTTED `@param` AND THE BRACKET FORMS, isolated. +// +// `cjs/jsdoc/param-returns.js` already contained dotted `@param` names, and it +// did NOT catch the defect js-impl found — its dotted tags hang off a +// DESTRUCTURED parameter and the row came out empty rather than wrong. Having +// the construct is not the same as discriminating on it, which is why this file +// exists beside that one rather than inside it. +// +// ## What actually happens, measured against the parser at 2d5a4dc +// +// A dotted `@param` following a plain-identifier parent puts **the raw remaining +// comment text** into the parent's `declaredTypeName` — sibling tags, newlines, +// leading asterisks and all: +// +// @param {object} ctx -> declaredTypeName = +// @param {string} ctx.model "@param {string} ctx.model\n * @param {number} ctx.version\n * " +// @param {number} ctx.version +// +// Five shapes reproduce it: no trailing tag, a trailing `@returns`, a trailing +// plain `@param`, trailing prose, and two levels of nesting. +// +// ## Two corrections to how this was described to me +// +// 1. **The positional count is NOT wrong.** A later plain `@param` still lands +// at the right position with the right type — in `withLaterPlainParam` below, +// `limit` is position 1 with `declaredTypeName = "number"`. Only the +// parent's type text is corrupted. Worth stating because "counted as a +// positional parameter" would send someone looking at `position`, which is +// correct — and `outOfOrderTags` shows tags are matched to parameters BY +// NAME rather than by order, which is also correct and also worth not +// breaking while fixing the rest. +// +// 2. **The bracket forms have a SEPARATE defect nobody named**, and the +// fixture pins its exact shape. `@param {string} [x]` and +// `@param {string} [x=y]` extract their TYPE correctly — `"string"` — and set +// **`isOptional = false`, `hasDefault = false`**. The schema documents +// `isOptional` as "JSDoc `[x]` **or** a default value". +// +// `defaultsDisagree` below is what makes the diagnosis precise: it is the +// only bracketed parameter in this file that also has a default IN THE CODE, +// and it is the only one that comes back `true, true`. So the two columns are +// being derived **from the code alone** — the bracket is parsed for its type +// and its optionality is discarded on the way past. A silent loss, not a +// corruption, which is why no amount of looking at `declaredTypeName` finds +// it. +// +// Grounded in the shape every options-object API documents itself with — a web +// framework's `app.listen(options)`, the platform's `fs.readFile(path, options)`, and any function +// whose second argument is a config bag. + +'use strict'; + +// --- the dotted form, five shapes ------------------------------------------------ + +/** + * Parent is a plain identifier, dotted children follow, nothing after them. + * + * @param {object} ctx + * @param {string} ctx.model + * @param {number} ctx.version + */ +function noTrailingTag(ctx) { + return ctx.model + ctx.version; +} + +/** + * The same, with a trailing `@returns`. A trailing tag does NOT stop it. + * + * @param {object} scope + * @param {string} scope.name + * @returns {string} + */ +function withReturns(scope) { + return scope.name; +} + +/** + * A later PLAIN `@param` after the dotted ones. This is the case that proves + * the positional count is unaffected: `limit` must be position 1 with type + * `number`, and it is. + * + * @param {object} query + * @param {string} query.table + * @param {number} limit + */ +function withLaterPlainParam(query, limit) { + return query.table + limit; +} + +/** + * Dotted children followed by prose rather than by a tag. + * + * @param {object} opts + * @param {boolean} opts.strict + * + * Remaining prose, deliberately after the last tag, describing nothing. + */ +function withTrailingProse(opts) { + return opts.strict; +} + +/** + * Two levels of nesting. `o.a.b` has a dotted parent which itself has a dotted + * parent, and neither level is a parameter. + * + * @param {object} tree + * @param {object} tree.branch + * @param {string} tree.branch.leaf + * @returns {string} + */ +function deeplyDotted(tree) { + return tree.branch.leaf; +} + +/** + * A dotted `@param` whose PARENT TAG IS ABSENT. Nothing declares `orphan` + * itself, only its child, so there is no parent row for the child to attach to. + * + * @param {string} orphan.child + */ +function orphanDotted(orphan) { + return orphan.child; +} + +/** + * Dotted names on a DESTRUCTURED parameter — the shape param-returns.js already + * had, kept here so the two are side by side and the difference is legible. + * One `js_method_parameter` row with `name = ""`, plus the bound names. + * + * @param {object} config + * @param {string} config.host + * @param {number} config.port + */ +function destructuredParent({ host, port }) { + return host + ':' + port; +} + +// --- the bracket forms ---------------------------------------------------------------- + +/** + * Optional, no default. `isOptional` must be true. + * + * @param {string} [maybeMissing] + * @returns {string} + */ +function optionalOnly(maybeMissing) { + return maybeMissing || ''; +} + +/** + * Optional WITH a default, declared only in the comment — the code has no + * default at all, so `hasDefault` is a claim the JSDoc makes alone. + * + * @param {string} [withCommentDefault=fallback] + * @returns {string} + */ +function defaultInCommentOnly(withCommentDefault) { + return withCommentDefault; +} + +/** + * Optional in the comment AND defaulted in the code, with DIFFERENT defaults. + * Both are written, both must be emitted, and neither adjudicates the other. + * + * @param {number} [disagreeing=1] + * @returns {number} + */ +function defaultsDisagree(disagreeing = 99) { + return disagreeing; +} + +/** + * A defaulted value containing the characters that terminate the form — an + * equals sign and a bracket inside the default itself. + * + * @param {string} [tricky=a=b] + * @param {string} [alsoTricky=[1,2]] + */ +function trickyDefaults(tricky, alsoTricky) { + return tricky + alsoTricky; +} + +/** + * Bracketed AND dotted together — an optional member of an options object, + * which is the single commonest JSDoc shape in a real codebase. + * + * @param {object} settings + * @param {string} settings.required + * @param {number} [settings.optional=30] + * @returns {number} + */ +function bracketedAndDotted(settings) { + return settings.optional || 0; +} + +// --- a @param naming a parameter that does not exist ------------------------------------ + +/** + * The tag names `ghost`; the function takes `actual`. JSDoc that contradicts the + * code is legitimate — it rots the moment a signature changes and nothing checks + * it — and the parser's job is to emit what is written, not to adjudicate. + * + * Measured: `actual` gets `declaredTypeName = ""` and the tag is dropped + * entirely. Nothing in the fact base records that the comment named a parameter + * the function does not have, so the contradiction is unrecoverable. + * + * @param {string} ghost + * @returns {string} + */ +function namesNoSuchParameter(actual) { + return actual; +} + +/** + * Two tags for one parameter, the second contradicting the first, plus a tag + * for a parameter that exists at a DIFFERENT position. + * + * @param {string} second + * @param {number} first + */ +function outOfOrderTags(first, second) { + return String(first) + second; +} + +module.exports = { + noTrailingTag, withReturns, withLaterPlainParam, withTrailingProse, + deeplyDotted, orphanDotted, destructuredParent, + optionalOnly, defaultInCommentOnly, defaultsDisagree, trickyDefaults, + bracketedAndDotted, namesNoSuchParameter, outOfOrderTags +}; diff --git a/parser/src/test-data/javascript/categories/jsdoc/param-returns.js b/parser/src/test-data/javascript/categories/jsdoc/param-returns.js new file mode 100644 index 000000000..82ca3cb35 --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/param-returns.js @@ -0,0 +1,181 @@ +// fixture: cjs/jsdoc/param-returns.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// JSDoc as the DECLARATION-SITE TYPE CHANNEL. 0.165% of parameters in the +// schema's corpus carry a syntactic annotation and 36.4% carry a JSDoc one, so +// this is where declared types actually live: @param 40,551 sites, @type 18,674, +// @returns 11,937. +// +// declaredTypeSource = JSDOC on every typed position here, and each type +// expression is a TREE in js_type_reference (contextKind = PARAM / RETURN / +// VARIABLE / FIELD), not a string. +// +// Grounded in a utility library with 73.0% JSDoc density, the highest in the +// schema's corpus, and a runtime's internal documentation comments. + +'use strict'; + +/** + * The plain case. + * + * @param {string} name + * @param {number} times + * @returns {string} + */ +function repeat(name, times) { + return name.repeat(times); +} + +/** + * Optional, defaulted, rest, and a parameter whose JSDoc name does not match + * the code's — the last one is a real and common mistake, and the parser's job + * is to emit what is written, not to correct it. + * + * @param {string} required + * @param {number} [optional] optional, no default + * @param {number} [withDefault=10] default IN THE COMMENT, and the + * code has its own default too + * @param {...string} rest + * @param {boolean} misnamed no parameter is called `misnamed` + * @returns {void} + */ +function options(required, optional, withDefault = 10, ...rest) { + return [required, optional, withDefault, rest]; +} + +/** + * A destructured parameter. ONE js_method_parameter row with bindingForm = + * OBJECT_PATTERN and patternBindingCount = 3, plus three js_variable rows — + * emitting three parameter rows would break `position`, and emitting one with no + * binding information would lose every name. + * + * The dotted @param names are how JSDoc describes the members of a destructured + * object, and they are not parameters. + * + * @param {Object} config + * @param {string} config.host + * @param {number} [config.port=80] + * @param {{ retries: number, backoff: number }} config.policy + * @returns {string} + */ +function connect({ host, port = 80, policy: { retries } }) { + return `${host}:${port}/${retries}`; +} + +/** + * Every type-expression shape, in parameter position. + * + * @param {string|number} union + * @param {?string} nullable + * @param {!Object} nonNullable + * @param {*} anything + * @param {string[]} arrayShorthand + * @param {Array} arrayGeneric + * @param {Object} record + * @param {{a: number, b: string}} objectLiteralType + * @param {function(number): string} functionType + * @param {[string, number]} tuple + * @param {'a'|'b'|'c'} stringLiterals + * @param {Promise>>} deepGeneric + * @param {typeof repeat} typeofQuery + * @param {import('./typedef-only.js').Header} importedType + * @returns {Promise} + */ +async function everyShape( + union, nullable, nonNullable, anything, arrayShorthand, arrayGeneric, + record, objectLiteralType, functionType, tuple, stringLiterals, + deepGeneric, typeofQuery, importedType +) { + return undefined; +} + +/** + * @returns with a description, @return (the singular alias), @yields, and + * @throws — which is the closest JavaScript comes to Java's throws clause, and + * it is a comment with no enforcement whatever. + * + * @param {number} n + * @return {Generator} the singular tag spelling + * @yields {number} + * @throws {RangeError} if n is negative + */ +function* countdown(n) { + if (n < 0) { throw new RangeError('negative'); } + while (n > 0) { yield n--; } + return 'done'; +} + +/** + * @this declares the receiver's type — the JSDoc equivalent of TypeScript's + * `this` parameter, and the only way to type a prototype-assigned method's + * receiver. + * + * @this {{ name: string }} + * @returns {string} + */ +function usesThis() { + return this.name; +} + +// --- @type on variables and fields --------------------------------------------- + +/** @type {number} */ +let count = 0; + +/** @type {Array} */ +const names = []; + +/** @type {Map} */ +const cache = new Map(); + +/** @type {function(string): boolean} */ +const predicate = (s) => s.length > 0; + +/** @type {?import('./typedef-only.js').Request} */ +let currentRequest = null; + +// @type on a destructuring declaration types the whole pattern, not one name. +/** @type {{a: number, b: number}} */ +const { a, b } = { a: 1, b: 2 }; + +class Server { + constructor() { + /** @type {number} */ + this.port = 0; + + /** @type {Array} */ + this.headers = []; + + /** + * @type {function(Error): void} + * @private + */ + this.onError = () => {}; + } + + /** + * @param {number} port + * @returns {this} for chaining — `this` as a return type + */ + listen(port) { + this.port = port; + return this; + } +} + +// --- an inline cast: the parenthesised @type ------------------------------------ +// +// A type annotation on an EXPRESSION rather than a declaration. The comment sits +// between the open paren and the expression, and it is the only place JSDoc +// annotates something that is not a declaration. + +const raw = JSON.parse('{}'); +const typed = /** @type {{id: number}} */ (raw); +const asConst = /** @type {const} */ ({ mode: 'strict' }); + +module.exports = { + repeat, options, connect, everyShape, countdown, usesThis, + count, names, cache, predicate, currentRequest, a, b, Server, typed, asConst +}; diff --git a/parser/src/test-data/javascript/categories/jsdoc/satisfies-and-unknown-syntax.js b/parser/src/test-data/javascript/categories/jsdoc/satisfies-and-unknown-syntax.js new file mode 100644 index 000000000..ca470819a --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/satisfies-and-unknown-syntax.js @@ -0,0 +1,169 @@ +// fixture: cjs/jsdoc/satisfies-and-unknown-syntax.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 +// +// The tail of the JSDoc vocabulary, and the dialect problem. +// +// @satisfies is the newest tag and the one with the fewest implementations: it +// checks a value against a type WITHOUT widening it to that type. Its JSDoc +// spelling exists only in TypeScript's dialect. +// +// referenceKind = UNKNOWN_SYNTAX is the deliberate escape hatch, and the second +// half of this file is what it is for. JSDoc type syntax is NOT STANDARDISED: +// Closure, TypeScript and jsdoc.app each accept things the others reject, and a +// type expression the parser cannot decompose gets ONE row with its text +// preserved rather than a guess or a dropped tag. js_parse_gap.gapKind = +// UNKNOWN_JSDOC_SYNTAX records that it happened. + +'use strict'; + +/** + * @satisfies on a variable. The value keeps its literal type; the tag only + * asserts it is assignable. + * + * @satisfies {Record} + */ +const routes = { + home: '/', + users: '/users' +}; + +/** + * @satisfies in the inline-cast position, which is where TypeScript's + * `expr satisfies T` lands in JavaScript. + */ +const config = /** @satisfies {import('./typedef-only.js').Header} */ ({ + name: 'content-type', + value: 'application/json' +}); + +/** + * @enum: Closure's constant set. The members are the annotated object's + * properties, so the type's members live in a VALUE and its declaration lives + * in a comment. + * + * @enum {number} + * @readonly + */ +const StatusCode = { + OK: 200, + NOT_FOUND: 404, + ERROR: 500 +}; + +/** + * @const with a type, @default, @overload, @package / @protected / @public — + * the access tags, none of which JavaScript enforces. + * + * @const {string} + * @default + */ +const VERSION = '1.0.0'; + +/** + * @overload declares an extra signature. There is exactly one function here and + * the comments claim three, which is the JSDoc spelling of an overload set — + * and unlike TypeScript's, nothing checks that the implementation covers them. + * + * @overload + * @param {string} value + * @returns {string} + * + * @overload + * @param {number} value + * @returns {number} + * + * @param {string|number} value + * @returns {string|number} + */ +function identity(value) { return value; } + +// --- the dialect boundary: syntax a parser may not be able to decompose -------- + +/** + * Closure's non-nullable/nullable prefixes combined with generics and a + * function type carrying `new:` — the constructor-signature spelling, which + * TypeScript's JSDoc parser does not accept. + * + * @param {function(new:Date, number)} ctor + * @param {!Array} mixed + * @returns {undefined} + */ +function closureOnly(ctor, mixed) { return undefined; } + +/** + * Closure's record type with a trailing comma and its `=` optional-suffix + * spelling, which is different from the `[name]` spelling used elsewhere in + * this fixture set. + * + * @param {{a: number, b: string,}} trailingComma + * @param {string=} suffixOptional + * @param {...!Object} restOfObjects + */ +function closureRecord(trailingComma, suffixOptional, restOfObjects) {} + +/** + * A conditional type and a mapped type in JSDoc. Both are TypeScript-only, both + * are legal in a @typedef, and neither is decomposable by a Closure-shaped + * parser. + * + * @typedef {T extends string ? number : boolean} Conditional + * @template T + */ + +/** + * @typedef {{ [K in keyof T]: string }} Mapped + * @template T + */ + +/** + * A template-literal type, and an indexed access. + * + * @typedef {`on${Capitalize}`} EventName + * @typedef {StatusCode[keyof StatusCode]} StatusValue + */ + +/** + * Genuinely malformed. An unbalanced brace, an empty type, and a type that is + * a bare sentence. Each should produce one UNKNOWN_SYNTAX row with the text + * preserved and a parse gap beside it — NOT a dropped tag and not a guess. + * + * @param {Array} something + * @param {string} x + */ +function unknownTag(x) { return x; } + +/** + * An inline {@link} and {@linkcode} inside a description, which are text + * markup, not types — a parser that harvests every brace pair as a type + * expression finds two here that are not. + * + * @param {string} x see {@link identity} and {@linkcode closureOnly} + */ +function inlineLinks(x) { return x; } + +// A line comment with a tag in it. Not JSDoc: JSDoc is a /** */ block, and +// @param here is prose. +// @param {string} notJsdoc + +/* A block comment that is not JSDoc — two stars are required, this has one. + @type {number} */ +const notAnnotated = 1; + +module.exports = { + routes, config, StatusCode, VERSION, identity, + closureOnly, closureRecord, malformed, unknownTag, inlineLinks, notAnnotated +}; diff --git a/parser/src/test-data/javascript/categories/jsdoc/this-annotation.js b/parser/src/test-data/javascript/categories/jsdoc/this-annotation.js new file mode 100644 index 000000000..95c4c202c --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/this-annotation.js @@ -0,0 +1,38 @@ +// fixture: categories/jsdoc/this-annotation.js +// nature: runtime-bearing +// JsTypeReferenceContextKind.THIS — declared as "@this {T}, the receiver's +// declared type, which nothing else can state", and emitted zero times. +// +// This is the one JSDoc tag with no other channel. A parameter type has @param, a +// return has @returns, a field has @type — all three emit js_type_reference rows +// with contextKind PARAM / RETURN / FIELD. The receiver of a plain function has +// only @this, and in the prototype-era code where `this` is genuinely ambiguous +// it is the only declaration of the receiver's type anywhere in the program. +// +// The controls are in this file on purpose: @param and @returns on the SAME +// function do emit, so a run that shows PARAM and RETURN and no THIS is showing +// the tag and not the file. +// +// module system: CommonJS, governed by categories/package.json. +'use strict'; + +/** + * @param {string} name + * @returns {string} + */ +function Widget(name) { this.name = name; return name; } + +/** + * @this {Widget} + * @param {string} suffix + * @returns {string} + */ +function render(suffix) { return this.name + suffix; } + +/** @this {Widget} */ +function bareThis() { return this.name; } + +Widget.prototype.render = render; +Widget.prototype.bareThis = bareThis; + +module.exports = { Widget }; diff --git a/parser/src/test-data/javascript/categories/jsdoc/typedef-only.js b/parser/src/test-data/javascript/categories/jsdoc/typedef-only.js new file mode 100644 index 000000000..54697816a --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsdoc/typedef-only.js @@ -0,0 +1,121 @@ +// fixture: cjs/jsdoc/typedef-only.js +// module system: CommonJS (governing: staging/cjs/package.json, "type": "commonjs") +// nature: TYPE-ONLY. There is no executable statement in this file. Not one +// declaration, not one call, not one binding. Every type it declares exists +// ONLY as a comment. +// syntax floor: none — the file contains no JavaScript syntax at all beyond +// comments and a single `module.exports` free of value (see the last line and +// the note about it). +// +// js_type rows with evidenceKind = COMMENT_ONLY, declarationForm = JSDOC_TYPEDEF, +// isTypeOnly = true, and a startLine INSIDE a comment. 677 @typedef sites in the +// schema's corpus. +// +// This is the fixture the type-only gate is written against: if any js_call_site +// or js_expression row originates here, the parser is leaking type-only +// constructs into the call graph, and that is a bug rather than a corpus gap. +// The check can only mean that if the file says which it is, which is why the +// nature line above is not decoration. +// +// Grounded in the `types.js` / `typedefs.js` file that JSDoc-typed projects keep +// as a central type registry — a charting library, a media player and every +// Closure-annotated codebase have one. + +/** + * A single HTTP header, as a name/value pair. + * + * @typedef {Object} Header + * @property {string} name the header name, lowercased + * @property {string} value the header value, unparsed + * @property {boolean} [singleton] whether repeating it is an error + */ + +/** + * A parsed request. Composed entirely of other typedefs in this file, which is + * what makes the FK from one comment-declared type to another a normal FK. + * + * @typedef {Object} Request + * @property {string} method + * @property {string} url + * @property {Header[]} headers + * @property {Object} query + * @property {?Body} body nullable + * @property {!Socket} socket non-nullable (Closure syntax) + * @property {Request} [parent] recursive, and optional + */ + +/** + * A union typedef. The type is not an object at all. + * + * @typedef {string | Buffer | ReadableStream | null} Body + */ + +/** + * An intersection. + * + * @typedef {Request & { authenticated: true, user: User }} AuthenticatedRequest + */ + +/** + * A typedef whose right-hand side is a FUNCTION TYPE rather than a @callback. + * Both spellings exist and mean the same thing. + * + * @typedef {function(Request, Response): void} Middleware + */ + +/** + * A generic typedef. @template on a typedef is the only "type parameter" in + * JavaScript, and the schema deliberately gives it no relation of its own: + * it lives in js_type_reference with contextKind = TEMPLATE. + * + * @template T + * @typedef {{ ok: true, value: T } | { ok: false, error: Error }} Result + */ + +/** + * A typedef referring to a type declared in ANOTHER FILE, by import type + * syntax. This is a module edge that exists only inside a comment — the + * specifier is a string literal in a type position, and it resolves the same + * way any other specifier does. + * + * @typedef {import('./extends-implements.js').Emitter} ImportedEmitter + */ + +/** + * A typedef for a type that DOES NOT EXIST anywhere. Nothing declares + * `LegacyOptions`, in this file or any other. The honest row names the type and + * resolves nothing; there is no more evidence available. + * + * @typedef {LegacyOptions} StillUndeclared + */ + +/** + * A tuple, a record with numeric-ish keys, an array of unions, and a nested + * generic three deep — the type-expression TREE, which js_type_reference stores + * as one row per node rather than as a string. + * + * @typedef {[number, string, ...boolean[]]} Triple + * @typedef {Array>>} DeepNesting + * @typedef {Object} MixedMap + */ + +/** + * The remaining shapes: a rest parameter in a function type, an optional + * parameter, `*` (any), `?` (unknown), and a `this` type. + * + * @typedef {function(this:Request, string=, ...number): *} OddSignature + * @typedef {*} Anything + * @typedef {?} Unknown + */ + +/** + * An @enum. Closure's spelling of a constant set, and the values live in the + * object it annotates — which in this file is nowhere, so the enum has a type + * and no members. + * + * @enum {string} + */ + +// Deliberately NOT exported and deliberately no code. Adding +// `module.exports = {}` here would make the file runtime-bearing and destroy +// the only property it is here to prove. diff --git a/parser/src/test-data/javascript/categories/jsx/component-in-js.js b/parser/src/test-data/javascript/categories/jsx/component-in-js.js new file mode 100644 index 000000000..79dbc9016 --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsx/component-in-js.js @@ -0,0 +1,65 @@ +// fixture: jsx/component-in-js.js +// module system: CommonJS (governing: staging/jsx/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 + JSX, in a plain .js file +// +// JSX in .js. This is what scaffolded app output, every pre-2020 component +// codebase and most of the JSX in the schema's corpus (38 files, one small app) +// actually looks like on disk: the extension says nothing and the bundler is +// what knows. +// +// The schema's ruling is that scriptKind is recorded as PROVENANCE and never +// read from config, because ts.ScriptKind.JS already carries +// languageVariant = JSX and 0 of 2,942 files parsed differently. This file is +// the test of that claim from one side; the bottom section is the test from the +// other, because it contains `<` used as a COMPARISON in the same file as `<` +// used to open an element. +// +// If a parser ever needs the extension to decide, one of the two sections here +// breaks — and hasJsxContent, which the schema says is recorded AFTER parsing, +// is what a fixture can check that a config-derived flag cannot. + +'use strict'; + +const View = require('view-lib'); + +function Badge({ count, max }) { + // `<` opening JSX, on a line that also has arithmetic. + return {count > max ? max + '+' : count}; +} + +function List({ items, threshold }) { + // Comparison operators in the SAME FILE as JSX. `a < b` and ` item.weight < threshold); + const overflow = items.length > visible.length; + const ratio = visible.length / (items.length || 1); + + // A generic-looking comparison chain, which is the classic ambiguity: in + // .ts this parses as a type argument list and in .tsx it does not. In + // JavaScript there are no type arguments, so it is unambiguously comparison. + const chained = ratio < 1 && items.length > 0; + + return ( +
    + {visible.map((item) =>
  • )} + {overflow &&
  • …
  • } + {chained ?
  • : null} +
+ ); +} + +// A shift operator and a JSX element on adjacent lines. +const mask = 1 << 4; +const element = ; + +// An arrow whose entire body is JSX, with no parentheses. +const Inline = (props) => ; + +// JSX in a conditional expression, in an array, and as a default parameter — +// three positions where the element is not a statement. +const conditional = mask > 0 ? :
; +const collection = [,
]; +function withDefault(node =
) { return node; } + +module.exports = { Badge, List, Inline, element, conditional, collection, withDefault, mask }; diff --git a/parser/src/test-data/javascript/categories/jsx/component.jsx b/parser/src/test-data/javascript/categories/jsx/component.jsx new file mode 100644 index 000000000..8638ece52 --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsx/component.jsx @@ -0,0 +1,97 @@ +// fixture: jsx/component.jsx +// module system: CommonJS (governing: staging/jsx/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 + JSX (which is not ECMAScript at all — it is a syntax +// extension that no runtime executes; every line of it is compiled away) +// +// The .jsx extension is unambiguous. component-in-js.js is the same content in a +// .js file, which is what a pre-Vite View app actually ships, and the pair is +// the fixture: the schema's ruling is that scriptKind is PROVENANCE, never read +// from config, because ts.ScriptKind.JS already carries languageVariant = JSX +// and 0 of 2,942 corpus files parsed differently between JS and JSX. +// +// That ruling is only checkable against a file where `<` genuinely opens JSX and +// a file where it does not — see the comparison operators at the bottom of +// component-in-js.js. +// +// The extractor consequence, from TypeScript: a JSX BRACE produces no row of its +// own, so a subtree rooted at one dies before its children are enqueued. That +// cost 4,488 of one application's 14,335 call sites. Every call inside a brace below is +// there to make that failure visible. + +'use strict'; + +const View = require('view-lib'); +const { useValue, useSideEffect, useMemoizedFn } = require('view-lib'); + +function formatName(user) { + return user.first + ' ' + user.last; +} + +function Avatar({ user, size = 32 }) { + return {formatName(user)}; +} + +function UserCard({ user, onSelect, children }) { + const [expanded, setExpanded] = useValue(false); + const toggle = useMemoizedFn(() => setExpanded((v) => !v), []); + + useSideEffect(() => { + if (expanded) { onSelect(user.id); } + }, [expanded, user.id, onSelect]); + + return ( +
+ {/* A call inside a JSX brace, which is the case that vanished in TS */} +

{formatName(user)}

+ + {/* A component element: the tag name IS a call target */} + + + {/* Conditional rendering by && and by ternary */} + {expanded &&

{user.bio || 'no bio'}

} + {expanded ? : null} + + {/* A map with an arrow returning JSX — a function boundary inside a brace */} +
    + {(user.tags || []).map((tag) => ( +
  • {tag.toUpperCase()}
  • + ))} +
+ + {/* Spread attributes: the prop names are not in this file */} +
+ + {/* A fragment, both spellings */} + <> + short + + + long + + + {/* A namespaced/member component: the tag is a member expression */} + {children} + + {/* Text children, entities, and a self-closing tag with no attributes */} +

plain text & an entity — and {'a string expression'}

+
+
+ ); +} + +class ClassComponent extends View.Component { + render() { + const { items } = this.props; + return ( +
    + {items.map((item, index) =>
  • {this.renderItem(item)}
  • )} +
+ ); + } + renderItem(item) { + return this.props.onSelect(item)} />; + } +} + +module.exports = { Avatar, UserCard, ClassComponent, formatName }; diff --git a/parser/src/test-data/javascript/categories/jsx/package.json b/parser/src/test-data/javascript/categories/jsx/package.json new file mode 100644 index 000000000..c8c697ba4 --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsx/package.json @@ -0,0 +1,4 @@ +{ + "name": "javascript-categories-jsx", + "type": "commonjs" +} diff --git a/parser/src/test-data/javascript/categories/jsx/tag-forms.jsx b/parser/src/test-data/javascript/categories/jsx/tag-forms.jsx new file mode 100644 index 000000000..2d3cc981e --- /dev/null +++ b/parser/src/test-data/javascript/categories/jsx/tag-forms.jsx @@ -0,0 +1,159 @@ +// fixture: jsx/tag-forms.jsx +// module system: CommonJS (governing: staging/jsx/package.json, "type": "commonjs") +// nature: runtime-bearing +// syntax floor: ES2015 + JSX +// +// WHAT A JSX TAG NAME *IS*, which is the open schema question behind js-impl's +// recall finding that 833 JSX tag names emit nothing. +// +// The rule JSX actually uses is **syntactic, not semantic**, and it is not the +// one most people state: +// +// * a tag is a VALUE REFERENCE iff its name is a MEMBER EXPRESSION, or a +// simple identifier that is a VALID ECMASCRIPT IDENTIFIER not beginning with +// a lowercase ASCII letter. It compiles to the expression itself and +// resolves through the scope chain like any other name. +// * everything else that is a simple name is an INTRINSIC — it compiles to a +// STRING. That covers both `div` (lowercase) and `my-element` (hyphenated, +// so not an identifier at all), and it references no binding: a local named +// `div` in scope is not consulted. +// * a NAMESPACED name (`svg:circle`) is neither: it is a third form that most +// transforms reject outright. +// +// The validity clause is not pedantry. TypeScript reports `ts.isIdentifier()` +// TRUE for `my-element` AND for `Foo-Bar`, so a rule written as +// "isIdentifier && starts lowercase" gets `Foo-Bar` wrong — see the tag of that +// name below. +// +// So "capitalised = component" is a useful shorthand and a wrong rule. The two +// cases that break it are both below and both are common: +// +// `` lowercase, and a VALUE REFERENCE, because it is a member +// expression — the lowercase rule applies only to a bare +// identifier +// `<_Private />` not a capital letter, and still a value reference, because +// `_` is not a lowercase ASCII letter +// `` capitalised, and NOT a reference, because a hyphen makes it +// an invalid identifier — no such binding can exist +// +// This file puts every form in ONE file so the discrimination is forced rather +// than inferred across fixtures, and it discriminates whichever way js-oracle +// rules: if tag names become expression rows, the intrinsics must NOT resolve to +// a binding and the member forms MUST; if they stay silent, the count is zero +// and nothing here is emitted. Either ruling has something that can fail. +// +// Grounded in real View: `
` and `` are View-Bootstrap's +// documented API, `` is how compound components are namespaced, +// and `` appears in SVG-in-JSX written before the transform settled. + +'use strict'; + +const View = require('view-lib'); +const Button = require('./component').UserCard; +const Modal = { Header: Button, Body: { Inner: Button } }; +const widgets = { panel: Button, 'data-view': Button }; +const _Private = Button; +const $Dollar = Button; + +// A local binding named exactly like an intrinsic tag. If `
` below +// resolves to THIS, the rule has been implemented as resolution rather than as +// syntax — which is the single sharpest discriminator in the file. +const div = function ShadowingDiv() { return null; }; +const span = 'not a component either'; + +class Host { + constructor() { this.Slot = Button; } + render() { + // `this.Slot` is a member expression whose object is `this`. + return ; + } +} + +function AllTagForms({ user, ...rest }) { + return ( +
+ + {/* --- intrinsics: lowercase bare identifiers, compiled to STRINGS --- */} + text + link + +
+

heading

+ + {/* A hyphenated custom element. `-` cannot appear in an identifier, so + this can ONLY be a string — it is an intrinsic by construction. */} + + + + {/* CAPITALISED AND HYPHENATED. `ts.isIdentifier(tagName)` is TRUE here — + TypeScript gives `Foo-Bar` the node kind `Identifier` even though it is + not a valid ECMAScript identifier — and the first letter is uppercase. + So a rule implemented as "isIdentifier && starts lowercase = intrinsic" + classifies this as a REFERENCE, and there is no binding it could ever + refer to: `Foo-Bar` cannot be declared in JavaScript. It compiles to + the string "Foo-Bar", exactly as `my-element` does. Rare in practice + and the precise boundary of the rule, which is why it is here. */} + + + {/* --- value references: capitalised bare identifiers --- */} + +
+ + {/* --- attributes are never references --- */} + {/* `className`, `data-testid` and `xlink:href` are names in the source and + none of them resolves to anything. The SPREAD is the exception: `rest` + is a real read. */} +