Merge branch 'main' into latte233-fix2

This commit is contained in:
Gergő Magyar 2026-06-18 17:35:14 +01:00 • committed by GitHub
commit df95bd1a38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
186 changed files with 35552 additions and 1593 deletions

View file

@ -17,3 +17,9 @@ WEB_HOST_PORT=4173
# Optional read-only mount, exposed to the server as /workspace.
# Override with the directory that contains the repos you want to index.
WORKSPACE_DIR=./
# Azure DevOps Server Integration (passed to the server container)
# Prefer https:// — the PAT rides in an Authorization header, so cleartext
# http:// exposes it on the wire (still supported for internal-only instances).
# AZURE_DEVOPS_URL=https://azuredevops.example.com
# AZURE_DEVOPS_PAT=your-pat-here

View file

@ -39,6 +39,13 @@ _spec.loader.exec_module(readiness) # type: ignore[union-attr]
# format change that would silently break change-detection fails here. Group 2 is
# ONLY the Status cell ([^|]+? before the final `|$`).
_ROW_DIFF_RE = re.compile(r"\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
# Mirrors the scheduled issue-update summary extraction in
# tree-sitter-upgrade-readiness.yml. If the report prose changes again, the issue
# comment should not silently degrade to "?/? ready. ? blocker(s)".
_ISSUE_READY_RE = re.compile(
r"- (\d+)/(\d+) npm-installed grammars already accept tree-sitter@"
)
_ISSUE_BLOCKER_RE = re.compile(r"\*\*Blocked\*\* — (\d+) grammars? ")
def _physical_vendor_grammars() -> set[str]:
@ -291,6 +298,14 @@ class ReportRendering(TestCase):
for status in self.rows.values():
self.assertNotIn("|", status)
def test_issue_update_summary_regex_matches_current_report(self):
ready = _ISSUE_READY_RE.search(self.report)
blockers = _ISSUE_BLOCKER_RE.search(self.report)
self.assertIsNotNone(ready)
self.assertIsNotNone(blockers)
self.assertEqual(ready.groups(), ("9", "10"))
self.assertEqual(blockers.group(1), "2")
def _matrix_row(self, name: str) -> str:
for line in self.report.splitlines():
if line.startswith(f"| `{name}` |"):

View file

@ -276,6 +276,24 @@ jobs:
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
working-directory: gitnexus
- name: Emit-persistence throughput / byte-identity guards (#2203)
# Build-free: asserts streamAllCSVsToDisk output is byte-identical
# (order-independent CSV-line fingerprint — the #2203 U2/U3 emit
# optimisations must not change graph content) and that emit wall-time
# stays linear in node+edge count. The LadybugDB COPY half needs a real
# DB, so its timing lives in the runtime PROF_LBUG_LOAD breakdown.
run: node --import tsx bench/emit-persistence/measure.mjs --check
working-directory: gitnexus
- name: Streaming PDG-emit byte-identity / bounded-RSS guards (#2202)
# Build-free: asserts the streaming PdgEmitSink emits a CSV row SET
# byte-identical to the whole-graph streamAllCSVsToDisk emit, AND that
# the in-memory graph retains zero BasicBlock nodes (the O(chunk) peak-RSS
# bound that unblocks full-kernel-scale repos). Fails on fingerprint drift
# or any resident BasicBlock.
run: node --import tsx bench/emit-persistence/measure-streaming.mjs --check
working-directory: gitnexus
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
env:
GITNEXUS_BENCH: '1'

View file

@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
queries: security-and-quality
@ -65,10 +65,14 @@ jobs:
- 'gitnexus/src/core/parsing/**/parser.js'
# Test fixtures are intentionally synthetic inputs (broken/unused
# code, malformed samples) used to exercise the analyzer. CodeQL
# findings here are noise, not real bugs.
# findings here are noise, not real bugs. The second glob also
# covers fixtures nested deeper in the test tree, e.g.
# test/integration/cfg/fixtures/ (the CFG/PDG hazard inputs that
# deliberately contain use-before-init / unused-variable shapes).
- '**/test/fixtures/**'
- '**/test/**/fixtures/**'
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: '/language:${{ matrix.language }}'

View file

@ -53,7 +53,7 @@ jobs:
# No GITLEAKS_LICENSE secret is required for OSS / public-repo usage.
# If this repo becomes private, the action will require a license key.
- name: Gitleaks
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true

View file

@ -53,6 +53,6 @@ jobs:
retention-days: 5
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif

View file

@ -133,8 +133,8 @@ jobs:
const existing = open.find(i => i.title === title);
if (existing) {
// Extract ready/total count for the changelog comment.
const readyMatch = report.match(/\*\*(\d+)\/(\d+)\*\* grammars ready/);
const blockerMatch = report.match(/\*\*(\d+) blocker/);
const readyMatch = report.match(/- (\d+)\/(\d+) npm-installed grammars already accept tree-sitter@/);
const blockerMatch = report.match(/\*\*Blocked\*\* — (\d+) grammars? /);
const ready = readyMatch ? readyMatch[1] : '?';
const total = readyMatch ? readyMatch[2] : '?';
const blockers = blockerMatch ? blockerMatch[1] : '?';
@ -165,7 +165,7 @@ jobs:
}
const today = new Date().toISOString().slice(0, 10);
let comment = `**${today}:** ${ready}/${total} ready. ${blockers} blocker(s) remaining.`;
let comment = `**${today}:** ${ready}/${total} npm-installed ready. ${blockers} blocker(s) remaining.`;
if (changes.length > 0) {
comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n');
} else {

View file

@ -76,7 +76,7 @@ jobs:
exit-code: '0'
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-${{ matrix.image.name }}.sarif
category: trivy-${{ matrix.image.name }}

View file

@ -76,7 +76,7 @@ jobs:
continue-on-error: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: zizmor.sarif
category: zizmor

View file

@ -3,10 +3,17 @@ title = "GitNexus"
[extend]
useDefault = true
# Fake embedding API keys in unit tests (current probe + historical placeholder).
# Fake credentials in unit tests — none are real secrets:
# - embedding API keys in the http-embedder tests (regexes below)
# - synthetic GitHub PAT fixtures in the git-clone PAT-injection tests
# (e.g. ghp_secret123, ghp_uniqueRawSecret_98765) — allowlisted by path
# so the exception is bounded to that one test file.
[allowlist]
description = "fake embedding API keys in http-embedder unit tests"
description = "fake credentials in unit tests (no real secrets)"
regexes = [
'''secret-key-12345''',
'''test-api-key-redaction-check''',
]
paths = [
'''gitnexus/test/unit/git-clone\.test\.ts''',
]

View file

@ -324,6 +324,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |

204
eval/uv.lock generated
View file

@ -21,7 +21,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.0"
version = "3.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@ -33,108 +33,108 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" },
{ url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" },
{ url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" },
{ url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" },
{ url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" },
{ url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" },
{ url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" },
{ url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" },
{ url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" },
{ url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" },
{ url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" },
{ url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" },
{ url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" },
{ url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" },
{ url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" },
{ url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" },
{ url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" },
{ url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" },
{ url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" },
{ url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" },
{ url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" },
{ url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" },
{ url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" },
{ url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" },
{ url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" },
{ url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" },
{ url = "https://files.pythonhosted.org/packages/21/61/d11f7d9a3144bffe825247d6367cd93053666da50b94707c9129c78868d5/aiohttp-3.14.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:25400d710641a8040bf022a8a99f579e581ffa1c5bd42c33255d7d6f3957c127", size = 502399, upload-time = "2026-06-01T19:38:25.955Z" },
{ url = "https://files.pythonhosted.org/packages/4f/9b/a7e317625d36356844f8bb022cabd305b541f968856cc3c2e0b58e53ee6e/aiohttp-3.14.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c5492b9929826e07cc3fcb9739ae87aab05dff6b5e67a9b73fd1700c6d008981", size = 510068, upload-time = "2026-06-01T19:38:27.828Z" },
{ url = "https://files.pythonhosted.org/packages/11/41/cc2d2cfbfbdc3126ba258f3cd27d1ac8a33492ae3c35a4583ee21f0ba7f1/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3366751d68d237c621264233a32f3078bbc21b7904ab90a77e03d21390c742c6", size = 481670, upload-time = "2026-06-01T19:38:29.836Z" },
{ url = "https://files.pythonhosted.org/packages/3c/07/381f4023c3b08cb616e520f566d8c58957abad54e56441d41fe67cfb0195/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:57ea07d28695a7a40304d42251892a8df765e5588c10ee32afeddcd5df33c0a2", size = 487591, upload-time = "2026-06-01T19:38:31.704Z" },
{ url = "https://files.pythonhosted.org/packages/fb/4d/4506fdb7a022bdf70011a3bbb4ca00c5c570026ef6a3c5bd7bc70c39089c/aiohttp-3.14.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:076cb014191ae2e65d949e1ad01f1dcfe33e32789b5172510f3e79c79fc04d50", size = 496503, upload-time = "2026-06-01T19:38:33.6Z" },
{ url = "https://files.pythonhosted.org/packages/ef/7d/c814111e04894a45d9e2defc94443879a6f118d9633d5fedfe6e2e8af5f0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f3fc37054564dee64a855b5b092d87ec35dcddfaabf7dacb1c8a2b1f83dc0a9", size = 745870, upload-time = "2026-06-01T19:38:36.013Z" },
{ url = "https://files.pythonhosted.org/packages/c6/ee/80eee0efddfe187e7cd05027086b7ce1c0e492e82a4eda58f5c5543a44a0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8fcaef74d2ab0f607d7ff85a0d15e21bb5a258c4a58df1908396eb50d7f4ed3c", size = 505588, upload-time = "2026-06-01T19:38:38.282Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f8/0f28f04eef75d52fc9c715dde7ce9c0abb810fd20cfeb0fea7afd2ab1e98/aiohttp-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4c01b0bfc6209590960e68eac083cd22d5d87c21f974dd6208cafa5d3542bc8", size = 504492, upload-time = "2026-06-01T19:38:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/ff/db/44c755232085545065c94378dfce38641b1aee647f4939fcd32f5b32e719/aiohttp-3.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f12eb7896e81caf403a2b18c9406426f1207361e7239c057ab29c076d4257e83", size = 1752111, upload-time = "2026-06-01T19:38:42.682Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6a/42e030a46743841414402a3b00cd3d78419055e86c66fb5822c14b5abfc6/aiohttp-3.14.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c79a044cacf360ec46738d863d2f41c9300d2a06ef4a7402ea0df306a350e61", size = 1729674, upload-time = "2026-06-01T19:38:44.79Z" },
{ url = "https://files.pythonhosted.org/packages/34/26/3199beb415202e3108e7b83ecebe10914d806d33fb9860c3e4aa60a19be3/aiohttp-3.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85e0675f47be4eff0636bf88c02140ea89168ae0df3ff1f3f464e9de9610d277", size = 1798808, upload-time = "2026-06-01T19:38:47.01Z" },
{ url = "https://files.pythonhosted.org/packages/bd/94/b9b6fcf0ee17c21d0d19fb8c22bf83ad18f82e702a9c3bd901a868f5e446/aiohttp-3.14.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b33e751cab03fdc960095b1e326cb5a03f5ee577d6ded59f3d1c100f8668882", size = 1891921, upload-time = "2026-06-01T19:38:49.233Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a3/3800dbd095cb2bb165a7ea5d94d790914677e27f45638c7d80e3f34c8945/aiohttp-3.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9224c6dd7f5c749aba4f61315a894601448b28d94d12f4dea0903e26d2096", size = 1777241, upload-time = "2026-06-01T19:38:52.04Z" },
{ url = "https://files.pythonhosted.org/packages/21/2a/45be91ad1b860508557448d4cc2e165a2ee68dd865657b73bf66cc5a00fb/aiohttp-3.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6281aecdf2732940f4fe06bd6adec5ae4d59b78b080b8e3a6b81467301010988", size = 1579554, upload-time = "2026-06-01T19:38:54.508Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/dc94df99ed1511fdf28314f722643ed334112643cab00223577085e788c4/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23e8314e7aed8576fbe33314d218bd81447a3adbc91dc36f1163bf583cd3084c", size = 1714864, upload-time = "2026-06-01T19:38:56.788Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e4/1f1c8acbb3acd5c8f795473b92c9c3d44eb60a5692c6104256c8a1c83a0c/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3b54fbff46127aeafdd764cecd0d99fa2f24a0e37ea5c18a7c3a4ac450df1db3", size = 1749803, upload-time = "2026-06-01T19:38:59.367Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c8/c45ea6e7ed84cebba939b9c334498a045ba19d79c61b0110df5f21580de3/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b27d89af91a555f58e08e4902dbcbc48862fd40095720ca705990476bd93b7ac", size = 1765023, upload-time = "2026-06-01T19:39:01.651Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a1/a932941784432962fe390e1066823aaef64b4e5ac9fa595df57b5fe472a9/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:25d2326a4967bf705a9f9913a13005e93b6020ad8a9f6bd6bd78850d5171332e", size = 1571671, upload-time = "2026-06-01T19:39:04.044Z" },
{ url = "https://files.pythonhosted.org/packages/b0/01/e1280feac522597a4d46eb67a0cdfa053cfae263033030b761ab146f29fb/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1d209375c503472b3c0a340cdf3c55fcd82e84b46dda7caeaced59faba373ec", size = 1789904, upload-time = "2026-06-01T19:39:06.294Z" },
{ url = "https://files.pythonhosted.org/packages/fa/10/ab28818262f4d26bdb47ed5f1fc7999b69e2fc6e0370b02d0f49011f45ea/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:666c7c5036df57b693026398b69b41874a1931ac5b3485fd910e57bfac253869", size = 1754516, upload-time = "2026-06-01T19:39:08.788Z" },
{ url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" },
{ url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" },
{ url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" },
{ url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" },
{ url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" },
{ url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" },
{ url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" },
{ url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" },
{ url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" },
{ url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" },
{ url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" },
{ url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" },
{ url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" },
{ url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" },
{ url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" },
{ url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" },
{ url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" },
{ url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" },
{ url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" },
{ url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" },
{ url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" },
{ url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" },
{ url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" },
{ url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" },
{ url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" },
{ url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" },
{ url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" },
{ url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" },
{ url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" },
{ url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" },
{ url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" },
{ url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" },
{ url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" },
{ url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" },
{ url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" },
{ url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
{ url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
{ url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
{ url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
{ url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
{ url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
{ url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
{ url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
{ url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
]
[[package]]

View file

@ -32,7 +32,17 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Python]: ['.py'],
[SupportedLanguages.Java]: ['.java'],
[SupportedLanguages.C]: ['.c'],
[SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
[SupportedLanguages.CPlusPlus]: [
'.cpp',
'.cc',
'.cxx',
'.h',
'.hpp',
'.hxx',
'.hh',
'.cu',
'.cuh',
],
[SupportedLanguages.CSharp]: ['.cs'],
[SupportedLanguages.Go]: ['.go'],
[SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'],

View file

@ -20,6 +20,8 @@ export interface ParameterTypeClass {
indirection: 'value' | 'lvalue-ref' | 'rvalue-ref' | 'pointer' | 'unknown';
/** Number of pointer markers when indirection is `pointer`; otherwise 0. */
pointerDepth: number;
/** Normalized top-level template arguments, when a language preserves them. */
templateArguments?: string[];
}
export interface SymbolDefinition {

File diff suppressed because it is too large Load diff

View file

@ -21,14 +21,14 @@
"@langchain/anthropic": "^1.3.29",
"@langchain/core": "^1.1.44",
"@langchain/google-genai": "^2.1.30",
"@langchain/langgraph": "^1.3.2",
"@langchain/ollama": "^1.2.6",
"@langchain/langgraph": "^1.4.1",
"@langchain/ollama": "^1.2.7",
"@langchain/openai": "^1.4.5",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"d3": "^7.9.0",
"dompurify": "^3.4.8",
"dompurify": "^3.4.11",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@ -40,12 +40,12 @@
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.4",
"lru-cache": "^11.2.4",
"lucide-react": "^1.16.0",
"lucide-react": "^1.17.0",
"mermaid": "^11.15.0",
"mnemonist": "^0.39.0",
"mnemonist": "^0.40.4",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.6",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
@ -73,7 +73,7 @@
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^8.0.11",
"vite": "^8.0.16",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
},

View file

@ -3,7 +3,7 @@
*
* The "empty state" card rendered inside DropZone's Crossfade when the server
* is connected but zero repos are indexed. Replaces the generic error message
* with a first-class GitHub URL input flow.
* with a first-class repository URL input flow.
*
* Rendering context:
* DropZone (Crossfade, phase="analyze")
@ -15,7 +15,7 @@
* the app to the graph explorer.
*/
import { Sparkles, Github } from '@/lib/lucide-icons';
import { Sparkles, GitBranch } from '@/lib/lucide-icons';
import { RepoAnalyzer } from './RepoAnalyzer';
import { useTranslation } from 'react-i18next';
@ -46,7 +46,7 @@ export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
{/* Icon */}
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
<Github className="h-7 w-7 text-accent" />
<GitBranch className="h-7 w-7 text-accent" />
</div>
<h2 className="text-lg leading-snug font-semibold text-text-primary">

View file

@ -10,12 +10,14 @@ import { useState, useRef, useEffect, useId } from 'react';
import {
Github,
Gitlab,
AzureDevops,
FolderOpen,
Loader2,
Check,
ArrowRight,
AlertCircle,
Sparkles,
Key,
} from '@/lib/lucide-icons';
import {
startAnalyze,
@ -30,10 +32,14 @@ import { useTranslation } from 'react-i18next';
// ── Helpers ──────────────────────────────────────────────────────────────────
type InputMode = 'github' | 'gitlab' | 'local';
type InputMode = 'github' | 'gitlab' | 'azure' | 'local';
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
const GITLAB_RE = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+(\/.*)?$/i;
// One-or-more path segments before `/_git/`, so the legacy single-project
// cloud form (myorg.visualstudio.com/project/_git/repo) is accepted too —
// the backend already supports it (isAzureDevOpsUrl / extractRepoName).
const AZURE_RE = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+_git\/[^/\s]+/i;
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
function isValidGithubUrl(value: string): boolean {
@ -44,6 +50,10 @@ function isValidGitlabUrl(value: string): boolean {
return GITLAB_RE.test(value.trim());
}
function isValidAzureUrl(value: string): boolean {
return AZURE_RE.test(value.trim());
}
// ── Mode tabs ────────────────────────────────────────────────────────────────
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
@ -81,6 +91,19 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
<Gitlab className="h-3 w-3" />
{t('repoAnalyzer.gitlabUrl')}
</button>
<button
role="tab"
aria-selected={mode === 'azure'}
onClick={() => onChange('azure')}
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
mode === 'azure'
? 'bg-accent text-white shadow-sm'
: 'text-text-muted hover:text-text-secondary'
} `}
>
<AzureDevops className="h-3 w-3" />
{t('repoAnalyzer.azureDevOpsUrl')}
</button>
<button
role="tab"
aria-selected={mode === 'local'}
@ -173,7 +196,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
null,
);
const [githubUrl, setGithubUrl] = useState('');
const [githubToken, setGithubToken] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('');
const [azureUrl, setAzureUrl] = useState('');
const [localPath, setLocalPath] = useState('');
const [phase, setPhase] = useState<InternalPhase>('input');
const [validationError, setValidationError] = useState<string | null>(null);
@ -239,7 +264,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
invalidateRequest();
setMode(m);
setGithubUrl('');
setGithubToken('');
setGitlabUrl('');
setAzureUrl('');
setLocalPath('');
setValidationError(null);
setUploadSummary(null);
@ -259,7 +286,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
: mode === 'gitlab'
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
: mode === 'azure'
? isValidAzureUrl(azureUrl) && (phase === 'input' || phase === 'error')
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
const handleAnalyze = async () => {
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
@ -270,6 +299,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
setValidationError('Please enter a valid GitLab repository URL.');
return;
}
if (mode === 'azure' && !isValidAzureUrl(azureUrl)) {
setValidationError(t('errors:invalidAzureDevOpsUrl'));
return;
}
if (mode === 'local' && localPath.trim().length < 2) {
setValidationError(t('errors:missingFolderPath'));
return;
@ -285,10 +318,15 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
try {
const request =
mode === 'github'
? { url: githubUrl.trim() }
? {
url: githubUrl.trim(),
...(githubToken.trim() ? { token: githubToken.trim() } : {}),
}
: mode === 'gitlab'
? { url: gitlabUrl.trim() }
: { path: localPath.trim() };
: mode === 'azure'
? { url: azureUrl.trim() }
: { path: localPath.trim() };
const { jobId } = await startAnalyze(request);
// Stale resolution: return without cancelling — URL jobIds may be
// dedup-aliased to a job another session owns (see cancelStaleUploadJob).
@ -299,7 +337,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
? githubUrl.trim()
: mode === 'gitlab'
? gitlabUrl.trim()
: localPath.trim();
: mode === 'azure'
? azureUrl.trim()
: localPath.trim();
trackJob(jobId, nameSource);
} catch (err) {
// Unmount aborts the controller, so this also covers the unmounted case.
@ -327,6 +367,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
: undefined) ??
t('onboarding:repoAnalyzer.defaultRepoName');
setCompletedRepoName(name);
setGithubToken('');
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
@ -396,6 +437,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
} catch {}
jobIdRef.current = null;
}
setGithubToken('');
setPhase('input');
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
setUploading(false);
@ -460,6 +502,39 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
</div>
)}
</div>
{/* Optional GitHub Personal Access Token for private repos */}
<div className="space-y-1.5 pt-1">
<label
htmlFor={`${inputId}-token`}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
{t('onboarding:repoAnalyzer.githubTokenLabel')}
</label>
<div className="flex items-center gap-3 rounded-xl border border-border-default bg-void px-4 py-3 transition-all duration-200 focus-within:border-accent/40">
<Key className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={`${inputId}-token`}
type="password"
value={githubToken}
onChange={(e) => setGithubToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit && !isLoading) {
e.preventDefault();
handleAnalyze();
}
}}
disabled={isLoading}
placeholder={t('onboarding:repoAnalyzer.githubTokenPlaceholder')}
autoComplete="off"
spellCheck={false}
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
/>
</div>
<p className="text-xs text-text-muted">
{t('onboarding:repoAnalyzer.githubTokenHelp')}
</p>
</div>
</div>
)}
@ -516,6 +591,61 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
</div>
)}
{/* Azure DevOps URL input */}
{showInput && mode === 'azure' && (
<div className="space-y-2">
<label
htmlFor={inputId}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
{t('onboarding:repoAnalyzer.azureDevOpsRepositoryUrl')}
</label>
<div
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
validationError && phase === 'error'
? 'border-red-500/50'
: isValidAzureUrl(azureUrl)
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
} `}
>
<AzureDevops className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={inputId}
type="url"
value={azureUrl}
onChange={(e) => {
setAzureUrl(e.target.value);
if (validationError) setValidationError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit && !isLoading) {
e.preventDefault();
handleAnalyze();
}
}}
disabled={isLoading}
placeholder="http://azuredevops.example.com/Collection/Project/_git/Repo"
autoComplete="url"
spellCheck={false}
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
/>
{azureUrl.length > 10 && (
<div className="shrink-0">
{isValidAzureUrl(azureUrl) ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
)}
</div>
)}
</div>
<p className="text-xs text-text-muted">
{t('onboarding:repoAnalyzer.azureDevOpsSupported')}
</p>
</div>
)}
{/* Local folder input */}
{showInput && mode === 'local' && (
<div className="space-y-2">

View file

@ -185,6 +185,41 @@ export const Gitlab = forwardRef<SVGSVGElement, LucideProps>(function Gitlab(
);
});
/**
* Azure DevOps mark — SVG path data from simple-icons (CC0-1.0).
*
* The Azure DevOps logo is a registered trademark of Microsoft Corporation.
* We use it here only to indicate Azure DevOps source-repo integration.
*
* API-compatible with `lucide-react` icons (`LucideProps`).
*/
export const AzureDevops = forwardRef<SVGSVGElement, LucideProps>(function AzureDevops(
{
size = 24,
color = 'currentColor',
className,
strokeWidth: _strokeWidth,
absoluteStrokeWidth: _absoluteStrokeWidth,
...rest
},
ref,
) {
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill={color}
className={className}
{...rest}
>
<path d="M0 8.877L2.247 5.91l8.405-3.416V.022l7.37 5.393L2.966 8.338v8.225L0 15.707zm24-4.45v14.651l-5.753 4.9-9.303-3.057v3.056l-5.978-7.416 15.057 1.798V5.415z" />
</svg>
);
});
export const Github = forwardRef<SVGSVGElement, LucideProps>(function Github(
{
size = 24,

View file

@ -6,6 +6,7 @@
"analysisFailed": "Analysis failed. Check server logs.",
"startAnalysisFailed": "Failed to start analysis",
"invalidGithubUrl": "Please enter a valid GitHub repository URL.",
"invalidAzureDevOpsUrl": "Please enter a valid Azure DevOps repository URL.",
"missingFolderPath": "Please enter a folder path.",
"backend": {
"reconnecting": "Server connection lost. Reconnecting…",

View file

@ -31,7 +31,7 @@
},
"analyzeFirst": {
"title": "Analyze your first repository",
"description": "Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live knowledge graph — right in your browser.",
"description": "Paste a repository URL and GitNexus will clone it, parse the code, and build a live knowledge graph — right in your browser.",
"footer": "Public repos only · Cloned locally by the server · No data leaves your machine"
},
"landing": {
@ -51,6 +51,7 @@
"inputType": "Input type",
"githubUrl": "GitHub URL",
"gitlabUrl": "GitLab URL",
"azureDevOpsUrl": "Azure DevOps",
"localFolder": "Local Folder",
"starting": "Starting analysis...",
"analyzeRepository": "Analyze Repository",
@ -58,8 +59,13 @@
"loadingGraph": "Loading graph...",
"defaultRepoName": "repository",
"githubRepositoryUrl": "GitHub Repository URL",
"githubTokenLabel": "Personal Access Token (optional)",
"githubTokenPlaceholder": "ghp_… or github_pat_…",
"githubTokenHelp": "Required for private repos. Needs the 'repo' (or fine-grained Contents:read) scope. Sent once, not stored.",
"gitlabRepositoryUrl": "GitLab Repository URL",
"gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.",
"azureDevOpsRepositoryUrl": "Azure DevOps Repository URL",
"azureDevOpsSupported": "Format: https://dev.azure.com/organization/project/_git/repository",
"localFolderPath": "Local Folder Path",
"hideBackground": "Hide (analysis continues in background)",
"upload": {

View file

@ -6,6 +6,7 @@
"analysisFailed": "分析失败,请检查服务器日志。",
"startAnalysisFailed": "启动分析失败",
"invalidGithubUrl": "请输入有效的 GitHub 仓库 URL。",
"invalidAzureDevOpsUrl": "请输入有效的 Azure DevOps 仓库 URL。",
"missingFolderPath": "请输入文件夹路径。",
"backend": {
"reconnecting": "服务器连接已断开,正在重连…",

View file

@ -31,7 +31,7 @@
},
"analyzeFirst": {
"title": "分析你的第一个仓库",
"description": "粘贴 GitHub URL,GitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
"description": "粘贴仓库 URL,GitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
"footer": "仅支持公开仓库 · 服务器本地克隆 · 数据不会离开你的机器"
},
"landing": {
@ -51,6 +51,7 @@
"inputType": "输入类型",
"githubUrl": "GitHub URL",
"gitlabUrl": "GitLab URL",
"azureDevOpsUrl": "Azure DevOps",
"localFolder": "本地文件夹",
"starting": "正在启动分析...",
"analyzeRepository": "分析仓库",
@ -58,8 +59,13 @@
"loadingGraph": "正在加载图数据...",
"defaultRepoName": "仓库",
"githubRepositoryUrl": "GitHub 仓库 URL",
"githubTokenLabel": "个人访问令牌(可选)",
"githubTokenPlaceholder": "ghp_… 或 github_pat_…",
"githubTokenHelp": "私有仓库需要此项。需 'repo' 范围(或细粒度 Contents:read)。仅发送一次,不会保存。",
"gitlabRepositoryUrl": "GitLab 仓库 URL",
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
"azureDevOpsRepositoryUrl": "Azure DevOps 仓库 URL",
"azureDevOpsSupported": "格式: http://server/Collection/Project/_git/Repository",
"localFolderPath": "本地文件夹路径",
"hideBackground": "隐藏(分析继续在后台进行)",
"upload": {

View file

@ -854,6 +854,7 @@ export const startAnalyze = async (request: {
path?: string;
force?: boolean;
embeddings?: boolean;
token?: string;
}): Promise<{ jobId: string; status: string }> => {
const response = await fetchWithTimeout(
`${_backendUrl}/api/analyze`,

View file

@ -10,3 +10,13 @@
# Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI.
# See README for details.
# Azure DevOps Server (Self-Hosted) Integration
# Base URL of your Azure DevOps Server instance. Prefer https:// — the PAT is
# sent in an Authorization header, so cleartext http:// exposes it on the wire
# (http:// is still supported for internal-only instances; the server warns).
# AZURE_DEVOPS_URL=https://azuredevops.example.com
# Personal Access Token with Code (Read) scope for cloning private repos.
# Used for both self-hosted and cloud (dev.azure.com) Azure DevOps.
# AZURE_DEVOPS_PAT=your-pat-here

View file

@ -29,8 +29,25 @@
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 10.0,
"_note": "#2082 M2: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The overlay design measures rd ~5.2 normalized: the OUT spine copy on genning blocks is O(V) per block, which is quadratic when V scales with B (bounded in prod by maxFunctionLines; real functions have V~10-40). Budget 10 deliberately tolerates that known shape and exists to catch the repo's recurring per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16). If rd drops well below 5, tighten."
"rd_scaling_budget": 2.0,
"_note": "#2082 M2 / #2201 SSA: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The dense GEN/KILL worklist measured rd ~5.2 normalized here (the OUT spine copy is O(V) per block, quadratic when V scales with B). The #2201 SSA-sparse solver answers each use's reaching set from the def-use graph WITHOUT a per-block dense lattice, dropping rd to ~0.86 (linear; measured 5-23x faster absolute). Budget tightened 10->2: still absorbs noise + catches a regression to the per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16), but now also catches a fall-back to the dense quadratic. Fingerprint unchanged -- CFG construction is untouched."
},
"deep-nest": {
"fingerprint": "c0ca870487abc6ff379304c3162003e9e4f9b44aeb2fc29adfcf8d2179c7613a",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"rd_scaling_budget": 2.0,
"facts_large_min": 150,
"_note": "#2201: N nested loops carrying ONE variable end-to-end (depth 40->160) -- the pathology the dense worklist is superlinear on and whose block-visit total drives it past the blocks×64 ceiling (it would TRUNCATE to empty). rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget) to prove the ceiling stops firing: the depth-INDEPENDENT SSA solver (phi-nodes capture loop merges statically; no fixpoint iteration) computes the full facts (measured 164 at large) with rd_scaling ~0.68 (linear in depth; measured ~0.57ms at depth 160). facts_large_min tightened 100->150 (#2201 review R7): a partial-truncation regression that still cleared the old floor of 100 (but lost facts of the measured 164) now fails, with ~9% headroom under 164 for noise; the companion rd_all_computed gate also catches any non-'computed' status. rd_scaling_budget 2.0 catches a regression back to superlinear. No heap_budget -- the deep-nest CFG payload is tiny and the retained-heap delta is GC-noise-dominated. Re-baseline the fingerprint only on an intentional CFG/visitor change."
},
"wide-merge": {
"fingerprint": "7a66a844ee3994bd930c1e34bad3d7b410a762220e927c94fb3787f38d745280",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"facts_large_min": 24000,
"_note": "#2201 review R7: N bindings, EACH assigned in a 3-way branch (a wide multi-operand phi per binding) inside a loop, then all used after the merge. Distinct from dense-bindings (one CHAINED redef per `if`): every binding fans into its OWN wide phi, so this exercises phi-placement + renaming + the reachByScc condensation across MANY independent wide merges. N bindings x constant arms => O(N) facts (measured 26008 at the large size), so the gate is rd_scaling LINEARITY: measured ~1.07 (time 9.3->39.8ms over the 4x size step); budget 2.0 catches a regression to the per-binding-rescan O(N^2) class -- the recurring solver antipattern the reachByScc alias fast path (review R2) guards against. rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget): all functions report 'computed' (the SSA path does not truncate here), and facts_large_min 24000 (measured 26008, ~7% headroom) + the rd_all_computed gate assert the wide merges compute fully. fp_blocks 82 / fp_edges 112 at FP_SIZE=15. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change."
},
"fact-fanout": {
"fingerprint": "83a8243a8aff117f69aeecb39d02a483e6cca70439d75f63e433f4e4ac85578f",
@ -53,5 +70,13 @@
"taint_reason_bytes_large_max": 198000,
"taint_zero_match_budget": 0.5,
"_note": "#2083 M3 U7 (R10): N functions, each with 12 req.body sources + a 4-hop chain + 13 eval sinks (13 deduped findings/fn) at 125->500 fns; the zero-match control (inp.payload/evalish) keeps the identical CFG shape with zero model hits. BOUNDEDNESS pin: kept findings/function == 8 (the scenario cap) at BOTH sizes -- above means the cap was lost, below means detection regressed; total findings grow linearly with N by design. disk_bytes_large_max is the LOAD-BEARING site-harvest absolute ceiling (densest sites of the suite; measured 2335772 at N=500, ceiling ~1.35x). taint_reason_bytes_large_max caps the persisted TAINTED reason bytes (measured 146827 = ~37 B/finding, ceiling ~1.35x; blows on hop-encoding bloat or cap loss). taint_zero_match_budget 0.5 vs measured 0.15: the zero-match pass (match gate only, no solver) must stay a small fraction of the match-dense pass. taint scaling measured ~0.93 (per-function work is N-linear); time/disk/heap/rd ratios all ~1.0."
},
"go:branchy": {
"fingerprint": "bba6ad5452c64125daa1dec4cf25e5e111692748a4ef30f309c9b0e03b3e5017",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2195 U7: the first NON-TS scaling scenario -- the C-family analogue of `branchy`, driven through the Go grammar + Go CFG visitor (lang:'go'). ONE Go function with N sequential `if`s (block/edge growth in a single CFG). The `go:` key namespace keeps it out of the TS baseline keyspace (no collision/re-baseline of a TS scenario). Measured time ~1.08, disk ~1.03, heap ~1.0, rd ~1.06 (budgets mirror the TS `branchy` scenario: scaling 1.8 absorbs single-CFG noise + catches a ~4.0 quadratic). Cross-check: fp_blocks 32 / fp_edges 46 are IDENTICAL to the TS branchy fingerprint shape -- the Go visitor builds the same per-`if` block/edge topology. CFG-only (Go has no registered taint model), so no taint gates. Re-baseline the fingerprint only on an intentional Go CFG/harvest-shape change."
}
}

View file

@ -42,12 +42,16 @@ import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts';
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.ts';
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.ts';
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
import {
DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS,
} from '../../src/core/ingestion/cfg/emit.ts';
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
import { getProvider } from '../../src/core/ingestion/languages/index.ts';
import { SupportedLanguages } from '../../src/config/supported-languages.ts';
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.ts';
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.ts';
import {
@ -59,12 +63,53 @@ import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const visitor = createTypeScriptCfgVisitor();
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
// Large synthetic sources exceed tree-sitter's default read buffer; size it
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
// ---- per-language registry (the U1 parameterization, #2195) ----
//
// A scenario names a `lang` (default 'ts'); the registry resolves its grammar,
// CFG visitor, and (optional) taint model GENERICALLY — the grammar via the
// production `getLanguageGrammar` loader and the visitor via the provider's
// `cfgVisitor` hook (the same seam `cfg-snapshot.test.ts` uses). No language is
// named in the bench logic itself: adding a language is one row here, not a new
// static grammar import (the language-naming anti-pattern). Lazy by design —
// only languages actually referenced by a scenario are loaded, so a missing
// optional grammar never breaks an unrelated run.
//
// - `grammar` — SupportedLanguages enum value for `getLanguageGrammar`.
// - `taintModel` — source/sink config threaded into the taint pass. ONLY the
// TS row carries `TS_JS_TAINT_MODEL`; C-family rows have no
// model (matching prod: `getSourceSinkConfig(<c-lang>)` is
// `undefined`), so the TS model never runs against a
// C-family CFG.
const LANGS = {
ts: { grammar: SupportedLanguages.TypeScript, taintModel: TS_JS_TAINT_MODEL },
go: { grammar: SupportedLanguages.Go, taintModel: null },
java: { grammar: SupportedLanguages.Java, taintModel: null },
c: { grammar: SupportedLanguages.C, taintModel: null },
cpp: { grammar: SupportedLanguages.CPlusPlus, taintModel: null },
csharp: { grammar: SupportedLanguages.CSharp, taintModel: null },
};
// Lazily build + cache one { parser, visitor, parse, taintModel } toolkit per
// language id. The parser is created once and reused across parses/reps for that
// language (parse cost is isolated from CFG-build cost by reusing the tree).
const langToolkitCache = new Map();
function langToolkit(langId) {
const cached = langToolkitCache.get(langId);
if (cached) return cached;
const spec = LANGS[langId];
if (!spec) throw new Error(`bench: unknown lang '${langId}' (add a row to LANGS)`);
const visitor = getProvider(spec.grammar).cfgVisitor;
if (!visitor)
throw new Error(`bench: provider for '${langId}' has no cfgVisitor (visitor not wired?)`);
const parser = new Parser();
parser.setLanguage(getLanguageGrammar(spec.grammar));
// Large synthetic sources exceed tree-sitter's default read buffer; size it
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
const toolkit = { visitor, parse, taintModel: spec.taintModel };
langToolkitCache.set(langId, toolkit);
return toolkit;
}
// ---- synthetic generators (one cost dimension each) ----
@ -130,6 +175,56 @@ const SCENARIOS = [
return s + ' c = c - 1;\n }\n return v0;\n}\n';
},
},
{
name: 'deep-nest',
// #2201: N nested loops carrying one variable end-to-end — the pathology the
// dense GEN/KILL worklist is superlinear on and that drives its block-visit
// total past the blocks×64 ceiling (it would truncate to an empty result).
// The production SSA solver is depth-INDEPENDENT (φ-nodes capture the loop
// merges statically; no fixpoint iteration), so rd time scales ~linearly
// with depth and the ceiling never fires. Two gates: rd_scaling_budget
// catches a regression back to superlinear, and facts_large_min asserts the
// solver still COMPUTES full facts under the PRODUCTION blocks×64 budget
// (rdProductionBudget) — a dense worklist would report zero facts here.
small: 40,
large: 160, // 4×, well under the visitor's recursive-nesting depth guard
rdMaxFacts: 0, // measure the algorithm, not the cap
rdProductionBudget: true, // pass blocks×64 — the SSA solver must still compute
gen: (n) => {
let s = 'function f(c: number) {\n let x = 0;\n';
for (let i = 0; i < n; i++) s += ' '.repeat(i + 1) + `while (c > ${i}) {\n`;
s += ' '.repeat(n + 1) + 'x = x + 1;\n';
for (let i = n - 1; i >= 0; i--) s += ' '.repeat(i + 1) + '}\n';
return s + ' return x;\n}\n';
},
},
{
name: 'wide-merge',
// #2201 review R7: N bindings, each assigned in a 3-way branch (a WIDE φ
// merge per binding) inside a loop, then all used after the merge. Unlike
// dense-bindings (one chained redef per `if`), every binding here fans into
// its own multi-operand φ — so the scenario stresses φ-placement + renaming +
// the reachByScc condensation across MANY independent wide merges. N bindings
// × constant arms ⇒ O(N) facts, so the gate is rd_scaling LINEARITY: a
// regression to the per-binding-rescan class (O(N²), the recurring solver
// antipattern reachByScc's alias fast path guards against) blows the ratio.
// >=16 blocks + a reachable loop ⇒ the production SSA path.
rdMaxFacts: 0, // measure the algorithm, not the cap
rdProductionBudget: true, // prove the SSA path computes under blocks×64
gen: (n) => {
let s = 'function f(c: number) {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i};\n`;
s += ' while (c > 0) {\n';
for (let i = 0; i < n; i++) {
s +=
` if (c > ${i}) { v${i} = ${i} + c; }` +
` else if (c < ${i}) { v${i} = ${i} - c; }` +
` else { v${i} = c; }\n`;
}
for (let i = 0; i < n; i++) s += ` use(v${i});\n`;
return s + ' c = c - 1;\n }\n return v0;\n}\n';
},
},
{
name: 'fact-fanout',
// #2082 M2: N parallel case-arm defs of one variable + N later uses —
@ -166,10 +261,28 @@ const SCENARIOS = [
// cost ~nothing (no solver call), gated as zero-time/dense-time ratio.
small: 125,
large: 500, // 4x, like the global sizes — per-fn bodies are ~30 lines
lang: 'ts', // taint model is TS-only; never run TS_JS_TAINT_MODEL on a C-family CFG
taint: { cap: 8 },
gen: (n) => genTaintFunctions(n, false),
genZero: (n) => genTaintFunctions(n, true),
},
{
name: 'go:branchy',
// #2195 U7: the first NON-TS scaling scenario — the C-family analogue of the
// TS `branchy` stressor, run through the Go grammar + Go CFG visitor. ONE Go
// function with N sequential `if`s → N condition blocks + 2N+ edges in a
// single CFG; stresses block/edge growth and the namedChildren walk on the
// Go body. The `go:` namespace keys it out of the TS baseline keyspace so a
// C-family entry can never collide with (or silently re-baseline) a TS
// scenario. CFG-only (Go has no registered taint model — see LANGS), so the
// gated metrics are the time/disk/heap/rd scaling ratios + the fingerprint.
lang: 'go',
gen: (n) => {
let s = 'package p\nfunc f(x int) {\n';
for (let i = 0; i < n; i++) s += `\tif x > ${i} {\n\t\ts${i}()\n\t}\n`;
return s + '}\n';
},
},
];
// taint-dense generator: `zero` swaps every model-matched name for an
@ -207,14 +320,14 @@ function median(xs) {
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function measureCollect(src, file, reps) {
const root = parse(src).rootNode; // parse ONCE; reuse across reps
collectFunctionCfgs(root, visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
function measureCollect(tk, src, file, reps) {
const root = tk.parse(src).rootNode; // parse ONCE; reuse across reps
collectFunctionCfgs(root, tk.visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = collectFunctionCfgs(root, visitor, file, NO_CAP);
out = collectFunctionCfgs(root, tk.visitor, file, NO_CAP);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return {
@ -236,17 +349,32 @@ function measureCollect(src, file, reps) {
// the scope-resolution emit loop adds per file on a --pdg run). `maxFacts`
// mirrors the per-scenario production posture: 0 (unlimited) measures the
// algorithm; the production default exercises the boundedness contract.
function measureReachingDefs(cfgs, reps, maxFacts) {
for (const c of cfgs) computeReachingDefs(c, { maxFacts }); // warm JIT
// When `blockVisitsMul` > 0 each call also passes the PRODUCTION per-function
// maxBlockVisits budget (blocks × mul). On the deep-nest scenario this is how
// "the ceiling stops firing" (#2201) is measured: the dense worklist would
// truncate to an empty result under this budget, whereas the production SSA
// solver computes the full facts — so a nonzero `facts` under the budget is the
// gate (see facts_large_min in baselines.json).
function measureReachingDefs(cfgs, reps, maxFacts, blockVisitsMul = 0) {
const limitsFor = (c) =>
blockVisitsMul > 0
? { maxFacts, maxBlockVisits: c.blocks.length * blockVisitsMul }
: { maxFacts };
for (const c of cfgs) computeReachingDefs(c, limitsFor(c)); // warm JIT
const samples = [];
let facts = 0;
let allComputed = true;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
facts = 0;
for (const c of cfgs) facts += computeReachingDefs(c, { maxFacts }).facts.length;
for (const c of cfgs) {
const r = computeReachingDefs(c, limitsFor(c));
facts += r.facts.length;
if (r.status !== 'computed') allComputed = false;
}
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return { ms: median(samples), facts };
return { ms: median(samples), facts, allComputed };
}
// ---- taint pass cost (#2083 M3 U7) ----
@ -257,7 +385,7 @@ function measureReachingDefs(cfgs, reps, maxFacts) {
// maxFindingsPerFunction (deliberately small so the cap BINDS on the dense
// generator). Also sums the encoded TAINTED `reason` bytes for the kept
// findings — the persisted-taint disk posture (R10).
function measureTaint(cfgs, reps, cap) {
function measureTaint(cfgs, reps, cap, taintModel) {
const importIndex = buildTaintImportIndex([]); // bench callees are globals
const pass = () => {
let analyzed = 0;
@ -265,7 +393,7 @@ function measureTaint(cfgs, reps, cap) {
let dropped = 0;
let reasonBytes = 0;
for (const c of cfgs) {
const matches = matchFunctionSites(c, TS_JS_TAINT_MODEL, importIndex);
const matches = matchFunctionSites(c, taintModel, importIndex);
if (!matches.hasSource || !matches.hasSink) continue;
const du = computeReachingDefs(c, {
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
@ -307,7 +435,7 @@ function measureTaint(cfgs, reps, cap) {
// run without the flag still works).
const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null;
function retainedHeapBytes(src, file) {
function retainedHeapBytes(tk, src, file) {
if (!GC) return null;
// Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them,
// GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel
@ -315,7 +443,7 @@ function retainedHeapBytes(src, file) {
// is flushed) — robust to pre-existing garbage, which is constant across both
// measurements. The parse tree is a temporary (its native memory isn't on the
// JS heap); block text strings are fresh copies, so they count here.
let cfgs = collectFunctionCfgs(parse(src).rootNode, visitor, file, NO_CAP).cfgs;
let cfgs = collectFunctionCfgs(tk.parse(src).rootNode, tk.visitor, file, NO_CAP).cfgs;
GC();
const withCfgs = process.memoryUsage().heapUsed;
if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs
@ -342,8 +470,13 @@ function canonicalizeCfg(cfg) {
return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${bindings}\n${blocks.join('\n')}\n${edges.join('\n')}`;
}
function fingerprint(scenario) {
const out = collectFunctionCfgs(parse(scenario.gen(FP_SIZE)).rootNode, visitor, 'fp.ts', NO_CAP);
function fingerprint(tk, scenario) {
const out = collectFunctionCfgs(
tk.parse(scenario.gen(FP_SIZE)).rootNode,
tk.visitor,
'fp',
NO_CAP,
);
const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n');
return {
fingerprint: crypto.createHash('sha256').update(canon).digest('hex'),
@ -354,48 +487,62 @@ function fingerprint(scenario) {
}
function measureScenario(scenario) {
// Resolve the scenario's language toolkit ONCE (default 'ts' keeps every
// pre-existing TS scenario on the exact same grammar+visitor+model path it
// used before the U1 parameterization → byte-identical baselines).
const tk = langToolkit(scenario.lang ?? 'ts');
// Per-scenario sizes (straight-line needs larger N to separate a concat
// quadratic from noise — see its comment); the rest default to the globals.
const nSmall = scenario.small ?? SMALL;
const nLarge = scenario.large ?? LARGE;
const small = measureCollect(scenario.gen(nSmall), `${scenario.name}.ts`, REPS);
const large = measureCollect(scenario.gen(nLarge), `${scenario.name}.ts`, REPS);
const small = measureCollect(tk, scenario.gen(nSmall), `${scenario.name}.src`, REPS);
const large = measureCollect(tk, scenario.gen(nLarge), `${scenario.name}.src`, REPS);
const sizeRatio = nLarge / nSmall;
const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0;
const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0;
// Memory growth (only when --expose-gc gave us a forced GC).
const heapSmall = retainedHeapBytes(scenario.gen(nSmall), `${scenario.name}.ts`);
const heapLarge = retainedHeapBytes(scenario.gen(nLarge), `${scenario.name}.ts`);
const heapSmall = retainedHeapBytes(tk, scenario.gen(nSmall), `${scenario.name}.src`);
const heapLarge = retainedHeapBytes(tk, scenario.gen(nLarge), `${scenario.name}.src`);
const heapRatio =
heapSmall !== null && heapLarge !== null && heapSmall > 0
? heapLarge / heapSmall / sizeRatio
: null;
// #2082 M2: reaching-defs solve cost over the same CFGs.
// #2082 M2: reaching-defs solve cost over the same CFGs. #2201: scenarios
// marked `rdProductionBudget` also pass the per-function blocks×64 ceiling, to
// prove the production SSA solver still COMPUTES where the dense worklist would
// truncate (the deep-nest ceiling-stops-firing acceptance).
const rdMaxFacts = scenario.rdMaxFacts ?? 0;
const rdSmall = measureReachingDefs(small.cfgs, REPS, rdMaxFacts);
const rdLarge = measureReachingDefs(large.cfgs, REPS, rdMaxFacts);
const rdBudgetMul = scenario.rdProductionBudget ? DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS : 0;
const rdSmall = measureReachingDefs(small.cfgs, REPS, rdMaxFacts, rdBudgetMul);
const rdLarge = measureReachingDefs(large.cfgs, REPS, rdMaxFacts, rdBudgetMul);
// Clamp the denominator: a 0.000ms small-N median would otherwise yield
// ratio 0 and the gate would self-disable exactly when the solver is fast.
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios.
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios. The
// taint model is the scenario's language model (TS_JS_TAINT_MODEL for the TS
// taint-dense scenario; a taint scenario requires a model-bearing language).
let taintMetrics = {};
if (scenario.taint !== undefined) {
if (!tk.taintModel)
throw new Error(
`bench: scenario '${scenario.name}' has a taint config but lang '${scenario.lang ?? 'ts'}' has no taint model`,
);
const cap = scenario.taint.cap;
const tSmall = measureTaint(small.cfgs, REPS, cap);
const tLarge = measureTaint(large.cfgs, REPS, cap);
const tSmall = measureTaint(small.cfgs, REPS, cap, tk.taintModel);
const tLarge = measureTaint(large.cfgs, REPS, cap, tk.taintModel);
const tRatio = tLarge.ms / Math.max(tSmall.ms, 0.001) / sizeRatio;
// Zero-match control: identical CFG shape, no model hits — measures the
// match-gate overhead unmatched functions pay on a real --pdg repo.
const zeroCfgs = collectFunctionCfgs(
parse(scenario.genZero(nLarge)).rootNode,
visitor,
`${scenario.name}-zero.ts`,
tk.parse(scenario.genZero(nLarge)).rootNode,
tk.visitor,
`${scenario.name}-zero.src`,
NO_CAP,
).cfgs;
const tZero = measureTaint(zeroCfgs, REPS, cap);
const tZero = measureTaint(zeroCfgs, REPS, cap, tk.taintModel);
taintMetrics = {
taint_ms_small: Number(tSmall.ms.toFixed(3)),
taint_ms_large: Number(tLarge.ms.toFixed(3)),
@ -431,7 +578,8 @@ function measureScenario(scenario) {
rd_scaling_ratio: Number(rdRatio.toFixed(3)),
facts_small: rdSmall.facts,
facts_large: rdLarge.facts,
...fingerprint(scenario),
rd_all_computed: rdLarge.allComputed,
...fingerprint(tk, scenario),
};
}
@ -495,6 +643,25 @@ if (!CHECK) {
`(the maxFacts early-stop is the boundedness contract)`,
);
}
// #2201 deep-nest: under the PRODUCTION blocks×64 budget the SSA solver must
// still COMPUTE full facts (a nonzero floor) where the dense worklist would
// truncate to empty — "the ceiling stops firing".
if (base.facts_large_min !== undefined && r.facts_large < base.facts_large_min) {
failures.push(
`${r.scenario}: only ${r.facts_large} facts < floor ${base.facts_large_min} under the ` +
`production block-visit budget — the ceiling fired (SSA should not truncate here)` +
(r.rd_all_computed ? '' : ` [status != computed]`),
);
}
// Independent of the fact-count floor: under the production budget every
// function in a facts_large_min scenario must report status 'computed'. This
// catches a partial-truncation regression that still clears the count floor.
if (base.facts_large_min !== undefined && r.rd_all_computed === false) {
failures.push(
`${r.scenario}: a function did not reach status 'computed' under the production ` +
`block-visit budget — the SSA solver truncated where it must compute`,
);
}
if (base.disk_bytes_large_max !== undefined && r.disk_bytes_large > base.disk_bytes_large_max) {
failures.push(
`${r.scenario}: cfgSideChannel absolute size ${r.disk_bytes_large} > ceiling ` +

View file

@ -0,0 +1,58 @@
# Emit-persistence bench (#2203)
Build-free throughput + byte-identity guard for the **CSV-generation half** of
the graph-DB persistence pipeline (`streamAllCSVsToDisk`), which dominates
large-repo `analyze` wall time alongside parsing (issue #2203).
```bash
# from gitnexus/
node --import tsx bench/emit-persistence/measure.mjs # print one JSON line
node --import tsx bench/emit-persistence/measure.mjs --check # gate vs baselines.json
```
## What it measures
A synthetic `KnowledgeGraph` (files + functions + classes + 4 edge types across
the `File→Function`, `File→Class`, `Function→Function` label pairs) at two
scales:
- **`elapsed_ms_small` / `elapsed_ms_large`** — median wall-clock over `REPS`
runs of `streamAllCSVsToDisk`.
- **`scaling_ratio`** — `(t_large/t_small)/(LARGE/SMALL)`; ~1.0 is linear. The
`--check` gate fails if it exceeds `scaling_budget` (catches an O(n²)
re-regression in the emit/routing path).
- **`fingerprint`** — order-independent sha256 over every emitted CSV line (node
CSVs + per-FROM→TO-label-pair rel CSVs). This is the **byte-identity gate**:
the U2 (direct per-pair routing) and U3 (per-row microtask elimination)
optimisations must not change graph content, and any future change that does
fails `--check`. Byte-identity holds for all quote-free ids; for an id
containing a `"` the router intentionally diverges from — and is more correct
than — the legacy regex oracle (see `src/core/lbug/rel-pair-routing.ts`).
## What it does NOT measure
- **The LadybugDB `COPY` half.** Bulk loading needs a live writable DB
connection, so it can't run build-free. Its per-stage timing lives in the
runtime `PROF_LBUG_LOAD=1` breakdown (`[lbug-load prof] csv-emit=… copy-nodes=…
copy-rels=… fallback=… total=…`) and is exercised end-to-end by the
integration round-trip tests (`test/integration/basicblock-roundtrip.test.ts`,
`lbug-core-adapter.test.ts`).
- **Content extraction.** Bench nodes have no backing source files, so the
`content` column is empty — emit cost here reflects the CSV machinery
(routing, escaping, buffering, disk writes), not file reads.
- **At-scale absolute numbers.** The real postgres / kernel-`fs/` wall (issue
#2203's table) is a maintainer-run measurement; this synthetic bench is the
reproducible regression guard, not a substitute for those runs.
## Deferred follow-up
Parallelising the `COPY` loop (`PARALLEL=false` is load-bearing; LadybugDB is
single-writer) is **out of scope** for #2203 pending empirical validation of
concurrent-COPY support — the `PROF_LBUG_LOAD` breakdown is the prerequisite
that shows whether COPY is the dominant cost worth that risk.
## Regenerating the baseline
```bash
node --import tsx bench/emit-persistence/measure.mjs # copy fingerprint + ratio into baselines.json
```

View file

@ -0,0 +1,4 @@
{
"fingerprint": "386f432c74f4992455055d8891dbe6c873ea95afa60ef4be023a21aed7b4bcb1",
"_note": "Byte-identity + bounded-retention gate for streaming/chunked PDG emit (#2202). fingerprint = sha256 of the sorted, header-stripped BasicBlock + PDG-edge data rows of the canonical synthetic set. --check also asserts the streamed PdgEmitSink output is byte-identical to the whole-graph streamAllCSVsToDisk emit (byte_identical_nodes/edges) and that the in-memory graph retains 0 BasicBlocks (resident_basic_blocks === 0, the O(chunk) RSS bound). Regenerate via `node --import tsx bench/emit-persistence/measure-streaming.mjs`."
}

View file

@ -0,0 +1,6 @@
{
"fingerprint": "1b9dd0b783899b47067c36511d241860f291ac736e57682b0ece14148e3958ff",
"scaling_budget": 1.8,
"max_ms_large": 1000,
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
}

View file

@ -0,0 +1,199 @@
/**
* Build-free byte-identity + bounded-retention bench for streaming/chunked PDG
* graph emit (issue #2202).
*
* Proves the two acceptance criteria at scale, without a DB connection:
* 1. BYTE-IDENTITY (R2): emitting a BasicBlock + intra-file PDG-edge set via
* the streaming `PdgEmitSink` produces the IDENTICAL CSV data-row set as
* the whole-graph `streamAllCSVsToDisk` path. Compared per file
* (basicblock.csv, rel_BasicBlock_BasicBlock.csv) over header-stripped,
* sorted lines so it is a pure function of the emitted row SET.
* 2. BOUNDED RETENTION (R1): with streaming on, the in-memory graph holds
* ZERO BasicBlock nodes regardless of how many are emitted — the PDG layer
* never accumulates in process memory (peak RSS O(chunk), not O(graph)).
*
* Build-free: imports the `.ts` hotpaths through tsx
* (`node --import tsx bench/emit-persistence/measure-streaming.mjs`).
*
* Without args: prints one JSON object. With `--check`: asserts byte-identity,
* retention, and fingerprint == the committed baseline; exits non-zero on any
* failure.
*/
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { createKnowledgeGraph } from '../../src/core/graph/graph.ts';
import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.ts';
import { PdgEmitSink } from '../../src/core/lbug/pdg-emit-sink.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines-streaming.json');
// PDG edge types streamed per file (all intra-block BasicBlock→BasicBlock).
const PDG_TYPES = ['CFG', 'REACHING_DEF', 'CDG', 'POST_DOMINATE', 'TAINTED', 'SANITIZES'];
const FUNCS = 1200; // functions
const BLOCKS = 6; // basic blocks per function ⇒ FUNCS*BLOCKS BasicBlocks total
const CHUNK_ROWS = 64; // tiny streamed buffer to exercise frequent flushing
/**
* Build the canonical PDG node/edge SET: `FUNCS` functions each with `BLOCKS`
* BasicBlocks and a chain of intra-function PDG edges. Returns the structural
* nodes (File/Function) separately from the BasicBlock + PDG-edge layer so the
* streamed path can route them to different sinks.
*/
function buildSet() {
const structuralNodes = [];
const structuralRels = [];
const bbNodes = [];
const pdgEdges = [];
for (let f = 0; f < FUNCS; f++) {
const fp = `src/m${f % 50}.ts`;
const fnId = `Function:${fp}:fn${f}:1`;
structuralNodes.push({
id: fnId,
label: 'Function',
properties: { name: `fn${f}`, filePath: fp, startLine: 1, endLine: 99 },
});
for (let b = 0; b < BLOCKS; b++) {
bbNodes.push({
id: `BasicBlock:${fp}:1:0:${f}_${b}`,
label: 'BasicBlock',
properties: {
name: '',
filePath: fp,
startLine: b * 3,
endLine: b * 3 + 2,
text: `f${f}b${b}`,
},
});
}
for (let b = 0; b < BLOCKS - 1; b++) {
const from = `BasicBlock:${fp}:1:0:${f}_${b}`;
const to = `BasicBlock:${fp}:1:0:${f}_${b + 1}`;
for (const type of PDG_TYPES) {
pdgEdges.push({
id: `${type}:${f}:${b}`,
sourceId: from,
targetId: to,
type,
confidence: 1,
reason: type === 'REACHING_DEF' ? `v${b}` : type === 'CDG' ? 'T' : '',
});
}
}
}
// A few File nodes so the structural emit produces a realistic multi-table mix.
for (let m = 0; m < 50; m++) {
structuralNodes.push({
id: `File:src/m${m}.ts`,
label: 'File',
properties: { name: `m${m}.ts`, filePath: `src/m${m}.ts` },
});
}
return { structuralNodes, structuralRels, bbNodes, pdgEdges };
}
/** Header-stripped, sorted, non-empty data rows of one CSV file (or [] if absent). */
async function dataRows(csvPath) {
let text;
try {
text = await fsp.readFile(csvPath, 'utf8');
} catch {
return [];
}
const lines = text.split('\n').filter((l) => l.length > 0);
return lines.slice(1).sort(); // drop the header line
}
const sha = (rows) => crypto.createHash('sha256').update(rows.join('\n')).digest('hex');
async function measure() {
// mkdtemp (unpredictable, unique) rather than a predictable pid-based tmp path.
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-stream-bench-'));
try {
const { structuralNodes, structuralRels, bbNodes, pdgEdges } = buildSet();
// ── whole-graph path ─────────────────────────────────────────────────
const wholeGraph = createKnowledgeGraph();
for (const n of structuralNodes) wholeGraph.addNode(n);
for (const n of bbNodes) wholeGraph.addNode(n);
for (const r of structuralRels) wholeGraph.addRelationship(r);
for (const e of pdgEdges) wholeGraph.addRelationship(e);
const wholeDir = path.join(tmpRoot, 'whole');
await streamAllCSVsToDisk(wholeGraph, path.join(tmpRoot, 'no-repo'), wholeDir);
// ── streamed path ────────────────────────────────────────────────────
const realGraph = createKnowledgeGraph();
const sink = new PdgEmitSink(realGraph, path.join(tmpRoot, 'pdg-csv'), CHUNK_ROWS);
for (const n of structuralNodes) realGraph.addNode(n); // structural → real graph
for (const r of structuralRels) realGraph.addRelationship(r);
for (const n of bbNodes) sink.addNode(n); // BasicBlock layer → sink (CSV)
for (const e of pdgEdges) sink.addRelationship(e);
sink.finalize();
const streamedCsvDir = path.join(tmpRoot, 'streamed');
await streamAllCSVsToDisk(realGraph, path.join(tmpRoot, 'no-repo'), streamedCsvDir);
// ── retention (R1): the real graph holds ZERO BasicBlocks ────────────
let residentBasicBlocks = 0;
for (const n of realGraph.iterNodes()) if (n.label === 'BasicBlock') residentBasicBlocks++;
// ── byte-identity (R2): per-file data-row set equality ───────────────
const wholeBb = await dataRows(path.join(wholeDir, 'basicblock.csv'));
const streamedBb = await dataRows(path.join(tmpRoot, 'pdg-csv', 'basicblock.csv'));
const wholeRel = await dataRows(path.join(wholeDir, 'rel_BasicBlock_BasicBlock.csv'));
const streamedRel = await dataRows(
path.join(tmpRoot, 'pdg-csv', 'rel_BasicBlock_BasicBlock.csv'),
);
const bbIdentical = sha(wholeBb) === sha(streamedBb);
const relIdentical = sha(wholeRel) === sha(streamedRel);
// Fingerprint over the canonical PDG data-row set (drift gate).
const fingerprint = sha([...wholeBb, ...wholeRel].sort());
return {
scenario: 'streamingPdgEmit',
basic_blocks: bbNodes.length,
pdg_edges: pdgEdges.length,
chunk_rows: CHUNK_ROWS,
resident_basic_blocks: residentBasicBlocks,
byte_identical_nodes: bbIdentical,
byte_identical_edges: relIdentical,
fingerprint,
};
} finally {
await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
}
}
const CHECK = process.argv.includes('--check');
const result = await measure();
if (!CHECK) {
process.stdout.write(JSON.stringify(result) + '\n');
} else {
const base = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const failures = [];
if (!result.byte_identical_nodes)
failures.push('streamed BasicBlock rows differ from whole-graph emit');
if (!result.byte_identical_edges)
failures.push('streamed PDG-edge rows differ from whole-graph emit');
if (result.resident_basic_blocks !== 0) {
failures.push(
`RSS bound violated: ${result.resident_basic_blocks} BasicBlock node(s) retained in the in-memory graph (expected 0)`,
);
}
if (result.fingerprint !== base.fingerprint) {
failures.push(`fingerprint drift (got ${result.fingerprint}, expected ${base.fingerprint})`);
}
process.stdout.write(JSON.stringify(result) + '\n');
if (failures.length > 0) {
for (const f of failures) process.stderr.write(`[stream-pdg-emit --check] FAIL: ${f}\n`);
process.exit(1);
}
process.stderr.write('[stream-pdg-emit --check] PASS\n');
}

View file

@ -0,0 +1,216 @@
/**
* Build-free emit-path throughput + byte-identity bench for the graph-DB
* persistence pipeline (issue #2203).
*
* Measures `streamAllCSVsToDisk` — the CSV-generation half of the persistence
* path that U2 (direct per-pair relationship routing) and U3 (per-row
* microtask elimination) optimised. The LadybugDB `COPY` half needs a real DB
* connection, so its timing lives in the runtime `PROF_LBUG_LOAD` breakdown +
* the integration round-trip tests, NOT here (see README.md).
*
* For a synthetic KnowledgeGraph at two scales it reports:
* - elapsed_ms_small / elapsed_ms_large (median over REPS) + a scaling ratio
* `(t_large/t_small)/(LARGE/SMALL)`: ~1.0 linear, ~3.x quadratic;
* - an order-independent sha256 fingerprint over every emitted CSV line
* (node CSVs + per-FROM→TO-label-pair rel CSVs), as the byte-identity gate
* guarding the issue's "byte-identical graph content" requirement.
*
* Build-free: imports the `.ts` hotpaths through tsx
* (`node --import tsx bench/emit-persistence/measure.mjs`). Static `.ts`
* imports work; a top-level `await import()` breaks tsx's lexer.
*
* Without args: prints one JSON object per scenario.
* With `--check`: asserts the fingerprint == the committed baseline AND the
* scaling ratio < the recorded budget; exits non-zero on drift/regression.
*/
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { createKnowledgeGraph } from '../../src/core/graph/graph.ts';
import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
// ---- synthetic graph generation (deterministic — no randomness) ----
/**
* Build a graph of `entityCount` files, each with 2 functions + 1 class and 4
* relationships. ids carry valid table-label prefixes (File:/Function:/Class:)
* so edges route to real pairs (File→Function, File→Class, Function→Function)
* — exercising the U2 router across multiple label pairs. Node `content` is
* never populated (no backing files), so emit cost reflects the CSV machinery
* (routing, escaping, buffering, disk writes), not content extraction.
*/
function generateGraph(entityCount) {
const graph = createKnowledgeGraph();
for (let i = 0; i < entityCount; i++) {
const fp = `src/e${i}.ts`;
const fileId = `File:${fp}`;
const fnA = `Function:${fp}:fnA:1`;
const fnB = `Function:${fp}:fnB:10`;
const cls = `Class:${fp}:C:20`;
graph.addNode({ id: fileId, label: 'File', properties: { name: `e${i}.ts`, filePath: fp } });
graph.addNode({
id: fnA,
label: 'Function',
properties: { name: 'fnA', filePath: fp, startLine: 1, endLine: 5, isExported: true },
});
graph.addNode({
id: fnB,
label: 'Function',
properties: { name: 'fnB', filePath: fp, startLine: 10, endLine: 15, isExported: false },
});
graph.addNode({
id: cls,
label: 'Class',
properties: { name: 'C', filePath: fp, startLine: 20, endLine: 30, isExported: true },
});
graph.addRelationship({
id: `${fileId}->${fnA}`,
sourceId: fileId,
targetId: fnA,
type: 'CONTAINS',
confidence: 1,
reason: '',
});
graph.addRelationship({
id: `${fileId}->${fnB}`,
sourceId: fileId,
targetId: fnB,
type: 'CONTAINS',
confidence: 1,
reason: '',
});
graph.addRelationship({
id: `${fileId}->${cls}`,
sourceId: fileId,
targetId: cls,
type: 'CONTAINS',
confidence: 1,
reason: '',
});
graph.addRelationship({
id: `${fnA}->${fnB}`,
sourceId: fnA,
targetId: fnB,
type: 'CALLS',
confidence: 1,
reason: '',
});
}
return graph;
}
// ---- byte-identity fingerprint (order-independent) ----
/** sha256 over every non-empty line of every emitted CSV file, sorted so the
* digest is a pure function of the emitted line SET (insertion-order agnostic). */
async function fingerprintEmit(graph, dir) {
await streamAllCSVsToDisk(graph, path.join(dir, 'no-such-repo'), dir);
// Per-file digest bound to the filename: a row routed to the WRONG pair file
// (or a header written to the wrong file) changes the fingerprint — a global
// line-flatten could not catch that. File bytes are hashed as-written (so it
// also catches within-file row reordering); the entry list is sorted so
// readdir order doesn't matter.
const entries = [];
for (const name of fs.readdirSync(dir)) {
if (!name.endsWith('.csv')) continue;
const bytes = await fsp.readFile(path.join(dir, name));
entries.push(`${name}\n${crypto.createHash('sha256').update(bytes).digest('hex')}`);
}
return crypto.createHash('sha256').update(entries.sort().join('\n')).digest('hex');
}
// ---- timing ----
function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
async function timeEmit(graph, dir, reps) {
await streamAllCSVsToDisk(graph, path.join(dir, 'no-such-repo'), dir); // warmup (not counted)
const samples = [];
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
await streamAllCSVsToDisk(graph, path.join(dir, 'no-such-repo'), dir);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return median(samples);
}
const SMALL = 600;
const LARGE = 2400;
const REPS = 5;
async function measure() {
const tmpRoot = path.join(os.tmpdir(), `gitnexus-emit-bench-${process.pid}`);
await fsp.mkdir(tmpRoot, { recursive: true });
try {
const smallGraph = generateGraph(SMALL);
const largeGraph = generateGraph(LARGE);
const fingerprint = await fingerprintEmit(largeGraph, path.join(tmpRoot, 'fp'));
const small = await timeEmit(smallGraph, path.join(tmpRoot, 'small'), REPS);
const large = await timeEmit(largeGraph, path.join(tmpRoot, 'large'), REPS);
const scalingRatio = small > 0 ? large / small / (LARGE / SMALL) : 0;
return {
scenario: 'streamAllCSVsToDisk',
entities_small: SMALL,
entities_large: LARGE,
nodes_large: LARGE * 4,
rels_large: LARGE * 4,
elapsed_ms_small: Number(small.toFixed(2)),
elapsed_ms_large: Number(large.toFixed(2)),
scaling_ratio: Number(scalingRatio.toFixed(3)),
fingerprint,
};
} finally {
await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
}
}
// ---- run ----
const CHECK = process.argv.includes('--check');
const result = await measure();
if (!CHECK) {
process.stdout.write(JSON.stringify(result) + '\n');
} else {
const base = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const failures = [];
if (result.fingerprint !== base.fingerprint) {
failures.push(
`byte-identity fingerprint drift (got ${result.fingerprint}, expected ${base.fingerprint})`,
);
}
if (result.scaling_ratio >= base.scaling_budget) {
failures.push(
`scaling ratio ${result.scaling_ratio} >= budget ${base.scaling_budget} ` +
`(${SMALL}->${LARGE} entities, ms ${result.elapsed_ms_small}->${result.elapsed_ms_large})`,
);
}
// Absolute backstop: the scaling ratio alone passes a uniform Nx slowdown (it
// only compares large/small). A generous, host-noise-tolerant ceiling catches
// a gross absolute regression. Opt-in (only enforced when max_ms_large is set).
if (base.max_ms_large !== undefined && result.elapsed_ms_large >= base.max_ms_large) {
failures.push(
`absolute wall-time regression: elapsed_ms_large ${result.elapsed_ms_large}ms >= budget ` +
`${base.max_ms_large}ms (coarse backstop, not a tight SLA)`,
);
}
process.stdout.write(JSON.stringify(result) + '\n');
if (failures.length > 0) {
for (const f of failures) process.stderr.write(`[emit-persistence --check] FAIL: ${f}\n`);
process.exit(1);
}
process.stderr.write('[emit-persistence --check] PASS\n');
}

View file

@ -18,11 +18,12 @@
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "9b5b4393d158d76dcf1ef9807e0326462c45a5266310f0ae7894d017f3858219",
"fingerprint": "5ef259c2cf9c4804bc83c41d25a3141d1a2cc57ce46485cce09013cf6f5e725e",
"scaling_budget": 1.5,
"_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 1.5).",
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)."
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5)."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",

View file

@ -1340,19 +1340,18 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
@ -1361,12 +1360,6 @@
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
@ -1822,9 +1815,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@ -3276,9 +3269,9 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"version": "4.12.26",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@ -4351,24 +4344,23 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.1",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
@ -4917,9 +4909,9 @@
}
},
"node_modules/tar": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"version": "7.5.16",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@ -5636,40 +5628,6 @@
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
},
"vendor/tree-sitter-dart": {
"version": "1.0.0",
"extraneous": true,
"license": "ISC",
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"vendor/tree-sitter-proto": {
"version": "0.4.1",
"extraneous": true,
"license": "MIT",
"peerDependencies": {
"tree-sitter": ">=0.21.0"
}
},
"vendor/tree-sitter-swift": {
"version": "0.7.1",
"extraneous": true,
"license": "MIT",
"peerDependencies": {
"tree-sitter": "^0.21.1 || ^0.22.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
}
}
}

View file

@ -30,7 +30,7 @@ import { logger } from '../../logger.js';
/**
* Cross-repo C/C++ `#include` dependency extractor.
*
* **Provider side:** registers every `.h/.hpp/.hxx/.hh` file in the repo
* **Provider side:** registers every `.h/.hpp/.hxx/.hh/.cuh` file in the repo
* as a provider contract with `include::<relative-path>`.
*
* **Consumer side:** parses all C/C++ source/header files for `#include "…"`
@ -45,13 +45,20 @@ import { logger } from '../../logger.js';
// ---------- constants ----------
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh', '.cuh']);
// Source = headers (provider-eligible) ∪ implementation files (.c/.cpp/.cc/.cxx).
// Source = headers (provider-eligible) ∪ implementation files (.c/.cpp/.cc/.cxx/.cu).
// Spread keeps the subset relationship explicit so a future contributor adding
// a new header extension to HEADER_EXTENSIONS does not have to remember to
// also add it here.
const SOURCE_EXTENSIONS = new Set<string>([...HEADER_EXTENSIONS, '.c', '.cpp', '.cc', '.cxx']);
const SOURCE_EXTENSIONS = new Set<string>([
...HEADER_EXTENSIONS,
'.c',
'.cpp',
'.cc',
'.cxx',
'.cu',
]);
const INCLUDE_QUERY_SRC = '(preproc_include path: (_) @import.source) @import';
@ -275,6 +282,8 @@ function getLanguageForFile(filePath: string): unknown | null {
case '.hpp':
case '.hxx':
case '.hh':
case '.cu':
case '.cuh':
return Cpp;
default:
return null;
@ -298,7 +307,7 @@ function getLanguageForFile(filePath: string): unknown | null {
function isLocalInclude(cleaned: string, suffixIndex: SuffixIndex): boolean {
const candidates = [cleaned];
if (!/\.[a-zA-Z0-9]+$/.test(cleaned)) {
for (const ext of ['.h', '.hpp', '.hxx', '.hh']) candidates.push(cleaned + ext);
for (const ext of HEADER_EXTENSIONS) candidates.push(cleaned + ext);
}
for (const c of candidates) {
if (suffixIndex.get(c) || suffixIndex.getInsensitive(c)) return true;
@ -428,7 +437,7 @@ export class IncludeExtractor implements ContractExtractor {
try {
const rows = await db(
`MATCH (f:File)
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh)$'
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh|cuh)$'
RETURN f.filePath AS filePath, f.id AS fileId`,
);
// gitnexus analyze stores absolute paths in the File.filePath column.

View file

@ -42,10 +42,40 @@ interface MutableBlock {
statements: StatementFacts[];
}
/**
* Hard ceiling on CFG recursive-descent scope-entry depth (#2195). A language
* `CfgVisitor` wraps each nested block scope in {@link CfgBuilder.withNesting} (its
* `visitBody` / `visitSeq` choke points), so the live count tracks scope entries,
* not statement width. NOTE the count is ~2× LEXICAL nesting for block-bodied
* constructs (visitBody → visitSeq both enter), so the effective lexical ceiling
* is ~250 levels for block bodies (~500 for single-statement bodies / bare
* blocks). Real source nests ≤ ~50 deep, so this fires only on machine-generated
* / adversarial input. Both effective ceilings sit far below the engine's native
* stack limit (~1.2k+ nesting even on the raised worker `stackSizeMb`), so the
* bail is a DETERMINISTIC, language-independent {@link CfgNestingDepthError}
* rather than a nondeterministic `RangeError` thrown somewhere mid-walk.
*/
export const MAX_CFG_NESTING_DEPTH = 500;
/**
* Thrown by the visitor nesting-depth guard ({@link CfgBuilder.enterNesting})
* when lexical nesting exceeds {@link MAX_CFG_NESTING_DEPTH}. `collectFunctionCfgs`
* catches it and counts the function under `skipped.tooDeeplyNested`, isolating
* the bail to one function instead of risking a worker-wide stack overflow.
*/
export class CfgNestingDepthError extends Error {
constructor(readonly limit: number) {
super(`CFG nesting depth exceeded ${limit}`);
this.name = 'CfgNestingDepthError';
}
}
export class CfgBuilder {
private readonly blocks: MutableBlock[] = [];
private readonly edges: CfgEdgeData[] = [];
private readonly edgeKeys = new Set<string>();
/** Live recursive-descent nesting depth — see {@link enterNesting}. */
private nesting = 0;
readonly entryIndex: number;
readonly exitIndex: number;
@ -119,6 +149,45 @@ export class CfgBuilder {
return this.blocks.length;
}
/**
* Run `fn` inside ONE nested block scope (#2195) — the single choke every
* visitor's `visitBody` / `visitSeq` funnels through. Enters on the way in and
* exits in a `finally`, so the live depth is balanced on every return AND every
* throw and the enter/exit can never drift out of pair (the reason this is one
* helper, not 24 hand-paired call sites). Throws {@link CfgNestingDepthError}
* when nesting exceeds {@link MAX_CFG_NESTING_DEPTH} — a proactive, deterministic
* bail before the native stack can overflow on a pathologically nested function.
*
* A block-bodied construct passes through BOTH visitBody and visitSeq, so it
* costs TWO scopes per lexical level: the effective structural ceiling is
* ~MAX_CFG_NESTING_DEPTH/2 (~250) lexical levels for block bodies (~500 for
* single-statement bodies / bare blocks, which hit only one of the two). Still
* an order of magnitude below the native limit and far above real code (≤ ~50).
*/
withNesting<T>(fn: () => T): T {
this.enterNesting();
try {
return fn();
} finally {
this.exitNesting();
}
}
/**
* Increment the nesting counter, throwing {@link CfgNestingDepthError} past the
* cap. Prefer {@link withNesting}, which pairs the exit in a `finally`; this is
* exposed for direct depth-accounting tests only.
*/
enterNesting(): void {
if (++this.nesting > MAX_CFG_NESTING_DEPTH)
throw new CfgNestingDepthError(MAX_CFG_NESTING_DEPTH);
}
/** Decrement the nesting counter — the partner of {@link enterNesting}. */
exitNesting(): void {
this.nesting--;
}
/** Produce the serializable CFG. Caller is responsible for having wired the
* function's dangling exits to {@link exitIndex} before calling.
*

View file

@ -14,6 +14,7 @@
* cannot blow up worker time/memory. A cap of `0` means no limit.
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { CfgNestingDepthError } from './cfg-builder.js';
import type { CfgVisitor, FunctionCfg } from './types.js';
/**
@ -24,10 +25,62 @@ import type { CfgVisitor, FunctionCfg } from './types.js';
*/
export const DEFAULT_PDG_MAX_FUNCTION_LINES = 2000;
/**
* CFG-bearing functions skipped during the walk, bucketed by reason (#2195).
* Surfaced per-language in the parse telemetry (parsing-processor.ts) so a CFG
* coverage gap is observable, not silent. All-zero ⇒ nothing skipped.
*/
export interface CfgSkipCounts {
/** Source span exceeded `maxFunctionLines` (minified / generated code). */
readonly tooManyLines: number;
/**
* Recursive-descent nesting hit {@link MAX_CFG_NESTING_DEPTH} — a proactive,
* deterministic bail (see {@link CfgNestingDepthError}) before a worker stack
* overflow.
*/
readonly tooDeeplyNested: number;
/**
* `buildFunctionCfg` threw an unexpected error. Caught PER FUNCTION so one
* malformed function no longer drops the whole file's CFGs (the throw used to
* escape to the worker's language-group catch).
*/
readonly buildError: number;
}
export interface CollectedCfgs {
readonly cfgs: readonly FunctionCfg[];
/** Functions skipped for exceeding `maxFunctionLines` (0 ⇒ none skipped). */
readonly skipped: number;
/** Per-reason skip counts (#2195). */
readonly skipped: CfgSkipCounts;
}
/**
* Convert a CFG built from an EXTRACTED sub-document's AST (script-relative
* tree-sitter rows) into the enclosing file's coordinates by adding `offset` to
* every source-line field. Needed for embedded scripts — a Vue SFC `<script>`
* block parses at row 0 but lives at `lineOffset` in the `.vue` file, and every
* other worker-emitted graph node is already file-relative; without this, the
* CFG's `functionStartLine` would never join its Function/Method graph node
* (inter-procedural taint silently resolves nothing) and BasicBlock source
* lines would point at the wrong `.vue` line. A 0 offset returns the input
* unchanged (the common case: `.ts`/`.js`/etc. parse at the file root), keeping
* non-embedded languages byte-identical. Synthetic bindings keep `declLine` 0.
*/
function shiftCfgLines(cfg: FunctionCfg, offset: number): FunctionCfg {
if (offset === 0) return cfg;
return {
...cfg,
functionStartLine: cfg.functionStartLine + offset,
functionEndLine: cfg.functionEndLine + offset,
blocks: cfg.blocks.map((b) => ({
...b,
startLine: b.startLine + offset,
endLine: b.endLine + offset,
statements: b.statements?.map((s) => ({ ...s, line: s.line + offset })),
})),
bindings: cfg.bindings?.map((bd) =>
bd.declLine > 0 ? { ...bd, declLine: bd.declLine + offset } : bd,
),
};
}
export function collectFunctionCfgs(
@ -35,9 +88,12 @@ export function collectFunctionCfgs(
visitor: CfgVisitor<SyntaxNode>,
filePath: string,
maxFunctionLines = 0,
lineOffset = 0,
): CollectedCfgs {
const cfgs: FunctionCfg[] = [];
let skipped = 0;
let tooManyLines = 0;
let tooDeeplyNested = 0;
let buildError = 0;
const stack: SyntaxNode[] = [root];
while (stack.length) {
@ -45,10 +101,19 @@ export function collectFunctionCfgs(
if (visitor.isFunction(node)) {
const lines = node.endPosition.row - node.startPosition.row + 1;
if (maxFunctionLines > 0 && lines > maxFunctionLines) {
skipped++;
tooManyLines++;
} else {
const cfg = visitor.buildFunctionCfg(node, filePath);
if (cfg) cfgs.push(cfg);
// Isolate the per-function build: a proactive deep-nesting bail
// (CfgNestingDepthError) or any other visitor throw is counted and
// skipped HERE, so it can't escape to the worker's language-group catch
// and silently drop every remaining function's CFG (#2195).
try {
const cfg = visitor.buildFunctionCfg(node, filePath);
if (cfg) cfgs.push(shiftCfgLines(cfg, lineOffset));
} catch (err) {
if (err instanceof CfgNestingDepthError) tooDeeplyNested++;
else buildError++;
}
}
}
// Descend regardless (a skipped mega-function may still contain small
@ -59,5 +124,5 @@ export function collectFunctionCfgs(
}
}
return { cfgs, skipped };
return { cfgs, skipped: { tooManyLines, tooDeeplyNested, buildError } };
}

View file

@ -1,15 +1,26 @@
/**
* Control dependence (#2085 M5 U3) — Ferrante, Ottenstein & Warren §3.1.1 over
* the post-dominator tree. A block `dependent` is control-dependent on a branch
* block `controller` when `controller` decides whether `dependent` executes:
* formally, there is a CFG edge `controller → B` such that `dependent`
* post-dominates `B` but does NOT strictly post-dominate `controller`.
* Control dependence (#2085 M5 U3) — Ferrante, Ottenstein & Warren §3.1.1
* semantics. A block `dependent` is control-dependent on a branch block
* `controller` when `controller` decides whether `dependent` executes: formally,
* there is a CFG edge `controller → B` such that `dependent` post-dominates `B`
* but does NOT strictly post-dominate `controller`.
*
* Construction (§3.1.1): for each CFG edge `(A, B)` where `B` does NOT
* post-dominate `A`, walk UP the post-dom tree from `B` to (but not including)
* `ipdom(A)`; every block on that path is control-dependent on `A`. The branch
* SENSE of the edge ('T' | 'F') becomes the edge label (KTD4 / KTD3 — it rides
* the persisted relation's `reason` column).
* Construction — the reverse-CFG dominance-frontier formulation (Cytron,
* Ferrante, Rosen, Wegman & Zadeck 1991): control dependence IS the dominance
* frontier of the reverse CFG, so `A ∈ PDF(X)` (the post-dominance frontier)
* ⟺ `X` is control-dependent on `A`. The PDF is computed bottom-up over the
* post-dom tree (`PDF_local` from a node's CFG predecessors + `PDF_up` from its
* post-dom-tree children) in O(N + E + output) — each up-step is charged to a
* distinct emitted edge, NOT re-walked per CFG edge as the original §3.1.1
* up-walk did (which was Θ(N²) on a deep post-dom chain). The two formulations
* enumerate the IDENTICAL full `(controller, dependent, label)` set (verified
* byte-identical on 3203 CFGs + ~1M-case differential fuzz); LLVM, Joern and WALA
* use the reverse-DF form. (Only the rare TRUNCATED prefix — when a function
* exceeds `maxEdges` — differs from the old prefix: it is now a sorted
* deterministic prefix rather than CFG-edge-iteration order. Both are valid,
* deterministic subsets; the full untruncated output is unchanged.)
* The branch SENSE ('T' | 'F') of the controlling edge becomes the edge label
* (KTD4 / KTD3 — it rides the persisted relation's `reason` column).
*
* PURE AND DETERMINISTIC (mirrors post-dominators.ts / reaching-defs.ts): no
* graph, no logger, importable outside the worker; output is deduped per
@ -18,12 +29,7 @@
* control-dependent on ITSELF (`controller === dependent`) — the loop predicate
* gates its own re-execution; this is standard PDG behavior, not a bug.
*/
import {
computePostDominators,
postDominates,
NO_IPDOM,
type PostDomTree,
} from './post-dominators.js';
import { computePostDominators, NO_IPDOM, type PostDomTree } from './post-dominators.js';
import type { CfgEdgeKind, FunctionCfg } from './types.js';
export type CdgLabel = 'T' | 'F';
@ -111,10 +117,14 @@ function labelFor(kind: CfgEdgeKind, controller: ArmSenses): CdgLabel {
export function computeControlDependence(
cfg: FunctionCfg,
postDom?: PostDomTree,
// Heap-safety ceiling on materialized edges, mirroring computeReachingDefs'
// `maxFacts` (#2188 review): the pre-dedup walk is O(edges × post-dom depth),
// so bound it before it can spike. `0` ⇒ unbounded. On overflow `edges` is a
// deterministic prefix and `truncated` is set — never a silent drop.
// Output-size ceiling, mirroring computeReachingDefs' `maxFacts` (#2188 review).
// The reverse-DF set is the bounded (controller, dependent, label) dependence
// relation, so peak working set ≈ output here (no pre-dedup spike like the old
// up-walk) — this caps the final edge COUNT, not transient memory. `0` ⇒
// unbounded. On overflow `edges` is a deterministic SORTED prefix and
// `truncated` is set — never a silent drop. (The sorted prefix is the prefix
// CONTENTS may differ from the old up-walk's CFG-edge-iteration prefix at the
// cap boundary; the FULL untruncated set is byte-identical — see the module doc.)
maxEdges: number = 0,
): ControlDepResult {
const tree = postDom ?? computePostDominators(cfg);
@ -123,42 +133,73 @@ export function computeControlDependence(
const armSenses = buildArmSenses(cfg);
const cap = maxEdges > 0 ? maxEdges : Infinity;
const out: ControlDepEdge[] = [];
const seen = new Set<string>();
let truncated = false;
// Reverse-CFG post-dominance frontier (Cytron, Ferrante, Rosen, Wegman,
// Zadeck 1991): control dependence IS the dominance frontier of the reverse
// CFG. `A ∈ PDF(X)` ⟺ X is control-dependent on A, so emit (controller=A,
// dependent=X). Computing the PDF bottom-up over the post-dom tree charges
// each up-step to a DISTINCT emitted entry — O(N+E+output) — instead of the
// old Ferrante up-walk that re-climbs the ipdom chain per CFG edge (Θ(N²) on
// a deep post-dom chain). Output is the identical (controller, dependent,
// label) set (verified byte-identical on 3203 CFGs across all languages +
// fuzz) and 1-2 orders of magnitude faster. LLVM (ReverseIDFCalculator),
// Joern (CdgPass) and WALA use the same formulation.
const children: number[][] = Array.from({ length: n }, () => []);
const inEdges: { from: number; kind: CfgEdgeKind }[][] = Array.from({ length: n }, () => []);
for (let b = 0; b < n; b++) {
const ip = ipdom[b];
if (ip !== NO_IPDOM && ip >= 0 && ip < n) children[ip].push(b);
}
for (const e of cfg.edges) {
if (e.from < 0 || e.from >= n || e.to < 0 || e.to >= n) continue;
inEdges[e.to].push({ from: e.from, kind: e.kind });
}
scan: for (const e of cfg.edges) {
const a = e.from;
const b = e.to;
if (a < 0 || a >= n || b < 0 || b >= n) continue;
// No control dependence when B post-dominates A — every path leaving A
// through this edge still reaches B, so A does not decide B's execution.
// This guard is exactly AC2: a dependence exists IFF post-dominance fails.
if (postDominates(tree, b, a)) continue;
// Post-dom-tree post-order (children before parents). Iterative — the post-dom
// forest can itself be chain-deep. Roots are the NO_IPDOM nodes (EXIT, plus
// any exit-unreachable region per #2188 F2). The reverse of a root-first DFS
// visits every parent AFTER all its descendants.
const dfs: number[] = [];
for (let r = 0; r < n; r++) if (ipdom[r] === NO_IPDOM) dfs.push(r);
const preorder: number[] = [];
while (dfs.length) {
const x = dfs.pop() as number;
preorder.push(x);
for (const c of children[x]) dfs.push(c);
}
const order = preorder.reverse();
// Sense is read from the CONTROLLER's arms, not this edge's kind alone —
// seq/loop-back fall-through false arms would otherwise mislabel as 'T'
// (#2188 F1).
const label = labelFor(e.kind, armSenses[a]);
const stop = ipdom[a]; // walk up to ipdom(A), EXCLUSIVE (NO_IPDOM ⇒ to root)
let cur = b;
let steps = 0;
// `steps <= n` is defensive — the ipdom chain is a finite tree.
while (cur !== NO_IPDOM && cur !== stop && steps <= n) {
const key = `${a}:${cur}:${label}`;
if (!seen.has(key)) {
// Check BEFORE pushing so `truncated` means a genuine overflow (a new
// unique edge had to be dropped), not merely "reached the ceiling" —
// exactly `cap` edges is a full, non-truncated result.
if (out.length >= cap) {
truncated = true;
break scan;
}
seen.add(key);
out.push({ controllerBlock: a, dependentBlock: cur, label });
// PDF[X]: controller A → the label SET with which A controls X. A set (not one
// label) because a controller can reach X via opposite-sense arms (goto-
// cycles) — the old (a, cur, label) dedup kept both rows.
const pdf: Map<number, Set<CdgLabel>>[] = Array.from({ length: n }, () => new Map());
const add = (x: number, a: number, label: CdgLabel): void => {
const set = pdf[x].get(a);
if (set) set.add(label);
else pdf[x].set(a, new Set([label]));
};
for (const x of order) {
// PDF_local: a CFG-predecessor A of X that X does not (immediately) post-
// dominate. `A !== X && ipdom[A] !== X` is exactly the production
// `!postDominates(X, A)` for one edge A→X (postDominates(X,A) ⟺ ipdom[A]===X),
// and it excludes self-edges + NO_IPDOM regions. Sense is read from the
// CONTROLLER's arms (seq/loop-back fall-through false arms would otherwise
// mislabel as 'T' — #2188 F1).
for (const { from: a, kind } of inEdges[x]) {
if (a !== x && ipdom[a] !== x) add(x, a, labelFor(kind, armSenses[a]));
}
// PDF_up: inherit each post-dom child's frontier controller (with its label
// set) when X does not post-dominate it.
for (const z of children[x]) {
for (const [a, labels] of pdf[z]) {
if (ipdom[a] !== x) for (const l of labels) add(x, a, l);
}
cur = ipdom[cur];
steps += 1;
}
}
const out: ControlDepEdge[] = [];
for (const x of order) {
for (const [a, labels] of pdf[x]) {
for (const label of labels) out.push({ controllerBlock: a, dependentBlock: x, label });
}
}
@ -168,5 +209,13 @@ export function computeControlDependence(
x.dependentBlock - y.dependentBlock ||
(x.label < y.label ? -1 : x.label > y.label ? 1 : 0),
);
// `maxEdges` is a heap-safety backstop applied to the SORTED set (the DF makes
// overflow far rarer than the old per-edge walk). Deterministic prefix, never
// a silent drop; mirrors computeReachingDefs' `truncated`.
let truncated = false;
if (cap !== Infinity && out.length > cap) {
truncated = true;
out.length = cap;
}
return { edges: out, truncated };
}

View file

@ -126,6 +126,18 @@ export class ControlFlowContext {
);
}
/**
* Resolve a Java `yield e` (switch-EXPRESSION arm exit): the nearest enclosing
* SWITCH frame's exit, threading the finalizers stacked above it. Unlike a
* `break`, a `yield` ALWAYS targets the switch — never an intervening loop — so
* it cannot match a loop frame (a `yield` inside a loop inside a switch arm
* still exits the whole switch). Returns `undefined` when there is no enclosing
* switch (malformed input); the caller falls back to its conservative routing.
*/
resolveYield(): JumpResolution | undefined {
return this.resolve((f) => f.kind === 'switch');
}
/** Every active finalizer, innermost first — what a `return` must cross. */
finalizersForReturn(): readonly FinalizerFrame[] {
const fins: FinalizerFrame[] = [];

View file

@ -27,6 +27,7 @@ import {
isExitReachableFromAllBlocks,
NO_IPDOM,
} from './post-dominators.js';
import { augmentForPostDom } from './synthetic-escape.js';
import type { BindingEntry, FunctionCfg } from './types.js';
/**
@ -94,6 +95,32 @@ export const REACHING_DEF_FACTS_PER_EDGE_CAP = 4;
export const DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION =
REACHING_DEF_FACTS_PER_EDGE_CAP * DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION;
/**
* Fixpoint-iteration budget for {@link computeReachingDefs}, as a multiple of
* the function's block count ({@link emitFileReachingDefs} passes
* `blocks.length × this` as `maxBlockVisits`). Iterative reaching-defs on a
* reducible CFG converges in O(loop-nesting-depth) passes, so a worklist
* re-visits each block a small multiple of times for real code; this budget
* tolerates a nesting depth far beyond any hand-written function (real code is
* ≤ ~15 deep) while truncating the pathological deep nest that otherwise drives
* the solver to O(blocks²) — measured at seconds + GB on a machine-generated
* 2000-line all-loops function whose fact count stays linear (so `maxFacts`
* never fires). Truncation degrades to a sound empty REACHING_DEF for that one
* function (status `truncated`), never wrong facts.
*
* As of #2201 this ceiling is an adversarial-only backstop that effectively
* never fires on real code: the production solver auto-selects the SSA-sparse
* path for the looping functions that would breach it, and the SSA path has no
* fixpoint iteration (it answers reaching queries from the def-use graph in one
* pass) so it computes the full facts where the dense worklist would have
* truncated. The budget is still consulted on the dense fallback path (small /
* loop-free functions, and throw-edge / unreachable-block functions the SSA path
* does not model). WTO / loop-aware iteration ordering was benchmarked and
* rejected (0% faster — the cost was dense-set propagation, not visitation
* order); SSA-sparse was the real fix. See reaching-defs.ts.
*/
export const DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS = 64;
export interface CfgEmitResult {
blocks: number;
edges: number;
@ -358,7 +385,10 @@ export function emitFileReachingDefs(
);
continue;
}
const r = computeReachingDefs(cfg, { maxFacts });
const r = computeReachingDefs(cfg, {
maxFacts,
maxBlockVisits: cfg.blocks.length * DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS,
});
if (r.status === 'no-facts') continue;
result.facts += r.facts.length;
@ -511,12 +541,24 @@ export function emitFileCdg(
for (const cfg of cfgs) {
const { filePath, functionStartLine, functionStartColumn } = cfg;
// Synthetic-escape pass (#2197 U1): restore EXIT reverse-reachability for a
// genuine exit-unreachable CYCLE (an unconditional `goto`-cycle / infinite
// loop) so the post-dom / CDG pass runs instead of being withheld. A no-op
// (returns `cfg` unchanged) for terminating functions and properly-escaped
// loops — those stay byte-identical. The synthetic edges are ANALYSIS-ONLY:
// they live on the returned shallow clone, never on the persisted `cfg`, so
// CFG / REACHING_DEF and the byte-identical-off golden are unaffected. Both
// the gate below AND the post-dom / CDG passes must see the augmented view
// (KTD7 — the Ferrante walk re-reads `cfg.edges`).
const view = augmentForPostDom(cfg);
// Sound post-dominance requires EXIT reachable from every entry-reachable
// block (#2188 review). A CFG that violates it — a future visitor's
// multi-terminal / non-terminating shape — would yield a CDG that both
// drops real and invents spurious dependences, so skip CDG for it. CFG and
// REACHING_DEF (emitted elsewhere, independent of post-dominance) are kept.
if (!isExitReachableFromAllBlocks(cfg)) {
// block (#2188 review). The synthetic-escape pass recovers genuine cycles;
// anything STILL unreachable after it is a residual non-cycle anomaly (a
// dangling/dead-end block, a branch-less trapping spin, or a construction
// error) — NOT something we bridge (that would mask the bug). Skip CDG for
// it and surface the skip. CFG and REACHING_DEF (emitted elsewhere,
// independent of post-dominance) are kept.
if (!isExitReachableFromAllBlocks(view)) {
result.skippedUnsoundFunctions++;
onWarn?.(
`[cdg] ${filePath}:${functionStartLine}: EXIT not reachable from all ` +
@ -525,13 +567,16 @@ export function emitFileCdg(
continue;
}
// Compute the post-dom tree once and feed it to the control-dependence
// pass (avoids recomputing it) and to the optional POST_DOMINATE emit.
const tree = computePostDominators(cfg);
// pass (avoids recomputing it) and to the optional POST_DOMINATE emit. The
// CDG edges reference BLOCK INDICES, which are identical in `view` and `cfg`
// (the augmentation only appends edges), so persisting them keyed off the
// original block ids is correct.
const tree = computePostDominators(view);
// Bound the pre-dedup materialization (heap parity with REACHING_DEF). The
// fixed ceiling is a catastrophe backstop; the per-function edge cap below
// remains the reporting authority. A ceiling hit is surfaced, not silent.
const { edges: cdgEdges, truncated } = computeControlDependence(
cfg,
view,
tree,
DEFAULT_PDG_MAX_CDG_MATERIALIZATION_PER_FUNCTION,
);

View file

@ -0,0 +1,318 @@
/**
* Pure graph sub-stages for the reaching-definitions solvers (#2201 review R4).
*
* Extracted from reaching-defs.ts to keep that module focused on the
* orchestrator, the dense oracle, the statement sweep, and the dispatcher.
* Everything here is a pure function of plain arrays — no CFG, no harvest, no
* solver state — so this module has NO dependency on reaching-defs.ts (a strict
* one-way import) and each stage is independently testable. The SSA pipeline
* (dominators → dominance frontiers → Tarjan SCC → reach-set condensation)
* implements Cooper-Harvey-Kennedy + Cytron + Tarjan; reverse-post-order, the
* loop-reachability check, and the def-set/lattice primitives are shared with
* the dense GEN/KILL solver and the dispatcher.
*
* These are held byte-identical to their former inline form by the differential
* equivalence fuzz (test/unit/cfg/reaching-defs-equivalence.test.ts) — any diff
* after extraction is an extraction bug, never the oracle.
*/
/** def-site keys reaching a program point (see reaching-defs.ts). */
type DefSet = Set<number>;
/** bindingIdx → def-site keys (the dense solver's per-block lattice). */
type Lattice = Map<number, DefSet>;
/**
* RPO over blocks reachable from `entry`; unreachable blocks appended by index.
* Returns the order AND the reachability bitmap the DFS already computed, so a
* caller needing "is every block reachable?" reuses this pass instead of a
* separate BFS (#2201 review R8 — the SSA path's reachability gate).
*
* @internal
*/
export function reversePostOrder(
entry: number,
succs: readonly number[][],
n: number,
): { order: number[]; visited: boolean[] } {
const visited = new Array<boolean>(n).fill(false);
const post: number[] = [];
// Iterative DFS with an explicit phase stack (children pushed in reverse so
// they pop in sorted order — determinism).
const stack: { node: number; childIdx: number }[] = [{ node: entry, childIdx: 0 }];
visited[entry] = true;
while (stack.length) {
const top = stack[stack.length - 1];
const children = succs[top.node];
if (top.childIdx < children.length) {
const next = children[top.childIdx];
top.childIdx += 1;
if (!visited[next]) {
visited[next] = true;
stack.push({ node: next, childIdx: 0 });
}
} else {
post.push(top.node);
stack.pop();
}
}
const order = post.reverse();
for (let b = 0; b < n; b++) if (!visited[b]) order.push(b);
return { order, visited };
}
/**
* Immediate dominators (Cooper-Harvey-Kennedy; correct on irreducible CFGs).
* `rpo` is the reverse-post-order rooted at the synthetic start `S`, `dPredsX`
* the dominator-graph predecessors (incl. S→entry). Returns idom[b] for every
* node in [0, nx); idom[S] === S.
*
* @internal
*/
export function buildDominators(
rpo: readonly number[],
dPredsX: readonly number[][],
S: number,
nx: number,
): number[] {
const rpoIdx = new Array<number>(nx);
rpo.forEach((b, i) => (rpoIdx[b] = i));
const idom = new Array<number>(nx).fill(-1);
idom[S] = S;
const intersect = (a: number, b: number): number => {
while (a !== b) {
while (rpoIdx[a] > rpoIdx[b]) a = idom[a];
while (rpoIdx[b] > rpoIdx[a]) b = idom[b];
}
return a;
};
for (let changed = true; changed; ) {
changed = false;
for (const b of rpo) {
if (b === S) continue;
let nd = -1;
for (const p of dPredsX[b]) if (idom[p] !== -1) nd = nd === -1 ? p : intersect(nd, p);
if (nd !== -1 && idom[b] !== nd) {
idom[b] = nd;
changed = true;
}
}
}
return idom;
}
/**
* Dominance frontiers (Cytron). df[b] is the set of nodes where b's dominance
* ends — the φ-placement targets for any binding defined in b.
*
* @internal
*/
export function buildDominanceFrontiers(
dPredsX: readonly number[][],
idom: readonly number[],
nx: number,
): Set<number>[] {
const df: Set<number>[] = Array.from({ length: nx }, () => new Set<number>());
for (let b = 0; b < nx; b++) {
const dp = dPredsX[b];
if (dp.length < 2) continue;
for (const p of dp) {
let runner = p;
while (runner !== idom[b] && runner !== -1) {
df[runner].add(b);
runner = idom[runner];
}
}
}
return df;
}
/**
* Tarjan strongly-connected components over the value-graph operand edges
* (`nodeOps[node]` = operand node ids). Iterative (explicit work stack — the
* graph can be deep). SCCs are emitted in REVERSE topological order, so an
* SCC's operand SCCs are numbered before it — the property
* {@link condenseReachingSets} relies on for its single forward pass.
*
* @internal
*/
export function tarjanScc(nodeOps: readonly number[][]): {
sccOf: number[];
sccMembers: number[][];
} {
const N = nodeOps.length;
const sccOf = new Array<number>(N).fill(-1);
const sccMembers: number[][] = [];
const index = new Array<number>(N).fill(-1);
const low = new Array<number>(N).fill(0);
const onStk = new Array<boolean>(N).fill(false);
const tarjanStk: number[] = [];
let counter = 0;
for (let start = 0; start < N; start++) {
if (index[start] !== -1) continue;
const work: { node: number; oi: number }[] = [{ node: start, oi: 0 }];
index[start] = low[start] = counter++;
tarjanStk.push(start);
onStk[start] = true;
while (work.length) {
const top = work[work.length - 1];
const ops = nodeOps[top.node];
if (top.oi < ops.length) {
const w = ops[top.oi++];
if (index[w] === -1) {
index[w] = low[w] = counter++;
tarjanStk.push(w);
onStk[w] = true;
work.push({ node: w, oi: 0 });
} else if (onStk[w] && index[w] < low[top.node]) {
low[top.node] = index[w];
}
} else {
if (low[top.node] === index[top.node]) {
const members: number[] = [];
let w: number;
do {
w = tarjanStk.pop()!;
onStk[w] = false;
sccOf[w] = sccMembers.length;
members.push(w);
} while (w !== top.node);
sccMembers.push(members);
}
work.pop();
if (work.length) {
const par = work[work.length - 1].node;
if (low[top.node] < low[par]) low[par] = low[top.node];
}
}
}
}
return { sccOf, sccMembers };
}
/**
* Reaching def-key set per SCC via condensation (cycle-safe union). Tarjan emits
* SCCs in reverse topological order, so a single forward pass over SCCs resolves
* every union: an SCC's reaching set is its members' own leaf keys plus the
* already-computed reaching sets of its cross-SCC operands.
*
* Alias fast path (#2201 review R2): an SCC with NO own leaf keys whose cross-SCC
* operands all resolve to a SINGLE source SCC has exactly that source's reaching
* set — share it BY REFERENCE instead of copying element-by-element (the O(defs²)
* cost at wide-fan-in φ merges). Safe: the returned sets are read-only after this
* pass, and contents are identical (set iteration order is irrelevant — the
* sweep sorts each use's keys before emission, KTD6).
*
* @internal
*/
export function condenseReachingSets(
sccMembers: readonly number[][],
sccOf: readonly number[],
nodeKeys: readonly (DefSet | null)[],
nodeOps: readonly number[][],
): DefSet[] {
const reachByScc: DefSet[] = new Array(sccMembers.length);
for (let s = 0; s < sccMembers.length; s++) {
const members = sccMembers[s];
let aliasTarget = -1; // the unique cross-SCC source SCC, or -1 if none/many
let hasOwnKeys = false;
let multiSource = false;
for (const node of members) {
if (nodeKeys[node]) {
hasOwnKeys = true;
break;
}
for (const w of nodeOps[node]) {
const ws = sccOf[w];
if (ws === s) continue; // intra-SCC operand: same set being built, adds nothing
if (aliasTarget === -1) aliasTarget = ws;
else if (aliasTarget !== ws) {
multiSource = true;
break;
}
}
if (multiSource) break;
}
if (!hasOwnKeys && !multiSource && aliasTarget !== -1) {
reachByScc[s] = reachByScc[aliasTarget]; // zero-copy share
continue;
}
// General case: union own leaf keys + every distinct cross-SCC operand set.
const set: DefSet = new Set();
for (const node of members) {
const keys = nodeKeys[node];
if (keys) for (const k of keys) set.add(k);
for (const w of nodeOps[node]) {
const ws = sccOf[w];
if (ws !== s) for (const k of reachByScc[ws]) set.add(k);
}
}
reachByScc[s] = set;
}
return reachByScc;
}
/**
* True iff a cycle is reachable from `entry` (the CFG has a loop). Iterative DFS
* with a gray/black coloring; a gray successor is a back-edge. O(V+E). Used by
* the production dispatcher to decide SSA-vs-dense.
*
* @internal
*/
export function hasReachableLoop(entry: number, succs: readonly number[][], n: number): boolean {
const color = new Uint8Array(n); // 0 white, 1 gray, 2 black
const stack: { node: number; i: number }[] = [{ node: entry, i: 0 }];
color[entry] = 1;
while (stack.length) {
const top = stack[stack.length - 1];
const ss = succs[top.node];
if (top.i < ss.length) {
const next = ss[top.i++];
if (color[next] === 1) return true;
if (color[next] === 0) {
color[next] = 1;
stack.push({ node: next, i: 0 });
}
} else {
color[top.node] = 2;
stack.pop();
}
}
return false;
}
/**
* Order-stable union of two def-sets (shares `a` when `b` adds nothing).
*
* @internal
*/
export function unionSets(a: DefSet, b: DefSet): DefSet {
let target = a;
let copied = false;
for (const key of b) {
if (!target.has(key)) {
if (!copied) {
target = new Set(a);
copied = true;
}
target.add(key);
}
}
return target;
}
/**
* Per-binding lattice equality with a reference fast path (sets only ever grow).
*
* @internal
*/
export function latticeEquals(a: Lattice, b: Lattice): boolean {
if (a === b) return true;
if (a.size !== b.size) return false;
for (const [k, bSet] of b) {
const aSet = a.get(k);
if (aSet === bSet) continue;
if (!aSet || aSet.size !== bSet.size) return false;
for (const v of bSet) if (!aSet.has(v)) return false;
}
return true;
}

View file

@ -1,8 +1,27 @@
/**
* Reaching definitions (#2082 M2 U3) — classic GEN/KILL monotone fixpoint over
* one function's CFG, plus the canonical intra-block statement sweep that
* recovers statement-granular def→use facts from M1's coalesced blocks
* WITHOUT re-splitting the CFG.
* Reaching definitions (#2082 M2 U3, SSA-sparse rewrite #2201) — per-function
* intraprocedural may-reaching-definitions, plus the canonical intra-block
* statement sweep that recovers statement-granular def→use facts from M1's
* coalesced blocks WITHOUT re-splitting the CFG.
*
* ARCHITECTURE (#2201): the analysis is split into solver-INDEPENDENT stages
* (shared by every path, so the byte-identical surface is maximal) and a
* swappable IN-set computation:
* - {@link harvestStatementFacts} — per-block GEN/allDefs + def/use telemetry.
* - {@link buildAdjacency} — throw-aware predecessor/successor adjacency.
* - the IN-set computer — answers block-entry reaching-set queries. Two
* implementations: {@link computeInSetsSparse} (SSA — CHK dominators →
* Cytron dominance frontiers + φ-placement → stack renaming over a
* synthetic entry, walked SCC-condensed) and {@link computeInSetsDense}
* (the original GEN/KILL worklist). Production runs {@link
* computeInSetsAuto}, which picks the SSA solver for looping functions large
* enough to amortize construction (where it is asymptotically faster and
* never hits the dense ceiling) and the dense worklist everywhere else; the
* dense path also serves the throw-edge / unreachable-block cases the SSA
* path does not model. The two are held byte-identical by the equivalence
* fuzz — only set CONTENTS must match (the sweep sorts each use's keys
* before the maxFacts cutoff, so iteration order is irrelevant).
* - {@link sweepFacts} — statement sweep + sort + maxFacts truncation.
*
* PURE AND DETERMINISTIC (load-bearing contract):
* - Pure function of its inputs — no graph, no logger (warnings are the
@ -14,17 +33,10 @@
* insertion-ordered Maps/Sets throughout, and the output fact array is
* explicitly sorted. Snapshot tests and content-derived edge ids rely on it.
*
* COMPLEXITY DISCIPLINE (the four-times-repeated repo bug shape is per-item
* re-derivation inside the loop): def-sets are SHARED BY REFERENCE, never
* deep-copied — a MUST def's kill is total per binding, so a transfer either
* aliases the incoming set or replaces it; a MAY def (conditional context —
* see StatementFacts.mayDefs) unions WITHOUT killing via a copy-on-extend.
* Single-predecessor blocks alias the predecessor's OUT map outright;
* multi-pred merges union only bindings whose incoming sets differ by
* reference. Iteration is reverse post-order, seeded with every block
* (unreachable blocks keep ⊥ IN — correct, their defs reach nothing).
* Convergence: sets grow monotonically within the finite def-site universe ⇒
* ≤ loop-depth+1 passes in practice.
* COMPLEXITY DISCIPLINE: def-sets are SHARED BY REFERENCE, never deep-copied —
* a MUST def's kill is total per binding, so a transfer either aliases the
* incoming set or replaces it; a MAY def (conditional context — see
* StatementFacts.mayDefs) unions WITHOUT killing via a copy-on-extend.
*
* `limits.maxFacts` bounds materialization: facts are O(defs×uses) BY SPEC in
* merge-heavy code (N branch-arm defs × N later uses = N² facts), and a
@ -34,6 +46,16 @@
* as a per-function taint-coverage gap.
*/
import type { BindingEntry, FunctionCfg } from './types.js';
import {
buildDominanceFrontiers,
buildDominators,
condenseReachingSets,
hasReachableLoop,
latticeEquals,
reversePostOrder,
tarjanScc,
unionSets,
} from './reaching-defs-graph.js';
/** A statement-granular program point within one function's CFG. */
export interface ProgramPoint {
@ -67,6 +89,43 @@ export interface ReachingDefsLimits {
* `status: 'truncated'`. `undefined`/0 ⇒ unlimited.
*/
readonly maxFacts?: number;
/**
* Adversarial-only safety bound on the DENSE worklist's iteration.
*
* The dense GEN/KILL solver reads this as a ceiling on total block dequeues:
* iterative reaching-defs on a reducible CFG converges in O(loop-nesting-depth)
* passes, but a pathologically deep loop nest drives the visit total — and thus
* the solver — to O(blocks²), seconds + GB of heap (`maxFacts` does not help:
* fact count stays linear). Exceeding the budget means the fixpoint has NOT
* converged, so any facts would be unsound — the dense solver bails to a sound
* empty `status: 'truncated'` (like the `overflow` guard).
*
* The SSA solver (#2201) has NO fixpoint iteration — it answers reaching
* queries from the def-use graph in one pass — so it always converges and this
* budget never trips it. The production dispatcher ({@link computeInSetsAuto})
* routes the deep nests that would breach the dense ceiling to the SSA solver,
* which computes their full facts: the ceiling that fired on the dense worklist
* effectively never fires on real code (#2201 acceptance). The budget is still
* honored on the dense fallback path (small / loop-free functions, and the
* throw-edge / unreachable-block cases the SSA path does not model).
*
* `undefined`/0 ⇒ unlimited (the default for direct callers; the emit path sets
* a per-function budget).
*/
readonly maxBlockVisits?: number;
/**
* Memory bound on the SSA-sparse solver's value-graph construction (#2201
* review R1). `maxFacts` bounds fact MATERIALIZATION (sweepFacts) but nothing
* bounds the φ/value-graph the sparse path builds first; a high-binding-density
* deep loop routed to SSA (≥ SSA_MIN_BLOCKS blocks + a reachable loop) builds an
* O(blocks×bindings) graph the dense path would have truncated at the
* `maxBlockVisits` ceiling (~1.5 GB measured on a 3000-block × 300-binding
* function). When the projected node count would exceed this, the sparse solver
* falls back to the dense oracle (byte-identical, and bounded — dense honors
* `maxBlockVisits`). Honored ONLY by the sparse path; the dense solver ignores
* it. `undefined`/0 ⇒ {@link DEFAULT_MAX_SSA_VALUE_GRAPH_NODES}.
*/
readonly maxSsaValueGraphNodes?: number;
}
export interface FunctionDefUse {
@ -98,7 +157,7 @@ export interface FunctionDefUse {
* statements into one block, so an overflow would silently alias
* (block b, stmt STRIDE+k) with (block b+1, stmt k) and fabricate wrong-block
* facts. computeReachingDefs therefore range-checks up front and bails to a
* sound empty `truncated` result instead of ever letting a key alias.
* sound empty `overflow` result instead of ever letting a key alias.
* 2^21 statements per block × blocks ≤ 2^32 stays inside Number's 2^53.
*/
const STMT_STRIDE = 1 << 21;
@ -111,11 +170,125 @@ type Lattice = Map<number, DefSet>;
const EMPTY_LATTICE: Lattice = new Map();
/** A block's GEN entry for one binding: the genned set + whether it kills. */
interface GenEntry {
set: DefSet;
kills: boolean;
}
/** Solver-independent per-block facts (shared by both IN-set computers). */
interface Harvest {
/** gen[b]: bindingIdx → { set, kills }. A MUST def kills; a MAY def adds. */
readonly gen: readonly (Map<number, GenEntry> | null)[];
/** allDefsGen[b]: bindingIdx → EVERY def-site key in the block (throw edges). */
readonly allDefsGen: readonly (Lattice | null)[];
readonly defLine: ReadonlyMap<number, number>;
readonly defCount: number;
readonly useCount: number;
}
/** Throw-aware adjacency (shared by both IN-set computers). */
interface Adjacency {
readonly preds: readonly { from: number; viaThrow: boolean }[][];
readonly succs: readonly number[][];
/** Handlers whose IN depends on a block's IN (throw edges). */
readonly throwSuccs: readonly number[][];
}
/**
* Block-entry reaching-set accessor: the set of def-site keys of `binding`
* reaching `blockIndex`'s entry, or undefined when none reach. Both solvers
* expose their result through this accessor so the sweep is solver-agnostic;
* the dense oracle backs it with precomputed per-block lattices, the sparse
* solver computes it lazily from the SSA def-use graph. Because {@link
* sweepFacts} sorts each use's reaching keys before the maxFacts cutoff, only
* the set CONTENTS need to match across solvers — not iteration order.
*/
type ReachingAt = (blockIndex: number, binding: number) => DefSet | undefined;
/**
* The swappable stage: a block-entry reaching-set accessor, or a non-
* convergence signal (the work budget exceeded ⇒ sound empty `truncated`).
*/
type InSetsResult = { converged: true; reachingAt: ReachingAt } | { converged: false };
type InSetsComputer = (
cfg: FunctionCfg,
n: number,
h: Harvest,
adj: Adjacency,
limits: ReachingDefsLimits | undefined,
) => InSetsResult;
/**
* Compute reaching definitions for one function. See the module doc for the
* purity/determinism/sharing contract.
*
* This is the production entry point. As of #2201 it auto-dispatches via
* {@link computeInSetsAuto} — the SSA-sparse solver ({@link computeInSetsSparse})
* for looping functions large enough to amortize construction, the dense
* GEN/KILL worklist ({@link computeInSetsDense}) everywhere else (and for the
* throw-edge / unreachable-block functions the SSA path does not model). The two
* solvers are held byte-identical by the equivalence fuzz (status, bindings,
* sorted facts, def/use telemetry), so the dispatch is a pure performance
* heuristic; the dense solver doubles as that differential oracle.
*/
export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimits): FunctionDefUse {
// #2201: production auto-selects the solver per function (see
// {@link computeInSetsAuto}) — the SSA solver where it pays off (looping
// functions large enough to amortize construction, incl. the deep nests the
// dense ceiling used to truncate), the dense worklist everywhere else (small
// or loop-free functions, where it is faster). Both are held byte-identical
// by the equivalence fuzz, so the choice is a pure performance heuristic.
return solveReachingDefs(cfg, limits, computeInSetsAuto);
}
/**
* Dense GEN/KILL monotone worklist — the original (#2082 M2) reaching-defs
* solver. As of #2201 it plays two roles: (1) the production dispatcher
* ({@link computeInSetsAuto}) routes small / loop-free functions, and the
* throw-edge / unreachable-block functions the SSA path does not model, to this
* dense solver; (2) it is the differential equivalence ORACLE the fuzz checks
* the SSA path against. Keep it behavior-frozen — it is the ground truth.
*
* @internal exported for the equivalence fuzz harness (direct dense-vs-sparse
* comparison); the bench drives the production {@link computeReachingDefs}.
*/
export function computeReachingDefsDense(
cfg: FunctionCfg,
limits?: ReachingDefsLimits,
): FunctionDefUse {
return solveReachingDefs(cfg, limits, computeInSetsDense);
}
/**
* SSA-sparse reaching-defs (#2201) — exposed directly so the equivalence fuzz
* can drive the SSA solver on every eligible CFG (bypassing the production
* size/loop dispatch heuristic in {@link computeInSetsAuto}) and assert
* byte-identity against the dense oracle. See {@link computeInSetsSparse} for
* the algorithm and byte-identical contract.
*
* @internal exported only for the equivalence fuzz harness.
*/
export function computeReachingDefsSparse(
cfg: FunctionCfg,
limits?: ReachingDefsLimits,
): FunctionDefUse {
return solveReachingDefs(cfg, limits, computeInSetsSparse);
}
/**
* Shared orchestrator: the no-facts / overflow guards, the harvest, the
* adjacency build, the swappable IN-set computation, and the statement sweep.
* Only `computeInSets` differs between the production (sparse) and oracle
* (dense) paths — everything else is identical, which is what makes the two
* byte-identical by construction.
*/
function solveReachingDefs(
cfg: FunctionCfg,
limits: ReachingDefsLimits | undefined,
computeInSets: InSetsComputer,
): FunctionDefUse {
if (!cfg.bindings) {
return { status: 'no-facts', bindings: [], facts: [], defCount: 0, useCount: 0 };
}
@ -133,51 +306,46 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit
}
}
// ── adjacency (sorted for deterministic merges) ─────────────────────────
// A `throw` edge contributes IN(from) ∪ allDefs(from) to its handler, not
// OUT: an exception can fire BEFORE the block's defs complete (the seed def
// in `let x = seed(); try { x = risky(); } catch { sink(x) }` must reach the
// sink) AND between any two defs of a multi-def coalesced block (the parse
// def in `x = parse(a); x = normalize(x);` is live exactly when normalize
// throws — OUT's last-def-wins misses it). Sound over-approximation;
// monotone, so the fixpoint absorbs it. See mergePreds.
const preds: { from: number; viaThrow: boolean }[][] = Array.from({ length: n }, () => []);
const succs: number[][] = Array.from({ length: n }, () => []);
// Handlers whose IN depends on this block's IN (throw edges) — requeued on
// IN change, since a genned binding can absorb IN growth without changing
// OUT, which would otherwise leave the handler stale.
const throwSuccs: number[][] = Array.from({ length: n }, () => []);
for (const e of cfg.edges) {
// Optional-chained pushes drop out-of-range endpoints defensively — the
// emit path validates via isEmitSafeCfg, but this pure function also runs
// on hand-built CFGs.
succs[e.from]?.push(e.to);
preds[e.to]?.push({ from: e.from, viaThrow: e.kind === 'throw' });
if (e.kind === 'throw') throwSuccs[e.from]?.push(e.to);
const h = harvestStatementFacts(blocks, n);
const adj = buildAdjacency(cfg, n);
const solved = computeInSets(cfg, n, h, adj, limits);
if (!solved.converged) {
// Did NOT converge within the budget — the in-sets are not at the fixpoint,
// so any facts would be unsound. Bail to a sound empty `truncated` result
// (a coverage gap, not an error), carrying the def/use telemetry gathered.
return {
status: 'truncated',
bindings: cfg.bindings,
facts: [],
defCount: h.defCount,
useCount: h.useCount,
};
}
for (const list of preds) {
list.sort((a, b) => a.from - b.from || Number(a.viaThrow) - Number(b.viaThrow));
// duplicate (from, throw+non-throw) pairs both survive — the throw leg
// adds IN(from); the merge dedups set-wise.
}
for (const list of succs) list.sort((a, b) => a - b);
// ── per-block GEN + def/use telemetry ────────────────────────────────────
// gen[b]: bindingIdx → { set, kills }. A MUST def resets the accumulated
// set (kill is total); a MAY def (conditionally-evaluated context — see
// StatementFacts.mayDefs) only ADDS: the binding's incoming defs survive,
// so the transfer is out[x] = kills ? set : in[x] ∪ set.
interface GenEntry {
set: DefSet;
kills: boolean;
}
const maxFacts = limits?.maxFacts && limits.maxFacts > 0 ? limits.maxFacts : Infinity;
const { facts, truncated } = sweepFacts(blocks, solved.reachingAt, h.defLine, maxFacts);
return {
status: truncated ? 'truncated' : 'computed',
bindings: cfg.bindings,
facts,
defCount: h.defCount,
useCount: h.useCount,
};
}
/**
* Per-block GEN + def/use telemetry. gen[b]: bindingIdx → { set, kills }. A
* MUST def resets the accumulated set (kill is total); a MAY def (conditionally-
* evaluated context — see StatementFacts.mayDefs) only ADDS: the binding's
* incoming defs survive, so the transfer is out[x] = kills ? set : in[x] ∪ set.
* allDefsGen[b] is what a throw edge delivers to its handler: an exception can
* fire between any two statements, so every intermediate def may be the live one
* at the handler — IN∪OUT alone misses defs overwritten later in the same
* coalesced block.
*/
function harvestStatementFacts(blocks: FunctionCfg['blocks'], n: number): Harvest {
const gen: (Map<number, GenEntry> | null)[] = new Array(n).fill(null);
// allDefsGen[b]: bindingIdx → EVERY def-site key in the block (must + may).
// This is what a throw edge delivers to its handler: an exception can fire
// between any two statements, so every intermediate def may be the live one
// at the handler — IN∪OUT alone misses defs overwritten later in the same
// coalesced block (`try { x = parse(a); x = normalize(x); } catch { sink(x) }`
// — parse's value is exactly what sink sees when normalize throws).
const allDefsGen: (Lattice | null)[] = new Array(n).fill(null);
const defLine = new Map<number, number>(); // defKey → source line
let defCount = 0;
@ -212,21 +380,82 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit
gen[b.index] = g;
allDefsGen[b.index] = all;
}
return { gen, allDefsGen, defLine, defCount, useCount };
}
// ── iteration order: RPO over reachable blocks, then the rest by index ──
const order = reversePostOrder(cfg.entryIndex, succs, n);
/**
* Throw-aware predecessor/successor adjacency, sorted for deterministic merges.
* A `throw` edge contributes IN(from) ∪ allDefs(from) to its handler, not OUT:
* an exception may fire BEFORE the block's defs complete (the seed def in
* `let x = seed(); try { x = risky(); } catch { sink(x) }` must reach the sink)
* AND between any two defs of a multi-def coalesced block. Sound over-
* approximation; monotone, so the fixpoint absorbs it. See mergePreds.
*/
function buildAdjacency(cfg: FunctionCfg, n: number): Adjacency {
const preds: { from: number; viaThrow: boolean }[][] = Array.from({ length: n }, () => []);
const succs: number[][] = Array.from({ length: n }, () => []);
// Handlers whose IN depends on this block's IN (throw edges) — requeued on
// IN change, since a genned binding can absorb IN growth without changing
// OUT, which would otherwise leave the handler stale.
const throwSuccs: number[][] = Array.from({ length: n }, () => []);
for (const e of cfg.edges) {
// Optional-chained pushes drop out-of-range endpoints defensively — the
// emit path validates via isEmitSafeCfg, but this pure function also runs
// on hand-built CFGs.
succs[e.from]?.push(e.to);
preds[e.to]?.push({ from: e.from, viaThrow: e.kind === 'throw' });
if (e.kind === 'throw') throwSuccs[e.from]?.push(e.to);
}
for (const list of preds) {
list.sort((a, b) => a.from - b.from || Number(a.viaThrow) - Number(b.viaThrow));
// duplicate (from, throw+non-throw) pairs both survive — the throw leg
// adds IN(from); the merge dedups set-wise.
}
for (const list of succs) list.sort((a, b) => a - b);
return { preds, succs, throwSuccs };
}
/**
* DENSE IN-set computer — the original monotone GEN/KILL worklist. Iterates in
* reverse post-order, seeded with every block (unreachable blocks keep ⊥ IN —
* correct, their defs reach nothing). Convergence: sets grow monotonically
* within the finite def-site universe ⇒ ≤ loop-depth+1 passes in practice.
*
* WTO / loop-aware iteration (Bourdoncle 1993) was evaluated as a fix for the
* O(blocks²) deep-loop-nest blow-up and REJECTED (#2195): on the dense-loop
* benchmark a faithful weak-topological-order solver was 104/104 byte-identical
* but 0% faster — the cost is inherent to dense-set propagation + lattice
* merges, not visitation order. The asymptotic fix shipped in #2201: the
* SSA-sparse solver ({@link computeInSetsSparse}). This dense version is retained
* only as the differential equivalence oracle the fuzz checks SSA against.
*
* @internal
*/
function computeInSetsDense(
cfg: FunctionCfg,
n: number,
h: Harvest,
adj: Adjacency,
limits: ReachingDefsLimits | undefined,
): InSetsResult {
const { gen, allDefsGen } = h;
const { preds, succs, throwSuccs } = adj;
const { order } = reversePostOrder(cfg.entryIndex, succs, n);
// ── fixpoint ────────────────────────────────────────────────────────────
const inSets: Lattice[] = new Array(n).fill(EMPTY_LATTICE);
const outSets: Lattice[] = new Array(n).fill(EMPTY_LATTICE);
const inWorklist = new Array(n).fill(true);
let pending = n;
const maxBlockVisits =
limits?.maxBlockVisits && limits.maxBlockVisits > 0 ? limits.maxBlockVisits : Infinity;
let blockVisits = 0;
while (pending > 0) {
for (const b of order) {
if (!inWorklist[b]) continue;
inWorklist[b] = false;
pending -= 1;
if (++blockVisits > maxBlockVisits) return { converged: false };
const p = preds[b];
const inB: Lattice =
@ -271,17 +500,367 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit
}
}
// ── statement sweep: recover statement-granular def→use facts ───────────
const maxFacts = limits?.maxFacts && limits.maxFacts > 0 ? limits.maxFacts : Infinity;
return { converged: true, reachingAt: (blockIndex, binding) => inSets[blockIndex]?.get(binding) };
}
/**
* SPARSE IN-set computer (#2201) — the production solver. Instead of the dense
* GEN/KILL worklist's per-block lattice fixpoint, it builds pruned SSA for the
* function (Cooper-Harvey-Kennedy dominators → Cytron dominance frontiers and
* φ-placement → stack-based renaming) and answers block-entry reaching-def
* queries by walking the SSA def-use graph. φ-nodes statically capture loop
* merges, so a use's reaching set is recovered without iterating the loop
* (depth-independent), and pass-through blocks carry the dominating definition
* via the rename stack rather than re-materializing a dense lattice at every
* block — the two effects that make it faster than the dense solver on the
* deep-nest and dense-bindings pathologies.
*
* BYTE-IDENTICAL CONTRACT: it computes the same may-reaching-definition SET at
* each block entry as {@link computeInSetsDense}. Order does not matter — {@link
* sweepFacts} sorts each use's reaching keys before the maxFacts cutoff (#2201
* KTD6) — so only set CONTENTS must match; the equivalence fuzz holds the line.
*
* SCOPE (KTD4): the SSA path covers fully-reachable CFGs with kill/may-def
* transfers, reducible AND irreducible (CHK + Cytron are correct on irreducible
* graphs). It does NOT model throw edges' IN∪allDefs handler semantics or
* propagation among unreachable blocks; functions with either are routed to the
* dense oracle — byte-identical and correct, just not asymptotically faster.
* These are not the perf pathologies (deep nests / dense-bindings are
* throw-free and fully reachable), so the win lands where it matters.
*
* No fixpoint iteration ⇒ the solve always converges in O(program); the
* `maxBlockVisits` ceiling that fired on the dense worklist's deep nests never
* fires here (#2201 acceptance). The bound is honored only on the dense
* fallback path.
*
* @internal
*/
function computeInSetsSparse(
cfg: FunctionCfg,
n: number,
h: Harvest,
adj: Adjacency,
limits: ReachingDefsLimits | undefined,
): InSetsResult {
const nBindings = cfg.bindings?.length ?? 0;
if (nBindings === 0) return { converged: true, reachingAt: () => undefined };
const { gen } = h;
const { preds, succs, throwSuccs } = adj;
const entry = cfg.entryIndex;
// Gate to the dense oracle for the shapes the SSA path does not model.
for (const list of throwSuccs) if (list.length) return computeInSetsDense(cfg, n, h, adj, limits);
// Malformed-input guard: an out-of-range binding index (negative or
// ≥ nBindings — a corrupted/stale durable parsedfile store) would crash the
// SSA path's nBindings-sized arrays (defBlocks[v]/stacks[u]). The dense solver
// tolerates any index (its lattice is a Map), so fall back — keeping the two
// byte-identical AND preserving the graceful per-function degradation the
// dense path gave (a throw here would escape the unguarded taint/harvest call
// sites and lose the whole file's taint layer). See hasEmitSafeFacts (emit.ts).
for (const b of cfg.blocks) {
const stmts = b.statements;
if (!stmts) continue;
for (const s of stmts) {
for (const d of s.defs)
if (d < 0 || d >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits);
for (const u of s.uses)
if (u < 0 || u >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits);
if (s.mayDefs)
for (const d of s.mayDefs)
if (d < 0 || d >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits);
}
}
// Synthetic pre-entry block (#2201): textbook SSA construction assumes the
// entry has no predecessors. A loop back-edge into the entry — or a self-loop
// on it — makes the entry a merge that needs a φ, and the dominance-frontier
// walk degenerates when idom[entry] === entry (it never lands the entry in its
// own frontier). A virtual start node S → entry (S itself has no preds)
// restores the invariant: idom[entry] = S, the entry joins {start ⊔
// back-edges}, and the implicit start operand contributes ⊥ (an empty rename
// stack). S carries no statements, gen, or uses and is never queried.
const S = n;
const nx = n + 1;
const succsX: number[][] = new Array(nx);
for (let b = 0; b < n; b++) succsX[b] = succs[b] as number[];
succsX[S] = [entry];
const dPredsX: number[][] = new Array(nx);
for (let b = 0; b < n; b++) {
// preds[b] is pre-sorted by `from` (buildAdjacency), so duplicate `from`
// values (a throw + non-throw edge to the same handler, or parallel edges)
// are ADJACENT — dedup by skipping consecutive equals instead of a per-block
// Set + spread + sort (#2201 review R9). S = n exceeds every block index, so
// appending it for the entry keeps the list ascending without a re-sort.
const list: number[] = [];
let last = -1;
for (const p of preds[b]) {
if (p.from !== last) {
list.push(p.from);
last = p.from;
}
}
if (b === entry) list.push(S);
dPredsX[b] = list;
}
dPredsX[S] = [];
// ── dominators (Cooper-Harvey-Kennedy; correct on irreducible CFGs) ──
// RPO rooted at the synthetic entry. `reachX` is the reachability the DFS
// already computed — reused for the unreachable-block gate below instead of a
// separate BFS (#2201 review R8). Because S→entry is S's only edge, reachX[b]
// (b<n) is exactly "reachable from entry", identical to the old BFS gate.
const { order: rpo, visited: reachX } = reversePostOrder(S, succsX, nx);
// The SSA path does not model propagation among unreachable blocks (KTD4) —
// fall back to the dense oracle if any block is unreachable from the entry.
for (let b = 0; b < n; b++) if (!reachX[b]) return computeInSetsDense(cfg, n, h, adj, limits);
const idom = buildDominators(rpo, dPredsX, S, nx);
// ── dominance frontiers (Cytron) ──
const df = buildDominanceFrontiers(dPredsX, idom, nx);
// ── per-binding def blocks (must- or may-def ⇒ block transfer touches v) ──
const defBlocks: number[][] = Array.from({ length: nBindings }, () => []);
for (let b = 0; b < n; b++) {
const g = gen[b];
if (g) for (const v of g.keys()) defBlocks[v].push(b);
}
// ── value-graph nodes: leaves carry def-site keys; internal nodes (φ /
// may-def union) carry operand node ids. reachingSet(node) = union of all
// leaf keys reachable through operands (computed once, cycle-safe, below).
const nodeKeys: (DefSet | null)[] = [];
const nodeOps: number[][] = [];
const newLeaf = (keys: DefSet): number => (
nodeKeys.push(keys),
nodeOps.push([]),
nodeKeys.length - 1
);
const newInternal = (): number => (nodeKeys.push(null), nodeOps.push([]), nodeKeys.length - 1);
// ── φ-placement: φ for v at the iterated dominance frontier of v's defs ──
const phiNode: (Map<number, number> | null)[] = new Array(nx).fill(null);
for (let v = 0; v < nBindings; v++) {
const dB = defBlocks[v];
if (dB.length === 0) continue;
const placed = new Set<number>();
const inWork = new Set<number>(dB);
const work = [...dB];
while (work.length) {
const x = work.pop()!;
for (const y of df[x]) {
if (placed.has(y)) continue;
placed.add(y);
let m = phiNode[y];
if (!m) phiNode[y] = m = new Map();
m.set(v, newInternal());
if (!inWork.has(y)) {
inWork.add(y);
work.push(y);
}
}
}
}
// ── memory bound (#2201 review R1): cap the value graph, else fall back ──
// After φ-placement, nodeKeys.length == the φ-node count — the term that grows
// superlinearly with the input on the deep-loop / dense-binding pathology.
// Renaming below adds at most ~2 nodes per gen entry (already bounded by the
// def-site universe the STMT_STRIDE overflow guard caps). If the projected
// total would exceed the budget, fall back to the dense oracle here — BEFORE
// paying for renaming + Tarjan SCC on a blown-up graph. Byte-identical (dense
// is the equivalence oracle) and bounded (dense honors maxBlockVisits). Mirrors
// the throw-edge / unreachable / OOB-binding gates at the top of this function.
const nodeBudget =
limits?.maxSsaValueGraphNodes && limits.maxSsaValueGraphNodes > 0
? limits.maxSsaValueGraphNodes
: DEFAULT_MAX_SSA_VALUE_GRAPH_NODES;
let projectedRenameNodes = 0;
for (let b = 0; b < n; b++) projectedRenameNodes += (gen[b]?.size ?? 0) * 2;
if (nodeKeys.length + projectedRenameNodes > nodeBudget) {
return computeInSetsDense(cfg, n, h, adj, limits);
}
// ── renaming (iterative dominator-tree DFS, per-binding value stacks) ──
const domChildren: number[][] = Array.from({ length: nx }, () => []);
for (let b = 0; b < nx; b++) if (b !== S && idom[b] !== -1) domChildren[idom[b]].push(b);
for (const list of domChildren) list.sort((a, b) => a - b);
const stacks: number[][] = Array.from({ length: nBindings }, () => []);
const entryValue: (Map<number, number> | null)[] = new Array(nx).fill(null);
const enterBlock = (b: number): number[] => {
const pushed: number[] = [];
const pm = phiNode[b];
if (pm)
for (const [v, node] of pm) {
stacks[v].push(node);
pushed.push(v);
}
// record block-entry (IN) value for each binding USED here — after φ push,
// before this block's own gen (the sweep applies intra-block defs itself).
// The synthetic entry S has no block ⇒ no statements/gen/uses.
const stmts = cfg.blocks[b]?.statements;
if (stmts) {
let ev: Map<number, number> | null = null;
for (const s of stmts)
for (const u of s.uses) {
const st = stacks[u];
if (st.length) {
if (!ev) ev = new Map();
ev.set(u, st[st.length - 1]);
}
}
entryValue[b] = ev;
}
// apply block gen ⇒ OUT values that flow to successors
const g = gen[b];
if (g)
for (const [v, ge] of g) {
const st = stacks[v];
let node: number;
if (ge.kills) {
node = newLeaf(ge.set);
} else {
node = newInternal();
if (st.length) nodeOps[node].push(st[st.length - 1]); // prior reaching (may-def keeps it)
nodeOps[node].push(newLeaf(ge.set));
}
st.push(node);
pushed.push(v);
}
// fill successor φ operands with this block's current OUT for each φ binding
for (const s of succsX[b]) {
const sm = phiNode[s];
if (!sm) continue;
for (const [v, phi] of sm) {
const st = stacks[v];
if (st.length) nodeOps[phi].push(st[st.length - 1]);
}
}
return pushed;
};
const frames: { b: number; ci: number; pushed: number[] }[] = [
{ b: S, ci: 0, pushed: enterBlock(S) },
];
while (frames.length) {
const f = frames[frames.length - 1];
const kids = domChildren[f.b];
if (f.ci < kids.length) {
const c = kids[f.ci++];
frames.push({ b: c, ci: 0, pushed: enterBlock(c) });
} else {
for (const v of f.pushed) stacks[v].pop();
frames.pop();
}
}
// ── reaching sets per node via SCC condensation (cycle-safe union) ──
// Tarjan condenses the value graph (operand cycles from loop φs collapse to a
// single SCC); a forward pass over the reverse-topo SCC order unions each
// SCC's reaching set from its operands' (alias fast path for single-source
// SCCs — #2201 review R2). Both stages are pure (reaching-defs-graph.ts).
const { sccOf, sccMembers } = tarjanScc(nodeOps);
const reachByScc = condenseReachingSets(sccMembers, sccOf, nodeKeys, nodeOps);
return {
converged: true,
reachingAt: (blockIndex, binding) => {
const node = entryValue[blockIndex]?.get(binding);
if (node === undefined) return undefined;
const set = reachByScc[sccOf[node]];
return set.size ? set : undefined;
},
};
}
/**
* Minimum block count below which SSA construction (dominators + dominance
* frontiers + φ-placement + renaming + SCC) does not amortize over the dense
* worklist's single-pass aliasing. Calibrated empirically (~14-block crossover
* for loop-heavy functions; 16 leaves headroom); the dense-bindings
* `rd_scaling_budget` gate in bench/cfg/baselines.json catches a regression if
* this is mistuned. Paired with a reachable-loop check — loop-free functions
* always take the cheaper dense path regardless of size.
*/
const SSA_MIN_BLOCKS = 16;
/**
* Default ceiling on the SSA-sparse solver's value-graph node count (#2201
* review R1). Above this the sparse path falls back to the dense oracle (which
* bounds its own work via `maxBlockVisits`), trading the deep-loop full-facts
* win for bounded memory on pathological inputs. Sized FAR above any real or
* benchmarked function: the suite's densest SSA scenarios (`dense-bindings`,
* `deep-nest`) build well under 10⁴ nodes, while the pathology this guards
* (thousands of blocks × hundreds of bindings) builds 10⁶–10⁷. The
* `dense-bindings` / `deep-nest` `rd_scaling_budget` gates in
* bench/cfg/baselines.json fail if this is set so low it forces those scenarios
* onto the dense path. Overridable per-call via
* {@link ReachingDefsLimits.maxSsaValueGraphNodes}.
*/
const DEFAULT_MAX_SSA_VALUE_GRAPH_NODES = 1_000_000;
/**
* Production solver dispatcher (#2201). The SSA solver beats the dense worklist
* only when there is enough work to amortize SSA construction — a loop (so the
* dense fixpoint pays the loop-depth pass multiplier, or truncates at the
* ceiling) AND a non-trivial block count. Small or loop-free functions, which
* dense solves in one or two cheap aliasing passes, stay on the dense path.
* Because the two solvers are byte-identical (held by the equivalence fuzz),
* this is a pure performance heuristic with no effect on results.
*
* @internal
*/
function computeInSetsAuto(
cfg: FunctionCfg,
n: number,
h: Harvest,
adj: Adjacency,
limits: ReachingDefsLimits | undefined,
): InSetsResult {
if (n >= SSA_MIN_BLOCKS && hasReachableLoop(cfg.entryIndex, adj.succs, n)) {
return computeInSetsSparse(cfg, n, h, adj, limits);
}
return computeInSetsDense(cfg, n, h, adj, limits);
}
/**
* Statement sweep — recover statement-granular def→use facts from the per-block
* entry reaching lattices, sort them, and apply the maxFacts truncation. SHARED
* by both solvers, and the maxFacts cutoff is where their (intentionally
* different) reaching-set INSERTION orders would otherwise leak into the output:
* the dense worklist seeds keys in RPO fixpoint order, the SSA solver in
* renaming/SCC order, so a loop-carried use's reaching set is the same SET in a
* different order. The byte-identity of a TRUNCATED result therefore does NOT
* come from matching insertion orders — it comes from the KTD6 per-use
* `useKeys.sort()` BELOW, which canonicalizes each use's keys by defKey before
* the cutoff. (The full, untruncated fact array is re-sorted at the end, so the
* pre-sort is a no-op there; its whole purpose is the truncated prefix.) Outer
* emission order — block index, then statement index, then use order — is shared
* structurally and needs no canonicalization.
*/
function sweepFacts(
blocks: FunctionCfg['blocks'],
reachingAt: ReachingAt,
defLine: ReadonlyMap<number, number>,
maxFacts: number,
): { facts: DefUseFact[]; truncated: boolean } {
const facts: DefUseFact[] = [];
let truncated = false;
// Scratch buffer for one use's reaching def-keys, reused across every use to
// avoid a per-use array allocation (#2201 review R9). Cleared per use; the
// KTD6 sort below operates on it in place.
const useKeys: number[] = [];
outer: for (const b of blocks) {
const stmts = b.statements;
if (!stmts || stmts.length === 0) continue;
// Lazy overlay of IN — entries are replaced (never mutated) on def, so the
// shared sets stay intact.
let reach: Lattice | null = null;
// Sparse intra-block overlay: only the bindings REDEFINED within this block
// so far. A use's reaching set is the overlay's override if present, else
// the block-entry reaching set (reachingAt). This never materializes the
// full block lattice — the dense O(live-vars) per-block copy the sparse
// solver exists to avoid.
const overlay = new Map<number, DefSet>();
for (let i = 0; i < stmts.length; i++) {
const s = stmts[i];
// A use's binding that the SAME statement also defines could be a
@ -292,17 +871,32 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit
// self-fact on compound assignments is harmless; missing the
// assign-and-test def→use (the most common JS idiom) would be a taint
// false negative. May-defs join the self-key set the same way.
const sameStmtDefs =
s.defs.length > 0 || s.mayDefs?.length ? new Set([...s.defs, ...(s.mayDefs ?? [])]) : null;
// def/mayDef arrays are tiny (1–3 entries), so a membership scan over them
// is cheaper than the old per-statement `new Set([...defs, ...mayDefs])`
// (#2201 review R9). `hasSelfDefs` short-circuits pure-use statements.
const hasSelfDefs = s.defs.length > 0 || (s.mayDefs?.length ?? 0) > 0;
for (const u of s.uses) {
const reaching = (reach ?? inSets[b.index]).get(u);
const selfKey = sameStmtDefs?.has(u) ? defKey(b.index, i) : undefined;
const reaching = overlay.get(u) ?? reachingAt(b.index, u);
const selfKey =
hasSelfDefs && (s.defs.includes(u) || (s.mayDefs?.includes(u) ?? false))
? defKey(b.index, i)
: undefined;
if (!reaching && selfKey === undefined) continue;
const keys =
selfKey !== undefined && !reaching?.has(selfKey)
? [...(reaching ?? []), selfKey]
: [...(reaching ?? [])];
for (const key of keys) {
// Reuse the scratch buffer instead of spreading a fresh array per use.
useKeys.length = 0;
if (reaching) for (const k of reaching) useKeys.push(k);
if (selfKey !== undefined && !reaching?.has(selfKey)) useKeys.push(selfKey);
// Canonical emission order (#2201 KTD6): sort each use's reaching
// def-sites by defKey (= def block, then def stmt) BEFORE the maxFacts
// cutoff. The full (untruncated) fact array is re-sorted identically at
// the end, so this is a no-op there; its purpose is to make the
// TRUNCATED subset schedule-independent — the reaching SET's insertion
// order is fixpoint-evaluation-order-dependent for loop-carried
// bindings (dense RPO vs sparse change-driven seed different keys
// first), so a pre-sort cutoff is what keeps the two solvers'
// truncated results byte-identical.
useKeys.sort((a, b) => a - b);
for (const key of useKeys) {
if (facts.length >= maxFacts) {
truncated = true;
break outer;
@ -318,16 +912,14 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit
}
if (s.mayDefs?.length) {
// Gen WITHOUT kill: the conditional def joins the binding's set.
if (!reach) reach = new Map(inSets[b.index]);
const key = defKey(b.index, i);
for (const d of s.mayDefs) {
const prior = reach.get(d);
reach.set(d, prior ? unionSets(prior, new Set([key])) : new Set([key]));
const prior = overlay.get(d) ?? reachingAt(b.index, d);
overlay.set(d, prior ? unionSets(prior, new Set([key])) : new Set([key]));
}
}
if (s.defs.length > 0) {
if (!reach) reach = new Map(inSets[b.index]);
for (const d of s.defs) reach.set(d, new Set([defKey(b.index, i)])); // kill + gen
for (const d of s.defs) overlay.set(d, new Set([defKey(b.index, i)])); // kill + gen
}
}
}
@ -341,41 +933,7 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit
a.bindingIdx - b.bindingIdx,
);
return {
status: truncated ? 'truncated' : 'computed',
bindings: cfg.bindings,
facts,
defCount,
useCount,
};
}
/** RPO over blocks reachable from `entry`; unreachable blocks appended by index. */
function reversePostOrder(entry: number, succs: readonly number[][], n: number): number[] {
const visited = new Array<boolean>(n).fill(false);
const post: number[] = [];
// Iterative DFS with an explicit phase stack (children pushed in reverse so
// they pop in sorted order — determinism).
const stack: { node: number; childIdx: number }[] = [{ node: entry, childIdx: 0 }];
visited[entry] = true;
while (stack.length) {
const top = stack[stack.length - 1];
const children = succs[top.node];
if (top.childIdx < children.length) {
const next = children[top.childIdx];
top.childIdx += 1;
if (!visited[next]) {
visited[next] = true;
stack.push({ node: next, childIdx: 0 });
}
} else {
post.push(top.node);
stack.pop();
}
}
const order = post.reverse();
for (let b = 0; b < n; b++) if (!visited[b]) order.push(b);
return order;
return { facts, truncated };
}
/**
@ -427,32 +985,3 @@ function mergePreds(
}
return merged;
}
/** Order-stable union of two def-sets (shares `a` when `b` adds nothing). */
function unionSets(a: DefSet, b: DefSet): DefSet {
let target = a;
let copied = false;
for (const key of b) {
if (!target.has(key)) {
if (!copied) {
target = new Set(a);
copied = true;
}
target.add(key);
}
}
return target;
}
/** Per-binding equality with a reference fast path (sets only ever grow). */
function latticeEquals(a: Lattice, b: Lattice): boolean {
if (a === b) return true;
if (a.size !== b.size) return false;
for (const [k, bSet] of b) {
const aSet = a.get(k);
if (aSet === bSet) continue;
if (!aSet || aSet.size !== bSet.size) return false;
for (const v of bSet) if (!aSet.has(v)) return false;
}
return true;
}

View file

@ -0,0 +1,331 @@
/**
* Synthetic-escape pass for CDG soundness (#2197 U1).
*
* THE PROBLEM. Control dependence is computed over the post-dominator tree
* (control-dependence.ts), which is only sound when EXIT is reverse-reachable
* from every entry-reachable block (post-dominators.ts §
* {@link isExitReachableFromAllBlocks}). Loop visitors keep that invariant by
* giving every loop a structural `header → loopExit` `cond-false` edge — so an
* ordinary `while`/`for` always has a path to EXIT. The `goto` handlers
* (C/C++/C#/Go), however, wire an UNCONDITIONAL back-edge as plain `seq` with no
* such escape:
*
* void handler(int a){ start: if (a > 0) { work(); } goto start; }
*
* Here every body block sits in a trapping cycle (`start … goto start`) with no
* path to EXIT, so EXIT is non-reverse-reachable and {@link emitFileCdg} (the
* soundness gate) WITHHOLDS all control dependence for the whole function —
* silent CDG coverage loss for an entire (common) class of functions.
*
* THE FIX (nontermination-sensitive control dependence — Ranganath et al.,
* TOPLAS 2007). For a genuinely exit-unreachable *cycle* (an infinite loop), add
* an ANALYSIS-ONLY virtual escape edge from the cycle's controlling branch to
* EXIT, making the post-dom tree well-defined again. The synthetic edge is inert
* in the Ferrante walk (EXIT post-dominates its source, so the post-dom guard
* skips it) — it only restores reverse-reachability so the REAL control points
* inside the loop get their dependences.
*
* ANALYSIS-ONLY (load-bearing — KTD7). The pass NEVER mutates the input. The
* persisted CFG / REACHING_DEF graph and the byte-identical-off golden depend on
* `cfg.edges` staying faithful, so the augmentation lives on a shallow-cloned
* {@link FunctionCfg} whose `edges` is `[...cfg.edges, ...synthetic]`. Because
* both {@link computePostDominators} AND {@link computeControlDependence}
* (its Ferrante walk + `buildArmSenses`) re-read `cfg.edges` directly, the
* augmented view must be passed to BOTH — feeding only an augmented post-dom
* tree would leave the walk on the un-augmented edges (KTD7).
*
* PURE AND DETERMINISTIC (mirrors post-dominators.ts / reaching-defs.ts). The
* SCC routine sorts every adjacency list and emits SCCs root-deterministically,
* so the chosen representative — hence the augmented edge set and any downstream
* snapshot — is identical across runs.
*
* WHICH SCCs ARE BRIDGED (KTD2 / KTD6, and the anti-masking guarantee R2). The
* decision is gated on the WHOLE entry-reachable trapped region (the union of
* the entry-reachable blocks that cannot reach EXIT): the pass bridges only when
* that region contains at least one *control point* — a block with ≥2 successors
* (a branch terminator). A region with a control point is a real, recoverable
* loop (a `goto`-cycle always carries the `if` predicate from its guard); a
* region with NO control point is a branch-less infinite spin that carries no
* control dependence to recover AND is indistinguishable from a genuine
* CFG-construction anomaly (e.g. a disconnected EXIT block), so it is
* deliberately LEFT UNBRIDGED — the existing soundness gate then skips the
* function and surfaces the skip (R2 / R3). In practice a branch-less trapping
* region never comes from a real loop visitor (loops emit the structural escape
* edge) — it signals a construction error, exactly what we must not paper over.
*
* When the region is bridged, EACH exit-less SCC gets one synthetic escape edge
* from its *controlling representative*: the entry-reachable member with a branch
* terminator (≥2 successors), highest out-degree, lowest-index tie-break. That
* branch is the predicate deciding stay-in-loop vs. leave, the faithful escape
* representative; attaching the escape anywhere else invents or drops CDG edges
* while still passing the AC2 post-dominance property test, so the choice is
* pinned by an exact-edge-set test, not `CDG>0`. When an exit-less SCC has NO
* internal branch (e.g. the body of an irreducible loop whose control point sits
* OUTSIDE the cycle), its escape attaches to the lowest-index member — the
* choice is semantically immaterial (the SCC has no internal control point so it
* contributes no internal CDG), and a deterministic index keeps snapshots
* stable. This per-SCC bridging restores reverse-reachability for the whole
* region in one batch, then the gate re-checks (KTD2).
*
* GRANULARITY OF A MIXED cycle + dead-end FUNCTION. {@link emitFileCdg} is
* all-or-nothing per function: it computes CDG only when EXIT is reverse-
* reachable from EVERY entry-reachable block. So if a function contains a
* recoverable goto-cycle AND a *separate* residual block that is still
* exit-unreachable after all escapes (a dangling/dead-end block not in any
* bridgeable cycle), the pass restores the cycle but the residual block keeps
* EXIT non-reverse-reachable → the WHOLE function is still skipped and surfaced.
* We do NOT bridge the residual (that would mask the construction error), and we
* do NOT emit partial per-cycle CDG (the emit layer has no partial mode). This
* is the documented, intentional trade-off: recover the common goto-cycle case;
* surface anything with a genuine residual anomaly rather than guess.
*/
import { isExitReachableFromAllBlocks, type PostDomTree } from './post-dominators.js';
import type { CfgEdgeData, FunctionCfg } from './types.js';
/**
* The synthetic escape edge kind. Reuses the existing `cond-false` kind (the
* same kind every loop's structural `header → loopExit` escape carries — see the
* module doc), so the augmented view is structurally indistinguishable from a
* normally-escaped loop and `buildArmSenses`/`labelFor` treat it identically.
* The edge is analysis-only and never persisted.
*/
const SYNTHETIC_ESCAPE_KIND: CfgEdgeData['kind'] = 'cond-false';
/** Forward / reverse reachability over a CFG's in-range edges. */
interface Reachability {
/** `fromEntry[b]` — block `b` is forward-reachable from ENTRY. */
readonly fromEntry: Uint8Array;
/** `canReachExit[b]` — block `b` can reach EXIT (reverse-reachable from it). */
readonly canReachExit: Uint8Array;
/** Forward adjacency (sorted, in-range). */
readonly succ: readonly number[][];
}
function reach(start: number, adj: readonly number[][], n: number): Uint8Array {
const seen = new Uint8Array(n);
if (start < 0 || start >= n) return seen;
const stack = [start];
seen[start] = 1;
while (stack.length > 0) {
const b = stack.pop() as number;
for (const next of adj[b]) {
if (!seen[next]) {
seen[next] = 1;
stack.push(next);
}
}
}
return seen;
}
function computeReachability(cfg: FunctionCfg): Reachability {
const n = cfg.blocks.length;
const succ: number[][] = Array.from({ length: n }, () => []);
const pred: number[][] = Array.from({ length: n }, () => []);
for (const e of cfg.edges) {
if (e.from < 0 || e.from >= n || e.to < 0 || e.to >= n) continue;
succ[e.from].push(e.to);
pred[e.to].push(e.from);
}
// Sorted adjacency — determinism (mirrors post-dominators.ts).
for (const l of succ) l.sort((a, b) => a - b);
return {
fromEntry: reach(cfg.entryIndex, succ, n),
canReachExit: reach(cfg.exitIndex, pred, n),
succ,
};
}
/**
* Strongly-connected components of a CFG via an ITERATIVE Tarjan over the
* forward edges. Pure and deterministic: nodes are visited in ascending index
* and every successor list is iterated in sorted order, so the component
* partition (and the per-component member order) is identical across runs.
*
* Returns `compOf[b]` = the component id of block `b`, plus `members[c]` = the
* (ascending-index) members of component `c`. Component ids are assigned in
* Tarjan completion order (a reverse-topological order over the condensation),
* which is deterministic but not relied upon — callers key on `compOf`.
*/
export interface SccResult {
readonly compOf: readonly number[];
readonly members: readonly (readonly number[])[];
}
export function computeScc(succ: readonly number[][], n: number): SccResult {
const compOf = new Array<number>(n).fill(-1);
const members: number[][] = [];
const index = new Array<number>(n).fill(-1);
const lowlink = new Array<number>(n).fill(0);
const onStack = new Uint8Array(n);
const tarjanStack: number[] = [];
let nextIndex = 0;
// Explicit work stack: each frame tracks the node and how far through its
// (sorted) successor list we have iterated, so recursion depth never blows the
// JS stack on a large per-function CFG.
for (let root = 0; root < n; root++) {
if (index[root] !== -1) continue;
const work: { node: number; childIdx: number }[] = [{ node: root, childIdx: 0 }];
while (work.length > 0) {
const frame = work[work.length - 1];
const v = frame.node;
if (frame.childIdx === 0) {
// First visit to v.
index[v] = nextIndex;
lowlink[v] = nextIndex;
nextIndex += 1;
tarjanStack.push(v);
onStack[v] = 1;
}
const succs = succ[v];
if (frame.childIdx < succs.length) {
const w = succs[frame.childIdx];
frame.childIdx += 1;
if (index[w] === -1) {
// Descend into the unvisited child; resume v afterwards.
work.push({ node: w, childIdx: 0 });
} else if (onStack[w]) {
if (index[w] < lowlink[v]) lowlink[v] = index[w];
}
continue;
}
// All successors of v processed: propagate lowlink to the parent, and if v
// is a component root, pop its component off the Tarjan stack.
if (lowlink[v] === index[v]) {
const comp: number[] = [];
for (;;) {
const w = tarjanStack.pop() as number;
onStack[w] = 0;
comp.push(w);
if (w === v) break;
}
comp.sort((a, b) => a - b); // ascending member order — determinism
const id = members.length;
for (const w of comp) compOf[w] = id;
members.push(comp);
}
work.pop();
if (work.length > 0) {
const parent = work[work.length - 1].node;
if (lowlink[v] < lowlink[parent]) lowlink[parent] = lowlink[v];
}
}
}
return { compOf, members };
}
/**
* Restore EXIT reverse-reachability for genuine exit-unreachable cycles so the
* post-dom / CDG pass runs on a well-defined tree, WITHOUT masking construction
* errors or perturbing sound functions. See the module doc for the full
* contract.
*
* Returns the input `cfg` UNCHANGED (referential no-op) when EXIT is already
* reverse-reachable from every entry-reachable block — terminating functions and
* properly-escaped loops are byte-identical (zero synthetic edges). Otherwise
* returns a SHALLOW-CLONED {@link FunctionCfg} whose `edges` is the original
* edges followed by the synthetic escapes; the input's `edges` is never mutated.
*
* Pass the returned view to BOTH {@link computePostDominators} and
* {@link computeControlDependence} (KTD7).
*/
export function augmentForPostDom(cfg: FunctionCfg): FunctionCfg {
const n = cfg.blocks.length;
const { entryIndex, exitIndex } = cfg;
if (n === 0 || entryIndex < 0 || entryIndex >= n || exitIndex < 0 || exitIndex >= n) {
return cfg; // degenerate — leave to the existing gate
}
const { fromEntry, canReachExit, succ } = computeReachability(cfg);
// No-op fast path: every entry-reachable block already reaches EXIT.
let allReach = true;
for (let b = 0; b < n; b++) {
if (fromEntry[b] && !canReachExit[b]) {
allReach = false;
break;
}
}
if (allReach) return cfg;
// Anti-masking gate (R2): only bridge when the entry-reachable TRAPPED REGION
// (the union of entry-reachable blocks that cannot reach EXIT) holds at least
// one control point — a block with ≥2 successors. A branch-less trapped region
// is a degenerate spin / construction anomaly we refuse to mask; the existing
// soundness gate skips it and surfaces the skip. A real `goto`-cycle always
// carries its guard's `if`, so it is recovered; a disconnected/dangling EXIT
// (no branch anywhere in the trap) is left to skip. See the module doc.
let regionHasControlPoint = false;
for (let b = 0; b < n; b++) {
if (fromEntry[b] && !canReachExit[b] && succ[b].length >= 2) {
regionHasControlPoint = true;
break;
}
}
if (!regionHasControlPoint) return cfg;
// Condense into SCCs over the real edges.
const { members } = computeScc(succ, n);
// Bridge EACH exit-less SCC (a trapping cycle: no member can reach EXIT, so
// `canReachExit` is false for the whole SCC). `canReachExit` already encodes
// the transitive closure, so a single member's flag answers it for the SCC.
const synthetic: CfgEdgeData[] = [];
for (const comp of members) {
if (comp.length === 0) continue;
const rep = comp[0]; // ascending-order members → comp[0] is the lowest index
if (canReachExit[rep]) continue; // SCC escapes to EXIT — nothing to bridge
// Only genuine CYCLES trap. A singleton SCC with no self-edge is an ordinary
// acyclic block (ENTRY / the spine) that is exit-unreachable only because it
// FEEDS a trap downstream; it gets its path to EXIT for free once the trap is
// bridged, so it is never bridged on its own.
const isCycle = comp.length > 1 || cfg.edges.some((e) => e.from === rep && e.to === rep);
if (!isCycle) continue;
// Controlling representative: the entry-reachable member with a branch
// terminator (≥2 successors), highest out-degree, lowest-index tie-break.
// When the SCC has no internal branch (its control point sits outside, e.g.
// an irreducible loop body), fall back to the lowest-index member — the
// choice is immaterial (no internal control point ⇒ no internal CDG) and a
// deterministic index keeps snapshots stable (KTD6).
let controller = -1;
let bestOutDeg = 1; // require ≥2 to qualify as a branch
for (const b of comp) {
if (!fromEntry[b]) continue;
const outDeg = succ[b].length;
if (outDeg >= 2 && outDeg > bestOutDeg) {
bestOutDeg = outDeg;
controller = b;
}
}
if (controller === -1) {
// No internal branch — attach at the lowest entry-reachable member (or the
// lowest member if none is entry-reachable, a defensive fallback).
controller = comp.find((b) => fromEntry[b]) ?? rep;
}
synthetic.push({ from: controller, to: exitIndex, kind: SYNTHETIC_ESCAPE_KIND });
}
if (synthetic.length === 0) return cfg; // nothing bridgeable — gate will skip
// Shallow clone with the augmented edge set; the input's `edges` is untouched.
return { ...cfg, edges: [...cfg.edges, ...synthetic] };
}
/**
* Convenience: `true` iff {@link augmentForPostDom} returned a DIFFERENT object
* (i.e. at least one synthetic escape edge was added). Useful for tests
* asserting the no-op path. Reference equality is exact: the no-op path returns
* the input unchanged.
*/
export function wasAugmented(cfg: FunctionCfg, view: FunctionCfg): boolean {
return view !== cfg;
}
// Re-export so callers can build the augmented view and gate it in one import.
export { isExitReachableFromAllBlocks };
export type { PostDomTree };

View file

@ -0,0 +1,577 @@
/**
* C / C++ def/use harvester (#2195 U2, plan KTD2) — the C-family analogue of
* {@link import('./typescript-harvest.js').TsHarvester}.
*
* Runs in the parse worker next to the C/C++ CFG visitor, extracting
* per-statement variable definition/use facts that ride the side channel for
* the reaching-defs / CDG solvers. Output is the per-function binding table
* ({@link BindingEntry}[]) plus {@link StatementFacts} the visitor attaches to
* blocks as it walks. One class serves both languages: the control-flow node
* set is identical (grammar-introspection probe confirmed — see U2 report),
* and the harvest's def/use node taxonomy (`declaration`/`init_declarator`/
* `assignment_expression`/`update_expression`/`parameter_declaration`) is shared
* too; C++-only `lambda_expression` is handled exactly like a nested function
* (opaque), so no language naming or branching is needed.
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS harvester): the
* CFG walk is NOT source-order (`visitFor` builds the init block after the body,
* `visitDoWhile` the condition before the body), so resolving names against a
* scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans
* the whole function subtree once into a completed lexical scope tree; phase 2
* resolves defs/uses against that finished tree from any walk order.
*
* v1 def-semantics scope:
* - `declaration` → `init_declarator` (an initialized local is a def; a bare
* `int x;` with no initializer writes nothing at runtime — not a def, like
* the TS bare-`var` rule).
* - `assignment_expression` (plain + compound `+=` etc.), `update_expression`
* (`x++`/`--x`) — define and (for compound/update) also use the lvalue.
* - parameters (`parameter_declaration` declarator chain).
* EXCLUDED, deliberately (TypeScript-CFA precedent): member / pointer / array
* writes (`obj.f = …`, `*p = …`, `a[i] = …`) are NOT scalar defs — their
* identifiers are uses only. Both directions of nested-function (C++ lambda)
* capture are invisible (the lambda body is an opaque block in the enclosing
* CFG, exactly as TS treats arrow/function bodies).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&`/`||` (`if (a && (x = f()))`), a ternary arm, or a switch
* case-test — is a may-def (gen without kill), so the not-taken path's prior
* def is not falsely killed.
*
* Identifiers with no in-function declaration (globals, macros, params of an
* enclosing scope) resolve to a SYNTHETIC module-level binding (`name@module`),
* applied identically by def and use harvesting.
*
* RAII NOTE: C++ destructors that run at scope exit are NOT represented in the
* tree-sitter AST (they are implicit), so this harvest cannot and does not model
* destructor side effects — documented gap, see the visitor doc-comment.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { CallSiteFactAccumulator } from './call-site-harvest.js';
import { ScopeTreeHarvester, type Scope, type FactAccumulator } from './scope-tree-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set(['lambda_expression', 'function_definition']);
/**
* Nodes that open a lexical scope for block-local declarations. A `compound_
* statement` is one scope; the for-loops open a scope for their loop variable.
*/
const SCOPE_TYPES = new Set([
'compound_statement',
'for_statement',
'for_range_loop',
'catch_clause',
]);
/** Type-position subtrees — identifiers inside them are not value uses. */
const TYPE_CONTEXT_TYPES = new Set([
'type_descriptor',
'template_argument_list',
'template_type',
'sized_type_specifier',
'primitive_type',
]);
export class CCppHarvester extends ScopeTreeHarvester {
constructor(fnNode: SyntaxNode) {
super(fnNode);
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body, this.openScope(body));
}
/** The function/lambda body block (`compound_statement`). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
const body = fnNode.childForFieldName('body');
if (body) return body;
return fnNode.namedChildren.find((c) => c.type === 'compound_statement');
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
/** The bare identifier a declarator chain ultimately names (or undefined). */
private declaratorName(node: SyntaxNode | null): SyntaxNode | undefined {
let cur: SyntaxNode | null = node;
let hops = 12;
while (cur && hops-- > 0) {
if (cur.type === 'identifier' || cur.type === 'field_identifier') return cur;
// Unwrap pointer/reference/array/init/parenthesized declarator layers.
const next =
cur.childForFieldName('declarator') ??
cur.namedChildren.find(
(c) =>
c.type === 'identifier' ||
c.type === 'field_identifier' ||
c.type === 'pointer_declarator' ||
c.type === 'reference_declarator' ||
c.type === 'array_declarator' ||
c.type === 'parenthesized_declarator' ||
c.type === 'init_declarator',
);
if (!next || next.id === cur.id) break;
cur = next;
}
return undefined;
}
private declareParams(fnNode: SyntaxNode): void {
// function_definition: declarator → function_declarator → parameter_list.
// A C++ lambda routes through abstract_function_declarator.
const declarator = fnNode.childForFieldName('declarator');
const fnDeclarator = this.findFunctionDeclarator(declarator);
const params = fnDeclarator?.childForFieldName('parameters');
if (!params) return;
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter_declaration') continue;
const name = this.declaratorName(p.childForFieldName('declarator') ?? null);
if (name) this.declare(name, 'param', this.root);
}
}
private findFunctionDeclarator(node: SyntaxNode | null): SyntaxNode | undefined {
let cur: SyntaxNode | null = node;
let hops = 10;
while (cur && hops-- > 0) {
if (cur.type === 'function_declarator' || cur.type === 'abstract_function_declarator') {
return cur;
}
cur = cur.childForFieldName('declarator') ?? null;
}
return undefined;
}
protected prescan(node: SyntaxNode, scope: Scope): void {
this.nearestScopeCache.set(node.id, scope);
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// A nested function/lambda body is opaque — do not descend.
return;
}
let childScope = scope;
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
switch (t) {
case 'declaration':
this.declareDeclarators(node, childScope);
break;
case 'for_range_loop': {
// `for (int x : xs)` — the declarator binds in the loop scope.
const decl = node.childForFieldName('declarator');
const name = this.declaratorName(decl ?? null);
if (name) this.declare(name, 'var', childScope);
break;
}
case 'catch_clause': {
const params = node.childForFieldName('parameters');
if (params) {
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter_declaration') continue;
const name = this.declaratorName(p.childForFieldName('declarator') ?? null);
if (name) this.declare(name, 'catch', childScope);
}
}
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c, childScope);
}
}
/**
* The `structured_binding_declarator` a declarator binds (C++17 `auto [a,b]`,
* or `auto& [a,b]` whose binding sits under a `reference_declarator`), or
* undefined. C has no structured bindings, so this is inert for C.
*/
private structuredBinding(declarator: SyntaxNode | null | undefined): SyntaxNode | undefined {
let cur = declarator ?? undefined;
let hops = 4;
while (cur && hops-- > 0) {
if (cur.type === 'structured_binding_declarator') return cur;
if (cur.type !== 'reference_declarator' && cur.type !== 'pointer_declarator') break;
cur = cur.namedChildren.find(
(c) =>
c.type === 'structured_binding_declarator' ||
c.type === 'reference_declarator' ||
c.type === 'pointer_declarator',
);
}
return undefined;
}
/** The identifier leaves a `structured_binding_declarator` binds (`[a, b]`). */
private structuredBindingNames(sbd: SyntaxNode): SyntaxNode[] {
return sbd.namedChildren.filter((c) => c.type === 'identifier');
}
private declareDeclarators(declNode: SyntaxNode, scope: Scope): void {
for (let i = 0; i < declNode.namedChildCount; i++) {
const d = declNode.namedChild(i);
if (!d) continue;
if (d.type === 'init_declarator') {
const declarator = d.childForFieldName('declarator');
const sbd = this.structuredBinding(declarator);
if (sbd) {
// `auto [a, b] = e;` — every identifier binds a scalar local.
for (const id of this.structuredBindingNames(sbd)) this.declare(id, 'var', scope);
} else {
const name = this.declaratorName(declarator ?? null);
if (name) this.declare(name, 'var', scope);
}
} else if (
d.type === 'identifier' ||
d.type === 'pointer_declarator' ||
d.type === 'array_declarator' ||
d.type === 'reference_declarator'
) {
// Uninitialized local (`int x;`) — declare the BINDING (so a later
// assignment resolves to a real, non-synthetic binding) but the
// declaration itself produces no def (handled in phase 2).
const name = this.declaratorName(d);
if (name) this.declare(name, 'var', scope);
}
}
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/** Facts for a `for (decl : right)` range head: decl binds, right is used. */
forRangeHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const decl = stmt.childForFieldName('declarator');
const right = stmt.childForFieldName('right');
const name = this.declaratorName(decl ?? null);
if (name) this.def(name, acc);
if (right) this.walkValue(right, acc);
return acc.finish();
}
/** ENTRY-block facts for the function's parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const declarator = this.fnNode.childForFieldName('declarator');
const fnDeclarator = this.findFunctionDeclarator(declarator);
const params = fnDeclarator?.childForFieldName('parameters');
if (!params) return undefined;
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter_declaration') continue;
const name = this.declaratorName(p.childForFieldName('declarator') ?? null);
if (name) this.def(name, acc);
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch (T& e)` parameter — prepend to the handler entry block. */
catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined {
const params = catchClause.childForFieldName('parameters');
if (!params) return undefined;
const acc = new FactAccumulator(catchClause.startPosition.row + 1);
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter_declaration') continue;
const name = this.declaratorName(p.childForFieldName('declarator') ?? null);
if (name) this.def(name, acc);
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Strip parenthesized wrappers around an lvalue (`(x) = 1`). */
private unwrapLvalue(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 8;
while (n.type === 'parenthesized_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/** Value-position walk: collect uses; route def positions to the lvalue handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (TYPE_CONTEXT_TYPES.has(t)) return;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// Opaque nested function / lambda — captured reads/writes are invisible.
return;
}
switch (t) {
case 'identifier':
case 'field_identifier':
this.use(node, acc);
return;
case 'declaration':
for (let i = 0; i < node.namedChildCount; i++) {
const d = node.namedChild(i);
if (d?.type !== 'init_declarator') continue;
const declarator = d.childForFieldName('declarator');
const value = d.childForFieldName('value');
const sbd = this.structuredBinding(declarator);
if (sbd && value) {
// `auto [a, b] = e;` — each identifier is a scalar def; the result
// of `e` flows into all of them (resultDefs covers the whole list).
const snap = acc.defSnapshot();
for (const id of this.structuredBindingNames(sbd)) this.def(id, acc);
this.registerResultDefs(value, acc.defsSince(snap));
this.walkValue(value, acc);
continue;
}
const name = declarator ? this.declaratorName(declarator) : undefined;
// Only an INITIALIZED declarator writes (`int x = e;`). A bare
// `int x;` is not a def (it writes nothing at runtime), matching the
// TS bare-`var` rule. Pointer/array/member declarators are not scalar
// defs either — their inner identifiers stay uses.
if (name && value && declarator?.type === 'identifier') {
const snap = acc.defSnapshot();
this.def(name, acc);
this.registerResultDefs(value, acc.defsSince(snap));
} else if (declarator && declarator.type !== 'identifier') {
this.walkValue(declarator, acc);
}
if (value) this.walkValue(value, acc);
}
return;
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '=';
if (left) {
const lv = this.unwrapLvalue(left);
if (lv.type === 'identifier') {
const snap = acc.defSnapshot();
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
// A plain `x = f(a)` attaches `resultDefs: [x]` to f's site; a
// compound `x += f(a)` does not (the prior value flows in too).
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
} else {
this.walkValue(lv, acc); // member/pointer/subscript target — uses only
}
}
if (right) this.walkValue(right, acc);
return;
}
case 'update_expression': {
const rawArg = node.childForFieldName('argument');
const arg = rawArg ? this.unwrapLvalue(rawArg) : null;
if (arg?.type === 'identifier') {
this.def(arg, acc);
this.use(arg, acc);
} else if (arg) {
this.walkValue(arg, acc);
}
return;
}
case 'binary_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||') this.conditional(() => this.walkValue(right, acc));
else this.walkValue(right, acc);
}
return;
}
case 'conditional_expression': {
const cond = node.childForFieldName('condition');
const cons = node.childForFieldName('consequence');
const alt = node.childForFieldName('alternative');
if (cond) this.walkValue(cond, acc);
if (cons) this.conditional(() => this.walkValue(cons, acc));
if (alt) this.conditional(() => this.walkValue(alt, acc));
return;
}
case 'call_expression':
// #2195 U6: explicit case (previously default-descended) — same uses,
// plus a taint-site record. Defs/uses stay byte-identical.
this.visitCall(node, acc, 'call');
return;
case 'new_expression':
// C++ `new Foo(x)` — constructor call site (`type` field is the callee).
this.visitCall(node, acc, 'new');
return;
case 'field_expression': {
// `a.b` / `a->b` — value read of the chain root only; the field name is
// not a scalar binding. Mirrors the TS member-read use semantics, plus a
// member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c && !TYPE_CONTEXT_TYPES.has(c.type)) this.walkValue(c, acc);
}
}
}
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
/**
* When `value`'s root (after stripping parens) is a call/new node, remember
* that its site should carry `resultDefs: defs` — consumed by
* {@link visitCall} once the value walk reaches the node.
*/
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
if (defs.length === 0) return;
const root = this.unwrapLvalue(value);
if (root.type === 'call_expression' || root.type === 'new_expression') {
this.resultDefTargets.set(root.id, [...defs]);
}
}
/**
* Explicit call/new handler: records a call site (callee path, receiver,
* per-arg occurrence entries, result defs) while reproducing EXACTLY the uses
* the old default descent recorded — callee chain root + arguments. `new`
* sites read the `type` field as the callee; `call` sites the `function`
* field.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'type' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
const callee = this.unwrapLvalue(calleeNode);
if (callee.type === 'identifier' || callee.type === 'type_identifier') {
// The callee NAME is a statement-level use but NOT a value occurrence in
// any enclosing argument (`exec(escape(x))` must not put `escape` into
// exec's arg 0). A `new Foo(...)` type identifier is a type, not a
// scalar binding — record neither a use nor an occurrence for it.
if (callee.type === 'identifier') acc.addUseWithoutOccurrence(this.resolve(callee));
calleePath = callee.text;
} else if (callee.type === 'field_expression') {
// skipFinalRead: the final access IS the callee, carried by the dotted
// path — recording it as a member read would double-count.
const chain = this.walkChain(callee, acc, true);
calleePath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else if (callee.type === 'qualified_identifier') {
// `ns::g(...)` — a static dotted path, no scalar receiver binding.
calleePath = this.qualifiedPath(callee);
this.walkValue(callee, acc);
} else {
// Call-rooted chains, function-pointer expressions — the walk still
// records uses and nested sites.
this.walkValue(callee, acc);
}
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
acc.setFrameArg(pos);
this.walkValue(arg, acc);
pos++;
}
}
acc.popFrame();
}
/**
* Member chain walk shared by value position and callee position. Use-
* recording is identical to the old default descent (chain-root identifier
* once). Member-read sites: at most ONE per chain — the INNERMOST access —
* and only when the chain root is an identifier; `skipFinalRead` suppresses it
* when that access is the callee (carried by the dotted path instead).
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
// Collect field accesses outer→inner (unshift), then resolve the root.
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapLvalue(node);
for (;;) {
if (cur.type === 'field_expression') {
const field = cur.childForFieldName('field');
accesses.unshift(field?.text ?? '');
const obj = cur.childForFieldName('argument');
if (!obj) break;
cur = this.unwrapLvalue(obj);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier' || cur.type === 'field_identifier') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
this.walkValue(cur, acc); // call-rooted etc. — uses + nested sites
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}
/** Dotted path of a `ns::a::b` qualified_identifier (`::` folded to `.`). */
private qualifiedPath(node: SyntaxNode): string | undefined {
const segs: string[] = [];
let cur: SyntaxNode | null = node;
let hops = 16;
while (cur && hops-- > 0) {
if (cur.type === 'qualified_identifier') {
const scope = cur.childForFieldName('scope');
const name = cur.childForFieldName('name');
if (scope) segs.push(scope.text);
cur = name ?? null;
} else {
segs.push(cur.text);
break;
}
}
return segs.length ? segs.join('.') : undefined;
}
}
/**
* Ordered, deduplicating def/use + call-site collector for one statement record.
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
* old local class had, plus the taint-site harvest (#2195 U6).
*/
const FactAccumulator = CallSiteFactAccumulator;

View file

@ -0,0 +1,795 @@
/**
* C / C++ CfgVisitor (#2195 U2, plan KTD1).
*
* Walks a C or C++ function's tree-sitter AST and drives the language-agnostic
* {@link CfgBuilder} to produce a serializable {@link FunctionCfg}, plus a
* def/use harvest ({@link CCppHarvester}) for the reaching-defs / CDG solvers.
*
* SHARED CORE, TWO FACTORIES. A grammar-introspection probe (mandatory pre-step,
* KTD1) confirmed every control-flow node type and field this visitor uses is
* IDENTICAL between tree-sitter-c and tree-sitter-cpp — `if_statement`,
* `for_statement`, `while_statement`, `do_statement`, `switch_statement` /
* `case_statement`, `return`/`break`/`continue`/`goto_statement` /
* `labeled_statement`, `compound_statement`, and the `condition`/`consequence`/
* `alternative`/`initializer`/`update`/`body`/`label`/`value` fields. So the C
* walk ({@link CCfgWalk}) is grammar-shared, and C++ EXTENDS it ({@link
* CppCfgWalk}) with exception flow (`try_statement` / `catch_clause` /
* `throw_statement`) and `for_range_loop` — node types that simply never occur
* in a C parse, so there is no `if (lang === …)` branching (AGENTS.md
* no-language-naming rule). The two factories differ only in which walk class
* and function-node set they install.
*
* Edge-kind contract (matches the TS visitor — RD/CDG consume these):
* - if/else → `cond-true` / `cond-false`
* - loops (for / while / do-while / for-range) → `cond-true` / `loop-back` /
* `cond-false`
* - switch → `switch-case` / `fallthrough` (C-style: a case body that does not
* `break` falls into the next case)
* - try/catch (C++) → `throw` (every protected-region block → the handler)
* - return / throw / break / continue → the matching terminator kind
* - straight-line → `seq`
*
* Classic hazards, handled explicitly:
* - loops allocate a dedicated loop-exit block so `break` has a target before
* the loop's successor is known; `continue` targets the header/increment.
* - `for (;;) {}` / `while (1) {}` still emit the structural `header → loopExit`
* `cond-false` escape edge so EXIT stays reverse-reachable from every block —
* the post-dominator / CDG pass is unsound otherwise (it silently emits zero
* CDG for the function).
* - C `goto`/`labeled_statement`: labels resolve within the function (forward
* AND backward); an unresolved `goto` (label not in this function) routes to
* EXIT and logs via the builder warn path, preserving single-exit.
* - C++ `try`/`catch`: conservative exceptional flow — EVERY block in the
* protected region edges to the handler (an exception may fire mid-block),
* matching the TS `visitTry` over-approximation. A `throw` with no enclosing
* try routes to EXIT.
*
* Known limitations:
* - C++ RAII: a destructor runs implicitly at scope exit, but tree-sitter does
* NOT represent that call in the AST. This visitor therefore does NOT model
* destructor-at-scope-exit control/data flow — it is a documented gap, not
* faked. (C++ has no `finally`; `try`/`catch` is the only modeled exception
* construct, so the TS finalizer-frame machinery is unused here.)
* - A `goto` whose label is undefined in the function keeps the conservative
* route-to-EXIT fallback (single-exit preserved; the continuation path is
* approximate). `setjmp`/`longjmp` non-local control is not modeled.
* - Computed `goto` (`goto *ptr;`, a GNU extension) has no static label target
* and routes to EXIT like an unresolved label.
* - Def/use harvest scope: see `c-cpp-harvest.ts` — member/pointer/array writes
* are not scalar defs; C++ lambda bodies are opaque in both directions.
*
* Returns `undefined` (never throws) for an AST shape it cannot model, so a
* malformed function never drops the whole file's CFG group (R4).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { CCppHarvester } from './c-cpp-harvest.js';
/** C function node — only `function_definition` owns a CFG-bearing body. */
const C_FUNCTION_TYPES = new Set(['function_definition']);
/** C++ adds the lambda as a CFG-bearing function. */
const CPP_FUNCTION_TYPES = new Set(['function_definition', 'lambda_expression']);
/** Statement node types that break a basic block (everything else coalesces). */
const C_CONTROL_FLOW_TYPES = new Set([
'if_statement',
'while_statement',
'do_statement',
'for_statement',
'switch_statement',
'return_statement',
'break_statement',
'continue_statement',
'goto_statement',
'labeled_statement',
'compound_statement',
]);
/** C++ control-flow node types (C set ∪ exceptions ∪ range-for). */
const CPP_CONTROL_FLOW_TYPES = new Set([
...C_CONTROL_FLOW_TYPES,
'for_range_loop',
'try_statement',
'throw_statement',
'co_return_statement',
]);
const LOOP_OR_SWITCH_TYPES = new Set([
'while_statement',
'do_statement',
'for_statement',
'for_range_loop',
'switch_statement',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/** A `break`/`continue` jump-target frame (loop or switch). */
interface JumpFrame {
readonly kind: 'loop' | 'switch';
/** Where a `break` jumps to (loop exit / switch exit). */
readonly breakTo: number;
/** Where a `continue` jumps to (loop header / increment). -1 for a switch. */
readonly continueTo: number;
}
/**
* Per-function C walk state. One instance per function so the jump-target stack,
* exception-handler stack, and label tables are scoped to that function.
*
* Designed to be EXTENDED by the C++ walk: the dispatch table is open via the
* protected {@link visitStmt} override hook, so C++ adds its node types without
* any language conditional in the C core.
*/
class CCfgWalk {
protected readonly jumps: JumpFrame[] = [];
/** Stack of exception-handler entry blocks (catch) a `throw` jumps to. */
protected readonly handlers: number[] = [];
/** label name → its `labeled_statement` body's entry block (resolved on demand). */
protected readonly labelBlocks = new Map<string, number>();
/** Pending gotos to a label not yet seen: label → list of source blocks. */
protected readonly pendingGotos = new Map<string, number[]>();
/** Set of node types that break a block, used by {@link visitSeq}. */
protected readonly controlFlowTypes: ReadonlySet<string>;
constructor(
protected readonly builder: CfgBuilder,
protected readonly harvest: CCppHarvester,
controlFlowTypes: ReadonlySet<string>,
) {
this.controlFlowTypes = controlFlowTypes;
}
/** Statements of a block node, ignoring comments. */
protected statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => c.type !== 'comment');
}
/** The `body` block of a node (field, or the first compound_statement child). */
protected bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return (
node.childForFieldName('body') ??
node.namedChildren.find((c) => c.type === 'compound_statement')
);
}
/** Visit a body that may be a `compound_statement` or a single statement. */
protected visitBody(node: SyntaxNode | undefined | null): SeqResult {
return this.builder.withNesting(() => {
if (!node) return null;
if (node.type === 'compound_statement') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
});
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
return this.builder.withNesting(() => {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (this.controlFlowTypes.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
if (openSimple === undefined) {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
} else {
this.builder.extendBlock(
openSimple,
endLineOf(stmt),
stmt.text,
this.harvest.facts(stmt),
);
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
});
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'while_statement':
return this.visitWhile(stmt);
case 'do_statement':
return this.visitDoWhile(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'switch_statement':
return this.visitSwitch(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'break_statement':
return this.visitBreak(stmt);
case 'continue_statement':
return this.visitContinue(stmt);
case 'goto_statement':
return this.visitGoto(stmt);
case 'labeled_statement':
return this.visitLabeled(stmt);
case 'compound_statement':
return this.visitSeq(this.statementsOf(stmt));
default:
return this.visitExtra(stmt) ?? this.visitSimple(stmt);
}
}
/**
* Extension hook for node types the C core does not handle (C++ try/catch/
* throw/for-range). Returns `undefined` to fall through to {@link visitSimple}.
* The C core has none, so this is a no-op here.
*/
protected visitExtra(_stmt: SyntaxNode): SeqResult | undefined {
return undefined;
}
protected visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
return { entry: idx, exits: [idx] };
}
protected visitReturn(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
this.builder.edge(idx, this.builder.exitIndex, 'return');
return { entry: idx, exits: [] };
}
protected visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const target = this.nearestBreakTarget();
this.builder.edge(idx, target ?? this.builder.exitIndex, 'break');
return { entry: idx, exits: [] };
}
protected visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const target = this.nearestContinueTarget();
this.builder.edge(idx, target ?? this.builder.exitIndex, 'continue');
return { entry: idx, exits: [] };
}
/** Nearest enclosing loop/switch `break` target, or undefined (→ EXIT). */
private nearestBreakTarget(): number | undefined {
return this.jumps.length ? this.jumps[this.jumps.length - 1].breakTo : undefined;
}
/** Nearest enclosing loop `continue` target (switches don't catch continue). */
private nearestContinueTarget(): number | undefined {
for (let i = this.jumps.length - 1; i >= 0; i--) {
if (this.jumps[i].kind === 'loop') return this.jumps[i].continueTo;
}
return undefined;
}
/** `goto label;` — route to the label block if known, else defer / EXIT. */
protected visitGoto(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = this.labelOf(stmt);
if (label === undefined) {
// Computed goto (`goto *p;`) or malformed — route to EXIT (single-exit).
this.builder.edge(idx, this.builder.exitIndex, 'seq');
return { entry: idx, exits: [] };
}
const target = this.labelBlocks.get(label);
if (target !== undefined) {
this.builder.edge(idx, target, 'seq'); // backward goto: label already built
} else {
// Forward goto — wire once the label is created (or to EXIT at finish()).
const list = this.pendingGotos.get(label);
if (list) list.push(idx);
else this.pendingGotos.set(label, [idx]);
}
return { entry: idx, exits: [] };
}
protected visitLabeled(stmt: SyntaxNode): SeqResult {
const label = this.labelOf(stmt);
// The labeled statement's body is the trailing named child (no `body` field
// on labeled_statement in C/C++).
const body =
stmt.namedChildren.find((c) => c.type !== 'statement_identifier' && c.type !== 'comment') ??
null;
const res = this.visitBody(body);
if (label !== undefined) {
const entry = res?.entry ?? this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.labelBlocks.set(label, entry);
// Resolve any forward gotos that were waiting on this label.
const pending = this.pendingGotos.get(label);
if (pending) {
for (const from of pending) this.builder.edge(from, entry, 'seq');
this.pendingGotos.delete(label);
}
if (!res) return { entry, exits: [entry] };
}
return res;
}
protected visitIf(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const condBlock = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
if (thenRes) {
this.builder.edge(condBlock, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(condBlock); // empty then — true path falls through
}
const elseNode = this.elseBodyOf(stmt);
if (elseNode) {
const elseRes = this.visitBody(elseNode);
if (elseRes) {
this.builder.edge(condBlock, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(condBlock);
}
} else {
exits.push(condBlock); // no else — false path falls through to the join
}
return { entry: condBlock, exits: [...new Set(exits)] };
}
/** The else body node (unwraps an `else_clause` wrapper if present). */
private elseBodyOf(ifStmt: SyntaxNode): SyntaxNode | undefined {
const alt = ifStmt.childForFieldName('alternative');
if (!alt) return undefined;
if (alt.type === 'else_clause') {
return alt.childForFieldName('body') ?? alt.namedChildren[0];
}
return alt; // an `else if` is the nested if_statement directly
}
protected visitWhile(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.jumps.push({ kind: 'loop', breakTo: loopExit, continueTo: header });
const body = this.visitBody(this.bodyBlockOf(stmt));
this.jumps.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
}
// Always emit the structural exit edge — even `while (1)` keeps EXIT
// reverse-reachable for the post-dominator / CDG pass.
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
protected visitDoWhile(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const condBlock = this.builder.newBlock(
startLineOf(cond),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.jumps.push({ kind: 'loop', breakTo: loopExit, continueTo: condBlock });
const body = this.visitBody(this.bodyBlockOf(stmt));
this.jumps.pop();
const backTarget = body ? body.entry : condBlock;
if (body) this.builder.connect(body.exits, condBlock, 'seq');
this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again
this.builder.edge(condBlock, loopExit, 'cond-false');
return { entry: backTarget, exits: [loopExit] };
}
protected visitFor(stmt: SyntaxNode): TraversalResult {
const init = stmt.childForFieldName('initializer');
const cond = stmt.childForFieldName('condition');
const incr = stmt.childForFieldName('update');
const header = this.builder.newBlock(
startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for(;;)',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
let incrBlock = header;
if (incr) {
incrBlock = this.builder.newBlock(
startLineOf(incr),
endLineOf(incr),
incr.text,
'normal',
this.harvest.facts(incr),
);
this.builder.edge(incrBlock, header, 'loop-back');
}
this.jumps.push({ kind: 'loop', breakTo: loopExit, continueTo: incrBlock });
const body = this.visitBody(this.bodyBlockOf(stmt));
this.jumps.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
} else {
this.builder.edge(header, incrBlock, 'cond-true');
if (!incr) this.builder.edge(header, header, 'loop-back');
}
// Structural exit edge — `for (;;) {}` (no condition) still keeps EXIT
// reverse-reachable so CDG is not silently skipped for the function.
this.builder.edge(header, loopExit, 'cond-false');
let entry = header;
if (init) {
const initBlock = this.builder.newBlock(
startLineOf(init),
endLineOf(init),
init.text,
'normal',
this.harvest.facts(init),
);
this.builder.edge(initBlock, header, 'seq');
entry = initBlock;
}
return { entry, exits: [loopExit] };
}
protected visitSwitch(stmt: SyntaxNode): TraversalResult {
const value = stmt.childForFieldName('condition') ?? stmt;
const dispatch = this.builder.newBlock(
startLineOf(stmt),
endLineOf(value),
value.text,
'normal',
this.harvest.facts(value),
);
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.jumps.push({ kind: 'switch', breakTo: switchExit, continueTo: -1 });
const body = stmt.childForFieldName('body');
// C/C++ uses ONE `case_statement` node for both `case X:` and `default:`;
// a default has no `value` field.
const cases = body ? body.namedChildren.filter((c) => c.type === 'case_statement') : [];
// `case X:` test expressions live in no block — harvest their uses onto the
// dispatch block as may-defs/uses (sound over-approx of in-order evaluation).
for (const c of cases) {
const caseValue = c.childForFieldName('value');
if (caseValue) this.builder.attachFacts(dispatch, this.harvest.factsConditional(caseValue));
}
const caseResults = cases.map((c) => this.visitSeq(this.caseStatements(c)));
const hasDefault = cases.some((c) => !c.childForFieldName('value'));
const entryOf: number[] = new Array(cases.length);
let after = switchExit;
for (let i = cases.length - 1; i >= 0; i--) {
entryOf[i] = caseResults[i]?.entry ?? after;
after = entryOf[i];
}
for (let i = 0; i < cases.length; i++) {
this.builder.edge(dispatch, entryOf[i], 'switch-case');
}
if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
for (let i = 0; i < cases.length; i++) {
const res = caseResults[i];
if (!res) continue;
const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit;
this.builder.connect(res.exits, fallTarget, 'fallthrough');
}
this.jumps.pop();
return { entry: dispatch, exits: [switchExit] };
}
/** A case's body statements (everything but the `value` test and comments). */
private caseStatements(caseNode: SyntaxNode): SyntaxNode[] {
const value = caseNode.childForFieldName('value');
return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment');
}
/** Nearest enclosing exception handler, or the function EXIT. */
protected currentHandler(): number {
return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex;
}
private labelOf(stmt: SyntaxNode): string | undefined {
const id =
stmt.childForFieldName('label') ??
stmt.namedChildren.find((c) => c.type === 'statement_identifier');
return id?.text;
}
/**
* Drain any forward gotos whose label never appeared in the function (a label
* defined in a header macro, or malformed source) — route them to EXIT so the
* graph stays single-exit. Logs via console.warn (the builder's warn path)
* so a dropped jump is never silent (R4). Called once after the body walk.
*/
finishGotos(): void {
for (const [label, froms] of this.pendingGotos) {
// eslint-disable-next-line no-console
console.warn(
`[cfg] unresolved goto label "${label}" routed to EXIT (${froms.length} site(s))`,
);
for (const from of froms) this.builder.edge(from, this.builder.exitIndex, 'seq');
}
this.pendingGotos.clear();
}
}
/**
* C++ walk — extends the C core with exception flow and the range-for loop.
* These node types never appear in a C parse, so no language conditional is
* needed; the C core dispatches them through {@link visitExtra}.
*/
class CppCfgWalk extends CCfgWalk {
protected override visitExtra(stmt: SyntaxNode): SeqResult | undefined {
switch (stmt.type) {
case 'for_range_loop':
return this.visitForRange(stmt);
case 'try_statement':
return this.visitTry(stmt);
case 'throw_statement':
return this.visitThrow(stmt);
case 'co_return_statement':
// A coroutine `co_return` terminates the coroutine like an ordinary
// return: edge to EXIT, no fallthrough (`co_await`/`co_yield` are plain
// expressions that continue, so they need no control-flow handling).
return this.visitReturn(stmt);
default:
return undefined;
}
}
private visitThrow(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
this.builder.edge(idx, this.currentHandler(), 'throw');
return { entry: idx, exits: [] };
}
private visitForRange(stmt: SyntaxNode): TraversalResult {
// Header text is synthesized; facts come from the declarator (def) + the
// iterated expression (use) directly.
const header = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.forRangeHeaderText(stmt),
'normal',
this.harvest.forRangeHeadFacts(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.jumps.push({ kind: 'loop', breakTo: loopExit, continueTo: header });
const body = this.visitBody(this.bodyBlockOf(stmt));
this.jumps.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private forRangeHeaderText(stmt: SyntaxNode): string {
const decl = stmt.childForFieldName('declarator')?.text ?? '';
const right = stmt.childForFieldName('right')?.text ?? '';
return decl || right ? `for(${decl} : ${right})` : 'for(… : …)';
}
/**
* try / catch (C++ has no `finally`). Conservative exceptional flow: every
* block created while walking the protected body edges to the (first) catch
* handler — an exception may fire mid-block, and a branched body must still
* reach the handler from any interior block (matching the TS visitTry
* over-approximation). Multiple `catch` clauses: a body throw routes to EVERY
* handler (the runtime type match is dynamic), and each handler's normal
* completion joins the post-try continuation.
*/
private visitTry(stmt: SyntaxNode): SeqResult {
const bodyNode = stmt.childForFieldName('body');
const catchClauses: SyntaxNode[] = [];
for (let i = 0; i < stmt.namedChildCount; i++) {
const c = stmt.namedChild(i);
if (c?.type === 'catch_clause') catchClauses.push(c);
}
// Build each catch handler. The handler entry is the catch-param binding
// block (a facts-only block) in front of the body, so the exception's
// binding happens exactly once on handler entry.
const handlerEntries: number[] = [];
const handlerExits: number[] = [];
for (const clause of catchClauses) {
const clauseBody = this.bodyBlockOf(clause);
let res: SeqResult = clauseBody ? this.visitSeq(this.statementsOf(clauseBody)) : null;
if (res === null) {
// Empty catch body still CATCHES — synthesize one block so the
// exception lands somewhere and the post-try code stays reachable.
const idx = this.builder.newBlock(startLineOf(clause), endLineOf(clause), '');
res = { entry: idx, exits: [idx] };
}
const paramFacts = this.harvest.catchParamFacts(clause);
if (paramFacts) {
const paramBlock = this.builder.newBlock(
startLineOf(clause),
startLineOf(clause),
'',
'normal',
paramFacts,
);
this.builder.edge(paramBlock, res.entry, 'seq');
res = { entry: paramBlock, exits: res.exits };
}
handlerEntries.push(res.entry);
handlerExits.push(...res.exits);
}
// The protected body's handler is the FIRST catch (if any), else the outer.
const tryHandler = handlerEntries[0] ?? this.currentHandler();
const protectedStart = this.builder.blockCount;
this.handlers.push(tryHandler);
const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null;
this.handlers.pop();
// Conservative exceptional edges: every protected-region block → EVERY handler
// entry. The runtime catch that matches a thrown type is not statically known,
// so over-approximate to ALL clauses — wiring only the first (tryHandler)
// orphaned `catch` clauses 2..N, dropping their control/data flow entirely
// (the binding `catch(T2 e)` and the handler body became unreachable). Mirrors
// the Swift multi-catch handling.
if (catchClauses.length > 0) {
for (let b = protectedStart; b < this.builder.blockCount; b++) {
for (const handler of handlerEntries) this.builder.edge(b, handler, 'throw');
}
}
const exits: number[] = [];
if (bodyRes) exits.push(...bodyRes.exits);
exits.push(...handlerExits);
// No catch clause at all — an exception re-propagates to the outer handler.
if (catchClauses.length === 0 && bodyRes) {
// (A bare `try {}` with no catch is ill-formed C++, but stay robust.)
this.builder.connect(bodyRes.exits, this.currentHandler(), 'throw');
}
const entry = bodyRes?.entry ?? handlerEntries[0];
if (entry === undefined) return null;
return { entry, exits: [...new Set(exits)] };
}
}
/** Build the CFG for one C/C++ function node, or `undefined` if not modelable. */
function buildFunctionCfg(
fnNode: SyntaxNode,
filePath: string,
functionTypes: ReadonlySet<string>,
controlFlowTypes: ReadonlySet<string>,
WalkClass: typeof CCfgWalk,
): FunctionCfg | undefined {
try {
if (!functionTypes.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
// The body is a compound_statement (field `body`, or first such child).
const body =
fnNode.childForFieldName('body') ??
fnNode.namedChildren.find((c) => c.type === 'compound_statement');
if (!body || body.type !== 'compound_statement') return undefined; // declaration / no body
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const harvest = new CCppHarvester(fnNode);
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
const walk = new WalkClass(builder, harvest, controlFlowTypes);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
walk.finishGotos();
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish(harvest.bindingTable());
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish(harvest.bindingTable());
} catch (err) {
// Never throw out of buildFunctionCfg — a malformed AST shape must skip only
// this one function's CFG, never drop the whole file's language group (R4).
// eslint-disable-next-line no-console
console.warn(`[cfg] C/C++ buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`);
return undefined;
}
}
/** The C CFG visitor. */
export function createCCfgVisitor(): CfgVisitor<SyntaxNode> {
return {
isFunction: (node) => C_FUNCTION_TYPES.has(node.type),
buildFunctionCfg: (fnNode, filePath) =>
buildFunctionCfg(fnNode, filePath, C_FUNCTION_TYPES, C_CONTROL_FLOW_TYPES, CCfgWalk),
};
}
/** The C++ CFG visitor (C core + exceptions + range-for + lambdas). */
export function createCppCfgVisitor(): CfgVisitor<SyntaxNode> {
return {
isFunction: (node) => CPP_FUNCTION_TYPES.has(node.type),
buildFunctionCfg: (fnNode, filePath) =>
buildFunctionCfg(fnNode, filePath, CPP_FUNCTION_TYPES, CPP_CONTROL_FLOW_TYPES, CppCfgWalk),
};
}
export { C_FUNCTION_TYPES, CPP_FUNCTION_TYPES };

View file

@ -0,0 +1,374 @@
/**
* Shared call-site taint substrate for the C-family CFG harvesters (#2195 U6,
* plan R7 / KTD2) — the language-agnostic mechanism the C/C++, C#, Java and Go
* harvesters layer their grammar-specific call/member walks on top of.
*
* This file is PURE MECHANISM: it contains no tree-sitter node-type or field
* literals (each harvester supplies those when it drives `openCallSite` /
* `addMemberRead` / `setFrameArg`), so it names no language and carries nothing
* the grammar-literal CI gate needs to validate. It is the C-family analogue of
* the `FactAccumulator` site machinery in
* {@link import('./typescript-harvest.js')} — extracted into one place because
* the four C-family harvesters already share an identical def/use accumulator,
* and the site layer is identical across them too (only the per-grammar node
* shapes differ, and those live in each harvester's `walkValue`/`visitCall`).
*
* Produces the same {@link SiteRecord} shape the (future, deferred) shared
* taint matcher consumes uniformly across all languages: callee path, receiver,
* per-argument occurrence entries (with sanitizer-interposition via-tags),
* result defs, spread/template markers, and member reads. INERT BY DESIGN — no
* C-family source/sink/sanitizer model is registered today (`getSourceSinkConfig`
* returns undefined for every C-family language), so a harvest with no model
* produces ZERO TAINTED edges; this only emits the substrate the deferred model
* work will match against.
*
* Sites are emitted on {@link StatementFacts.sites} only when non-empty, exactly
* like the TS harvester — flag-off runs never harvest, and most fact-bearing
* statements carry no calls.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js';
/** Mutable build-time view of a {@link SiteRecord}. */
interface MutableSite {
kind: SiteRecord['kind'];
parent?: [number, number];
callee?: string;
receiver?: number;
args?: SiteArgOccurrence[][];
resultDefs?: number[];
spread?: number;
template?: boolean;
requireArg?: string;
object?: number;
property?: string;
}
/**
* One open call/new site during the walk (mirrors the TS `SiteFrame`). `argIdx`
* is the argument position currently being walked, or -1 while outside any
* argument (callee walk) — occurrences recorded then do NOT land in this frame's
* args, but still fan out (via-tagged) to enclosing arg-active frames.
*/
interface SiteFrame {
siteIdx: number;
argIdx: number;
}
/**
* Minimal ordered, deduplicating def/use collector for one statement record,
* with NO call-site machinery (#2195 U7). The Kotlin / Python / Ruby / Rust /
* Dart / Swift harvesters each carried a BYTE-IDENTICAL copy of this class:
* those units harvest NO call sites (the taint substrate is a later step), so a
* site-free accumulator keeps their emitted facts free of any `sites` key
* (matching the Python harvester) and byte-identical to one another. This is the
* no-site sibling of {@link CallSiteFactAccumulator}; `finish` omits `sites`
* entirely. `useCount` is live (Ruby's emit guard is `defCount() ||
* useCount()`).
*/
export class DefUseAccumulator {
private readonly defs: number[] = [];
private readonly uses: number[] = [];
private readonly mayDefs: number[] = [];
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
}
/** A def that may not execute (conditional context) — gen without kill. */
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
}
addUse(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
}
useCount(): number {
return this.uses.length;
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
// Stay absent when empty — keeps the serialized side-channel payload lean.
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
}
}
/**
* Defensive per-statement cap on harvested taint `sites` (#2195 U11). A real
* statement carries a handful of call / member-read sites; this only bounds a
* pathological or machine-generated statement (e.g. hundreds of nested calls)
* from producing an unbounded site list. Mirrors the PDG edge/fact caps' style
* (a generous-but-finite limit, checked before each push). Overflow is silent
* but observable via {@link CallSiteFactAccumulator.sitesTruncated}; the first
* `DEFAULT_PDG_MAX_SITES_PER_STATEMENT` sites are kept fully intact (callee,
* args, parent), the over-cap tail is dropped.
*/
export const DEFAULT_PDG_MAX_SITES_PER_STATEMENT = 512;
/**
* Ordered, deduplicating def/use collector for one statement record, PLUS the
* call-site harvest machinery (#2195 U6). A drop-in superset of the simple
* def/use accumulator the C-family harvesters used before the substrate landed
* — `addDef`/`addMayDef`/`addUse`/`defCount`/`useCount`/`finish` are unchanged,
* so harvesters that never open a site emit byte-identical facts (no `sites`
* key, since `finish` omits it when empty).
*/
export class CallSiteFactAccumulator {
private readonly defs: number[] = [];
private readonly uses: number[] = [];
private readonly mayDefs: number[] = [];
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
/** Taint sites recorded for this statement. */
private readonly sites: MutableSite[] = [];
/** Composite (object|property|parent) keys of recorded member-read sites — O(1) dedup. */
private readonly memberReadKeys = new Set<string>();
/** Stack of open call/new sites — the occurrence fan-out targets. */
private readonly frames: SiteFrame[] = [];
/** Set once the per-statement site cap is hit; over-cap sites are dropped. */
private _sitesTruncated = false;
constructor(private readonly line: number) {}
/** True iff this statement hit {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}. */
get sitesTruncated(): boolean {
return this._sitesTruncated;
}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
}
/** A def that may not execute (conditional context) — gen without kill. */
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
}
addUse(idx: number): void {
// Occurrence fan-out happens BEFORE the statement-level dedup: `exec(x, x)`
// records x at BOTH arg positions even though `uses` lists it once.
this.recordOccurrence(idx);
this.addUseWithoutOccurrence(idx);
}
/**
* Statement-level use that is NOT a value occurrence in any open site
* argument — bare callee names only (see each harvester's `visitCall`).
*/
addUseWithoutOccurrence(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
}
useCount(): number {
return this.uses.length;
}
// ── site machinery (#2195 U6, mirrors the TS harvester) ──────────────────
/** `[defs.length, mayDefs.length]` marker for {@link defsSince}. */
defSnapshot(): readonly [number, number] {
return [this.defs.length, this.mayDefs.length];
}
/** Binding indices def'd (must- OR may-) since the snapshot was taken. */
defsSince(snap: readonly [number, number]): number[] {
return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
}
/**
* Open a call/new site; parent = innermost enclosing argument position.
* Returns the new site index, or -1 when the per-statement site cap is hit
* (the caller threads -1 through `pushFrame`/`setSite*`, all of which no-op on
* a sentinel index — see {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}).
*/
openCallSite(kind: 'call' | 'new'): number {
if (this.sites.length >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) {
this._sitesTruncated = true;
return -1;
}
const site: MutableSite = { kind };
const parent = this.innermostArgPosition();
if (parent) site.parent = parent;
this.sites.push(site);
return this.sites.length - 1;
}
pushFrame(siteIdx: number): void {
this.frames.push({ siteIdx, argIdx: -1 });
}
popFrame(): void {
this.frames.pop();
}
/** Set the argument position the top frame is currently walking. */
setFrameArg(argIdx: number): void {
const top = this.frames[this.frames.length - 1];
if (top) top.argIdx = argIdx;
}
/**
* Run `fn` with all open arg frames temporarily detached (argIdx = -1), so
* identifier reads inside still record USES but do NOT fan occurrences into
* the enclosing sink-argument position (e.g. the non-value operands of a
* comma expression — only the final operand's value flows).
*/
suppressOccurrences(fn: () => void): void {
const saved = this.frames.map((f) => f.argIdx);
for (const f of this.frames) f.argIdx = -1;
try {
fn();
} finally {
this.frames.forEach((f, i) => {
f.argIdx = saved[i];
});
}
}
setSiteCallee(siteIdx: number, callee: string): void {
const site = this.sites[siteIdx];
if (site) site.callee = callee;
}
setSiteReceiver(siteIdx: number, receiver: number): void {
const site = this.sites[siteIdx];
if (site) site.receiver = receiver;
}
setSiteResultDefs(siteIdx: number, resultDefs: readonly number[]): void {
const site = this.sites[siteIdx];
if (site) site.resultDefs = [...resultDefs];
}
setSiteSpread(siteIdx: number, firstSpreadArg: number): void {
const site = this.sites[siteIdx];
if (site && site.spread === undefined) site.spread = firstSpreadArg;
}
/**
* Record a value-position member read. Exact duplicates within the statement
* (same object/property/parent position) dedup; reads at DIFFERENT argument
* positions stay distinct (`exec(req.body, req.body)` is two occurrences).
*/
addMemberRead(object: number, property: string): void {
const parent = this.innermostArgPosition();
const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`;
if (this.memberReadKeys.has(dedupKey)) return;
if (this.sites.length >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) {
this._sitesTruncated = true;
return;
}
this.memberReadKeys.add(dedupKey);
const site: MutableSite = { kind: 'member-read' };
if (parent) site.parent = parent;
site.object = object;
site.property = property;
this.sites.push(site);
}
private innermostArgPosition(): [number, number] | undefined {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx >= 0) return [f.siteIdx, f.argIdx];
}
return undefined;
}
/**
* Fan a binding occurrence out to every arg-active open frame, via-tagged
* with the site of the IMMEDIATELY nested frame when one exists:
* `exec(escape(x))` puts a plain `x` in escape's arg 0 and `[x, escapeIdx]`
* in exec's arg 0 — the sanitizer-interposition substrate.
*/
private recordOccurrence(idx: number): void {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx < 0) continue;
// A nested frame whose site was cap-dropped (siteIdx -1) is not a real via.
const next = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
const via = next !== undefined && next >= 0 ? next : undefined;
this.pushArgEntry(f.siteIdx, f.argIdx, idx, via);
}
}
private pushArgEntry(
siteIdx: number,
argIdx: number,
bindingIdx: number,
via: number | undefined,
): void {
const site = this.sites[siteIdx];
if (!site) return; // cap-dropped frame (siteIdx -1) — no target to fan into
const args = (site.args ??= []);
while (args.length <= argIdx) args.push([]);
const list = args[argIdx];
// Dedup exact (binding, via) pairs per position — `f(x + x)` is one entry;
// `f(x + g(x))` keeps the plain AND the via-tagged entry (distinct paths).
for (const e of list) {
const match =
typeof e === 'number'
? via === undefined && e === bindingIdx
: via !== undefined && e[0] === bindingIdx && e[1] === via;
if (match) return;
}
list.push(via === undefined ? bindingIdx : [bindingIdx, via]);
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
// Optional fields stay absent when empty — keeps the serialized
// side-channel payload lean (most statements have no may-defs / sites).
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
...(this.sites.length > 0 ? { sites: this.sites.map(finalizeSite) } : {}),
};
}
}
/** Trim trailing empty arg positions; drop `args` entirely when all-empty. */
const finalizeSite = (site: MutableSite): SiteRecord => {
const args = site.args;
if (args !== undefined) {
let end = args.length;
while (end > 0 && args[end - 1].length === 0) end--;
if (end === 0) delete site.args;
else if (end < args.length) site.args = args.slice(0, end);
}
return site as SiteRecord;
};

View file

@ -0,0 +1,615 @@
/**
* C# def/use harvester (#2195 U3, plan KTD2) — the C# analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the closely-related
* {@link import('./c-cpp-harvest.js').CCppHarvester}.
*
* Runs in the parse worker next to the C# CFG visitor, extracting per-statement
* variable definition/use facts that ride the side channel for the reaching-defs
* / CDG solvers. Output is the per-function binding table ({@link BindingEntry}[])
* plus {@link StatementFacts} the visitor attaches to blocks as it walks.
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / C-C++ harvesters):
* the CFG walk is NOT source-order (`visitFor` builds the init block after the
* body, `visitDoWhile` the condition before the body), so resolving names against
* a scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans
* the whole function subtree once into a completed lexical scope tree; phase 2
* resolves defs/uses against that finished tree from any walk order.
*
* v1 def-semantics scope:
* - `local_declaration_statement` → `variable_declaration` → `variable_declarator`
* (an INITIALIZED local is a def; a bare `int x;` with no initializer writes
* nothing at runtime — not a def, like the TS bare-`var` rule).
* - `assignment_expression` (plain + compound `+=` etc.), `postfix_unary_expression`
* / `prefix_unary_expression` (`x++` / `--x`) — define and (for compound /
* update) also use the lvalue.
* - parameters (`parameter` → `name` field), the `foreach` loop variable
* (`foreach_statement` field `left`), pattern bindings (`declaration_pattern`
* `name`, e.g. `o is string s` / `case int n:`), and catch-clause names
* (`catch_declaration` `name`).
* EXCLUDED, deliberately (TypeScript-CFA precedent): member / element / pointer
* writes (`obj.F = …`, `a[i] = …`) are NOT scalar defs — their identifiers are
* uses only. Nested-function (lambda / local-function / anonymous-method) bodies
* are opaque in BOTH directions (writes to and reads of captured outer variables
* are invisible).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` / `??` (`a ?? (a = load())`), a ternary arm, or a switch
* arm/case test — is a may-def (gen without kill), so the not-taken path's prior
* def is not falsely killed.
*
* Identifiers with no in-function declaration (fields, properties, statics,
* namespaced names) resolve to a SYNTHETIC module-level binding (`name@module`),
* applied identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { CallSiteFactAccumulator } from './call-site-harvest.js';
import { ScopeTreeHarvester, type Scope, type FactAccumulator } from './scope-tree-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'lambda_expression',
'anonymous_method_expression',
'local_function_statement',
'method_declaration',
'constructor_declaration',
]);
/**
* Nodes that open a lexical scope for block-local declarations. A `block` is one
* scope; the loop constructs open a scope for their loop variable; a
* `catch_clause` scopes its exception name; a `using_statement` scopes its
* resource declaration; a `switch_section` scopes its pattern bindings.
*/
const SCOPE_TYPES = new Set([
'block',
'for_statement',
'foreach_statement',
'while_statement',
'using_statement',
'catch_clause',
'switch_section',
'switch_expression_arm',
]);
export class CsharpHarvester extends ScopeTreeHarvester {
constructor(fnNode: SyntaxNode) {
super(fnNode);
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body, this.openScope(body));
}
/** The function/lambda body node (a `block` or an expression for `=> expr`). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
const body = fnNode.childForFieldName('body');
if (body) return body;
// Anonymous method / local function: the body is the first `block` child.
return fnNode.namedChildren.find((c) => c.type === 'block');
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declareParams(fnNode: SyntaxNode): void {
const params =
fnNode.childForFieldName('parameters') ??
fnNode.namedChildren.find(
(c) => c.type === 'parameter_list' || c.type === 'implicit_parameter',
);
if (!params) return;
if (params.type === 'implicit_parameter') {
// Single un-parenthesized lambda parameter: `x => …`.
this.declare(params, 'param', this.root);
return;
}
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter') continue;
const name = p.childForFieldName('name');
if (name) this.declare(name, 'param', this.root);
}
}
protected prescan(node: SyntaxNode, scope: Scope): void {
this.nearestScopeCache.set(node.id, scope);
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// A nested function / lambda body is opaque — do not descend.
return;
}
let childScope = scope;
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
switch (t) {
case 'local_declaration_statement': {
const decl = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (decl) this.declareVariableDeclaration(decl, childScope);
break;
}
case 'foreach_statement': {
// `foreach (var x in xs)` — the `left` is the loop var (identifier or a
// `tuple_pattern` of identifiers); binds in the loop scope.
const left = node.childForFieldName('left');
if (left) this.declareForeachTarget(left, childScope);
break;
}
case 'using_statement': {
// `using (var f = Open())` — declaration form binds the resource.
const decl = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (decl) this.declareVariableDeclaration(decl, childScope);
break;
}
case 'catch_clause': {
const declNode = node.namedChildren.find((c) => c.type === 'catch_declaration');
const name = declNode?.childForFieldName('name');
if (name) this.declare(name, 'catch', childScope);
break;
}
case 'declaration_pattern': {
// `o is string s` / `case int n:` — `s`/`n` is a fresh binding.
const name = node.childForFieldName('name');
if (name) this.declare(name, 'var', childScope);
break;
}
case 'declaration_expression': {
// `f(out var n)` / `f(out int n)` — `n` is a fresh out-binding written
// by the callee; declare it so its def + later uses resolve to a real
// local rather than a synthetic module binding.
const name = node.childForFieldName('name');
if (name) this.declare(name, 'var', childScope);
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c, childScope);
}
}
/** Declare every `variable_declarator` name in a `variable_declaration`. */
private declareVariableDeclaration(declNode: SyntaxNode, scope: Scope): void {
for (let i = 0; i < declNode.namedChildCount; i++) {
const d = declNode.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
if (name) {
this.declare(name, 'var', scope);
} else {
// Deconstruction declaration `var (a, b) = …;` — the declarator's name
// slot is a `tuple_pattern`; declare each identifier under it (reusing
// the foreach tuple-target logic).
const tuple = d.namedChildren.find((c) => c.type === 'tuple_pattern');
if (tuple) this.declareForeachTarget(tuple, scope);
}
}
}
/** Declare a `foreach` target — an identifier or a `tuple_pattern`. */
private declareForeachTarget(left: SyntaxNode, scope: Scope): void {
if (left.type === 'identifier') {
this.declare(left, 'var', scope);
return;
}
// `var (k, v)` deconstruction — declare each identifier under the pattern.
for (let i = 0; i < left.namedChildCount; i++) {
const c = left.namedChild(i);
if (c?.type === 'identifier') this.declare(c, 'var', scope);
}
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Def-ONLY facts for a value-position binding carrier (`var x = k switch {…}`,
* #2207): just the declared name(s)' def, attached to the continuation block the
* switch arms rejoin. The discriminant + arm-value USES are already harvested
* onto the branch's own blocks ({@link facts} on each arm), so this must NOT
* re-walk the initializer — only each `variable_declarator`'s name is a def here.
*/
bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const decl = stmt.namedChildren.find((c) => c.type === 'variable_declaration');
if (decl) {
for (let i = 0; i < decl.namedChildCount; i++) {
const d = decl.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
if (name) this.def(name, acc);
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Facts for a `foreach (decl in right)` head: decl binds, right is used. */
forEachHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const left = stmt.childForFieldName('left');
const right = stmt.childForFieldName('right');
if (left) this.defForeachTarget(left, acc);
if (right) this.walkValue(right, acc);
return acc.finish();
}
/** ENTRY-block facts for the function's parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const params =
this.fnNode.childForFieldName('parameters') ??
this.fnNode.namedChildren.find(
(c) => c.type === 'parameter_list' || c.type === 'implicit_parameter',
);
if (!params) return undefined;
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
if (params.type === 'implicit_parameter') {
this.def(params, acc);
} else {
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter') continue;
const name = p.childForFieldName('name');
if (name) this.def(name, acc);
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch (T e)` declaration — prepend to the handler entry block. */
catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined {
const declNode = catchClause.namedChildren.find((c) => c.type === 'catch_declaration');
const name = declNode?.childForFieldName('name');
if (!name) return undefined;
const acc = new FactAccumulator(catchClause.startPosition.row + 1);
this.def(name, acc);
return acc.defCount() ? acc.finish() : undefined;
}
/** Strip parenthesized wrappers around an lvalue (`(x) = 1`). */
private unwrapLvalue(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 8;
while (n.type === 'parenthesized_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/** Def a `foreach` target (identifier or tuple) in a header fact accumulator. */
private defForeachTarget(left: SyntaxNode, acc: FactAccumulator): void {
if (left.type === 'identifier') {
this.def(left, acc);
return;
}
for (let i = 0; i < left.namedChildCount; i++) {
const c = left.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
/** Value-position walk: collect uses; route def positions to the lvalue handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// Opaque nested function / lambda — captured reads/writes are invisible.
return;
}
switch (t) {
case 'identifier':
this.use(node, acc);
return;
case 'local_declaration_statement':
case 'variable_declaration': {
const decl = t === 'variable_declaration' ? node : node.namedChild(0);
if (decl && decl.type === 'variable_declaration') {
for (let i = 0; i < decl.namedChildCount; i++) {
const d = decl.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
if (!name) {
// Deconstruction declaration `var (a, b) = e;` — def each
// identifier in the `tuple_pattern`; the initializer is the
// declarator's non-pattern child.
const tuple = d.namedChildren.find((c) => c.type === 'tuple_pattern');
const tupleInit = d.namedChildren.find((c) => c.type !== 'tuple_pattern');
if (tuple) {
const snap = acc.defSnapshot();
this.defTupleTargets(tuple, acc);
if (tupleInit) this.registerResultDefs(tupleInit, acc.defsSince(snap));
}
if (tupleInit) this.walkValue(tupleInit, acc);
continue;
}
// The initializer (if any) is the LAST named child after `name`.
const init = this.declaratorInit(d);
if (name && init) {
const snap = acc.defSnapshot();
this.def(name, acc);
this.registerResultDefs(init, acc.defsSince(snap));
}
if (init) this.walkValue(init, acc);
}
}
return;
}
case 'declaration_expression': {
// `out var n` / `out int n` — the callee writes `n` (a must-def: C#
// requires an out parameter to be assigned before the method returns).
const name = node.childForFieldName('name');
if (name) this.def(name, acc);
return;
}
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '=';
if (left) {
const lv = this.unwrapLvalue(left);
if (lv.type === 'identifier') {
const snap = acc.defSnapshot();
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
} else if (lv.type === 'tuple_expression') {
this.defTupleTargets(lv, acc); // `(a, b) = …` deconstruction
} else {
this.walkValue(lv, acc); // member/element target — uses only
}
}
if (right) this.walkValue(right, acc);
return;
}
case 'postfix_unary_expression':
case 'prefix_unary_expression': {
const arg = node.namedChild(0);
const lv = arg ? this.unwrapLvalue(arg) : null;
// Only `++`/`--` write; `!x`/`-x`/`~x` are pure reads. The operator is an
// anonymous child; treat as a def+use only when the operand is an
// identifier AND the op text is an increment/decrement.
if (lv?.type === 'identifier' && this.isIncDec(node)) {
this.def(lv, acc);
this.use(lv, acc);
} else if (arg) {
this.walkValue(arg, acc);
}
return;
}
case 'binary_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||' || op === '??') {
this.conditional(() => this.walkValue(right, acc));
} else {
this.walkValue(right, acc);
}
}
return;
}
case 'conditional_expression': {
const cond = node.childForFieldName('condition');
const cons = node.childForFieldName('consequence');
const alt = node.childForFieldName('alternative');
if (cond) this.walkValue(cond, acc);
if (cons) this.conditional(() => this.walkValue(cons, acc));
if (alt) this.conditional(() => this.walkValue(alt, acc));
return;
}
case 'invocation_expression':
// #2195 U6: explicit case (previously default-descended) — same uses,
// plus a taint-site record. Defs/uses stay byte-identical.
this.visitCall(node, acc, 'call');
return;
case 'object_creation_expression':
// `new Foo(x)` — constructor call site (`type` field is the callee).
this.visitCall(node, acc, 'new');
return;
case 'member_access_expression': {
// `a.B` — value read of the chain root only; the member name is not a
// scalar binding. Mirrors the TS member-read use semantics, plus a
// member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
/**
* When `value`'s root (after stripping parens) is an invocation/creation
* node, remember that its site should carry `resultDefs: defs` — consumed by
* {@link visitCall} once the value walk reaches the node.
*/
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
if (defs.length === 0) return;
const root = this.unwrapLvalue(value);
if (root.type === 'invocation_expression' || root.type === 'object_creation_expression') {
this.resultDefTargets.set(root.id, [...defs]);
}
}
/**
* Explicit invocation / object-creation handler: records a call site (callee
* path, receiver, per-arg occurrence entries, result defs) while reproducing
* EXACTLY the uses the old default descent recorded. C# wraps each argument in
* an `argument` node; `new Foo(...)` reads the `type` field as the callee.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'type' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
const callee = this.unwrapLvalue(calleeNode);
if (callee.type === 'identifier') {
// For a `new Foo(...)`, the `type` is a type name, not a scalar
// binding — record neither a use nor an occurrence for it; for a bare
// call the callee name is a statement-level use but not an occurrence.
if (kind === 'call') acc.addUseWithoutOccurrence(this.resolve(callee));
calleePath = callee.text;
} else if (callee.type === 'member_access_expression') {
// skipFinalRead: the final access IS the callee, carried by the path.
// A static dotted path (`System.Console.WriteLine(...)`) parses as a
// member_access_expression chain too, so this one branch covers both
// instance and static dotted callees.
const chain = this.walkChain(callee, acc, true);
calleePath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
this.walkValue(callee, acc);
}
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
// C# wraps each value in an `argument` node; the value is the inner
// expression (a named argument `name: x` exposes the label through the
// `name` field — skip it so `x` is what flows).
if (!arg || arg.type === 'comment') continue;
acc.setFrameArg(pos);
const value = arg.type === 'argument' ? this.argumentValue(arg) : arg;
if (value) this.walkValue(value, acc);
pos++;
}
}
acc.popFrame();
}
/**
* The value expression inside an `argument` node. A named argument
* (`name: x`) carries the label on the `name` field and the value as a
* sibling; a positional argument is just the value. Returns the last named
* child that is not the `name`-field label (and skips `ref`/`out`/`in`
* modifier keywords, which are anonymous tokens, not named children).
*/
private argumentValue(arg: SyntaxNode): SyntaxNode | undefined {
const label = arg.childForFieldName('name');
for (let i = arg.namedChildCount - 1; i >= 0; i--) {
const c = arg.namedChild(i);
if (c && c.id !== label?.id) return c;
}
return undefined;
}
/**
* Member chain walk shared by value position and callee position. Records the
* chain-root identifier as a use (identical to the old default descent), plus
* at most ONE member-read site — the INNERMOST access — when the root is an
* identifier; `skipFinalRead` suppresses it when that access is the callee.
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapLvalue(node);
for (;;) {
if (cur.type === 'member_access_expression') {
const name = cur.childForFieldName('name');
accesses.unshift(name?.text ?? '');
const expr = cur.childForFieldName('expression');
if (!expr) break;
cur = this.unwrapLvalue(expr);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
this.walkValue(cur, acc);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}
/**
* The initializer value of a `variable_declarator` — the named child after
* `name`. NOTE: deliberately duplicated in `csharp.ts` (the visitor is a
* standalone class with no shared base — repo convention). The two copies must
* stay in sync; there is no C#-specific shared module to host it, and the only
* module both files share is the generic `utils/ast-helpers` (types only).
*/
private declaratorInit(declarator: SyntaxNode): SyntaxNode | undefined {
const name = declarator.childForFieldName('name');
for (let i = 0; i < declarator.namedChildCount; i++) {
const c = declarator.namedChild(i);
if (c && c.id !== name?.id) return c;
}
return undefined;
}
/** Whether a unary expression is `++`/`--` (the only writing unary ops). */
private isIncDec(node: SyntaxNode): boolean {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c && !c.isNamed && (c.text === '++' || c.text === '--')) return true;
}
return false;
}
/** Def each identifier in a `(a, b) = …` tuple deconstruction target. */
private defTupleTargets(tuple: SyntaxNode, acc: FactAccumulator): void {
for (let i = 0; i < tuple.namedChildCount; i++) {
const c = tuple.namedChild(i);
if (!c) continue;
// tuple_expression wraps each element in an `argument`.
const inner = c.type === 'argument' ? c.namedChild(0) : c;
if (inner?.type === 'identifier') this.def(inner, acc);
else if (inner) this.walkValue(inner, acc);
}
}
}
/**
* Ordered, deduplicating def/use + call-site collector for one statement record.
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
* old local class had, plus the taint-site harvest (#2195 U6).
*/
const FactAccumulator = CallSiteFactAccumulator;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,565 @@
/**
* Dart def/use harvester (#2195) — the Dart analogue of
* {@link import('./kotlin-harvest.js').KotlinHarvester} and the Swift / Python /
* Rust harvesters. Like them it harvests NO call-site `sites[]` (the call-site
* taint substrate is a later step): it emits only the per-function binding table
* ({@link BindingEntry}[]) plus {@link StatementFacts} (defs / uses / mayDefs) via
* a local {@link FactAccumulator} with no site machinery, so the produced facts
* never carry a `sites` key.
*
* Runs in the parse worker next to the Dart CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
* plus the per-block def/use facts the reaching-defs / CDG solvers consume.
*
* Every node-type literal below was grammar-validated against the VENDORED
* tree-sitter-dart via the introspection probe before use (mandatory pre-step —
* the grammar-literal CI gate maps `dart-harvest.ts → Dart` and fails on a wrong
* literal). Dart's grammar splits a function into SIBLING nodes — a
* `function_signature` / `method_signature` / getter/setter signature followed by
* a sibling `function_body` (the body, NOT a child of the signature) — so this
* harvester takes the `function_body` (or a closure's `function_expression`) as
* the function node and reaches the signature via the previous sibling.
*
* Dart shapes pre-empted (verified by a real parse):
* - parameters: `function_signature`/`method_signature`/`setter_signature` own a
* `formal_parameter_list` → `formal_parameter` (each `name:identifier`). A
* closure (`function_expression`) owns `parameters:formal_parameter_list`.
* - `local_variable_declaration` → `initialized_variable_definition`
* (`name:identifier` `= value`). The declaration kind keyword is `inferred_type`
* (`var`), `final_builtin` (`final`), a `type_identifier`/`void_type` (typed),
* or `late` (anon). A bare `var e;` with no initializer still binds the name.
* - `for_loop_parts` — C-style (`init:local_variable_declaration`,
* `condition:`, `update:`) OR for-in (`inferred_type`? `name:identifier` `in`
* `value:` — or a bare `identifier` `in` `value:` over an existing variable).
* - `catch_clause` → `catch_parameters` (`(e)` or `(e, st)` — both bound).
* - reads: `identifier`, `selector` (`.name` / `(...args)` member/call chain),
* `assignment_expression` (`left:assignable_expression` `operator:` `right:`),
* `if_null_expression` (`a ?? b`), `conditional_expression` (`c ? a : b`),
* logical `&&` / `||` (`logical_and_expression` / `logical_or_expression`).
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Kotlin / Swift / Rust
* harvesters): the CFG walk is NOT source-order (`do … while` builds the condition
* after the body), so resolving names against a scope stack populated *during* the
* walk would mis-resolve. Phase 1 pre-scans the whole function subtree once,
* declaring every bound name into ONE function table; phase 2 resolves defs/uses
* against that finished table from any walk order. Dart DOES have block scope +
* shadowing, but a single function table is the documented v1 simplification used
* by the Kotlin / Swift / Python / Rust harvesters — distinct shadowing
* redeclarations of the same name collapse onto one binding (an over-approximation
* that can falsely kill across a shadow, the sound direction for taint).
*
* v1 def-semantics scope:
* - `initialized_variable_definition` (`var`/`final`/typed `PAT = …`) — the
* `name:identifier` is a def; the value is walked for uses. A bare declaration
* with no initializer still binds the name (Dart locals are in scope from the
* declaration; an uninitialized read is a compile error, so binding is safe).
* - `assignment_expression` plain `=` — a plain-identifier lvalue is a def; a
* member / subscript target (`this.x = …`, `a[i] = …`) is NOT a scalar def
* (its root is a use). A compound `+=`/`-=`/… target def-AND-uses the lvalue.
* - `postfix_expression` / `prefix_expression` update (`i++` / `--i`) def-and-use.
* - `for (var e in xs)` — the loop pattern name is a def, the collection a use.
* - `catch (e, st)` — both error binders bind.
* - parameters (incl. closure params) are `param`-kind defs.
* EXCLUDED, deliberately (TypeScript-CFA precedent): member / subscript writes
* (`obj.f = …`, `a[i] = …`) are NOT scalar defs — their root identifiers are uses
* only. Nested-function bodies (`function_expression`) are opaque in BOTH directions.
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` short-circuit, the `??` right operand, a conditional
* (`? :`) arm, and a `switch`-expression / case-pattern test — is a may-def (gen
* WITHOUT kill), so the not-taken path's prior def is not falsely killed.
*
* Identifiers with no in-function declaration (top-level functions, types,
* fields) resolve to a SYNTHETIC module-level binding (`name@module`), applied
* identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set(['function_expression', 'function_body']);
const COMMENT_TYPES = new Set(['comment', 'documentation_comment']);
const FUNCTION_VALUE_TYPES = new Set(['function_expression']);
export class DartHarvester {
private readonly bindings: BindingEntry[] = [];
/** Single function-scope name → binding index (v1: no block scope). */
private readonly table = new Map<string, number>();
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* @param fnNode The function-bearing node: a `function_body` (whose previous
* sibling is the signature carrying the params) or a `function_expression`
* (a closure, carrying its own `parameters`).
* @param signature The previous-sibling signature for a `function_body`, or
* undefined for a `function_expression` (which carries params directly).
*/
constructor(
private readonly fnNode: SyntaxNode,
private readonly signature: SyntaxNode | undefined,
) {
this.fnId = fnNode.id;
this.declareParams();
const body = this.bodyOf(fnNode);
if (body) this.prescan(body);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
/** The body subtree to pre-scan: a `function_body`'s `block`/expr, or a closure's body. */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
if (fnNode.type === 'function_expression') {
return fnNode.childForFieldName('body') ?? undefined;
}
// `function_body` — its child `block` or arrow expression.
return fnNode.namedChildren.find((c) => !COMMENT_TYPES.has(c.type));
}
// ── parameters ────────────────────────────────────────────────────────────
/** The `formal_parameter_list` owning this function's params. */
private paramList(): SyntaxNode | undefined {
if (this.fnNode.type === 'function_expression') {
return this.fnNode.childForFieldName('parameters') ?? undefined;
}
if (!this.signature) return undefined;
// A `method_signature` wraps a `function_signature` / getter / setter that
// carries the actual `formal_parameter_list`; unwrap one level first.
let sig = this.signature;
if (sig.type === 'method_signature') {
const inner = sig.namedChildren.find(
(c) =>
c.type === 'function_signature' ||
c.type === 'setter_signature' ||
c.type === 'getter_signature' ||
c.type === 'constructor_signature' ||
c.type === 'factory_constructor_signature',
);
if (inner) sig = inner;
}
return sig.namedChildren.find((c) => c.type === 'formal_parameter_list');
}
/** Every `formal_parameter`'s bound name node. */
private paramNames(): SyntaxNode[] {
const list = this.paramList();
if (!list) return [];
const names: SyntaxNode[] = [];
for (const p of list.namedChildren) {
if (p.type !== 'formal_parameter') continue;
const name =
p.childForFieldName('name') ?? p.namedChildren.find((c) => c.type === 'identifier');
if (name) names.push(name);
}
return names;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void {
const name = nameNode.text;
if (!name || name === '_' || this.table.has(name)) return;
this.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
private declareParams(): void {
for (const name of this.paramNames()) this.declare(name, 'param');
}
/**
* Pre-scan the function body once, declaring every bound name. Recurses into
* compound expressions but NOT into nested function/closure bodies (opaque).
*/
private prescan(node: SyntaxNode): void {
const t = node.type;
if (FUNCTION_VALUE_TYPES.has(t) && node.id !== this.fnId) return;
switch (t) {
case 'initialized_variable_definition':
this.declareInitializedVar(node, 'let');
break;
case 'for_loop_parts':
this.declareForParts(node);
break;
case 'catch_parameters':
this.declareCatchParams(node);
break;
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c);
}
}
/** Declare every name of an `initialized_variable_definition` (`var a = 1, b = 2`). */
private declareInitializedVar(node: SyntaxNode, kind: BindingEntry['kind']): void {
const name = node.childForFieldName('name');
if (name) this.declare(name, kind);
// Trailing comma-separated bindings: each `initialized_identifier` (`b = 2`)
// names another local that the `name` field alone misses.
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type !== 'initialized_identifier') continue;
const id = c.namedChildren.find((g) => g.type === 'identifier');
if (id) this.declare(id, kind);
}
}
/**
* Declare a `for`'s loop variable: a C-style `init:local_variable_declaration`
* is handled by its own `initialized_variable_definition` recursion; a for-in
* binds the `name:identifier` after the optional `inferred_type`/type. A for-in
* over an existing variable (`for (e in xs)`) has no declaration — its bare
* `identifier` is a use (an assignment target), not a new binding.
*/
private declareForParts(node: SyntaxNode): void {
// for-in declares the loop var only when a binder keyword/type precedes it.
if (!this.isForIn(node)) return;
if (!this.forInDeclares(node)) return;
const name = node.childForFieldName('name');
if (name) this.declare(name, 'let');
}
/** A `for_loop_parts` is for-in iff it has an `in` keyword child + a `value` field. */
private isForIn(node: SyntaxNode): boolean {
return node.children.some((c) => c.type === 'in');
}
/** A for-in declares a fresh loop var iff a binder keyword/type precedes the name. */
private forInDeclares(node: SyntaxNode): boolean {
return node.namedChildren.some(
(c) =>
c.type === 'inferred_type' ||
c.type === 'final_builtin' ||
c.type === 'type_identifier' ||
c.type === 'void_type',
);
}
/** Declare a `catch (e[, st])` error name(s). */
private declareCatchParams(node: SyntaxNode): void {
for (const id of node.namedChildren) {
if (id.type === 'identifier') this.declare(id, 'catch');
}
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Def-ONLY facts for a value-position binding carrier (`var x = switch (…) {…}`,
* #2207): just the declared name(s)' def, attached to the continuation block the
* switch arms rejoin. The subject + arm-value USES are already harvested onto
* the branch's own blocks, so this must NOT re-walk the value — only each
* `initialized_variable_definition`'s `name` (and trailing binders) is a def.
*/
bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
for (const def of stmt.namedChildren) {
if (def.type !== 'initialized_variable_definition') continue;
const name = def.childForFieldName('name');
if (name) this.def(name, acc);
for (let i = 0; i < def.namedChildCount; i++) {
const c = def.namedChild(i);
if (c?.type !== 'initialized_identifier') continue;
const id = c.namedChildren.find((g) => g.type === 'identifier');
if (id) this.def(id, acc);
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/**
* Facts for a `for` head. For-in: the loop var name is a def, the collection a
* use. C-style: the init/condition/update sub-expressions are walked for
* defs/uses (the init `local_variable_declaration` defines, the condition reads,
* the update def-and-uses).
*/
forHeadFacts(parts: SyntaxNode | undefined): StatementFacts | undefined {
const line = (parts ?? this.fnNode).startPosition.row + 1;
const acc = new FactAccumulator(line);
if (!parts) return undefined;
if (this.isForIn(parts)) {
const value = parts.childForFieldName('value');
if (value) this.walkValue(value, acc);
// The loop var: a fresh `for (var e in xs)` binder is a def; a
// `for (e in xs)` over an existing var also writes it each iteration (a
// def). Either way the `name:identifier` is a def of the loop variable.
const name = parts.childForFieldName('name');
if (name) this.def(name, acc);
} else {
// C-style: walk init / condition / update.
const init = parts.childForFieldName('init');
const cond = parts.childForFieldName('condition');
const update = parts.childForFieldName('update');
if (init) this.walkValue(init, acc);
if (cond) this.walkValue(cond, acc);
if (update) this.walkValue(update, acc);
}
return acc.finish();
}
/** ENTRY-block facts for the parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
for (const name of this.paramNames()) this.def(name, acc);
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact(s) for a `catch (e[, st])` — prepend to the handler entry block. */
catchParamFacts(catchParams: SyntaxNode | undefined): StatementFacts | undefined {
if (!catchParams) return undefined;
const acc = new FactAccumulator(catchParams.startPosition.row + 1);
for (const id of catchParams.namedChildren) {
if (id.type === 'identifier') this.def(id, acc);
}
return acc.defCount() ? acc.finish() : undefined;
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const idx = this.table.get(name);
if (idx !== undefined) return idx;
let syn = this.synthetic.get(name);
if (syn === undefined) {
syn = this.bindings.length;
this.synthetic.set(name, syn);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return syn;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return; // blank target defines nothing
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
acc.addUse(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
private conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
/** Value-position walk: collect uses; route def positions to the pattern handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (FUNCTION_VALUE_TYPES.has(t) && node.id !== this.fnId) return; // opaque closure
switch (t) {
case 'identifier':
this.use(node, acc);
return;
case 'initialized_variable_definition': {
const value = node.childForFieldName('value');
if (value) this.walkValue(value, acc);
const name = node.childForFieldName('name');
if (name) this.def(name, acc);
// Trailing comma-separated bindings (`var a = 1, b = 2;`): each
// `initialized_identifier` is an `identifier` + its own value expr.
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type !== 'initialized_identifier') continue;
const id = c.namedChildren.find((g) => g.type === 'identifier');
const val = c.namedChildren.find((g) => g.type !== 'identifier');
if (val) this.walkValue(val, acc);
if (id) this.def(id, acc);
}
return;
}
case 'assignment_expression': {
const lvalue = node.childForFieldName('left');
const op = node.childForFieldName('operator');
const value = node.childForFieldName('right');
if (value) this.walkValue(value, acc);
// A `right` field can repeat (an identifier + trailing selectors): walk
// every named child after the operator that isn't the lvalue.
for (const c of node.namedChildren) {
if (c === lvalue) continue;
if (c.type === 'assignable_expression') continue;
if (c === value) continue;
this.walkValue(c, acc);
}
if (lvalue) {
const scalar = this.scalarAssignTarget(lvalue);
if (scalar) {
this.def(scalar, acc);
if (op && op.text !== '=') this.use(scalar, acc); // compound assign reads too
} else {
// `this.x = …`, `a[i] = …` — a member / subscript write is NOT a
// scalar def; walk the lvalue so its root identifier is a use.
this.walkValue(lvalue, acc);
}
}
return;
}
case 'postfix_expression':
case 'unary_expression': {
// `i++` (`postfix_expression`) / `++i` (`unary_expression` with an
// `increment_operator`) — the assignable operand is def-and-use. A
// `unary_expression` with no `increment_operator` (`!x`, `-x`, `await e`)
// is a pure read and falls through to the generic walk below.
const isUpdate =
t === 'postfix_expression' ||
node.namedChildren.some((c) => c.type === 'increment_operator');
const operand = isUpdate
? node.namedChildren.find((c) => c.type === 'assignable_expression')
: undefined;
if (operand) {
const scalar = this.scalarAssignTarget(operand);
if (scalar) {
this.use(scalar, acc);
this.def(scalar, acc);
} else {
// `obj.x++` / `a[i]++` — member/subscript update, not a scalar def.
this.walkValue(operand, acc);
}
} else {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
return;
}
case 'selector': {
// `.name` / `(...args)` — a member-access suffix name is not a scalar
// binding; walk the argument part for uses but skip the bare property id.
for (const c of node.namedChildren) {
if (
c.type === 'unconditional_assignable_selector' ||
c.type === 'conditional_assignable_selector'
) {
continue; // `.name` — property name is not a use
}
this.walkValue(c, acc);
}
return;
}
case 'logical_and_expression':
case 'logical_or_expression': {
// `a && b` / `a || b` — the right operand is conditionally evaluated.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const rhs = operands[i];
this.conditional(() => this.walkValue(rhs, acc));
}
return;
}
case 'if_null_expression': {
// `a ?? b` — the right operand only evaluates when the left is null.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const rhs = operands[i];
this.conditional(() => this.walkValue(rhs, acc));
}
return;
}
case 'conditional_expression': {
// `c ? a : b` — the condition runs always; both arms are conditional.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const arm = operands[i];
this.conditional(() => this.walkValue(arm, acc));
}
return;
}
case 'switch_expression': {
// `switch (x) { p1 => a, p2 => b }` (Dart 3): the subject runs always;
// each arm (pattern + value) is conditional, so a def inside an arm value
// (`z = 1`) is a MAY-def, not an unconditional KILL of the prior `z`
// (#2206). Mirrors conditional_expression.
const subject = node.childForFieldName('condition');
if (subject) this.walkValue(subject, acc);
for (const c of node.namedChildren) {
if (c.type === 'switch_expression_case') {
this.conditional(() => this.walkValue(c, acc));
}
}
return;
}
case 'inferred_type':
case 'final_builtin':
case 'type_identifier':
case 'void_type':
// Binding keyword / type position — no scalar value uses.
return;
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
/**
* The bare `identifier` of an `assignable_expression` lvalue WHEN it is a
* scalar target (`x = …`), or undefined when it is a member / subscript write
* (`obj.x = …`, `a[i] = …`) — those carry a trailing
* `unconditional_assignable_selector` / `conditional_assignable_selector` /
* `index_selector` and are NOT scalar defs (their root identifier is a use).
*/
private scalarAssignTarget(node: SyntaxNode): SyntaxNode | undefined {
// Unwrap nested `assignable_expression` wrappers (defensive).
let n = node;
let hops = 4;
while (n.type === 'assignable_expression' && hops-- > 0) {
const named = n.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
// A single bare identifier child ⇒ scalar target; any trailing selector ⇒
// member/subscript write (not scalar).
if (named.length === 1 && named[0].type === 'identifier') return named[0];
if (named.length === 1 && named[0].type === 'assignable_expression') {
n = named[0];
continue;
}
return undefined; // identifier + selector(s) — member/subscript write
}
return n.type === 'identifier' ? n : undefined;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,669 @@
/**
* Go def/use harvester (#2195 U5, plan KTD2) — the Go analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the closely-related
* {@link import('./java-harvest.js').JavaHarvester} / {@link
* import('./csharp-harvest.js').CsharpHarvester}.
*
* Runs in the parse worker next to the Go CFG visitor, extracting per-statement
* variable definition/use facts that ride the side channel for the reaching-defs
* / CDG solvers. Output is the per-function binding table ({@link BindingEntry}[])
* plus {@link StatementFacts} the visitor attaches to blocks as it walks.
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-go via the introspection probe before use (mandatory pre-step,
* KTD5). Go shapes pre-empted (verified by a real parse):
* - declarations: `short_var_declaration` (`a, b := f()`, fields `left`/`right`
* of `expression_list`s) and `var_declaration` → `var_spec` (fields `name`*,
* `type`?, `value`?) [block form wraps specs in a `var_spec_list`].
* - assignments: `assignment_statement` (fields `left`/`operator`/`right`, all
* `expression_list`s; covers `=`, `+=`, multi-assign `a, b = b, a`), and
* `inc_statement` / `dec_statement` (`x++` / `x--`).
* - loop binders: `range_clause` (`for k, v := range xs`, fields `left`/`right`;
* the `=` reassign form and the bare `for range xs` form both parse here).
* - `selector_expression` (`a.b`, fields `operand`/`field`), `index_expression`
* (`m[k]`, fields `operand`/`index`), `parenthesized_expression`,
* `binary_expression` (fields `left`/`operator`/`right`), `unary_expression`
* (fields `operator`/`operand`; `*p` deref + `<-ch` receive).
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / Java / C#
* harvesters): the CFG walk is NOT source-order (`visitFor` builds the init block
* after the body, the `for`-clause condition before the update), so resolving
* names against a scope stack populated *during* the walk would mis-resolve.
* Phase 1 pre-scans the whole function subtree once into a completed lexical
* scope tree; phase 2 resolves defs/uses against that finished tree from any
* walk order.
*
* v1 def-semantics scope:
* - `short_var_declaration` `:=` — every identifier in the `left`
* `expression_list` is a def (`a, b := f()` defines BOTH `a` and `b`).
* - `var_declaration` → `var_spec` — an INITIALIZED spec (`var x = 1`,
* `var x int = 1`) defines each `name`; a bare `var x int` writes nothing at
* runtime (not a def, the TS bare-`var` rule).
* - `assignment_statement` (plain `=` + compound `+=` …) — each identifier in
* the `left` list is a def; a compound op also USES the lvalue.
* - `inc_statement` / `dec_statement` (`x++` / `x--`) — def AND use the lvalue.
* - parameters (`parameter_declaration` `name`, incl. variadic), the method
* receiver (`method_declaration` `receiver`), and the `range` loop variables
* (`range_clause` `left`).
* EXCLUDED, deliberately (TypeScript-CFA precedent): selector / index / pointer
* writes (`obj.f = …`, `m[k] = …`, `*p = …`) are NOT scalar defs — their root
* identifiers are uses only. Nested-function (`func_literal`) bodies are opaque in
* BOTH directions (writes to and reads of captured outer variables are invisible).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` — is a may-def (gen without kill), so the not-taken
* path's prior def is not falsely killed. (Go has no ternary or `??`; assignment
* is a statement, not an expression, so in-expression assignment defs do not
* occur — `&&`/`||` short-circuit is the only conditional-def shape, and it can
* only surface a may-def via a nested closure, which is opaque anyway. The
* machinery is kept for switch/select case-test parity.)
*
* Identifiers with no in-function declaration (package-level vars, imported
* names, functions) resolve to a SYNTHETIC module-level binding (`name@module`),
* applied identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { CallSiteFactAccumulator } from './call-site-harvest.js';
import { ScopeTreeHarvester, type Scope, type FactAccumulator } from './scope-tree-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'func_literal',
'function_declaration',
'method_declaration',
]);
/**
* Nodes that open a lexical scope for block-local declarations. A `block` is one
* scope; the loop / branch constructs open a scope for their init / loop var; a
* switch/select case scopes its case-local declarations.
*/
const SCOPE_TYPES = new Set([
'block',
'for_statement',
'if_statement',
'expression_switch_statement',
'type_switch_statement',
'select_statement',
'expression_case',
'type_case',
'default_case',
'communication_case',
]);
export class GoHarvester extends ScopeTreeHarvester {
constructor(fnNode: SyntaxNode) {
super(fnNode);
this.declareReceiver(fnNode);
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body && body.type === 'block') this.prescan(body, this.openScope(body));
}
/** The function/method/literal body node (always a `block` in Go). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
return fnNode.childForFieldName('body') ?? undefined;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
/** Go override: `_` is the blank identifier and binds nothing. */
protected override declare(nameNode: SyntaxNode, kind: BindingEntry['kind'], scope: Scope): void {
const name = nameNode.text;
if (!name || name === '_' || scope.table.has(name)) return; // `_` is the blank identifier
scope.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/** Method receiver: `func (r *T) M()` — `r` binds at function scope. */
private declareReceiver(fnNode: SyntaxNode): void {
const recv = fnNode.childForFieldName('receiver');
if (!recv) return;
for (let i = 0; i < recv.namedChildCount; i++) {
const p = recv.namedChild(i);
if (p?.type !== 'parameter_declaration') continue;
const name = p.childForFieldName('name');
if (name) this.declare(name, 'param', this.root);
}
}
private declareParams(fnNode: SyntaxNode): void {
const params = fnNode.childForFieldName('parameters');
if (!params) return;
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter_declaration' && p?.type !== 'variadic_parameter_declaration') {
continue;
}
// A single `parameter_declaration` can name several params: `a, b int`.
for (let j = 0; j < p.namedChildCount; j++) {
const c = p.namedChild(j);
if (c?.type === 'identifier') this.declare(c, 'param', this.root);
}
}
}
protected prescan(node: SyntaxNode, scope: Scope): void {
this.nearestScopeCache.set(node.id, scope);
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// A nested function literal is opaque — do not descend.
return;
}
let childScope = scope;
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
switch (t) {
case 'short_var_declaration': {
// `a, b := …` — every identifier on the left is a fresh binding.
const left = node.childForFieldName('left');
if (left) this.declareIdentifiers(left, childScope);
break;
}
case 'var_declaration':
this.declareVarDeclaration(node, childScope);
break;
case 'range_clause': {
// `for k, v := range xs` — the `:=` form binds the loop vars; the `=`
// reassign form references existing names (not declared here).
if (this.rangeIsShort(node)) {
const left = node.childForFieldName('left');
if (left) this.declareIdentifiers(left, childScope);
}
break;
}
case 'type_switch_statement': {
// `switch t := i.(type)` — `t` binds once for the whole switch (the
// per-case narrowed `t` shares the name).
const alias = node.childForFieldName('alias');
if (alias) this.declareIdentifiers(alias, childScope);
break;
}
case 'receive_statement': {
// `case v := <-ch` inside a select — the `:=` form binds `v`.
if (this.receiveIsShort(node)) {
const left = node.childForFieldName('left');
if (left) this.declareIdentifiers(left, childScope);
}
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c, childScope);
}
}
/** Declare every identifier child of an `expression_list` (LHS of `:=`). */
private declareIdentifiers(list: SyntaxNode, scope: Scope): void {
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (c?.type === 'identifier') this.declare(c, 'var', scope);
}
}
/** Declare names of an INITIALIZED `var_spec` (a bare `var x int` writes nothing). */
private declareVarDeclaration(declNode: SyntaxNode, scope: Scope): void {
const specs = this.varSpecs(declNode);
for (const spec of specs) {
const hasValue = spec.childForFieldName('value') !== null;
if (!hasValue) continue; // bare `var x int` — not a runtime write
for (let i = 0; i < spec.namedChildCount; i++) {
const c = spec.namedChild(i);
if (c?.type === 'identifier') this.declare(c, 'var', scope);
}
}
}
/** The `var_spec` nodes of a `var_declaration` (single or `var ( … )` block). */
private varSpecs(declNode: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < declNode.namedChildCount; i++) {
const c = declNode.namedChild(i);
if (!c) continue;
if (c.type === 'var_spec') out.push(c);
else if (c.type === 'var_spec_list') {
for (let j = 0; j < c.namedChildCount; j++) {
const s = c.namedChild(j);
if (s?.type === 'var_spec') out.push(s);
}
}
}
return out;
}
/** A `range_clause` is the `:=` short form iff it has no `=` operator token. */
private rangeIsShort(node: SyntaxNode): boolean {
return this.hasAnonChild(node, ':=');
}
/** A `receive_statement` (`case v := <-ch`) is the `:=` short form. */
private receiveIsShort(node: SyntaxNode): boolean {
return this.hasAnonChild(node, ':=');
}
private hasAnonChild(node: SyntaxNode, text: string): boolean {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c && !c.isNamed && c.text === text) return true;
}
return false;
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Facts for a `for … range right` head: the `:=` loop vars are defs (the `=`
* reassign form's vars are also written), and `right` is used.
*/
rangeHeadFacts(rangeClause: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(rangeClause.startPosition.row + 1);
const left = rangeClause.childForFieldName('left');
const right = rangeClause.childForFieldName('right');
if (left) {
for (let i = 0; i < left.namedChildCount; i++) {
const c = left.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
else if (c) this.walkValue(c, acc);
}
}
if (right) this.walkValue(right, acc);
return acc.finish();
}
/**
* Facts for a `switch t := i.(type)` head: `t` binds (a def) and the inspected
* value is used.
*/
typeSwitchHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const alias = stmt.childForFieldName('alias');
const value = stmt.childForFieldName('value');
if (alias) {
for (let i = 0; i < alias.namedChildCount; i++) {
const c = alias.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
if (value) this.walkValue(value, acc);
return acc.finish();
}
/** ENTRY-block facts for the receiver + parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
const recv = this.fnNode.childForFieldName('receiver');
if (recv) {
for (let i = 0; i < recv.namedChildCount; i++) {
const p = recv.namedChild(i);
if (p?.type !== 'parameter_declaration') continue;
const name = p.childForFieldName('name');
if (name) this.def(name, acc);
}
}
const params = this.fnNode.childForFieldName('parameters');
if (params) {
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter_declaration' && p?.type !== 'variadic_parameter_declaration') {
continue;
}
for (let j = 0; j < p.namedChildCount; j++) {
const c = p.namedChild(j);
if (c?.type === 'identifier') this.def(c, acc);
}
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Go override: the blank identifier (`_`) defines nothing. */
protected override def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return; // blank identifier defines nothing
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
/** Go override: the blank identifier (`_`) is read of nothing. */
protected override use(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
acc.addUse(this.resolve(nameNode));
}
/** Strip parenthesized wrappers around an lvalue (`(x) = 1`). */
private unwrapLvalue(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 8;
while (n.type === 'parenthesized_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/** Def each identifier of an LHS `expression_list`; route non-identifiers to uses. */
private defLeftList(list: SyntaxNode, acc: FactAccumulator, alsoUse: boolean): void {
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (!c) continue;
const lv = this.unwrapLvalue(c);
if (lv.type === 'identifier') {
this.def(lv, acc);
if (alsoUse) this.use(lv, acc); // compound assign (`+=`) reads too
} else {
// selector / index / pointer-deref target — uses only (root identifier).
this.walkValue(lv, acc);
}
}
}
/** Value-position walk: collect uses; route def positions to the lvalue handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// Opaque nested function literal — captured reads/writes are invisible.
return;
}
switch (t) {
case 'identifier':
this.use(node, acc);
return;
case 'short_var_declaration': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
// Register result-defs BEFORE walking the value so the nested call site
// (reached during the value walk) carries them — single-target only
// (`x := f(a)`; a multi-target `a, b := f()` attaches nothing).
if (left && right) this.registerListResultDefs(left, right);
if (right) this.walkValue(right, acc);
if (left) {
for (let i = 0; i < left.namedChildCount; i++) {
const c = left.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
return;
}
case 'var_declaration': {
for (const spec of this.varSpecs(node)) {
const value = spec.childForFieldName('value');
if (value && this.singleSpecName(spec)) this.registerResultDefs(value, [spec]);
if (value) this.walkValue(value, acc);
if (value) {
for (let i = 0; i < spec.namedChildCount; i++) {
const c = spec.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
}
return;
}
case 'assignment_statement': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '=';
// Plain `x = f(a)` attaches `resultDefs: [x]`; a compound `x += f(a)`
// does not (the prior value flows in too).
if (op === '=' && left && right) this.registerListResultDefs(left, right);
if (right) this.walkValue(right, acc);
if (left) this.defLeftList(left, acc, op !== '=');
return;
}
case 'receive_statement': {
// `select { case v := <-ch: }` / `case v = <-ch:` — the left
// identifier(s) are DEFS of the channel-received value; `<-ch` (right)
// is a use of the channel. A bare `case <-ch:` has no left (uses only).
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const isShort = node.children.some((c) => !c.isNamed && c.text === ':=');
if (isShort && left && right) this.registerListResultDefs(left, right);
if (right) this.walkValue(right, acc);
if (left) this.defLeftList(left, acc, false);
return;
}
case 'inc_statement':
case 'dec_statement': {
// `x++` / `x--` — def AND use the lvalue when it's a plain identifier.
const operand = node.namedChild(0);
const lv = operand ? this.unwrapLvalue(operand) : null;
if (lv?.type === 'identifier') {
this.def(lv, acc);
this.use(lv, acc);
} else if (operand) {
this.walkValue(operand, acc);
}
return;
}
case 'binary_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||') this.conditional(() => this.walkValue(right, acc));
else this.walkValue(right, acc);
}
return;
}
case 'call_expression':
// #2195 U6: explicit case (previously default-descended) — same uses,
// plus a taint-site record. Go has no `new` (constructor calls are plain
// `call_expression`s). Defs/uses stay byte-identical.
this.visitCall(node, acc);
return;
case 'selector_expression': {
// `a.b` — value read of the operand root only; the field name is not a
// scalar binding. Mirrors the TS member-read use semantics, plus a
// member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
/** The single `var_spec` name when the spec declares exactly one name, else undefined. */
private singleSpecName(spec: SyntaxNode): SyntaxNode | undefined {
const names: SyntaxNode[] = [];
for (let i = 0; i < spec.namedChildCount; i++) {
const c = spec.namedChild(i);
if (c?.type === 'identifier') names.push(c);
}
return names.length === 1 ? names[0] : undefined;
}
/**
* Register result-defs for a single-target LHS `expression_list` → RHS
* `expression_list` whose sole element is a call. `a, b := f()` (multi-target)
* and `x, y := f(), g()` attach nothing — the per-target mapping is ambiguous,
* matching the TS harvester's per-declarator restriction.
*/
private registerListResultDefs(left: SyntaxNode, right: SyntaxNode): void {
const leftNames = this.listIdentifiers(left);
const rightVals = this.listElements(right);
if (leftNames.length !== 1 || rightVals.length !== 1) return;
this.registerResultDefs(rightVals[0], [leftNames[0]]);
}
/** Identifier elements of an `expression_list` (`a, b` ⇒ [a, b]). */
private listIdentifiers(list: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (c?.type === 'identifier') out.push(c);
}
return out;
}
/** Named elements of an `expression_list` (or the node itself if not a list). */
private listElements(list: SyntaxNode): SyntaxNode[] {
if (list.type !== 'expression_list') return [list];
const out: SyntaxNode[] = [];
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (c) out.push(c);
}
return out;
}
/**
* When `value`'s root (after stripping parens) is a call, remember its site
* should carry `resultDefs` — the binding indices of `targets` (def-position
* identifiers, resolved against the completed scope tree). Consumed by
* {@link visitCall} once the value walk reaches the node.
*/
private registerResultDefs(value: SyntaxNode, targets: readonly SyntaxNode[]): void {
const root = this.unwrapLvalue(value);
if (root.type !== 'call_expression') return;
const defs: number[] = [];
for (const target of targets) {
// A `var_spec` carries its name(s) as children; an identifier resolves
// directly. Skip the blank identifier (`_`), which binds nothing.
const names = target.type === 'identifier' ? [target] : this.listIdentifiers(target);
for (const n of names) {
if (n.text === '_') continue;
defs.push(this.resolve(n));
}
}
if (defs.length > 0) this.resultDefTargets.set(root.id, defs);
}
/**
* Explicit `call_expression` handler. Records a call site (callee path,
* receiver, per-arg occurrence entries, result defs) while reproducing EXACTLY
* the uses the old default descent recorded (callee chain root + arguments).
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const calleeNode = node.childForFieldName('function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call');
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
const callee = this.unwrapLvalue(calleeNode);
if (callee.type === 'identifier') {
if (callee.text !== '_') acc.addUseWithoutOccurrence(this.resolve(callee));
calleePath = callee.text;
} else if (callee.type === 'selector_expression') {
// skipFinalRead: the final `.field` IS the callee, carried by the path.
const chain = this.walkChain(callee, acc, true);
calleePath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
// Call-rooted chains, conversions (`T(x)`), parenthesized funcs — the
// walk still records uses and nested sites.
this.walkValue(callee, acc);
}
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
// `f(xs...)` — a variadic spread. Mark the first spread position so the
// matcher degrades soundly; the inner value still walks for occurrences.
if (arg.type === 'variadic_argument') {
acc.setFrameArg(pos);
acc.setSiteSpread(siteIdx, pos);
const inner = arg.namedChild(0);
if (inner) this.walkValue(inner, acc);
} else {
acc.setFrameArg(pos);
this.walkValue(arg, acc);
}
pos++;
}
}
acc.popFrame();
}
/**
* `selector_expression` chain walk shared by value position and callee
* position. Records the chain-root identifier as a use (identical to the old
* default descent) plus at most ONE member-read site — the INNERMOST access —
* when the root is an identifier; `skipFinalRead` suppresses it when that
* access is the callee (carried by the dotted path instead).
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapLvalue(node);
for (;;) {
if (cur.type === 'selector_expression') {
const field = cur.childForFieldName('field');
accesses.unshift(field?.text ?? '');
const operand = cur.childForFieldName('operand');
if (!operand) break;
cur = this.unwrapLvalue(operand);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier' && cur.text !== '_') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
this.walkValue(cur, acc);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}
}
/**
* Ordered, deduplicating def/use + call-site collector for one statement record.
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
* old local class had, plus the taint-site harvest (#2195 U6).
*/
const FactAccumulator = CallSiteFactAccumulator;

View file

@ -0,0 +1,888 @@
/**
* Go CfgVisitor (#2195 U5, plan KTD2) — the highest-divergence C-family target.
*
* Walks a Go function / method / closure's tree-sitter AST and drives the
* language-agnostic {@link CfgBuilder} to produce a serializable
* {@link FunctionCfg}, plus a def/use harvest ({@link GoHarvester}) for the
* reaching-defs / CDG solvers. Structured like the Java / C# visitors — a
* `visit_<node_type>` dispatch over the statement taxonomy, driving a
* per-function {@link ControlFlowContext} for labeled break/continue and the
* `defer` completion chain (Go's analogue of finally route-through).
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-go via the introspection probe before use (mandatory pre-step,
* KTD5). Go shapes pre-empted (verified by a real parse):
* - functions: `function_declaration`, `method_declaration` (field `receiver`),
* `func_literal` — all carry `parameters` + a `body` `block`.
* - `if_statement` fields `initializer`? / `condition` / `consequence` /
* `alternative`?; `else if` ⇒ `alternative` is a nested `if_statement`, plain
* `else` ⇒ `alternative` is a `block` (NO `else_clause` wrapper).
* - `for_statement` — Go's SINGLE loop keyword. `body` is a `block`; the first
* child is a `for_clause` (C-style, fields `initializer`?/`condition`?/`update`?)
* OR a `range_clause` (for-range, fields `left`?/`right`) OR a bare condition
* expression (while-style) OR ABSENT (`for {}` infinite). All four handled.
* - `expression_switch_statement` (fields `initializer`?/`value`?; children
* `expression_case` [field `value`=`expression_list`] / `default_case`) and
* `type_switch_statement` (fields `alias`?/`value`; children `type_case`
* [field `type`] / `default_case`) — cases do NOT fall through by default.
* - `fallthrough_statement` — EXPLICIT fallthrough to the next case (the
* opposite of C; modeled with a `fallthrough` edge).
* - `select_statement` (children `communication_case` [field `communication`=
* `receive_statement`/`send_statement`] / `default_case`).
* - `return_statement` (multiple-return via an `expression_list`),
* `break_statement` / `continue_statement` (BOTH may carry a `label_name`),
* `goto_statement` (`label_name` child), `labeled_statement` (field `label`=
* `label_name`).
* - `defer_statement` / `go_statement` — each wraps a `call_expression`.
*
* Edge-kind contract (matches the TS / Java / C# visitors — RD/CDG consume these):
* - if/else → `cond-true` / `cond-false`
* - for-loops (all four shapes) → `cond-true` / `loop-back` / `cond-false`
* - switch / select dispatch → `switch-case`; an explicit `fallthrough` → a
* `fallthrough` edge to the next case (Go cases otherwise do NOT fall through)
* - a `return` / normal completion threads through the active `defer` chain as
* `return` (first leg) + `finally-return` (each defer's completion leg)
* - return / break / continue → the matching terminator kind; a labeled
* `break outer;` / `continue outer;` targets the labeled frame, not the
* nearest one
* - straight-line → `seq`
*
* Go-specific modeling decisions (documented approximations — see the plan U5):
* - `defer f()`: deferred calls run at FUNCTION RETURN in LIFO order. Modeled as
* stacked completion legs (the {@link ControlFlowContext} finalizer machinery,
* Go's analogue of a `finally` route-through): each `defer` pushes a finalizer
* frame that stays active for the rest of the function, so every `return` AND
* the normal fall-off thread through ALL active defers innermost-first (LIFO).
* APPROXIMATION: a `defer` is registered at the point it executes, so a defer
* inside a not-yet-run branch is conservatively treated as active for the
* whole remaining function tail (Go would only run it if that branch ran). The
* panic/recover path is not modeled (documented gap).
* - `go f()`: spawns a goroutine — a SEPARATE flow. Decision (the simpler correct
* option): the `go` call is modeled as a normal straight-line statement in the
* CURRENT CFG and the spawned body is NOT followed inline. When the argument is
* a `go func(){…}()` closure, that `func_literal` is still collected as its OWN
* function by `isFunction` (the worker enumerates every function node), so its
* body gets a standalone CFG — nothing is dropped. A bare `go namedFn()` call's
* callee body lives in its own function CFG already. No edge is dropped, so no
* warning is logged for the common shapes.
* - `select {}` with no `default` BLOCKS forever; `for {}` (and `for cond {}`,
* and a `for {…}` with no `break`) may never terminate. Exactly as the sibling
* visitors emit a structural `header → loopExit` `cond-false` edge for
* `while(true)`, this visitor emits a structural exit-escape edge for EVERY
* for-loop shape AND for a `select` with no default, so EXIT stays
* reverse-reachable from every block — the post-dominator / CDG pass silently
* emits ZERO control-dependence for the function otherwise (CFG / REACHING_DEF
* survive; CDG goes to zero). This is the single highest-risk correctness
* property of the visitor.
*
* Classic hazards, handled explicitly (mirrors the Java / C# visitors):
* - loops allocate a dedicated loop-exit block so `break` has a target before
* the loop's successor is known; `continue` targets the header / update.
* - labeled `break outer;` / `continue outer;`: the label resolves against the
* frame of the construct it names (a labeled loop / switch / select), NOT the
* nearest enclosing frame. An UNLABELED break never targets a labeled-block
* frame (control-flow-context.ts enforces this).
* - `goto label;`: labels resolve within the function (forward AND backward);
* an unresolved `goto` (label in a sibling scope Go would reject, or malformed)
* routes to EXIT and logs, preserving single-exit.
*
* Known limitations:
* - `go`/goroutine inter-flow scheduling and channel happens-before are not
* modeled (each goroutine body is an independent CFG).
* - panic / recover: a `panic()` is a normal call here (no abnormal edge), and
* `recover()` inside a deferred closure is opaque; the panic-unwind path
* through defers is not modeled — documented gap, not faked.
* - Def/use harvest scope: see `go-harvest.ts` — selector / index / pointer
* writes are not scalar defs; `func_literal` bodies are opaque in both
* directions.
*
* Returns `undefined` (never throws) for an AST shape it cannot model, so a
* malformed function never drops the whole file's CFG group (R4).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import {
ControlFlowContext,
drainFinalizerPending,
wireJumpThroughFinalizers,
} from '../control-flow-context.js';
import type { FinalizerFrame } from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { GoHarvester } from './go-harvest.js';
/** Go node types that own a CFG-bearing function body. */
const GO_FUNCTION_TYPES = new Set(['function_declaration', 'method_declaration', 'func_literal']);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_statement',
'for_statement',
'expression_switch_statement',
'type_switch_statement',
'select_statement',
'return_statement',
'break_statement',
'continue_statement',
'goto_statement',
'labeled_statement',
'fallthrough_statement',
'defer_statement',
'block',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function Go walk state. One instance per function so the
* {@link ControlFlowContext}, the `defer` finalizer chain, and the label tables
* are scoped to that function and never leak across functions.
*/
class GoCfgWalk {
private readonly cfc = new ControlFlowContext();
/** label name → its `labeled_statement` body's entry block (resolved on demand). */
private readonly labelBlocks = new Map<string, number>();
/** Pending gotos to a label not yet seen: label → list of source blocks. */
private readonly pendingGotos = new Map<string, number[]>();
/** Label(s) pending attachment to the NEXT pushed loop/switch/select frame. */
private pendingLabels: string[] = [];
/**
* Active `defer` finalizer frames in source (push) order. Innermost-LIFO is the
* REVERSE of this list — `finalizersForReturn()` already yields innermost-first,
* matching Go's LIFO defer execution. Frames stay active for the whole function
* tail and are drained once at the top-level walk's end.
*/
private readonly deferFrames: FinalizerFrame[] = [];
constructor(
private readonly builder: CfgBuilder,
private readonly harvest: GoHarvester,
) {}
/** Statements of a block node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => c.type !== 'comment');
}
/** The `body` block of a node. */
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'block');
}
/** Visit a body that may be a `block` or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
return this.builder.withNesting(() => {
if (!node) return null;
if (node.type === 'block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
});
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
return this.builder.withNesting(() => {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
if (openSimple === undefined) {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
} else {
this.builder.extendBlock(
openSimple,
endLineOf(stmt),
stmt.text,
this.harvest.facts(stmt),
);
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
});
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'expression_switch_statement':
return this.visitExprSwitch(stmt);
case 'type_switch_statement':
return this.visitTypeSwitch(stmt);
case 'select_statement':
return this.visitSelect(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'break_statement':
return this.visitBreak(stmt);
case 'continue_statement':
return this.visitContinue(stmt);
case 'goto_statement':
return this.visitGoto(stmt);
case 'labeled_statement':
return this.visitLabeled(stmt);
case 'fallthrough_statement':
return this.visitFallthrough(stmt);
case 'defer_statement':
return this.visitDefer(stmt);
case 'block':
return this.visitSeq(this.statementsOf(stmt));
default:
return this.visitSimple(stmt);
}
}
private visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
return { entry: idx, exits: [idx] };
}
/**
* `return [expr…]` — threads through EVERY active `defer` (innermost-first =
* LIFO) before EXIT. `finalizersForReturn()` yields the active finalizer frames
* innermost-first, which is exactly Go's defer execution order.
*/
private visitReturn(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
wireJumpThroughFinalizers(
this.builder,
idx,
this.cfc.finalizersForReturn(),
this.builder.exitIndex,
'return',
);
return { entry: idx, exits: [] };
}
private visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = this.jumpLabel(stmt);
const res = this.cfc.resolveBreak(label);
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break');
return { entry: idx, exits: [] };
}
private visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = this.jumpLabel(stmt);
const res = this.cfc.resolveContinue(label);
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue');
return { entry: idx, exits: [] };
}
/** The trailing `label_name` of a `break`/`continue`, if any. */
private jumpLabel(stmt: SyntaxNode): string | undefined {
const id = stmt.namedChildren.find((c) => c.type === 'label_name');
return id?.text;
}
/** `goto label;` — route to the label block if known, else defer / EXIT. */
private visitGoto(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = stmt.namedChildren.find((c) => c.type === 'label_name')?.text;
if (label === undefined) {
this.builder.edge(idx, this.builder.exitIndex, 'seq'); // malformed — single-exit
return { entry: idx, exits: [] };
}
const target = this.labelBlocks.get(label);
if (target !== undefined) {
this.builder.edge(idx, target, 'seq'); // backward goto: label already built
} else {
const list = this.pendingGotos.get(label);
if (list) list.push(idx);
else this.pendingGotos.set(label, [idx]);
}
return { entry: idx, exits: [] };
}
/**
* `label: <statement>` — the label names the construct it directly wraps. For a
* loop / switch / select we forward the label so its pushed frame carries it
* (`break outer;` then resolves to it); for any other labeled statement we
* register the label block so a `goto label` reaches it.
*/
private visitLabeled(stmt: SyntaxNode): SeqResult {
const labelNode = stmt.childForFieldName('label');
const label = labelNode?.text;
const body =
stmt.namedChildren.find((c) => c.id !== labelNode?.id && c.type !== 'comment') ?? null;
if (label !== undefined && body && this.isBreakableStatement(body)) {
// Forward the label to the loop/switch/select frame this statement pushes.
this.pendingLabels = [...this.pendingLabels, label];
const res = this.visitStmt(body);
this.registerLabel(label, res?.entry ?? this.synthLabelBlock(stmt));
return res ?? { entry: this.labelBlocks.get(label)!, exits: [] };
}
const res = this.visitBody(body);
if (label !== undefined) {
const entry = res?.entry ?? this.synthLabelBlock(stmt);
this.registerLabel(label, entry);
if (!res) return { entry, exits: [entry] };
}
return res;
}
private synthLabelBlock(stmt: SyntaxNode): number {
return this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
}
/** Register a resolved label block and wire any forward gotos that waited on it. */
private registerLabel(label: string, entry: number): void {
this.labelBlocks.set(label, entry);
const pending = this.pendingGotos.get(label);
if (pending) {
for (const from of pending) this.builder.edge(from, entry, 'seq');
this.pendingGotos.delete(label);
}
}
private isBreakableStatement(node: SyntaxNode): boolean {
return (
node.type === 'for_statement' ||
node.type === 'expression_switch_statement' ||
node.type === 'type_switch_statement' ||
node.type === 'select_statement'
);
}
/** Take and clear the labels queued by an enclosing `labeled_statement`. */
private takeLabels(): string[] {
const labels = this.pendingLabels;
this.pendingLabels = [];
return labels;
}
/**
* `fallthrough` — EXPLICIT transfer to the next case body (Go cases do not fall
* through implicitly). Recorded as a marker block; the enclosing switch wires
* the `fallthrough` edge from a case body whose dangling exit is this block.
* It carries no normal exit (control leaves to the next case).
*/
private visitFallthrough(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
this.fallthroughBlocks.add(idx);
return { entry: idx, exits: [idx] };
}
/** Blocks that are an explicit `fallthrough` terminator (per-function). */
private readonly fallthroughBlocks = new Set<number>();
/**
* `defer f()` — register the deferred call as a finalizer frame that stays
* active for the rest of the function tail. Every later `return` (and the
* normal fall-off) threads through it; LIFO across multiple defers falls out of
* `finalizersForReturn()` yielding innermost-first. The defer's call expression
* carries its def/use facts. The frame is NOT popped here — the top-level walk
* drains all defer frames once at function end.
*/
private visitDefer(stmt: SyntaxNode): TraversalResult {
// A single facts-only block is created for the deferred call body; it is the
// finalizer entry that completion legs route through.
const call = stmt.namedChildren.find((c) => c.type !== 'comment') ?? stmt;
const deferBlock = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(call),
);
const frame = this.cfc.pushFinalizer(deferBlock);
this.deferFrames.push(frame);
// The `defer` statement itself is a no-op at its source position — control
// falls straight through to the next statement (the deferred body only runs
// at function exit, modeled by the completion-leg threading). We therefore
// return a SEPARATE marker block as the in-line position so the deferred
// block stays out of the straight-line flow.
const marker = this.builder.newBlock(startLineOf(stmt), startLineOf(stmt), '');
return { entry: marker, exits: [marker] };
}
/**
* Drain the active `defer` chain at function end. Each deferred block is a
* single block, so its "exit" is itself; the chain runs innermost-first (LIFO,
* matching Go). Returns the block set control reaches AFTER the whole defer
* chain runs (to be wired to EXIT by the caller), or `normalExits` unchanged
* when there are no defers.
*
* Two completion sources converge here:
* - `return` statements that crossed these frames registered pending legs via
* `wireJumpThroughFinalizers`; {@link drainFinalizerPending} wires them
* (return → defer[0]; defer[i] → defer[i+1]; defer[last] → EXIT).
* - the function's NORMAL fall-off is threaded explicitly here through the
* same chain (`return` first leg, `finally-return` inter-defer legs); the
* builder de-dups, so legs shared with the return paths collapse.
*/
finishDefers(normalExits: readonly number[]): readonly number[] {
if (this.deferFrames.length === 0) return normalExits;
// Innermost-first (LIFO): the LAST-registered defer runs first.
const lifo = [...this.deferFrames].reverse();
// Pop the frames off the active stack and drain any pending completion legs
// the return/break handlers registered while these frames were active.
for (let i = 0; i < lifo.length; i++) this.cfc.pop();
for (const frame of lifo) drainFinalizerPending(this.builder, frame, [frame.entry]);
// Thread the normal fall-off through the chain innermost-first: the first leg
// keeps the bare `return` kind (the "kind ⟹ source terminator" invariant), the
// inter-defer legs are `finally-return` completion edges.
if (normalExits.length > 0) {
this.builder.connect(normalExits, lifo[0].entry, 'return');
for (let i = 0; i + 1 < lifo.length; i++) {
this.builder.edge(lifo[i].entry, lifo[i + 1].entry, 'finally-return');
}
}
// After the outermost defer runs, control reaches EXIT.
return [lifo[lifo.length - 1].entry];
}
/**
* Route any forward `goto`s whose label never appeared in the function to EXIT
* (single-exit preserved) and log them so a dropped jump is never silent (R4).
* Called once after the body walk.
*/
flushGotos(builder: CfgBuilder): void {
for (const [label, froms] of this.pendingGotos) {
// eslint-disable-next-line no-console
console.warn(
`[cfg] Go: unresolved goto label "${label}" routed to EXIT (${froms.length} site(s))`,
);
for (const from of froms) builder.edge(from, builder.exitIndex, 'seq');
}
this.pendingGotos.clear();
}
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const init = stmt.childForFieldName('initializer');
// The header block carries the (optional) initializer's facts AND the
// condition's facts — both evaluate before the branch.
const header = this.builder.newBlock(
init ? startLineOf(init) : startLineOf(stmt),
endLineOf(cond),
init ? `${init.text}; ${cond.text}` : cond.text,
'normal',
this.harvest.facts(cond),
);
if (init) this.builder.attachFacts(header, this.harvest.facts(init));
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
if (thenRes) {
this.builder.edge(header, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(header); // empty then — true path falls through
}
// No `else_clause` wrapper in Go: `alternative` is the else `block` or the
// nested `if_statement` of an `else if` chain directly.
const elseNode = stmt.childForFieldName('alternative');
if (elseNode) {
const elseRes = this.visitBody(elseNode);
if (elseRes) {
this.builder.edge(header, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(header);
}
} else {
exits.push(header); // no else — false path falls through to the join
}
return { entry: header, exits: [...new Set(exits)] };
}
/**
* `for_statement` — Go's single loop keyword covers all four shapes:
* 1. `for clause { }` (`for_clause`: init?/cond?/update? — C-style)
* 2. `for range x { }` (`range_clause`)
* 3. `for cond { }` (a bare condition expression — while-style)
* 4. `for { }` (no header child — infinite)
*/
private visitFor(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const head = this.forHeadChild(stmt);
if (head?.type === 'for_clause') return this.visitForClause(stmt, head, labels);
if (head?.type === 'range_clause') return this.visitForRange(stmt, head, labels);
// While-style (bare condition) or infinite (`for {}`): `head` is the
// condition expression (or undefined).
return this.visitForCond(stmt, head ?? undefined, labels);
}
/** The header child of a `for_statement` (for_clause / range_clause / cond), or undefined. */
private forHeadChild(stmt: SyntaxNode): SyntaxNode | undefined {
const body = stmt.childForFieldName('body');
return stmt.namedChildren.find((c) => c.id !== body?.id && c.type !== 'comment');
}
private visitForClause(stmt: SyntaxNode, clause: SyntaxNode, labels: string[]): TraversalResult {
const init = clause.childForFieldName('initializer');
const cond = clause.childForFieldName('condition');
const incr = clause.childForFieldName('update');
const header = this.builder.newBlock(
cond ? startLineOf(cond) : startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for{}',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
let incrBlock = header;
if (incr) {
incrBlock = this.builder.newBlock(
startLineOf(incr),
endLineOf(incr),
incr.text,
'normal',
this.harvest.facts(incr),
);
this.builder.edge(incrBlock, header, 'loop-back');
}
this.cfc.pushLoop(incrBlock, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
} else {
this.builder.edge(header, incrBlock, 'cond-true');
if (!incr) this.builder.edge(header, header, 'loop-back');
}
// Structural exit edge — `for ;; {}` (no condition) still keeps EXIT
// reverse-reachable so CDG is not silently skipped for the function.
this.builder.edge(header, loopExit, 'cond-false');
let entry = header;
if (init) {
const initBlock = this.builder.newBlock(
startLineOf(init),
endLineOf(init),
init.text,
'normal',
this.harvest.facts(init),
);
this.builder.edge(initBlock, header, 'seq');
entry = initBlock;
}
return { entry, exits: [loopExit] };
}
private visitForRange(stmt: SyntaxNode, clause: SyntaxNode, labels: string[]): TraversalResult {
// Header carries the range head facts (loop vars are defs, the iterated
// expression is a use).
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(clause),
clause.text,
'normal',
this.harvest.rangeHeadFacts(clause),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private visitForCond(
stmt: SyntaxNode,
cond: SyntaxNode | undefined,
labels: string[],
): TraversalResult {
const header = this.builder.newBlock(
cond ? startLineOf(cond) : startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for{}',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty `for {}` body re-tests
}
// Always emit the structural exit edge — even `for {}` (infinite, no
// condition) and `for cond {}` keep EXIT reverse-reachable for the CDG pass.
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private visitExprSwitch(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const value = stmt.childForFieldName('value');
const init = stmt.childForFieldName('initializer');
const dispatch = this.builder.newBlock(
startLineOf(stmt),
value ? endLineOf(value) : startLineOf(stmt),
value ? value.text : 'switch{}',
'normal',
value ? this.harvest.facts(value) : undefined,
);
if (init) this.builder.attachFacts(dispatch, this.harvest.facts(init));
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(switchExit, labels);
const cases = stmt.namedChildren.filter(
(c) => c.type === 'expression_case' || c.type === 'default_case',
);
// Each `expression_case`'s test value(s) evaluate before the body — harvest
// their uses CONDITIONALLY onto the dispatch block (a later case only tests
// when earlier cases didn't match).
for (const c of cases) {
if (c.type !== 'expression_case') continue;
const test = c.childForFieldName('value');
if (test) this.builder.attachFacts(dispatch, this.harvest.factsConditional(test));
}
const result = this.buildCases(dispatch, switchExit, cases, (c) => this.exprCaseBody(c));
this.cfc.pop();
return result;
}
private visitTypeSwitch(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const dispatch = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.typeSwitchHeaderText(stmt),
'normal',
this.harvest.typeSwitchHeadFacts(stmt),
);
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(switchExit, labels);
const cases = stmt.namedChildren.filter(
(c) => c.type === 'type_case' || c.type === 'default_case',
);
const result = this.buildCases(dispatch, switchExit, cases, (c) => this.typeCaseBody(c));
this.cfc.pop();
return result;
}
private visitSelect(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const dispatch = this.builder.newBlock(startLineOf(stmt), startLineOf(stmt), 'select');
const selectExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(selectExit, labels);
const cases = stmt.namedChildren.filter(
(c) => c.type === 'communication_case' || c.type === 'default_case',
);
// A comm case's communication clause (`v := <-ch` / `ch <- x`) evaluates as
// part of dispatch — harvest its facts onto the dispatch block.
for (const c of cases) {
if (c.type !== 'communication_case') continue;
const comm = c.childForFieldName('communication');
if (comm) this.builder.attachFacts(dispatch, this.harvest.facts(comm));
}
const hasDefault = cases.some((c) => c.type === 'default_case');
const result = this.buildCases(dispatch, selectExit, cases, (c) => this.commCaseBody(c));
// A `select` with NO default BLOCKS until a case is ready — and a `select {}`
// with no cases at all blocks forever. Either way EXIT must stay
// reverse-reachable: emit a structural escape edge dispatch → selectExit so
// the CDG pass is not silently skipped for the function.
if (!hasDefault) this.builder.edge(dispatch, selectExit, 'switch-case');
this.cfc.pop();
return result;
}
/**
* Shared dispatch builder for expression-switch / type-switch / select. Cases
* do NOT fall through by default (the opposite of C); an EXPLICIT
* `fallthrough` block in a case body spills into the NEXT case instead of the
* switch exit. `default_case` is the no-match target; without one, the dispatch
* also reaches the switch exit directly (no-match path).
*/
private buildCases(
dispatch: number,
switchExit: number,
cases: SyntaxNode[],
bodyOf: (c: SyntaxNode) => SyntaxNode[],
): TraversalResult {
const caseResults = cases.map((c) => this.visitSeq(bodyOf(c)));
const hasDefault = cases.some((c) => c.type === 'default_case');
const entryOf: number[] = new Array(cases.length);
let after = switchExit;
for (let i = cases.length - 1; i >= 0; i--) {
entryOf[i] = caseResults[i]?.entry ?? after;
after = entryOf[i];
}
for (let i = 0; i < cases.length; i++) {
this.builder.edge(dispatch, entryOf[i], 'switch-case');
}
if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
// Case bodies rejoin AFTER the switch (no implicit fallthrough), UNLESS the
// body ends in an explicit `fallthrough`, which spills into the next case.
for (let i = 0; i < cases.length; i++) {
const res = caseResults[i];
if (!res) continue;
const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit;
for (const ex of res.exits) {
if (this.fallthroughBlocks.has(ex)) {
this.builder.edge(ex, fallTarget, 'fallthrough');
} else {
this.builder.edge(ex, switchExit, 'seq');
}
}
}
return { entry: dispatch, exits: [switchExit] };
}
/** `expression_case` body statements (everything but the `value` test). */
private exprCaseBody(caseNode: SyntaxNode): SyntaxNode[] {
const value = caseNode.childForFieldName('value');
return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment');
}
/** `type_case` body statements (everything but the `type` field children). */
private typeCaseBody(caseNode: SyntaxNode): SyntaxNode[] {
const typeIds = new Set<number>();
// All `type` field children (a type_case may list several types).
for (let i = 0; i < caseNode.childCount; i++) {
if (caseNode.fieldNameForChild(i) === 'type') {
const c = caseNode.child(i);
if (c) typeIds.add(c.id);
}
}
return caseNode.namedChildren.filter((c) => !typeIds.has(c.id) && c.type !== 'comment');
}
/** `communication_case` body statements (everything but the `communication` clause). */
private commCaseBody(caseNode: SyntaxNode): SyntaxNode[] {
const comm = caseNode.childForFieldName('communication');
return caseNode.namedChildren.filter((c) => c.id !== comm?.id && c.type !== 'comment');
}
private typeSwitchHeaderText(stmt: SyntaxNode): string {
const alias = stmt.childForFieldName('alias')?.text;
const value = stmt.childForFieldName('value')?.text ?? '';
return alias ? `switch ${alias} := ${value}.(type)` : `switch ${value}.(type)`;
}
}
/** Build the CFG for one Go function node, or `undefined` if not modelable. */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
try {
if (!GO_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
const body = fnNode.childForFieldName('body');
if (!body || body.type !== 'block') return undefined; // forward decl / interface method
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const harvest = new GoHarvester(fnNode);
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
const walk = new GoCfgWalk(builder, harvest);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
builder.edge(builder.entryIndex, res ? res.entry : builder.exitIndex, 'seq');
// Normal fall-off threads through the active `defer` chain (LIFO) → EXIT.
const normalExits = res ? res.exits : [builder.entryIndex];
const afterDefers = walk.finishDefers(normalExits);
builder.connect(afterDefers, builder.exitIndex, 'seq');
walk.flushGotos(builder);
return builder.finish(harvest.bindingTable());
} catch (err) {
// Never throw out of buildFunctionCfg — a malformed AST shape must skip only
// this one function's CFG, never drop the whole file's language group (R4).
// eslint-disable-next-line no-console
console.warn(`[cfg] Go buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`);
return undefined;
}
}
/** Whether a node is a Go function this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return GO_FUNCTION_TYPES.has(node.type);
}
/** The Go CFG visitor. */
export function createGoCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { GO_FUNCTION_TYPES };

View file

@ -0,0 +1,535 @@
/**
* Java def/use harvester (#2195 U4, plan KTD2) — the Java analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the closely-related
* {@link import('./csharp-harvest.js').CsharpHarvester}.
*
* Runs in the parse worker next to the Java CFG visitor, extracting per-statement
* variable definition/use facts that ride the side channel for the reaching-defs
* / CDG solvers. Output is the per-function binding table ({@link BindingEntry}[])
* plus {@link StatementFacts} the visitor attaches to blocks as it walks.
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / C# harvesters):
* the CFG walk is NOT source-order (`visitFor` builds the init block after the
* body, `visitDoWhile` the condition before the body), so resolving names against
* a scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans
* the whole function subtree once into a completed lexical scope tree; phase 2
* resolves defs/uses against that finished tree from any walk order.
*
* v1 def-semantics scope:
* - `local_variable_declaration` → `variable_declarator` (an INITIALIZED local
* is a def; a bare `int x;` with no initializer writes nothing at runtime —
* not a def, like the TS bare-`var` rule).
* - `assignment_expression` (plain + compound `+=` etc.) and `update_expression`
* (`x++` / `--x`) — define and (for compound / update) also use the lvalue.
* - parameters (`formal_parameter` / `spread_parameter` → `name`), the
* enhanced-for loop variable (`enhanced_for_statement` field `name`), and
* catch parameters (`catch_formal_parameter` → `name`), incl. multi-catch.
* EXCLUDED, deliberately (TypeScript-CFA precedent): field / array writes
* (`obj.f = …`, `a[i] = …`) are NOT scalar defs — their identifiers are uses
* only. Nested-function (lambda) bodies are opaque in BOTH directions (writes to
* and reads of captured outer variables are invisible).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` (`a && (x = f())`), a ternary arm, or a switch case test
* — is a may-def (gen without kill), so the not-taken path's prior def is not
* falsely killed. (Java has no `??`.)
*
* Identifiers with no in-function declaration (fields, statics, imported names)
* resolve to a SYNTHETIC module-level binding (`name@module`), applied
* identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { CallSiteFactAccumulator } from './call-site-harvest.js';
import { ScopeTreeHarvester, type Scope, type FactAccumulator } from './scope-tree-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'lambda_expression',
'method_declaration',
'constructor_declaration',
'compact_constructor_declaration',
]);
/**
* Nodes that open a lexical scope for block-local declarations. A `block` is one
* scope; the loop constructs open a scope for their loop variable; a
* `catch_clause` scopes its exception name; a `try_with_resources_statement`
* scopes its resource declarations; a switch group/rule scopes its statements.
*/
const SCOPE_TYPES = new Set([
'block',
'for_statement',
'enhanced_for_statement',
'while_statement',
'do_statement',
'catch_clause',
'try_with_resources_statement',
'switch_block_statement_group',
'switch_rule',
]);
/** Comment node types tree-sitter-java surfaces (NOT `comment`). */
const COMMENT_TYPES = new Set(['line_comment', 'block_comment']);
export class JavaHarvester extends ScopeTreeHarvester {
constructor(fnNode: SyntaxNode) {
super(fnNode);
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body && body.type === 'block') this.prescan(body, this.openScope(body));
}
/** The function/lambda body node (a `block`, or an expression for `x -> expr`). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
const body = fnNode.childForFieldName('body');
if (body) return body;
return fnNode.namedChildren.find((c) => c.type === 'block');
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declareParams(fnNode: SyntaxNode): void {
const params = fnNode.childForFieldName('parameters');
if (!params) {
// Lambda single un-parenthesized parameter: `x -> …` (a bare identifier).
const lambdaParam = fnNode.namedChildren.find((c) => c.type === 'identifier');
if (fnNode.type === 'lambda_expression' && lambdaParam) {
this.declare(lambdaParam, 'param', this.root);
}
// Lambda inferred parameters: `(x, y) -> …`.
const inferred = fnNode.namedChildren.find((c) => c.type === 'inferred_parameters');
if (inferred) {
for (let i = 0; i < inferred.namedChildCount; i++) {
const p = inferred.namedChild(i);
if (p?.type === 'identifier') this.declare(p, 'param', this.root);
}
}
return;
}
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'formal_parameter' && p?.type !== 'spread_parameter') continue;
const name = this.paramName(p);
if (name) this.declare(name, 'param', this.root);
}
}
/** The `name` identifier of a `formal_parameter` / `spread_parameter`. */
private paramName(param: SyntaxNode): SyntaxNode | undefined {
const named = param.childForFieldName('name');
if (named) return named;
// `spread_parameter` (`int... xs`) exposes its name through a nested
// variable_declarator rather than a `name` field.
const declarator = param.namedChildren.find((c) => c.type === 'variable_declarator');
return declarator?.childForFieldName('name');
}
protected prescan(node: SyntaxNode, scope: Scope): void {
this.nearestScopeCache.set(node.id, scope);
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// A nested function / lambda body is opaque — do not descend.
return;
}
let childScope = scope;
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
switch (t) {
case 'local_variable_declaration':
this.declareVariableDeclaration(node, childScope);
break;
case 'enhanced_for_statement': {
const name = node.childForFieldName('name');
if (name) this.declare(name, 'var', childScope);
break;
}
case 'resource': {
// `try (var f = open())` — the resource binds in the try scope.
const name = node.childForFieldName('name');
if (name) this.declare(name, 'var', childScope);
break;
}
case 'catch_clause': {
const param = node.namedChildren.find((c) => c.type === 'catch_formal_parameter');
const name = param?.childForFieldName('name');
if (name) this.declare(name, 'catch', childScope);
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c, childScope);
}
}
/** Declare every `variable_declarator` name in a `local_variable_declaration`. */
private declareVariableDeclaration(declNode: SyntaxNode, scope: Scope): void {
for (let i = 0; i < declNode.namedChildCount; i++) {
const d = declNode.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
if (name) this.declare(name, 'var', scope);
}
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Def-ONLY facts for a value-position binding carrier (`var x = switch (…) {…}`,
* #2207): just the declared name(s)' def, attached to the continuation block the
* switch arms rejoin. The switch subject + arm-value USES are already harvested
* onto the branch's own blocks ({@link facts} on each arm), so this must NOT
* re-walk the value — only each `variable_declarator`'s `name` is a def here.
*/
bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
for (let i = 0; i < stmt.namedChildCount; i++) {
const d = stmt.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
if (name) this.def(name, acc);
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Facts for a `for (T name : value)` head: name binds, value is used. */
forEachHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const name = stmt.childForFieldName('name');
const value = stmt.childForFieldName('value');
if (name) this.def(name, acc);
if (value) this.walkValue(value, acc);
return acc.finish();
}
/** Facts for the resource-close finalizer: each resource is USED on close. */
resourceCloseFacts(resources: readonly SyntaxNode[]): StatementFacts | undefined {
const first = resources[0];
if (!first) return undefined;
const acc = new FactAccumulator(first.startPosition.row + 1);
for (const r of resources) {
const name = r.childForFieldName('name');
if (name) this.use(name, acc);
}
return acc.useCount() ? acc.finish() : undefined;
}
/** ENTRY-block facts for the function's parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
const params = this.fnNode.childForFieldName('parameters');
if (params) {
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'formal_parameter' && p?.type !== 'spread_parameter') continue;
const name = this.paramName(p);
if (name) this.def(name, acc);
}
} else if (this.fnNode.type === 'lambda_expression') {
const lambdaParam = this.fnNode.namedChildren.find((c) => c.type === 'identifier');
if (lambdaParam) this.def(lambdaParam, acc);
const inferred = this.fnNode.namedChildren.find((c) => c.type === 'inferred_parameters');
if (inferred) {
for (let i = 0; i < inferred.namedChildCount; i++) {
const p = inferred.namedChild(i);
if (p?.type === 'identifier') this.def(p, acc);
}
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch (T e)` parameter — prepend to the handler entry block. */
catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined {
const param = catchClause.namedChildren.find((c) => c.type === 'catch_formal_parameter');
const name = param?.childForFieldName('name');
if (!name) return undefined;
const acc = new FactAccumulator(catchClause.startPosition.row + 1);
this.def(name, acc);
return acc.defCount() ? acc.finish() : undefined;
}
/** Strip parenthesized wrappers around an lvalue (`(x) = 1`). */
private unwrapLvalue(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 8;
while (n.type === 'parenthesized_expression' && hops-- > 0) {
const inner = n.namedChildren.find((c) => !COMMENT_TYPES.has(c.type));
if (!inner) break;
n = inner;
}
return n;
}
/** Value-position walk: collect uses; route def positions to the lvalue handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// Opaque nested function / lambda — captured reads/writes are invisible.
return;
}
switch (t) {
case 'identifier':
this.use(node, acc);
return;
case 'local_variable_declaration': {
for (let i = 0; i < node.namedChildCount; i++) {
const d = node.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
const value = d.childForFieldName('value');
// Only an INITIALIZED declarator writes (`int x = e;`). A bare
// `int x;` is not a def (it writes nothing at runtime), matching the
// TS bare-`var` rule.
if (name && value) {
const snap = acc.defSnapshot();
this.def(name, acc);
this.registerResultDefs(value, acc.defsSince(snap));
}
if (value) this.walkValue(value, acc);
}
return;
}
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '=';
if (left) {
const lv = this.unwrapLvalue(left);
if (lv.type === 'identifier') {
const snap = acc.defSnapshot();
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
} else {
this.walkValue(lv, acc); // field/array target — uses only
}
}
if (right) this.walkValue(right, acc);
return;
}
case 'update_expression': {
// `x++` / `++x` / `x--` / `--x` — the only writing unary ops. The operand
// is an anonymous (non-field) child; treat as def+use when it's an
// identifier.
const operand = this.updateOperand(node);
const lv = operand ? this.unwrapLvalue(operand) : null;
if (lv?.type === 'identifier') {
this.def(lv, acc);
this.use(lv, acc);
} else if (operand) {
this.walkValue(operand, acc);
}
return;
}
case 'binary_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||') this.conditional(() => this.walkValue(right, acc));
else this.walkValue(right, acc);
}
return;
}
case 'ternary_expression': {
const cond = node.childForFieldName('condition');
const cons = node.childForFieldName('consequence');
const alt = node.childForFieldName('alternative');
if (cond) this.walkValue(cond, acc);
if (cons) this.conditional(() => this.walkValue(cons, acc));
if (alt) this.conditional(() => this.walkValue(alt, acc));
return;
}
case 'method_invocation':
// #2195 U6: explicit case (previously default-descended) — same uses,
// plus a taint-site record. Defs/uses stay byte-identical.
this.visitCall(node, acc);
return;
case 'object_creation_expression':
// `new Foo(x)` — constructor call site (`type` field is the callee).
this.visitNew(node, acc);
return;
case 'field_access': {
// `a.b` — value read of the object root only; the field name is not a
// scalar binding. Mirrors the TS member-read use semantics, plus a
// member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
/**
* When `value`'s root (after stripping parens) is a method-invocation /
* object-creation node, remember its site should carry `resultDefs: defs`.
*/
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
if (defs.length === 0) return;
const root = this.unwrapLvalue(value);
if (root.type === 'method_invocation' || root.type === 'object_creation_expression') {
this.resultDefTargets.set(root.id, [...defs]);
}
}
/**
* Explicit `method_invocation` handler. Unlike a member-chain callee, Java
* carries the method NAME on the `name` field and the receiver on a sibling
* `object` field (`db.query(x)` ⇒ object `db`, name `query`); a bare call
* (`exec(x)`) has no `object`. Reproduces EXACTLY the uses the old default
* descent recorded (object root + arguments) and adds the call site.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
const objectNode = node.childForFieldName('object');
const nameNode = node.childForFieldName('name');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call');
acc.pushFrame(siteIdx);
let receiverPath: string | undefined;
if (objectNode) {
// The receiver is a value read (object chain root) — record its uses and
// the member-read sites, and capture its dotted path + binding root.
const chain = this.walkChain(objectNode, acc, false);
receiverPath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
}
if (nameNode) {
// The method NAME was a (synthetic) statement-level use under the old
// default descent — preserve it byte-identically, but never as a value
// occurrence in an enclosing argument (`exec(escape(x))` must not put the
// `escape` name into exec's arg 0).
acc.addUseWithoutOccurrence(this.resolve(nameNode));
const callee =
receiverPath !== undefined ? `${receiverPath}.${nameNode.text}` : nameNode.text;
acc.setSiteCallee(siteIdx, callee);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
this.walkArgs(argsNode, acc);
acc.popFrame();
}
/** Explicit `object_creation_expression` (`new Foo(x)`) handler. */
private visitNew(node: SyntaxNode, acc: FactAccumulator): void {
const typeNode = node.childForFieldName('type');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('new');
acc.pushFrame(siteIdx);
if (typeNode) {
// The type name is not a scalar binding — record it only as the callee
// path, never a use/occurrence (matches the type-position semantics).
acc.setSiteCallee(siteIdx, typeNode.text.replace(/\s+/g, ''));
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
this.walkArgs(argsNode, acc);
acc.popFrame();
}
/** Walk an `argument_list`, tagging each positional argument for occurrences. */
private walkArgs(argsNode: SyntaxNode | null, acc: FactAccumulator): void {
if (!argsNode) return;
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || COMMENT_TYPES.has(arg.type)) continue;
acc.setFrameArg(pos);
this.walkValue(arg, acc);
pos++;
}
}
/**
* `field_access` chain walk shared by value position and the method-invocation
* receiver. Records the chain-root identifier as a use (identical to the old
* default descent) plus at most ONE member-read site — the INNERMOST access —
* when the root is an identifier; `skipFinalRead` suppresses it when that
* access is the callee (never the case for `field_access`, which is value-only).
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapLvalue(node);
for (;;) {
if (cur.type === 'field_access') {
const field = cur.childForFieldName('field');
accesses.unshift(field?.text ?? '');
const obj = cur.childForFieldName('object');
if (!obj) break;
cur = this.unwrapLvalue(obj);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else if (cur.type === 'this' || cur.type === 'super') {
rootSegment = cur.text; // `this`/`super` are path segments, never bind
} else {
this.walkValue(cur, acc);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
acc.addMemberRead(rootIdx, innermost);
}
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}
/** The operand identifier of an `update_expression` (`x++` / `--x`). */
private updateOperand(node: SyntaxNode): SyntaxNode | undefined {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
}
/**
* Ordered, deduplicating def/use + call-site collector for one statement record.
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
* old local class had, plus the taint-site harvest (#2195 U6).
*/
const FactAccumulator = CallSiteFactAccumulator;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,575 @@
/**
* Kotlin def/use harvester (#2195) — the Kotlin analogue of
* {@link import('./swift-harvest.js').SwiftHarvester} and the C-family / Go /
* Rust / Python harvesters. Like the Swift / Python / Rust harvesters it harvests
* NO call-site `sites[]` (the call-site taint substrate is a later step): it emits
* only the per-function binding table ({@link BindingEntry}[]) plus
* {@link StatementFacts} (defs / uses / mayDefs) via a local
* {@link FactAccumulator} with no site machinery, so the produced facts never
* carry a `sites` key.
*
* Runs in the parse worker next to the Kotlin CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
* plus the per-block def/use facts the reaching-defs / CDG solvers consume.
*
* Every node-type literal below was grammar-validated against the VENDORED
* tree-sitter-kotlin via the introspection probe before use (mandatory pre-step).
* The grammar is FIELD-LESS for the constructs harvested here (no
* `childForFieldName` fields on `parameter` / `property_declaration` /
* `for_statement` / etc.), so this harvester navigates by child TYPE and position.
* Kotlin shapes pre-empted (verified by a real parse):
* - functions: `function_declaration` (`fun` `simple_identifier`
* `function_value_parameters` `function_body`), `anonymous_function`
* (`fun function_value_parameters function_body`), `lambda_literal`
* (`{ lambda_parameters? -> statements }`).
* - parameters: `function_value_parameters` → `parameter`
* (`simple_identifier : user_type`). A lambda's params live in
* `lambda_parameters` → `variable_declaration` (each a `simple_identifier`,
* optionally `: user_type`).
* - `property_declaration` — `binding_pattern_kind` (`val`/`var`), then a
* `variable_declaration` (`simple_identifier`) OR a `multi_variable_declaration`
* (`( variable_declaration, … )` for `val (a, b) = p`), then `= value`.
* - `for_statement` — pattern is a `variable_declaration` / `multi_variable_declaration`
* after `(`; the iterated collection is the expression after `in`.
* - `catch_block` — `catch ( simple_identifier : user_type ) { statements }`; the
* bound error is the `simple_identifier`.
* - `when_subject` — `( expr )` or `( val variable_declaration = expr )`.
* - reads: `simple_identifier`, `navigation_expression` (`a.b` / `a?.b`),
* `call_expression`, `assignment` (`directly_assignable_expression` lvalue +
* operator + value), `elvis_expression` (`a ?: b`).
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Swift / Rust / Go
* harvesters): the CFG walk is NOT source-order (`do … while` builds the condition
* after the body), so resolving names against a scope stack populated *during* the
* walk would mis-resolve. Phase 1 pre-scans the whole function subtree once,
* declaring every bound name into ONE function table; phase 2 resolves defs/uses
* against that finished table from any walk order. Kotlin DOES have block scope +
* shadowing, but a single function table is the documented v1 simplification used
* by the Swift / Python / Rust harvesters — distinct shadowing redeclarations of
* the same name collapse onto one binding (an over-approximation that can falsely
* kill across a shadow, the sound direction for taint).
*
* v1 def-semantics scope:
* - `property_declaration` (`val`/`var PAT = …`) — each `simple_identifier` leaf
* of the `variable_declaration` / `multi_variable_declaration` is a def; the
* value is walked for uses.
* - `assignment` plain `=` — a plain-identifier lvalue is a def; a
* `navigation_expression` / subscript target (`this.x = …`, `a[i] = …`) is NOT
* a scalar def (its root is a use). A compound `+=`/`-=`/… target def-AND-uses
* the lvalue.
* - `for (x in xs)` — the loop pattern's leaves are defs, the collection a use.
* - a `when (val r = e)` subject binds `r`.
* - `catch_block`'s error identifier binds.
* - parameters (incl. lambda params) are `param`-kind defs.
* EXCLUDED, deliberately (TypeScript-CFA precedent): member / subscript writes
* (`obj.f = …`, `a[i] = …`) are NOT scalar defs — their root identifiers are uses
* only. Nested-function bodies (`lambda_literal`, nested `anonymous_function` /
* `function_declaration`) are opaque in BOTH directions.
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` short-circuit, the elvis (`?:`) right operand and a
* safe-call (`?.`) chain, and a `when`-entry case test — is a may-def (gen WITHOUT
* kill), so the not-taken path's prior def is not falsely killed.
*
* Identifiers with no in-function declaration (top-level functions, types,
* properties) resolve to a SYNTHETIC module-level binding (`name@module`), applied
* identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'function_declaration',
'anonymous_function',
'lambda_literal',
]);
const COMMENT_TYPES = new Set(['line_comment', 'multiline_comment', 'shebang_line']);
export class KotlinHarvester {
private readonly bindings: BindingEntry[] = [];
/** Single function-scope name → binding index (v1: no block scope). */
private readonly table = new Map<string, number>();
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
/** The function/lambda body subtree to pre-scan (`statements` or `function_body`). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
if (fnNode.type === 'lambda_literal') {
return fnNode.namedChildren.find((c) => c.type === 'statements');
}
return fnNode.namedChildren.find((c) => c.type === 'function_body');
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void {
const name = nameNode.text;
if (!name || name === '_' || this.table.has(name)) return;
this.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/** Declare every parameter binder of a fn / anonymous fn / lambda. */
private declareParams(fnNode: SyntaxNode): void {
const params = fnNode.namedChildren.find((c) => c.type === 'function_value_parameters');
if (params) {
for (const p of params.namedChildren) {
if (p.type !== 'parameter') continue;
const name = p.namedChildren.find((c) => c.type === 'simple_identifier');
if (name) this.declare(name, 'param');
}
}
// Lambda params: `lambda_parameters` → `variable_declaration`(s).
const lambdaParams = fnNode.namedChildren.find((c) => c.type === 'lambda_parameters');
if (lambdaParams) {
for (const vd of lambdaParams.namedChildren) {
if (vd.type === 'variable_declaration') this.declareVariableDeclaration(vd, 'param');
else if (vd.type === 'multi_variable_declaration') {
for (const inner of vd.namedChildren) {
if (inner.type === 'variable_declaration')
this.declareVariableDeclaration(inner, 'param');
}
}
}
}
}
/** Declare the `simple_identifier` of a `variable_declaration`. */
private declareVariableDeclaration(vd: SyntaxNode, kind: BindingEntry['kind']): void {
const id = vd.namedChildren.find((c) => c.type === 'simple_identifier') ?? vd;
if (id.type === 'simple_identifier') this.declare(id, kind);
}
/**
* Pre-scan the function body once, declaring every bound name. Recurses into
* compound expressions but NOT into nested function/lambda bodies (opaque).
*/
private prescan(node: SyntaxNode): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return;
switch (t) {
case 'property_declaration':
this.declarePropertyPattern(node, 'let');
break;
case 'for_statement':
this.declareForPattern(node);
break;
case 'catch_block':
this.declareCatchParam(node);
break;
case 'when_subject':
this.declareWhenSubject(node);
break;
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c);
}
}
/** Declare every binder of a `property_declaration`'s pattern (single or multi). */
private declarePropertyPattern(node: SyntaxNode, kind: BindingEntry['kind']): void {
const single = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (single) {
this.declareVariableDeclaration(single, kind);
return;
}
const multi = node.namedChildren.find((c) => c.type === 'multi_variable_declaration');
if (multi) {
for (const vd of multi.namedChildren) {
if (vd.type === 'variable_declaration') this.declareVariableDeclaration(vd, kind);
}
}
}
/** Declare a `for` loop variable (`variable_declaration` / `multi_variable_declaration`). */
private declareForPattern(node: SyntaxNode): void {
const single = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (single) {
this.declareVariableDeclaration(single, 'let');
return;
}
const multi = node.namedChildren.find((c) => c.type === 'multi_variable_declaration');
if (multi) {
for (const vd of multi.namedChildren) {
if (vd.type === 'variable_declaration') this.declareVariableDeclaration(vd, 'let');
}
}
}
/** Declare a `catch (e: T)` error name. */
private declareCatchParam(node: SyntaxNode): void {
const id = node.namedChildren.find((c) => c.type === 'simple_identifier');
if (id) this.declare(id, 'catch');
}
/** Declare a `when (val r = e)` subject binding. */
private declareWhenSubject(node: SyntaxNode): void {
const vd = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (vd) this.declareVariableDeclaration(vd, 'let');
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Facts for a `for ( PAT in COLLECTION )` head: the loop pattern's leaves are
* defs, the iterated collection a use.
*/
forHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const collection = this.forCollection(stmt);
if (collection) this.walkValue(collection, acc);
this.defForPattern(stmt, acc);
return acc.finish();
}
/** Facts for a `when` subject: a `val r = e` binds `r` (def); the expr's uses. */
whenSubjectFacts(subject: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(subject.startPosition.row + 1);
const vd = subject.namedChildren.find((c) => c.type === 'variable_declaration');
// Walk the subject's value expression(s) for uses; bind the `val` name.
for (const c of subject.namedChildren) {
if (c.type === 'variable_declaration') continue;
this.walkValue(c, acc);
}
if (vd) this.defVariableDeclaration(vd, acc);
return acc.finish();
}
/**
* Def-ONLY facts for a value-position binding carrier (`val x = <branch>`,
* #2205): just the bound name's def, attached to the continuation block the
* branch arms rejoin. The branch subject + arm-value USES are already harvested
* onto the branch's own blocks (visitWhen / visitIf), so this must not re-walk
* them — only the `variable_declaration` leaves are defs here.
*/
bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
this.defForPattern(stmt, acc);
return acc.defCount() ? acc.finish() : undefined;
}
/**
* Def-ONLY facts for a value-position assignment carrier (`x = when (k) {…}`,
* #2205): just the LHS target, attached to the continuation block the branch
* arms rejoin. The branch subject + arm-value USES are already harvested onto
* the branch's own blocks, so this must NOT re-walk the RHS — only a plain `=`
* to a simple-identifier lvalue defines (a member / index target is not a
* scalar def; a compound `+=` is not a value-branch carrier).
*/
assignmentDefFacts(node: SyntaxNode): StatementFacts | undefined {
if (this.assignmentOperator(node) !== '=') return undefined;
const acc = new FactAccumulator(node.startPosition.row + 1);
const lvalue = node.namedChildren.find((c) => c.type === 'directly_assignable_expression');
if (lvalue) {
const lv = this.unwrapAssignable(lvalue);
if (lv.type === 'simple_identifier') this.def(lv, acc);
}
return acc.defCount() ? acc.finish() : undefined;
}
/** ENTRY-block facts for the parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
const params = this.fnNode.namedChildren.find((c) => c.type === 'function_value_parameters');
if (params) {
for (const p of params.namedChildren) {
if (p.type !== 'parameter') continue;
const name = p.namedChildren.find((c) => c.type === 'simple_identifier');
if (name) this.def(name, acc);
}
}
const lambdaParams = this.fnNode.namedChildren.find((c) => c.type === 'lambda_parameters');
if (lambdaParams) {
for (const vd of lambdaParams.namedChildren) {
if (vd.type === 'variable_declaration') this.defVariableDeclaration(vd, acc);
else if (vd.type === 'multi_variable_declaration') {
for (const inner of vd.namedChildren) {
if (inner.type === 'variable_declaration') this.defVariableDeclaration(inner, acc);
}
}
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch (e: T)` error name — prepend to the handler entry block. */
catchParamFacts(catchBlock: SyntaxNode): StatementFacts | undefined {
const id = catchBlock.namedChildren.find((c) => c.type === 'simple_identifier');
if (!id) return undefined;
const acc = new FactAccumulator(catchBlock.startPosition.row + 1);
this.def(id, acc);
return acc.defCount() ? acc.finish() : undefined;
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const idx = this.table.get(name);
if (idx !== undefined) return idx;
let syn = this.synthetic.get(name);
if (syn === undefined) {
syn = this.bindings.length;
this.synthetic.set(name, syn);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return syn;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return; // blank target defines nothing
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
acc.addUse(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
private conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
/** Def the `simple_identifier` of a `variable_declaration`. */
private defVariableDeclaration(vd: SyntaxNode, acc: FactAccumulator): void {
const id = vd.namedChildren.find((c) => c.type === 'simple_identifier');
if (id) this.def(id, acc);
}
/** Def every binder of a `for` loop pattern. */
private defForPattern(stmt: SyntaxNode, acc: FactAccumulator): void {
const single = stmt.namedChildren.find((c) => c.type === 'variable_declaration');
if (single) {
this.defVariableDeclaration(single, acc);
return;
}
const multi = stmt.namedChildren.find((c) => c.type === 'multi_variable_declaration');
if (multi) {
for (const vd of multi.namedChildren) {
if (vd.type === 'variable_declaration') this.defVariableDeclaration(vd, acc);
}
}
}
/** The iterated collection of a `for_statement` — the named child after `in`. */
private forCollection(stmt: SyntaxNode): SyntaxNode | undefined {
let sawIn = false;
for (let i = 0; i < stmt.childCount; i++) {
const c = stmt.child(i);
if (!c) continue;
if (c.type === 'in') {
sawIn = true;
continue;
}
if (c.type === ')') return undefined;
if (sawIn && c.isNamed && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
/** Value-position walk: collect uses; route def positions to the pattern handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; // opaque
switch (t) {
case 'simple_identifier':
this.use(node, acc);
return;
case 'property_declaration': {
// Walk the value for uses, then def each pattern binder.
const binder = node.namedChildren.find(
(c) => c.type === 'variable_declaration' || c.type === 'multi_variable_declaration',
);
const value = this.propertyValue(node);
if (value) this.walkValue(value, acc);
if (binder?.type === 'variable_declaration') this.defVariableDeclaration(binder, acc);
else if (binder?.type === 'multi_variable_declaration') {
for (const vd of binder.namedChildren) {
if (vd.type === 'variable_declaration') this.defVariableDeclaration(vd, acc);
}
}
return;
}
case 'assignment': {
const lvalue = node.namedChildren.find((c) => c.type === 'directly_assignable_expression');
const op = this.assignmentOperator(node);
const value = this.assignmentValue(node);
if (value) this.walkValue(value, acc);
if (lvalue) {
const lv = this.unwrapAssignable(lvalue);
if (lv.type === 'simple_identifier') {
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
} else {
// `this.x = …`, `a[i] = …` — root is a use only (not a scalar def).
this.walkValue(lv, acc);
}
}
return;
}
case 'postfix_expression':
case 'prefix_expression': {
// `x++` / `--x` — def AND use the operand when it is a plain identifier
// and the operator is an increment/decrement. Other pre/postfix forms
// (`-x`, `!x`, `x!!`, `x?`) are pure reads → walk the operand as a use.
const operand = node.namedChild(0);
if (operand?.type === 'simple_identifier' && this.isIncDec(node)) {
this.def(operand, acc);
this.use(operand, acc);
} else if (operand) {
this.walkValue(operand, acc);
}
return;
}
case 'navigation_expression': {
// `a.b` / `a?.b` — value read of the chain root only; the suffix name is
// not a scalar binding.
const target = node.namedChild(0);
if (target) this.walkValue(target, acc);
return;
}
case 'conjunction_expression':
case 'disjunction_expression': {
// `a && b` / `a || b` — the right operand is conditionally evaluated.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const rhs = operands[i];
this.conditional(() => this.walkValue(rhs, acc));
}
return;
}
case 'elvis_expression': {
// `a ?: b` — the right operand only evaluates when the left is null.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const rhs = operands[i];
this.conditional(() => this.walkValue(rhs, acc));
}
return;
}
case 'when_subject':
case 'user_type':
case 'type_identifier':
case 'binding_pattern_kind':
// Binding keyword / type position — no scalar value uses.
return;
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
/** True iff `node` carries a `++` / `--` operator token (`x++` / `--x`). */
private isIncDec(node: SyntaxNode): boolean {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c && !c.isNamed && (c.text === '++' || c.text === '--')) return true;
}
return false;
}
/** The `= value` expression of a `property_declaration` (the child after `=`). */
private propertyValue(node: SyntaxNode): SyntaxNode | undefined {
let sawEq = false;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c) continue;
if (c.type === '=') {
sawEq = true;
continue;
}
if (sawEq && c.isNamed && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
/** The assignment operator text (`=` / `+=` / …) of an `assignment`. */
private assignmentOperator(node: SyntaxNode): string {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c && !c.isNamed && /^[+\-*/%]?=$/.test(c.type)) return c.type;
}
return '=';
}
/** The right-hand value of an `assignment` (the named child after the operator). */
private assignmentValue(node: SyntaxNode): SyntaxNode | undefined {
let sawOp = false;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c) continue;
if (!c.isNamed && /^[+\-*/%]?=$/.test(c.type)) {
sawOp = true;
continue;
}
if (sawOp && c.isNamed && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
/** Strip a `directly_assignable_expression` wrapper around an lvalue. */
private unwrapAssignable(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 4;
while (n.type === 'directly_assignable_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,683 @@
/**
* PHP def/use harvester (PDG layer — brace-family CFG, closest to Java/C#).
*
* Runs in the parse worker next to the PHP CFG visitor, extracting per-statement
* variable definition/use facts that ride the side channel for the reaching-defs
* / CDG solvers. Output is the per-function binding table ({@link BindingEntry}[])
* plus {@link StatementFacts} the visitor attaches to blocks as it walks. The
* call-site substrate ({@link CallSiteFactAccumulator}) is harvested too (it is
* INERT until a PHP source/sink model is registered).
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Java / C# harvesters):
* the CFG walk is NOT source-order (`visitFor` builds the init block after the
* body, `visitDoWhile` the condition before the body), so resolving names against
* a scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans
* the whole function subtree once into a completed lexical scope tree; phase 2
* resolves defs/uses against that finished tree from any walk order.
*
* PHP-SPECIFIC NOTE — PHP variables are FUNCTION-SCOPED (no block scope): a `$x`
* written inside an `if` body is the SAME variable as one written at the top
* level (unlike Java/C# block scoping). So the harvester declares EVERY assigned/
* parameter/foreach/catch variable into the single function-root scope; there is
* no per-block shadowing. The grammar carries the leading `$` on `variable_name`
* text (`$x`), which we keep as the binding name (consistent and unambiguous).
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-php (`php_only` export) via the introspection probe before use
* (mandatory pre-step). PHP shapes pre-empted (verified by a real parse):
* - functions: `function_definition`/`method_declaration` (fields
* `name`/`parameters`/`body`), `anonymous_function` (`parameters`/`body` plus
* an `anonymous_function_use_clause` capturing outer vars), `arrow_function`
* (`parameters`/`body`; body is an EXPRESSION).
* - parameters: `simple_parameter` / `variadic_parameter` /
* `property_promotion_parameter`, each with a `name` field (`variable_name` or
* a `by_ref` wrapping one); `simple_parameter` may carry `default_value`.
* - assignment: `assignment_expression` (`left`/`right`),
* `augmented_assignment_expression` (`left`/`operator`/`right`, def+use),
* `update_expression` (`argument`/`operator`, def+use). An lvalue may be a
* `variable_name`, a `list_literal` (`[$a,$b]` / `list($a,$b)` destructure),
* a `member_access_expression` (`$o->p` — a USE of the object, not a scalar
* def), or a `subscript_expression` (`$a[$i]` — same).
* - `foreach_statement`: the iterable + a value `variable_name`, OR a
* `pair` (`$k => $v`) binding both — NO field names (positional children).
* - `catch_clause` (`type`/`name`/`body`): `name` is the exception
* `variable_name`.
* - conditional contexts: `binary_expression` operator `&&`/`||`/`??`,
* `conditional_expression` (`condition`/`body`/`alternative`; short `?:` omits
* `body`), and switch/match case tests.
*
* v1 def-semantics scope:
* - assignment / augmented-assignment / update to a `variable_name` (or to a
* `list_literal` destructure target) — define (and, for augmented/update, use)
* the variable.
* - parameters, the `foreach` value/key variable, catch parameters, and
* `anonymous_function` `use (...)` captures (by-value AND by-ref).
* EXCLUDED, deliberately (TypeScript-CFA precedent, mirrored by Java): property /
* array-element writes (`$o->p = …`, `$a[$i] = …`) are NOT scalar defs — their
* variables are uses only. Nested-function (closure / arrow) bodies are opaque in
* BOTH directions.
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` / `??` (`$a && ($x = f())`, `$c ?? ($c = load())`), a
* ternary arm, or a switch/match case test — is a may-def (gen without kill), so
* the not-taken path's prior def is not falsely killed.
*
* Identifiers with no in-function declaration (globals, statics, imported names)
* resolve to a SYNTHETIC module-level binding (`name@module`), applied
* identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { CallSiteFactAccumulator } from './call-site-harvest.js';
/**
* The per-statement def/use + call-site collector, aliased to the shared
* {@link CallSiteFactAccumulator} (one name for the value and the type).
*/
type FactAccumulator = CallSiteFactAccumulator;
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'function_definition',
'method_declaration',
'anonymous_function',
'arrow_function',
]);
export class PhpHarvester {
private readonly bindings: BindingEntry[] = [];
/** PHP is function-scoped: one flat table, name → binding index. */
private readonly table = new Map<string, number>();
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
/**
* Call/new node id → bindings whose declarator/assignment VALUE is exactly
* that call. Registered before the value walk, consumed by {@link visitCall} /
* {@link visitNew} (mirrors the Java harvester's `resultDefTargets`).
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.declareParams(fnNode);
this.declareUseClause(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
/** The function/closure body node (a `compound_statement`, or an expression). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
return fnNode.childForFieldName('body') ?? undefined;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declare(name: string, declNode: SyntaxNode, kind: BindingEntry['kind']): void {
if (!name || this.table.has(name)) return;
this.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: declNode.startPosition.row + 1,
declColumn: declNode.startPosition.column,
kind,
});
}
/** The `$name` text of a parameter's `name` field (a `variable_name` or `by_ref`). */
private paramVarName(param: SyntaxNode): SyntaxNode | undefined {
const name = param.childForFieldName('name');
if (!name) return undefined;
if (name.type === 'by_ref') {
return name.namedChildren.find((c) => c.type === 'variable_name');
}
return name.type === 'variable_name' ? name : undefined;
}
private declareParams(fnNode: SyntaxNode): void {
const params = fnNode.childForFieldName('parameters');
if (!params) return;
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (!p) continue;
if (
p.type !== 'simple_parameter' &&
p.type !== 'variadic_parameter' &&
p.type !== 'property_promotion_parameter'
) {
continue;
}
const varName = this.paramVarName(p);
if (varName) this.declare(varName.text, varName, 'param');
}
}
/** `anonymous_function ... use ($a, &$b)` — each captured var binds in the closure. */
private declareUseClause(fnNode: SyntaxNode): void {
if (fnNode.type !== 'anonymous_function') return;
const clause = fnNode.namedChildren.find((c) => c.type === 'anonymous_function_use_clause');
if (!clause) return;
for (const v of this.useClauseVars(clause)) this.declare(v.text, v, 'param');
}
/** The captured `variable_name`s of a `use (...)` clause (unwrapping `by_ref`). */
private useClauseVars(clause: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < clause.namedChildCount; i++) {
const c = clause.namedChild(i);
if (!c) continue;
if (c.type === 'variable_name') out.push(c);
else if (c.type === 'by_ref') {
const inner = c.namedChildren.find((x) => x.type === 'variable_name');
if (inner) out.push(inner);
}
}
return out;
}
/**
* Walk the function body once, declaring every assigned / foreach / catch
* variable into the FLAT function scope (PHP has no block scoping). Nested
* function/closure bodies are NOT descended (opaque).
*/
private prescan(node: SyntaxNode): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return;
switch (t) {
case 'assignment_expression': {
const left = node.childForFieldName('left');
if (left) this.declareLvalue(left);
break;
}
case 'augmented_assignment_expression': {
const left = node.childForFieldName('left');
if (left && left.type === 'variable_name') this.declare(left.text, left, 'var');
break;
}
case 'update_expression': {
const arg = node.childForFieldName('argument');
if (arg && arg.type === 'variable_name') this.declare(arg.text, arg, 'var');
break;
}
case 'foreach_statement': {
for (const v of this.foreachTargets(node)) this.declare(v.text, v, 'var');
break;
}
case 'catch_clause': {
const name = node.childForFieldName('name');
if (name && name.type === 'variable_name') this.declare(name.text, name, 'catch');
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c);
}
}
/**
* Declare the variable(s) named by an assignment lvalue: a plain
* `variable_name`, or a `list_literal` destructure (`[$a,$b]` / `list($a,$b)`,
* possibly keyed `["x" => $e]`). Member / subscript targets bind nothing.
*/
private declareLvalue(left: SyntaxNode): void {
if (left.type === 'variable_name') {
this.declare(left.text, left, 'var');
} else if (left.type === 'list_literal') {
for (const v of this.listTargets(left)) this.declare(v.text, v, 'var');
}
}
/** Every `variable_name` bound by a `list_literal` (including keyed entries). */
private listTargets(list: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
const walk = (n: SyntaxNode): void => {
if (n.type === 'variable_name') {
out.push(n);
return;
}
// Keyed (`"x" => $e`) entries and nested lists descend; non-variable keys
// (the string/int key) are not lvalues and carry no `variable_name`.
for (let i = 0; i < n.namedChildCount; i++) {
const c = n.namedChild(i);
if (c) walk(c);
}
};
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (c) walk(c);
}
return out;
}
/**
* The bound variable(s) of a `foreach ($it as [$k =>] $v)`: the value (and key)
* `variable_name`s. The structure is positional — the FIRST named child is the
* iterable, then either a bare `variable_name` (value) or a `pair` ($k => $v).
*/
private foreachTargets(stmt: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
// Skip the iterable (first named child); collect value / pair targets after.
for (let i = 1; i < stmt.namedChildCount; i++) {
const c = stmt.namedChild(i);
if (!c) continue;
if (c.type === 'variable_name') out.push(c);
else if (c.type === 'pair') {
for (let j = 0; j < c.namedChildCount; j++) {
const v = c.namedChild(j);
if (v?.type === 'variable_name') out.push(v);
}
}
// `body` (compound_statement / colon_block) is not a target — it has its
// own non-variable_name/non-pair type, so it is skipped here.
}
return out;
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Def-ONLY facts for a value-position assignment carrier (`$x = match($v) {…}`,
* #2207): just the LHS target(s), attached to the continuation block the match
* arms rejoin. The match condition + arm-value USES are already harvested onto
* the branch's own blocks (visitMatch), so this must NOT re-walk the RHS. A
* member/subscript target (`$this->x = match …`) has no scalar def → undefined.
*/
assignmentDefFacts(assignExpr: SyntaxNode): StatementFacts | undefined {
const acc = new FactAccumulator(assignExpr.startPosition.row + 1);
const left = assignExpr.childForFieldName('left');
if (left) {
const lv = this.unwrapParen(left);
if (lv.type === 'variable_name') this.def(lv, acc);
else if (lv.type === 'list_literal') for (const v of this.listTargets(lv)) this.def(v, acc);
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Facts for a `foreach ($it as [$k =>] $v)` head: targets bind, iterable used. */
foreachHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const iterable = stmt.namedChild(0);
if (iterable) this.walkValue(iterable, acc);
for (const v of this.foreachTargets(stmt)) this.def(v, acc);
return acc.finish();
}
/** ENTRY-block facts for the function's parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
const params = this.fnNode.childForFieldName('parameters');
if (params) {
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (!p) continue;
if (
p.type !== 'simple_parameter' &&
p.type !== 'variadic_parameter' &&
p.type !== 'property_promotion_parameter'
) {
continue;
}
const varName = this.paramVarName(p);
if (varName) this.def(varName, acc);
}
}
// A closure's `use (...)` captures are live on entry too — model as defs.
if (this.fnNode.type === 'anonymous_function') {
const clause = this.fnNode.namedChildren.find(
(c) => c.type === 'anonymous_function_use_clause',
);
if (clause) for (const v of this.useClauseVars(clause)) this.def(v, acc);
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch (T $e)` parameter — prepend to the handler entry block. */
catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined {
const name = catchClause.childForFieldName('name');
if (!name || name.type !== 'variable_name') return undefined;
const acc = new FactAccumulator(catchClause.startPosition.row + 1);
this.def(name, acc);
return acc.defCount() ? acc.finish() : undefined;
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const idx = this.table.get(name);
if (idx !== undefined) return idx;
let syn = this.synthetic.get(name);
if (syn === undefined) {
syn = this.bindings.length;
this.synthetic.set(name, syn);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return syn;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
acc.addUse(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
private conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
/** Strip parenthesized wrappers around an lvalue (`($x) = 1`). */
private unwrapParen(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 8;
while (n.type === 'parenthesized_expression' && hops-- > 0) {
const inner = n.namedChildren.find((c) => c.type !== 'comment');
if (!inner) break;
n = inner;
}
return n;
}
/** Value-position walk: collect uses; route def positions to the lvalue handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// Opaque nested function / closure — captured reads/writes are invisible.
return;
}
switch (t) {
case 'variable_name':
this.use(node, acc);
return;
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left) {
const lv = this.unwrapParen(left);
if (lv.type === 'variable_name') {
const snap = acc.defSnapshot();
this.def(lv, acc);
if (right) this.registerResultDefs(right, acc.defsSince(snap));
} else if (lv.type === 'list_literal') {
// Destructure: every target binds; non-variable keys are uses.
for (const v of this.listTargets(lv)) this.def(v, acc);
} else {
this.walkValue(lv, acc); // member / subscript target — uses only
}
}
if (right) this.walkValue(right, acc);
return;
}
case 'augmented_assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left) {
const lv = this.unwrapParen(left);
if (lv.type === 'variable_name') {
this.def(lv, acc);
this.use(lv, acc); // compound assign reads too
} else {
this.walkValue(lv, acc);
}
}
if (right) this.walkValue(right, acc);
return;
}
case 'update_expression': {
const arg = node.childForFieldName('argument');
const lv = arg ? this.unwrapParen(arg) : null;
if (lv?.type === 'variable_name') {
this.def(lv, acc);
this.use(lv, acc);
} else if (arg) {
this.walkValue(arg, acc);
}
return;
}
case 'binary_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||' || op === '??' || op === 'and' || op === 'or') {
this.conditional(() => this.walkValue(right, acc));
} else {
this.walkValue(right, acc);
}
}
return;
}
case 'conditional_expression': {
const cond = node.childForFieldName('condition');
const body = node.childForFieldName('body');
const alt = node.childForFieldName('alternative');
if (cond) this.walkValue(cond, acc);
if (body) this.conditional(() => this.walkValue(body, acc));
if (alt) this.conditional(() => this.walkValue(alt, acc));
return;
}
case 'function_call_expression':
this.visitCall(node, acc, 'function');
return;
case 'member_call_expression':
case 'nullsafe_member_call_expression':
this.visitCall(node, acc, 'member');
return;
case 'scoped_call_expression':
this.visitCall(node, acc, 'scoped');
return;
case 'object_creation_expression':
this.visitNew(node, acc);
return;
case 'member_access_expression':
case 'nullsafe_member_access_expression': {
// `$o->p` — value read of the object root only (the property name is not
// a scalar binding); record the innermost identifier-rooted member read.
this.walkChain(node, acc);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
// ── taint-site harvest ───────────────────────────────────────────────────
/**
* When `value`'s root (after stripping parens) is a call / object-creation
* node, remember its site should carry `resultDefs: defs`.
*/
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
if (defs.length === 0) return;
const root = this.unwrapParen(value);
if (
root.type === 'function_call_expression' ||
root.type === 'member_call_expression' ||
root.type === 'nullsafe_member_call_expression' ||
root.type === 'scoped_call_expression' ||
root.type === 'object_creation_expression'
) {
this.resultDefTargets.set(root.id, [...defs]);
}
}
/**
* Call-site handler for the three PHP call shapes:
* - `function`: `function_call_expression` (`function` field = name, no receiver)
* - `member`: `member_call_expression` (`object` receiver, `name` method)
* - `scoped`: `scoped_call_expression` (`scope` class, `name` method)
* Reproduces the same uses the default descent recorded plus the call site.
*/
private visitCall(
node: SyntaxNode,
acc: FactAccumulator,
shape: 'function' | 'member' | 'scoped',
): void {
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('call');
acc.pushFrame(siteIdx);
if (shape === 'function') {
const fnNode = node.childForFieldName('function');
if (fnNode) {
if (fnNode.type === 'name' || fnNode.type === 'qualified_name') {
acc.setSiteCallee(siteIdx, fnNode.text);
} else {
// dynamic callee (`$fn()`, `($obj->cb)()`) — record uses, no static path
this.walkValue(fnNode, acc);
}
}
} else if (shape === 'member') {
const objectNode = node.childForFieldName('object');
const nameNode = node.childForFieldName('name');
let receiverPath: string | undefined;
if (objectNode) {
const chain = this.walkChain(objectNode, acc);
receiverPath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
}
if (nameNode && nameNode.type === 'name') {
const callee =
receiverPath !== undefined ? `${receiverPath}.${nameNode.text}` : nameNode.text;
acc.setSiteCallee(siteIdx, callee);
}
} else {
// scoped: `C::method(...)` — scope is a class name (not a binding).
const scopeNode = node.childForFieldName('scope');
const nameNode = node.childForFieldName('name');
const scopeText =
scopeNode && (scopeNode.type === 'name' || scopeNode.type === 'qualified_name')
? scopeNode.text
: undefined;
if (nameNode && nameNode.type === 'name') {
const callee = scopeText !== undefined ? `${scopeText}.${nameNode.text}` : nameNode.text;
acc.setSiteCallee(siteIdx, callee);
}
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
this.walkArgs(argsNode, acc);
acc.popFrame();
}
/** Explicit `object_creation_expression` (`new Foo($x)`) handler. */
private visitNew(node: SyntaxNode, acc: FactAccumulator): void {
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite('new');
acc.pushFrame(siteIdx);
// The class name is the first `name`/`qualified_name` child (not a binding).
const className = node.namedChildren.find(
(c) => c.type === 'name' || c.type === 'qualified_name',
);
if (className) acc.setSiteCallee(siteIdx, className.text.replace(/\s+/g, ''));
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
this.walkArgs(argsNode, acc);
acc.popFrame();
}
/** Walk an `arguments` node, tagging each positional `argument` for occurrences. */
private walkArgs(argsNode: SyntaxNode | null, acc: FactAccumulator): void {
if (!argsNode) return;
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
if (arg.type !== 'argument') {
// A spread (`...$xs`) or other non-`argument` child — still walk for uses.
this.walkValue(arg, acc);
continue;
}
acc.setFrameArg(pos);
this.walkValue(arg, acc);
pos++;
}
}
/**
* Member-access chain walk shared by value position and a method-call receiver.
* Records the chain-root `variable_name` as a use plus at most ONE member-read
* site — the innermost access — when the root is a variable.
*/
private walkChain(node: SyntaxNode, acc: FactAccumulator): { path?: string; rootIdx?: number } {
const accesses: string[] = [];
let cur: SyntaxNode = this.unwrapParen(node);
for (;;) {
if (
cur.type === 'member_access_expression' ||
cur.type === 'nullsafe_member_access_expression'
) {
const field = cur.childForFieldName('name');
accesses.unshift(field?.text ?? '');
const obj = cur.childForFieldName('object');
if (!obj) break;
cur = this.unwrapParen(obj);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'variable_name') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else {
this.walkValue(cur, acc);
}
const innermost = accesses[0];
if (rootIdx !== undefined && innermost) acc.addMemberRead(rootIdx, innermost);
const path =
rootSegment !== undefined && accesses.every((a) => a !== '')
? [rootSegment, ...accesses].join('.')
: undefined;
return { path, rootIdx };
}
}
/**
* Ordered, deduplicating def/use + call-site collector for one statement record.
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery plus
* the taint-site harvest.
*/
const FactAccumulator = CallSiteFactAccumulator;

View file

@ -0,0 +1,988 @@
/**
* PHP CfgVisitor (PDG layer — brace-family, closest to Java/C#).
*
* Walks a PHP function / method / closure / arrow-function tree-sitter AST and
* drives the language-agnostic {@link CfgBuilder} to produce a serializable
* {@link FunctionCfg}, plus a def/use harvest ({@link PhpHarvester}) for the
* reaching-defs / CDG solvers. Structured like the Java / C# visitors — a
* `visit_<node_type>` dispatch over the statement taxonomy, driving a
* per-function {@link ControlFlowContext} — because PHP shares their `finally`
* semantics (try/catch/finally) and C-style switch FALLTHROUGH.
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-php (`php_only` export) via the introspection probe before use
* (mandatory pre-step). PHP shapes pre-empted (verified by a real parse):
* - functions: `function_definition` / `method_declaration` (fields
* `name`/`parameters`/`body`, body a `compound_statement`),
* `anonymous_function` (`parameters`/`body` + `anonymous_function_use_clause`),
* `arrow_function` (`parameters`/`body`; body is an EXPRESSION).
* - `if_statement` field `condition` (a `parenthesized_expression`), `body`, and
* zero-or-more `alternative` fields, each an `else_if_clause`
* (`condition`/`body`) or a trailing `else_clause` (`body`). PHP has no nested-
* `if` else chain — `elseif` is its own clause. The ALTERNATIVE colon syntax
* (`if … : … elseif … : … else: … endif;`) parses to the SAME node types with
* a `colon_block` body instead of `compound_statement`, so reading the `body`
* field handles both uniformly.
* - `for_statement` fields `initialize` / `condition` / `update` / `body` (NOT
* `init`/`incr`); `foreach_statement` field `body` plus POSITIONAL children:
* the iterable `variable_name`, then a value `variable_name` OR a `pair`
* (`$k => $v`); `while_statement` (`condition`/`body`); `do_statement`
* (`body`/`condition`).
* - `switch_statement` (`condition`/`body` = `switch_block`); the block holds
* `case_statement` (field `value`, body statements are siblings — FALLS
* THROUGH) and `default_statement`. `match_expression` (`condition`/`body` =
* `match_block`) is a value-position expression with NO fallthrough.
* - `try_statement` field `body`; `catch_clause` (`type`/`name`/`body`),
* `finally_clause` (`body`).
* - `return_statement`; `break_statement` / `continue_statement` carry an
* optional `integer` child (`break 2;` targets the 2nd enclosing loop/switch);
* `throw` is a `throw_expression` wrapped in an `expression_statement` (there
* is NO `throw_statement` node); `goto_statement` + `named_label_statement`.
*
* Edge-kind contract (matches the existing visitors — RD/CDG consume these):
* - if/elseif/else → `cond-true` / `cond-false`
* - loops (for / foreach / while / do-while) → `cond-true` / `loop-back` /
* `cond-false`
* - switch → `switch-case` / `fallthrough` (a `case` with no `break`/`return`
* falls through to the next case); a value-position `match` with ≥2 arms also
* dispatches as `switch-case` (no fallthrough), see the limitations.
* - try/catch → `throw` (every protected-region block → the handler); a
* `finally` runs on normal AND exception exit, so a `return`/`break`/`continue`
* crossing it gets a `finally-*` completion edge.
* - return / throw / break / continue → the matching terminator kind; `break N`
* / `continue N` target the N-th enclosing loop/switch (not the nearest).
* - straight-line → `seq`
*
* Classic hazards, handled explicitly (mirrors the Java / TS visitors):
* - loops allocate a dedicated loop-exit block so `break` has a target before
* the loop's successor is known; `continue` targets the header / update.
* - `for (;;) {}` / `while (true) {}` still emit the structural `header →
* loopExit` `cond-false` escape edge so EXIT stays reverse-reachable from
* every block — the post-dominator / CDG pass silently emits zero CDG for the
* function otherwise.
* - `break N` / `continue N`: each loop/switch frame is pushed with a UNIQUE
* synthetic label, and an N-level jump resolves against the label of the N-th
* enclosing loop/switch frame — reusing the existing finalizer-threading
* machinery so a jump that crosses a `finally` still threads through it.
* - try/catch: conservative exceptional flow — EVERY block in the protected
* region edges to the handler (an exception may fire mid-block).
*
* Known limitations:
* - A value-position `match($x) { … }` with ≥2 arms IS modeled as a `switch-case`
* dispatch in two carriers (#2207): an `$x = match(…) {…}` assignment (arms
* rejoin at a binding continuation) and `return match(…) {…}` (each arm
* returns). A `match` in any OTHER position — a call argument, a nested
* subexpression — stays INLINE inside its owning block. The ternary `?:` is
* excluded by design (a micro-branch, like elvis in Kotlin).
* - context-manager-style suppression and PHP's exception-from-mid-call outside
* any `try` are not modeled (no edge), matching the other visitors.
* - `goto` / named labels are modeled as straight-line blocks (the label is a
* plain block; a `goto` does NOT create a jump edge — PHP `goto` is rare and
* intra-function only; an over-approximation here would harm precision more
* than the missing edge). Documented gap.
* - Def/use harvest scope: see `php-harvest.ts` — property / array-element
* writes are not scalar defs; nested-function (closure / arrow) bodies are
* opaque in both directions.
*
* Returns `undefined` (never throws) for an AST shape it cannot model, so a
* malformed function never drops the whole file's CFG group (R4).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import {
ControlFlowContext,
drainFinalizerPending,
wireJumpThroughFinalizers,
} from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { PhpHarvester } from './php-harvest.js';
/** PHP node types that own a CFG-bearing function body. */
const PHP_FUNCTION_TYPES = new Set([
'function_definition',
'method_declaration',
'anonymous_function',
'arrow_function',
]);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_statement',
'for_statement',
'foreach_statement',
'while_statement',
'do_statement',
'switch_statement',
'try_statement',
'return_statement',
'break_statement',
'continue_statement',
'goto_statement',
'named_label_statement',
'compound_statement',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
const isComment = (n: SyntaxNode): boolean => n.type === 'comment';
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function PHP walk state. One instance per function so the
* {@link ControlFlowContext}, exception-handler stack, and the `break N` /
* `continue N` synthetic-label bookkeeping are scoped to that function and never
* leak across functions.
*/
class PhpCfgWalk {
private readonly cfc = new ControlFlowContext();
/** Stack of exception-handler entry blocks (catch / finally) a `throw` jumps to. */
private readonly handlers: number[] = [];
/**
* Synthetic labels of the active loop/switch frames, innermost LAST — so the
* N-th enclosing frame's label is `loopLabels[length - N]`. PHP's `break N` /
* `continue N` resolve against these (no source labels exist).
*/
private readonly loopLabels: string[] = [];
private labelSeq = 0;
constructor(
private readonly builder: CfgBuilder,
private readonly harvest: PhpHarvester,
) {}
/** Statements of a body node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => !isComment(c));
}
/** The `body` block of a node (a `compound_statement` / `colon_block` / stmt). */
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return node.childForFieldName('body') ?? undefined;
}
/** Strip a `parenthesized_expression` wrapper (PHP `if`/`while` conditions). */
private unwrapParen(node: SyntaxNode): SyntaxNode {
if (node.type === 'parenthesized_expression') {
const inner = node.namedChildren.find((c) => !isComment(c));
if (inner) return inner;
}
return node;
}
/** Visit a body that may be a block-ish container or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
return this.builder.withNesting(() => {
if (!node) return null;
if (node.type === 'compound_statement' || node.type === 'colon_block') {
return this.visitSeq(this.statementsOf(node));
}
return this.visitStmt(node);
});
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
return this.builder.withNesting(() => {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
// An `expression_statement` wrapping a bare `throw_expression` is a
// terminator (PHP has no `throw_statement` node), so it breaks the block.
// An `$x = match($v) {…}` value-position assignment breaks too (#2207).
const breaks =
CONTROL_FLOW_TYPES.has(stmt.type) ||
this.isThrowStatement(stmt) ||
this.isValueBranchAssignment(stmt);
if (breaks) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
if (openSimple === undefined) {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
} else {
this.builder.extendBlock(
openSimple,
endLineOf(stmt),
stmt.text,
this.harvest.facts(stmt),
);
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
});
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
if (this.isThrowStatement(stmt)) return this.visitThrow(stmt);
// `$x = match($v) { … };` (#2207): model the match arms as control flow and
// bind the assignment target on the rejoin.
const assign = this.assignmentBranch(stmt);
if (assign) return this.visitBindAssign(stmt, assign.expr, assign.match);
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'foreach_statement':
return this.visitForEach(stmt);
case 'while_statement':
return this.visitWhile(stmt);
case 'do_statement':
return this.visitDoWhile(stmt);
case 'switch_statement':
return this.visitSwitch(stmt);
case 'try_statement':
return this.visitTry(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'break_statement':
return this.visitBreak(stmt);
case 'continue_statement':
return this.visitContinue(stmt);
case 'compound_statement':
case 'colon_block':
return this.visitSeq(this.statementsOf(stmt));
case 'goto_statement':
case 'named_label_statement':
// `goto` / labels are modeled as straight-line blocks (no jump edge — see
// the visitor limitations); they still carry their text + facts.
return this.visitSimple(stmt);
default:
return this.visitSimple(stmt);
}
}
/** True for an `expression_statement` whose value is a bare `throw_expression`. */
private isThrowStatement(stmt: SyntaxNode): boolean {
if (stmt.type !== 'expression_statement') return false;
const inner = stmt.namedChildren.find((c) => !isComment(c));
return inner?.type === 'throw_expression';
}
private visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
return { entry: idx, exits: [idx] };
}
private visitReturn(stmt: SyntaxNode): TraversalResult {
// `return match($v) { … };` (#2207): the returned value is a value-position
// branch — model it as control flow, with each arm returning (its value IS
// the function result), threading every active finally per arm.
const branch = stmt.namedChildren.find((c) => !isComment(c));
if (branch && this.isModelableValueBranch(branch)) {
const res = this.visitBranchExpr(branch);
const finalizers = this.cfc.finalizersForReturn();
for (const ex of res.exits) {
wireJumpThroughFinalizers(this.builder, ex, finalizers, this.builder.exitIndex, 'return');
}
return { entry: res.entry, exits: [] };
}
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
// A return crosses EVERY active finally before EXIT.
wireJumpThroughFinalizers(
this.builder,
idx,
this.cfc.finalizersForReturn(),
this.builder.exitIndex,
'return',
);
return { entry: idx, exits: [] };
}
private visitThrow(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
this.builder.edge(idx, this.currentHandler(), 'throw');
return { entry: idx, exits: [] };
}
private visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = this.jumpLabel(stmt);
const res = this.cfc.resolveBreak(label);
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break');
return { entry: idx, exits: [] };
}
private visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = this.jumpLabel(stmt);
const res = this.cfc.resolveContinue(label);
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue');
return { entry: idx, exits: [] };
}
/**
* Resolve a `break N` / `continue N` to the SYNTHETIC label of the N-th
* enclosing loop/switch frame (innermost = 1). Returns undefined for a bare
* `break`/`continue` (no level), so the context resolves the nearest frame as
* usual. PHP counts BOTH loop AND switch frames for `break N` and `continue N`
* (a `switch` acts like a loop level for `continue`), which is exactly the set
* pushed onto {@link loopLabels} here — so one count serves both.
*/
private jumpLabel(stmt: SyntaxNode): string | undefined {
const level = this.jumpLevel(stmt);
if (level <= 1) return undefined; // bare break/continue → nearest frame
const n = this.loopLabels.length;
if (level > n) return undefined; // over-deep level → conservative fallback
return this.loopLabels[n - level];
}
/** The integer level of a `break N;` / `continue N;` (default 1). */
private jumpLevel(stmt: SyntaxNode): number {
const intNode = stmt.namedChildren.find((c) => c.type === 'integer');
if (!intNode) return 1;
const v = parseInt(intNode.text, 10);
return Number.isFinite(v) && v >= 1 ? v : 1;
}
/** Push a fresh synthetic loop/switch label and return it. */
private nextLabel(): string {
const label = `__php_lvl_${this.labelSeq++}`;
return label;
}
/**
* `if cond: … elseif cond: … else: …`. PHP has NO nested-if else chain: the
* `if_statement` carries the condition + body plus zero-or-more `alternative`
* fields, each an `else_if_clause` (its own condition + body) or a trailing
* `else_clause`. The elif chain is threaded on the `cond-false` edge. Handles
* both brace bodies and the colon (`endif`) syntax uniformly (body field).
*/
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = this.condOf(stmt) ?? stmt;
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('body'));
if (thenRes) {
this.builder.edge(header, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(header); // empty then — true path falls through
}
const alternatives = this.alternativesOf(stmt);
let falseFrom = header;
for (const alt of alternatives) {
if (alt.type === 'else_if_clause') {
const elifCondRaw = alt.childForFieldName('condition');
const elifCond = elifCondRaw ? this.unwrapParen(elifCondRaw) : alt;
const elifHeader = this.builder.newBlock(
startLineOf(alt),
endLineOf(elifCond),
elifCond.text,
'normal',
this.harvest.facts(elifCond),
);
this.builder.edge(falseFrom, elifHeader, 'cond-false');
const elifRes = this.visitBody(alt.childForFieldName('body'));
if (elifRes) {
this.builder.edge(elifHeader, elifRes.entry, 'cond-true');
exits.push(...elifRes.exits);
} else {
exits.push(elifHeader);
}
falseFrom = elifHeader;
} else if (alt.type === 'else_clause') {
const elseRes = this.visitBody(alt.childForFieldName('body'));
if (elseRes) {
this.builder.edge(falseFrom, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(falseFrom);
}
falseFrom = -1; // an else consumes the false path entirely
}
}
if (falseFrom >= 0) exits.push(falseFrom); // no trailing else → fall through
return { entry: header, exits: [...new Set(exits)] };
}
/** The `alternative`-field children of an `if_statement`, in source order. */
private alternativesOf(stmt: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < stmt.childCount; i++) {
if (stmt.fieldNameForChild(i) === 'alternative') {
const c = stmt.child(i);
if (c) out.push(c);
}
}
return out;
}
/** The (paren-unwrapped) condition expression of an if/while/do/switch. */
private condOf(stmt: SyntaxNode): SyntaxNode | undefined {
const cond = stmt.childForFieldName('condition');
return cond ? this.unwrapParen(cond) : undefined;
}
private visitWhile(stmt: SyntaxNode): TraversalResult {
const label = this.nextLabel();
const cond = this.condOf(stmt) ?? stmt;
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.loopLabels.push(label);
this.cfc.pushLoop(header, loopExit, [label]);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
this.loopLabels.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
}
// Always emit the structural exit edge — even `while (true)` keeps EXIT
// reverse-reachable for the post-dominator / CDG pass.
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private visitDoWhile(stmt: SyntaxNode): TraversalResult {
const label = this.nextLabel();
const cond = this.condOf(stmt) ?? stmt;
const condBlock = this.builder.newBlock(
startLineOf(cond),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.loopLabels.push(label);
this.cfc.pushLoop(condBlock, loopExit, [label]);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
this.loopLabels.pop();
const backTarget = body ? body.entry : condBlock;
if (body) this.builder.connect(body.exits, condBlock, 'seq');
this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again
this.builder.edge(condBlock, loopExit, 'cond-false');
return { entry: backTarget, exits: [loopExit] };
}
private visitFor(stmt: SyntaxNode): TraversalResult {
const label = this.nextLabel();
const init = stmt.childForFieldName('initialize');
const cond = stmt.childForFieldName('condition');
const incr = stmt.childForFieldName('update');
const header = this.builder.newBlock(
startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for(;;)',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
let incrBlock = header;
if (incr) {
incrBlock = this.builder.newBlock(
startLineOf(incr),
endLineOf(incr),
incr.text,
'normal',
this.harvest.facts(incr),
);
this.builder.edge(incrBlock, header, 'loop-back');
}
this.loopLabels.push(label);
this.cfc.pushLoop(incrBlock, loopExit, [label]);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
this.loopLabels.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
} else {
this.builder.edge(header, incrBlock, 'cond-true');
if (!incr) this.builder.edge(header, header, 'loop-back');
}
// Structural exit edge — `for (;;) {}` (no condition) still keeps EXIT
// reverse-reachable so CDG is not silently skipped for the function.
this.builder.edge(header, loopExit, 'cond-false');
let entry = header;
if (init) {
const initBlock = this.builder.newBlock(
startLineOf(init),
endLineOf(init),
init.text,
'normal',
this.harvest.facts(init),
);
this.builder.edge(initBlock, header, 'seq');
entry = initBlock;
}
return { entry, exits: [loopExit] };
}
private visitForEach(stmt: SyntaxNode): TraversalResult {
const label = this.nextLabel();
// Header text is SYNTHESIZED, so facts come from the iterable (use) + the
// loop target variable(s) (def) directly.
const header = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.forEachHeaderText(stmt),
'normal',
this.harvest.foreachHeadFacts(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.loopLabels.push(label);
this.cfc.pushLoop(header, loopExit, [label]);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
this.loopLabels.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private forEachHeaderText(stmt: SyntaxNode): string {
const first = stmt.namedChild(0);
return first ? `foreach(${first.text} as …)` : 'foreach(… as …)';
}
private visitSwitch(stmt: SyntaxNode): TraversalResult {
const label = this.nextLabel();
const value = this.condOf(stmt) ?? stmt;
const dispatch = this.builder.newBlock(
startLineOf(stmt),
endLineOf(value),
value.text,
'normal',
this.harvest.facts(value),
);
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.loopLabels.push(label);
this.cfc.pushSwitch(switchExit, [label]);
const body = stmt.childForFieldName('body');
// A `switch_block` holds `case_statement`s (field `value`, fall through) and a
// `default_statement`.
const groups = body
? body.namedChildren.filter(
(c) => c.type === 'case_statement' || c.type === 'default_statement',
)
: [];
// Each case-test expression evaluates before its body runs — harvest its uses
// onto the dispatch block, CONDITIONALLY (a later case test only runs when
// earlier cases didn't match).
for (const g of groups) {
const test = this.caseTest(g);
if (test) this.builder.attachFacts(dispatch, this.harvest.factsConditional(test));
}
const groupResults = groups.map((g) => this.visitSeq(this.caseStatements(g)));
const hasDefault = groups.some((g) => g.type === 'default_statement');
const entryOf: number[] = new Array(groups.length);
let after = switchExit;
for (let i = groups.length - 1; i >= 0; i--) {
entryOf[i] = groupResults[i]?.entry ?? after;
after = entryOf[i];
}
for (let i = 0; i < groups.length; i++) {
this.builder.edge(dispatch, entryOf[i], 'switch-case');
}
if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
// C-style FALLTHROUGH: a case with no break/return falls through to the next.
for (let i = 0; i < groups.length; i++) {
const res = groupResults[i];
if (!res) continue;
const fallTarget = i + 1 < groups.length ? entryOf[i + 1] : switchExit;
this.builder.connect(res.exits, fallTarget, 'fallthrough');
}
this.cfc.pop();
this.loopLabels.pop();
return { entry: dispatch, exits: [switchExit] };
}
/** A switch group's body statements (everything but its case-test value). */
private caseStatements(group: SyntaxNode): SyntaxNode[] {
const value = group.childForFieldName('value');
return group.namedChildren.filter((c) => c.id !== value?.id && !isComment(c));
}
/** The case-test value expression of a `case_statement` (default has none). */
private caseTest(group: SyntaxNode): SyntaxNode | undefined {
return group.childForFieldName('value') ?? undefined;
}
// ── value-position match expression (#2207) ─────────────────────────────────
/**
* The `{expr, match}` of an `$x = match($v) {…}` value-position assignment
* carrier, or undefined. `expr` is the `assignment_expression` (for the target
* def); `match` is the modelable `match_expression` RHS. Only a plain `=`
* assignment qualifies (an augmented `??=` etc. is not a value-branch bind).
*/
private assignmentBranch(stmt: SyntaxNode): { expr: SyntaxNode; match: SyntaxNode } | undefined {
if (stmt.type !== 'expression_statement') return undefined;
const expr = stmt.namedChildren.find((c) => !isComment(c));
if (!expr || expr.type !== 'assignment_expression') return undefined;
const right = expr.childForFieldName('right');
return right && this.isModelableValueBranch(right) ? { expr, match: right } : undefined;
}
/** Whether a statement is an `$x = match(…) {…}` value-branch assignment. */
private isValueBranchAssignment(stmt: SyntaxNode): boolean {
return this.assignmentBranch(stmt) !== undefined;
}
/**
* Whether `node` is a value-position branch worth modeling as control flow
* (#2207): a `match_expression` with ≥2 arms — a real dispatch. PHP `match` is
* the only value-position branch (there is no `if`-expression); the ternary
* `?:` is deliberately excluded, like elvis in Kotlin.
*/
private isModelableValueBranch(node: SyntaxNode): boolean {
if (node.type !== 'match_expression') return false;
const block = node.childForFieldName('body');
if (!block) return false;
return (
block.namedChildren.filter(
(c) => c.type === 'match_conditional_expression' || c.type === 'match_default_expression',
).length >= 2
);
}
/** Model a value-position branch as control flow (only `match_expression`). */
private visitBranchExpr(node: SyntaxNode): TraversalResult {
return this.visitMatch(node);
}
/**
* Model a value-position `match($v) { c => v, default => v }` as a CFG dispatch:
* a discriminant block, each arm's value expression a block reached by a
* `switch-case` edge, all arms rejoining at one exit (no fallthrough — `match`
* never falls through). The arm condition lists are harvested as conditional
* uses on the dispatch (a later arm test runs only when earlier arms missed).
*/
private visitMatch(node: SyntaxNode): TraversalResult {
const condRaw = node.childForFieldName('condition');
const cond = condRaw ? this.unwrapParen(condRaw) : node;
const dispatch = this.builder.newBlock(
startLineOf(node),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const matchExit = this.builder.newBlock(endLineOf(node), endLineOf(node), '');
const block = node.childForFieldName('body');
const arms = block
? block.namedChildren.filter(
(c) => c.type === 'match_conditional_expression' || c.type === 'match_default_expression',
)
: [];
let hasDefault = false;
for (const arm of arms) {
const condList = arm.namedChildren.find((c) => c.type === 'match_condition_list');
if (condList) this.builder.attachFacts(dispatch, this.harvest.factsConditional(condList));
if (arm.type === 'match_default_expression') hasDefault = true;
const value = this.matchArmValue(arm);
const armBlock = this.builder.newBlock(
startLineOf(value ?? arm),
endLineOf(value ?? arm),
(value ?? arm).text,
'normal',
value ? this.harvest.facts(value) : undefined,
);
this.builder.edge(dispatch, armBlock, 'switch-case');
this.builder.edge(armBlock, matchExit, 'seq');
}
// `match` with no `default` throws `\UnhandledMatchError` on no match; keep
// EXIT reachable via a conservative no-match edge when no default arm exists.
if (!hasDefault) this.builder.edge(dispatch, matchExit, 'switch-case');
return { entry: dispatch, exits: [matchExit] };
}
/** The value (result) expression of a match arm — its LAST named child. */
private matchArmValue(arm: SyntaxNode): SyntaxNode | undefined {
const named = arm.namedChildren.filter((c) => !isComment(c));
return named[named.length - 1];
}
/**
* `$x = match($v) { … }` (#2207): visit the match as control flow, then rejoin
* its arms at a facts-only continuation carrying ONLY the LHS target def (the
* condition + arm-value uses are already on the match's blocks). The arms are
* now control-dependent on the dispatch — mirrors the Ruby value-branch assign.
*/
private visitBindAssign(
stmt: SyntaxNode,
assignExpr: SyntaxNode,
branch: SyntaxNode,
): TraversalResult {
const res = this.visitBranchExpr(branch);
const cont = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
'',
'normal',
this.harvest.assignmentDefFacts(assignExpr),
);
this.builder.connect(res.exits, cont, 'seq');
return { entry: res.entry, exits: [cont] };
}
/**
* try / catch / finally. A `finally` runs on BOTH normal and exception exit —
* a `return`/`break`/`continue` crossing it threads through it (`finally-*`
* completion edges).
*/
private visitTry(stmt: SyntaxNode): SeqResult {
const bodyNode = stmt.childForFieldName('body');
const catchClauses: SyntaxNode[] = [];
let finallyClause: SyntaxNode | undefined;
for (let i = 0; i < stmt.namedChildCount; i++) {
const c = stmt.namedChild(i);
if (c?.type === 'catch_clause') catchClauses.push(c);
else if (c?.type === 'finally_clause') finallyClause = c;
}
const finallyBody = finallyClause?.childForFieldName('body');
return this.buildProtected(bodyNode ?? null, catchClauses, finallyBody ?? null);
}
/**
* Shared try/catch/finally builder (mirrors the Java visitor). `catchClauses`
* may be empty; `finallyBody` is the explicit finally's body (or null).
*
* Normal completion of try AND catch flows through the finally; a throw in the
* protected region routes to the handler; early exits crossing the finally
* thread through it (`finally-*` completion edges).
*/
private buildProtected(
bodyNode: SyntaxNode | null,
catchClauses: SyntaxNode[],
finallyBody: SyntaxNode | null,
): SeqResult {
const finallyRes = finallyBody ? this.visitBody(finallyBody) : null;
const finFrame = finallyRes ? this.cfc.pushFinalizer(finallyRes.entry) : null;
const finalizerEntry = finallyRes?.entry;
// Build each catch handler.
const catchEntries: number[] = [];
const catchExits: number[] = [];
let firstCatchEntry: number | undefined;
for (const clause of catchClauses) {
const clauseBody = clause.childForFieldName('body');
if (finalizerEntry !== undefined) this.handlers.push(finalizerEntry);
let res: SeqResult = clauseBody ? this.visitBody(clauseBody) : null;
if (finalizerEntry !== undefined) this.handlers.pop();
if (res === null) {
// Empty `catch {}` still catches — synthesize one block so exception flow
// lands somewhere and the post-try code stays reachable.
const idx = this.builder.newBlock(startLineOf(clause), endLineOf(clause), '');
res = { entry: idx, exits: [idx] };
}
const paramFacts = this.harvest.catchParamFacts(clause);
if (paramFacts) {
const paramBlock = this.builder.newBlock(
startLineOf(clause),
startLineOf(clause),
'',
'normal',
paramFacts,
);
this.builder.edge(paramBlock, res.entry, 'seq');
res = { entry: paramBlock, exits: res.exits };
}
catchEntries.push(res.entry);
catchExits.push(...res.exits);
if (firstCatchEntry === undefined) firstCatchEntry = res.entry;
}
// Handler for the try body: first catch if present, else the finally, else
// the outer handler.
const tryHandler = firstCatchEntry ?? finalizerEntry ?? this.currentHandler();
const protectedStart = this.builder.blockCount;
this.handlers.push(tryHandler);
const bodyRes = bodyNode ? this.visitBody(bodyNode) : null;
this.handlers.pop();
if (catchClauses.length > 0 || finalizerEntry !== undefined) {
for (let b = protectedStart; b < this.builder.blockCount; b++) {
this.builder.edge(b, tryHandler, 'throw');
}
}
// Pop the finalizer frame and drain its pending crossing-jump legs.
if (finFrame && finallyRes) {
this.cfc.pop();
drainFinalizerPending(this.builder, finFrame, finallyRes.exits);
}
const exits: number[] = [];
if (finalizerEntry !== undefined && finallyRes) {
if (bodyRes) this.builder.connect(bodyRes.exits, finalizerEntry, 'seq');
for (const e of catchExits) this.builder.edge(e, finalizerEntry, 'seq');
exits.push(...finallyRes.exits);
// No catch → an exception re-propagates out after the finally runs.
if (catchClauses.length === 0) {
this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw');
}
} else {
if (bodyRes) exits.push(...bodyRes.exits);
exits.push(...catchExits);
}
const entry = bodyRes?.entry ?? finalizerEntry ?? catchEntries[0];
if (entry === undefined) return null;
return { entry, exits: [...new Set(exits)] };
}
/** Nearest enclosing exception handler, or the function EXIT. */
private currentHandler(): number {
return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex;
}
}
/** Build the CFG for one PHP function node, or `undefined` if not modelable. */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
try {
if (!PHP_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
const body = fnNode.childForFieldName('body');
if (!body) return undefined; // abstract / interface method — no body
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const harvest = new PhpHarvester(fnNode);
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
if (fnNode.type === 'arrow_function' || body.type !== 'compound_statement') {
// `fn($x) => expr` — the body is an EXPRESSION (no block): one block whose
// value is returned.
const blk = builder.newBlock(
startLineOf(body),
endLineOf(body),
body.text,
'normal',
harvest.facts(body),
);
builder.edge(builder.entryIndex, blk, 'seq');
builder.edge(blk, builder.exitIndex, 'return');
return builder.finish(harvest.bindingTable());
}
const walk = new PhpCfgWalk(builder, harvest);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish(harvest.bindingTable());
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish(harvest.bindingTable());
} catch (err) {
// Never throw out of buildFunctionCfg — a malformed AST shape must skip only
// this one function's CFG, never drop the whole file's language group (R4).
// eslint-disable-next-line no-console
console.warn(`[cfg] PHP buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`);
return undefined;
}
}
/** Whether a node is a PHP function this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return PHP_FUNCTION_TYPES.has(node.type);
}
/** The PHP CFG visitor. */
export function createPhpCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { PHP_FUNCTION_TYPES };

View file

@ -0,0 +1,660 @@
/**
* Python def/use harvester — the Python analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the C-family
* harvesters ({@link import('./go-harvest.js').GoHarvester} et al.). Python is
* the most structurally divergent CFG target (indentation blocks, no braces,
* comprehensions, `with`, `try/except/else/finally`, `match/case`), so this
* harvester exercises the shared reaching-defs / CDG substrate against a grammar
* with none of the brace-family assumptions.
*
* Runs in the parse worker next to the Python CFG visitor, extracting
* per-statement variable definition/use facts that ride the side channel for the
* reaching-defs / CDG solvers. Output is the per-function binding table
* ({@link BindingEntry}[]) plus {@link StatementFacts} the visitor attaches to
* blocks as it walks. NO `sites[]` are harvested here — the call-site taint
* substrate is a later step (this unit emits only bindings + defs/uses + mayDefs
* via the local {@link FactAccumulator}, which has no site machinery at all).
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-python (0.23.x) via the introspection probe before use (mandatory
* pre-step). Python shapes pre-empted (verified by a real parse):
* - functions: `function_definition` (fields `name`/`parameters`/`body`; async
* is the SAME node with an `async` token child) and `lambda` (fields
* `parameters`=`lambda_parameters`, `body`).
* - parameters: bare `identifier`, `default_parameter` (fields `name`/`value`),
* `typed_parameter` (no `name` field — the binder is a named child:
* `identifier` / `list_splat_pattern` / `dictionary_splat_pattern`),
* `typed_default_parameter` (fields `name`/`type`/`value`),
* `list_splat_pattern` (`*args`), `dictionary_splat_pattern` (`**kwargs`).
* - assignment targets: `assignment` (fields `left`/`right`/optional `type`;
* LHS may be `identifier`, `pattern_list`, `tuple_pattern`, `list_pattern`,
* `attribute`, or `subscript`), `augmented_assignment` (fields
* `left`/`operator`/`right` — read+write), `named_expression` walrus (fields
* `name`/`value`).
* - unpacking patterns: `pattern_list` / `tuple_pattern` / `list_pattern`
* nest `identifier` and `list_splat_pattern` (`*rest`) targets.
* - binders: `for_statement` (fields `left`/`right`), `for_in_clause`
* (comprehension binder, fields `left`/`right`), `with_item` (field `value`
* = `as_pattern` whose `alias`=`as_pattern_target`, or a bare expression),
* `except_clause` / `except_group_clause` (`as_pattern` → `as_pattern_target`),
* `global_statement` / `nonlocal_statement` (identifier children).
* - reads: `attribute` (fields `object`/`attribute`), `subscript` (fields
* `value`/`subscript`), `call` (fields `function`/`arguments`),
* `boolean_operator` (fields `left`/`operator`/`right`),
* `conditional_expression` (ternary: consequent / condition / alternative in
* source order), `parenthesized_expression`.
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / Go harvesters):
* the CFG walk is NOT source-order, so resolving names against a scope stack
* populated *during* the walk would mis-resolve. Phase 1 pre-scans the whole
* function subtree once, declaring every in-function name (Python's def-on-first-
* assignment scoping has a SINGLE function scope — there is no block scope, so
* the whole function body shares one table). Phase 2 resolves defs/uses against
* that finished table from any walk order.
*
* Python scope model (deliberately simplified, documented): Python binds names
* at FUNCTION scope on first assignment anywhere in the body (no block scope).
* We therefore declare all assignment / for / with / except / walrus /
* comprehension targets and parameters into the single function table.
* `global x` / `nonlocal x` names are recorded as SYNTHETIC module-level
* bindings (`name@module`) so their writes/reads share one binding with the
* outer scope rather than minting a confusing function-local. Comprehension
* targets technically have their OWN scope in Py3 (a leaked `i` after `[i for
* i in xs]` does NOT exist), but we declare them in the function table anyway —
* a documented over-approximation that keeps the comprehension target a real
* def (the plan's explicit ask) without modeling nested comprehension scopes.
*
* v1 def-semantics scope:
* - `assignment` plain `=` — each identifier target in the (possibly nested)
* LHS pattern is a def; a `*rest` splat target is a def; an `attribute` /
* `subscript` target is NOT a scalar def (its root identifier is a use).
* - `augmented_assignment` (`x += 1`) — def AND use the lvalue.
* - `named_expression` walrus (`(n := f())`) — `n` is a def.
* - `for_statement` / `for_in_clause` `left` — loop/comprehension targets are
* defs; `right` is a use.
* - `with_item` `as` alias (`with cm as fh`) — `fh` is a def.
* - `except ... as e` — `e` is a `catch`-kind def (matters to the taint pass).
* - parameters (incl. defaults, `*args`, `**kwargs`, typed) — `param`-kind defs.
* EXCLUDED, deliberately (TypeScript-CFA precedent): attribute / subscript
* writes (`obj.f = …`, `arr[i] = …`) are NOT scalar defs — their root
* identifiers are uses only. Nested function (`function_definition` / `lambda`)
* bodies are opaque in BOTH directions (reads of and writes to captured outer
* variables are invisible — callback flows are later-pass territory).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression is a may-def
* (gen WITHOUT kill), so the not-taken path's prior def is not falsely killed.
* Python's conditional-def shapes: a walrus in the right operand of `or` / `and`
* short-circuit (`a or (x := b)`), a walrus in either non-test arm of a ternary
* (`(a := p) if c else (b := q)`), and a `case`-clause guard / pattern test.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set(['function_definition', 'lambda']);
/** LHS pattern containers whose identifier/splat leaves are assignment targets. */
const PATTERN_LIST_TYPES = new Set(['pattern_list', 'tuple_pattern', 'list_pattern']);
export class PythonHarvester {
private readonly bindings: BindingEntry[] = [];
/** Single function-scope name → binding index (Python has no block scope). */
private readonly table = new Map<string, number>();
private readonly synthetic = new Map<string, number>();
/** Names declared `global`/`nonlocal` — resolve to the synthetic module binding. */
private readonly globalNames = new Set<string>();
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
/** The function/lambda body node (a `block` for `def`, an expression for `lambda`). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
return fnNode.childForFieldName('body') ?? undefined;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void {
const name = nameNode.text;
if (!name || name === '_' || this.table.has(name) || this.globalNames.has(name)) return;
this.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/** Declare every parameter binder (incl. defaults, typed, `*args`, `**kwargs`). */
private declareParams(fnNode: SyntaxNode): void {
const params =
fnNode.childForFieldName('parameters') ??
fnNode.namedChildren.find((c) => c.type === 'parameters' || c.type === 'lambda_parameters');
if (!params) return;
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p) this.declareParam(p);
}
}
/** Declare the binder identifier(s) of one parameter node. */
private declareParam(p: SyntaxNode): void {
switch (p.type) {
case 'identifier':
this.declare(p, 'param');
return;
case 'default_parameter':
case 'typed_default_parameter': {
const name = p.childForFieldName('name');
if (name) this.declareParamBinder(name);
return;
}
case 'typed_parameter': {
// No `name` field — the binder is the first non-`type` named child
// (an identifier or a splat pattern).
const typeNode = p.childForFieldName('type');
for (let i = 0; i < p.namedChildCount; i++) {
const c = p.namedChild(i);
if (c && c.id !== typeNode?.id) {
this.declareParamBinder(c);
break;
}
}
return;
}
case 'list_splat_pattern':
case 'dictionary_splat_pattern':
this.declareParamBinder(p);
return;
default:
// tuple-grouped params and the like — declare any identifier leaves.
this.declareParamBinder(p);
}
}
/** Declare a binder that may be an identifier or a `*`/`**` splat pattern. */
private declareParamBinder(node: SyntaxNode): void {
if (node.type === 'identifier') {
this.declare(node, 'param');
return;
}
if (node.type === 'list_splat_pattern' || node.type === 'dictionary_splat_pattern') {
const id = node.namedChild(0);
if (id?.type === 'identifier') this.declare(id, 'param');
return;
}
// Nested grouping — declare identifier descendants up to the next binder.
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'identifier') this.declare(c, 'param');
}
}
/**
* Pre-scan the function body once, declaring every in-function name. Recurses
* into compound statements but NOT into nested `function_definition` / `lambda`
* bodies (opaque). `global`/`nonlocal` are processed FIRST in a sibling sweep
* so a later assignment to a global name does not mint a function-local.
*/
private prescan(node: SyntaxNode): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return;
switch (t) {
case 'global_statement':
case 'nonlocal_statement': {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'identifier') this.globalNames.add(c.text);
}
return;
}
case 'assignment': {
const left = node.childForFieldName('left');
if (left) this.declareTargets(left);
break;
}
case 'augmented_assignment': {
const left = node.childForFieldName('left');
if (left) this.declareTargets(left);
break;
}
case 'named_expression': {
const name = node.childForFieldName('name');
if (name?.type === 'identifier') this.declare(name, 'let');
break;
}
case 'for_statement':
case 'for_in_clause': {
const left = node.childForFieldName('left');
if (left) this.declareTargets(left);
break;
}
case 'with_item': {
this.declareWithItem(node);
break;
}
case 'except_clause':
case 'except_group_clause': {
this.declareExceptAlias(node);
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c);
}
}
/** Declare identifier/splat leaves of an assignment / loop target pattern. */
private declareTargets(target: SyntaxNode): void {
const t = target.type;
if (t === 'identifier') {
this.declare(target, 'let');
return;
}
if (PATTERN_LIST_TYPES.has(t)) {
for (let i = 0; i < target.namedChildCount; i++) {
const c = target.namedChild(i);
if (c) this.declareTargets(c);
}
return;
}
if (t === 'list_splat_pattern') {
const id = target.namedChild(0);
if (id?.type === 'identifier') this.declare(id, 'let');
return;
}
// attribute / subscript target — not a scalar def (root is a use only).
}
/** `with EXPR as TARGET` — declare the alias target(s). */
private declareWithItem(item: SyntaxNode): void {
const value = item.childForFieldName('value') ?? item.namedChild(0);
if (value?.type !== 'as_pattern') return;
const alias = value.childForFieldName('alias') ?? this.asPatternTarget(value);
if (alias) this.declareAsTarget(alias);
}
/** `except E as e` — declare `e`. */
private declareExceptAlias(clause: SyntaxNode): void {
for (let i = 0; i < clause.namedChildCount; i++) {
const c = clause.namedChild(i);
if (c?.type === 'as_pattern') {
const alias = c.childForFieldName('alias') ?? this.asPatternTarget(c);
if (alias) this.declareAsTarget(alias, 'catch');
}
}
}
/** The `as_pattern_target` child of an `as_pattern` (when no `alias` field). */
private asPatternTarget(asPattern: SyntaxNode): SyntaxNode | undefined {
return asPattern.namedChildren.find((c) => c.type === 'as_pattern_target');
}
/** Declare an `as_pattern_target` (or its identifier child) as a binding. */
private declareAsTarget(target: SyntaxNode, kind: BindingEntry['kind'] = 'let'): void {
if (target.type === 'identifier') {
this.declare(target, kind);
return;
}
// `as_pattern_target` wraps an identifier (or a tuple pattern).
const inner = target.namedChild(0);
if (inner?.type === 'identifier') this.declare(inner, kind);
else if (inner) this.declareTargets(inner);
else this.declare(target, kind); // a bare `as_pattern_target` text IS the name
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Facts for a `for TARGET in ITER` / `for_in_clause` head: the loop target(s)
* are defs, the iterated expression is a use.
*/
loopHeadFacts(headNode: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(headNode.startPosition.row + 1);
const left = headNode.childForFieldName('left');
const right = headNode.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) this.defTargets(left, acc);
return acc.finish();
}
/** Facts for a `with_item`: the `as` alias is a def, the value an use. */
withItemFacts(item: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(item.startPosition.row + 1);
const value = item.childForFieldName('value') ?? item.namedChild(0);
if (!value) return acc.finish();
if (value.type === 'as_pattern') {
const inner = value.namedChild(0);
if (inner) this.walkValue(inner, acc);
const alias = value.childForFieldName('alias') ?? this.asPatternTarget(value);
if (alias) this.defAsTarget(alias, acc);
} else {
this.walkValue(value, acc);
}
return acc.finish();
}
/** Facts for an `except E as e:` header: `e` is a def, `E` a use. */
exceptHeadFacts(clause: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(clause.startPosition.row + 1);
const block = clause.namedChildren.find((c) => c.type === 'block');
for (let i = 0; i < clause.namedChildCount; i++) {
const c = clause.namedChild(i);
if (!c || c.id === block?.id) continue;
if (c.type === 'as_pattern') {
const exc = c.namedChild(0);
if (exc) this.walkValue(exc, acc);
const alias = c.childForFieldName('alias') ?? this.asPatternTarget(c);
if (alias) this.defAsTarget(alias, acc);
} else {
this.walkValue(c, acc);
}
}
return acc.finish();
}
/** ENTRY-block facts for the parameters (defs only — incl. default-value uses). */
paramFacts(): StatementFacts | undefined {
const params =
this.fnNode.childForFieldName('parameters') ??
this.fnNode.namedChildren.find(
(c) => c.type === 'parameters' || c.type === 'lambda_parameters',
);
if (!params) return undefined;
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p) this.defParam(p, acc);
}
return acc.defCount() || acc.finish().uses.length ? acc.finish() : undefined;
}
/** Def the binder(s) of one parameter node and use any default-value expr. */
private defParam(p: SyntaxNode, acc: FactAccumulator): void {
switch (p.type) {
case 'identifier':
this.def(p, acc);
return;
case 'default_parameter':
case 'typed_default_parameter': {
const value = p.childForFieldName('value');
if (value) this.walkValue(value, acc);
const name = p.childForFieldName('name');
if (name) this.defParamBinder(name, acc);
return;
}
case 'typed_parameter': {
const typeNode = p.childForFieldName('type');
for (let i = 0; i < p.namedChildCount; i++) {
const c = p.namedChild(i);
if (c && c.id !== typeNode?.id) {
this.defParamBinder(c, acc);
break;
}
}
return;
}
case 'list_splat_pattern':
case 'dictionary_splat_pattern':
this.defParamBinder(p, acc);
return;
default:
this.defParamBinder(p, acc);
}
}
private defParamBinder(node: SyntaxNode, acc: FactAccumulator): void {
if (node.type === 'identifier') {
this.def(node, acc);
return;
}
if (node.type === 'list_splat_pattern' || node.type === 'dictionary_splat_pattern') {
const id = node.namedChild(0);
if (id?.type === 'identifier') this.def(id, acc);
return;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
if (!this.globalNames.has(name)) {
const idx = this.table.get(name);
if (idx !== undefined) return idx;
}
let idx = this.synthetic.get(name);
if (idx === undefined) {
idx = this.bindings.length;
this.synthetic.set(name, idx);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return idx;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return; // blank target defines nothing of interest
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
acc.addUse(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
private conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
/**
* Def each identifier/splat leaf of an assignment / loop target pattern; route
* attribute / subscript targets to the value walk (root identifier is a use).
*/
private defTargets(target: SyntaxNode, acc: FactAccumulator): void {
const t = target.type;
if (t === 'identifier') {
this.def(target, acc);
return;
}
if (PATTERN_LIST_TYPES.has(t)) {
for (let i = 0; i < target.namedChildCount; i++) {
const c = target.namedChild(i);
if (c) this.defTargets(c, acc);
}
return;
}
if (t === 'list_splat_pattern') {
const id = target.namedChild(0);
if (id?.type === 'identifier') this.def(id, acc);
else if (id) this.defTargets(id, acc);
return;
}
// attribute / subscript / call target — uses only (the root identifier).
this.walkValue(target, acc);
}
/** Def an `as_pattern_target` (or its identifier child). */
private defAsTarget(target: SyntaxNode, acc: FactAccumulator): void {
if (target.type === 'identifier') {
this.def(target, acc);
return;
}
const inner = target.namedChild(0);
if (inner?.type === 'identifier') this.def(inner, acc);
else if (inner) this.defTargets(inner, acc);
else this.def(target, acc);
}
/** Value-position walk: collect uses; route def positions to the target handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; // opaque
switch (t) {
case 'identifier':
this.use(node, acc);
return;
case 'assignment': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) this.defTargets(left, acc);
return;
}
case 'augmented_assignment': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) {
// `x += v` reads AND writes a plain-identifier lvalue.
if (left.type === 'identifier') {
this.use(left, acc);
this.def(left, acc);
} else {
this.walkValue(left, acc); // attribute/subscript lvalue — use only
}
}
return;
}
case 'named_expression': {
// walrus `(n := v)` — `n` is a def, `v` a use.
const name = node.childForFieldName('name');
const value = node.childForFieldName('value');
if (value) this.walkValue(value, acc);
if (name?.type === 'identifier') this.def(name, acc);
return;
}
case 'boolean_operator': {
// `a or b` / `a and b` — the right operand is conditionally evaluated, so
// any def inside it (a walrus) is a may-def; uses are still recorded.
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left) this.walkValue(left, acc);
if (right) this.conditional(() => this.walkValue(right, acc));
return;
}
case 'conditional_expression': {
// `consequent if condition else alternative`. The condition always
// evaluates; each arm is conditional (its walrus defs are may-defs).
const children = node.namedChildren;
const [consequent, condition, alternative] = children;
if (condition) this.walkValue(condition, acc);
if (consequent) this.conditional(() => this.walkValue(consequent, acc));
if (alternative) this.conditional(() => this.walkValue(alternative, acc));
return;
}
case 'attribute': {
// `a.b` — value read of the operand root only; the attribute name is not
// a scalar binding.
const obj = node.childForFieldName('object');
if (obj) this.walkValue(obj, acc);
return;
}
case 'subscript': {
// `a[i]` — both the container root and the index are uses.
const value = node.childForFieldName('value');
const sub = node.childForFieldName('subscript');
if (value) this.walkValue(value, acc);
if (sub) this.walkValue(sub, acc);
return;
}
case 'call': {
// Reproduce default-descent uses: callee chain + arguments. No site
// record (taint substrate is a later step).
const fn = node.childForFieldName('function');
const args = node.childForFieldName('arguments');
if (fn) this.walkValue(fn, acc);
if (args) this.walkValue(args, acc);
return;
}
case 'list_comprehension':
case 'set_comprehension':
case 'dictionary_comprehension':
case 'generator_expression': {
this.walkComprehension(node, acc);
return;
}
case 'keyword_argument': {
// `f(k=v)` — `k` is a parameter name, not a use; only `v` is a use.
const value = node.childForFieldName('value');
if (value) this.walkValue(value, acc);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
/**
* A comprehension (`[body for t in src if g]`): each `for_in_clause` target is
* a def, its source a use; the `body` and any `if_clause` are uses. Kept INLINE
* (no separate CFG blocks) per the plan — the target binding is harvested so it
* is a real def. The `for_in_clause` source is walked in the ENCLOSING context
* (it reads outer names), the body/filter after the targets are bound.
*/
private walkComprehension(node: SyntaxNode, acc: FactAccumulator): void {
const body = node.childForFieldName('body');
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (!c) continue;
if (c.type === 'for_in_clause') {
const left = c.childForFieldName('left');
const right = c.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) this.defTargets(left, acc);
} else if (c.id !== body?.id) {
// `if_clause` filter (and any other auxiliary clause) — uses only.
this.walkValue(c, acc);
}
}
if (body) this.walkValue(body, acc);
}
}

View file

@ -0,0 +1,775 @@
/**
* Python CfgVisitor — the most STRUCTURALLY DIVERGENT CFG target (PDG layer
* beyond the C-family). Python has no braces (suites are indented `block` nodes),
* `elif` clauses, `for`/`while ... else` (the else runs on NORMAL completion, not
* on `break`), `with` (deterministic `__exit__` on both normal AND exception
* exit — a try/finally analogue), `try` / `except` / `except-group` (except-star)
* / `else` / `finally`, and `match`/`case` (no fallthrough). A good stress test
* that the shared CFG core
* carries no hidden brace-family assumptions.
*
* Walks a Python `function_definition` / `lambda`'s tree-sitter AST and drives
* the language-agnostic {@link CfgBuilder} to produce a serializable
* {@link FunctionCfg}, plus a def/use harvest ({@link PythonHarvester}) for the
* reaching-defs / CDG solvers. Structured like the C-family visitors — a
* `visit_<node_type>` dispatch over the statement taxonomy, driving a
* per-function {@link ControlFlowContext} for break/continue and the `with` /
* `finally` completion chain (finalizer route-through). NO call-site `sites[]`
* are harvested (taint substrate is a later step — see python-harvest.ts).
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-python (0.23.x) via the introspection probe before use (mandatory
* pre-step). Python shapes pre-empted (verified by a real parse):
* - functions: `function_definition` (fields `name`/`parameters`/`body`; async
* is the SAME node with an `async` token child), `lambda` (fields
* `parameters`/`body`; the body is an EXPRESSION, not a `block`).
* - `if_statement` fields `condition`/`consequence` plus ZERO-OR-MORE
* `alternative` fields, each an `elif_clause` (fields `condition`/`consequence`)
* or an `else_clause` (field `body`) — Python has no nested-`if` else chain.
* - `for_statement` fields `left`/`right`/`body` + optional `alternative`
* (`else_clause`); `while_statement` fields `condition`/`body` + optional
* `alternative` (`else_clause`). The loop `else` runs on the cond-false /
* normal-completion path, NOT on `break`.
* - `with_statement` field `body`; a `with_clause` of `with_item`s (field
* `value` = `as_pattern` or a bare expression). `__exit__` runs on normal AND
* exception exit — modeled as a finalizer (try/finally analogue).
* - `try_statement` field `body`; children `except_clause` /
* `except_group_clause` (each holds the exception expr/`as_pattern` + a
* `block`), `else_clause` (field `body`, runs if NO exception), and
* `finally_clause` (holds a `block`).
* - `match_statement` fields `subject`/`body`; the `body` `block` holds
* `case_clause`s (field `alternative`), each with `case_pattern` child(ren),
* an optional `guard` (`if_clause`), and a `consequence` `block`. No
* fallthrough between cases.
* - `return_statement` / `raise_statement` / `break_statement` /
* `continue_statement` / `pass_statement`.
*
* Edge-kind contract (matches the existing visitors — RD/CDG consume these):
* - if/elif/else → `cond-true` / `cond-false`
* - for/while → `cond-true` / `loop-back` / `cond-false`; the loop `else` runs
* on the `cond-false` / normal-completion path (NOT on `break`)
* - match dispatch → `switch-case` (no fallthrough — like Go's switch)
* - try/except → `throw` (every protected block → each except handler)
* - a `break`/`continue`/`return` crossing a `with` `__exit__` or a `finally`
* threads through as `break`/`continue`/`return` (first leg) +
* `finally-break`/`finally-continue`/`finally-return` (each completion leg)
* - return/raise/break/continue → the matching terminator kind
* - straight-line → `seq`
*
* Python-specific modeling decisions (documented approximations):
* - `with EXPR as t:` runs the body then `__exit__` deterministically on BOTH
* the normal exit and an exception (it can suppress the exception, but the
* common case re-raises). Modeled exactly like `try/finally`: a finalizer
* frame for the body's exit-dispose, with the protected body edging the
* dispose block on `throw`. APPROXIMATION: exception SUPPRESSION by a context
* manager is not modeled (the dispose re-propagates), the sound direction.
* - the loop `else` clause runs once on normal completion (the loop ran to
* exhaustion without `break`). It sits on the `cond-false` edge BEFORE the
* join; a `break` targets the loop exit AFTER the else, so `break` skips it.
* - `match`/`case` cases do NOT fall through (like Go). The dispatch fans a
* `switch-case` edge to each case body; a guarded / non-wildcard tail with no
* `case _` also reaches the join directly (no-match path), keeping EXIT
* reverse-reachable.
* - `while True:` (and any loop with no statically-false exit) STILL emits the
* structural `header → loopExit` `cond-false` edge — exactly like the
* C-family visitors — so EXIT stays reverse-reachable and the post-dominator /
* CDG pass is not silently skipped for the function. Highest-risk property.
* - `lambda` has an EXPRESSION body (no `block`): one block whose value is the
* returned expression.
* - comprehensions are harvested for their target bindings but kept INLINE (no
* separate CFG blocks) — the plan's explicit choice.
*
* Known limitations:
* - async (`async def`, `await`, `async for`, `async with`) is the same node
* shape as the sync form plus an `async` token; the suspension points are
* modeled as normal straight-line control flow (no scheduler edges).
* - generators (`yield` / `yield from`): a `yield` is a normal expression here
* (no suspend/resume edge); the generator's resumption flow is not modeled.
* - comprehension target scoping: comprehension targets are declared in the
* single function table (Python 3 gives them their own scope; the leaked-name
* distinction is not modeled) — see python-harvest.ts.
* - context-manager exception SUPPRESSION and `recover`-style flow are not
* modeled (the `with` dispose always re-propagates).
*
* Returns `undefined` (never throws) for an AST shape it cannot model, so a
* malformed function never drops the whole file's CFG group (R4).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import {
ControlFlowContext,
drainFinalizerPending,
wireJumpThroughFinalizers,
} from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { PythonHarvester } from './python-harvest.js';
/** Python node types that own a CFG-bearing function body. */
const PY_FUNCTION_TYPES = new Set(['function_definition', 'lambda']);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_statement',
'for_statement',
'while_statement',
'with_statement',
'try_statement',
'match_statement',
'return_statement',
'raise_statement',
'break_statement',
'continue_statement',
'block',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function Python walk state. One instance per function so the
* {@link ControlFlowContext}, the exception-handler stack, and the `with` /
* `finally` finalizer chain are scoped to that function and never leak across
* functions.
*/
class PythonCfgWalk {
private readonly cfc = new ControlFlowContext();
/** Stack of exception-handler entry blocks (except/finally/with-dispose) a `raise` jumps to. */
private readonly handlers: number[] = [];
constructor(
private readonly builder: CfgBuilder,
private readonly harvest: PythonHarvester,
) {}
/** Statements of a block node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => c.type !== 'comment');
}
/** The `body` block of a node (field, or the first `block` child). */
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'block');
}
/** Visit a body that may be a `block` or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
return this.builder.withNesting(() => {
if (!node) return null;
if (node.type === 'block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
});
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
return this.builder.withNesting(() => {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
if (openSimple === undefined) {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
} else {
this.builder.extendBlock(
openSimple,
endLineOf(stmt),
stmt.text,
this.harvest.facts(stmt),
);
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
});
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'while_statement':
return this.visitWhile(stmt);
case 'with_statement':
return this.visitWith(stmt);
case 'try_statement':
return this.visitTry(stmt);
case 'match_statement':
return this.visitMatch(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'raise_statement':
return this.visitRaise(stmt);
case 'break_statement':
return this.visitBreak(stmt);
case 'continue_statement':
return this.visitContinue(stmt);
case 'block':
return this.visitSeq(this.statementsOf(stmt));
default:
return this.visitSimple(stmt);
}
}
private visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
return { entry: idx, exits: [idx] };
}
/** `return [expr]` — threads through EVERY active `with`/`finally` before EXIT. */
private visitReturn(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
wireJumpThroughFinalizers(
this.builder,
idx,
this.cfc.finalizersForReturn(),
this.builder.exitIndex,
'return',
);
return { entry: idx, exits: [] };
}
/** `raise [expr]` — jumps to the nearest handler (except / with-dispose / EXIT). */
private visitRaise(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
this.builder.edge(idx, this.currentHandler(), 'throw');
return { entry: idx, exits: [] };
}
private visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const res = this.cfc.resolveBreak();
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break');
return { entry: idx, exits: [] };
}
private visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const res = this.cfc.resolveContinue();
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue');
return { entry: idx, exits: [] };
}
/**
* `if cond: … elif cond: … else: …`. Python has NO nested-if else chain: an
* `if_statement` carries the condition + consequence plus zero-or-more
* `alternative` fields, each an `elif_clause` (its own condition + consequence)
* or a single trailing `else_clause`. The elif chain is threaded on the
* `cond-false` edge.
*/
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
if (thenRes) {
this.builder.edge(header, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(header); // empty then — true path falls through
}
// The alternatives, in source order: elif_clause* then optional else_clause.
const alternatives = this.alternativesOf(stmt);
let falseFrom = header; // block whose cond-false edge feeds the next alternative
for (const alt of alternatives) {
if (alt.type === 'elif_clause') {
const elifCond = alt.childForFieldName('condition') ?? alt;
const elifHeader = this.builder.newBlock(
startLineOf(alt),
endLineOf(elifCond),
elifCond.text,
'normal',
this.harvest.facts(elifCond),
);
this.builder.edge(falseFrom, elifHeader, 'cond-false');
const elifRes = this.visitBody(alt.childForFieldName('consequence'));
if (elifRes) {
this.builder.edge(elifHeader, elifRes.entry, 'cond-true');
exits.push(...elifRes.exits);
} else {
exits.push(elifHeader);
}
falseFrom = elifHeader;
} else if (alt.type === 'else_clause') {
const elseRes = this.visitBody(alt.childForFieldName('body'));
if (elseRes) {
this.builder.edge(falseFrom, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(falseFrom);
}
falseFrom = -1; // an else consumes the false path entirely
}
}
// No trailing else: the last header's cond-false falls through to the join.
if (falseFrom >= 0) exits.push(falseFrom);
return { entry: header, exits: [...new Set(exits)] };
}
/** The `alternative`-field children of an `if_statement`, in source order. */
private alternativesOf(stmt: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < stmt.childCount; i++) {
if (stmt.fieldNameForChild(i) === 'alternative') {
const c = stmt.child(i);
if (c) out.push(c);
}
}
return out;
}
/**
* `for TARGET in ITER: … [else: …]`. Header = the iteration test (a use of the
* iterable + a def of the target). The loop `else` runs on NORMAL completion
* (the cond-false path) — a `break` targets the loop exit AFTER the else, so it
* skips the else.
*/
private visitFor(stmt: SyntaxNode): TraversalResult {
const left = stmt.childForFieldName('left');
const right = stmt.childForFieldName('right');
const headEnd = right ? endLineOf(right) : startLineOf(stmt);
const header = this.builder.newBlock(
startLineOf(stmt),
headEnd,
this.loopHeaderText(stmt, left, right),
'normal',
this.harvest.loopHeadFacts(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, []);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
}
this.wireLoopElse(stmt, header, loopExit);
return { entry: header, exits: [loopExit] };
}
/** `while cond: … [else: …]`. Same `else`-on-normal-completion semantics as `for`. */
private visitWhile(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, []);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty `while c: pass` re-tests
}
this.wireLoopElse(stmt, header, loopExit);
return { entry: header, exits: [loopExit] };
}
/**
* Wire the optional loop `else` clause. The else runs once on normal completion
* (the header's `cond-false` edge). With an else, the cond-false edge goes
* `header → elseEntry` and the else's exits reach `loopExit`; without one, the
* structural `header → loopExit` `cond-false` edge keeps EXIT reverse-reachable
* (critical for `while True:` — and matches the C-family visitors). A `break`
* always targets `loopExit` directly, so it never runs the else.
*/
private wireLoopElse(stmt: SyntaxNode, header: number, loopExit: number): void {
const elseClause = this.loopElseOf(stmt);
if (elseClause) {
const elseRes = this.visitBody(elseClause.childForFieldName('body'));
if (elseRes) {
this.builder.edge(header, elseRes.entry, 'cond-false');
this.builder.connect(elseRes.exits, loopExit, 'seq');
return;
}
}
// No (or empty) else — normal completion falls straight to the loop exit.
this.builder.edge(header, loopExit, 'cond-false');
}
/** The `else_clause` of a `for`/`while` (its `alternative` field), if any. */
private loopElseOf(stmt: SyntaxNode): SyntaxNode | undefined {
const alt = stmt.childForFieldName('alternative');
return alt?.type === 'else_clause' ? alt : undefined;
}
private loopHeaderText(
stmt: SyntaxNode,
left: SyntaxNode | null,
right: SyntaxNode | null,
): string {
const l = left?.text ?? '';
const r = right?.text ?? '';
return l || r ? `for ${l} in ${r}` : stmt.text.split('\n')[0];
}
/**
* `with EXPR [as t], …: BODY`. The context managers' `__exit__` runs
* deterministically on BOTH the normal exit and an exception — modeled exactly
* like `try/finally`: a finalizer frame holding the dispose block, plus a
* `throw` edge from every protected-body block to the dispose. A
* `return`/`break`/`continue` inside the body threads through the dispose. The
* dispose re-propagates on the exception path (suppression is not modeled).
*/
private visitWith(stmt: SyntaxNode): SeqResult {
// The dispose block carries the `with`-header facts (the `as` aliases are
// defs, the manager expressions uses) — it runs on every exit, so attaching
// the binding facts here is the single execution point of the bindings.
const items = this.withItems(stmt);
const dispose = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.withHeaderText(stmt),
);
for (const item of items) this.builder.attachFacts(dispose, this.harvest.withItemFacts(item));
const finFrame = this.cfc.pushFinalizer(dispose);
// The body raises into the dispose (which re-propagates to the outer handler).
this.handlers.push(dispose);
const protectedStart = this.builder.blockCount;
const body = this.visitBody(this.bodyBlockOf(stmt));
this.handlers.pop();
// Conservative exceptional edges: ANY block in the with-body may raise to the
// dispose (an exception fires mid-block) — sound over-approximation.
for (let b = protectedStart; b < this.builder.blockCount; b++) {
this.builder.edge(b, dispose, 'throw');
}
this.cfc.pop();
drainFinalizerPending(this.builder, finFrame, [dispose]);
// Normal completion of the body flows into the dispose; the dispose's normal
// exit is the with-statement's exit. The dispose re-propagates the exception
// path to the OUTER handler (a CM normally re-raises).
if (body) this.builder.connect(body.exits, dispose, 'seq');
this.builder.edge(dispose, this.currentHandler(), 'throw');
const entry = body?.entry ?? dispose;
return { entry, exits: [dispose] };
}
/** The `with_item`s of a `with_statement` (under its `with_clause`). */
private withItems(stmt: SyntaxNode): SyntaxNode[] {
const clause = stmt.namedChildren.find((c) => c.type === 'with_clause');
if (!clause) return [];
return clause.namedChildren.filter((c) => c.type === 'with_item');
}
private withHeaderText(stmt: SyntaxNode): string {
const clause = stmt.namedChildren.find((c) => c.type === 'with_clause');
return clause ? `with ${clause.text}` : 'with';
}
/**
* `try: BODY [except …: H]* [else: E] [finally: F]`. Mirrors the TS visitor's
* try-route-through:
* - `finally` runs on every exit (normal, exception, and early jumps) — a
* finalizer frame for early-exit threading + a normal/exceptional join.
* - each `except` / except-group handler catches from the protected body.
* - `else` runs only if the body completed with no exception.
*/
private visitTry(stmt: SyntaxNode): SeqResult {
const bodyNode = stmt.childForFieldName('body');
const exceptClauses: SyntaxNode[] = [];
let elseClause: SyntaxNode | undefined;
let finallyClause: SyntaxNode | undefined;
for (let i = 0; i < stmt.namedChildCount; i++) {
const c = stmt.namedChild(i);
if (!c) continue;
if (c.type === 'except_clause' || c.type === 'except_group_clause') exceptClauses.push(c);
else if (c.type === 'else_clause') elseClause = c;
else if (c.type === 'finally_clause') finallyClause = c;
}
// Build finally first — known as both a normal join and a handler target. It
// runs OUTSIDE this try's finalizer frame (a return inside finally threads
// only OUTER finallys).
const finallyBlock = finallyClause
? (this.bodyBlockOf(finallyClause) ??
finallyClause.namedChildren.find((c) => c.type === 'block'))
: undefined;
const finallyRes = finallyBlock ? this.visitSeq(this.statementsOf(finallyBlock)) : null;
const finFrame = finallyRes ? this.cfc.pushFinalizer(finallyRes.entry) : null;
// Each except handler. A `raise` inside a handler propagates to finally (if
// any), else the outer handler.
const handlerEntries: number[] = [];
const handlerExits: number[] = [];
for (const clause of exceptClauses) {
if (finallyRes) this.handlers.push(finallyRes.entry);
const handlerBlock = clause.namedChildren.find((c) => c.type === 'block');
// The `except E as e:` header binds `e` — its own facts-only block in front
// of the handler body (the binding happens once, on handler entry).
const headFacts = this.harvest.exceptHeadFacts(clause);
const headBlock = this.builder.newBlock(
startLineOf(clause),
startLineOf(clause),
'',
'normal',
headFacts,
);
const bodyRes = handlerBlock ? this.visitSeq(this.statementsOf(handlerBlock)) : null;
if (bodyRes) {
this.builder.edge(headBlock, bodyRes.entry, 'seq');
handlerExits.push(...bodyRes.exits);
} else {
handlerExits.push(headBlock); // empty handler body — header is the exit
}
handlerEntries.push(headBlock);
if (finallyRes) this.handlers.pop();
}
// Handler for the try body: the first except if present, else finally, else
// the outer handler.
const tryHandler = handlerEntries[0] ?? finallyRes?.entry ?? this.currentHandler();
const protectedStart = this.builder.blockCount;
this.handlers.push(tryHandler);
const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null;
this.handlers.pop();
// Conservative exceptional edges: ANY protected-region block may raise to
// EACH handler (an unmatched exception type tries the next handler).
if (exceptClauses.length > 0 || finallyClause) {
const targets =
handlerEntries.length > 0 ? handlerEntries : finallyRes ? [finallyRes.entry] : [];
for (let b = protectedStart; b < this.builder.blockCount; b++) {
for (const h of targets) this.builder.edge(b, h, 'throw');
}
}
// The `else` runs only on no-exception normal completion of the body.
let normalAfterBody: number[] = bodyRes ? [...bodyRes.exits] : [];
if (elseClause) {
const elseRes = this.visitBody(elseClause.childForFieldName('body'));
if (elseRes && bodyRes) {
this.builder.connect(bodyRes.exits, elseRes.entry, 'seq');
normalAfterBody = [...elseRes.exits];
} else if (elseRes) {
normalAfterBody = [...elseRes.exits];
}
}
// Close the finalizer frame; wire crossing-jump completion legs.
if (finFrame && finallyRes) {
this.cfc.pop();
drainFinalizerPending(this.builder, finFrame, finallyRes.exits);
}
const exits: number[] = [];
if (finallyRes) {
// Normal completion of (body→else) AND each handler flows through finally.
this.builder.connect(normalAfterBody, finallyRes.entry, 'seq');
this.builder.connect(handlerExits, finallyRes.entry, 'seq');
exits.push(...finallyRes.exits);
// A try with no except → an uncaught exception re-propagates after finally.
if (handlerEntries.length === 0) {
this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw');
}
} else {
exits.push(...normalAfterBody);
exits.push(...handlerExits);
}
const entry = bodyRes?.entry ?? finallyRes?.entry ?? handlerEntries[0];
if (entry === undefined) return null;
return { entry, exits: [...new Set(exits)] };
}
/**
* `match SUBJECT: case P [if guard]: BODY …`. Cases do NOT fall through (like
* Go's switch). Each case body is dispatched from the subject block with a
* `switch-case` edge; a `match` with no `case _` wildcard also reaches the join
* directly (no-match path), keeping EXIT reverse-reachable.
*/
private visitMatch(stmt: SyntaxNode): TraversalResult {
const subject = stmt.childForFieldName('subject');
const dispatch = this.builder.newBlock(
startLineOf(stmt),
subject ? endLineOf(subject) : startLineOf(stmt),
subject ? `match ${subject.text}` : 'match',
'normal',
subject ? this.harvest.facts(subject) : undefined,
);
const matchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
const body =
stmt.childForFieldName('body') ?? stmt.namedChildren.find((c) => c.type === 'block');
const cases = body ? body.namedChildren.filter((c) => c.type === 'case_clause') : [];
// A case guard (`case P if g:`) evaluates conditionally — harvest its uses
// onto the dispatch block (a later case only tests when earlier patterns
// didn't match; defs there are may-defs).
for (const c of cases) {
const guard = c.childForFieldName('guard');
if (guard) this.builder.attachFacts(dispatch, this.harvest.factsConditional(guard));
}
this.cfc.pushSwitch(matchExit, []);
let hasWildcard = false;
for (const c of cases) {
const caseBody = this.visitBody(c.childForFieldName('consequence'));
const entry = caseBody?.entry ?? matchExit;
this.builder.edge(dispatch, entry, 'switch-case');
if (caseBody) this.builder.connect(caseBody.exits, matchExit, 'seq');
if (this.isWildcardCase(c)) hasWildcard = true;
}
this.cfc.pop();
// No catch-all `case _` (or no cases) → a no-match path reaches the exit
// directly. Keeps EXIT reverse-reachable even when every case body jumps.
if (!hasWildcard) this.builder.edge(dispatch, matchExit, 'switch-case');
return { entry: dispatch, exits: [matchExit] };
}
/** A `case _:` (bare wildcard with no guard) is the unconditional catch-all. */
private isWildcardCase(caseClause: SyntaxNode): boolean {
if (caseClause.childForFieldName('guard')) return false;
const pattern = caseClause.namedChildren.find((c) => c.type === 'case_pattern');
return pattern?.text.trim() === '_';
}
/** Nearest enclosing exception handler, or the function EXIT. */
private currentHandler(): number {
return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex;
}
}
/** Build the CFG for one Python function/lambda node, or `undefined` if not modelable. */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
try {
if (!PY_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
const body = fnNode.childForFieldName('body');
if (!body) return undefined; // no body — nothing to model
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const harvest = new PythonHarvester(fnNode);
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
if (fnNode.type === 'lambda' || body.type !== 'block') {
// `lambda x: expr` — the body is an EXPRESSION (no `block`): one block whose
// value is returned. Threads through no finally (a lambda has none).
const blk = builder.newBlock(
startLineOf(body),
endLineOf(body),
body.text,
'normal',
harvest.facts(body),
);
builder.edge(builder.entryIndex, blk, 'seq');
builder.edge(blk, builder.exitIndex, 'return');
return builder.finish(harvest.bindingTable());
}
const walk = new PythonCfgWalk(builder, harvest);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish(harvest.bindingTable());
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish(harvest.bindingTable());
} catch (err) {
// Never throw out of buildFunctionCfg — a malformed AST shape must skip only
// this one function's CFG, never drop the whole file's language group (R4).
// eslint-disable-next-line no-console
console.warn(`[cfg] Python buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`);
return undefined;
}
}
/** Whether a node is a Python function this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return PY_FUNCTION_TYPES.has(node.type);
}
/** The Python CFG visitor. */
export function createPythonCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { PY_FUNCTION_TYPES };

View file

@ -0,0 +1,482 @@
/**
* Ruby def/use harvester — the Ruby analogue of
* {@link import('./python-harvest.js').PythonHarvester} (the closest structural
* sibling: implicit/keyword-delimited blocks, statement-modifier forms, a
* begin/rescue/else/ensure exception model, and `case`/`when` + `case`/`in`
* pattern matching). Like the Python harvester, this unit emits ONLY the
* per-function binding table ({@link BindingEntry}[]) plus {@link StatementFacts}
* (defs / uses / mayDefs) — NO call-site `sites[]` are harvested (the taint
* substrate is a later step), so it uses a local {@link FactAccumulator} with no
* site machinery at all and the emitted facts carry no `sites` key.
*
* Runs in the parse worker next to the Ruby CFG visitor.
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-ruby via the introspection probe before use (mandatory pre-step).
* Ruby shapes pre-empted (verified by a real parse):
* - functions: `method` / `singleton_method` (fields `name`/`parameters`/`body`;
* `parameters` is a `method_parameters`; `body` is a `body_statement`), and
* blocks `do_block` (`body` = `body_statement`) / `block` (`body` =
* `block_body`) / `lambda` (`body` = a `block` wrapping a `block_body`) — each
* has a `parameters` (`block_parameters` / `lambda_parameters`).
* - parameters: bare `identifier`, `optional_parameter` (fields `name`/`value`),
* `splat_parameter` / `hash_splat_parameter` / `block_parameter` /
* `keyword_parameter` (field `name`).
* - assignment: `assignment` (fields `left`/`right`; LHS may be `identifier`,
* `left_assignment_list` (multi `a, b = …`), `instance_variable` (`@x`),
* `class_variable` (`@@x`), `global_variable` (`$x`), `constant`),
* `operator_assignment` (fields `left`/`operator`/`right` — read+write).
* - binders: `for` (fields `pattern`/`value`=`in`/`body`), block `parameters`
* (`block_parameters` of identifier / optional / splat leaves), rescue
* `variable` (an `exception_variable` wrapping the bound `identifier`).
* - reads: `call` (fields `receiver`?/`method`/`arguments`), `binary` (fields
* `left`/`operator`/`right`), `parenthesized_statements`.
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Python / TS / Go
* harvesters): the CFG walk is NOT source-order, so resolving names against a
* scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans
* the whole function subtree once, declaring every in-function local name; phase
* 2 resolves defs/uses against that finished table from any walk order.
*
* Ruby scope model (deliberately simplified, documented): Ruby binds LOCAL
* variables on first assignment; block parameters and block-local variables have
* their own block scope, but — exactly as the Python harvester declares all
* targets in a SINGLE function table (a documented over-approximation) — this
* harvester declares every assignment / for / block-param / rescue-variable /
* method-param target into one function-scope table. Instance/class/global
* variables (`@x` / `@@x` / `$x`) and bare constants are NOT local variables: a
* read or write of one is recorded as a use only (an attribute-like write — its
* "name" is not a function-scoped scalar def), matching the TS/Python member-
* write exclusion. A bare method call with no parens (`foo`) is indistinguishable
* from a local read at this layer; we resolve such an identifier against the
* local table and only mint a SYNTHETIC module binding when it is unknown (the
* conservative direction — a real method call resolves to a `module` synthetic,
* never a false local def).
*
* v1 def-semantics scope:
* - `assignment` plain `=` — each `identifier` target in the (possibly
* `left_assignment_list`) LHS is a def; an `@x`/`@@x`/`$x`/`Const` target or
* an index/attribute target is NOT a scalar def (root is a use only).
* - `operator_assignment` (`x += 1`, `x ||= y`) — def AND use the lvalue.
* - `for x in xs` / `for a, b in xs` — the loop target(s) are defs; `xs` a use.
* - block params (`|x|`, `|x, y|`, `|*rest|`) — `param`-kind defs.
* - `rescue ... => e` — `e` is a `catch`-kind def (matters to the taint pass).
* - method parameters (incl. defaults, `*splat`, `**kwsplat`, `&block`,
* keyword) — `param`-kind defs.
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression is a may-def
* (gen WITHOUT kill), so the not-taken path's prior def is not falsely killed.
* Ruby's conditional-def shapes: an assignment in the right operand of `&&`/`and`
* / `||`/`or` short-circuit (`a && (x = 1)`), and a `when`/`in` case-test
* expression / `in`-clause guard (a later case only evaluates when earlier
* patterns did not match).
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'method',
'singleton_method',
'do_block',
'block',
'lambda',
]);
/** Parameter container node types (method + block + lambda). */
const PARAM_CONTAINER_TYPES = new Set([
'method_parameters',
'block_parameters',
'lambda_parameters',
]);
/** Parameter leaf node types whose `name` field (or bare identifier) is the binder. */
const NAMED_PARAM_TYPES = new Set([
'optional_parameter',
'splat_parameter',
'hash_splat_parameter',
'block_parameter',
'keyword_parameter',
]);
export class RubyHarvester {
private readonly bindings: BindingEntry[] = [];
/** Single function-scope name → binding index (documented over-approximation). */
private readonly table = new Map<string, number>();
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
/** The function/block/lambda body node. */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
return fnNode.childForFieldName('body') ?? undefined;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void {
const name = nameNode.text;
if (!name || name === '_' || this.table.has(name)) return;
this.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/** Declare every parameter binder (method / block / lambda). */
private declareParams(fnNode: SyntaxNode): void {
const params = this.paramsOf(fnNode);
if (!params) return;
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p) this.declareParam(p);
}
}
/** The `parameters` field (or first parameter-container child) of a function node. */
private paramsOf(fnNode: SyntaxNode): SyntaxNode | undefined {
const field = fnNode.childForFieldName('parameters');
if (field) return field;
return fnNode.namedChildren.find((c) => PARAM_CONTAINER_TYPES.has(c.type));
}
/** Declare the binder identifier of one parameter node. */
private declareParam(p: SyntaxNode): void {
if (p.type === 'identifier') {
this.declare(p, 'param');
return;
}
if (NAMED_PARAM_TYPES.has(p.type)) {
const name =
p.childForFieldName('name') ?? p.namedChildren.find((c) => c.type === 'identifier');
if (name) this.declare(name, 'param');
return;
}
// Destructured / grouped block param — declare any identifier leaves.
for (let i = 0; i < p.namedChildCount; i++) {
const c = p.namedChild(i);
if (c?.type === 'identifier') this.declare(c, 'param');
else if (c) this.declareParam(c);
}
}
/**
* Pre-scan the function body once, declaring every in-function local name.
* Recurses into compound statements but NOT into nested function/block/lambda
* bodies (opaque).
*/
private prescan(node: SyntaxNode): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return;
switch (t) {
case 'assignment': {
const left = node.childForFieldName('left');
if (left) this.declareTargets(left);
break;
}
case 'operator_assignment': {
const left = node.childForFieldName('left');
if (left) this.declareTargets(left);
break;
}
case 'for': {
const pattern = node.childForFieldName('pattern');
if (pattern) this.declareTargets(pattern);
break;
}
case 'rescue': {
this.declareRescueVar(node);
break;
}
default:
break;
}
// A nested `do_block`/`block`/`lambda` body is opaque, but its OWN block
// parameters are declared (they are not local to THIS function, yet the
// single-table model harvests them where used) — handled by declareParam in
// the visitor's per-block harvester instance, not here. We only recurse to
// collect assignment/for/rescue local targets in THIS function body.
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c);
}
}
/** `rescue [Exc] => e` — declare `e` (a `catch`-kind def). */
private declareRescueVar(clause: SyntaxNode): void {
const variable = clause.childForFieldName('variable');
const id = variable?.namedChildren.find((c) => c.type === 'identifier') ?? variable;
if (id?.type === 'identifier') this.declare(id, 'catch');
}
/** Declare identifier leaves of an assignment / loop target (skip non-local LHS). */
private declareTargets(target: SyntaxNode): void {
const t = target.type;
if (t === 'identifier') {
this.declare(target, 'let');
return;
}
if (t === 'left_assignment_list') {
for (let i = 0; i < target.namedChildCount; i++) {
const c = target.namedChild(i);
if (c) this.declareTargets(c);
}
return;
}
if (t === 'splat_parameter' || t === 'rest_assignment') {
const id = target.namedChildren.find((c) => c.type === 'identifier');
if (id) this.declare(id, 'let');
return;
}
// instance/class/global var, constant, element/attribute target — not a
// function-scoped scalar def (the root identifier is a use only).
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (case tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Facts for a `for PATTERN in VALUE` head: the loop target(s) are defs, the
* iterated expression is a use.
*/
loopHeadFacts(forNode: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(forNode.startPosition.row + 1);
const pattern = forNode.childForFieldName('pattern');
const value = forNode.childForFieldName('value');
if (value) this.walkValue(value, acc);
if (pattern) this.defTargets(pattern, acc);
return acc.finish();
}
/**
* Def-ONLY facts for a value-position assignment (`x = if … / case …`, #2205):
* just the LHS target(s), attached to the continuation block the branch arms
* rejoin. The branch condition + arm-value USES are harvested onto the branch's
* own blocks (visitIf / visitCase), so this must not re-walk the RHS.
*/
assignmentDefFacts(stmt: SyntaxNode): StatementFacts | undefined {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const left = stmt.childForFieldName('left');
if (left) this.defTargets(left, acc);
return acc.defCount() ? acc.finish() : undefined;
}
/** Facts for a `rescue [Exc] => e` header: `e` is a def, the exception list a use. */
rescueHeadFacts(clause: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(clause.startPosition.row + 1);
const exceptions = clause.childForFieldName('exceptions');
if (exceptions) this.walkValue(exceptions, acc);
const variable = clause.childForFieldName('variable');
const id = variable?.namedChildren.find((c) => c.type === 'identifier');
if (id) this.def(id, acc);
return acc.finish();
}
/** ENTRY-block facts for the parameters (defs only — incl. default-value uses). */
paramFacts(): StatementFacts | undefined {
const params = this.paramsOf(this.fnNode);
if (!params) return undefined;
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p) this.defParam(p, acc);
}
return acc.defCount() || acc.useCount() ? acc.finish() : undefined;
}
/** Def the binder of one parameter node and use any default-value expr. */
private defParam(p: SyntaxNode, acc: FactAccumulator): void {
if (p.type === 'identifier') {
this.def(p, acc);
return;
}
if (p.type === 'optional_parameter' || p.type === 'keyword_parameter') {
const value = p.childForFieldName('value');
if (value) this.walkValue(value, acc);
const name = p.childForFieldName('name');
if (name) this.def(name, acc);
return;
}
if (NAMED_PARAM_TYPES.has(p.type)) {
const name =
p.childForFieldName('name') ?? p.namedChildren.find((c) => c.type === 'identifier');
if (name) this.def(name, acc);
return;
}
for (let i = 0; i < p.namedChildCount; i++) {
const c = p.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
else if (c) this.defParam(c, acc);
}
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const idx = this.table.get(name);
if (idx !== undefined) return idx;
let s = this.synthetic.get(name);
if (s === undefined) {
s = this.bindings.length;
this.synthetic.set(name, s);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return s;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
// Resolve only known LOCAL names; an unknown bare identifier is a method
// call (resolves to a `module` synthetic — never a false local).
acc.addUse(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
private conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
/**
* Def each identifier leaf of an assignment / loop target; route non-local
* targets (`@x`, index/attribute writes) to the value walk (root is a use).
*/
private defTargets(target: SyntaxNode, acc: FactAccumulator): void {
const t = target.type;
if (t === 'identifier') {
this.def(target, acc);
return;
}
if (t === 'left_assignment_list') {
for (let i = 0; i < target.namedChildCount; i++) {
const c = target.namedChild(i);
if (c) this.defTargets(c, acc);
}
return;
}
if (t === 'splat_parameter' || t === 'rest_assignment') {
const id = target.namedChildren.find((c) => c.type === 'identifier');
if (id) this.def(id, acc);
else this.walkValue(target, acc);
return;
}
// instance/class/global var, constant, element/attribute target — uses only.
this.walkValue(target, acc);
}
/** Value-position walk: collect uses; route def positions to the target handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; // opaque
switch (t) {
case 'identifier':
this.use(node, acc);
return;
// Non-local variables and constants — recorded as neither a scalar def nor
// a local use (they are not function-scoped locals); nothing to add.
case 'instance_variable':
case 'class_variable':
case 'global_variable':
case 'constant':
case 'self':
return;
case 'assignment': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) this.defTargets(left, acc);
return;
}
case 'operator_assignment': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) {
if (left.type === 'identifier') {
this.use(left, acc);
this.def(left, acc);
} else {
this.walkValue(left, acc); // non-local lvalue — use only
}
}
return;
}
case 'binary': {
// `a && b` / `a || b` / `and` / `or` — the right operand is conditionally
// evaluated, so any def inside it is a may-def; uses are still recorded.
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||' || op === 'and' || op === 'or') {
this.conditional(() => this.walkValue(right, acc));
} else {
this.walkValue(right, acc);
}
}
return;
}
case 'call': {
// `recv.meth(args)` / `meth(args)` — the receiver root + arguments are
// uses (the method name is not a scalar binding). A nested block child is
// its OWN function CFG (opaque here).
const receiver = node.childForFieldName('receiver');
const args = node.childForFieldName('arguments');
if (receiver) this.walkValue(receiver, acc);
if (args) this.walkValue(args, acc);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,592 @@
/**
* Rust def/use harvester (#2195 U7) — the Rust analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the C-family /
* Go / Python harvesters. Like the Python harvester it harvests NO call-site
* `sites[]` (the call-site taint substrate is a later step): it emits only the
* per-function binding table ({@link BindingEntry}[]) plus {@link StatementFacts}
* (defs / uses / mayDefs) via a local {@link FactAccumulator} with no site
* machinery, so the produced facts never carry a `sites` key.
*
* Runs in the parse worker next to the Rust CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
* plus the per-block def/use facts the reaching-defs / CDG solvers consume.
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-rust via the introspection probe before use (mandatory pre-step).
* Rust shapes pre-empted (verified by a real parse):
* - functions: `function_item` (fields `name`/`parameters`/`return_type`/`body`;
* methods are `function_item` inside an `impl_item`'s `declaration_list`) and
* `closure_expression` (field `parameters`=`closure_parameters`, `body` is a
* `block` OR a bare expression).
* - parameters: `parameter` (field `pattern`, optional `mutable_specifier`),
* `self_parameter`. A `closure_parameters` lists bare `identifier`s and/or
* `parameter` nodes.
* - declarations: `let_declaration` (field `pattern`, optional `value`, optional
* `alternative` block for `let … else`; optional `mutable_specifier`). The
* `mut` keyword is irrelevant to def-ness.
* - patterns (each bound `identifier` leaf is a def): `identifier`,
* `tuple_pattern`, `slice_pattern`, `struct_pattern` (`field_pattern`s whose
* `name` is a `shorthand_field_identifier`, or `name: pat`), `tuple_struct_pattern`
* (field `type` is the variant path — NOT a binding; the inner patterns bind),
* `ref_pattern` / `mut_pattern` (the inner identifier binds), `captured_pattern`
* (`v @ subpat` — `v` binds, and the subpattern's leaves bind), `or_pattern`,
* `range_pattern` (binds nothing). The wildcard `_` binds nothing.
* - assignments: `assignment_expression` (fields `left`/`right`),
* `compound_assignment_expr` (fields `left`/`operator`/`right` — read+write).
* - loop / match binders: `for_expression` `pattern`; `match_arm` `pattern`
* (a `match_pattern` whose leaves bind, plus an optional `if` guard with field
* `condition`); `let_condition` `pattern` (`if let` / `while let`).
* - reads: `field_expression` (fields `value`/`field`), `call_expression`
* (fields `function`/`arguments`), `binary_expression` (fields
* `left`/`operator`/`right`), `try_expression` (`expr?`).
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / Go / C
* harvesters): the CFG walk is NOT source-order, so resolving names against a
* scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans
* the whole function subtree once, declaring every bound name into ONE function
* table; phase 2 resolves defs/uses against that finished table from any walk
* order. Rust DOES have block scope + shadowing, but a single function table is
* the documented v1 simplification used by the Python harvester — distinct
* shadowing redeclarations of the same name collapse onto one binding (an
* over-approximation that can falsely kill across a shadow, the sound direction
* for taint: never a missed flow).
*
* v1 def-semantics scope:
* - `let PAT = …` (and `let PAT = … else { … }`) — each identifier leaf of PAT
* is a def; the value (and the `else` block) are walked for uses.
* - `assignment_expression` plain `=` — a plain-identifier lvalue is a def; a
* `field_expression` / index lvalue is NOT a scalar def (its root is a use).
* - `compound_assignment_expr` (`x += 1`) — def AND use the lvalue.
* - `for PAT in ITER` — the loop pattern's leaves are defs, ITER a use.
* - `match` arm patterns bind their leaves; `if let` / `while let` patterns bind.
* - parameters (incl. `mut`, typed, closure params) are `param`-kind defs.
* EXCLUDED, deliberately (TypeScript-CFA precedent): field / index writes
* (`obj.f = …`, `arr[i] = …`) are NOT scalar defs — their root identifiers are
* uses only. Nested-function bodies (`closure_expression`, an inner
* `function_item`) are opaque in BOTH directions (captured reads/writes invisible).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` short-circuit, and a match-arm guard / `if let` pattern
* test — is a may-def (gen WITHOUT kill), so the not-taken path's prior def is
* not falsely killed. (Rust assignment is an expression but yields `()`, so an
* in-`&&` assignment is rare; the machinery is kept for guard / case-test parity.)
*
* Identifiers with no in-function declaration (module items, imported names,
* constants, enum variants) resolve to a SYNTHETIC module-level binding
* (`name@module`), applied identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set(['function_item', 'closure_expression']);
/** Pattern containers whose identifier leaves are binding targets. */
const PATTERN_CONTAINER_TYPES = new Set([
'tuple_pattern',
'slice_pattern',
'or_pattern',
'reference_pattern',
]);
export class RustHarvester {
private readonly bindings: BindingEntry[] = [];
/** Single function-scope name → binding index (v1: no block scope). */
private readonly table = new Map<string, number>();
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
/** The function/closure body node (a `block` for a fn, block-or-expr for a closure). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
return fnNode.childForFieldName('body') ?? undefined;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void {
const name = nameNode.text;
if (!name || name === '_' || this.table.has(name)) return;
this.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/** Declare every parameter binder of a fn / closure (incl. `mut`, typed). */
private declareParams(fnNode: SyntaxNode): void {
const params =
fnNode.childForFieldName('parameters') ??
fnNode.namedChildren.find((c) => c.type === 'parameters' || c.type === 'closure_parameters');
if (!params) return;
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (!p) continue;
if (p.type === 'parameter') {
const pat = p.childForFieldName('pattern');
if (pat) this.declarePattern(pat, 'param');
} else if (p.type === 'self_parameter') {
// `&self` / `self` — bind `self` so reads of it resolve to a real
// binding rather than a synthetic module name.
const id = p.namedChildren.find((c) => c.type === 'self');
if (id) this.declare(id, 'param');
} else if (p.type === 'identifier') {
// Bare closure param `|x|`.
this.declare(p, 'param');
} else {
// Typed closure param without the `parameter` wrapper, etc.
this.declarePattern(p, 'param');
}
}
}
/**
* Pre-scan the function body once, declaring every bound name. Recurses into
* compound expressions but NOT into nested `function_item` / `closure_expression`
* bodies (opaque).
*/
private prescan(node: SyntaxNode): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return;
switch (t) {
case 'let_declaration': {
const pat = node.childForFieldName('pattern');
if (pat) this.declarePattern(pat, 'let');
break;
}
case 'for_expression': {
const pat = node.childForFieldName('pattern');
if (pat) this.declarePattern(pat, 'let');
break;
}
case 'let_condition': {
// `if let PAT = …` / `while let PAT = …`.
const pat = node.childForFieldName('pattern');
if (pat) this.declarePattern(pat, 'let');
break;
}
case 'match_arm': {
const pat = node.childForFieldName('pattern');
if (pat) this.declarePattern(pat, 'let');
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c);
}
}
/**
* Declare every identifier leaf of a binding pattern. Handles the full Rust
* pattern taxonomy: tuple / slice / struct / tuple-struct / ref / mut /
* captured (`@`) / or patterns. A `tuple_struct_pattern`'s `type` field is the
* variant PATH (`Some`, `Ok`) — not a binding; only its inner patterns bind.
* `_`, literals and range patterns bind nothing.
*/
private declarePattern(pat: SyntaxNode, kind: BindingEntry['kind']): void {
const t = pat.type;
if (t === 'identifier') {
this.declare(pat, kind);
return;
}
if (t === '_') return; // standalone wildcard pattern is the `_` node
if (t === 'match_pattern') {
// The arm pattern wrapper — declare its (non-guard) sub-patterns. The
// guard `if cond` is a value test, not a binder.
const guard = pat.childForFieldName('condition');
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c && c.id !== guard?.id) this.declarePattern(c, kind);
}
return;
}
if (PATTERN_CONTAINER_TYPES.has(t)) {
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c) this.declarePattern(c, kind);
}
return;
}
if (t === 'tuple_struct_pattern') {
// `Some(n)` / `Ok(v)` — the `type` field is the variant path (not a binder);
// every other named child is an inner binding pattern.
const typeNode = pat.childForFieldName('type');
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c && c.id !== typeNode?.id) this.declarePattern(c, kind);
}
return;
}
if (t === 'struct_pattern') {
// `Point { x, y }` — each `field_pattern` binds; shorthand `x` binds `x`,
// `x: pat` binds `pat`'s leaves. The `type` field is the struct path.
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (!c) continue;
if (c.type === 'field_pattern') {
this.declareFieldPattern(c, kind);
} else if (c.type === 'shorthand_field_identifier') {
this.declare(c, kind);
}
}
return;
}
if (t === 'ref_pattern' || t === 'mut_pattern' || t === 'reference_pattern') {
// `ref r` / `mut m` / `&p` — unwrap to the inner pattern.
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c && c.type !== 'mutable_specifier') this.declarePattern(c, kind);
}
return;
}
if (t === 'captured_pattern') {
// `v @ subpat` — `v` binds AND the subpattern's leaves bind.
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c) this.declarePattern(c, kind);
}
return;
}
// range_pattern / literal patterns / scoped paths bind nothing.
}
/** `field_pattern` — shorthand `x` binds `x`; `x: pat` binds `pat`'s leaves. */
private declareFieldPattern(field: SyntaxNode, kind: BindingEntry['kind']): void {
const name = field.childForFieldName('name');
const pattern = field.childForFieldName('pattern');
if (pattern) {
this.declarePattern(pattern, kind);
return;
}
if (name && name.type === 'shorthand_field_identifier') {
this.declare(name, kind);
return;
}
// Fallback: declare any identifier / shorthand leaf.
for (let i = 0; i < field.namedChildCount; i++) {
const c = field.namedChild(i);
if (c?.type === 'shorthand_field_identifier' || c?.type === 'identifier')
this.declare(c, kind);
}
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (guards/tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Facts for a `for PAT in ITER` head: the loop pattern's leaves are defs, the
* iterated expression a use.
*/
forHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const value = stmt.childForFieldName('value');
const pat = stmt.childForFieldName('pattern');
if (value) this.walkValue(value, acc);
if (pat) this.defPattern(pat, acc);
return acc.finish();
}
/**
* Facts for ONLY a `let_declaration`'s PATTERN bindings (no value walk) — used
* when the value is a control-flow expression already harvested by the visitor,
* so the binding defs land on a separate continuation block without
* double-counting the value's uses.
*/
letPatternFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const pat = stmt.childForFieldName('pattern');
if (pat) this.defPattern(pat, acc);
return acc.finish();
}
/**
* Facts for a `let PAT = VALUE` condition (`if let` / `while let`): the value
* is a use, the pattern's leaves are defs. When `conditional` is true the defs
* become may-defs (a `while let` re-test may not bind on the exit iteration).
*/
letConditionFacts(cond: SyntaxNode, conditional: boolean): StatementFacts {
const acc = new FactAccumulator(cond.startPosition.row + 1);
const run = (): void => {
const value = cond.childForFieldName('value');
const pat = cond.childForFieldName('pattern');
if (value) this.walkValue(value, acc);
if (pat) this.defPattern(pat, acc);
};
if (conditional) this.conditional(run);
else run();
return acc.finish();
}
/**
* Facts for a `match` arm's PATTERN bindings (#2206): `Some(n) => …` binds `n`
* from the matched subject. The bindings are MAY-defs (only the arm that
* actually matches binds; a later arm tests only when earlier ones didn't) and
* are attached to the dispatch block, co-located with the subject's use, so a
* tainted subject can propagate to the arm binding. The guard is skipped by
* {@link defPattern}'s `match_pattern` handling. `undefined` when the pattern
* binds nothing (`_`, a literal, a unit variant).
*/
matchArmPatternFacts(arm: SyntaxNode): StatementFacts | undefined {
const acc = new FactAccumulator(arm.startPosition.row + 1);
const pat = arm.childForFieldName('pattern');
if (pat) this.conditional(() => this.defPattern(pat, acc));
return acc.defCount() ? acc.finish() : undefined;
}
/** ENTRY-block facts for the parameters (defs only — incl. default-position uses). */
paramFacts(): StatementFacts | undefined {
const params =
this.fnNode.childForFieldName('parameters') ??
this.fnNode.namedChildren.find(
(c) => c.type === 'parameters' || c.type === 'closure_parameters',
);
if (!params) return undefined;
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (!p) continue;
if (p.type === 'parameter') {
const pat = p.childForFieldName('pattern');
if (pat) this.defPattern(pat, acc);
} else if (p.type === 'self_parameter') {
const id = p.namedChildren.find((c) => c.type === 'self');
if (id) this.def(id, acc);
} else if (p.type === 'identifier') {
this.def(p, acc);
} else {
this.defPattern(p, acc);
}
}
return acc.defCount() ? acc.finish() : undefined;
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const idx = this.table.get(name);
if (idx !== undefined) return idx;
let syn = this.synthetic.get(name);
if (syn === undefined) {
syn = this.bindings.length;
this.synthetic.set(name, syn);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return syn;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return; // blank target defines nothing
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
acc.addUse(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
private conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
/**
* Def each identifier leaf of a binding pattern (the def-position analogue of
* {@link declarePattern}). A `tuple_struct_pattern`'s `type` field path is a
* variant name, not a def; its inner patterns bind. A struct field shorthand
* binds; `_` binds nothing.
*/
private defPattern(pat: SyntaxNode, acc: FactAccumulator): void {
const t = pat.type;
if (t === 'identifier') {
this.def(pat, acc);
return;
}
if (t === '_') return; // standalone wildcard pattern is the `_` node
if (t === 'match_pattern') {
const guard = pat.childForFieldName('condition');
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c && c.id !== guard?.id) this.defPattern(c, acc);
}
return;
}
if (PATTERN_CONTAINER_TYPES.has(t)) {
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c) this.defPattern(c, acc);
}
return;
}
if (t === 'tuple_struct_pattern') {
const typeNode = pat.childForFieldName('type');
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c && c.id !== typeNode?.id) this.defPattern(c, acc);
}
return;
}
if (t === 'struct_pattern') {
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (!c) continue;
if (c.type === 'field_pattern') this.defFieldPattern(c, acc);
else if (c.type === 'shorthand_field_identifier') this.def(c, acc);
}
return;
}
if (t === 'ref_pattern' || t === 'mut_pattern' || t === 'reference_pattern') {
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c && c.type !== 'mutable_specifier') this.defPattern(c, acc);
}
return;
}
if (t === 'captured_pattern') {
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (c) this.defPattern(c, acc);
}
return;
}
// range / literal / scoped path — binds nothing.
}
private defFieldPattern(field: SyntaxNode, acc: FactAccumulator): void {
const name = field.childForFieldName('name');
const pattern = field.childForFieldName('pattern');
if (pattern) {
this.defPattern(pattern, acc);
return;
}
if (name && name.type === 'shorthand_field_identifier') {
this.def(name, acc);
return;
}
for (let i = 0; i < field.namedChildCount; i++) {
const c = field.namedChild(i);
if (c?.type === 'shorthand_field_identifier' || c?.type === 'identifier') this.def(c, acc);
}
}
/** Value-position walk: collect uses; route def positions to the pattern handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; // opaque
switch (t) {
case 'identifier':
this.use(node, acc);
return;
case 'let_declaration': {
const value = node.childForFieldName('value');
const pat = node.childForFieldName('pattern');
const alt = node.childForFieldName('alternative'); // `let … else { … }`
if (value) this.walkValue(value, acc);
if (alt) this.walkValue(alt, acc);
if (pat) this.defPattern(pat, acc);
return;
}
case 'let_condition': {
const value = node.childForFieldName('value');
const pat = node.childForFieldName('pattern');
if (value) this.walkValue(value, acc);
if (pat) this.defPattern(pat, acc);
return;
}
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) {
if (left.type === 'identifier') {
this.def(left, acc);
} else {
// field / index lvalue (`obj.f = …`, `a[i] = …`) — root is a use only.
this.walkValue(left, acc);
}
}
return;
}
case 'compound_assignment_expr': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) {
if (left.type === 'identifier') {
this.use(left, acc);
this.def(left, acc);
} else {
this.walkValue(left, acc);
}
}
return;
}
case 'binary_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||') this.conditional(() => this.walkValue(right, acc));
else this.walkValue(right, acc);
}
return;
}
case 'field_expression': {
// `a.b` — value read of the chain root only; the field name is not a
// scalar binding.
const value = node.childForFieldName('value');
if (value) this.walkValue(value, acc);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
}

View file

@ -0,0 +1,730 @@
/**
* Rust CfgVisitor (#2195 U7) — the EXPRESSION-ORIENTED CFG target. Unlike the
* C-family / Go / Python visitors (statement languages), Rust control flow is
* built from EXPRESSIONS: `if` / `loop` / `while` / `for` / `match` / `block`
* are all expressions that produce a value. The visitor still drives the
* language-agnostic {@link CfgBuilder} to produce a serializable
* {@link FunctionCfg} plus a def/use harvest ({@link RustHarvester}) for the
* reaching-defs / CDG solvers, structured like the sibling visitors — a
* `visit_<node_type>` dispatch over the control-flow taxonomy driving a
* per-function {@link ControlFlowContext} for labeled break/continue.
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-rust via the introspection probe before use (mandatory pre-step).
* Rust shapes pre-empted (verified by a real parse):
* - functions: `function_item` (fields `name`/`parameters`/`return_type`/`body`;
* a method is a `function_item` inside an `impl_item`'s `declaration_list`)
* and `closure_expression` (field `parameters`=`closure_parameters`; `body` is
* a `block` OR a bare expression — `|x| x + 1`).
* - `if_expression` fields `condition` / `consequence` (a `block`) /
* `alternative` (an `else_clause` wrapping a `block` or a nested
* `if_expression` of an `else if`). The condition can be a `let_condition`
* (`if let PAT = e`) or a `let_chain` (`if let PAT = e && cond`).
* - `loop_expression` field `body` — the INFINITE loop (NO `condition` field);
* an optional `label` NAMED CHILD (label is NOT a field). The key
* non-terminating case.
* - `while_expression` fields `condition` (may be a `let_condition` for
* `while let`) / `body`; optional `label` named child.
* - `for_expression` fields `pattern` / `value` / `body`; optional `label`
* named child.
* - `match_expression` fields `value` / `body` (a `match_block` of `match_arm`s).
* A `match_arm` has field `pattern` (a `match_pattern`, which may carry an `if`
* guard with field `condition`) and field `value` (the arm body — an
* expression or a `block`). Arms do NOT fall through.
* - `break_expression` — optional `label` named child AND an optional value
* expression (`break 'a 42`). `continue_expression` — optional `label`.
* - `return_expression` — optional value child. `try_expression` (`expr?`) —
* the early-return operator.
* - a `label` node's bare name is its `identifier` child's text (`'outer`'s name
* is `outer`), matching `break 'outer` / `continue 'outer`.
*
* Edge-kind contract (matches the existing visitors — RD/CDG consume these):
* - if / else (incl. `if let`) → `cond-true` / `cond-false`
* - `loop {}` (no condition) → `loop-back` (body re-enters) PLUS a structural
* `cond-false` escape edge (header → loopExit) so EXIT stays reverse-reachable
* - `while` / `while let` / `for` → `cond-true` / `loop-back` / `cond-false`
* - `match` dispatch → `switch-case` (NO fallthrough — like Go / Python)
* - `break` / `continue` / `return` → the matching terminator kind; a labeled
* `break 'outer` / `continue 'outer` targets the labeled loop frame; a
* `break value` is still a `break` edge
* - the `?` operator (`try_expression`) → `throw` — an early error-return edge to
* EXIT from the `?` site (a conservative throw-like edge; the Err/None path)
* - straight-line → `seq`
*
* Rust-specific modeling decisions (documented approximations):
* - `loop {}` is the canonical Rust infinite loop with NO condition. We ALWAYS
* emit a structural `header → loopExit` escape edge (exactly as the C-family /
* Go visitors do for `while(true)` / `for {}`), so EXIT stays reverse-reachable
* and the post-dominator / CDG pass is not silently skipped for the function.
* This is the single highest-risk correctness property. A `loop {}` that
* DOES `break` also reaches EXIT via the break; the structural escape edge is
* emitted either way.
* - the `?` operator desugars to a `match` that returns the Err/None early. We
* model it conservatively as a `throw` edge to the function EXIT from the block
* that contains the `?` — so the post-`?` continuation stays reachable (the Ok
* path) AND the early-exit path is represented. Multiple `?` in one block emit
* one early-exit edge per `?`-bearing block (deduped by the builder).
* - a closure (`closure_expression`) is collected as its OWN function by
* `isFunction`, so its body gets a standalone CFG; in the ENCLOSING function it
* is an opaque straight-line value (its body is not followed inline), exactly
* as the Go visitor treats a spawned closure and the TS visitor an arrow body.
* - the trailing tail expression of a `block` (no `;`) is the block's value; for
* control-flow purposes it just falls off normally to the block's successor.
*
* Known limitations:
* - panic: a `panic!()` (or an out-of-bounds index, an `unwrap` on `None`) aborts
* the function abnormally, but tree-sitter sees only a normal macro/method call.
* The panic-unwind path is NOT modeled (documented gap, not faked) — Rust has
* no try/catch, so there is no handler structure to route to.
* - async (`async fn`, `.await`): the suspension points are modeled as normal
* straight-line control flow (no scheduler edges).
* - block scope + shadowing in the def/use harvest is flattened to one function
* table (see rust-harvest.ts) — a documented v1 over-approximation.
* - macro bodies (`println!`, `vec!`, custom macros) are opaque token trees — any
* control flow expanded by a macro is invisible.
*
* Returns `undefined` (never throws) for an AST shape it cannot model, so a
* malformed function never drops the whole file's CFG group (R4).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import { ControlFlowContext, wireJumpThroughFinalizers } from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { RustHarvester } from './rust-harvest.js';
/** Rust node types that own a CFG-bearing function body. */
const RUST_FUNCTION_TYPES = new Set(['function_item', 'closure_expression']);
/**
* Expression / statement node types that break a basic block (everything else
* coalesces). `expression_statement` is the `<expr>;` wrapper; the control-flow
* EXPRESSIONS inside it are unwrapped by {@link visitStmt}.
*/
const CONTROL_FLOW_TYPES = new Set([
'expression_statement',
'if_expression',
'loop_expression',
'while_expression',
'for_expression',
'match_expression',
'return_expression',
'break_expression',
'continue_expression',
'block',
]);
/** Expression node types that the statement walker treats as control-flow. */
const CONTROL_FLOW_EXPR_TYPES = new Set([
'if_expression',
'loop_expression',
'while_expression',
'for_expression',
'match_expression',
'return_expression',
'break_expression',
'continue_expression',
'block',
]);
/** Node types whose subtrees are opaque (a nested function owns its own CFG). */
const NESTED_FUNCTION_TYPES = new Set(['function_item', 'closure_expression']);
/** Rust comment node types — line (`//`) and block (slash-star) comments. */
const COMMENT_TYPES = new Set(['line_comment', 'block_comment']);
const isNotComment = (n: SyntaxNode): boolean => !COMMENT_TYPES.has(n.type);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function Rust walk state. One instance per function so the
* {@link ControlFlowContext} and label tables are scoped to that function and
* never leak across functions.
*/
class RustCfgWalk {
private readonly cfc = new ControlFlowContext();
constructor(
private readonly builder: CfgBuilder,
private readonly harvest: RustHarvester,
) {}
/** Statements of a `block`, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter(isNotComment);
}
/** The `body` of a node (a `block`, or a bare expression for a closure / arm). */
private bodyOf(node: SyntaxNode): SyntaxNode | undefined {
return node.childForFieldName('body') ?? undefined;
}
/** Visit a body that may be a `block` or a single expression. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
return this.builder.withNesting(() => {
if (!node) return null;
if (node.type === 'block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
});
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
return this.builder.withNesting(() => {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (this.isControlFlow(stmt)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
const idx =
openSimple === undefined ? this.openBlock(stmt) : this.extendOpen(openSimple, stmt);
if (openSimple === undefined) {
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
dangling = [idx];
}
openSimple = idx;
// A straight-line statement that contains a `?` early-returns to EXIT.
this.wireTryExits(stmt, idx);
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
});
}
private openBlock(stmt: SyntaxNode): number {
return this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
}
private extendOpen(open: number, stmt: SyntaxNode): number {
this.builder.extendBlock(open, endLineOf(stmt), stmt.text, this.harvest.facts(stmt));
return open;
}
/** Whether a statement node breaks the current straight-line block. */
private isControlFlow(stmt: SyntaxNode): boolean {
if (stmt.type === 'expression_statement') {
const inner = this.exprStmtInner(stmt);
return inner ? CONTROL_FLOW_EXPR_TYPES.has(inner.type) : false;
}
if (stmt.type === 'let_declaration') {
// `let v = loop {…}` / `let w = if c {…} else {…}` / `let PAT = e else {…}`
// — the value is a control-flow EXPRESSION, or the let-else alternative
// block is divergent; either way the let must be modeled structurally.
const value = stmt.childForFieldName('value');
const alt = stmt.childForFieldName('alternative');
return (value !== null && CONTROL_FLOW_EXPR_TYPES.has(value.type)) || alt !== null;
}
return CONTROL_FLOW_TYPES.has(stmt.type);
}
/** The inner expression of an `expression_statement` (`if x {…};`). */
private exprStmtInner(stmt: SyntaxNode): SyntaxNode | undefined {
return stmt.namedChildren.find(isNotComment);
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
// Unwrap an `expression_statement` to its control-flow expression.
if (stmt.type === 'expression_statement') {
const inner = this.exprStmtInner(stmt);
if (inner && CONTROL_FLOW_EXPR_TYPES.has(inner.type)) return this.visitStmt(inner);
return this.visitSimple(stmt);
}
if (stmt.type === 'let_declaration') return this.visitLet(stmt);
switch (stmt.type) {
case 'if_expression':
return this.visitIf(stmt);
case 'loop_expression':
return this.visitLoop(stmt);
case 'while_expression':
return this.visitWhile(stmt);
case 'for_expression':
return this.visitFor(stmt);
case 'match_expression':
return this.visitMatch(stmt);
case 'return_expression':
return this.visitReturn(stmt);
case 'break_expression':
return this.visitBreak(stmt);
case 'continue_expression':
return this.visitContinue(stmt);
case 'block':
return this.visitSeq(this.statementsOf(stmt));
default:
return this.visitSimple(stmt);
}
}
private visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.openBlock(stmt);
this.wireTryExits(stmt, idx);
return { entry: idx, exits: [idx] };
}
/**
* `let PAT = VALUE [else { ALT }]` whose VALUE is a control-flow expression
* (`let v = loop {…}`, `let w = if c {…} else {…}`, `let m = match …`) or which
* has a divergent let-else ALT block. A plain `let x = e;` never reaches here
* (it coalesces into a straight-line block in {@link visitSeq}).
*
* The value's control-flow construct is visited as a sub-CFG; the let-pattern's
* bindings are defs that happen on the value's NORMAL completion — attached to a
* facts-only continuation block the value's exits feed. For a `let … else`, the
* ALT block runs on the refutable-failure path and (per Rust's rules) MUST
* diverge (return/break/continue/panic); it is visited as control flow, so its
* own jumps wire directly to their targets and it contributes no normal exit.
*/
private visitLet(stmt: SyntaxNode): TraversalResult {
const value = stmt.childForFieldName('value');
const alt = stmt.childForFieldName('alternative');
const cfValue = value !== null && CONTROL_FLOW_EXPR_TYPES.has(value.type);
let entry: number;
let normalExits: number[];
if (cfValue && value) {
// `let v = loop {…}` — the value is a control-flow construct: visit it, then
// attach ONLY the pattern's binding defs to a facts-only continuation (the
// value's uses are already harvested onto its own blocks).
const valueRes = this.visitStmt(value);
const cont = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
'',
'normal',
this.harvest.letPatternFacts(stmt),
);
this.builder.connect(valueRes?.exits ?? [], cont, 'seq');
entry = valueRes ? valueRes.entry : cont;
normalExits = [cont];
} else {
// A simple-value `let PAT = e [else {…}]` — ONE block with the whole let's
// facts (value uses + pattern defs). It reaches here only via the let-else
// alternative (a plain `let x = e;` coalesces in visitSeq).
const idx = this.openBlock(stmt);
this.wireTryExits(stmt, idx);
entry = idx;
normalExits = [idx];
}
// `let … else { … }` — the else block runs on the binding-FAILURE path and
// (per Rust) MUST diverge (return/break/continue/panic); visit it as control
// flow so its jumps wire themselves to their targets. It is NOT on the normal
// continuation — branched from the binding site with a `cond-false` (refute)
// edge.
if (alt) {
const altRes = this.visitBody(alt);
if (altRes) this.builder.connect(normalExits, altRes.entry, 'cond-false');
}
return { entry, exits: normalExits };
}
/**
* Emit a `throw` (early-return) edge to EXIT for every `?` operator inside a
* straight-line statement's subtree (excluding nested function bodies). The
* `?` desugars to "return Err(...) early"; modeling it as a throw-like edge to
* EXIT keeps the early-exit path represented while the Ok path falls through
* normally. Deduped by the builder, so repeated `?` in a block emit one edge.
*/
private wireTryExits(stmt: SyntaxNode, fromBlock: number): void {
if (this.containsTry(stmt)) this.builder.edge(fromBlock, this.builder.exitIndex, 'throw');
}
private containsTry(node: SyntaxNode): boolean {
if (node.type === 'try_expression') return true;
if (NESTED_FUNCTION_TYPES.has(node.type)) return false; // opaque
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c && this.containsTry(c)) return true;
}
return false;
}
/** `return [expr]` — direct edge to the function EXIT. */
private visitReturn(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
// A `return f()?;` early-returns on the `?` path too.
this.wireTryExits(stmt, idx);
this.builder.edge(idx, this.builder.exitIndex, 'return');
return { entry: idx, exits: [] };
}
/**
* `break ['label] [value]` — targets the labeled loop frame if labeled, else the
* nearest enclosing loop. `break value` is still a `break` edge (the value is a
* normal use harvested onto the block).
*/
private visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
this.wireTryExits(stmt, idx);
const label = this.jumpLabel(stmt);
const res = this.cfc.resolveBreak(label);
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break');
return { entry: idx, exits: [] };
}
/** `continue ['label]` — re-tests the labeled (or nearest) loop header. */
private visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = this.jumpLabel(stmt);
const res = this.cfc.resolveContinue(label);
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue');
return { entry: idx, exits: [] };
}
/** The bare name (`outer`) of a `break`/`continue`'s `'label`, if any. */
private jumpLabel(stmt: SyntaxNode): string | undefined {
const label = stmt.namedChildren.find((c) => c.type === 'label');
return this.labelName(label);
}
/** The bare identifier name of a `label` node (`'outer` ⇒ `outer`). */
private labelName(label: SyntaxNode | undefined): string | undefined {
if (!label) return undefined;
const id = label.namedChildren.find((c) => c.type === 'identifier');
return id?.text ?? label.text.replace(/^'/, '');
}
/**
* The optional `'label` of a loop expression (a NAMED CHILD, not a field — Rust
* attaches the label directly to the loop, unlike Go's `labeled_statement`).
*/
private loopLabels(stmt: SyntaxNode): string[] {
const label = stmt.namedChildren.find((c) => c.type === 'label');
const name = this.labelName(label);
return name !== undefined ? [name] : [];
}
/**
* `if COND { … } [else { … } | else if …]`. The condition can be a plain
* expression, a `let_condition` (`if let PAT = e`), or a `let_chain`. The header
* carries the condition's def/use facts (an `if let` pattern is a def, its value
* a use). The else `alternative` is an `else_clause` wrapping a `block` or a
* nested `if_expression` (the `else if` chain).
*/
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.condFacts(cond, false),
);
this.wireTryExits(cond, header);
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
if (thenRes) {
this.builder.edge(header, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(header); // empty then — true path falls through
}
const elseNode = this.elseBodyOf(stmt);
if (elseNode) {
const elseRes = this.visitBody(elseNode);
if (elseRes) {
this.builder.edge(header, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(header);
}
} else {
exits.push(header); // no else — false path falls through to the join
}
return { entry: header, exits: [...new Set(exits)] };
}
/** The else body of an `if_expression` (unwraps the `else_clause` wrapper). */
private elseBodyOf(stmt: SyntaxNode): SyntaxNode | undefined {
const alt = stmt.childForFieldName('alternative');
if (!alt) return undefined;
if (alt.type === 'else_clause') {
// The clause wraps a `block` or a nested `if_expression` (`else if`).
return alt.namedChildren.find(isNotComment);
}
return alt;
}
/**
* `loop { … }` — Rust's INFINITE loop (NO condition). Body exits re-enter the
* header (`loop-back`); a `break` reaches `loopExit`. We ALWAYS emit a
* structural `header → loopExit` `cond-false` escape edge so EXIT stays
* reverse-reachable (a `loop {}` with no break never reaches EXIT otherwise, and
* the CDG pass would be silently skipped for the whole function).
*/
private visitLoop(stmt: SyntaxNode): TraversalResult {
const labels = this.loopLabels(stmt);
const header = this.builder.newBlock(startLineOf(stmt), startLineOf(stmt), 'loop');
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.bodyOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty `loop {}` re-enters
}
// Structural escape edge — keeps EXIT reverse-reachable even for `loop {}`
// with no `break` (the canonical Rust non-terminating case).
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
/**
* `while COND { … }` (and `while let PAT = e { … }`). Standard loop: header
* tests, true → body → loop-back, false → loop exit. The `while let` pattern is
* a may-def on the header (the binding does not happen on the exit iteration).
*/
private visitWhile(stmt: SyntaxNode): TraversalResult {
const labels = this.loopLabels(stmt);
const cond = stmt.childForFieldName('condition') ?? stmt;
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.condFacts(cond, true),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.bodyOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
}
// Structural exit edge — even `while true {}` keeps EXIT reverse-reachable.
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
/**
* `for PAT in ITER { … }`. The header binds the loop pattern (a def) and uses
* the iterated expression. Standard loop topology.
*/
private visitFor(stmt: SyntaxNode): TraversalResult {
const labels = this.loopLabels(stmt);
const value = stmt.childForFieldName('value');
const headEnd = value ? endLineOf(value) : startLineOf(stmt);
const header = this.builder.newBlock(
startLineOf(stmt),
headEnd,
this.forHeaderText(stmt),
'normal',
this.harvest.forHeadFacts(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.bodyOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-iterates
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private forHeaderText(stmt: SyntaxNode): string {
const pat = stmt.childForFieldName('pattern')?.text ?? '';
const value = stmt.childForFieldName('value')?.text ?? '';
return pat || value ? `for ${pat} in ${value}` : 'for';
}
/**
* `match VALUE { PAT [if guard] => ARM, … }`. Arms do NOT fall through (like
* Go / Python). Each arm body is dispatched from the subject block with a
* `switch-case` edge; arm bodies rejoin AFTER the match. A `match` with no
* irrefutable `_` arm also reaches the join directly (no-match path), keeping
* EXIT reverse-reachable.
*/
private visitMatch(stmt: SyntaxNode): TraversalResult {
const value = stmt.childForFieldName('value');
const dispatch = this.builder.newBlock(
startLineOf(stmt),
value ? endLineOf(value) : startLineOf(stmt),
value ? `match ${value.text}` : 'match',
'normal',
value ? this.harvest.facts(value) : undefined,
);
if (value) this.wireTryExits(value, dispatch);
const matchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
const body =
stmt.childForFieldName('body') ?? stmt.namedChildren.find((c) => c.type === 'match_block');
const arms = body ? body.namedChildren.filter((c) => c.type === 'match_arm') : [];
// Each arm's pattern bindings (`Some(n) =>`) are MAY-defs from the matched
// subject, and a guarded arm (`PAT if g`) evaluates `g` conditionally — both
// are harvested onto the dispatch block (co-located with the subject's use, so
// a tainted subject reaches the binding), as may-defs (a later arm binds/tests
// only when earlier ones didn't match). #2206.
for (const arm of arms) {
const patFacts = this.harvest.matchArmPatternFacts(arm);
if (patFacts) this.builder.attachFacts(dispatch, patFacts);
const guard = this.armGuard(arm);
if (guard) this.builder.attachFacts(dispatch, this.harvest.factsConditional(guard));
}
this.cfc.pushSwitch(matchExit, []);
let hasIrrefutable = false;
for (const arm of arms) {
// The arm body may be an expr or block; its pattern bindings were harvested
// onto the dispatch above (#2206).
const armBody = this.visitBody(arm.childForFieldName('value'));
const entry = armBody?.entry ?? matchExit;
this.builder.edge(dispatch, entry, 'switch-case');
if (armBody) this.builder.connect(armBody.exits, matchExit, 'seq');
if (this.isIrrefutableArm(arm)) hasIrrefutable = true;
}
this.cfc.pop();
// No catch-all arm → a no-match path reaches the exit directly. (A real Rust
// match is exhaustive, but a non-`_`-tailed match keeps EXIT reverse-reachable
// even when every arm body jumps.)
if (!hasIrrefutable) this.builder.edge(dispatch, matchExit, 'switch-case');
return { entry: dispatch, exits: [matchExit] };
}
/** The guard condition of a `match_arm` (`PAT if g`), if any. */
private armGuard(arm: SyntaxNode): SyntaxNode | undefined {
const pat = arm.childForFieldName('pattern');
return pat?.childForFieldName('condition') ?? undefined;
}
/** A `_ =>` arm with no guard is the unconditional catch-all. */
private isIrrefutableArm(arm: SyntaxNode): boolean {
if (this.armGuard(arm)) return false;
const pat = arm.childForFieldName('pattern');
return pat?.text.trim() === '_';
}
/**
* Def/use facts for an `if`/`while` condition. A `let_condition` binds a pattern
* (a def — a may-def for `while let`, which re-tests) and uses its value; a
* `let_chain` threads through each `let_condition`. A plain expression is walked
* for uses.
*/
private condFacts(cond: SyntaxNode, loopCond: boolean): ReturnType<RustHarvester['facts']> {
if (cond.type === 'let_condition') {
return this.harvest.letConditionFacts(cond, loopCond);
}
// A `let_chain` (`let PAT = e && cond`) — harvest the whole chain. The let
// bindings inside it are defs (may-defs for a while-let chain).
return this.harvest.facts(cond);
}
}
/** Build the CFG for one Rust function / closure node, or `undefined`. */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
try {
if (!RUST_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
const body = fnNode.childForFieldName('body');
if (!body) return undefined; // trait method signature / no body
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const harvest = new RustHarvester(fnNode);
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
const walk = new RustCfgWalk(builder, harvest);
if (body.type !== 'block') {
// A closure with an expression body (`|x| x + 1`): one block whose value is
// the returned expression. A `?` inside it early-returns to EXIT.
const res = walk.visitStmt(body);
builder.edge(builder.entryIndex, res ? res.entry : builder.exitIndex, 'seq');
builder.connect(res ? res.exits : [builder.entryIndex], builder.exitIndex, 'seq');
return builder.finish(harvest.bindingTable());
}
const res = walk.visitSeq(body.namedChildren.filter(isNotComment));
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish(harvest.bindingTable());
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish(harvest.bindingTable());
} catch (err) {
// Never throw out of buildFunctionCfg — a malformed AST shape must skip only
// this one function's CFG, never drop the whole file's language group (R4).
// eslint-disable-next-line no-console
console.warn(`[cfg] Rust buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`);
return undefined;
}
}
/** Whether a node is a Rust function/closure this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return RUST_FUNCTION_TYPES.has(node.type);
}
/** The Rust CFG visitor. */
export function createRustCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { RUST_FUNCTION_TYPES };

View file

@ -0,0 +1,171 @@
/**
* Shared scope-tree substrate for the C-family / Go / Java / C# def/use
* harvesters (#2197 U6, plan KTD4 — a byte-equivalent consolidation).
*
* The Go ({@link import('./go-harvest.js').GoHarvester}), Java ({@link
* import('./java-harvest.js').JavaHarvester}), C# ({@link
* import('./csharp-harvest.js').CsharpHarvester}) and C/C++ ({@link
* import('./c-cpp-harvest.js').CCppHarvester}) harvesters each carried a
* BYTE-IDENTICAL copy of the lexical scope tree machinery: the {@link Scope}
* record, the binding/scope/synthetic state, the two-phase resolution cache, and
* the `openScope` / `nearestScopeOf` / `resolve` / `def` / `use` / `conditional`
* / `bindingTable` methods. This base holds that one copy; the four harvesters
* extend it and supply ONLY their genuine per-language variation — the
* `prescan` switch (abstract) and, for Go, the blank-identifier (`_`) overrides
* of `declare` / `def` / `use`.
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing): the CFG walk is NOT source-order
* (`visitFor` builds the init block after the body, `visitDoWhile` the condition
* before the body), so resolving names against a scope stack populated *during*
* the walk would mis-resolve. Phase 1 (`prescan`, per-language) pre-scans the
* whole function subtree once into a completed lexical scope tree; phase 2
* (`resolve`) resolves defs/uses against that finished tree from any walk order.
*
* Identifiers with no in-function declaration (globals, fields, imports, …)
* resolve to a SYNTHETIC module-level binding (`name@module`), created on first
* reference and applied identically by def and use harvesting.
*
* NOTE: nothing serialized via the harvested bindings/facts may carry a field
* named `nodeId` — the durable parsedfile-store reviver dedups objects keyed on
* that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry } from '../types.js';
import { CallSiteFactAccumulator } from './call-site-harvest.js';
/**
* The per-statement def/use + call-site collector, aliased to the shared
* {@link CallSiteFactAccumulator} (one name for the value and the type).
*/
export type FactAccumulator = CallSiteFactAccumulator;
export interface Scope {
readonly parent: Scope | null;
/** name → binding index */
readonly table: Map<string, number>;
}
/**
* Abstract base owning the lexical scope tree + the two-phase resolution
* substrate. Subclasses provide the per-language constructor wiring (param /
* receiver declaration + the body `prescan` kick-off) and the abstract
* `prescan`; Go additionally overrides `declare` / `def` / `use` for its `_`
* blank-identifier semantics.
*/
export abstract class ScopeTreeHarvester {
protected readonly bindings: BindingEntry[] = [];
protected readonly scopeByNode = new Map<number, Scope>();
protected readonly root: Scope = { parent: null, table: new Map() };
protected readonly synthetic = new Map<string, number>();
protected readonly fnId: number;
/** Innermost enclosing scope per visited node id (prescan-filled) — O(scope-chain) phase-2 resolution. */
protected readonly nearestScopeCache = new Map<number, Scope>();
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
protected conditionalDepth = 0;
/**
* Call/new node id → bindings whose declaration/assignment VALUE is exactly
* that call (#2195 U6). Registered before the value walk, consumed by the
* language harvester's `visitCall` (mirrors the TS harvester's
* `resultDefTargets`).
*/
protected readonly resultDefTargets = new Map<number, number[]>();
constructor(protected readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.scopeByNode.set(fnNode.id, this.root);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
protected openScope(node: SyntaxNode): Scope {
const existing = this.scopeByNode.get(node.id);
if (existing) return existing;
const scope: Scope = { parent: this.nearestScopeOf(node), table: new Map() };
this.scopeByNode.set(node.id, scope);
return scope;
}
protected nearestScopeOf(node: SyntaxNode): Scope {
for (let p = node.parent; p; p = p.parent) {
const s = this.scopeByNode.get(p.id);
if (s) return s;
if (p.id === this.fnId) break;
}
return this.root;
}
protected declare(nameNode: SyntaxNode, kind: BindingEntry['kind'], scope: Scope): void {
const name = nameNode.text;
if (!name || scope.table.has(name)) return;
scope.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/**
* Phase-1 declaration pre-scan — the only genuine per-language variation (each
* grammar has a distinct declaration-node taxonomy). Walks the function
* subtree once, filling `nearestScopeCache` and the scope tables.
*/
protected abstract prescan(node: SyntaxNode, scope: Scope): void;
// ── phase 2: per-statement fact extraction ───────────────────────────────
protected resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const cached = this.nearestScopeCache.get(nameNode.id);
let startScope: Scope | null = cached ?? null;
if (!startScope) {
for (let p: SyntaxNode | null = nameNode; p; p = p.parent) {
const scope = this.scopeByNode.get(p.id) ?? this.nearestScopeCache.get(p.id);
if (scope) {
startScope = scope;
break;
}
if (p.id === this.fnId) {
startScope = this.root;
break;
}
}
}
for (let s: Scope | null = startScope; s; s = s.parent) {
const idx = s.table.get(name);
if (idx !== undefined) return idx;
}
let idx = this.synthetic.get(name);
if (idx === undefined) {
idx = this.bindings.length;
this.synthetic.set(name, idx);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return idx;
}
protected def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
protected use(nameNode: SyntaxNode, acc: FactAccumulator): void {
acc.addUse(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
protected conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
}

View file

@ -0,0 +1,551 @@
/**
* Swift def/use harvester (#2195) — the Swift analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the C-family / Go /
* Rust / Python harvesters. Like the Python / Rust harvesters it harvests NO
* call-site `sites[]` (the call-site taint substrate is a later step): it emits
* only the per-function binding table ({@link BindingEntry}[]) plus
* {@link StatementFacts} (defs / uses / mayDefs) via a local
* {@link FactAccumulator} with no site machinery, so the produced facts never
* carry a `sites` key.
*
* Runs in the parse worker next to the Swift CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
* plus the per-block def/use facts the reaching-defs / CDG solvers consume.
*
* Every node type and field literal below was grammar-validated against the
* VENDORED tree-sitter-swift via the introspection probe before use (mandatory
* pre-step). Swift shapes pre-empted (verified by a real parse):
* - functions: `function_declaration` / `init_declaration` / `deinit_declaration`
* (field `body`=`function_body`, which wraps a `statements` node) and
* `lambda_literal` (a closure — its `statements` follow an optional
* `lambda_function_type` + `in`, NO `function_body` wrapper).
* - parameters: `parameter` (fields `external_name`?/`name`=`simple_identifier`,
* plus a type child). A closure's parameters live in `lambda_function_type` →
* `lambda_function_type_parameters` (bare `simple_identifier`s).
* - `property_declaration` — Swift's `let`/`var` binding: a `value_binding_pattern`
* (`mutability` = `let`/`var`), then repeated `name`=`pattern` + `value`= pairs
* (`let p = 1, q = 2`). A `pattern` binds via `bound_identifier`=`simple_identifier`
* or nests `pattern`s for tuple destructuring (`let (a, b) = pair`).
* - optional binding (`if let` / `while let` / `guard let`): a `value_binding_pattern`
* in the construct's `condition` fields, then a `bound_identifier` field and the
* bound value as further `condition` fields.
* - `for_statement` fields `item`=`pattern` / `collection` / optional `where_clause`.
* - `catch_block` field `error`=`pattern` (the bound error).
* - reads: `simple_identifier`, `navigation_expression` (`a.b` — fields
* `target`/`suffix`), `call_expression` (`f()` — `call_suffix`),
* `assignment` (fields `target`/`operator`/`result`).
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Rust / Go / C
* harvesters): the CFG walk is NOT source-order (`repeat … while` builds the
* condition after the body), so resolving names against a scope stack populated
* *during* the walk would mis-resolve. Phase 1 pre-scans the whole function
* subtree once, declaring every bound name into ONE function table; phase 2
* resolves defs/uses against that finished table from any walk order. Swift DOES
* have block scope + shadowing, but a single function table is the documented v1
* simplification used by the Python / Rust harvesters — distinct shadowing
* redeclarations of the same name collapse onto one binding (an over-approximation
* that can falsely kill across a shadow, the sound direction for taint).
*
* v1 def-semantics scope:
* - `property_declaration` (`let`/`var PAT = …`) — each `simple_identifier`
* leaf of every `name` pattern is a def; the values are walked for uses.
* - `assignment` plain `=` — a plain-identifier target is a def; a
* `navigation_expression` / subscript target (`self.x = …`, `a[i] = …`) is
* NOT a scalar def (its root is a use). A compound `+=`/`-=`/… target
* def-AND-uses the lvalue.
* - `for x in xs` — the loop pattern's leaves are defs, the collection a use.
* - optional binding (`if let` / `while let` / `guard let`) binds its pattern.
* - `catch_block`'s `error` pattern binds.
* - parameters (incl. closure params) are `param`-kind defs.
* EXCLUDED, deliberately (TypeScript-CFA precedent): member / subscript writes
* (`obj.f = …`, `a[i] = …`) are NOT scalar defs — their root identifiers are
* uses only. Nested-function bodies (`lambda_literal`, a nested
* `function_declaration`) are opaque in BOTH directions (captured reads/writes
* invisible).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` short-circuit, and a switch-case `where` guard / case
* test — is a may-def (gen WITHOUT kill), so the not-taken path's prior def is
* not falsely killed. A `while let` re-test binding is also a may-def (the bind
* does not happen on the exit iteration).
*
* Identifiers with no in-function declaration (module/global functions, types,
* enum cases) resolve to a SYNTHETIC module-level binding (`name@module`),
* applied identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import { DefUseAccumulator as FactAccumulator } from './call-site-harvest.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'function_declaration',
'init_declaration',
'deinit_declaration',
'lambda_literal',
]);
export class SwiftHarvester {
private readonly bindings: BindingEntry[] = [];
/** Single function-scope name → binding index (v1: no block scope). */
private readonly table = new Map<string, number>();
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
/**
* The function/closure body `statements` node. A `function_declaration` /
* `init_declaration` / `deinit_declaration` wraps it in a `function_body`; a
* `lambda_literal` carries the `statements` directly.
*/
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
const fb =
fnNode.childForFieldName('body') ??
fnNode.namedChildren.find((c) => c.type === 'function_body');
if (fb && fb.type === 'function_body') {
return fb.namedChildren.find((c) => c.type === 'statements') ?? fb;
}
// lambda_literal — its `statements` is a direct named child.
return fnNode.namedChildren.find((c) => c.type === 'statements');
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void {
const name = nameNode.text;
if (!name || name === '_' || this.table.has(name)) return;
this.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/** Declare every parameter binder of a fn / init / closure. */
private declareParams(fnNode: SyntaxNode): void {
for (const p of fnNode.namedChildren) {
if (p.type === 'parameter') {
const name = p.childForFieldName('name');
if (name && name.type === 'simple_identifier') this.declare(name, 'param');
}
}
// Closure params live in lambda_function_type → lambda_function_type_parameters.
const lambdaType = fnNode.namedChildren.find((c) => c.type === 'lambda_function_type');
if (lambdaType) this.declareClosureParams(lambdaType);
}
private declareClosureParams(lambdaType: SyntaxNode): void {
for (const params of lambdaType.namedChildren) {
if (params.type !== 'lambda_function_type_parameters') continue;
for (const id of params.namedChildren) {
if (id.type === 'simple_identifier') this.declare(id, 'param');
else if (id.type === 'lambda_parameter') {
const name =
id.childForFieldName('name') ??
id.namedChildren.find((c) => c.type === 'simple_identifier');
if (name) this.declare(name, 'param');
}
}
}
}
/**
* Pre-scan the function body once, declaring every bound name. Recurses into
* compound expressions but NOT into nested `function_declaration` /
* `lambda_literal` bodies (opaque).
*/
private prescan(node: SyntaxNode): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return;
switch (t) {
case 'property_declaration':
// `let`/`var PAT = …` — declare every `name` pattern's leaves.
for (let i = 0; i < node.childCount; i++) {
if (node.fieldNameForChild(i) === 'name') {
const pat = node.child(i);
if (pat) this.declarePattern(pat);
}
}
break;
case 'for_statement': {
const pat = node.childForFieldName('item');
if (pat) this.declarePattern(pat);
break;
}
case 'catch_block': {
const err = node.childForFieldName('error');
if (err) this.declarePattern(err);
break;
}
case 'switch_pattern': {
// `case let n` / `case (let a, let b)` / `case .some(let v)` — declare
// the value binding(s) so a body use resolves to a real local.
const pat = node.namedChildren.find((c) => c.type === 'pattern');
if (pat) this.declarePattern(pat);
break;
}
default:
// Optional binding (`if let` / `while let` / `guard let`): a
// `value_binding_pattern` condition followed by a `bound_identifier`.
if (t === 'if_statement' || t === 'while_statement' || t === 'guard_statement') {
this.declareOptionalBindings(node);
}
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c);
}
}
/** Declare the bindings of each optional binding in a condition. */
private declareOptionalBindings(node: SyntaxNode): void {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
// `if/guard let v = e` — a direct `bound_identifier` field.
if (node.fieldNameForChild(i) === 'bound_identifier') {
this.declare(child, 'let');
} else if (child.type === 'pattern') {
// `if/guard case PAT = e` (e.g. `case .some(let v)`): the binder is nested
// in a `pattern` condition child, not a direct `bound_identifier`, so it
// was missed and resolved to a synthetic global. declarePattern finds its
// bound leaves (#2206).
this.declarePattern(child);
}
}
}
/**
* Declare every `simple_identifier` leaf of a binding pattern. Handles the
* common Swift pattern shapes: a `bound_identifier` simple pattern and tuple
* destructuring (`(a, b)`), which nests `pattern` children. `_` (the wildcard)
* binds nothing.
*/
private declarePattern(pat: SyntaxNode): void {
const bound = pat.childForFieldName?.('bound_identifier');
if (bound && bound.type === 'simple_identifier') {
this.declare(bound, 'let');
return;
}
if (pat.type === 'simple_identifier') {
this.declare(pat, 'let');
return;
}
// Tuple / nested pattern — recurse into child patterns / identifiers.
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (!c) continue;
if (c.type === 'pattern') this.declarePattern(c);
else if (c.type === 'simple_identifier') this.declare(c, 'let');
else if (c.type === 'value_binding_pattern') continue;
else this.declarePattern(c);
}
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/** Def/use facts for one statement (or construct-header expression) node. */
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/** Facts for an expression whose WHOLE evaluation is conditional (guards/tests). */
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/**
* Def-ONLY facts for a value-position binding carrier (`let x = if … / switch …`,
* #2207): just the declared name pattern's leaves, attached to the continuation
* block the branch arms rejoin. The condition + arm-value USES are already
* harvested onto the branch's own blocks (visitIf / visitSwitch), so this must
* NOT re-walk the value — only the `name`-field pattern leaves are defs here.
*/
bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
for (let i = 0; i < stmt.childCount; i++) {
if (stmt.fieldNameForChild(i) === 'name') {
const pat = stmt.child(i);
if (pat) this.defPattern(pat, acc);
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/**
* MAY-def facts for a `switch_pattern`'s value bindings (`case let n` /
* `case .some(let v)`). The binding only takes effect when the case matches,
* so it is a may-def on the dispatch block — propagated into the case body
* where the bound name is read.
*/
switchPatternFacts(switchPattern: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(switchPattern.startPosition.row + 1);
const pat = switchPattern.namedChildren.find((c) => c.type === 'pattern');
if (pat) this.conditional(() => this.defPattern(pat, acc));
return acc.finish();
}
/**
* Facts for a `for item in COLLECTION` head: the loop pattern's leaves are
* defs, the iterated collection a use. The `where` guard (if any) is harvested
* conditionally.
*/
forHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const collection = stmt.childForFieldName('collection');
const item = stmt.childForFieldName('item');
if (collection) this.walkValue(collection, acc);
if (item) this.defPattern(item, acc);
const where = stmt.namedChildren.find((c) => c.type === 'where_clause');
if (where) this.conditional(() => this.walkValue(where, acc));
return acc.finish();
}
/**
* Facts for an `if`/`while`/`guard` condition: optional bindings bind their
* pattern (a def — a may-def when `conditional`), and the condition expression
* children are uses. The construct's `condition` / `bound_identifier` fields are
* interleaved, so we walk all children and classify them.
*/
conditionFacts(stmt: SyntaxNode, conditional: boolean): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const run = (): void => {
for (let i = 0; i < stmt.childCount; i++) {
const field = stmt.fieldNameForChild(i);
const child = stmt.child(i);
if (!child) continue;
if (field === 'bound_identifier') this.def(child, acc);
else if (field === 'condition') {
// `value_binding_pattern` (`let`) and the `=` operator carry no uses.
if (child.type === 'value_binding_pattern') continue;
if (!child.isNamed) continue;
// `if/guard case PAT = e` (e.g. `case .some(let v)`): the `pattern` child
// BINDS — its leaves are defs (a may-def when conditional), not uses, so
// a tainted subject propagates to the binding (#2206). The matched
// subject and any other condition child are uses.
if (child.type === 'pattern') this.defPattern(child, acc);
else this.walkValue(child, acc);
}
}
};
if (conditional) this.conditional(run);
else run();
return acc.finish();
}
/** ENTRY-block facts for the parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
for (const p of this.fnNode.namedChildren) {
if (p.type === 'parameter') {
const name = p.childForFieldName('name');
if (name && name.type === 'simple_identifier') this.def(name, acc);
}
}
const lambdaType = this.fnNode.namedChildren.find((c) => c.type === 'lambda_function_type');
if (lambdaType) {
for (const params of lambdaType.namedChildren) {
if (params.type !== 'lambda_function_type_parameters') continue;
for (const id of params.namedChildren) {
if (id.type === 'simple_identifier') this.def(id, acc);
else if (id.type === 'lambda_parameter') {
const name =
id.childForFieldName('name') ??
id.namedChildren.find((c) => c.type === 'simple_identifier');
if (name) this.def(name, acc);
}
}
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch let e` error pattern — prepend to the handler entry block. */
catchErrorFacts(catchBlock: SyntaxNode): StatementFacts | undefined {
const err = catchBlock.childForFieldName('error');
if (!err) return undefined;
const acc = new FactAccumulator(catchBlock.startPosition.row + 1);
this.defPattern(err, acc);
return acc.defCount() ? acc.finish() : undefined;
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const idx = this.table.get(name);
if (idx !== undefined) return idx;
let syn = this.synthetic.get(name);
if (syn === undefined) {
syn = this.bindings.length;
this.synthetic.set(name, syn);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return syn;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return; // blank target defines nothing
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
acc.addUse(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
private conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
/**
* Def each `simple_identifier` leaf of a binding pattern (the def-position
* analogue of {@link declarePattern}). Tuple destructuring recurses; `_` binds
* nothing.
*/
private defPattern(pat: SyntaxNode, acc: FactAccumulator): void {
const bound = pat.childForFieldName?.('bound_identifier');
if (bound && bound.type === 'simple_identifier') {
this.def(bound, acc);
return;
}
if (pat.type === 'simple_identifier') {
this.def(pat, acc);
return;
}
for (let i = 0; i < pat.namedChildCount; i++) {
const c = pat.namedChild(i);
if (!c) continue;
if (c.type === 'pattern') this.defPattern(c, acc);
else if (c.type === 'simple_identifier') this.def(c, acc);
else if (c.type === 'value_binding_pattern') continue;
else this.defPattern(c, acc);
}
}
/** Value-position walk: collect uses; route def positions to the pattern handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; // opaque
switch (t) {
case 'simple_identifier':
this.use(node, acc);
return;
case 'property_declaration': {
// Walk each `value` for uses, then def each `name` pattern's leaves.
const names: SyntaxNode[] = [];
for (let i = 0; i < node.childCount; i++) {
const field = node.fieldNameForChild(i);
const child = node.child(i);
if (!child) continue;
if (field === 'value') this.walkValue(child, acc);
else if (field === 'name') names.push(child);
else if (field === 'computed_value') this.walkValue(child, acc);
}
for (const pat of names) this.defPattern(pat, acc);
return;
}
case 'assignment': {
const target = node.childForFieldName('target');
const result = node.childForFieldName('result');
const op = node.childForFieldName('operator')?.text ?? '=';
if (result) this.walkValue(result, acc);
if (target) {
const lv = this.unwrapAssignable(target);
if (lv.type === 'simple_identifier') {
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
} else {
// `self.x = …`, `a[i] = …` — root is a use only (not a scalar def).
this.walkValue(lv, acc);
}
}
return;
}
case 'navigation_expression': {
// `a.b` — value read of the chain root only; the suffix name is not a
// scalar binding.
const target = node.childForFieldName('target');
if (target) this.walkValue(target, acc);
return;
}
case 'try_expression': {
// `try expr` / `try? expr` / `try! expr` — the wrapped expression's uses.
const expr = node.childForFieldName('expr');
if (expr) this.walkValue(expr, acc);
else
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c && c.type !== 'try_operator') this.walkValue(c, acc);
}
return;
}
case 'conjunction_expression':
case 'disjunction_expression': {
// `a && b` / `a || b` — the right operand is conditionally evaluated.
const lhs = node.childForFieldName('lhs');
const rhs = node.childForFieldName('rhs');
if (lhs) this.walkValue(lhs, acc);
else if (node.namedChildCount > 0) this.walkValue(node.namedChild(0)!, acc);
if (rhs) this.conditional(() => this.walkValue(rhs, acc));
else if (node.namedChildCount > 1) {
this.conditional(() => this.walkValue(node.namedChild(node.namedChildCount - 1)!, acc));
}
return;
}
case 'value_binding_pattern':
case 'type_identifier':
case 'user_type':
// Binding keyword / type position — no scalar value uses.
return;
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
/** Strip a `directly_assignable_expression` wrapper around an lvalue. */
private unwrapAssignable(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 4;
while (n.type === 'directly_assignable_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
}

File diff suppressed because it is too large Load diff

View file

@ -76,8 +76,6 @@ const TS_FUNCTION_TYPES = new Set([
'method_definition',
'generator_function_declaration',
'generator_function',
'async_function_declaration',
'async_arrow_function',
]);
/** Statement node types that break a basic block (everything else coalesces). */
@ -87,7 +85,6 @@ const CONTROL_FLOW_TYPES = new Set([
'do_statement',
'for_statement',
'for_in_statement',
'for_of_statement',
'switch_statement',
'try_statement',
'return_statement',
@ -103,7 +100,6 @@ const LOOP_OR_SWITCH_TYPES = new Set([
'do_statement',
'for_statement',
'for_in_statement',
'for_of_statement',
'switch_statement',
]);
@ -146,52 +142,56 @@ class TsCfgWalk {
/** Visit a body that may be a `statement_block` or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
if (!node) return null;
if (node.type === 'statement_block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
return this.builder.withNesting(() => {
if (!node) return null;
if (node.type === 'statement_block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
});
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
return this.builder.withNesting(() => {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
// Simple statement — coalesce into the current straight-line block.
if (openSimple === undefined) {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
for (const stmt of stmts) {
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
this.builder.extendBlock(
openSimple,
endLineOf(stmt),
stmt.text,
this.harvest.facts(stmt),
);
// Simple statement — coalesce into the current straight-line block.
if (openSimple === undefined) {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
} else {
this.builder.extendBlock(
openSimple,
endLineOf(stmt),
stmt.text,
this.harvest.facts(stmt),
);
}
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
if (entry === undefined) return null;
return { entry, exits: dangling };
});
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
@ -206,7 +206,6 @@ class TsCfgWalk {
case 'for_statement':
return this.visitFor(stmt);
case 'for_in_statement':
case 'for_of_statement':
return this.visitForIn(stmt);
case 'switch_statement':
return this.visitSwitch(stmt);

View file

@ -36,6 +36,8 @@ export const EXTENSIONS = [
'.cxx',
'.hxx',
'.hh',
'.cu',
'.cuh',
// C#
'.cs',
// Go

View file

@ -70,6 +70,7 @@ import {
type CppConstraintPayload,
} from './cpp/constraint-extractor.js';
import { assertCloneable } from '../workers/clone-safety.js';
import { createCCfgVisitor, createCppCfgVisitor } from '../cfg/visitors/c-cpp.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@ -401,6 +402,7 @@ export const cProvider = defineLanguage({
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
emitScopeCaptures: emitCScopeCaptures,
cfgVisitor: createCCfgVisitor(),
// Worker-side: snapshot the module-level `static`-linkage marks
// `emitCScopeCaptures` just populated for this file (`markStaticName` →
// `staticNames`) into plain data on `ParsedFile.captureSideChannel`, so the
@ -425,7 +427,9 @@ export const cProvider = defineLanguage({
export const cppProvider = defineLanguage({
id: SupportedLanguages.CPlusPlus,
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
// CUDA files route through tree-sitter-cpp as a conservative C++-subset parser:
// definitions still extract, but CUDA launch syntax (`<<< >>>`) is not modeled as calls.
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh', '.cu', '.cuh'],
entryPointPatterns: [
/^main$/,
/^init_/,
@ -484,6 +488,7 @@ export const cppProvider = defineLanguage({
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
emitScopeCaptures: emitCppScopeCaptures,
cfgVisitor: createCppCfgVisitor(),
// Worker-side: snapshot the module-level capture marks `emitCppScopeCaptures`
// just populated for this file into plain data on `ParsedFile.captureSideChannel`,
// so the main thread can restore them via `applyCaptureSideChannel` WITHOUT a

View file

@ -34,6 +34,11 @@ export const cobolProvider = defineLanguage({
exportChecker: () => false,
importResolver: () => null,
// No `cfgVisitor`: COBOL is the deliberate non-goal of the PDG-language
// rollout (#2195). There is no installed tree-sitter grammar and COBOL's
// PERFORM / GO-TO control flow is exotic; the worker's `provider.cfgVisitor &&`
// gate therefore emits no CFG/PDG layer for COBOL (see worker-roundtrip.test.ts).
// ── Scope-resolution hooks ───────────────────────────────────────
emitScopeCaptures: emitCobolScopeCaptures,
interpretImport: interpretCobolImport,

View file

@ -201,6 +201,7 @@ export function classifyCppParameterType(
cv,
indirection,
pointerDepth,
...templateArgumentsFor(`${source} ${rawType} ${declaratorText ?? ''}`),
};
}
@ -213,6 +214,39 @@ function unknownTypeClass(base: string): ParameterTypeClass {
};
}
function templateArgumentsFor(rawType: string): Pick<ParameterTypeClass, 'templateArguments'> {
const args = parseTopLevelTemplateArguments(rawType);
return args === undefined ? {} : { templateArguments: args };
}
function parseTopLevelTemplateArguments(rawType: string): string[] | undefined {
const start = rawType.indexOf('<');
if (start < 0) return undefined;
const args: string[] = [];
let depth = 0;
let argStart = start + 1;
for (let i = start + 1; i < rawType.length; i++) {
const ch = rawType[i];
if (ch === '<') {
depth++;
} else if (ch === '>') {
if (depth === 0) {
const finalArg = rawType.slice(argStart, i).trim();
if (finalArg.length > 0) args.push(normalizeCppParamType(finalArg));
return args.length > 0 ? args : undefined;
}
depth--;
} else if (ch === ',' && depth === 0) {
const arg = rawType.slice(argStart, i).trim();
if (arg.length > 0) args.push(normalizeCppParamType(arg));
argStart = i + 1;
}
}
return undefined;
}
function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
let decl = node.childForFieldName('declarator');
if (decl === null) {

View file

@ -21,6 +21,7 @@ import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './a
import { markCppInlineNamespaceRange } from './inline-namespaces.js';
import { extractCppTemplateConstraints } from './constraint-extractor.js';
import { captureCppMemberLookupFacts } from './member-lookup.js';
import { CPP_BRACED_INIT_TYPE_PREFIX } from './conversion-rank.js';
export function emitCppScopeCaptures(
sourceText: string,
@ -1022,6 +1023,8 @@ function unknownTypeClass(base: string): ParameterTypeClass {
*/
function inferCppLiteralType(node: SyntaxNode): string {
switch (node.type) {
case 'initializer_list':
return inferCppBracedInitType(node);
case 'number_literal': {
const text = node.text;
// Floating-point literals contain '.', 'e', 'E', or end with 'f'/'F'
@ -1053,6 +1056,25 @@ function inferCppLiteralType(node: SyntaxNode): string {
}
}
function inferCppBracedInitType(node: SyntaxNode): string {
const elementTypes: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child === null) continue;
if (child.type === ',' || child.type === '{' || child.type === '}') continue;
const elementType = inferCppLiteralType(child);
if (elementType === '' || elementType.startsWith(CPP_BRACED_INIT_TYPE_PREFIX)) {
return `${CPP_BRACED_INIT_TYPE_PREFIX}unknown:${elementTypes.length + 1}`;
}
elementTypes.push(elementType);
}
if (elementTypes.length === 0) return `${CPP_BRACED_INIT_TYPE_PREFIX}unknown:0`;
const first = elementTypes[0];
return elementTypes.every((type) => type === first)
? `${CPP_BRACED_INIT_TYPE_PREFIX}${first}:${elementTypes.length}`
: `${CPP_BRACED_INIT_TYPE_PREFIX}unknown:${elementTypes.length}`;
}
/**
* Look up the declared type of a variable by scanning sibling declarations
* in the enclosing compound_statement (function body). Handles:

View file

@ -21,6 +21,7 @@
*/
import type { ParameterTypeClass } from 'gitnexus-shared';
import { normalizeCppParamType } from './arity-metadata.js';
import { hasCppUserDefinedConversion } from './user-defined-conversions.js';
/** Set of normalized arithmetic types that support implicit conversion. */
@ -32,6 +33,29 @@ const INTEGRAL_PROMOTION = new Map([
['bool', 'int'],
]);
export const CPP_BRACED_INIT_TYPE_PREFIX = 'braced-init:';
export const CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES = [CPP_BRACED_INIT_TYPE_PREFIX] as const;
const BRACED_INIT_CONTAINER_TYPES = new Set([
'array',
'deque',
'list',
'set',
'std::array',
'std::deque',
'std::list',
'std::set',
'std::unordered_set',
'std::vector',
'unordered_set',
'vector',
]);
interface BracedInitArgType {
elementType: string;
elementCount?: number;
}
/**
* Return the conversion rank from `argType` to `paramType`.
*
@ -46,6 +70,20 @@ export function cppConversionRank(
argTypeClass?: ParameterTypeClass,
paramTypeClass?: ParameterTypeClass,
): number {
const bracedInitType = parseBracedInitArgType(argType);
if (bracedInitType !== undefined) {
if (bracedInitType.elementType === 'unknown') return Infinity;
if (bracedInitType.elementCount === 1) {
const scalarRank = cppConversionRank(
bracedInitType.elementType,
paramType,
undefined,
paramTypeClass,
);
if (isFinite(scalarRank)) return scalarRank;
}
return bracedInitConversionRank(paramType, bracedInitType, paramTypeClass);
}
if (argType === paramType) {
return exactShapeCompatible(argTypeClass, paramTypeClass) ? 0 : Infinity;
}
@ -60,6 +98,79 @@ export function cppConversionRank(
return Infinity;
}
function parseBracedInitArgType(argType: string): BracedInitArgType | undefined {
if (!argType.startsWith(CPP_BRACED_INIT_TYPE_PREFIX)) return undefined;
const payload = argType.slice(CPP_BRACED_INIT_TYPE_PREFIX.length);
if (payload === '') return undefined;
const separator = payload.lastIndexOf(':');
if (separator > 0) {
const countText = payload.slice(separator + 1);
if (/^\d+$/.test(countText)) {
return {
elementType: payload.slice(0, separator),
elementCount: Number(countText),
};
}
}
return { elementType: payload };
}
function bracedInitConversionRank(
paramType: string,
argType: BracedInitArgType,
paramTypeClass?: ParameterTypeClass,
): number {
const targetBase = bracedInitTargetBase(paramType);
if (targetBase === 'initializer_list' || targetBase === 'std::initializer_list') {
return bracedInitValueTypeMatches(paramType, argType, paramTypeClass) ? 0 : Infinity;
}
if (BRACED_INIT_CONTAINER_TYPES.has(targetBase)) {
return bracedInitValueTypeMatches(paramType, argType, paramTypeClass) ? 4 : Infinity;
}
return Infinity;
}
function bracedInitValueTypeMatches(
paramType: string,
argType: BracedInitArgType,
paramTypeClass?: ParameterTypeClass,
): boolean {
const valueType = bracedInitTargetValueType(paramType, paramTypeClass);
if (valueType === undefined) return false;
return isFinite(cppConversionRank(argType.elementType, valueType));
}
function bracedInitTargetValueType(
paramType: string,
paramTypeClass?: ParameterTypeClass,
): string | undefined {
return firstTemplateArgument(paramType) ?? paramTypeClass?.templateArguments?.[0];
}
function firstTemplateArgument(rawType: string): string | undefined {
const start = rawType.indexOf('<');
if (start < 0) return undefined;
let depth = 0;
for (let i = start + 1; i < rawType.length; i++) {
const ch = rawType[i];
if (ch === '<') {
depth++;
} else if (ch === '>') {
if (depth === 0) return bracedInitTargetBase(rawType.slice(start + 1, i));
depth--;
} else if (ch === ',' && depth === 0) {
return bracedInitTargetBase(rawType.slice(start + 1, i));
}
}
return undefined;
}
function bracedInitTargetBase(paramType: string): string {
return normalizeCppParamType(paramType);
}
function isPointer(typeClass: ParameterTypeClass | undefined): boolean {
return typeClass?.indirection === 'pointer' && typeClass.pointerDepth > 0;
}

View file

@ -2,14 +2,14 @@ import { readdirSync, type Dirent } from 'fs';
import { join, relative } from 'path';
/** C++ header extensions to scan for in the workspace. */
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh', '.cuh']);
/**
* Walk `repoPath` recursively and return relative paths of all C++ header files.
* Used by `loadResolutionConfig` so the C++ resolver can resolve `#include`
* targets that live in header files.
*
* Scans for: .h, .hpp, .hxx, .hh
* Scans for: .h, .hpp, .hxx, .hh, .cuh
*/
export function scanCppHeaderFiles(repoPath: string): ReadonlySet<string> {
const headers = new Set<string>();

View file

@ -33,7 +33,7 @@ import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
import { cppConversionRank } from './conversion-rank.js';
import { CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, cppConversionRank } from './conversion-rank.js';
interface RangeKey {
readonly startLine: number;
@ -167,7 +167,12 @@ export function resolveCppQualifiedNamespaceMember(
allHits,
callsite?.arity,
callsite?.argumentTypes,
callsite !== undefined ? { conversionRankFn: cppConversionRank } : undefined,
callsite !== undefined
? {
conversionRankFn: cppConversionRank,
conversionOnlyArgTypePrefixes: CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES,
}
: undefined,
);
if (narrowed.length === 1) return narrowed[0];
if (narrowed.length === 0) return undefined;

View file

@ -13,7 +13,7 @@ import {
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { cppConstraintCompatibility } from './constraint-filter.js';
import { cppConversionRank } from './conversion-rank.js';
import { CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, cppConversionRank } from './conversion-rank.js';
interface CapturedBaseEdge {
readonly childName: string;
@ -309,6 +309,7 @@ function chooseOverload(
const narrowed = narrowOverloadCandidates(candidates, callsite.arity, callsite.argumentTypes, {
argumentTypeClasses: callsite.argumentTypeClasses,
conversionRankFn: cppConversionRank,
conversionOnlyArgTypePrefixes: CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES,
constraintCompatibility: cppConstraintCompatibility,
});
if (narrowed.length === 1) return { kind: 'resolved', definition: narrowed[0]! };

View file

@ -11,7 +11,7 @@ import {
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { cppProvider } from '../c-cpp.js';
import { cppArityCompatibility } from './arity.js';
import { cppConversionRank } from './conversion-rank.js';
import { CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, cppConversionRank } from './conversion-rank.js';
import { cppMergeBindings } from './merge-bindings.js';
import { resolveCppImportTarget } from './import-target.js';
import { scanCppHeaderFiles } from './header-scan.js';
@ -257,6 +257,7 @@ export const cppScopeResolver: ScopeResolver = {
// Disambiguates `f(int)` vs `f(double)` called with `f(2.5)` by scoring
// each candidate's conversion cost; exact match wins over standard conversion.
conversionRankFn: cppConversionRank,
conversionOnlyArgTypePrefixes: CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES,
// Range-for element type inference: for (auto& user : users) → bind user to User
populateRangeBindings: populateCppRangeBindings,
// C++ method return-type bindings need to be visible from module scope

View file

@ -24,6 +24,7 @@ import { createMethodExtractor } from '../method-extractors/generic.js';
import { csharpMethodConfig } from '../method-extractors/configs/csharp.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js';
import { createCsharpCfgVisitor } from '../cfg/visitors/csharp.js';
import {
emitCsharpScopeCaptures,
interpretCsharpImport,
@ -200,6 +201,7 @@ export const csharpProvider = defineLanguage({
// the full per-hook rationale and the canonical capture vocabulary
// in ./csharp/query.ts (CSHARP_SCOPE_QUERY constant).
emitScopeCaptures: emitCsharpScopeCaptures,
cfgVisitor: createCsharpCfgVisitor(),
interpretImport: interpretCsharpImport,
interpretTypeBinding: interpretCsharpTypeBinding,
bindingScopeFor: csharpBindingScopeFor,

View file

@ -22,6 +22,7 @@ import { dartExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { dartImportConfig } from '../import-resolvers/configs/dart.js';
import { DART_QUERIES } from '../tree-sitter-queries.js';
import { createDartCfgVisitor } from '../cfg/visitors/dart.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
@ -131,6 +132,7 @@ export const dartProvider = defineLanguage({
// emit-side `ScopeResolver` lives in `dart/scope-resolver.ts`; the same
// function references flow through both interfaces.
emitScopeCaptures: emitDartScopeCaptures,
cfgVisitor: createDartCfgVisitor(),
interpretImport: interpretDartImport,
interpretTypeBinding: interpretDartTypeBinding,
bindingScopeFor: dartBindingScopeFor,

View file

@ -11,6 +11,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { goClassConfig } from '../class-extractors/configs/go.js';
import { createGoCfgVisitor } from '../cfg/visitors/go.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as goConfig } from '../type-extractors/go.js';
import { goExportChecker } from '../export-detection.js';
@ -141,6 +142,7 @@ export const goProvider = defineLanguage({
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
emitScopeCaptures: emitGoScopeCaptures,
cfgVisitor: createGoCfgVisitor(),
interpretImport: interpretGoImport,
interpretTypeBinding: interpretGoTypeBinding,
bindingScopeFor: goBindingScopeFor,

View file

@ -26,6 +26,7 @@ import { createMethodExtractor } from '../method-extractors/generic.js';
import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
import { createJavaCfgVisitor } from '../cfg/visitors/java.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import {
emitJavaScopeCaptures,
@ -118,6 +119,9 @@ export const javaProvider = defineLanguage({
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitJavaScopeCaptures,
// ── PDG: per-function CFG + def/use harvest (#2195 U4) ──
cfgVisitor: createJavaCfgVisitor(),
interpretImport: interpretJavaImport,
interpretTypeBinding: interpretJavaTypeBinding,
bindingScopeFor: javaBindingScopeFor,

View file

@ -22,6 +22,7 @@ import type { AstFrameworkPatternConfig } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { kotlinCallConfig } from '../call-extractors/configs/jvm.js';
import { createKotlinCfgVisitor } from '../cfg/visitors/kotlin.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { kotlinConfig } from '../field-extractors/configs/jvm.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
@ -177,6 +178,8 @@ export const kotlinProvider = defineLanguage({
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitKotlinScopeCaptures,
// ── #2195 PDG layer: Kotlin CFG visitor (vendored grammar) ──
cfgVisitor: createKotlinCfgVisitor(),
// Worker-side: snapshot the module-level companion-scope marks
// `emitKotlinScopeCaptures` just populated for this file (`markCompanionScope`
// → `companionScopesByFile`) into plain data on `ParsedFile.captureSideChannel`,

View file

@ -20,6 +20,7 @@ import {
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { phpClassConfig } from '../class-extractors/configs/php.js';
import { createPhpCfgVisitor } from '../cfg/visitors/php.js';
import { defineLanguage, type AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
@ -297,6 +298,7 @@ export const phpProvider = defineLanguage({
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────────────────
emitScopeCaptures: emitPhpScopeCaptures,
cfgVisitor: createPhpCfgVisitor(),
interpretImport: interpretPhpImport,
interpretTypeBinding: interpretPhpTypeBinding,
// LanguageProvider uses (def, callsite); phpArityCompatibility uses (def, callsite) — same.

View file

@ -27,6 +27,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
import { pythonVariableConfig } from '../variable-extractors/configs/python.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { pythonCallConfig } from '../call-extractors/configs/python.js';
import { createPythonCfgVisitor } from '../cfg/visitors/python.js';
import type { CaptureMap } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import {
@ -137,6 +138,7 @@ export const pythonProvider = defineLanguage({
// full per-hook rationale and the canonical capture vocabulary in
// ./python/query.ts (PYTHON_SCOPE_QUERY constant).
emitScopeCaptures: emitPythonScopeCaptures,
cfgVisitor: createPythonCfgVisitor(),
interpretImport: interpretPythonImport,
interpretTypeBinding: interpretPythonTypeBinding,
bindingScopeFor: pythonBindingScopeFor,

View file

@ -28,6 +28,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
import { rubyVariableConfig } from '../variable-extractors/configs/ruby.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { rubyCallConfig } from '../call-extractors/configs/ruby.js';
import { createRubyCfgVisitor } from '../cfg/visitors/ruby.js';
import {
emitRubyScopeCaptures,
rubyArityCompatibility,
@ -205,6 +206,7 @@ export const rubyProvider = defineLanguage({
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
emitScopeCaptures: emitRubyScopeCaptures,
cfgVisitor: createRubyCfgVisitor(),
interpretImport: interpretRubyImport,
interpretTypeBinding: interpretRubyTypeBinding,
bindingScopeFor: rubyBindingScopeFor,

View file

@ -28,6 +28,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
import { rustVariableConfig } from '../variable-extractors/configs/rust.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { rustCallConfig } from '../call-extractors/configs/rust.js';
import { createRustCfgVisitor } from '../cfg/visitors/rust.js';
import {
emitRustScopeCaptures,
rustArityCompatibility,
@ -178,6 +179,7 @@ export const rustProvider = defineLanguage({
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
emitScopeCaptures: emitRustScopeCaptures,
cfgVisitor: createRustCfgVisitor(),
interpretImport: interpretRustImport,
interpretTypeBinding: interpretRustTypeBinding,
bindingScopeFor: rustBindingScopeFor,

View file

@ -25,6 +25,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js';
import { swiftVariableConfig } from '../variable-extractors/configs/swift.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { swiftCallConfig } from '../call-extractors/configs/swift.js';
import { createSwiftCfgVisitor } from '../cfg/visitors/swift.js';
import {
emitSwiftScopeCaptures,
interpretSwiftImport,
@ -247,6 +248,7 @@ export const swiftProvider = defineLanguage({
// ── Scope-based resolution hooks (RFC #909 Ring 3, issue #937). See
// languages/swift/ for the implementations. ──────────────────────
emitScopeCaptures: emitSwiftScopeCaptures,
cfgVisitor: createSwiftCfgVisitor(),
interpretImport: interpretSwiftImport,
interpretTypeBinding: interpretSwiftTypeBinding,
bindingScopeFor: swiftBindingScopeFor,

View file

@ -37,6 +37,7 @@ import {
resolveTsImportTarget,
} from './typescript/index.js';
import { emitVueScopeCaptures } from './vue/captures.js';
import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js';
const VUE_SPECIFIC_BUILT_INS = [
'ref',
@ -88,6 +89,11 @@ export const vueProvider = defineLanguage({
variableExtractor: createVariableExtractor(typescriptVariableConfig),
classExtractor: vueClassExtractor,
builtInNames: VUE_BUILT_INS,
// Vue SFC <script> blocks are extracted and parsed with the TypeScript
// grammar (parse-worker GRAMMAR_BY_LANGUAGE[Vue] = TypeScript.typescript),
// so the TS CFG visitor builds CFGs for the script's functions verbatim —
// no Vue-specific visitor needed (#2195).
cfgVisitor: createTypeScriptCfgVisitor(),
// Scope-resolution pipeline hooks (RFC #909 Ring 3)
emitScopeCaptures: emitVueScopeCaptures,
interpretImport: interpretTsImport,

View file

@ -8,6 +8,7 @@ import { accumulateExportedTypesFromParsedNode, type ExportedTypeMap } from './c
import type { ParsedFile } from 'gitnexus-shared';
import { WorkerPool } from './workers/worker-pool.js';
import type { SkippedPath } from './workers/clone-safety.js';
import type { CfgSkipCounts } from './cfg/collect.js';
import { logger } from '../logger.js';
import type {
ParseWorkerResult,
@ -198,6 +199,31 @@ export const dispatchChunkParse = async (
logger.warn(` Skipped unsupported languages: ${summary}`);
}
// Per-language CFG skip telemetry (#2195): functions skipped during the worker
// CFG walk, bucketed by reason. Only surfaced for a `--pdg` run (otherwise
// `cfgSkipped` is empty). Warn ONLY when a robustness-relevant bucket
// (too-deeply-nested / build-error) is non-zero — a too-many-lines skip is the
// expected, benign minified/generated-code case and would otherwise be spam.
const cfgSkipped = new Map<string, CfgSkipCounts>();
for (const result of chunkResults) {
for (const [lang, counts] of Object.entries(result.cfgSkipped ?? {})) {
const prev = cfgSkipped.get(lang) ?? { tooManyLines: 0, tooDeeplyNested: 0, buildError: 0 };
cfgSkipped.set(lang, {
tooManyLines: prev.tooManyLines + counts.tooManyLines,
tooDeeplyNested: prev.tooDeeplyNested + counts.tooDeeplyNested,
buildError: prev.buildError + counts.buildError,
});
}
}
for (const [lang, c] of cfgSkipped) {
if (c.tooDeeplyNested > 0 || c.buildError > 0) {
logger.warn(
` CFG functions skipped (${lang}): ${c.tooDeeplyNested} too-deeply-nested, ` +
`${c.buildError} build-error(s), ${c.tooManyLines} over line cap`,
);
}
}
// Clone-safety telemetry (#2112): files whose parse output carried a value
// the structured-clone algorithm couldn't serialize across the worker
// boundary. The worker sanitized/dropped the offending value so the run

View file

@ -121,6 +121,23 @@ export interface PipelineOptions {
/** Per-run `TAINT_PATH` edge cap (#2084 review P1-3). `undefined` ⇒
* `DEFAULT_PDG_MAX_INTERPROC_EDGES` (1000); `0` ⇒ no cap. */
pdgMaxInterprocEdges?: number;
/**
* Streaming/chunked PDG graph emit (#2202). When true, the BasicBlock +
* intra-file PDG-edge layer (CFG / REACHING_DEF / CDG / POST_DOMINATE /
* TAINTED / SANITIZES) is streamed to CSV-on-disk during the scope-resolution
* emit loop instead of being materialized in the in-memory graph, bounding
* peak RSS to O(chunk) rather than O(graph) at full-kernel scale. Already
* gated by the caller to full-rebuild runs only (the incremental writeback
* reads BasicBlocks back from the in-memory graph). Memory-only — produces a
* byte-identical persisted graph and is NOT part of `RepoMeta.pdg`, so
* toggling it never trips `pdgModeMismatch`. Default/false ⇒ today's
* whole-graph emit.
*/
streamPdgEmit?: boolean;
/** Streamed PDG-emit write buffer (rows) when `streamPdgEmit` is on (#2202).
* `undefined` ⇒ `DEFAULT_PDG_EMIT_CHUNK_ROWS`. Memory-only; does not affect
* emitted bytes. */
pdgEmitChunkSize?: number;
/**
* Request parsing with the worker pool disabled. The sequential parser was
* removed — the worker pool is the sole parse path — so setting this now
@ -287,10 +304,10 @@ export const runPipelineFromRepo = async (
let communityResult: CommunitiesOutput['communityResult'] | undefined;
let processResult: ProcessesOutput['processResult'] | undefined;
const resolutionOutcomes = getPhaseOutput<ScopeResolutionOutput>(
results,
'scopeResolution',
).resolutionOutcomes;
const scopeResolutionOutput = getPhaseOutput<ScopeResolutionOutput>(results, 'scopeResolution');
const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes;
// Streamed PDG-emit manifest (#2202): present only when streaming was on.
const pdgEmitManifest = scopeResolutionOutput.pdgEmitManifest;
if (!options?.skipGraphPhases) {
communityResult = getPhaseOutput<CommunitiesOutput>(results, 'communities').communityResult;
@ -319,5 +336,6 @@ export const runPipelineFromRepo = async (
processResult,
resolutionOutcomes,
usedWorkerPool,
pdgEmitManifest,
};
};

View file

@ -654,12 +654,19 @@ function parseJsonParameterTypeClassesCapture(
if (typeof o.pointerDepth !== 'number' || !Number.isFinite(o.pointerDepth)) {
return undefined;
}
out.push({
const shape: ParameterTypeClass = {
base: o.base,
cv: o.cv,
indirection: o.indirection,
pointerDepth: o.pointerDepth,
});
};
if (Array.isArray(o.templateArguments)) {
if (!o.templateArguments.every((x): x is string => typeof x === 'string')) {
return undefined;
}
shape.templateArguments = [...o.templateArguments];
}
out.push(shape);
}
return out;
} catch {

View file

@ -706,6 +706,16 @@ export interface ScopeResolver {
*/
readonly conversionRankFn?: ConversionRankFn;
/**
* Optional per-language argument-type prefixes for conversion-only
* argument sentinels. When ranking cannot find any viable candidate
* for a multi-overload set containing one of these sentinels, shared
* narrowing suppresses the ambiguous set instead of falling back to
* arity-only candidates. Languages without such sentinels leave this
* undefined.
*/
readonly conversionOnlyArgTypePrefixes?: readonly string[];
/**
* Optional predicate to identify definitions with file-local linkage
* (e.g. C `static` functions). When provided, `pickUniqueGlobalCallable`

View file

@ -80,6 +80,7 @@ export function emitFreeCallFallback(
parsedFiles: readonly ParsedFile[],
) => readonly SymbolDefinition[] | undefined;
readonly conversionRankFn?: ConversionRankFn;
readonly conversionOnlyArgTypePrefixes?: readonly string[];
/** Optional per-language constraint hook threaded into
* `narrowOverloadCandidates`. Drops candidates whose template
* constraints (e.g. C++ `enable_if_t`, C++20 `requires`) provably
@ -164,6 +165,7 @@ export function emitFreeCallFallback(
if (fnDef === undefined) {
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model, {
conversionRankFn: options.conversionRankFn,
conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes,
constraintCompatibility: options.constraintCompatibility,
});
fnDefFromImplicitThis = fnDef !== undefined;
@ -191,6 +193,7 @@ export function emitFreeCallFallback(
{
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: options.conversionRankFn,
conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes,
constraintCompatibility: options.constraintCompatibility,
},
);
@ -277,6 +280,7 @@ export function emitFreeCallFallback(
const narrowed = narrowOverloadCandidates(ordinary, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: options.conversionRankFn,
conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes,
constraintCompatibility: options.constraintCompatibility,
});
if (narrowed.length === 1) {
@ -324,6 +328,7 @@ export function emitFreeCallFallback(
const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: options.conversionRankFn,
conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes,
constraintCompatibility: options.constraintCompatibility,
});
if (narrowed.length === 1) {
@ -380,6 +385,7 @@ export function emitFreeCallFallback(
site.argumentTypeClasses,
options.conversionRankFn,
scopeDefsCache,
options.conversionOnlyArgTypePrefixes,
);
}
if (fnDef === undefined) continue;
@ -576,6 +582,7 @@ export function pickUniqueGlobalCallable(
callArgTypeClasses?: readonly ParameterTypeClass[],
conversionRankFn?: ConversionRankFn,
scopeDefsCache?: Map<string, readonly SymbolDefinition[]>,
conversionOnlyArgTypePrefixes?: readonly string[],
): SymbolDefinition | undefined {
// The scope-index candidate list is a pure function of (name, callerFilePath):
// the same-name bucket is fixed for the pass, the file-local filter depends
@ -637,6 +644,7 @@ export function pickUniqueGlobalCallable(
const narrowed = narrowOverloadCandidates(scopeDefs, callArity, callArgTypes, {
argumentTypeClasses: callArgTypeClasses,
conversionRankFn,
conversionOnlyArgTypePrefixes,
});
if (narrowed.length === 1) return narrowed[0];
}
@ -678,6 +686,7 @@ export function pickUniqueGlobalCallable(
const narrowed = narrowOverloadCandidates(defs, callArity, callArgTypes, {
argumentTypeClasses: callArgTypeClasses,
conversionRankFn,
conversionOnlyArgTypePrefixes,
});
if (narrowed.length === 1) return narrowed[0];
}
@ -808,6 +817,7 @@ export function pickImplicitThisOverload(
model: SemanticModel,
hookCtx?: {
readonly conversionRankFn?: ConversionRankFn;
readonly conversionOnlyArgTypePrefixes?: readonly string[];
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
},
): SymbolDefinition | undefined {
@ -840,6 +850,7 @@ export function pickImplicitThisOverload(
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: hookCtx?.conversionRankFn,
conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes,
constraintCompatibility: hookCtx?.constraintCompatibility,
});
if (candidates.length !== 1) return undefined;

View file

@ -83,6 +83,10 @@ export interface OverloadNarrowingHookCtx {
/** Conversion-rank scoring fallback (step 4b). Engages when the
* exact-type filter rejects every candidate. */
readonly conversionRankFn?: ConversionRankFn;
/** Per-language argument-type prefixes whose conversion-rank failures
* should suppress genuinely ambiguous multi-overload sets instead of
* falling back to arity-only candidates. */
readonly conversionOnlyArgTypePrefixes?: readonly string[];
/** Constraint filter (step 4c). Drops candidates whose template
* guards (SFINAE `enable_if_t`, C++20 `requires`, future Rust
* trait bounds, etc.) provably fail at the call site. Three-valued
@ -176,6 +180,12 @@ export function narrowOverloadCandidates(
hookCtx.argumentTypeClasses,
);
if (ranked.length > 0) result = ranked;
else if (
candidates.length > 1 &&
hasConversionOnlyArgType(argTypes, hookCtx.conversionOnlyArgTypePrefixes)
) {
result = [];
}
}
}
@ -222,6 +232,14 @@ export function narrowOverloadCandidates(
return result;
}
function hasConversionOnlyArgType(
argTypes: readonly string[],
prefixes: readonly string[] | undefined,
): boolean {
if (prefixes === undefined || prefixes.length === 0) return false;
return argTypes.some((type) => prefixes.some((prefix) => type.startsWith(prefix)));
}
function exactTypeSlotMatches(
argType: string,
paramType: string,

View file

@ -85,6 +85,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'resolveReceiverMember'
| 'resolveThisViaEnclosingClass'
| 'conversionRankFn'
| 'conversionOnlyArgTypePrefixes'
| 'constraintCompatibility'
| 'isStaticOnly'
>;
@ -519,6 +520,7 @@ export function emitReceiverBoundCalls(
{
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: provider.conversionRankFn,
conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes,
constraintCompatibility: provider.constraintCompatibility,
},
);
@ -1275,6 +1277,7 @@ function pickOverload(
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: provider.conversionRankFn,
conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes,
constraintCompatibility: provider.constraintCompatibility,
});
// When narrowing leaves >1 candidate that share identical normalized
@ -1382,6 +1385,7 @@ function pickFirstNonStaticOnly(
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: provider.conversionRankFn,
conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes,
constraintCompatibility: provider.constraintCompatibility,
});
// Same ambiguity handling as `pickOverload`: when normalization
@ -1427,6 +1431,7 @@ function recordReceiverOverloadSuppression(
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
argumentTypeClasses: site.argumentTypeClasses,
conversionRankFn: provider.conversionRankFn,
conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes,
constraintCompatibility: provider.constraintCompatibility,
});
const reason: ResolutionSuppressionReason = isOverloadAmbiguousAfterNormalization(

View file

@ -44,6 +44,8 @@ import {
import type { ResolutionOutcome } from '../resolution-outcome.js';
import type { FunctionSummary } from '../../taint/summary-model.js';
import { buildFunctionNodeIndex } from '../../taint/summary-harvest-driver.js';
import { PdgEmitSink, type PdgEmitManifest } from '../../../lbug/pdg-emit-sink.js';
import { resolveNativeSafeStorageDir } from '../../../lbug/lbug-config.js';
import { logger } from '../../../logger.js';
export interface ScopeResolutionOutput {
@ -72,6 +74,14 @@ export interface ScopeResolutionOutput {
* The `taintSummaries` phase composes these over the `CALLS` graph.
*/
readonly functionSummaries: readonly FunctionSummary[];
/**
* Streamed PDG-emit COPY manifest (#2202). Present only when streaming was on
* (full rebuild + `--pdg` + enabled): the BasicBlock node CSV + per-pair PDG
* edge CSVs that were flushed to disk during the emit loop, for the persistence
* step to COPY alongside the structural CSVs. Absent ⇒ the PDG layer (if any)
* is in the in-memory graph and persists via the normal whole-graph emit.
*/
readonly pdgEmitManifest?: PdgEmitManifest;
}
const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({
@ -242,236 +252,291 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
? buildFunctionNodeIndex(ctx.graph)
: undefined;
for (const [lang, provider] of SCOPE_RESOLVERS) {
// Standalone providers (COBOL, JCL) don't emit graph edges yet
// through the scope-resolution path. This is the canonical guard:
// runScopeResolution is never called for standalone providers, which
// keeps cobolPhase as the sole IMPORTS edge producer. Keep this guard
// in sync with any additional standalone providers added to
// SCOPE_RESOLVERS.
if (provider.languageProvider.parseStrategy === 'standalone') continue;
const primaryLangFiles = filesByLang.get(lang) ?? [];
if (primaryLangFiles.length === 0) continue;
const primaryFilePaths = primaryLangFiles.map((f) => f.path);
// Load per-language import-resolution config (tsconfig paths,
// composer.json autoload, go.mod, ...). One I/O round trip per
// workspace pass — cached implicitly by the result handed to
// every `resolveImportTarget` call below.
const resolutionConfig =
provider.loadResolutionConfig !== undefined
? await provider.loadResolutionConfig(ctx.repoPath)
: undefined;
// Some languages (e.g. Vue) expand their file universe beyond the
// primary-language files via the `collectScopeContextPaths` hook.
// The hook receives raw source contents of the primary files so it
// can trace import closures without a second tree-sitter parse.
//
// To avoid reading primary files twice (once for the hook, once for
// the resolution pass), we read them upfront and merge with the
// extra context paths the hook may add.
// Stream this language's pre-built ParsedFiles in from the disk store
// FIRST (huge-repo path). Doing it before reading source lets us skip
// loading content for files the store already covers — for a provider
// with no content-consuming hook that source is pure dead weight once
// extraction is served from the store (~1.5 GB on the kernel's C pass).
// Merged into `preExtractedByPath`; the per-language release block below
// evicts these again before the next language, so only one language's
// ParsedFiles are resident at a time.
const loadStoreFor = async (paths: ReadonlySet<string>): Promise<void> => {
if (!parsedFileStorePath) return;
const fromDisk = await loadParsedFilesForPaths(parsedFileStorePath, paths);
for (const [fp, pf] of fromDisk) preExtractedByPath.set(fp, pf);
};
// A provider that feeds source text into a post-extract hook
// (populateWorkspaceOwners / populateNamespaceSiblings /
// populateRangeBindings / emitPostResolutionEdges) needs content for ALL
// its files; one without those hooks only needs content for files the
// store does NOT cover (fresh-extract fallback). Keep this in sync with
// the getFileContents() call-sites in run.ts.
const providerNeedsAllContent =
provider.populateWorkspaceOwners !== undefined ||
provider.populateNamespaceSiblings !== undefined ||
provider.populateRangeBindings !== undefined ||
provider.emitPostResolutionEdges !== undefined;
let scopeFilePaths: Set<string>;
let contents: Map<string, string>;
if (provider.collectScopeContextPaths !== undefined) {
// Context-expanding providers (e.g. Vue) need every primary file's
// source up front for the closure hook, so load it all.
const entryFileContents = await readFileContents(ctx.repoPath, primaryFilePaths);
scopeFilePaths = provider.collectScopeContextPaths({
primaryFilePaths,
preExtractedByPath,
entryFileContents,
allScannedPaths,
resolutionConfig,
});
// Read only the extra context files (TS/JS etc.) not already loaded.
const extraPaths = [...scopeFilePaths].filter((p) => !entryFileContents.has(p));
const extraContents = await readFileContents(ctx.repoPath, extraPaths);
contents = new Map([...entryFileContents, ...extraContents]);
await loadStoreFor(scopeFilePaths);
// Streaming/chunked PDG emit (#2202): when enabled (the caller has already
// gated this to full-rebuild + `--pdg`), route the BasicBlock + intra-file
// PDG-edge layer to CSV-on-disk through one sink shared across every
// language pass, so it never accumulates in `ctx.graph` (peak RSS O(chunk)).
// Needs the storage dir (the parse-cache store path, the same `.gitnexus`
// dir loadGraphToLbug COPYs from); if that is somehow absent we skip
// streaming and fall back to the in-memory whole-graph emit.
let pdgEmitSink: PdgEmitSink | undefined;
if (ctx.options?.streamPdgEmit === true && totalScopeFiles > 0) {
if (parsedFileStorePath) {
pdgEmitSink = new PdgEmitSink(
ctx.graph,
// Same ASCII-safe relocation the structural CSVs get (#2202 review #2):
// on Windows non-ASCII storage paths the COPY can't open files under
// the native path, so the dir is relocated to a hashed os.tmpdir().
resolveNativeSafeStorageDir(parsedFileStorePath, 'pdg-csv'),
ctx.options?.pdgEmitChunkSize,
);
} else {
scopeFilePaths = new Set(primaryFilePaths);
await loadStoreFor(scopeFilePaths);
const pathsToRead = providerNeedsAllContent
? primaryFilePaths
: primaryFilePaths.filter((p) => !preExtractedByPath.has(p));
contents = await readFileContents(ctx.repoPath, pathsToRead);
}
const filePaths = [...scopeFilePaths];
const files: { path: string; content: string }[] = [];
for (const fp of filePaths) {
const content = contents.get(fp);
if (content !== undefined) {
files.push({ path: fp, content });
} else if (preExtractedByPath.has(fp)) {
// Store covers extraction for this file and we deliberately skipped
// reading its source; the empty string is never consumed (the
// extract loop uses the pre-extracted ParsedFile and this provider
// has no content hook).
files.push({ path: fp, content: '' });
}
// else: uncovered AND unreadable → skip (unchanged from prior behavior).
}
const langFileCount = files.length;
logHeapProbe(
'scope-lang-start',
`lang=${lang} files=${langFileCount} contentsLoaded=${contents.size}`,
);
const langLabel = lang.charAt(0).toUpperCase() + lang.slice(1);
currentLangIdx++;
const langTag =
totalScopeLangs > 1 ? `${langLabel} [${currentLangIdx}/${totalScopeLangs}]` : langLabel;
if (totalScopeFiles > 0) {
const pct =
SCOPE_PCT_START + Math.round((processedScopeFiles / totalScopeFiles) * SCOPE_PCT_RANGE);
ctx.onProgress({
phase: 'scopeResolution',
percent: pct,
message: 'Resolving types',
detail: `${langTag}, ${langFileCount.toLocaleString()} files`,
});
}
const stats = runScopeResolution(
{
graph: ctx.graph,
model,
files,
resolutionConfig,
prebuiltNodeLookup: sharedNodeLookup,
prebuiltFunctionNodeIndex: sharedFnNodeIndex,
preExtractedParsedFiles: preExtractedByPath,
scopeIndexStorePath: parsedFileStorePath,
// CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph.
pdg: ctx.options?.pdg === true,
pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: ctx.options?.pdgMaxReachingDefEdgesPerFunction,
pdgMaxCdgEdgesPerFunction: ctx.options?.pdgMaxCdgEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: ctx.options?.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: ctx.options?.pdgMaxTaintHops,
recordResolutionOutcome: (outcome) => {
resolutionOutcomes.push(outcome);
},
onWarn: (msg) => {
if (isSemanticModelValidatorEnabled()) {
logger.warn(`[scope-resolution:${lang}] ${msg}`);
}
},
onProgress:
totalScopeFiles > 0
? (subPhase: ScopeResolutionSubPhase, current, total) => {
let langRatio: number;
switch (subPhase) {
case 'extracting':
langRatio = total > 0 ? (current / total) * 0.5 : 0;
break;
case 'analyzing types':
langRatio = 0.5;
break;
case 'resolving references':
langRatio = 0.7;
break;
case 'linking symbols':
langRatio = 0.85;
break;
default: {
const _exhaustive: never = subPhase;
langRatio = 0.85;
}
}
const overallRatio = Math.min(
1,
(processedScopeFiles + langRatio * langFileCount) / totalScopeFiles,
);
const pct = SCOPE_PCT_START + Math.round(overallRatio * SCOPE_PCT_RANGE);
ctx.onProgress({
phase: 'scopeResolution',
percent: pct,
message: 'Resolving types',
detail:
subPhase === 'extracting'
? `${langTag} — extracting ${current.toLocaleString()}/${total.toLocaleString()} files`
: `${langTag} — ${subPhase}`,
});
}
: undefined,
},
provider,
);
// Release file contents and pre-extracted entries after each language
// to reduce memory pressure. For large codebases (16K+ PHP files),
// holding all source code simultaneously with scope trees causes OOM.
// See: https://github.com/abhigyanpatwari/GitNexus/issues/1741
//
// Use `filePaths` (not `primaryFilePaths`) so that any context files
// added by `collectScopeContextPaths` (e.g. TS/JS files pulled in for
// Vue cross-file resolution) are also evicted and not held until GC.
files.length = 0;
contents.clear();
for (const fp of filePaths) {
preExtractedByPath.delete(fp);
}
// This language's ParsedFiles are now unreachable (runScopeResolution has
// returned and the Map entries are deleted). Force a GC HERE so a heavy
// language's ~17-20GB set (e.g. C/C++ on the Linux kernel) is reclaimed
// BEFORE the next language's store-load — instead of leaving V8 to collect
// it lazily under the next pass's allocation pressure (which, at a cap >=
// RAM, degrades into swap-thrash). Collects only dead objects: the live
// cross-file index of the next pass is untouched. The pre/post probe
// confirms whether old-space fragmentation defeats the reclaim.
logHeapProbe('lang-release-pre-gc', `lang=${lang}`);
forceGc();
logHeapProbe('lang-release-post-gc', `lang=${lang}`);
logHeapProbe('scope-lang-end', `lang=${lang} filesProcessed=${stats.filesProcessed}`);
processedScopeFiles += langFileCount;
anyRan = true;
functionSummaries.push(...stats.functionSummaries);
totalFiles += stats.filesProcessed;
totalImports += stats.importsEmitted;
totalRefs += stats.referenceEdgesEmitted;
perLanguage.set(lang, {
filesProcessed: stats.filesProcessed,
importsEmitted: stats.importsEmitted,
referenceEdgesEmitted: stats.referenceEdgesEmitted,
});
if (isDev) {
logger.info(
`[scope-resolution:${lang}] ${stats.filesProcessed} files → ${stats.importsEmitted} IMPORTS + ${stats.referenceEdgesEmitted} reference edges (${stats.resolve.unresolved} unresolved sites, ${stats.referenceSkipped} skipped)`,
logger.warn(
'[scope-resolution] streaming PDG emit requested but no storage path is ' +
'available; falling back to in-memory whole-graph emit',
);
}
}
// Cross-pass per-file dedup set for the streaming sink (#2202): one set
// shared across every language pass so a file emitted in two passes (e.g. a
// `.ts` module pulled into the Vue context pass) streams its PDG layer once.
// Only created when streaming — the in-memory-graph path dedups via its Map.
const pdgEmittedFiles = pdgEmitSink !== undefined ? new Set<string>() : undefined;
// Stream the PDG layer with guaranteed writer cleanup: a throw escaping the
// per-language loop (outside run.ts's per-file try/catch — e.g. from
// finalize/propagate/a provider hook) must still release the sink's file
// descriptors. finalize() runs on the success path; the finally closes the
// sink only when finalize did not (idempotent via the sink's `finalized`).
let pdgEmitManifest: PdgEmitManifest | undefined;
let pdgSinkSettled = false;
try {
for (const [lang, provider] of SCOPE_RESOLVERS) {
// Standalone providers (COBOL, JCL) don't emit graph edges yet
// through the scope-resolution path. This is the canonical guard:
// runScopeResolution is never called for standalone providers, which
// keeps cobolPhase as the sole IMPORTS edge producer. Keep this guard
// in sync with any additional standalone providers added to
// SCOPE_RESOLVERS.
if (provider.languageProvider.parseStrategy === 'standalone') continue;
const primaryLangFiles = filesByLang.get(lang) ?? [];
if (primaryLangFiles.length === 0) continue;
const primaryFilePaths = primaryLangFiles.map((f) => f.path);
// Load per-language import-resolution config (tsconfig paths,
// composer.json autoload, go.mod, ...). One I/O round trip per
// workspace pass — cached implicitly by the result handed to
// every `resolveImportTarget` call below.
const resolutionConfig =
provider.loadResolutionConfig !== undefined
? await provider.loadResolutionConfig(ctx.repoPath)
: undefined;
// Some languages (e.g. Vue) expand their file universe beyond the
// primary-language files via the `collectScopeContextPaths` hook.
// The hook receives raw source contents of the primary files so it
// can trace import closures without a second tree-sitter parse.
//
// To avoid reading primary files twice (once for the hook, once for
// the resolution pass), we read them upfront and merge with the
// extra context paths the hook may add.
// Stream this language's pre-built ParsedFiles in from the disk store
// FIRST (huge-repo path). Doing it before reading source lets us skip
// loading content for files the store already covers — for a provider
// with no content-consuming hook that source is pure dead weight once
// extraction is served from the store (~1.5 GB on the kernel's C pass).
// Merged into `preExtractedByPath`; the per-language release block below
// evicts these again before the next language, so only one language's
// ParsedFiles are resident at a time.
const loadStoreFor = async (paths: ReadonlySet<string>): Promise<void> => {
if (!parsedFileStorePath) return;
const fromDisk = await loadParsedFilesForPaths(parsedFileStorePath, paths);
for (const [fp, pf] of fromDisk) preExtractedByPath.set(fp, pf);
};
// A provider that feeds source text into a post-extract hook
// (populateWorkspaceOwners / populateNamespaceSiblings /
// populateRangeBindings / emitPostResolutionEdges) needs content for ALL
// its files; one without those hooks only needs content for files the
// store does NOT cover (fresh-extract fallback). Keep this in sync with
// the getFileContents() call-sites in run.ts.
const providerNeedsAllContent =
provider.populateWorkspaceOwners !== undefined ||
provider.populateNamespaceSiblings !== undefined ||
provider.populateRangeBindings !== undefined ||
provider.emitPostResolutionEdges !== undefined;
let scopeFilePaths: Set<string>;
let contents: Map<string, string>;
if (provider.collectScopeContextPaths !== undefined) {
// Context-expanding providers (e.g. Vue) need every primary file's
// source up front for the closure hook, so load it all.
const entryFileContents = await readFileContents(ctx.repoPath, primaryFilePaths);
scopeFilePaths = provider.collectScopeContextPaths({
primaryFilePaths,
preExtractedByPath,
entryFileContents,
allScannedPaths,
resolutionConfig,
});
// Read only the extra context files (TS/JS etc.) not already loaded.
const extraPaths = [...scopeFilePaths].filter((p) => !entryFileContents.has(p));
const extraContents = await readFileContents(ctx.repoPath, extraPaths);
contents = new Map([...entryFileContents, ...extraContents]);
await loadStoreFor(scopeFilePaths);
} else {
scopeFilePaths = new Set(primaryFilePaths);
await loadStoreFor(scopeFilePaths);
const pathsToRead = providerNeedsAllContent
? primaryFilePaths
: primaryFilePaths.filter((p) => !preExtractedByPath.has(p));
contents = await readFileContents(ctx.repoPath, pathsToRead);
}
const filePaths = [...scopeFilePaths];
const files: { path: string; content: string }[] = [];
for (const fp of filePaths) {
const content = contents.get(fp);
if (content !== undefined) {
files.push({ path: fp, content });
} else if (preExtractedByPath.has(fp)) {
// Store covers extraction for this file and we deliberately skipped
// reading its source; the empty string is never consumed (the
// extract loop uses the pre-extracted ParsedFile and this provider
// has no content hook).
files.push({ path: fp, content: '' });
}
// else: uncovered AND unreadable → skip (unchanged from prior behavior).
}
const langFileCount = files.length;
logHeapProbe(
'scope-lang-start',
`lang=${lang} files=${langFileCount} contentsLoaded=${contents.size}`,
);
const langLabel = lang.charAt(0).toUpperCase() + lang.slice(1);
currentLangIdx++;
const langTag =
totalScopeLangs > 1 ? `${langLabel} [${currentLangIdx}/${totalScopeLangs}]` : langLabel;
if (totalScopeFiles > 0) {
const pct =
SCOPE_PCT_START + Math.round((processedScopeFiles / totalScopeFiles) * SCOPE_PCT_RANGE);
ctx.onProgress({
phase: 'scopeResolution',
percent: pct,
message: 'Resolving types',
detail: `${langTag}, ${langFileCount.toLocaleString()} files`,
});
}
const stats = runScopeResolution(
{
graph: ctx.graph,
model,
files,
resolutionConfig,
prebuiltNodeLookup: sharedNodeLookup,
prebuiltFunctionNodeIndex: sharedFnNodeIndex,
preExtractedParsedFiles: preExtractedByPath,
scopeIndexStorePath: parsedFileStorePath,
// CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph.
pdg: ctx.options?.pdg === true,
pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: ctx.options?.pdgMaxReachingDefEdgesPerFunction,
pdgMaxCdgEdgesPerFunction: ctx.options?.pdgMaxCdgEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: ctx.options?.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: ctx.options?.pdgMaxTaintHops,
// Streaming PDG-emit sink (#2202) — undefined ⇒ emit to the in-memory graph.
pdgEmitSink,
// Cross-pass per-file dedup set (#2202) — undefined when not streaming.
pdgEmittedFiles,
recordResolutionOutcome: (outcome) => {
resolutionOutcomes.push(outcome);
},
onWarn: (msg) => {
if (isSemanticModelValidatorEnabled()) {
logger.warn(`[scope-resolution:${lang}] ${msg}`);
}
},
onProgress:
totalScopeFiles > 0
? (subPhase: ScopeResolutionSubPhase, current, total) => {
let langRatio: number;
switch (subPhase) {
case 'extracting':
langRatio = total > 0 ? (current / total) * 0.5 : 0;
break;
case 'analyzing types':
langRatio = 0.5;
break;
case 'resolving references':
langRatio = 0.7;
break;
case 'linking symbols':
langRatio = 0.85;
break;
default: {
const _exhaustive: never = subPhase;
langRatio = 0.85;
}
}
const overallRatio = Math.min(
1,
(processedScopeFiles + langRatio * langFileCount) / totalScopeFiles,
);
const pct = SCOPE_PCT_START + Math.round(overallRatio * SCOPE_PCT_RANGE);
ctx.onProgress({
phase: 'scopeResolution',
percent: pct,
message: 'Resolving types',
detail:
subPhase === 'extracting'
? `${langTag} — extracting ${current.toLocaleString()}/${total.toLocaleString()} files`
: `${langTag} — ${subPhase}`,
});
}
: undefined,
},
provider,
);
// Release file contents and pre-extracted entries after each language
// to reduce memory pressure. For large codebases (16K+ PHP files),
// holding all source code simultaneously with scope trees causes OOM.
// See: https://github.com/abhigyanpatwari/GitNexus/issues/1741
//
// Use `filePaths` (not `primaryFilePaths`) so that any context files
// added by `collectScopeContextPaths` (e.g. TS/JS files pulled in for
// Vue cross-file resolution) are also evicted and not held until GC.
files.length = 0;
contents.clear();
for (const fp of filePaths) {
preExtractedByPath.delete(fp);
}
// This language's ParsedFiles are now unreachable (runScopeResolution has
// returned and the Map entries are deleted). Force a GC HERE so a heavy
// language's ~17-20GB set (e.g. C/C++ on the Linux kernel) is reclaimed
// BEFORE the next language's store-load — instead of leaving V8 to collect
// it lazily under the next pass's allocation pressure (which, at a cap >=
// RAM, degrades into swap-thrash). Collects only dead objects: the live
// cross-file index of the next pass is untouched. The pre/post probe
// confirms whether old-space fragmentation defeats the reclaim.
logHeapProbe('lang-release-pre-gc', `lang=${lang}`);
forceGc();
logHeapProbe('lang-release-post-gc', `lang=${lang}`);
logHeapProbe('scope-lang-end', `lang=${lang} filesProcessed=${stats.filesProcessed}`);
processedScopeFiles += langFileCount;
anyRan = true;
functionSummaries.push(...stats.functionSummaries);
totalFiles += stats.filesProcessed;
totalImports += stats.importsEmitted;
totalRefs += stats.referenceEdgesEmitted;
perLanguage.set(lang, {
filesProcessed: stats.filesProcessed,
importsEmitted: stats.importsEmitted,
referenceEdgesEmitted: stats.referenceEdgesEmitted,
});
if (isDev) {
logger.info(
`[scope-resolution:${lang}] ${stats.filesProcessed} files → ${stats.importsEmitted} IMPORTS + ${stats.referenceEdgesEmitted} reference edges (${stats.resolve.unresolved} unresolved sites, ${stats.referenceSkipped} skipped)`,
);
}
}
// Finalize the streaming PDG sink (#2202) once after the last language:
// flush + close its CSV writers and capture the COPY manifest. forceGc at
// the boundary reclaims transient write buffers (mirrors the per-language
// release below).
pdgEmitManifest = pdgEmitSink?.finalize();
pdgSinkSettled = true;
if (pdgEmitSink !== undefined) forceGc();
} finally {
// Release fds if a throw skipped finalize (idempotent with finalize()).
if (pdgEmitSink !== undefined && !pdgSinkSettled) pdgEmitSink.close();
}
if (totalScopeFiles > 0 && anyRan) {
ctx.onProgress({
@ -494,7 +559,10 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
}
}
if (!anyRan) return NOOP_OUTPUT;
// Even when no language ran, surface a finalized manifest (its CSVs are on
// disk) so loadGraphToLbug COPYs them rather than orphaning them — empty in
// the no-files case, harmless.
if (!anyRan) return pdgEmitManifest ? { ...NOOP_OUTPUT, pdgEmitManifest } : NOOP_OUTPUT;
return {
ran: true,
@ -504,6 +572,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
resolutionOutcomes,
perLanguage,
functionSummaries,
pdgEmitManifest,
};
},
};

View file

@ -302,6 +302,30 @@ interface RunScopeResolutionInput {
* `reason`; consumed by the U4 taint emit step). `undefined` ⇒
* `DEFAULT_PDG_MAX_TAINT_HOPS` (32); `0` ⇒ no cap. */
readonly pdgMaxTaintHops?: number;
/**
* Streaming PDG-emit sink (#2202). When present (streaming on, full rebuild),
* the `--pdg` emit routes BasicBlock nodes + intra-file PDG edges to THIS
* graph-shaped target instead of the in-memory `graph`, so the bulky PDG
* layer never accumulates in memory (peak RSS O(chunk)). Typed as a plain
* `KnowledgeGraph` so this module stays decoupled from the persistence layer;
* the caller (the scope-resolution phase) owns its lifecycle and finalizes it
* after the last language. Absent ⇒ the emit writes to `graph` as before
* (byte-identical default).
*/
readonly pdgEmitSink?: KnowledgeGraph;
/**
* Cross-pass per-file dedup set for streaming PDG emit (#2202). Shared across
* every language pass (owned by the scope-resolution phase). A file imported
* by more than one language (e.g. a `.ts` module pulled into the Vue context
* pass) is PDG-emitted in each pass over the same `cfgSideChannel`, producing
* identical ids; the in-memory graph dedups that by id, but the streaming sink
* is dedup-free (to stay O(write buffer), not O(total ids)). So when present
* (streaming on), the emit loop skips a file whose PDG already streamed and
* records the rest — keeping the streamed set byte-identical to the
* Map-deduped whole-graph emit, for any language-pass order. Absent ⇒ no skip
* (the graph Map dedups), so the default path is unchanged.
*/
readonly pdgEmittedFiles?: Set<string>;
/**
* Optional graph-node lookup built ONCE by the caller and shared across
* every language pass. `buildGraphNodeLookup` scans the whole graph and is
@ -717,6 +741,7 @@ export function runScopeResolution(
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
resolveAdlCandidates: provider.resolveAdlCandidates,
conversionRankFn: provider.conversionRankFn,
conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes,
constraintCompatibility: provider.constraintCompatibility,
recordResolutionOutcome,
},
@ -769,6 +794,11 @@ export function runScopeResolution(
// can bracket it. Printed as the PROF `taint=` segment.
let taintMs = 0;
if (input.pdg === true) {
// Streaming target (#2202): when a sink is provided, BasicBlock nodes +
// intra-file PDG edges are routed to CSV-on-disk through it instead of
// accumulating in `graph`. The function-node index below is still built
// from the real `graph` (Function/Method nodes live there, never the sink).
const pdgTarget: KnowledgeGraph = input.pdgEmitSink ?? graph;
let cfgBlocks = 0;
let cfgEdges = 0;
let cfgDroppedEdges = 0;
@ -778,6 +808,7 @@ export function runScopeResolution(
let rdTruncated = 0;
let cdgEdges = 0;
let cdgDropped = 0;
let cdgSkippedUnsound = 0;
// ── M3 taint setup (#2083 U4) ────────────────────────────────────────
// Explicit model-registration seam (idempotent, cheap) — the registry
// stays empty on non-pdg runs, preserving default-run parity. The
@ -834,6 +865,15 @@ export function runScopeResolution(
// shard that slipped the version gate) must skip emission, not throw a
// TypeError mid-graph-build and abort scope-resolution for the language.
if (!Array.isArray(cfgs) || cfgs.length === 0) continue;
// Cross-pass per-file dedup (#2202): when streaming, a file whose PDG
// already streamed in a prior language pass (e.g. a `.ts` module pulled
// into the Vue context pass) would re-emit identical ids from the same
// cfgSideChannel — the dedup-free streaming sink would double the rows.
// Skip it here; the in-memory-graph path needs no skip (its Map dedups).
if (input.pdgEmittedFiles !== undefined) {
if (input.pdgEmittedFiles.has(pf.filePath)) continue;
input.pdgEmittedFiles.add(pf.filePath);
}
try {
// Per-element emit-safety filter (mirrors the parsedfile-store
// reviver's POLICY: valid elements in a mixed array still emit; junk
@ -853,7 +893,7 @@ export function runScopeResolution(
}
if (wellFormed.length === 0) continue;
const emitted = emitFileCfgs(
graph,
pdgTarget,
wellFormed,
input.pdgMaxEdgesPerFunction ?? DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
// Log cap-overflow drops UNCONDITIONALLY (not via input.onWarn, which is
@ -872,7 +912,7 @@ export function runScopeResolution(
// PROF-gated like every other checkpoint here (zero cost when off).
const t0 = PROF ? performance.now() : 0;
const rd = emitFileReachingDefs(
graph,
pdgTarget,
wellFormed,
input.pdgMaxReachingDefEdgesPerFunction ??
DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
@ -891,7 +931,7 @@ export function runScopeResolution(
// persisted and its time folds into the `pdg=` PROF segment next to RD.
const tCdg = PROF ? performance.now() : 0;
const cdg = emitFileCdg(
graph,
pdgTarget,
wellFormed,
input.pdgMaxCdgEdgesPerFunction ?? DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION,
(message) => logger.warn(message), // unconditional — R6, no silent truncation
@ -899,6 +939,7 @@ export function runScopeResolution(
if (PROF) pdgMs += performance.now() - tCdg;
cdgEdges += cdg.edges;
cdgDropped += cdg.droppedEdges;
cdgSkippedUnsound += cdg.skippedUnsoundFunctions;
// M3 (#2083 U4): taint over the SAME validated CFGs, inside the SAME
// per-file try (a taint throw costs this file's taint layer only —
@ -907,7 +948,7 @@ export function runScopeResolution(
if (taintSpec !== undefined) {
const t1 = PROF ? performance.now() : 0;
const taint = emitFileTaint(
graph,
pdgTarget,
wellFormed,
pf.parsedImports,
taintSpec,
@ -975,6 +1016,9 @@ export function runScopeResolution(
(rdTruncated > 0 ? `, ${rdTruncated} function(s) hit the fact limit` : '') +
`; ${cdgEdges} CDG edges` +
(cdgDropped > 0 ? `, ${cdgDropped} CDG edges dropped (per-function cap)` : '') +
(cdgSkippedUnsound > 0
? `, ${cdgSkippedUnsound} function(s) CDG-skipped (EXIT not reachable from all blocks)`
: '') +
// M3 volume telemetry — only for languages with a registered model.
(taintSpec !== undefined
? `; taint: ${taintTotals.findings} TAINTED, ${taintTotals.kills} SANITIZES ` +
@ -987,6 +1031,20 @@ export function runScopeResolution(
: ''),
);
}
// R8 (#2195): CDG soundness skips surface UNCONDITIONALLY (parity with the
// taint/RD gap warns) — not buried in the logger.debug stats line above. A
// function whose EXIT is not reverse-reachable from every block gets NO
// control dependence (an unmodeled non-terminating / multi-terminal CFG
// shape the synthetic-escape pass could not bridge). Withholding CDG
// silently would let a language's control dependence erode unnoticed; CFG
// and REACHING_DEF do not depend on post-dominance and are unaffected.
if (cdgSkippedUnsound > 0) {
logger.warn(
`[cfg] lang=${provider.language}: ${cdgSkippedUnsound} function(s) had control ` +
`dependence skipped (EXIT not reverse-reachable from all blocks); ` +
`CFG and REACHING_DEF are unaffected`,
);
}
// R4: taint coverage gaps and cap drops surface UNCONDITIONALLY (never
// logger.debug, never input.onWarn) at the per-language aggregate, with
// counts and up to 5 example functions. Per-function warns above cover

View file

@ -19,6 +19,7 @@ import {
methodToTypeArgPosition,
type TypeArgPosition,
} from './shared.js';
import { CPP_BRACED_INIT_TYPE_PREFIX } from '../languages/cpp/conversion-rank.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set(['declaration']);
@ -479,6 +480,8 @@ const extractForLoopBinding: ForLoopExtractor = (
/** Infer the type of a literal AST node for C++ overload disambiguation. */
const inferLiteralType: LiteralTypeInferrer = (node) => {
switch (node.type) {
case 'initializer_list':
return inferBracedInitLiteralType(node);
case 'number_literal': {
const t = node.text;
// Float suffixes
@ -505,6 +508,23 @@ const inferLiteralType: LiteralTypeInferrer = (node) => {
}
};
function inferBracedInitLiteralType(node: SyntaxNode): string | undefined {
const elementTypes: string[] = [];
for (const child of node.children) {
if (child.type === ',' || child.type === '{' || child.type === '}') continue;
const elementType = inferLiteralType(child);
if (elementType === undefined || elementType.startsWith(CPP_BRACED_INIT_TYPE_PREFIX)) {
return `${CPP_BRACED_INIT_TYPE_PREFIX}unknown:${elementTypes.length + 1}`;
}
elementTypes.push(elementType);
}
if (elementTypes.length === 0) return `${CPP_BRACED_INIT_TYPE_PREFIX}unknown:0`;
const first = elementTypes[0];
return elementTypes.every((type) => type === first)
? `${CPP_BRACED_INIT_TYPE_PREFIX}${first}:${elementTypes.length}`
: `${CPP_BRACED_INIT_TYPE_PREFIX}unknown:${elementTypes.length}`;
}
/** C++: detect constructor type from smart pointer factory calls (make_shared<Dog>()).
* Extracts the template type argument as the constructor type for virtual dispatch. */
const detectCppConstructorType: ConstructorTypeDetector = (node, classNames) => {

View file

@ -28,6 +28,18 @@ export const parseTruthyEnv = (raw: string | undefined): boolean => {
return value === '1' || value === 'true' || value === 'yes';
};
/**
* Parse a positive-integer env-var value. Returns the integer when `raw` is a
* finite integer `> 0`; otherwise (`undefined`, empty, non-numeric, `0`, or
* negative) returns `undefined` so the caller falls back to its default. Used
* for numeric tuning knobs like `GITNEXUS_PDG_EMIT_CHUNK_SIZE` (#2202).
*/
export const parsePositiveIntEnv = (raw: string | undefined): number | undefined => {
if (raw === undefined) return undefined;
const n = Number(raw.trim());
return Number.isInteger(n) && n > 0 ? n : undefined;
};
/**
* Whether scope-resolution dev validators (e.g. `validateBindingsImmutability`)
* should run AND emit warnings. Off by default in CLI runs to avoid silent

Some files were not shown because too many files have changed in this diff Show more